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