Skip to main content

mdlint/lint/rules/
md021.rs

1use crate::lint::rule::Rule;
2use crate::markdown::MarkdownParser;
3use crate::types::{Fix, Violation};
4use serde_json::Value;
5
6pub struct MD021;
7
8impl Rule for MD021 {
9    fn name(&self) -> &str {
10        "MD021"
11    }
12
13    fn description(&self) -> &str {
14        "Multiple spaces inside hashes on closed atx style heading"
15    }
16
17    fn tags(&self) -> &[&str] {
18        &["headings", "atx_closed", "spaces"]
19    }
20
21    fn check(&self, parser: &MarkdownParser, _config: Option<&Value>) -> Vec<Violation> {
22        let mut violations = Vec::new();
23
24        for (line_num, line) in parser.lines().iter().enumerate() {
25            let line_number = line_num + 1;
26            let trimmed = line.trim();
27
28            // Check if this is a closed ATX heading
29            if trimmed.starts_with('#') && trimmed.ends_with('#') {
30                let parts: Vec<&str> = trimmed.split_whitespace().collect();
31                if parts.len() >= 2 && parts.last().unwrap().chars().all(|c| c == '#') {
32                    let closing_hashes = parts.last().unwrap();
33
34                    // Find position of closing hashes
35                    if let Some(pos) = trimmed.rfind(closing_hashes) {
36                        // Count spaces before closing hashes
37                        let mut space_count = 0;
38                        let mut check_pos = pos;
39                        while check_pos > 0 {
40                            check_pos -= 1;
41                            if trimmed.chars().nth(check_pos) == Some(' ') {
42                                space_count += 1;
43                            } else {
44                                break;
45                            }
46                        }
47
48                        if space_count > 1 {
49                            // Replace multiple spaces with single space
50                            let before_spaces = &trimmed[..check_pos + 1];
51                            let after_spaces = &trimmed[pos..];
52                            let replacement = format!("{} {}", before_spaces, after_spaces);
53
54                            violations.push(Violation {
55                                line: line_number,
56                                column: Some(1),
57                                rule: self.name().to_string(),
58                                message:
59                                    "Multiple spaces inside hashes on closed atx style heading"
60                                        .to_string(),
61                                fix: Some(Fix {
62                                    line_start: line_number,
63                                    line_end: line_number,
64                                    column_start: None,
65                                    column_end: None,
66                                    replacement,
67                                    description: "Replace multiple spaces with single space"
68                                        .to_string(),
69                                }),
70                            });
71                        }
72                    }
73                }
74            }
75        }
76
77        violations
78    }
79
80    fn fixable(&self) -> bool {
81        true
82    }
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88    use crate::fix::Fixer;
89
90    fn apply_fixes(content: &str, violations: &[Violation]) -> String {
91        let fixes: Vec<_> = violations.iter().filter_map(|v| v.fix.clone()).collect();
92        Fixer::new()
93            .apply_fixes_to_content(content, &fixes)
94            .unwrap()
95    }
96
97    #[test]
98    fn test_single_space() {
99        let content = "# Heading #";
100        let parser = MarkdownParser::new(content);
101        let rule = MD021;
102        let violations = rule.check(&parser, None);
103
104        assert_eq!(violations.len(), 0); // Single space is OK for MD021
105    }
106
107    #[test]
108    fn test_multiple_spaces() {
109        let content = "# Heading  ##";
110        let parser = MarkdownParser::new(content);
111        let rule = MD021;
112        let violations = rule.check(&parser, None);
113
114        assert_eq!(violations.len(), 1);
115    }
116
117    #[test]
118    fn test_no_space() {
119        let content = "# Heading#";
120        let parser = MarkdownParser::new(content);
121        let rule = MD021;
122        let violations = rule.check(&parser, None);
123
124        assert_eq!(violations.len(), 0);
125    }
126
127    #[test]
128    fn test_regular_heading() {
129        let content = "# Heading";
130        let parser = MarkdownParser::new(content);
131        let rule = MD021;
132        let violations = rule.check(&parser, None);
133
134        assert_eq!(violations.len(), 0); // Not closed
135    }
136
137    #[test]
138    fn test_fix_collapses_spaces_before_closing_hashes() {
139        let content = "# Heading  ##\n";
140        let parser = MarkdownParser::new(content);
141        let rule = MD021;
142        let violations = rule.check(&parser, None);
143        assert_eq!(violations.len(), 1);
144        let fixed = apply_fixes(content, &violations);
145        assert_eq!(fixed, "# Heading ##\n");
146    }
147}