Skip to main content

mdlint/lint/rules/
md009.rs

1use crate::lint::rule::Rule;
2use crate::markdown::MarkdownParser;
3use crate::types::{Fix, Violation};
4use serde_json::Value;
5
6pub struct MD009;
7
8impl Rule for MD009 {
9    fn name(&self) -> &str {
10        "MD009"
11    }
12
13    fn description(&self) -> &str {
14        "Trailing spaces"
15    }
16
17    fn tags(&self) -> &[&str] {
18        &["whitespace"]
19    }
20
21    fn check(&self, parser: &MarkdownParser, config: Option<&Value>) -> Vec<Violation> {
22        let br_spaces = config
23            .and_then(|c| c.get("br_spaces"))
24            .and_then(|v| v.as_u64())
25            .unwrap_or(2) as usize;
26
27        let strict = config
28            .and_then(|c| c.get("strict"))
29            .and_then(|v| v.as_bool())
30            .unwrap_or(false);
31
32        let mut violations = Vec::new();
33
34        for (line_num, line) in parser.lines().iter().enumerate() {
35            let trimmed = line.trim_end();
36            let trailing_spaces = line.len() - trimmed.len();
37
38            if trailing_spaces > 0 {
39                // Allow br_spaces for line breaks unless strict mode
40                if !strict && trailing_spaces == br_spaces {
41                    continue;
42                }
43
44                violations.push(Violation {
45                    line: line_num + 1,
46                    column: Some(trimmed.len() + 1),
47                    rule: self.name().to_string(),
48                    message: format!("Trailing spaces ({} spaces)", trailing_spaces),
49                    fix: Some(Fix {
50                        line_start: line_num + 1,
51                        line_end: line_num + 1,
52                        column_start: Some(trimmed.len() + 1),
53                        column_end: Some(line.len()),
54                        replacement: String::new(),
55                        description: "Remove trailing spaces".to_string(),
56                    }),
57                });
58            }
59        }
60
61        violations
62    }
63
64    fn fixable(&self) -> bool {
65        true
66    }
67}
68
69#[cfg(test)]
70mod tests {
71    use super::*;
72    use crate::fix::Fixer;
73
74    fn apply_fixes(content: &str, violations: &[Violation]) -> String {
75        let fixes: Vec<_> = violations.iter().filter_map(|v| v.fix.clone()).collect();
76        Fixer::new()
77            .apply_fixes_to_content(content, &fixes)
78            .unwrap()
79    }
80
81    #[test]
82    fn test_no_trailing_spaces() {
83        let content = "Line 1\nLine 2\nLine 3";
84        let parser = MarkdownParser::new(content);
85        let rule = MD009;
86        let violations = rule.check(&parser, None);
87
88        assert_eq!(violations.len(), 0);
89    }
90
91    #[test]
92    fn test_trailing_spaces() {
93        let content = "Line 1  \nLine 2\nLine 3   ";
94        let parser = MarkdownParser::new(content);
95        let rule = MD009;
96        let violations = rule.check(&parser, None);
97
98        assert_eq!(violations.len(), 1); // Line 3 has 3 spaces, Line 1 has 2 (allowed for br)
99        assert_eq!(violations[0].line, 3);
100        assert_eq!(violations[0].column, Some(7));
101    }
102
103    #[test]
104    fn test_strict_mode() {
105        let content = "Line 1  \nLine 2";
106        let parser = MarkdownParser::new(content);
107        let rule = MD009;
108        let config = serde_json::json!({ "strict": true });
109        let violations = rule.check(&parser, Some(&config));
110
111        assert_eq!(violations.len(), 1);
112        assert_eq!(violations[0].line, 1);
113    }
114
115    #[test]
116    fn test_custom_br_spaces() {
117        let content = "Line 1   \nLine 2";
118        let parser = MarkdownParser::new(content);
119        let rule = MD009;
120        let config = serde_json::json!({ "br_spaces": 3 });
121        let violations = rule.check(&parser, Some(&config));
122
123        assert_eq!(violations.len(), 0); // 3 spaces allowed for br
124    }
125
126    #[test]
127    fn test_fix_removes_trailing_spaces() {
128        let content = "Line 1   \nLine 2\n";
129        let parser = MarkdownParser::new(content);
130        let rule = MD009;
131        let violations = rule.check(&parser, None);
132        let fixed = apply_fixes(content, &violations);
133        assert_eq!(fixed, "Line 1\nLine 2\n");
134    }
135}