rumdl_lib/rules/
md020_no_missing_space_closed_atx.rs1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
5use crate::utils::range_utils::calculate_single_line_range;
6use crate::utils::regex_cache::get_cached_fancy_regex;
7
8const CLOSED_ATX_NO_SPACE_PATTERN_STR: &str = r"^(\s*)(#+)([^#\s].*?)([^#\s\\])(?<!\\)(#+)(\s*(?:\{#[^}]+\})?\s*)$";
11const CLOSED_ATX_NO_SPACE_START_PATTERN_STR: &str = r"^(\s*)(#+)([^#\s].*?)\s(?<!\\)(#+)(\s*(?:\{#[^}]+\})?\s*)$";
12const CLOSED_ATX_NO_SPACE_END_PATTERN_STR: &str = r"^(\s*)(#+)\s(.*?)([^#\s\\])(?<!\\)(#+)(\s*(?:\{#[^}]+\})?\s*)$";
13
14#[derive(Clone)]
15pub struct MD020NoMissingSpaceClosedAtx;
16
17impl Default for MD020NoMissingSpaceClosedAtx {
18 fn default() -> Self {
19 Self::new()
20 }
21}
22
23impl MD020NoMissingSpaceClosedAtx {
24 pub fn new() -> Self {
25 Self
26 }
27
28 fn is_closed_atx_heading_without_space(&self, line: &str) -> bool {
29 get_cached_fancy_regex(CLOSED_ATX_NO_SPACE_PATTERN_STR)
30 .map(|re| re.is_match(line).unwrap_or(false))
31 .unwrap_or(false)
32 || get_cached_fancy_regex(CLOSED_ATX_NO_SPACE_START_PATTERN_STR)
33 .map(|re| re.is_match(line).unwrap_or(false))
34 .unwrap_or(false)
35 || get_cached_fancy_regex(CLOSED_ATX_NO_SPACE_END_PATTERN_STR)
36 .map(|re| re.is_match(line).unwrap_or(false))
37 .unwrap_or(false)
38 }
39
40 fn fix_closed_atx_heading(&self, line: &str) -> String {
41 if let Some(captures) = get_cached_fancy_regex(CLOSED_ATX_NO_SPACE_PATTERN_STR)
42 .ok()
43 .and_then(|re| re.captures(line).ok().flatten())
44 {
45 let indentation = &captures[1];
46 let opening_hashes = &captures[2];
47 let content = &captures[3];
48 let last_char = &captures[4];
49 let closing_hashes = &captures[5];
50 let custom_id = &captures[6];
51 format!("{indentation}{opening_hashes} {content}{last_char} {closing_hashes}{custom_id}")
52 } else if let Some(captures) = get_cached_fancy_regex(CLOSED_ATX_NO_SPACE_START_PATTERN_STR)
53 .ok()
54 .and_then(|re| re.captures(line).ok().flatten())
55 {
56 let indentation = &captures[1];
57 let opening_hashes = &captures[2];
58 let content = &captures[3];
59 let closing_hashes = &captures[4];
60 let custom_id = &captures[5];
61 format!("{indentation}{opening_hashes} {content} {closing_hashes}{custom_id}")
62 } else if let Some(captures) = get_cached_fancy_regex(CLOSED_ATX_NO_SPACE_END_PATTERN_STR)
63 .ok()
64 .and_then(|re| re.captures(line).ok().flatten())
65 {
66 let indentation = &captures[1];
67 let opening_hashes = &captures[2];
68 let content = &captures[3];
69 let last_char = &captures[4];
70 let closing_hashes = &captures[5];
71 let custom_id = &captures[6];
72 format!("{indentation}{opening_hashes} {content}{last_char} {closing_hashes}{custom_id}")
73 } else {
74 line.to_string()
75 }
76 }
77}
78
79impl Rule for MD020NoMissingSpaceClosedAtx {
80 fn name(&self) -> &'static str {
81 "MD020"
82 }
83
84 fn description(&self) -> &'static str {
85 "No space inside hashes on closed heading"
86 }
87
88 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
89 let mut warnings = Vec::new();
90
91 for (line_num, line_info) in ctx.lines.iter().enumerate() {
93 if let Some(heading) = &line_info.heading {
94 if line_info.visual_indent >= 4 {
96 continue;
97 }
98
99 if matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
101 let line = line_info.content(ctx.content);
102
103 if self.is_closed_atx_heading_without_space(line) {
107 let line_range = ctx.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_fancy_regex(CLOSED_ATX_NO_SPACE_PATTERN_STR)
114 .ok()
115 .and_then(|re| re.captures(line).ok().flatten())
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_fancy_regex(CLOSED_ATX_NO_SPACE_START_PATTERN_STR)
127 .ok()
128 .and_then(|re| re.captures(line).ok().flatten())
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_fancy_regex(CLOSED_ATX_NO_SPACE_END_PATTERN_STR)
140 .ok()
141 .and_then(|re| re.captures(line).ok().flatten())
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().to_string()),
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.visual_indent >= 4 {
188 lines.push(line_info.content(ctx.content).to_string());
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(ctx.content))
195 {
196 lines.push(self.fix_closed_atx_heading(line_info.content(ctx.content)));
197 fixed = true;
198 }
199 }
200
201 if !fixed {
202 lines.push(line_info.content(ctx.content).to_string());
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, None);
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, None);
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}