1use crate::filtered_lines::FilteredLinesExt;
2use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
3use crate::utils::hard_break::br_produces_useful_break;
4use crate::utils::range_utils::calculate_trailing_range;
5use crate::utils::regex_cache::{ORDERED_LIST_MARKER_REGEX, UNORDERED_LIST_MARKER_REGEX};
6
7mod md009_config;
8use md009_config::MD009Config;
9
10#[derive(Debug, Clone, Default)]
11pub struct MD009TrailingSpaces {
12 config: MD009Config,
13}
14
15impl MD009TrailingSpaces {
16 pub fn new(br_spaces: usize, strict: bool) -> Self {
17 Self {
18 config: MD009Config {
19 br_spaces: crate::types::BrSpaces::new(br_spaces),
20 strict,
21 list_item_empty_lines: false,
22 },
23 }
24 }
25
26 pub const fn from_config_struct(config: MD009Config) -> Self {
27 Self { config }
28 }
29
30 fn count_trailing_spaces(line: &str) -> usize {
31 line.chars().rev().take_while(|&c| c == ' ').count()
32 }
33
34 fn count_trailing_spaces_ascii(line: &str) -> usize {
35 line.as_bytes().iter().rev().take_while(|&&b| b == b' ').count()
36 }
37
38 fn count_trailing_whitespace(line: &str) -> usize {
41 line.chars().rev().take_while(|c| c.is_whitespace()).count()
42 }
43
44 fn trimmed_len_ascii_whitespace(line: &str) -> usize {
45 line.as_bytes()
46 .iter()
47 .rposition(|b| !b.is_ascii_whitespace())
48 .map_or(0, |idx| idx + 1)
49 }
50
51 fn calculate_trailing_range_ascii(
52 line: usize,
53 line_len: usize,
54 content_end: usize,
55 ) -> (usize, usize, usize, usize) {
56 (line, content_end + 1, line, line_len + 1)
58 }
59
60 fn is_empty_list_item_line(line: &str, prev_line: Option<&str>) -> bool {
61 if !line.trim().is_empty() {
65 return false;
66 }
67
68 if let Some(prev) = prev_line {
69 UNORDERED_LIST_MARKER_REGEX.is_match(prev) || ORDERED_LIST_MARKER_REGEX.is_match(prev)
71 } else {
72 false
73 }
74 }
75}
76
77impl Rule for MD009TrailingSpaces {
78 fn name(&self) -> &'static str {
79 "MD009"
80 }
81
82 fn description(&self) -> &'static str {
83 "Trailing spaces should be removed"
84 }
85
86 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
87 let content = ctx.content;
88
89 let mut warnings = Vec::new();
90
91 let lines = ctx.raw_lines();
93
94 let mut filtered = ctx.filtered_lines().skip_front_matter().skip_pymdown_blocks();
95 if !self.config.strict {
96 filtered = filtered.skip_code_blocks();
97 }
98
99 for filtered_line in filtered {
100 let line_num = filtered_line.line_num - 1;
101 let line = filtered_line.content;
102
103 let line_is_ascii = line.is_ascii();
104 let trailing_ascii_spaces = if line_is_ascii {
106 Self::count_trailing_spaces_ascii(line)
107 } else {
108 Self::count_trailing_spaces(line)
109 };
110 let trailing_all_whitespace = if line_is_ascii {
113 trailing_ascii_spaces
114 } else {
115 Self::count_trailing_whitespace(line)
116 };
117
118 if trailing_all_whitespace == 0 {
120 continue;
121 }
122
123 let trimmed_len = if line_is_ascii {
125 Self::trimmed_len_ascii_whitespace(line)
126 } else {
127 line.trim_end().len()
128 };
129 if trimmed_len == 0 {
130 if trailing_all_whitespace > 0 {
131 let prev_line = if line_num > 0 { Some(lines[line_num - 1]) } else { None };
133 if self.config.list_item_empty_lines && Self::is_empty_list_item_line(line, prev_line) {
134 continue;
135 }
136
137 let (start_line, start_col, end_line, end_col) = if line_is_ascii {
139 Self::calculate_trailing_range_ascii(line_num + 1, line.len(), 0)
140 } else {
141 calculate_trailing_range(line_num + 1, line, 0)
142 };
143 let line_start = *ctx.line_offsets.get(line_num).unwrap_or(&0);
144 let fix_range = if line_is_ascii {
145 line_start..line_start + line.len()
146 } else {
147 ctx.line_column_byte_range_with_length(line_num + 1, 1, line.chars().count())
148 };
149
150 warnings.push(LintWarning {
151 rule_name: Some(self.name().to_string()),
152 line: start_line,
153 column: start_col,
154 end_line,
155 end_column: end_col,
156 message: "Empty line has trailing spaces".to_string(),
157 severity: Severity::Warning,
158 fix: Some(Fix::new(fix_range, String::new())),
159 });
160 }
161 continue;
162 }
163
164 let is_truly_last_line = line_num == lines.len() - 1 && !content.ends_with('\n');
174 let has_only_ascii_trailing = trailing_ascii_spaces == trailing_all_whitespace;
175 let matches_br_spaces = self.config.br_spaces.line_break() == Some(trailing_ascii_spaces);
176 if !is_truly_last_line && has_only_ascii_trailing && matches_br_spaces {
177 let allow = if self.config.strict {
178 br_produces_useful_break(ctx, line_num)
179 } else {
180 true
181 };
182 if allow {
183 continue;
184 }
185 }
186
187 let trimmed = if line_is_ascii {
190 &line[..trimmed_len]
191 } else {
192 line.trim_end()
193 };
194 let is_empty_blockquote_with_space = trimmed.chars().all(|c| c == '>' || c == ' ' || c == '\t')
195 && trimmed.contains('>')
196 && has_only_ascii_trailing
197 && trailing_ascii_spaces == 1;
198
199 if is_empty_blockquote_with_space {
200 continue; }
202 let (start_line, start_col, end_line, end_col) = if line_is_ascii {
204 Self::calculate_trailing_range_ascii(line_num + 1, line.len(), trimmed.len())
205 } else {
206 calculate_trailing_range(line_num + 1, line, trimmed.len())
207 };
208 let line_start = *ctx.line_offsets.get(line_num).unwrap_or(&0);
209 let fix_range = if line_is_ascii {
210 let start = line_start + trimmed.len();
211 let end = start + trailing_all_whitespace;
212 start..end
213 } else {
214 ctx.line_column_byte_range_with_length(
215 line_num + 1,
216 trimmed.chars().count() + 1,
217 trailing_all_whitespace,
218 )
219 };
220
221 warnings.push(LintWarning {
222 rule_name: Some(self.name().to_string()),
223 line: start_line,
224 column: start_col,
225 end_line,
226 end_column: end_col,
227 message: if trailing_all_whitespace == 1 {
228 "Trailing space found".to_string()
229 } else {
230 format!("{trailing_all_whitespace} trailing spaces found")
231 },
232 severity: Severity::Warning,
233 fix: Some(Fix::new(fix_range, String::new())),
234 });
235 }
236
237 Ok(warnings)
238 }
239
240 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
241 if self.should_skip(ctx) {
242 return Ok(ctx.content.to_string());
243 }
244 let warnings = self.check(ctx)?;
245 if warnings.is_empty() {
246 return Ok(ctx.content.to_string());
247 }
248 let warnings =
249 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
250 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings).map_err(LintError::InvalidInput)
251 }
252
253 fn as_any(&self) -> &dyn std::any::Any {
254 self
255 }
256
257 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
258 ctx.content.is_empty()
263 }
264
265 fn category(&self) -> RuleCategory {
266 RuleCategory::Whitespace
267 }
268
269 crate::impl_rule_config_methods!(MD009Config);
270}
271
272#[cfg(test)]
273mod tests {
274 use super::*;
275 use crate::lint_context::LintContext;
276 use crate::rule::Rule;
277
278 #[test]
279 fn test_no_trailing_spaces() {
280 let rule = MD009TrailingSpaces::default();
281 let content = "This is a line\nAnother line\nNo trailing spaces";
282 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
283 let result = rule.check(&ctx).unwrap();
284 assert!(result.is_empty());
285 }
286
287 #[test]
288 fn test_basic_trailing_spaces() {
289 let rule = MD009TrailingSpaces::default();
290 let content = "Line with spaces \nAnother line \nClean line";
291 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
292 let result = rule.check(&ctx).unwrap();
293 assert_eq!(result.len(), 1);
295 assert_eq!(result[0].line, 1);
296 assert_eq!(result[0].message, "3 trailing spaces found");
297 }
298
299 #[test]
300 fn test_md009_front_matter() {
301 let rule = MD009TrailingSpaces::default();
302 let content = "---\ntitle: Test \n---\nBody ";
303 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
304 let result = rule.check(&ctx).unwrap();
305 assert_eq!(result.len(), 1);
307 assert_eq!(result[0].line, 4);
308 }
309
310 #[test]
311 fn test_fix_basic_trailing_spaces() {
312 let rule = MD009TrailingSpaces::default();
313 let content = "Line with spaces \nAnother line \nClean line";
314 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
315 let fixed = rule.fix(&ctx).unwrap();
316 assert_eq!(fixed, "Line with spaces\nAnother line \nClean line");
320 }
321
322 #[test]
323 fn test_strict_mode() {
324 let rule = MD009TrailingSpaces::new(2, true);
325 let content = "Line with spaces \nCode block: \n``` \nCode with spaces \n``` ";
333 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
334 let result = rule.check(&ctx).unwrap();
335 let lines_flagged: Vec<usize> = result.iter().map(|w| w.line).collect();
336 assert_eq!(lines_flagged, vec![2, 3, 4, 5], "got: {result:?}");
337
338 let fixed = rule.fix(&ctx).unwrap();
339 assert_eq!(fixed, "Line with spaces \nCode block:\n```\nCode with spaces\n```");
340 }
341
342 #[test]
343 fn test_strict_mode_allows_br_spaces_on_paragraph_lines() {
344 let rule = MD009TrailingSpaces::new(2, true);
351 let content = "> Note: \n> This is in a new line due to 2 spaces behind \"Note:\".\n";
352 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
353 let result = rule.check(&ctx).unwrap();
354 assert!(
355 result.is_empty(),
356 "strict mode should allow br_spaces on paragraph-context lines, got: {result:?}"
357 );
358
359 let fixed = rule.fix(&ctx).unwrap();
361 assert_eq!(fixed, content);
362 }
363
364 #[test]
365 fn test_strict_mode_flags_br_spaces_on_heading() {
366 let rule = MD009TrailingSpaces::new(2, true);
368 let content = "# Heading \nFollow-up paragraph.\n";
369 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
370 let result = rule.check(&ctx).unwrap();
371 assert_eq!(result.len(), 1, "strict should flag heading br_spaces, got: {result:?}");
372 assert_eq!(result[0].line, 1);
373 }
374
375 #[test]
376 fn test_strict_mode_flags_br_spaces_on_last_paragraph_line() {
377 let rule = MD009TrailingSpaces::new(2, true);
381 let content = "Paragraph \n\nNext paragraph.\n";
382 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
383 let result = rule.check(&ctx).unwrap();
384 assert_eq!(
385 result.iter().map(|w| w.line).collect::<Vec<_>>(),
386 vec![1],
387 "strict should flag br_spaces on a single-line paragraph, got: {result:?}"
388 );
389 }
390
391 #[test]
392 fn test_strict_mode_flags_br_spaces_between_list_items() {
393 let rule = MD009TrailingSpaces::new(2, true);
396 let content = "- item 1 \n- item 2\n";
397 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
398 let result = rule.check(&ctx).unwrap();
399 assert_eq!(
400 result.iter().map(|w| w.line).collect::<Vec<_>>(),
401 vec![1],
402 "strict should flag br_spaces at the end of a list item, got: {result:?}"
403 );
404 }
405
406 #[test]
407 fn test_strict_mode_allows_br_spaces_in_list_item_continuation() {
408 let rule = MD009TrailingSpaces::new(2, true);
411 let content = "- first line \n second line of same item\n";
412 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
413 let result = rule.check(&ctx).unwrap();
414 assert!(
415 result.is_empty(),
416 "strict should allow br_spaces between a list item and its continuation, got: {result:?}"
417 );
418 }
419
420 #[test]
421 fn test_strict_mode_flags_br_spaces_before_heading() {
422 let rule = MD009TrailingSpaces::new(2, true);
425 let content = "Paragraph \n# Heading\n";
426 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
427 let result = rule.check(&ctx).unwrap();
428 assert_eq!(
429 result.iter().map(|w| w.line).collect::<Vec<_>>(),
430 vec![1],
431 "strict should flag br_spaces on the line before a heading, got: {result:?}"
432 );
433 }
434
435 #[test]
436 fn test_strict_mode_flags_br_spaces_on_setext_heading_text() {
437 let rule = MD009TrailingSpaces::new(2, true);
440 let content = "Setext heading \n===\n\nFollow-up paragraph.\n";
441 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
442 let result = rule.check(&ctx).unwrap();
443 assert_eq!(
444 result.iter().map(|w| w.line).collect::<Vec<_>>(),
445 vec![1],
446 "strict should flag setext heading text trailing spaces, got: {result:?}"
447 );
448 }
449
450 #[test]
451 fn test_strict_mode_flags_br_spaces_on_setext_underline() {
452 let rule = MD009TrailingSpaces::new(2, true);
456 let content = "Setext heading\n=== \n\nFollow-up paragraph.\n";
457 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
458 let result = rule.check(&ctx).unwrap();
459 assert_eq!(
460 result.iter().map(|w| w.line).collect::<Vec<_>>(),
461 vec![2],
462 "strict should flag setext underline trailing spaces, got: {result:?}"
463 );
464 }
465
466 #[test]
467 fn test_strict_mode_flags_br_spaces_in_indented_code_block() {
468 let rule = MD009TrailingSpaces::new(2, true);
472 let content = "Paragraph above.\n\n code line \n another code \n\nParagraph below.\n";
473 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
474 let result = rule.check(&ctx).unwrap();
475 assert_eq!(
476 result.iter().map(|w| w.line).collect::<Vec<_>>(),
477 vec![3, 4],
478 "strict should flag indented code block trailing spaces, got: {result:?}"
479 );
480 }
481
482 #[test]
483 fn test_strict_mode_allows_br_spaces_in_table_row() {
484 let rule = MD009TrailingSpaces::new(2, true);
489 let content = "| col |\n| --- |\n| cell |\n\nParagraph.\n";
490 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
491 let result = rule.check(&ctx).unwrap();
492 assert!(
493 result.is_empty(),
494 "rows that don't actually have trailing whitespace shouldn't trigger MD009, got: {result:?}"
495 );
496 }
497
498 #[test]
499 fn test_non_strict_mode_with_code_blocks() {
500 let rule = MD009TrailingSpaces::new(2, false);
501 let content = "Line with spaces \n```\nCode with spaces \n```\nOutside code ";
502 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
503 let result = rule.check(&ctx).unwrap();
504 assert_eq!(result.len(), 1);
508 assert_eq!(result[0].line, 5);
509 }
510
511 #[test]
512 fn test_br_spaces_preservation() {
513 let rule = MD009TrailingSpaces::new(2, false);
514 let content = "Line with two spaces \nLine with three spaces \nLine with one space ";
515 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
516 let result = rule.check(&ctx).unwrap();
517 assert_eq!(result.len(), 2);
521 assert_eq!(result[0].line, 2);
522 assert_eq!(result[1].line, 3);
523
524 let fixed = rule.fix(&ctx).unwrap();
525 assert_eq!(
529 fixed,
530 "Line with two spaces \nLine with three spaces\nLine with one space"
531 );
532 }
533
534 #[test]
535 fn test_empty_lines_with_spaces() {
536 let rule = MD009TrailingSpaces::default();
537 let content = "Normal line\n \n \nAnother line";
538 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
539 let result = rule.check(&ctx).unwrap();
540 assert_eq!(result.len(), 2);
541 assert_eq!(result[0].message, "Empty line has trailing spaces");
542 assert_eq!(result[1].message, "Empty line has trailing spaces");
543
544 let fixed = rule.fix(&ctx).unwrap();
545 assert_eq!(fixed, "Normal line\n\n\nAnother line");
546 }
547
548 #[test]
549 fn test_empty_blockquote_lines() {
550 let rule = MD009TrailingSpaces::default();
551 let content = "> Quote\n> \n> More quote";
552 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
553 let result = rule.check(&ctx).unwrap();
554 assert_eq!(result.len(), 1);
555 assert_eq!(result[0].line, 2);
556 assert_eq!(result[0].message, "3 trailing spaces found");
557
558 let fixed = rule.fix(&ctx).unwrap();
559 assert_eq!(fixed, "> Quote\n>\n> More quote"); }
561
562 #[test]
563 fn test_last_line_handling() {
564 let rule = MD009TrailingSpaces::new(2, false);
565
566 let content = "First line \nLast line ";
568 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
569 let result = rule.check(&ctx).unwrap();
570 assert_eq!(result.len(), 1);
572 assert_eq!(result[0].line, 2);
573
574 let fixed = rule.fix(&ctx).unwrap();
575 assert_eq!(fixed, "First line \nLast line");
576
577 let content_with_newline = "First line \nLast line \n";
579 let ctx = LintContext::new(content_with_newline, crate::config::MarkdownFlavor::Standard, None);
580 let result = rule.check(&ctx).unwrap();
581 assert!(result.is_empty());
583 }
584
585 #[test]
586 fn test_single_trailing_space() {
587 let rule = MD009TrailingSpaces::new(2, false);
588 let content = "Line with one space ";
589 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
590 let result = rule.check(&ctx).unwrap();
591 assert_eq!(result.len(), 1);
592 assert_eq!(result[0].message, "Trailing space found");
593 }
594
595 #[test]
596 fn test_tabs_not_spaces() {
597 let rule = MD009TrailingSpaces::default();
598 let content = "Line with tab\t\nLine with spaces ";
599 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
600 let result = rule.check(&ctx).unwrap();
601 assert_eq!(result.len(), 1);
603 assert_eq!(result[0].line, 2);
604 }
605
606 #[test]
607 fn test_mixed_content() {
608 let rule = MD009TrailingSpaces::new(2, false);
609 let mut content = String::new();
611 content.push_str("# Heading");
612 content.push_str(" "); content.push('\n');
614 content.push_str("Normal paragraph\n> Blockquote\n>\n```\nCode block\n```\n- List item\n");
615
616 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
617 let result = rule.check(&ctx).unwrap();
618 assert_eq!(result.len(), 1);
620 assert_eq!(result[0].line, 1);
621 assert!(result[0].message.contains("trailing spaces"));
622 }
623
624 #[test]
625 fn test_column_positions() {
626 let rule = MD009TrailingSpaces::default();
627 let content = "Text ";
628 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
629 let result = rule.check(&ctx).unwrap();
630 assert_eq!(result.len(), 1);
631 assert_eq!(result[0].column, 5); assert_eq!(result[0].end_column, 8); }
634
635 #[test]
636 fn test_default_config() {
637 let rule = MD009TrailingSpaces::default();
638 let config = rule.default_config_section();
639 assert!(config.is_some());
640 let (name, _value) = config.unwrap();
641 assert_eq!(name, "MD009");
642 }
643
644 #[test]
645 fn test_from_config() {
646 let mut config = crate::config::Config::default();
647 let mut rule_config = crate::config::RuleConfig::default();
648 rule_config
649 .values
650 .insert("br_spaces".to_string(), toml::Value::Integer(3));
651 rule_config
652 .values
653 .insert("strict".to_string(), toml::Value::Boolean(true));
654 config.rules.insert("MD009".to_string(), rule_config);
655
656 let rule = MD009TrailingSpaces::from_config(&config);
657 let content = "Line ";
658 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
659 let result = rule.check(&ctx).unwrap();
660 assert_eq!(result.len(), 1);
661
662 let fixed = rule.fix(&ctx).unwrap();
664 assert_eq!(fixed, "Line");
665 }
666
667 #[test]
668 fn test_list_item_empty_lines() {
669 let config = MD009Config {
671 list_item_empty_lines: true,
672 ..Default::default()
673 };
674 let rule = MD009TrailingSpaces::from_config_struct(config);
675
676 let content = "- First item\n \n- Second item";
678 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
679 let result = rule.check(&ctx).unwrap();
680 assert!(result.is_empty());
682
683 let content = "1. First item\n \n2. Second item";
685 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
686 let result = rule.check(&ctx).unwrap();
687 assert!(result.is_empty());
688
689 let content = "Normal paragraph\n \nAnother paragraph";
691 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
692 let result = rule.check(&ctx).unwrap();
693 assert_eq!(result.len(), 1);
694 assert_eq!(result[0].line, 2);
695 }
696
697 #[test]
698 fn test_list_item_empty_lines_disabled() {
699 let rule = MD009TrailingSpaces::default();
701
702 let content = "- First item\n \n- Second item";
703 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
704 let result = rule.check(&ctx).unwrap();
705 assert_eq!(result.len(), 1);
707 assert_eq!(result[0].line, 2);
708 }
709
710 #[test]
711 fn test_performance_large_document() {
712 let rule = MD009TrailingSpaces::default();
713 let mut content = String::new();
714 for i in 0..1000 {
715 content.push_str(&format!("Line {i} with spaces \n"));
716 }
717 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
718 let result = rule.check(&ctx).unwrap();
719 assert_eq!(result.len(), 0);
721 }
722
723 #[test]
724 fn test_preserve_content_after_fix() {
725 let rule = MD009TrailingSpaces::new(2, false);
726 let content = "**Bold** text \n*Italic* text \n[Link](url) ";
727 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
728 let fixed = rule.fix(&ctx).unwrap();
729 assert_eq!(fixed, "**Bold** text \n*Italic* text \n[Link](url)");
730 }
731
732 #[test]
733 fn test_nested_blockquotes() {
734 let rule = MD009TrailingSpaces::default();
735 let content = "> > Nested \n> > \n> Normal ";
736 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
737 let result = rule.check(&ctx).unwrap();
738 assert_eq!(result.len(), 2);
740 assert_eq!(result[0].line, 2);
741 assert_eq!(result[1].line, 3);
742
743 let fixed = rule.fix(&ctx).unwrap();
744 assert_eq!(fixed, "> > Nested \n> >\n> Normal");
748 }
749
750 #[test]
751 fn test_normalized_line_endings() {
752 let rule = MD009TrailingSpaces::default();
753 let content = "Line with spaces \nAnother line ";
755 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
756 let result = rule.check(&ctx).unwrap();
757 assert_eq!(result.len(), 1);
760 assert_eq!(result[0].line, 2);
761 }
762
763 #[test]
764 fn test_issue_80_no_space_normalization() {
765 let rule = MD009TrailingSpaces::new(2, false); let content = "Line with one space \nNext line";
770 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
771 let result = rule.check(&ctx).unwrap();
772 assert_eq!(result.len(), 1);
773 assert_eq!(result[0].line, 1);
774 assert_eq!(result[0].message, "Trailing space found");
775
776 let fixed = rule.fix(&ctx).unwrap();
777 assert_eq!(fixed, "Line with one space\nNext line");
778
779 let content = "Line with three spaces \nNext line";
781 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
782 let result = rule.check(&ctx).unwrap();
783 assert_eq!(result.len(), 1);
784 assert_eq!(result[0].line, 1);
785 assert_eq!(result[0].message, "3 trailing spaces found");
786
787 let fixed = rule.fix(&ctx).unwrap();
788 assert_eq!(fixed, "Line with three spaces\nNext line");
789
790 let content = "Line with two spaces \nNext line";
792 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
793 let result = rule.check(&ctx).unwrap();
794 assert_eq!(result.len(), 0); let fixed = rule.fix(&ctx).unwrap();
797 assert_eq!(fixed, "Line with two spaces \nNext line");
798 }
799
800 #[test]
801 fn test_unicode_whitespace_idempotent_fix() {
802 let rule = MD009TrailingSpaces::default(); let content = "> 0\u{2000} ";
808 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
809 let result = rule.check(&ctx).unwrap();
810 assert_eq!(result.len(), 1, "Should detect trailing Unicode+ASCII whitespace");
811
812 let fixed = rule.fix(&ctx).unwrap();
813 assert_eq!(fixed, "> 0", "Should strip all trailing whitespace in one pass");
814
815 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
817 let fixed2 = rule.fix(&ctx2).unwrap();
818 assert_eq!(fixed, fixed2, "Fix must be idempotent");
819 }
820
821 #[test]
822 fn test_unicode_whitespace_variants() {
823 let rule = MD009TrailingSpaces::default();
824
825 let content = "text\u{2000}\n";
827 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
828 let result = rule.check(&ctx).unwrap();
829 assert_eq!(result.len(), 1);
830 let fixed = rule.fix(&ctx).unwrap();
831 assert_eq!(fixed, "text\n");
832
833 let content = "text\u{2001}\n";
835 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
836 let result = rule.check(&ctx).unwrap();
837 assert_eq!(result.len(), 1);
838 let fixed = rule.fix(&ctx).unwrap();
839 assert_eq!(fixed, "text\n");
840
841 let content = "text\u{3000}\n";
843 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
844 let result = rule.check(&ctx).unwrap();
845 assert_eq!(result.len(), 1);
846 let fixed = rule.fix(&ctx).unwrap();
847 assert_eq!(fixed, "text\n");
848
849 let content = "text\u{2000} \n";
853 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
854 let result = rule.check(&ctx).unwrap();
855 assert_eq!(result.len(), 1, "Unicode+ASCII mix should be flagged");
856 let fixed = rule.fix(&ctx).unwrap();
857 assert_eq!(
858 fixed, "text\n",
859 "All trailing whitespace should be stripped when mix includes Unicode"
860 );
861 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
863 let fixed2 = rule.fix(&ctx2).unwrap();
864 assert_eq!(fixed, fixed2, "Fix must be idempotent");
865
866 let content = "text \nnext\n";
868 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
869 let result = rule.check(&ctx).unwrap();
870 assert_eq!(result.len(), 0, "Pure ASCII br_spaces should still be preserved");
871 }
872
873 #[test]
874 fn test_unicode_whitespace_strict_mode() {
875 let rule = MD009TrailingSpaces::new(2, true);
876
877 let content = "text\u{2000}\nmore\u{3000}\n";
879 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
880 let fixed = rule.fix(&ctx).unwrap();
881 assert_eq!(fixed, "text\nmore\n");
882 }
883
884 fn assert_fix_roundtrip(rule: &MD009TrailingSpaces, content: &str) {
886 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
887 let fixed = rule.fix(&ctx).unwrap();
888 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
889 let remaining = rule.check(&ctx2).unwrap();
890 assert!(
891 remaining.is_empty(),
892 "After fix(), check() should find 0 violations.\nOriginal: {content:?}\nFixed: {fixed:?}\nRemaining: {remaining:?}"
893 );
894 }
895
896 #[test]
897 fn test_roundtrip_basic_trailing_spaces() {
898 let rule = MD009TrailingSpaces::default();
899 assert_fix_roundtrip(&rule, "Line with spaces \nAnother line \nClean line");
900 }
901
902 #[test]
903 fn test_roundtrip_strict_mode() {
904 let rule = MD009TrailingSpaces::new(2, true);
905 assert_fix_roundtrip(
906 &rule,
907 "Line with spaces \nCode block: \n``` \nCode with spaces \n``` ",
908 );
909 }
910
911 #[test]
912 fn test_roundtrip_empty_lines() {
913 let rule = MD009TrailingSpaces::default();
914 assert_fix_roundtrip(&rule, "Normal line\n \n \nAnother line");
915 }
916
917 #[test]
918 fn test_roundtrip_br_spaces_preservation() {
919 let rule = MD009TrailingSpaces::new(2, false);
920 assert_fix_roundtrip(
921 &rule,
922 "Line with two spaces \nLine with three spaces \nLine with one space ",
923 );
924 }
925
926 #[test]
927 fn test_roundtrip_last_line_no_newline() {
928 let rule = MD009TrailingSpaces::new(2, false);
929 assert_fix_roundtrip(&rule, "First line \nLast line ");
930 }
931
932 #[test]
933 fn test_roundtrip_last_line_with_newline() {
934 let rule = MD009TrailingSpaces::new(2, false);
935 assert_fix_roundtrip(&rule, "First line \nLast line \n");
936 }
937
938 #[test]
939 fn test_roundtrip_unicode_whitespace() {
940 let rule = MD009TrailingSpaces::default();
941 assert_fix_roundtrip(&rule, "> 0\u{2000} ");
942 assert_fix_roundtrip(&rule, "text\u{2000}\n");
943 assert_fix_roundtrip(&rule, "text\u{3000}\n");
944 assert_fix_roundtrip(&rule, "text\u{2000} \n");
945 }
946
947 #[test]
948 fn test_roundtrip_code_blocks_non_strict() {
949 let rule = MD009TrailingSpaces::new(2, false);
950 assert_fix_roundtrip(
951 &rule,
952 "Line with spaces \n```\nCode with spaces \n```\nOutside code ",
953 );
954 }
955
956 #[test]
957 fn test_roundtrip_blockquotes() {
958 let rule = MD009TrailingSpaces::default();
959 assert_fix_roundtrip(&rule, "> Quote\n> \n> More quote");
960 assert_fix_roundtrip(&rule, "> > Nested \n> > \n> Normal ");
961 }
962
963 #[test]
964 fn test_roundtrip_list_item_empty_lines() {
965 let config = MD009Config {
966 list_item_empty_lines: true,
967 ..Default::default()
968 };
969 let rule = MD009TrailingSpaces::from_config_struct(config);
970 assert_fix_roundtrip(&rule, "- First item\n \n- Second item");
971 assert_fix_roundtrip(&rule, "Normal paragraph\n \nAnother paragraph");
972 }
973
974 #[test]
975 fn test_roundtrip_complex_document() {
976 let rule = MD009TrailingSpaces::default();
977 assert_fix_roundtrip(
978 &rule,
979 "# Title \n\nParagraph \n\n- List \n - Nested \n\n```\ncode \n```\n\n> Quote \n> \n\nEnd ",
980 );
981 }
982
983 #[test]
984 fn test_roundtrip_multibyte() {
985 let rule = MD009TrailingSpaces::new(2, true);
986 assert_fix_roundtrip(&rule, "- 1€ expenses \n");
987 assert_fix_roundtrip(&rule, "€100 + €50 = €150 \n");
988 assert_fix_roundtrip(&rule, "Hello 你好世界 \n");
989 assert_fix_roundtrip(&rule, "Party 🎉🎉🎉 \n");
990 assert_fix_roundtrip(&rule, "안녕하세요 \n");
991 }
992
993 #[test]
994 fn test_roundtrip_mixed_tabs_and_spaces() {
995 let rule = MD009TrailingSpaces::default();
996 assert_fix_roundtrip(&rule, "Line with tab\t\nLine with spaces ");
997 assert_fix_roundtrip(&rule, "Line\t \nAnother\n");
998 }
999
1000 #[test]
1001 fn test_roundtrip_heading_with_br_spaces() {
1002 let rule = MD009TrailingSpaces::new(2, false);
1005 let content = "# Heading \nParagraph\n";
1006 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1007 let warnings = rule.check(&ctx).unwrap();
1008 assert!(
1010 warnings.is_empty(),
1011 "check() should not flag heading with exactly br_spaces trailing spaces"
1012 );
1013 assert_fix_roundtrip(&rule, content);
1014 }
1015
1016 #[test]
1017 fn test_fix_replacement_always_removes_trailing_spaces() {
1018 let rule = MD009TrailingSpaces::new(2, false);
1021
1022 let content = "Hello \nWorld\n";
1025 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1026 let result = rule.check(&ctx).unwrap();
1027 assert_eq!(result.len(), 1);
1028
1029 let fix = result[0].fix.as_ref().expect("Should have a fix");
1030 assert_eq!(
1031 fix.replacement, "",
1032 "Fix replacement should always be empty string (remove trailing spaces)"
1033 );
1034
1035 let fixed = rule.fix(&ctx).unwrap();
1037 assert_eq!(fixed, "Hello\nWorld\n");
1038 }
1039}