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) -> &'static str {
10        "MD019"
11    }
12
13    fn description(&self) -> &'static 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_owned(),
54                            message: format!(
55                                "Multiple spaces after hash on atx style heading ({space_count} spaces)"
56                            ),
57                            fix: Some(Fix {
58                                line_start: line_number,
59                                line_end: line_number,
60                                column_start: None,
61                                column_end: None,
62                                replacement,
63                                description: "Replace multiple spaces with single space".to_owned(),
64                            }),
65                        });
66                    }
67                }
68            }
69        }
70
71        violations
72    }
73
74    fn fixable(&self) -> bool {
75        true
76    }
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82    use crate::fix::Fixer;
83
84    fn apply_fixes(content: &str, violations: &[Violation]) -> String {
85        let fixes: Vec<_> = violations.iter().filter_map(|v| v.fix.clone()).collect();
86        Fixer::new()
87            .apply_fixes_to_content(content, &fixes)
88            .unwrap()
89    }
90
91    #[test]
92    fn test_correct_single_space() {
93        let content = "# Heading 1\n## Heading 2\n### Heading 3";
94        let parser = MarkdownParser::new(content);
95        let rule = MD019;
96        let violations = rule.check(&parser, None);
97
98        assert_eq!(violations.len(), 0);
99    }
100
101    #[test]
102    fn test_multiple_spaces() {
103        let content = "#  Heading with 2 spaces\n## Correct heading";
104        let parser = MarkdownParser::new(content);
105        let rule = MD019;
106        let violations = rule.check(&parser, None);
107
108        assert_eq!(violations.len(), 1);
109        assert_eq!(violations[0].line, 1);
110    }
111
112    #[test]
113    fn test_many_spaces() {
114        let content = "###     Heading with 5 spaces";
115        let parser = MarkdownParser::new(content);
116        let rule = MD019;
117        let violations = rule.check(&parser, None);
118
119        assert_eq!(violations.len(), 1);
120        assert!(violations[0].message.contains("5 spaces"));
121    }
122
123    #[test]
124    fn test_heading_in_code_block_not_flagged() {
125        let content = "# Real heading\n\n```\n##  WouldBeViolation\n```\n";
126        let parser = MarkdownParser::new(content);
127        let rule = MD019;
128        let violations = rule.check(&parser, None);
129
130        assert_eq!(violations.len(), 0);
131    }
132
133    #[test]
134    fn test_fix_collapses_multiple_spaces() {
135        let content = "#  Too many spaces\n\n###   Even more\n";
136        let parser = MarkdownParser::new(content);
137        let rule = MD019;
138        let violations = rule.check(&parser, None);
139        assert_eq!(violations.len(), 2);
140        let fixed = apply_fixes(content, &violations);
141        assert_eq!(fixed, "# Too many spaces\n\n### Even more\n");
142    }
143}