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