Skip to main content

mdlint/lint/rules/
md018.rs

1use crate::lint::rule::Rule;
2use crate::markdown::MarkdownParser;
3use crate::types::{Fix, Violation};
4use serde_json::Value;
5
6pub struct MD018;
7
8impl Rule for MD018 {
9    fn name(&self) -> &str {
10        "MD018"
11    }
12
13    fn description(&self) -> &str {
14        "No space 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 without space 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 {
39                    // Check character after the hashes
40                    if let Some(next_char) = trimmed.chars().nth(hash_count)
41                        && !next_char.is_whitespace()
42                        && next_char != '#'
43                    {
44                        // Insert space after the hashes
45                        let hashes = "#".repeat(hash_count);
46                        let rest = &trimmed[hash_count..];
47                        let replacement = format!("{} {}", hashes, rest);
48
49                        violations.push(Violation {
50                            line: line_number,
51                            column: Some(hash_count + 1),
52                            rule: self.name().to_string(),
53                            message: "No space after hash on atx style heading".to_string(),
54                            fix: Some(Fix {
55                                line_start: line_number,
56                                line_end: line_number,
57                                column_start: None,
58                                column_end: None,
59                                replacement,
60                                description: "Add space after hash".to_string(),
61                            }),
62                        });
63                    }
64                }
65            }
66        }
67
68        violations
69    }
70
71    fn fixable(&self) -> bool {
72        true
73    }
74}
75
76#[cfg(test)]
77mod tests {
78    use super::*;
79    use crate::fix::Fixer;
80
81    fn apply_fixes(content: &str, violations: &[Violation]) -> String {
82        let fixes: Vec<_> = violations.iter().filter_map(|v| v.fix.clone()).collect();
83        Fixer::new()
84            .apply_fixes_to_content(content, &fixes)
85            .unwrap()
86    }
87
88    #[test]
89    fn test_correct_spacing() {
90        let content = "# Heading 1\n## Heading 2\n### Heading 3";
91        let parser = MarkdownParser::new(content);
92        let rule = MD018;
93        let violations = rule.check(&parser, None);
94
95        assert_eq!(violations.len(), 0);
96    }
97
98    #[test]
99    fn test_no_space_after_hash() {
100        let content = "#Heading without space\n## Correct heading";
101        let parser = MarkdownParser::new(content);
102        let rule = MD018;
103        let violations = rule.check(&parser, None);
104
105        assert_eq!(violations.len(), 1);
106        assert_eq!(violations[0].line, 1);
107    }
108
109    #[test]
110    fn test_multiple_violations() {
111        let content = "#First\n##Second\n### Correct";
112        let parser = MarkdownParser::new(content);
113        let rule = MD018;
114        let violations = rule.check(&parser, None);
115
116        assert_eq!(violations.len(), 2);
117    }
118
119    #[test]
120    fn test_closed_heading() {
121        let content = "## Closed heading ##";
122        let parser = MarkdownParser::new(content);
123        let rule = MD018;
124        let violations = rule.check(&parser, None);
125
126        assert_eq!(violations.len(), 0);
127    }
128
129    #[test]
130    fn test_heading_in_code_block_not_flagged() {
131        let content = "# Real heading\n\n```\n#NotAHeading\n```\n";
132        let parser = MarkdownParser::new(content);
133        let rule = MD018;
134        let violations = rule.check(&parser, None);
135
136        assert_eq!(violations.len(), 0);
137    }
138
139    #[test]
140    fn test_fix_inserts_space_after_hash() {
141        let content = "#Heading\n\n##Another\n";
142        let parser = MarkdownParser::new(content);
143        let rule = MD018;
144        let violations = rule.check(&parser, None);
145        assert_eq!(violations.len(), 2);
146        let fixed = apply_fixes(content, &violations);
147        assert_eq!(fixed, "# Heading\n\n## Another\n");
148    }
149}