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