quickmark_core/rules/
md009.rs

1use serde::Deserialize;
2use std::collections::HashSet;
3use std::rc::Rc;
4
5use tree_sitter::Node;
6
7use crate::{
8    linter::{range_from_tree_sitter, RuleViolation},
9    rules::{Context, Rule, RuleLinter, RuleType},
10};
11
12// MD009-specific configuration types
13#[derive(Debug, PartialEq, Clone, Deserialize)]
14pub struct MD009TrailingSpacesTable {
15    #[serde(default)]
16    pub br_spaces: usize,
17    #[serde(default)]
18    pub list_item_empty_lines: bool,
19    #[serde(default)]
20    pub strict: bool,
21}
22
23impl Default for MD009TrailingSpacesTable {
24    fn default() -> Self {
25        Self {
26            br_spaces: 2,
27            list_item_empty_lines: false,
28            strict: false,
29        }
30    }
31}
32
33/// MD009 Trailing Spaces Rule Linter
34///
35/// **SINGLE-USE CONTRACT**: This linter is designed for one-time use only.
36/// After processing a document (via feed() calls and finalize()), the linter
37/// should be discarded. The violations state is not cleared between uses.
38pub(crate) struct MD009Linter {
39    context: Rc<Context>,
40    violations: Vec<RuleViolation>,
41}
42
43impl MD009Linter {
44    pub fn new(context: Rc<Context>) -> Self {
45        Self {
46            context,
47            violations: Vec::new(),
48        }
49    }
50
51    /// Analyze all lines and store all violations for reporting via finalize()
52    /// Context cache is already initialized by MultiRuleLinter
53    fn analyze_all_lines(&mut self) {
54        let settings = &self.context.config.linters.settings.trailing_spaces;
55        let lines = self.context.lines.borrow();
56
57        // Determine effective br_spaces (< 2 becomes 0)
58        let expected_spaces = if settings.br_spaces < 2 {
59            0
60        } else {
61            settings.br_spaces
62        };
63
64        // Build sets of line numbers to exclude
65        let code_block_lines = self.get_code_block_lines();
66        let list_item_empty_lines = if settings.list_item_empty_lines {
67            self.get_list_item_empty_lines()
68        } else {
69            HashSet::new()
70        };
71
72        for (line_index, line) in lines.iter().enumerate() {
73            let line_number = line_index + 1;
74            let trailing_spaces = line.len() - line.trim_end().len();
75
76            if trailing_spaces > 0
77                && !code_block_lines.contains(&line_number)
78                && !list_item_empty_lines.contains(&line_number)
79            {
80                let followed_by_blank_line = lines
81                    .get(line_index + 1)
82                    .is_some_and(|next_line| next_line.trim().is_empty());
83
84                if self.should_violate(
85                    trailing_spaces,
86                    expected_spaces,
87                    settings.strict,
88                    settings.br_spaces,
89                    followed_by_blank_line,
90                ) {
91                    let violation =
92                        self.create_violation(line_index, line, trailing_spaces, expected_spaces);
93                    self.violations.push(violation);
94                }
95            }
96        }
97    }
98
99    /// Returns a set of line numbers that are part of code blocks.
100    /// This is performant as it uses the pre-parsed node cache.
101    fn get_code_block_lines(&self) -> HashSet<usize> {
102        let node_cache = self.context.node_cache.borrow();
103        ["indented_code_block", "fenced_code_block"]
104            .iter()
105            .filter_map(|kind| node_cache.get(*kind))
106            .flatten()
107            .flat_map(|node_info| (node_info.line_start + 1)..=(node_info.line_end + 1))
108            .collect()
109    }
110
111    /// Returns a set of line numbers for empty lines within list items.
112    /// This is more robust and performant than manual parsing, as it relies on the AST.
113    fn get_list_item_empty_lines(&self) -> HashSet<usize> {
114        let node_cache = self.context.node_cache.borrow();
115        let lines = self.context.lines.borrow();
116
117        node_cache.get("list").map_or_else(HashSet::new, |lists| {
118            lists
119                .iter()
120                .flat_map(|node_info| (node_info.line_start + 1)..=(node_info.line_end + 1))
121                .filter(|&line_num| {
122                    let line_index = line_num - 1;
123                    lines
124                        .get(line_index)
125                        .is_some_and(|line| line.trim().is_empty())
126                })
127                .collect()
128        })
129    }
130
131    /// Determines if a line with trailing spaces constitutes a violation.
132    fn should_violate(
133        &self,
134        trailing_spaces: usize,
135        expected_spaces: usize,
136        strict: bool,
137        br_spaces: usize,
138        followed_by_blank_line: bool,
139    ) -> bool {
140        if strict {
141            // In strict mode, there's an exception for `br_spaces` followed by a blank line.
142            if br_spaces >= 2 && trailing_spaces == br_spaces && followed_by_blank_line {
143                return false;
144            }
145            // Otherwise, any trailing space is a violation in strict mode.
146            return true;
147        }
148
149        // In non-strict mode, a violation occurs if the number of trailing spaces
150        // is not the amount expected for a hard line break.
151        trailing_spaces != expected_spaces
152    }
153
154    /// Creates a RuleViolation with a correctly calculated range.
155    fn create_violation(
156        &self,
157        line_index: usize,
158        line: &str,
159        trailing_spaces: usize,
160        expected_spaces: usize,
161    ) -> RuleViolation {
162        let message = if expected_spaces == 0 {
163            format!("Expected: 0 trailing spaces; Actual: {trailing_spaces}")
164        } else {
165            format!("Expected: 0 or {expected_spaces} trailing spaces; Actual: {trailing_spaces}")
166        };
167
168        let start_column = line.trim_end().len();
169        let end_column = line.len();
170
171        RuleViolation::new(
172            &MD009,
173            message,
174            self.context.file_path.clone(),
175            range_from_tree_sitter(&tree_sitter::Range {
176                // FIXME: Byte offsets are not correctly calculated as line start offset is unavailable here.
177                // This may result in incorrect highlighting in some tools.
178                // The primary information is in the points (row/column).
179                start_byte: 0,
180                end_byte: 0,
181                start_point: tree_sitter::Point {
182                    row: line_index,
183                    column: start_column,
184                },
185                end_point: tree_sitter::Point {
186                    row: line_index,
187                    column: end_column,
188                },
189            }),
190        )
191    }
192}
193
194impl RuleLinter for MD009Linter {
195    fn feed(&mut self, node: &Node) {
196        // This rule is line-based and only needs to run once.
197        // We trigger the analysis on seeing the top-level `document` node.
198        if node.kind() == "document" {
199            self.analyze_all_lines();
200        }
201    }
202
203    fn finalize(&mut self) -> Vec<RuleViolation> {
204        std::mem::take(&mut self.violations)
205    }
206}
207
208pub const MD009: Rule = Rule {
209    id: "MD009",
210    alias: "no-trailing-spaces",
211    tags: &["whitespace"],
212    description: "Trailing spaces",
213    rule_type: RuleType::Line,
214    // This is a line-based rule and does not require specific nodes from the AST.
215    // The logic runs once for the entire file content.
216    required_nodes: &[],
217    new_linter: |context| Box::new(MD009Linter::new(context)),
218};
219
220#[cfg(test)]
221mod test {
222    use std::path::PathBuf;
223
224    use crate::config::{LintersSettingsTable, MD009TrailingSpacesTable, RuleSeverity};
225    use crate::linter::MultiRuleLinter;
226    use crate::test_utils::test_helpers::{test_config_with_rules, test_config_with_settings};
227
228    fn test_config() -> crate::config::QuickmarkConfig {
229        test_config_with_rules(vec![
230            ("no-trailing-spaces", RuleSeverity::Error),
231            ("heading-style", RuleSeverity::Off),
232            ("heading-increment", RuleSeverity::Off),
233        ])
234    }
235
236    fn test_config_with_trailing_spaces(
237        trailing_spaces_config: MD009TrailingSpacesTable,
238    ) -> crate::config::QuickmarkConfig {
239        test_config_with_settings(
240            vec![
241                ("no-trailing-spaces", RuleSeverity::Error),
242                ("heading-style", RuleSeverity::Off),
243                ("heading-increment", RuleSeverity::Off),
244            ],
245            LintersSettingsTable {
246                trailing_spaces: trailing_spaces_config,
247                ..Default::default()
248            },
249        )
250    }
251
252    #[test]
253    fn test_basic_trailing_space_violation() {
254        #[rustfmt::skip]
255        let input = "This line has trailing spaces   "; // 3 spaces should violate
256
257        let config = test_config();
258        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
259        let violations = linter.analyze();
260        assert_eq!(1, violations.len());
261
262        let violation = &violations[0];
263        assert_eq!("MD009", violation.rule().id);
264        assert!(violation.message().contains("Expected:"));
265        assert!(violation.message().contains("Actual: 3"));
266    }
267
268    #[test]
269    fn test_no_trailing_spaces() {
270        let input = "This line has no trailing spaces";
271
272        let config = test_config();
273        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
274        let violations = linter.analyze();
275        assert_eq!(0, violations.len());
276    }
277
278    #[test]
279    fn test_single_trailing_space() {
280        #[rustfmt::skip]
281        let input = "This line has one trailing space ";
282
283        let config = test_config();
284        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
285        let violations = linter.analyze();
286        assert_eq!(1, violations.len());
287    }
288
289    #[test]
290    fn test_two_spaces_allowed_by_default() {
291        #[rustfmt::skip]
292        let input = "This line has two trailing spaces for line break  ";
293
294        let config = test_config();
295        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
296        let violations = linter.analyze();
297        assert_eq!(0, violations.len()); // Default br_spaces = 2, so this should be allowed
298    }
299
300    #[test]
301    fn test_three_spaces_violation() {
302        #[rustfmt::skip]
303        let input = "This line has three trailing spaces   ";
304
305        let config = test_config();
306        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
307        let violations = linter.analyze();
308        assert_eq!(1, violations.len());
309    }
310
311    #[test]
312    fn test_custom_br_spaces() {
313        let config = test_config_with_trailing_spaces(MD009TrailingSpacesTable {
314            br_spaces: 4,
315            list_item_empty_lines: false,
316            strict: false,
317        });
318
319        #[rustfmt::skip]
320        let input_allowed = "This line has four trailing spaces    ";
321        let mut linter = MultiRuleLinter::new_for_document(
322            PathBuf::from("test.md"),
323            config.clone(),
324            input_allowed,
325        );
326        let violations = linter.analyze();
327        assert_eq!(0, violations.len()); // Should be allowed
328
329        #[rustfmt::skip]
330        let input_violation = "This line has five trailing spaces     ";
331        let mut linter =
332            MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input_violation);
333        let violations = linter.analyze();
334        assert_eq!(1, violations.len()); // Should violate
335    }
336
337    #[test]
338    fn test_strict_mode() {
339        let config = test_config_with_trailing_spaces(MD009TrailingSpacesTable {
340            br_spaces: 2,
341            list_item_empty_lines: false,
342            strict: true,
343        });
344
345        // In strict mode, even allowed trailing spaces should be violations
346        // if they don't actually create a line break
347        #[rustfmt::skip]
348        let input = "This line has two trailing spaces but no line break after  ";
349        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
350        let violations = linter.analyze();
351        assert_eq!(1, violations.len()); // Should violate in strict mode
352    }
353
354    #[test]
355    fn test_br_spaces_less_than_two() {
356        let config = test_config_with_trailing_spaces(MD009TrailingSpacesTable {
357            br_spaces: 1,
358            list_item_empty_lines: false,
359            strict: false,
360        });
361
362        // When br_spaces < 2, it should behave like br_spaces = 0
363        #[rustfmt::skip]
364        let input = "Single trailing space ";
365        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
366        let violations = linter.analyze();
367        assert_eq!(1, violations.len()); // Should violate
368    }
369
370    #[test]
371    fn test_indented_code_block_excluded() {
372        #[rustfmt::skip]
373        let input = "    This is an indented code block with trailing spaces  ";
374
375        let config = test_config();
376        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
377        let violations = linter.analyze();
378        assert_eq!(0, violations.len()); // Code blocks should be excluded
379    }
380
381    #[test]
382    fn test_fenced_code_block_excluded() {
383        #[rustfmt::skip]
384        let input = r#"```rust
385fn main() {
386    println!("Hello");  
387}
388```"#;
389
390        let config = test_config();
391        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
392        let violations = linter.analyze();
393        assert_eq!(0, violations.len()); // Fenced code blocks should be excluded
394    }
395
396    #[test]
397    fn test_list_item_empty_lines() {
398        let config = test_config_with_trailing_spaces(MD009TrailingSpacesTable {
399            br_spaces: 2,
400            list_item_empty_lines: true,
401            strict: false,
402        });
403
404        #[rustfmt::skip]
405        let input = r#"- item 1
406 
407  - item 2"#; // Empty line with 1 space in list
408
409        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
410        let violations = linter.analyze();
411        assert_eq!(0, violations.len()); // Should be allowed when list_item_empty_lines = true
412    }
413
414    #[test]
415    fn test_list_item_empty_lines_disabled() {
416        let config = test_config(); // Default has list_item_empty_lines = false
417
418        #[rustfmt::skip]
419        let input = r#"- item 1
420 
421  - item 2"#; // Empty line with 1 space in list
422
423        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
424        let violations = linter.analyze();
425        assert_eq!(1, violations.len()); // Should violate when list_item_empty_lines = false
426    }
427
428    #[test]
429    fn test_multiple_lines_mixed() {
430        #[rustfmt::skip]
431        let input = r#"Line without trailing spaces
432Line with single space 
433Line with two spaces  
434Line with three spaces   
435Normal line again"#;
436
437        let config = test_config();
438        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
439        let violations = linter.analyze();
440        assert_eq!(2, violations.len()); // Single space and three spaces should violate
441    }
442
443    #[test]
444    fn test_empty_line_with_spaces() {
445        // Test with 2 spaces (default br_spaces) - should NOT violate
446        #[rustfmt::skip]
447        let input_2_spaces = r#"Line one
448  
449Line three"#; // Middle line has 2 spaces
450
451        let config = test_config();
452        let mut linter = MultiRuleLinter::new_for_document(
453            PathBuf::from("test.md"),
454            config.clone(),
455            input_2_spaces,
456        );
457        let violations = linter.analyze();
458        assert_eq!(0, violations.len()); // 2 spaces should be allowed by default
459
460        // Test with 3 spaces - should violate
461        #[rustfmt::skip]
462        let input_3_spaces = r#"Line one
463   
464Line three"#; // Middle line has 3 spaces
465
466        let mut linter =
467            MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input_3_spaces);
468        let violations = linter.analyze();
469        assert_eq!(1, violations.len()); // 3 spaces should violate
470    }
471
472    #[test]
473    fn test_strict_mode_paragraph_detection_parity() {
474        // This test captures a discrepancy found between quickmark and markdownlint
475        // In strict mode, markdownlint only flags trailing spaces that don't create actual line breaks
476
477        let config = test_config_with_trailing_spaces(MD009TrailingSpacesTable {
478            br_spaces: 2,
479            list_item_empty_lines: false,
480            strict: true,
481        });
482
483        // NOTE: The trailing spaces are significant in this input string.
484        #[rustfmt::skip]
485        let input = r#"This line has no trailing spaces
486This line has two trailing spaces for line break  
487
488Paragraph with proper line break  
489Next line continues the paragraph.
490
491Normal paragraph without any trailing spaces."#;
492
493        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
494        let violations = linter.analyze();
495
496        // Based on markdownlint behavior:
497        // - Line 2: has 2 spaces followed by empty line - creates actual line break, should NOT violate in strict
498        // - Line 4: has 2 spaces followed by continuation - does NOT create line break, SHOULD violate in strict
499        assert_eq!(
500            1,
501            violations.len(),
502            "Expected 1 violation (line 4 only) to match markdownlint behavior"
503        );
504
505        // Verify the violation is on the correct line
506        let line_numbers: Vec<usize> = violations
507            .iter()
508            .map(|v| v.location().range.start.line + 1)
509            .collect();
510
511        // Only line 4 should be reported (trailing spaces that don't create actual line breaks)
512        assert!(
513            line_numbers.contains(&4),
514            "Line 4 should be reported (trailing spaces before paragraph continuation)"
515        );
516        assert!(!line_numbers.contains(&2), "Line 2 should NOT be reported (trailing spaces before empty line create actual line break)");
517    }
518}