Skip to main content

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