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