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) -> &'static str {
10        "MD020"
11    }
12
13    fn description(&self) -> &'static 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
43                        .get(pos_before_closing)
44                        .copied()
45                        .expect("pos bounded by len")
46                        != ' '
47                    {
48                        // Insert space before closing hashes
49                        let before_closing: String = chars
50                            .get(..=pos_before_closing)
51                            .expect("pos bounded by len")
52                            .iter()
53                            .collect();
54                        let closing: String = chars
55                            .get((pos_before_closing + 1)..)
56                            .expect("pos+1 bounded by len")
57                            .iter()
58                            .collect();
59                        let replacement = format!("{before_closing} {closing}");
60
61                        violations.push(Violation {
62                            line: line_number,
63                            column: Some(1),
64                            rule: self.name().to_owned(),
65                            message: "No space inside hashes on closed atx style heading"
66                                .to_owned(),
67                            fix: Some(Fix {
68                                line_start: line_number,
69                                line_end: line_number,
70                                column_start: None,
71                                column_end: None,
72                                replacement,
73                                description: "Add space before closing hashes".to_owned(),
74                            }),
75                        });
76                    }
77                }
78            }
79        }
80
81        violations
82    }
83
84    fn fixable(&self) -> bool {
85        true
86    }
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92    use crate::fix::Fixer;
93
94    fn apply_fixes(content: &str, violations: &[Violation]) -> String {
95        let fixes: Vec<_> = violations.iter().filter_map(|v| v.fix.clone()).collect();
96        Fixer::new()
97            .apply_fixes_to_content(content, &fixes)
98            .unwrap()
99    }
100
101    #[test]
102    fn test_correct_closed_heading() {
103        let content = "# Heading #";
104        let parser = MarkdownParser::new(content);
105        let rule = MD020;
106        let violations = rule.check(&parser, None);
107
108        assert_eq!(violations.len(), 0); // Has space, so it's correct
109    }
110
111    #[test]
112    fn test_no_space_before_closing() {
113        let content = "# Heading#";
114        let parser = MarkdownParser::new(content);
115        let rule = MD020;
116        let violations = rule.check(&parser, None);
117
118        assert_eq!(violations.len(), 1); // No space, violation
119    }
120
121    #[test]
122    fn test_regular_heading() {
123        let content = "# Heading";
124        let parser = MarkdownParser::new(content);
125        let rule = MD020;
126        let violations = rule.check(&parser, None);
127
128        assert_eq!(violations.len(), 0); // Not a closed heading
129    }
130
131    #[test]
132    fn test_multiple_levels() {
133        let content = "## Heading##\n### Another###";
134        let parser = MarkdownParser::new(content);
135        let rule = MD020;
136        let violations = rule.check(&parser, None);
137
138        assert_eq!(violations.len(), 2); // Both missing spaces
139    }
140
141    #[test]
142    fn test_fix_inserts_space_before_closing_hashes() {
143        let content = "# Heading#\n";
144        let parser = MarkdownParser::new(content);
145        let rule = MD020;
146        let violations = rule.check(&parser, None);
147        assert_eq!(violations.len(), 1);
148        let fixed = apply_fixes(content, &violations);
149        assert_eq!(fixed, "# Heading #\n");
150    }
151}