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}