rumdl_lib/rules/
md020_no_missing_space_closed_atx.rs

1/// Rule MD020: No missing space inside closed ATX heading
2///
3/// See [docs/md020.md](../../docs/md020.md) for full documentation, configuration, and examples.
4use 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
8// Closed ATX heading patterns
9// Use negative lookbehind (?<!\\) to avoid matching escaped hashes like C\# (C-sharp)
10const 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        // Check all closed ATX headings from cached info
92        for (line_num, line_info) in ctx.lines.iter().enumerate() {
93            if let Some(heading) = &line_info.heading {
94                // Skip headings indented 4+ spaces (they're code blocks)
95                if line_info.visual_indent >= 4 {
96                    continue;
97                }
98
99                // Check all ATX headings (both properly closed and malformed)
100                if matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
101                    let line = line_info.content(ctx.content);
102
103                    // Check if line matches closed ATX pattern without space
104                    // This will detect both properly closed headings with missing space
105                    // and malformed attempts at closed headings like "# Heading#"
106                    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                            // Missing space at both start and end: #Heading#
118                            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                            // Highlight the position right after the opening hashes
124                            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                            // Missing space at start: #Heading #
131                            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                            // Highlight the position right after the opening hashes
137                            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                            // Missing space at end: # Heading#
144                            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                            // Highlight the position right before the closing hashes
151                            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                // Skip headings indented 4+ spaces (they're code blocks)
187                if line_info.visual_indent >= 4 {
188                    lines.push(line_info.content(ctx.content).to_string());
189                    continue;
190                }
191
192                // Fix ATX headings without space (both properly closed and malformed)
193                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        // Reconstruct content preserving line endings
207        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    /// Get the category of this rule for selective processing
216    fn category(&self) -> RuleCategory {
217        RuleCategory::Heading
218    }
219
220    /// Check if this rule should be skipped
221    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        // Test with correct spacing
247        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        // Test with missing spaces
253        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); // Should flag the two headings with missing spaces
257        assert_eq!(result[0].line, 1);
258        assert_eq!(result[1].line, 3);
259    }
260}