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 let line_index = &ctx.line_index;
135
136 let mut warnings = Vec::new();
137
138 let lines = ctx.raw_lines();
140
141 let mut filtered = ctx.filtered_lines().skip_front_matter().skip_pymdown_blocks();
142 if !self.config.strict {
143 filtered = filtered.skip_code_blocks();
144 }
145
146 for filtered_line in filtered {
147 let line_num = filtered_line.line_num - 1;
148 let line = filtered_line.content;
149
150 let line_is_ascii = line.is_ascii();
151 let trailing_ascii_spaces = if line_is_ascii {
153 Self::count_trailing_spaces_ascii(line)
154 } else {
155 Self::count_trailing_spaces(line)
156 };
157 let trailing_all_whitespace = if line_is_ascii {
160 trailing_ascii_spaces
161 } else {
162 Self::count_trailing_whitespace(line)
163 };
164
165 if trailing_all_whitespace == 0 {
167 continue;
168 }
169
170 let trimmed_len = if line_is_ascii {
172 Self::trimmed_len_ascii_whitespace(line)
173 } else {
174 line.trim_end().len()
175 };
176 if trimmed_len == 0 {
177 if trailing_all_whitespace > 0 {
178 let prev_line = if line_num > 0 { Some(lines[line_num - 1]) } else { None };
180 if self.config.list_item_empty_lines && Self::is_empty_list_item_line(line, prev_line) {
181 continue;
182 }
183
184 let (start_line, start_col, end_line, end_col) = if line_is_ascii {
186 Self::calculate_trailing_range_ascii(line_num + 1, line.len(), 0)
187 } else {
188 calculate_trailing_range(line_num + 1, line, 0)
189 };
190 let line_start = *ctx.line_offsets.get(line_num).unwrap_or(&0);
191 let fix_range = if line_is_ascii {
192 line_start..line_start + line.len()
193 } else {
194 line_index.line_col_to_byte_range_with_length(line_num + 1, 1, line.chars().count())
195 };
196
197 warnings.push(LintWarning {
198 rule_name: Some(self.name().to_string()),
199 line: start_line,
200 column: start_col,
201 end_line,
202 end_column: end_col,
203 message: "Empty line has trailing spaces".to_string(),
204 severity: Severity::Warning,
205 fix: Some(Fix::new(fix_range, String::new())),
206 });
207 }
208 continue;
209 }
210
211 let is_truly_last_line = line_num == lines.len() - 1 && !content.ends_with('\n');
220 let has_only_ascii_trailing = trailing_ascii_spaces == trailing_all_whitespace;
221 let matches_br_spaces = trailing_ascii_spaces == self.config.br_spaces.get();
222 if !is_truly_last_line && has_only_ascii_trailing && matches_br_spaces {
223 let allow = if self.config.strict {
224 br_produces_useful_break(ctx, line_num)
225 } else {
226 true
227 };
228 if allow {
229 continue;
230 }
231 }
232
233 let trimmed = if line_is_ascii {
236 &line[..trimmed_len]
237 } else {
238 line.trim_end()
239 };
240 let is_empty_blockquote_with_space = trimmed.chars().all(|c| c == '>' || c == ' ' || c == '\t')
241 && trimmed.contains('>')
242 && has_only_ascii_trailing
243 && trailing_ascii_spaces == 1;
244
245 if is_empty_blockquote_with_space {
246 continue; }
248 let (start_line, start_col, end_line, end_col) = if line_is_ascii {
250 Self::calculate_trailing_range_ascii(line_num + 1, line.len(), trimmed.len())
251 } else {
252 calculate_trailing_range(line_num + 1, line, trimmed.len())
253 };
254 let line_start = *ctx.line_offsets.get(line_num).unwrap_or(&0);
255 let fix_range = if line_is_ascii {
256 let start = line_start + trimmed.len();
257 let end = start + trailing_all_whitespace;
258 start..end
259 } else {
260 line_index.line_col_to_byte_range_with_length(
261 line_num + 1,
262 trimmed.chars().count() + 1,
263 trailing_all_whitespace,
264 )
265 };
266
267 warnings.push(LintWarning {
268 rule_name: Some(self.name().to_string()),
269 line: start_line,
270 column: start_col,
271 end_line,
272 end_column: end_col,
273 message: if trailing_all_whitespace == 1 {
274 "Trailing space found".to_string()
275 } else {
276 format!("{trailing_all_whitespace} trailing spaces found")
277 },
278 severity: Severity::Warning,
279 fix: Some(Fix::new(fix_range, String::new())),
280 });
281 }
282
283 Ok(warnings)
284 }
285
286 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
287 if self.should_skip(ctx) {
288 return Ok(ctx.content.to_string());
289 }
290 let warnings = self.check(ctx)?;
291 if warnings.is_empty() {
292 return Ok(ctx.content.to_string());
293 }
294 let warnings =
295 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
296 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings).map_err(LintError::InvalidInput)
297 }
298
299 fn as_any(&self) -> &dyn std::any::Any {
300 self
301 }
302
303 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
304 ctx.content.is_empty()
309 }
310
311 fn category(&self) -> RuleCategory {
312 RuleCategory::Whitespace
313 }
314
315 crate::impl_rule_config_methods!(MD009Config);
316}
317
318#[cfg(test)]
319mod tests {
320 use super::*;
321 use crate::lint_context::LintContext;
322 use crate::rule::Rule;
323
324 #[test]
325 fn test_no_trailing_spaces() {
326 let rule = MD009TrailingSpaces::default();
327 let content = "This is a line\nAnother line\nNo trailing spaces";
328 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
329 let result = rule.check(&ctx).unwrap();
330 assert!(result.is_empty());
331 }
332
333 #[test]
334 fn test_basic_trailing_spaces() {
335 let rule = MD009TrailingSpaces::default();
336 let content = "Line with spaces \nAnother line \nClean line";
337 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
338 let result = rule.check(&ctx).unwrap();
339 assert_eq!(result.len(), 1);
341 assert_eq!(result[0].line, 1);
342 assert_eq!(result[0].message, "3 trailing spaces found");
343 }
344
345 #[test]
346 fn test_md009_front_matter() {
347 let rule = MD009TrailingSpaces::default();
348 let content = "---\ntitle: Test \n---\nBody ";
349 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
350 let result = rule.check(&ctx).unwrap();
351 assert_eq!(result.len(), 1);
353 assert_eq!(result[0].line, 4);
354 }
355
356 #[test]
357 fn test_fix_basic_trailing_spaces() {
358 let rule = MD009TrailingSpaces::default();
359 let content = "Line with spaces \nAnother line \nClean line";
360 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
361 let fixed = rule.fix(&ctx).unwrap();
362 assert_eq!(fixed, "Line with spaces\nAnother line \nClean line");
366 }
367
368 #[test]
369 fn test_strict_mode() {
370 let rule = MD009TrailingSpaces::new(2, true);
371 let content = "Line with spaces \nCode block: \n``` \nCode with spaces \n``` ";
379 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
380 let result = rule.check(&ctx).unwrap();
381 let lines_flagged: Vec<usize> = result.iter().map(|w| w.line).collect();
382 assert_eq!(lines_flagged, vec![2, 3, 4, 5], "got: {result:?}");
383
384 let fixed = rule.fix(&ctx).unwrap();
385 assert_eq!(fixed, "Line with spaces \nCode block:\n```\nCode with spaces\n```");
386 }
387
388 #[test]
389 fn test_strict_mode_allows_br_spaces_on_paragraph_lines() {
390 let rule = MD009TrailingSpaces::new(2, true);
397 let content = "> Note: \n> This is in a new line due to 2 spaces behind \"Note:\".\n";
398 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
399 let result = rule.check(&ctx).unwrap();
400 assert!(
401 result.is_empty(),
402 "strict mode should allow br_spaces on paragraph-context lines, got: {result:?}"
403 );
404
405 let fixed = rule.fix(&ctx).unwrap();
407 assert_eq!(fixed, content);
408 }
409
410 #[test]
411 fn test_strict_mode_flags_br_spaces_on_heading() {
412 let rule = MD009TrailingSpaces::new(2, true);
414 let content = "# Heading \nFollow-up paragraph.\n";
415 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
416 let result = rule.check(&ctx).unwrap();
417 assert_eq!(result.len(), 1, "strict should flag heading br_spaces, got: {result:?}");
418 assert_eq!(result[0].line, 1);
419 }
420
421 #[test]
422 fn test_strict_mode_flags_br_spaces_on_last_paragraph_line() {
423 let rule = MD009TrailingSpaces::new(2, true);
427 let content = "Paragraph \n\nNext paragraph.\n";
428 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
429 let result = rule.check(&ctx).unwrap();
430 assert_eq!(
431 result.iter().map(|w| w.line).collect::<Vec<_>>(),
432 vec![1],
433 "strict should flag br_spaces on a single-line paragraph, got: {result:?}"
434 );
435 }
436
437 #[test]
438 fn test_strict_mode_flags_br_spaces_between_list_items() {
439 let rule = MD009TrailingSpaces::new(2, true);
442 let content = "- item 1 \n- item 2\n";
443 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
444 let result = rule.check(&ctx).unwrap();
445 assert_eq!(
446 result.iter().map(|w| w.line).collect::<Vec<_>>(),
447 vec![1],
448 "strict should flag br_spaces at the end of a list item, got: {result:?}"
449 );
450 }
451
452 #[test]
453 fn test_strict_mode_allows_br_spaces_in_list_item_continuation() {
454 let rule = MD009TrailingSpaces::new(2, true);
457 let content = "- first line \n second line of same item\n";
458 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
459 let result = rule.check(&ctx).unwrap();
460 assert!(
461 result.is_empty(),
462 "strict should allow br_spaces between a list item and its continuation, got: {result:?}"
463 );
464 }
465
466 #[test]
467 fn test_strict_mode_flags_br_spaces_before_heading() {
468 let rule = MD009TrailingSpaces::new(2, true);
471 let content = "Paragraph \n# Heading\n";
472 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
473 let result = rule.check(&ctx).unwrap();
474 assert_eq!(
475 result.iter().map(|w| w.line).collect::<Vec<_>>(),
476 vec![1],
477 "strict should flag br_spaces on the line before a heading, got: {result:?}"
478 );
479 }
480
481 #[test]
482 fn test_strict_mode_flags_br_spaces_on_setext_heading_text() {
483 let rule = MD009TrailingSpaces::new(2, true);
486 let content = "Setext heading \n===\n\nFollow-up paragraph.\n";
487 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
488 let result = rule.check(&ctx).unwrap();
489 assert_eq!(
490 result.iter().map(|w| w.line).collect::<Vec<_>>(),
491 vec![1],
492 "strict should flag setext heading text trailing spaces, got: {result:?}"
493 );
494 }
495
496 #[test]
497 fn test_strict_mode_flags_br_spaces_on_setext_underline() {
498 let rule = MD009TrailingSpaces::new(2, true);
502 let content = "Setext heading\n=== \n\nFollow-up paragraph.\n";
503 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
504 let result = rule.check(&ctx).unwrap();
505 assert_eq!(
506 result.iter().map(|w| w.line).collect::<Vec<_>>(),
507 vec![2],
508 "strict should flag setext underline trailing spaces, got: {result:?}"
509 );
510 }
511
512 #[test]
513 fn test_strict_mode_flags_br_spaces_in_indented_code_block() {
514 let rule = MD009TrailingSpaces::new(2, true);
518 let content = "Paragraph above.\n\n code line \n another code \n\nParagraph below.\n";
519 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
520 let result = rule.check(&ctx).unwrap();
521 assert_eq!(
522 result.iter().map(|w| w.line).collect::<Vec<_>>(),
523 vec![3, 4],
524 "strict should flag indented code block trailing spaces, got: {result:?}"
525 );
526 }
527
528 #[test]
529 fn test_strict_mode_allows_br_spaces_in_table_row() {
530 let rule = MD009TrailingSpaces::new(2, true);
535 let content = "| col |\n| --- |\n| cell |\n\nParagraph.\n";
536 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
537 let result = rule.check(&ctx).unwrap();
538 assert!(
539 result.is_empty(),
540 "rows that don't actually have trailing whitespace shouldn't trigger MD009, got: {result:?}"
541 );
542 }
543
544 #[test]
545 fn test_non_strict_mode_with_code_blocks() {
546 let rule = MD009TrailingSpaces::new(2, false);
547 let content = "Line with spaces \n```\nCode with spaces \n```\nOutside code ";
548 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
549 let result = rule.check(&ctx).unwrap();
550 assert_eq!(result.len(), 1);
554 assert_eq!(result[0].line, 5);
555 }
556
557 #[test]
558 fn test_br_spaces_preservation() {
559 let rule = MD009TrailingSpaces::new(2, false);
560 let content = "Line with two spaces \nLine with three spaces \nLine with one space ";
561 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
562 let result = rule.check(&ctx).unwrap();
563 assert_eq!(result.len(), 2);
567 assert_eq!(result[0].line, 2);
568 assert_eq!(result[1].line, 3);
569
570 let fixed = rule.fix(&ctx).unwrap();
571 assert_eq!(
575 fixed,
576 "Line with two spaces \nLine with three spaces\nLine with one space"
577 );
578 }
579
580 #[test]
581 fn test_empty_lines_with_spaces() {
582 let rule = MD009TrailingSpaces::default();
583 let content = "Normal line\n \n \nAnother line";
584 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
585 let result = rule.check(&ctx).unwrap();
586 assert_eq!(result.len(), 2);
587 assert_eq!(result[0].message, "Empty line has trailing spaces");
588 assert_eq!(result[1].message, "Empty line has trailing spaces");
589
590 let fixed = rule.fix(&ctx).unwrap();
591 assert_eq!(fixed, "Normal line\n\n\nAnother line");
592 }
593
594 #[test]
595 fn test_empty_blockquote_lines() {
596 let rule = MD009TrailingSpaces::default();
597 let content = "> Quote\n> \n> More quote";
598 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
599 let result = rule.check(&ctx).unwrap();
600 assert_eq!(result.len(), 1);
601 assert_eq!(result[0].line, 2);
602 assert_eq!(result[0].message, "3 trailing spaces found");
603
604 let fixed = rule.fix(&ctx).unwrap();
605 assert_eq!(fixed, "> Quote\n>\n> More quote"); }
607
608 #[test]
609 fn test_last_line_handling() {
610 let rule = MD009TrailingSpaces::new(2, false);
611
612 let content = "First line \nLast line ";
614 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
615 let result = rule.check(&ctx).unwrap();
616 assert_eq!(result.len(), 1);
618 assert_eq!(result[0].line, 2);
619
620 let fixed = rule.fix(&ctx).unwrap();
621 assert_eq!(fixed, "First line \nLast line");
622
623 let content_with_newline = "First line \nLast line \n";
625 let ctx = LintContext::new(content_with_newline, crate::config::MarkdownFlavor::Standard, None);
626 let result = rule.check(&ctx).unwrap();
627 assert!(result.is_empty());
629 }
630
631 #[test]
632 fn test_single_trailing_space() {
633 let rule = MD009TrailingSpaces::new(2, false);
634 let content = "Line with one space ";
635 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
636 let result = rule.check(&ctx).unwrap();
637 assert_eq!(result.len(), 1);
638 assert_eq!(result[0].message, "Trailing space found");
639 }
640
641 #[test]
642 fn test_tabs_not_spaces() {
643 let rule = MD009TrailingSpaces::default();
644 let content = "Line with tab\t\nLine with spaces ";
645 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
646 let result = rule.check(&ctx).unwrap();
647 assert_eq!(result.len(), 1);
649 assert_eq!(result[0].line, 2);
650 }
651
652 #[test]
653 fn test_mixed_content() {
654 let rule = MD009TrailingSpaces::new(2, false);
655 let mut content = String::new();
657 content.push_str("# Heading");
658 content.push_str(" "); content.push('\n');
660 content.push_str("Normal paragraph\n> Blockquote\n>\n```\nCode block\n```\n- List item\n");
661
662 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
663 let result = rule.check(&ctx).unwrap();
664 assert_eq!(result.len(), 1);
666 assert_eq!(result[0].line, 1);
667 assert!(result[0].message.contains("trailing spaces"));
668 }
669
670 #[test]
671 fn test_column_positions() {
672 let rule = MD009TrailingSpaces::default();
673 let content = "Text ";
674 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
675 let result = rule.check(&ctx).unwrap();
676 assert_eq!(result.len(), 1);
677 assert_eq!(result[0].column, 5); assert_eq!(result[0].end_column, 8); }
680
681 #[test]
682 fn test_default_config() {
683 let rule = MD009TrailingSpaces::default();
684 let config = rule.default_config_section();
685 assert!(config.is_some());
686 let (name, _value) = config.unwrap();
687 assert_eq!(name, "MD009");
688 }
689
690 #[test]
691 fn test_from_config() {
692 let mut config = crate::config::Config::default();
693 let mut rule_config = crate::config::RuleConfig::default();
694 rule_config
695 .values
696 .insert("br_spaces".to_string(), toml::Value::Integer(3));
697 rule_config
698 .values
699 .insert("strict".to_string(), toml::Value::Boolean(true));
700 config.rules.insert("MD009".to_string(), rule_config);
701
702 let rule = MD009TrailingSpaces::from_config(&config);
703 let content = "Line ";
704 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
705 let result = rule.check(&ctx).unwrap();
706 assert_eq!(result.len(), 1);
707
708 let fixed = rule.fix(&ctx).unwrap();
710 assert_eq!(fixed, "Line");
711 }
712
713 #[test]
714 fn test_list_item_empty_lines() {
715 let config = MD009Config {
717 list_item_empty_lines: true,
718 ..Default::default()
719 };
720 let rule = MD009TrailingSpaces::from_config_struct(config);
721
722 let content = "- First item\n \n- Second item";
724 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
725 let result = rule.check(&ctx).unwrap();
726 assert!(result.is_empty());
728
729 let content = "1. First item\n \n2. Second item";
731 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
732 let result = rule.check(&ctx).unwrap();
733 assert!(result.is_empty());
734
735 let content = "Normal paragraph\n \nAnother paragraph";
737 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
738 let result = rule.check(&ctx).unwrap();
739 assert_eq!(result.len(), 1);
740 assert_eq!(result[0].line, 2);
741 }
742
743 #[test]
744 fn test_list_item_empty_lines_disabled() {
745 let rule = MD009TrailingSpaces::default();
747
748 let content = "- First item\n \n- Second item";
749 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
750 let result = rule.check(&ctx).unwrap();
751 assert_eq!(result.len(), 1);
753 assert_eq!(result[0].line, 2);
754 }
755
756 #[test]
757 fn test_performance_large_document() {
758 let rule = MD009TrailingSpaces::default();
759 let mut content = String::new();
760 for i in 0..1000 {
761 content.push_str(&format!("Line {i} with spaces \n"));
762 }
763 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
764 let result = rule.check(&ctx).unwrap();
765 assert_eq!(result.len(), 0);
767 }
768
769 #[test]
770 fn test_preserve_content_after_fix() {
771 let rule = MD009TrailingSpaces::new(2, false);
772 let content = "**Bold** text \n*Italic* text \n[Link](url) ";
773 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
774 let fixed = rule.fix(&ctx).unwrap();
775 assert_eq!(fixed, "**Bold** text \n*Italic* text \n[Link](url)");
776 }
777
778 #[test]
779 fn test_nested_blockquotes() {
780 let rule = MD009TrailingSpaces::default();
781 let content = "> > Nested \n> > \n> Normal ";
782 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
783 let result = rule.check(&ctx).unwrap();
784 assert_eq!(result.len(), 2);
786 assert_eq!(result[0].line, 2);
787 assert_eq!(result[1].line, 3);
788
789 let fixed = rule.fix(&ctx).unwrap();
790 assert_eq!(fixed, "> > Nested \n> >\n> Normal");
794 }
795
796 #[test]
797 fn test_normalized_line_endings() {
798 let rule = MD009TrailingSpaces::default();
799 let content = "Line with spaces \nAnother line ";
801 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
802 let result = rule.check(&ctx).unwrap();
803 assert_eq!(result.len(), 1);
806 assert_eq!(result[0].line, 2);
807 }
808
809 #[test]
810 fn test_issue_80_no_space_normalization() {
811 let rule = MD009TrailingSpaces::new(2, false); let content = "Line with one space \nNext line";
816 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
817 let result = rule.check(&ctx).unwrap();
818 assert_eq!(result.len(), 1);
819 assert_eq!(result[0].line, 1);
820 assert_eq!(result[0].message, "Trailing space found");
821
822 let fixed = rule.fix(&ctx).unwrap();
823 assert_eq!(fixed, "Line with one space\nNext line");
824
825 let content = "Line with three spaces \nNext line";
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 assert_eq!(result[0].line, 1);
831 assert_eq!(result[0].message, "3 trailing spaces found");
832
833 let fixed = rule.fix(&ctx).unwrap();
834 assert_eq!(fixed, "Line with three spaces\nNext line");
835
836 let content = "Line with two spaces \nNext line";
838 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
839 let result = rule.check(&ctx).unwrap();
840 assert_eq!(result.len(), 0); let fixed = rule.fix(&ctx).unwrap();
843 assert_eq!(fixed, "Line with two spaces \nNext line");
844 }
845
846 #[test]
847 fn test_unicode_whitespace_idempotent_fix() {
848 let rule = MD009TrailingSpaces::default(); let content = "> 0\u{2000} ";
854 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
855 let result = rule.check(&ctx).unwrap();
856 assert_eq!(result.len(), 1, "Should detect trailing Unicode+ASCII whitespace");
857
858 let fixed = rule.fix(&ctx).unwrap();
859 assert_eq!(fixed, "> 0", "Should strip all trailing whitespace in one pass");
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
867 #[test]
868 fn test_unicode_whitespace_variants() {
869 let rule = MD009TrailingSpaces::default();
870
871 let content = "text\u{2000}\n";
873 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
874 let result = rule.check(&ctx).unwrap();
875 assert_eq!(result.len(), 1);
876 let fixed = rule.fix(&ctx).unwrap();
877 assert_eq!(fixed, "text\n");
878
879 let content = "text\u{2001}\n";
881 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
882 let result = rule.check(&ctx).unwrap();
883 assert_eq!(result.len(), 1);
884 let fixed = rule.fix(&ctx).unwrap();
885 assert_eq!(fixed, "text\n");
886
887 let content = "text\u{3000}\n";
889 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
890 let result = rule.check(&ctx).unwrap();
891 assert_eq!(result.len(), 1);
892 let fixed = rule.fix(&ctx).unwrap();
893 assert_eq!(fixed, "text\n");
894
895 let content = "text\u{2000} \n";
899 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
900 let result = rule.check(&ctx).unwrap();
901 assert_eq!(result.len(), 1, "Unicode+ASCII mix should be flagged");
902 let fixed = rule.fix(&ctx).unwrap();
903 assert_eq!(
904 fixed, "text\n",
905 "All trailing whitespace should be stripped when mix includes Unicode"
906 );
907 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
909 let fixed2 = rule.fix(&ctx2).unwrap();
910 assert_eq!(fixed, fixed2, "Fix must be idempotent");
911
912 let content = "text \nnext\n";
914 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
915 let result = rule.check(&ctx).unwrap();
916 assert_eq!(result.len(), 0, "Pure ASCII br_spaces should still be preserved");
917 }
918
919 #[test]
920 fn test_unicode_whitespace_strict_mode() {
921 let rule = MD009TrailingSpaces::new(2, true);
922
923 let content = "text\u{2000}\nmore\u{3000}\n";
925 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
926 let fixed = rule.fix(&ctx).unwrap();
927 assert_eq!(fixed, "text\nmore\n");
928 }
929
930 fn assert_fix_roundtrip(rule: &MD009TrailingSpaces, content: &str) {
932 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
933 let fixed = rule.fix(&ctx).unwrap();
934 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
935 let remaining = rule.check(&ctx2).unwrap();
936 assert!(
937 remaining.is_empty(),
938 "After fix(), check() should find 0 violations.\nOriginal: {content:?}\nFixed: {fixed:?}\nRemaining: {remaining:?}"
939 );
940 }
941
942 #[test]
943 fn test_roundtrip_basic_trailing_spaces() {
944 let rule = MD009TrailingSpaces::default();
945 assert_fix_roundtrip(&rule, "Line with spaces \nAnother line \nClean line");
946 }
947
948 #[test]
949 fn test_roundtrip_strict_mode() {
950 let rule = MD009TrailingSpaces::new(2, true);
951 assert_fix_roundtrip(
952 &rule,
953 "Line with spaces \nCode block: \n``` \nCode with spaces \n``` ",
954 );
955 }
956
957 #[test]
958 fn test_roundtrip_empty_lines() {
959 let rule = MD009TrailingSpaces::default();
960 assert_fix_roundtrip(&rule, "Normal line\n \n \nAnother line");
961 }
962
963 #[test]
964 fn test_roundtrip_br_spaces_preservation() {
965 let rule = MD009TrailingSpaces::new(2, false);
966 assert_fix_roundtrip(
967 &rule,
968 "Line with two spaces \nLine with three spaces \nLine with one space ",
969 );
970 }
971
972 #[test]
973 fn test_roundtrip_last_line_no_newline() {
974 let rule = MD009TrailingSpaces::new(2, false);
975 assert_fix_roundtrip(&rule, "First line \nLast line ");
976 }
977
978 #[test]
979 fn test_roundtrip_last_line_with_newline() {
980 let rule = MD009TrailingSpaces::new(2, false);
981 assert_fix_roundtrip(&rule, "First line \nLast line \n");
982 }
983
984 #[test]
985 fn test_roundtrip_unicode_whitespace() {
986 let rule = MD009TrailingSpaces::default();
987 assert_fix_roundtrip(&rule, "> 0\u{2000} ");
988 assert_fix_roundtrip(&rule, "text\u{2000}\n");
989 assert_fix_roundtrip(&rule, "text\u{3000}\n");
990 assert_fix_roundtrip(&rule, "text\u{2000} \n");
991 }
992
993 #[test]
994 fn test_roundtrip_code_blocks_non_strict() {
995 let rule = MD009TrailingSpaces::new(2, false);
996 assert_fix_roundtrip(
997 &rule,
998 "Line with spaces \n```\nCode with spaces \n```\nOutside code ",
999 );
1000 }
1001
1002 #[test]
1003 fn test_roundtrip_blockquotes() {
1004 let rule = MD009TrailingSpaces::default();
1005 assert_fix_roundtrip(&rule, "> Quote\n> \n> More quote");
1006 assert_fix_roundtrip(&rule, "> > Nested \n> > \n> Normal ");
1007 }
1008
1009 #[test]
1010 fn test_roundtrip_list_item_empty_lines() {
1011 let config = MD009Config {
1012 list_item_empty_lines: true,
1013 ..Default::default()
1014 };
1015 let rule = MD009TrailingSpaces::from_config_struct(config);
1016 assert_fix_roundtrip(&rule, "- First item\n \n- Second item");
1017 assert_fix_roundtrip(&rule, "Normal paragraph\n \nAnother paragraph");
1018 }
1019
1020 #[test]
1021 fn test_roundtrip_complex_document() {
1022 let rule = MD009TrailingSpaces::default();
1023 assert_fix_roundtrip(
1024 &rule,
1025 "# Title \n\nParagraph \n\n- List \n - Nested \n\n```\ncode \n```\n\n> Quote \n> \n\nEnd ",
1026 );
1027 }
1028
1029 #[test]
1030 fn test_roundtrip_multibyte() {
1031 let rule = MD009TrailingSpaces::new(2, true);
1032 assert_fix_roundtrip(&rule, "- 1€ expenses \n");
1033 assert_fix_roundtrip(&rule, "€100 + €50 = €150 \n");
1034 assert_fix_roundtrip(&rule, "Hello 你好世界 \n");
1035 assert_fix_roundtrip(&rule, "Party 🎉🎉🎉 \n");
1036 assert_fix_roundtrip(&rule, "안녕하세요 \n");
1037 }
1038
1039 #[test]
1040 fn test_roundtrip_mixed_tabs_and_spaces() {
1041 let rule = MD009TrailingSpaces::default();
1042 assert_fix_roundtrip(&rule, "Line with tab\t\nLine with spaces ");
1043 assert_fix_roundtrip(&rule, "Line\t \nAnother\n");
1044 }
1045
1046 #[test]
1047 fn test_roundtrip_heading_with_br_spaces() {
1048 let rule = MD009TrailingSpaces::new(2, false);
1051 let content = "# Heading \nParagraph\n";
1052 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1053 let warnings = rule.check(&ctx).unwrap();
1054 assert!(
1056 warnings.is_empty(),
1057 "check() should not flag heading with exactly br_spaces trailing spaces"
1058 );
1059 assert_fix_roundtrip(&rule, content);
1060 }
1061
1062 #[test]
1063 fn test_fix_replacement_always_removes_trailing_spaces() {
1064 let rule = MD009TrailingSpaces::new(2, false);
1067
1068 let content = "Hello \nWorld\n";
1071 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1072 let result = rule.check(&ctx).unwrap();
1073 assert_eq!(result.len(), 1);
1074
1075 let fix = result[0].fix.as_ref().expect("Should have a fix");
1076 assert_eq!(
1077 fix.replacement, "",
1078 "Fix replacement should always be empty string (remove trailing spaces)"
1079 );
1080
1081 let fixed = rule.fix(&ctx).unwrap();
1083 assert_eq!(fixed, "Hello\nWorld\n");
1084 }
1085}