rumdl_lib/rules/
md021_no_multiple_space_closed_atx.rs

1/// Rule MD021: No multiple spaces inside closed ATX heading
2///
3/// See [docs/md021.md](../../docs/md021.md) for full documentation, configuration, and examples.
4use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
5use crate::utils::range_utils::calculate_line_range;
6use crate::utils::regex_cache::get_cached_regex;
7
8// Regex patterns
9const CLOSED_ATX_MULTIPLE_SPACE_PATTERN_STR: &str = r"^(\s*)(#+)(\s+)(.*?)(\s+)(#+)\s*$";
10
11#[derive(Clone)]
12pub struct MD021NoMultipleSpaceClosedAtx;
13
14impl Default for MD021NoMultipleSpaceClosedAtx {
15    fn default() -> Self {
16        Self::new()
17    }
18}
19
20impl MD021NoMultipleSpaceClosedAtx {
21    pub fn new() -> Self {
22        Self
23    }
24
25    fn is_closed_atx_heading_with_multiple_spaces(&self, line: &str) -> bool {
26        if let Some(captures) = get_cached_regex(CLOSED_ATX_MULTIPLE_SPACE_PATTERN_STR)
27            .ok()
28            .and_then(|re| re.captures(line))
29        {
30            let start_spaces = captures.get(3).unwrap().as_str().len();
31            let end_spaces = captures.get(5).unwrap().as_str().len();
32            start_spaces > 1 || end_spaces > 1
33        } else {
34            false
35        }
36    }
37
38    fn fix_closed_atx_heading(&self, line: &str) -> String {
39        if let Some(captures) = get_cached_regex(CLOSED_ATX_MULTIPLE_SPACE_PATTERN_STR)
40            .ok()
41            .and_then(|re| re.captures(line))
42        {
43            let indentation = &captures[1];
44            let opening_hashes = &captures[2];
45            let content = &captures[4];
46            let closing_hashes = &captures[6];
47            format!(
48                "{}{} {} {}",
49                indentation,
50                opening_hashes,
51                content.trim(),
52                closing_hashes
53            )
54        } else {
55            line.to_string()
56        }
57    }
58
59    fn count_spaces(&self, line: &str) -> (usize, usize) {
60        if let Some(captures) = get_cached_regex(CLOSED_ATX_MULTIPLE_SPACE_PATTERN_STR)
61            .ok()
62            .and_then(|re| re.captures(line))
63        {
64            let start_spaces = captures.get(3).unwrap().as_str().len();
65            let end_spaces = captures.get(5).unwrap().as_str().len();
66            (start_spaces, end_spaces)
67        } else {
68            (0, 0)
69        }
70    }
71}
72
73impl Rule for MD021NoMultipleSpaceClosedAtx {
74    fn name(&self) -> &'static str {
75        "MD021"
76    }
77
78    fn description(&self) -> &'static str {
79        "Multiple spaces inside hashes on closed heading"
80    }
81
82    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
83        let mut warnings = Vec::new();
84
85        // Check all closed ATX headings from cached info
86        for (line_num, line_info) in ctx.lines.iter().enumerate() {
87            if let Some(heading) = &line_info.heading {
88                // Skip headings indented 4+ spaces (they're code blocks)
89                if line_info.visual_indent >= 4 {
90                    continue;
91                }
92
93                // Only check closed ATX headings
94                if matches!(heading.style, crate::lint_context::HeadingStyle::ATX) && heading.has_closing_sequence {
95                    let line = line_info.content(ctx.content);
96
97                    // Check if line matches closed ATX pattern with multiple spaces
98                    if self.is_closed_atx_heading_with_multiple_spaces(line) {
99                        let captures = get_cached_regex(CLOSED_ATX_MULTIPLE_SPACE_PATTERN_STR)
100                            .ok()
101                            .and_then(|re| re.captures(line))
102                            .unwrap();
103                        let _indentation = captures.get(1).unwrap();
104                        let opening_hashes = captures.get(2).unwrap();
105                        let (start_spaces, end_spaces) = self.count_spaces(line);
106
107                        let message = if start_spaces > 1 && end_spaces > 1 {
108                            format!(
109                                "Multiple spaces ({} at start, {} at end) inside hashes on closed heading (with {} at start and end)",
110                                start_spaces,
111                                end_spaces,
112                                "#".repeat(opening_hashes.as_str().len())
113                            )
114                        } else if start_spaces > 1 {
115                            format!(
116                                "Multiple spaces ({}) after {} at start of closed heading",
117                                start_spaces,
118                                "#".repeat(opening_hashes.as_str().len())
119                            )
120                        } else {
121                            format!(
122                                "Multiple spaces ({}) before {} at end of closed heading",
123                                end_spaces,
124                                "#".repeat(opening_hashes.as_str().len())
125                            )
126                        };
127
128                        // Replace the entire line with the fixed version
129                        let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num + 1, line);
130                        let replacement = self.fix_closed_atx_heading(line);
131
132                        warnings.push(LintWarning {
133                            rule_name: Some(self.name().to_string()),
134                            message,
135                            line: start_line,
136                            column: start_col,
137                            end_line,
138                            end_column: end_col,
139                            severity: Severity::Warning,
140                            fix: Some(Fix {
141                                range: ctx
142                                    .line_index
143                                    .line_col_to_byte_range_with_length(start_line, 1, line.len()),
144                                replacement,
145                            }),
146                        });
147                    }
148                }
149            }
150        }
151
152        Ok(warnings)
153    }
154
155    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
156        let mut lines = Vec::new();
157
158        for line_info in ctx.lines.iter() {
159            let mut fixed = false;
160
161            if let Some(heading) = &line_info.heading {
162                // Skip headings indented 4+ spaces (they're code blocks)
163                if line_info.visual_indent >= 4 {
164                    lines.push(line_info.content(ctx.content).to_string());
165                    continue;
166                }
167
168                // Fix closed ATX headings with multiple spaces
169                if matches!(heading.style, crate::lint_context::HeadingStyle::ATX)
170                    && heading.has_closing_sequence
171                    && self.is_closed_atx_heading_with_multiple_spaces(line_info.content(ctx.content))
172                {
173                    lines.push(self.fix_closed_atx_heading(line_info.content(ctx.content)));
174                    fixed = true;
175                }
176            }
177
178            if !fixed {
179                lines.push(line_info.content(ctx.content).to_string());
180            }
181        }
182
183        // Reconstruct content preserving line endings
184        let mut result = lines.join("\n");
185        if ctx.content.ends_with('\n') && !result.ends_with('\n') {
186            result.push('\n');
187        }
188
189        Ok(result)
190    }
191
192    /// Get the category of this rule for selective processing
193    fn category(&self) -> RuleCategory {
194        RuleCategory::Heading
195    }
196
197    /// Check if this rule should be skipped
198    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
199        ctx.content.is_empty() || !ctx.likely_has_headings()
200    }
201
202    fn as_any(&self) -> &dyn std::any::Any {
203        self
204    }
205
206    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
207    where
208        Self: Sized,
209    {
210        Box::new(MD021NoMultipleSpaceClosedAtx::new())
211    }
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217    use crate::lint_context::LintContext;
218
219    #[test]
220    fn test_basic_functionality() {
221        let rule = MD021NoMultipleSpaceClosedAtx;
222
223        // Test with correct spacing
224        let content = "# Heading 1 #\n## Heading 2 ##\n### Heading 3 ###";
225        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
226        let result = rule.check(&ctx).unwrap();
227        assert!(result.is_empty());
228
229        // Test with multiple spaces
230        let content = "#  Heading 1 #\n## Heading 2 ##\n### Heading 3  ###";
231        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
232        let result = rule.check(&ctx).unwrap();
233        assert_eq!(result.len(), 2); // Should flag the two headings with multiple spaces
234        assert_eq!(result[0].line, 1);
235        assert_eq!(result[1].line, 3);
236    }
237}