Skip to main content

mdlint/lint/rules/
md020.rs

1use crate::lint::rule::Rule;
2use crate::markdown::MarkdownParser;
3use crate::types::{Fix, Violation};
4use serde_json::Value;
5
6pub struct MD020;
7
8impl Rule for MD020 {
9    fn name(&self) -> &str {
10        "MD020"
11    }
12
13    fn description(&self) -> &str {
14        "No space 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 (starts and ends with #)
29            if trimmed.starts_with('#') && trimmed.ends_with('#') {
30                // Count opening hashes
31                let opening_hashes = trimmed.chars().take_while(|&c| c == '#').count();
32
33                // Count closing hashes
34                let closing_hashes = trimmed.chars().rev().take_while(|&c| c == '#').count();
35
36                // Make sure there's content between opening and closing hashes
37                if opening_hashes + closing_hashes < trimmed.len() {
38                    // Get the character before the closing hashes
39                    let chars: Vec<char> = trimmed.chars().collect();
40                    let pos_before_closing = chars.len() - closing_hashes - 1;
41
42                    if chars[pos_before_closing] != ' ' {
43                        // Insert space before closing hashes
44                        let before_closing: String = chars[..=pos_before_closing].iter().collect();
45                        let closing: String = chars[(pos_before_closing + 1)..].iter().collect();
46                        let replacement = format!("{} {}", before_closing, closing);
47
48                        violations.push(Violation {
49                            line: line_number,
50                            column: Some(1),
51                            rule: self.name().to_string(),
52                            message: "No space inside hashes on closed atx style heading"
53                                .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 before closing hashes".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_closed_heading() {
90        let content = "# Heading #";
91        let parser = MarkdownParser::new(content);
92        let rule = MD020;
93        let violations = rule.check(&parser, None);
94
95        assert_eq!(violations.len(), 0); // Has space, so it's correct
96    }
97
98    #[test]
99    fn test_no_space_before_closing() {
100        let content = "# Heading#";
101        let parser = MarkdownParser::new(content);
102        let rule = MD020;
103        let violations = rule.check(&parser, None);
104
105        assert_eq!(violations.len(), 1); // No space, violation
106    }
107
108    #[test]
109    fn test_regular_heading() {
110        let content = "# Heading";
111        let parser = MarkdownParser::new(content);
112        let rule = MD020;
113        let violations = rule.check(&parser, None);
114
115        assert_eq!(violations.len(), 0); // Not a closed heading
116    }
117
118    #[test]
119    fn test_multiple_levels() {
120        let content = "## Heading##\n### Another###";
121        let parser = MarkdownParser::new(content);
122        let rule = MD020;
123        let violations = rule.check(&parser, None);
124
125        assert_eq!(violations.len(), 2); // Both missing spaces
126    }
127
128    #[test]
129    fn test_fix_inserts_space_before_closing_hashes() {
130        let content = "# Heading#\n";
131        let parser = MarkdownParser::new(content);
132        let rule = MD020;
133        let violations = rule.check(&parser, None);
134        assert_eq!(violations.len(), 1);
135        let fixed = apply_fixes(content, &violations);
136        assert_eq!(fixed, "# Heading #\n");
137    }
138}