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
10// CommonMark separates ATX markers from the heading text with spaces and tabs only;
11// any other Unicode space (U+00A0, U+2003, ...) is part of the text.
12const CLOSED_ATX_MULTIPLE_SPACE_PATTERN_STR: &str = r"^([ \t]*)(#+)([ \t]+)(.*?)([ \t]+)(#+)[ \t]*$";
13static CLOSED_ATX_MULTIPLE_SPACE_PATTERN: LazyLock<Regex> =
14    LazyLock::new(|| Regex::new(CLOSED_ATX_MULTIPLE_SPACE_PATTERN_STR).unwrap());
15
16#[derive(Clone)]
17pub struct MD021NoMultipleSpaceClosedAtx;
18
19impl Default for MD021NoMultipleSpaceClosedAtx {
20    fn default() -> Self {
21        Self::new()
22    }
23}
24
25impl MD021NoMultipleSpaceClosedAtx {
26    pub fn new() -> Self {
27        Self
28    }
29
30    fn is_closed_atx_heading_with_multiple_spaces(&self, line: &str) -> bool {
31        if let Some(captures) = CLOSED_ATX_MULTIPLE_SPACE_PATTERN.captures(line) {
32            let start_spaces = captures.get(3).unwrap().as_str().len();
33            let end_spaces = captures.get(5).unwrap().as_str().len();
34            start_spaces > 1 || end_spaces > 1
35        } else {
36            false
37        }
38    }
39
40    fn fix_closed_atx_heading(&self, line: &str) -> String {
41        if let Some(captures) = CLOSED_ATX_MULTIPLE_SPACE_PATTERN.captures(line) {
42            let indentation = &captures[1];
43            let opening_hashes = &captures[2];
44            let content = &captures[4];
45            let closing_hashes = &captures[6];
46            format!(
47                "{}{} {} {}",
48                indentation,
49                opening_hashes,
50                content.trim_matches([' ', '\t']),
51                closing_hashes
52            )
53        } else {
54            line.to_string()
55        }
56    }
57
58    fn count_spaces(&self, line: &str) -> (usize, usize) {
59        if let Some(captures) = CLOSED_ATX_MULTIPLE_SPACE_PATTERN.captures(line) {
60            let start_spaces = captures.get(3).unwrap().as_str().len();
61            let end_spaces = captures.get(5).unwrap().as_str().len();
62            (start_spaces, end_spaces)
63        } else {
64            (0, 0)
65        }
66    }
67}
68
69impl Rule for MD021NoMultipleSpaceClosedAtx {
70    fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
71        Some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(21))
72    }
73
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        let edges = crate::utils::blockquote::container_edges(ctx);
86
87        // Check all closed ATX headings from cached info, quoted ones included
88        for parsed in ctx.headings() {
89            let line_num = parsed.line_num - 1;
90            let line_info = parsed.line_info;
91            let heading = parsed.heading;
92            // A quoted heading's `#`s follow the quote prefix, not the line's indent
93            let marker_byte = line_info.byte_offset
94                + if parsed.is_blockquote() {
95                    heading.marker_column
96                } else {
97                    line_info.indent
98                };
99            if ctx.overlaps_template_code(marker_byte, marker_byte + 1) {
100                continue;
101            }
102            // Skip headings indented 4+ spaces (they're code blocks); a quoted
103            // heading was recognized by the parser and may sit in a wide list item
104            if !parsed.is_blockquote()
105                && crate::utils::blockquote::is_indented_past_edge(&edges, line_num, line_info.content(ctx.content))
106            {
107                continue;
108            }
109
110            // Only check closed ATX headings
111            if matches!(heading.style, crate::lint_context::HeadingStyle::ATX) && heading.has_closing_sequence {
112                let full_line = line_info.content(ctx.content);
113                // The heading starts after any indentation and, when it
114                // opens a list item, after the list marker, which is kept
115                let (kept_prefix, line) = full_line.split_at(heading.marker_column);
116
117                // Check if line matches closed ATX pattern with multiple spaces
118                if self.is_closed_atx_heading_with_multiple_spaces(line) {
119                    let captures = CLOSED_ATX_MULTIPLE_SPACE_PATTERN.captures(line).unwrap();
120                    let _indentation = captures.get(1).unwrap();
121                    let opening_hashes = captures.get(2).unwrap();
122                    let (start_spaces, end_spaces) = self.count_spaces(line);
123
124                    let message = if start_spaces > 1 && end_spaces > 1 {
125                        format!(
126                            "Multiple spaces ({} at start, {} at end) inside hashes on closed heading (with {} at start and end)",
127                            start_spaces,
128                            end_spaces,
129                            "#".repeat(opening_hashes.as_str().len())
130                        )
131                    } else if start_spaces > 1 {
132                        format!(
133                            "Multiple spaces ({}) after {} at start of closed heading",
134                            start_spaces,
135                            "#".repeat(opening_hashes.as_str().len())
136                        )
137                    } else {
138                        format!(
139                            "Multiple spaces ({}) before {} at end of closed heading",
140                            end_spaces,
141                            "#".repeat(opening_hashes.as_str().len())
142                        )
143                    };
144
145                    // Replace the entire line with the fixed version
146                    let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num + 1, full_line);
147                    let replacement = format!("{kept_prefix}{}", self.fix_closed_atx_heading(line));
148
149                    warnings.push(LintWarning {
150                        rule_name: Some(self.name().to_string()),
151                        message,
152                        line: start_line,
153                        column: start_col,
154                        end_line,
155                        end_column: end_col,
156                        severity: Severity::Warning,
157                        fix: Some(Fix::new(
158                            ctx.line_column_byte_range_with_length(start_line, 1, full_line.len()),
159                            replacement,
160                        )),
161                    });
162                }
163            }
164        }
165
166        Ok(warnings)
167    }
168
169    fn fixes_from_diagnostics(&self) -> bool {
170        true
171    }
172
173    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
174        if self.should_skip(ctx) {
175            return Ok(ctx.content.to_string());
176        }
177        let warnings = self.check(ctx)?;
178        if warnings.is_empty() {
179            return Ok(ctx.content.to_string());
180        }
181        let warnings =
182            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
183        crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings).map_err(LintError::InvalidInput)
184    }
185
186    /// Get the category of this rule for selective processing
187    fn category(&self) -> RuleCategory {
188        RuleCategory::Heading
189    }
190
191    /// Check if this rule should be skipped
192    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
193        ctx.content.is_empty() || !ctx.likely_has_headings()
194    }
195
196    fn as_any(&self) -> &dyn std::any::Any {
197        self
198    }
199
200    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
201    where
202        Self: Sized,
203    {
204        Box::new(MD021NoMultipleSpaceClosedAtx::new())
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use crate::lint_context::LintContext;
212
213    #[test]
214    fn test_basic_functionality() {
215        let rule = MD021NoMultipleSpaceClosedAtx;
216
217        // Test with correct spacing
218        let content = "# Heading 1 #\n## Heading 2 ##\n### Heading 3 ###";
219        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
220        let result = rule.check(&ctx).unwrap();
221        assert!(result.is_empty());
222
223        // Test with multiple spaces
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_eq!(result.len(), 2); // Should flag the two headings with multiple spaces
228        assert_eq!(result[0].line, 1);
229        assert_eq!(result[1].line, 3);
230    }
231
232    #[test]
233    fn test_unicode_space_in_closed_atx_is_content() {
234        let rule = MD021NoMultipleSpaceClosedAtx;
235        let src = "#\u{a0}\u{a0}Title\u{a0}\u{a0}#\n";
236        let ctx = LintContext::new(src, crate::config::MarkdownFlavor::Standard, None);
237        assert_eq!(rule.fix(&ctx).unwrap(), src);
238    }
239
240    #[test]
241    fn test_ascii_spaces_around_unicode_space_are_still_collapsed() {
242        let rule = MD021NoMultipleSpaceClosedAtx;
243        let ctx = LintContext::new("#  \u{a0}Title  #\n", crate::config::MarkdownFlavor::Standard, None);
244        assert_eq!(rule.check(&ctx).unwrap().len(), 1);
245        assert_eq!(rule.fix(&ctx).unwrap(), "# \u{a0}Title #\n");
246    }
247}