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#[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
33pub(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    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        let expected_spaces = if settings.br_spaces < 2 {
59            0
60        } else {
61            settings.br_spaces
62        };
63
64        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    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    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    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            if br_spaces >= 2 && trailing_spaces == br_spaces && followed_by_blank_line {
143                return false;
144            }
145            return true;
147        }
148
149        trailing_spaces != expected_spaces
152    }
153
154    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                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        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    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   "; 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()); }
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()); #[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()); }
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        #[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()); }
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        #[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()); }
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()); }
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()); }
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"#; let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
410        let violations = linter.analyze();
411        assert_eq!(0, violations.len()); }
413
414    #[test]
415    fn test_list_item_empty_lines_disabled() {
416        let config = test_config(); #[rustfmt::skip]
419        let input = r#"- item 1
420 
421  - item 2"#; let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
424        let violations = linter.analyze();
425        assert_eq!(1, violations.len()); }
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()); }
442
443    #[test]
444    fn test_empty_line_with_spaces() {
445        #[rustfmt::skip]
447        let input_2_spaces = r#"Line one
448  
449Line three"#; 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()); #[rustfmt::skip]
462        let input_3_spaces = r#"Line one
463   
464Line three"#; 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()); }
471
472    #[test]
473    fn test_strict_mode_paragraph_detection_parity() {
474        let config = test_config_with_trailing_spaces(MD009TrailingSpacesTable {
478            br_spaces: 2,
479            list_item_empty_lines: false,
480            strict: true,
481        });
482
483        #[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        assert_eq!(
500            1,
501            violations.len(),
502            "Expected 1 violation (line 4 only) to match markdownlint behavior"
503        );
504
505        let line_numbers: Vec<usize> = violations
507            .iter()
508            .map(|v| v.location().range.start.line + 1)
509            .collect();
510
511        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}