Skip to main content

mdlint/lint/rules/
md019.rs

1use crate::lint::rule::Rule;
2use crate::markdown::MarkdownParser;
3use crate::types::{Fix, Violation};
4use serde_json::Value;
5
6pub struct MD019;
7
8impl Rule for MD019 {
9    fn name(&self) -> &str {
10        "MD019"
11    }
12
13    fn description(&self) -> &str {
14        "Multiple spaces after hash on atx style heading"
15    }
16
17    fn tags(&self) -> &[&str] {
18        &["headings", "headers", "atx", "spaces"]
19    }
20
21    fn check(&self, parser: &MarkdownParser, _config: Option<&Value>) -> Vec<Violation> {
22        let mut violations = Vec::new();
23        let code_block_lines = parser.get_code_block_line_numbers();
24
25        for (line_num, line) in parser.lines().iter().enumerate() {
26            let line_number = line_num + 1;
27            if code_block_lines.contains(&line_number) {
28                continue;
29            }
30            let trimmed = line.trim();
31
32            // Check for ATX heading with multiple spaces after hash
33            if trimmed.starts_with('#') {
34                // Count leading hashes
35                let hash_count = trimmed.chars().take_while(|&c| c == '#').count();
36
37                // Valid heading should have 1-6 hashes
38                if hash_count > 0 && hash_count <= 6 && trimmed.len() > hash_count {
39                    let after_hashes = &trimmed[hash_count..];
40
41                    // Count spaces after hashes
42                    let space_count = after_hashes.chars().take_while(|&c| c == ' ').count();
43
44                    if space_count > 1 {
45                        // Replace multiple spaces with single space
46                        let hashes = "#".repeat(hash_count);
47                        let rest = after_hashes[space_count..].trim_start();
48                        let replacement = format!("{} {}", hashes, rest);
49
50                        violations.push(Violation {
51                            line: line_number,
52                            column: Some(hash_count + 2),
53                            rule: self.name().to_string(),
54                            message: format!(
55                                "Multiple spaces after hash on atx style heading ({} spaces)",
56                                space_count
57                            ),
58                            fix: Some(Fix {
59                                line_start: line_number,
60                                line_end: line_number,
61                                column_start: None,
62                                column_end: None,
63                                replacement,
64                                description: "Replace multiple spaces with single space"
65                                    .to_string(),
66                            }),
67                        });
68                    }
69                }
70            }
71        }
72
73        violations
74    }
75
76    fn fixable(&self) -> bool {
77        true
78    }
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84    use crate::fix::Fixer;
85
86    fn apply_fixes(content: &str, violations: &[Violation]) -> String {
87        let fixes: Vec<_> = violations.iter().filter_map(|v| v.fix.clone()).collect();
88        Fixer::new()
89            .apply_fixes_to_content(content, &fixes)
90            .unwrap()
91    }
92
93    #[test]
94    fn test_correct_single_space() {
95        let content = "# Heading 1\n## Heading 2\n### Heading 3";
96        let parser = MarkdownParser::new(content);
97        let rule = MD019;
98        let violations = rule.check(&parser, None);
99
100        assert_eq!(violations.len(), 0);
101    }
102
103    #[test]
104    fn test_multiple_spaces() {
105        let content = "#  Heading with 2 spaces\n## Correct heading";
106        let parser = MarkdownParser::new(content);
107        let rule = MD019;
108        let violations = rule.check(&parser, None);
109
110        assert_eq!(violations.len(), 1);
111        assert_eq!(violations[0].line, 1);
112    }
113
114    #[test]
115    fn test_many_spaces() {
116        let content = "###     Heading with 5 spaces";
117        let parser = MarkdownParser::new(content);
118        let rule = MD019;
119        let violations = rule.check(&parser, None);
120
121        assert_eq!(violations.len(), 1);
122        assert!(violations[0].message.contains("5 spaces"));
123    }
124
125    #[test]
126    fn test_heading_in_code_block_not_flagged() {
127        let content = "# Real heading\n\n```\n##  WouldBeViolation\n```\n";
128        let parser = MarkdownParser::new(content);
129        let rule = MD019;
130        let violations = rule.check(&parser, None);
131
132        assert_eq!(violations.len(), 0);
133    }
134
135    #[test]
136    fn test_fix_collapses_multiple_spaces() {
137        let content = "#  Too many spaces\n\n###   Even more\n";
138        let parser = MarkdownParser::new(content);
139        let rule = MD019;
140        let violations = rule.check(&parser, None);
141        assert_eq!(violations.len(), 2);
142        let fixed = apply_fixes(content, &violations);
143        assert_eq!(fixed, "# Too many spaces\n\n### Even more\n");
144    }
145}