rumdl_lib/rules/
md020_no_missing_space_closed_atx.rs1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
5use crate::utils::range_utils::{LineIndex, calculate_single_line_range};
6use crate::utils::regex_cache::get_cached_regex;
7
8const CLOSED_ATX_NO_SPACE_PATTERN_STR: &str = r"^(\s*)(#+)([^#\s].*?)([^#\s])(#+)(\s*(?:\{#[^}]+\})?\s*)$";
10const CLOSED_ATX_NO_SPACE_START_PATTERN_STR: &str = r"^(\s*)(#+)([^#\s].*?)\s(#+)(\s*(?:\{#[^}]+\})?\s*)$";
11const CLOSED_ATX_NO_SPACE_END_PATTERN_STR: &str = r"^(\s*)(#+)\s(.*?)([^#\s])(#+)(\s*(?:\{#[^}]+\})?\s*)$";
12
13#[derive(Clone)]
14pub struct MD020NoMissingSpaceClosedAtx;
15
16impl Default for MD020NoMissingSpaceClosedAtx {
17 fn default() -> Self {
18 Self::new()
19 }
20}
21
22impl MD020NoMissingSpaceClosedAtx {
23 pub fn new() -> Self {
24 Self
25 }
26
27 fn is_closed_atx_heading_without_space(&self, line: &str) -> bool {
28 get_cached_regex(CLOSED_ATX_NO_SPACE_PATTERN_STR)
29 .map(|re| re.is_match(line))
30 .unwrap_or(false)
31 || get_cached_regex(CLOSED_ATX_NO_SPACE_START_PATTERN_STR)
32 .map(|re| re.is_match(line))
33 .unwrap_or(false)
34 || get_cached_regex(CLOSED_ATX_NO_SPACE_END_PATTERN_STR)
35 .map(|re| re.is_match(line))
36 .unwrap_or(false)
37 }
38
39 fn fix_closed_atx_heading(&self, line: &str) -> String {
40 if let Some(captures) = get_cached_regex(CLOSED_ATX_NO_SPACE_PATTERN_STR)
41 .ok()
42 .and_then(|re| re.captures(line))
43 {
44 let indentation = &captures[1];
45 let opening_hashes = &captures[2];
46 let content = &captures[3];
47 let last_char = &captures[4];
48 let closing_hashes = &captures[5];
49 let custom_id = &captures[6];
50 format!("{indentation}{opening_hashes} {content}{last_char} {closing_hashes}{custom_id}")
51 } else if let Some(captures) = get_cached_regex(CLOSED_ATX_NO_SPACE_START_PATTERN_STR)
52 .ok()
53 .and_then(|re| re.captures(line))
54 {
55 let indentation = &captures[1];
56 let opening_hashes = &captures[2];
57 let content = &captures[3];
58 let closing_hashes = &captures[4];
59 let custom_id = &captures[5];
60 format!("{indentation}{opening_hashes} {content} {closing_hashes}{custom_id}")
61 } else if let Some(captures) = get_cached_regex(CLOSED_ATX_NO_SPACE_END_PATTERN_STR)
62 .ok()
63 .and_then(|re| re.captures(line))
64 {
65 let indentation = &captures[1];
66 let opening_hashes = &captures[2];
67 let content = &captures[3];
68 let last_char = &captures[4];
69 let closing_hashes = &captures[5];
70 let custom_id = &captures[6];
71 format!("{indentation}{opening_hashes} {content}{last_char} {closing_hashes}{custom_id}")
72 } else {
73 line.to_string()
74 }
75 }
76}
77
78impl Rule for MD020NoMissingSpaceClosedAtx {
79 fn name(&self) -> &'static str {
80 "MD020"
81 }
82
83 fn description(&self) -> &'static str {
84 "No space inside hashes on closed heading"
85 }
86
87 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
88 let mut warnings = Vec::new();
89
90 for (line_num, line_info) in ctx.lines.iter().enumerate() {
92 if let Some(heading) = &line_info.heading {
93 if line_info.indent >= 4 {
95 continue;
96 }
97
98 if matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
100 let line = &line_info.content;
101
102 if self.is_closed_atx_heading_without_space(line) {
106 let line_index = LineIndex::new(ctx.content.to_string());
107 let line_range = line_index.line_content_range(line_num + 1);
108
109 let mut start_col = 1;
110 let mut length = 1;
111 let mut message = String::new();
112
113 if let Some(captures) = get_cached_regex(CLOSED_ATX_NO_SPACE_PATTERN_STR)
114 .ok()
115 .and_then(|re| re.captures(line))
116 {
117 let opening_hashes = captures.get(2).unwrap();
119 message = format!(
120 "Missing space inside hashes on closed heading (with {} at start and end)",
121 "#".repeat(opening_hashes.as_str().len())
122 );
123 start_col = opening_hashes.end() + 1;
125 length = 1;
126 } else if let Some(captures) = get_cached_regex(CLOSED_ATX_NO_SPACE_START_PATTERN_STR)
127 .ok()
128 .and_then(|re| re.captures(line))
129 {
130 let opening_hashes = captures.get(2).unwrap();
132 message = format!(
133 "Missing space after {} at start of closed heading",
134 "#".repeat(opening_hashes.as_str().len())
135 );
136 start_col = opening_hashes.end() + 1;
138 length = 1;
139 } else if let Some(captures) = get_cached_regex(CLOSED_ATX_NO_SPACE_END_PATTERN_STR)
140 .ok()
141 .and_then(|re| re.captures(line))
142 {
143 let content = captures.get(3).unwrap();
145 let closing_hashes = captures.get(5).unwrap();
146 message = format!(
147 "Missing space before {} at end of closed heading",
148 "#".repeat(closing_hashes.as_str().len())
149 );
150 start_col = content.end() + 1;
152 length = 1;
153 }
154
155 let (start_line, start_col_calc, end_line, end_col) =
156 calculate_single_line_range(line_num + 1, start_col, length);
157
158 warnings.push(LintWarning {
159 rule_name: Some(self.name()),
160 message,
161 line: start_line,
162 column: start_col_calc,
163 end_line,
164 end_column: end_col,
165 severity: Severity::Warning,
166 fix: Some(Fix {
167 range: line_range,
168 replacement: self.fix_closed_atx_heading(line),
169 }),
170 });
171 }
172 }
173 }
174 }
175
176 Ok(warnings)
177 }
178
179 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
180 let mut lines = Vec::new();
181
182 for line_info in ctx.lines.iter() {
183 let mut fixed = false;
184
185 if let Some(heading) = &line_info.heading {
186 if line_info.indent >= 4 {
188 lines.push(line_info.content.clone());
189 continue;
190 }
191
192 if matches!(heading.style, crate::lint_context::HeadingStyle::ATX)
194 && self.is_closed_atx_heading_without_space(&line_info.content)
195 {
196 lines.push(self.fix_closed_atx_heading(&line_info.content));
197 fixed = true;
198 }
199 }
200
201 if !fixed {
202 lines.push(line_info.content.clone());
203 }
204 }
205
206 let mut result = lines.join("\n");
208 if ctx.content.ends_with('\n') && !result.ends_with('\n') {
209 result.push('\n');
210 }
211
212 Ok(result)
213 }
214
215 fn category(&self) -> RuleCategory {
217 RuleCategory::Heading
218 }
219
220 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
222 ctx.content.is_empty() || !ctx.likely_has_headings()
223 }
224
225 fn as_any(&self) -> &dyn std::any::Any {
226 self
227 }
228
229 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
230 where
231 Self: Sized,
232 {
233 Box::new(MD020NoMissingSpaceClosedAtx::new())
234 }
235}
236
237#[cfg(test)]
238mod tests {
239 use super::*;
240 use crate::lint_context::LintContext;
241
242 #[test]
243 fn test_basic_functionality() {
244 let rule = MD020NoMissingSpaceClosedAtx;
245
246 let content = "# Heading 1 #\n## Heading 2 ##\n### Heading 3 ###";
248 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
249 let result = rule.check(&ctx).unwrap();
250 assert!(result.is_empty());
251
252 let content = "# Heading 1#\n## Heading 2 ##\n### Heading 3###";
254 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
255 let result = rule.check(&ctx).unwrap();
256 assert_eq!(result.len(), 2); assert_eq!(result[0].line, 1);
258 assert_eq!(result[1].line, 3);
259 }
260}