mdbook_lint_core/rules/standard/
md009.rs

1//! MD009: No trailing spaces
2//!
3//! This rule checks for trailing spaces at the end of lines.
4
5use crate::error::Result;
6use crate::rule::{AstRule, RuleCategory, RuleMetadata};
7use crate::{
8    Document,
9    violation::{Severity, Violation},
10};
11
12/// Rule to check for trailing spaces at the end of lines
13pub struct MD009 {
14    /// Whether to allow trailing spaces in code blocks
15    br_spaces: usize,
16    /// Whether to allow trailing spaces at the end of list items
17    list_item_empty_lines: bool,
18    /// Whether to ignore trailing spaces in strict mode
19    strict: bool,
20}
21
22impl MD009 {
23    /// Create a new MD009 rule with default settings
24    pub fn new() -> Self {
25        Self {
26            br_spaces: 2, // Allow 2 trailing spaces for line breaks
27            list_item_empty_lines: false,
28            strict: false,
29        }
30    }
31
32    /// Create a new MD009 rule with custom settings
33    #[allow(dead_code)]
34    pub fn with_config(br_spaces: usize, list_item_empty_lines: bool, strict: bool) -> Self {
35        Self {
36            br_spaces,
37            list_item_empty_lines,
38            strict,
39        }
40    }
41}
42
43impl Default for MD009 {
44    fn default() -> Self {
45        Self::new()
46    }
47}
48
49impl AstRule for MD009 {
50    fn id(&self) -> &'static str {
51        "MD009"
52    }
53
54    fn name(&self) -> &'static str {
55        "no-trailing-spaces"
56    }
57
58    fn description(&self) -> &'static str {
59        "Trailing spaces are not allowed"
60    }
61
62    fn metadata(&self) -> RuleMetadata {
63        RuleMetadata::stable(RuleCategory::Formatting).introduced_in("markdownlint v0.1.0")
64    }
65
66    fn check_ast<'a>(
67        &self,
68        document: &Document,
69        ast: &'a comrak::nodes::AstNode<'a>,
70    ) -> Result<Vec<Violation>> {
71        let mut violations = Vec::new();
72
73        // Get code block line ranges from provided AST
74        let code_block_lines = self.get_code_block_line_ranges(ast);
75        let list_item_lines = if self.list_item_empty_lines {
76            self.get_list_item_empty_lines(ast)
77        } else {
78            Vec::new()
79        };
80
81        for (line_number, line) in document.lines.iter().enumerate() {
82            let line_num = line_number + 1; // Convert to 1-based line numbers
83
84            // Skip if line has no trailing spaces
85            if !line.ends_with(' ') && !line.ends_with('\t') {
86                continue;
87            }
88
89            // Count trailing whitespace
90            let trailing_spaces = line.chars().rev().take_while(|c| c.is_whitespace()).count();
91
92            // Check if this line is in a code block
93            let in_code_block = code_block_lines
94                .iter()
95                .any(|(start, end)| line_num >= *start && line_num <= *end);
96
97            // Skip code blocks unless in strict mode
98            if in_code_block && !self.strict {
99                continue;
100            }
101
102            // Check if this is a list item empty line that we should ignore
103            if self.list_item_empty_lines && list_item_lines.contains(&line_num) {
104                continue;
105            }
106
107            // Allow exactly br_spaces trailing spaces for line breaks (markdown soft breaks)
108            if !self.strict && trailing_spaces == self.br_spaces {
109                continue;
110            }
111
112            // Create violation
113            let column = line.len() - trailing_spaces + 1;
114            violations.push(self.create_violation(
115                format!(
116                    "Trailing spaces detected (found {} trailing space{})",
117                    trailing_spaces,
118                    if trailing_spaces == 1 { "" } else { "s" }
119                ),
120                line_num,
121                column,
122                Severity::Warning,
123            ));
124        }
125
126        Ok(violations)
127    }
128}
129
130impl MD009 {
131    /// Get line ranges for code blocks to potentially skip them
132    fn get_code_block_line_ranges<'a>(
133        &self,
134        ast: &'a comrak::nodes::AstNode<'a>,
135    ) -> Vec<(usize, usize)> {
136        let mut ranges = Vec::new();
137        self.collect_code_block_ranges(ast, &mut ranges);
138        ranges
139    }
140
141    /// Recursively collect code block line ranges
142    #[allow(clippy::only_used_in_recursion)]
143    fn collect_code_block_ranges<'a>(
144        &self,
145        node: &'a comrak::nodes::AstNode<'a>,
146        ranges: &mut Vec<(usize, usize)>,
147    ) {
148        use comrak::nodes::NodeValue;
149
150        if let NodeValue::CodeBlock(_) = &node.data.borrow().value {
151            let sourcepos = node.data.borrow().sourcepos;
152            if sourcepos.start.line > 0 && sourcepos.end.line > 0 {
153                ranges.push((sourcepos.start.line, sourcepos.end.line));
154            }
155        }
156
157        for child in node.children() {
158            self.collect_code_block_ranges(child, ranges);
159        }
160    }
161
162    /// Get empty lines within list items (if list_item_empty_lines is enabled)
163    fn get_list_item_empty_lines<'a>(&self, ast: &'a comrak::nodes::AstNode<'a>) -> Vec<usize> {
164        let mut lines = Vec::new();
165        self.collect_list_item_empty_lines(ast, &mut lines);
166        lines
167    }
168
169    /// Recursively collect empty lines within list items
170    /// Collect empty lines within list items
171    #[allow(clippy::only_used_in_recursion)]
172    fn collect_list_item_empty_lines<'a>(
173        &self,
174        node: &'a comrak::nodes::AstNode<'a>,
175        lines: &mut Vec<usize>,
176    ) {
177        use comrak::nodes::NodeValue;
178
179        if let NodeValue::Item(_) = &node.data.borrow().value {
180            // For now, we don't implement the complex logic to identify empty lines within list items
181            // This would require more sophisticated AST analysis
182        }
183
184        for child in node.children() {
185            self.collect_list_item_empty_lines(child, lines);
186        }
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193    use crate::rule::Rule;
194    use std::path::PathBuf;
195
196    fn create_test_document(content: &str) -> Document {
197        Document::new(content.to_string(), PathBuf::from("test.md")).unwrap()
198    }
199
200    #[test]
201    fn test_md009_no_trailing_spaces() {
202        let content = "# Heading\n\nNo trailing spaces here.\nAnother clean line.";
203        let document = create_test_document(content);
204        let rule = MD009::new();
205        let violations = rule.check(&document).unwrap();
206
207        assert_eq!(violations.len(), 0);
208    }
209
210    #[test]
211    fn test_md009_single_trailing_space() {
212        let content = "# Heading\n\nLine with single trailing space. \nClean line.";
213        let document = create_test_document(content);
214        let rule = MD009::new();
215        let violations = rule.check(&document).unwrap();
216
217        assert_eq!(violations.len(), 1);
218        assert_eq!(violations[0].rule_id, "MD009");
219        assert_eq!(violations[0].line, 3);
220        assert_eq!(violations[0].column, 33);
221        assert!(violations[0].message.contains("1 trailing space"));
222    }
223
224    #[test]
225    fn test_md009_multiple_trailing_spaces() {
226        let content = "# Heading\n\nLine with spaces.   \nAnother line.    ";
227        let document = create_test_document(content);
228        let rule = MD009::new();
229        let violations = rule.check(&document).unwrap();
230
231        assert_eq!(violations.len(), 2);
232        assert_eq!(violations[0].line, 3);
233        assert!(violations[0].message.contains("3 trailing spaces"));
234        assert_eq!(violations[1].line, 4);
235        assert!(violations[1].message.contains("4 trailing spaces"));
236    }
237
238    #[test]
239    fn test_md009_trailing_tabs() {
240        let content = "# Heading\n\nLine with trailing tab.\t\nClean line.";
241        let document = create_test_document(content);
242        let rule = MD009::new();
243        let violations = rule.check(&document).unwrap();
244
245        assert_eq!(violations.len(), 1);
246        assert_eq!(violations[0].line, 3);
247        assert!(violations[0].message.contains("1 trailing space"));
248    }
249
250    #[test]
251    fn test_md009_line_break_spaces() {
252        let content = "# Heading\n\nLine with two spaces for break.  \nNext line.";
253        let document = create_test_document(content);
254        let rule = MD009::new();
255        let violations = rule.check(&document).unwrap();
256
257        // Should allow exactly 2 trailing spaces for line breaks
258        assert_eq!(violations.len(), 0);
259    }
260
261    #[test]
262    fn test_md009_strict_mode() {
263        let content = "# Heading\n\nLine with two spaces.  \nThree spaces.   ";
264        let document = create_test_document(content);
265        let rule = MD009::with_config(2, false, true);
266        let violations = rule.check(&document).unwrap();
267
268        // In strict mode, no trailing spaces are allowed
269        assert_eq!(violations.len(), 2);
270    }
271
272    #[test]
273    fn test_md009_code_block_ignored() {
274        let content = "# Heading\n\n```rust\nlet x = 1;  \n```\n\nRegular line.   ";
275        let document = create_test_document(content);
276        let rule = MD009::new();
277        let violations = rule.check(&document).unwrap();
278
279        // Should ignore trailing spaces in code blocks but catch them in regular text
280        assert_eq!(violations.len(), 1);
281        assert_eq!(violations[0].line, 7);
282    }
283
284    #[test]
285    fn test_md009_code_block_strict() {
286        let content = "# Heading\n\n```rust\nlet x = 1;  \n```\n\nRegular line.   ";
287        let document = create_test_document(content);
288        let rule = MD009::with_config(2, false, true);
289        let violations = rule.check(&document).unwrap();
290
291        // In strict mode, should catch trailing spaces everywhere
292        assert_eq!(violations.len(), 2);
293    }
294}