1use crate::lint_context::LintContext;
2use crate::lint_context::types::HeadingStyle;
3use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
4use crate::rule_config_serde::RuleConfig;
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 for (line_num, &line) in lines.iter().enumerate() {
142 if ctx.line_info(line_num + 1).is_some_and(|info| info.in_pymdown_block) {
144 continue;
145 }
146
147 let line_is_ascii = line.is_ascii();
148 let trailing_ascii_spaces = if line_is_ascii {
150 Self::count_trailing_spaces_ascii(line)
151 } else {
152 Self::count_trailing_spaces(line)
153 };
154 let trailing_all_whitespace = if line_is_ascii {
157 trailing_ascii_spaces
158 } else {
159 Self::count_trailing_whitespace(line)
160 };
161
162 if trailing_all_whitespace == 0 {
164 continue;
165 }
166
167 let trimmed_len = if line_is_ascii {
169 Self::trimmed_len_ascii_whitespace(line)
170 } else {
171 line.trim_end().len()
172 };
173 if trimmed_len == 0 {
174 if trailing_all_whitespace > 0 {
175 let prev_line = if line_num > 0 { Some(lines[line_num - 1]) } else { None };
177 if self.config.list_item_empty_lines && Self::is_empty_list_item_line(line, prev_line) {
178 continue;
179 }
180
181 let (start_line, start_col, end_line, end_col) = if line_is_ascii {
183 Self::calculate_trailing_range_ascii(line_num + 1, line.len(), 0)
184 } else {
185 calculate_trailing_range(line_num + 1, line, 0)
186 };
187 let line_start = *ctx.line_offsets.get(line_num).unwrap_or(&0);
188 let fix_range = if line_is_ascii {
189 line_start..line_start + line.len()
190 } else {
191 line_index.line_col_to_byte_range_with_length(line_num + 1, 1, line.chars().count())
192 };
193
194 warnings.push(LintWarning {
195 rule_name: Some(self.name().to_string()),
196 line: start_line,
197 column: start_col,
198 end_line,
199 end_column: end_col,
200 message: "Empty line has trailing spaces".to_string(),
201 severity: Severity::Warning,
202 fix: Some(Fix::new(fix_range, String::new())),
203 });
204 }
205 continue;
206 }
207
208 if !self.config.strict {
210 if let Some(line_info) = ctx.line_info(line_num + 1)
212 && line_info.in_code_block
213 {
214 continue;
215 }
216 }
217
218 let is_truly_last_line = line_num == lines.len() - 1 && !content.ends_with('\n');
227 let has_only_ascii_trailing = trailing_ascii_spaces == trailing_all_whitespace;
228 let matches_br_spaces = trailing_ascii_spaces == self.config.br_spaces.get();
229 if !is_truly_last_line && has_only_ascii_trailing && matches_br_spaces {
230 let allow = if self.config.strict {
231 br_produces_useful_break(ctx, line_num)
232 } else {
233 true
234 };
235 if allow {
236 continue;
237 }
238 }
239
240 let trimmed = if line_is_ascii {
243 &line[..trimmed_len]
244 } else {
245 line.trim_end()
246 };
247 let is_empty_blockquote_with_space = trimmed.chars().all(|c| c == '>' || c == ' ' || c == '\t')
248 && trimmed.contains('>')
249 && has_only_ascii_trailing
250 && trailing_ascii_spaces == 1;
251
252 if is_empty_blockquote_with_space {
253 continue; }
255 let (start_line, start_col, end_line, end_col) = if line_is_ascii {
257 Self::calculate_trailing_range_ascii(line_num + 1, line.len(), trimmed.len())
258 } else {
259 calculate_trailing_range(line_num + 1, line, trimmed.len())
260 };
261 let line_start = *ctx.line_offsets.get(line_num).unwrap_or(&0);
262 let fix_range = if line_is_ascii {
263 let start = line_start + trimmed.len();
264 let end = start + trailing_all_whitespace;
265 start..end
266 } else {
267 line_index.line_col_to_byte_range_with_length(
268 line_num + 1,
269 trimmed.chars().count() + 1,
270 trailing_all_whitespace,
271 )
272 };
273
274 warnings.push(LintWarning {
275 rule_name: Some(self.name().to_string()),
276 line: start_line,
277 column: start_col,
278 end_line,
279 end_column: end_col,
280 message: if trailing_all_whitespace == 1 {
281 "Trailing space found".to_string()
282 } else {
283 format!("{trailing_all_whitespace} trailing spaces found")
284 },
285 severity: Severity::Warning,
286 fix: Some(Fix::new(fix_range, String::new())),
287 });
288 }
289
290 Ok(warnings)
291 }
292
293 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
294 if self.should_skip(ctx) {
295 return Ok(ctx.content.to_string());
296 }
297 let warnings = self.check(ctx)?;
298 if warnings.is_empty() {
299 return Ok(ctx.content.to_string());
300 }
301 let warnings =
302 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
303 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings).map_err(LintError::InvalidInput)
304 }
305
306 fn as_any(&self) -> &dyn std::any::Any {
307 self
308 }
309
310 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
311 ctx.content.is_empty()
316 }
317
318 fn category(&self) -> RuleCategory {
319 RuleCategory::Whitespace
320 }
321
322 fn default_config_section(&self) -> Option<(String, toml::Value)> {
323 let default_config = MD009Config::default();
324 let json_value = serde_json::to_value(&default_config).ok()?;
325 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
326
327 if let toml::Value::Table(table) = toml_value {
328 if !table.is_empty() {
329 Some((MD009Config::RULE_NAME.to_string(), toml::Value::Table(table)))
330 } else {
331 None
332 }
333 } else {
334 None
335 }
336 }
337
338 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
339 where
340 Self: Sized,
341 {
342 let rule_config = crate::rule_config_serde::load_rule_config::<MD009Config>(config);
343 Box::new(Self::from_config_struct(rule_config))
344 }
345}
346
347#[cfg(test)]
348mod tests {
349 use super::*;
350 use crate::lint_context::LintContext;
351 use crate::rule::Rule;
352
353 #[test]
354 fn test_no_trailing_spaces() {
355 let rule = MD009TrailingSpaces::default();
356 let content = "This is a line\nAnother line\nNo trailing spaces";
357 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
358 let result = rule.check(&ctx).unwrap();
359 assert!(result.is_empty());
360 }
361
362 #[test]
363 fn test_basic_trailing_spaces() {
364 let rule = MD009TrailingSpaces::default();
365 let content = "Line with spaces \nAnother line \nClean line";
366 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
367 let result = rule.check(&ctx).unwrap();
368 assert_eq!(result.len(), 1);
370 assert_eq!(result[0].line, 1);
371 assert_eq!(result[0].message, "3 trailing spaces found");
372 }
373
374 #[test]
375 fn test_fix_basic_trailing_spaces() {
376 let rule = MD009TrailingSpaces::default();
377 let content = "Line with spaces \nAnother line \nClean line";
378 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
379 let fixed = rule.fix(&ctx).unwrap();
380 assert_eq!(fixed, "Line with spaces\nAnother line \nClean line");
384 }
385
386 #[test]
387 fn test_strict_mode() {
388 let rule = MD009TrailingSpaces::new(2, true);
389 let content = "Line with spaces \nCode block: \n``` \nCode with spaces \n``` ";
397 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
398 let result = rule.check(&ctx).unwrap();
399 let lines_flagged: Vec<usize> = result.iter().map(|w| w.line).collect();
400 assert_eq!(lines_flagged, vec![2, 3, 4, 5], "got: {result:?}");
401
402 let fixed = rule.fix(&ctx).unwrap();
403 assert_eq!(fixed, "Line with spaces \nCode block:\n```\nCode with spaces\n```");
404 }
405
406 #[test]
407 fn test_strict_mode_allows_br_spaces_on_paragraph_lines() {
408 let rule = MD009TrailingSpaces::new(2, true);
415 let content = "> Note: \n> This is in a new line due to 2 spaces behind \"Note:\".\n";
416 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
417 let result = rule.check(&ctx).unwrap();
418 assert!(
419 result.is_empty(),
420 "strict mode should allow br_spaces on paragraph-context lines, got: {result:?}"
421 );
422
423 let fixed = rule.fix(&ctx).unwrap();
425 assert_eq!(fixed, content);
426 }
427
428 #[test]
429 fn test_strict_mode_flags_br_spaces_on_heading() {
430 let rule = MD009TrailingSpaces::new(2, true);
432 let content = "# Heading \nFollow-up paragraph.\n";
433 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
434 let result = rule.check(&ctx).unwrap();
435 assert_eq!(result.len(), 1, "strict should flag heading br_spaces, got: {result:?}");
436 assert_eq!(result[0].line, 1);
437 }
438
439 #[test]
440 fn test_strict_mode_flags_br_spaces_on_last_paragraph_line() {
441 let rule = MD009TrailingSpaces::new(2, true);
445 let content = "Paragraph \n\nNext paragraph.\n";
446 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
447 let result = rule.check(&ctx).unwrap();
448 assert_eq!(
449 result.iter().map(|w| w.line).collect::<Vec<_>>(),
450 vec![1],
451 "strict should flag br_spaces on a single-line paragraph, got: {result:?}"
452 );
453 }
454
455 #[test]
456 fn test_strict_mode_flags_br_spaces_between_list_items() {
457 let rule = MD009TrailingSpaces::new(2, true);
460 let content = "- item 1 \n- item 2\n";
461 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
462 let result = rule.check(&ctx).unwrap();
463 assert_eq!(
464 result.iter().map(|w| w.line).collect::<Vec<_>>(),
465 vec![1],
466 "strict should flag br_spaces at the end of a list item, got: {result:?}"
467 );
468 }
469
470 #[test]
471 fn test_strict_mode_allows_br_spaces_in_list_item_continuation() {
472 let rule = MD009TrailingSpaces::new(2, true);
475 let content = "- first line \n second line of same item\n";
476 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
477 let result = rule.check(&ctx).unwrap();
478 assert!(
479 result.is_empty(),
480 "strict should allow br_spaces between a list item and its continuation, got: {result:?}"
481 );
482 }
483
484 #[test]
485 fn test_strict_mode_flags_br_spaces_before_heading() {
486 let rule = MD009TrailingSpaces::new(2, true);
489 let content = "Paragraph \n# Heading\n";
490 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
491 let result = rule.check(&ctx).unwrap();
492 assert_eq!(
493 result.iter().map(|w| w.line).collect::<Vec<_>>(),
494 vec![1],
495 "strict should flag br_spaces on the line before a heading, got: {result:?}"
496 );
497 }
498
499 #[test]
500 fn test_strict_mode_flags_br_spaces_on_setext_heading_text() {
501 let rule = MD009TrailingSpaces::new(2, true);
504 let content = "Setext heading \n===\n\nFollow-up paragraph.\n";
505 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
506 let result = rule.check(&ctx).unwrap();
507 assert_eq!(
508 result.iter().map(|w| w.line).collect::<Vec<_>>(),
509 vec![1],
510 "strict should flag setext heading text trailing spaces, got: {result:?}"
511 );
512 }
513
514 #[test]
515 fn test_strict_mode_flags_br_spaces_on_setext_underline() {
516 let rule = MD009TrailingSpaces::new(2, true);
520 let content = "Setext heading\n=== \n\nFollow-up paragraph.\n";
521 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
522 let result = rule.check(&ctx).unwrap();
523 assert_eq!(
524 result.iter().map(|w| w.line).collect::<Vec<_>>(),
525 vec![2],
526 "strict should flag setext underline trailing spaces, got: {result:?}"
527 );
528 }
529
530 #[test]
531 fn test_strict_mode_flags_br_spaces_in_indented_code_block() {
532 let rule = MD009TrailingSpaces::new(2, true);
536 let content = "Paragraph above.\n\n code line \n another code \n\nParagraph below.\n";
537 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
538 let result = rule.check(&ctx).unwrap();
539 assert_eq!(
540 result.iter().map(|w| w.line).collect::<Vec<_>>(),
541 vec![3, 4],
542 "strict should flag indented code block trailing spaces, got: {result:?}"
543 );
544 }
545
546 #[test]
547 fn test_strict_mode_allows_br_spaces_in_table_row() {
548 let rule = MD009TrailingSpaces::new(2, true);
553 let content = "| col |\n| --- |\n| cell |\n\nParagraph.\n";
554 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
555 let result = rule.check(&ctx).unwrap();
556 assert!(
557 result.is_empty(),
558 "rows that don't actually have trailing whitespace shouldn't trigger MD009, got: {result:?}"
559 );
560 }
561
562 #[test]
563 fn test_non_strict_mode_with_code_blocks() {
564 let rule = MD009TrailingSpaces::new(2, false);
565 let content = "Line with spaces \n```\nCode with spaces \n```\nOutside code ";
566 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
567 let result = rule.check(&ctx).unwrap();
568 assert_eq!(result.len(), 1);
572 assert_eq!(result[0].line, 5);
573 }
574
575 #[test]
576 fn test_br_spaces_preservation() {
577 let rule = MD009TrailingSpaces::new(2, false);
578 let content = "Line with two spaces \nLine with three spaces \nLine with one space ";
579 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
580 let result = rule.check(&ctx).unwrap();
581 assert_eq!(result.len(), 2);
585 assert_eq!(result[0].line, 2);
586 assert_eq!(result[1].line, 3);
587
588 let fixed = rule.fix(&ctx).unwrap();
589 assert_eq!(
593 fixed,
594 "Line with two spaces \nLine with three spaces\nLine with one space"
595 );
596 }
597
598 #[test]
599 fn test_empty_lines_with_spaces() {
600 let rule = MD009TrailingSpaces::default();
601 let content = "Normal line\n \n \nAnother line";
602 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
603 let result = rule.check(&ctx).unwrap();
604 assert_eq!(result.len(), 2);
605 assert_eq!(result[0].message, "Empty line has trailing spaces");
606 assert_eq!(result[1].message, "Empty line has trailing spaces");
607
608 let fixed = rule.fix(&ctx).unwrap();
609 assert_eq!(fixed, "Normal line\n\n\nAnother line");
610 }
611
612 #[test]
613 fn test_empty_blockquote_lines() {
614 let rule = MD009TrailingSpaces::default();
615 let content = "> Quote\n> \n> More quote";
616 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
617 let result = rule.check(&ctx).unwrap();
618 assert_eq!(result.len(), 1);
619 assert_eq!(result[0].line, 2);
620 assert_eq!(result[0].message, "3 trailing spaces found");
621
622 let fixed = rule.fix(&ctx).unwrap();
623 assert_eq!(fixed, "> Quote\n>\n> More quote"); }
625
626 #[test]
627 fn test_last_line_handling() {
628 let rule = MD009TrailingSpaces::new(2, false);
629
630 let content = "First line \nLast line ";
632 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
633 let result = rule.check(&ctx).unwrap();
634 assert_eq!(result.len(), 1);
636 assert_eq!(result[0].line, 2);
637
638 let fixed = rule.fix(&ctx).unwrap();
639 assert_eq!(fixed, "First line \nLast line");
640
641 let content_with_newline = "First line \nLast line \n";
643 let ctx = LintContext::new(content_with_newline, crate::config::MarkdownFlavor::Standard, None);
644 let result = rule.check(&ctx).unwrap();
645 assert!(result.is_empty());
647 }
648
649 #[test]
650 fn test_single_trailing_space() {
651 let rule = MD009TrailingSpaces::new(2, false);
652 let content = "Line with one space ";
653 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
654 let result = rule.check(&ctx).unwrap();
655 assert_eq!(result.len(), 1);
656 assert_eq!(result[0].message, "Trailing space found");
657 }
658
659 #[test]
660 fn test_tabs_not_spaces() {
661 let rule = MD009TrailingSpaces::default();
662 let content = "Line with tab\t\nLine with spaces ";
663 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
664 let result = rule.check(&ctx).unwrap();
665 assert_eq!(result.len(), 1);
667 assert_eq!(result[0].line, 2);
668 }
669
670 #[test]
671 fn test_mixed_content() {
672 let rule = MD009TrailingSpaces::new(2, false);
673 let mut content = String::new();
675 content.push_str("# Heading");
676 content.push_str(" "); content.push('\n');
678 content.push_str("Normal paragraph\n> Blockquote\n>\n```\nCode block\n```\n- List item\n");
679
680 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
681 let result = rule.check(&ctx).unwrap();
682 assert_eq!(result.len(), 1);
684 assert_eq!(result[0].line, 1);
685 assert!(result[0].message.contains("trailing spaces"));
686 }
687
688 #[test]
689 fn test_column_positions() {
690 let rule = MD009TrailingSpaces::default();
691 let content = "Text ";
692 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
693 let result = rule.check(&ctx).unwrap();
694 assert_eq!(result.len(), 1);
695 assert_eq!(result[0].column, 5); assert_eq!(result[0].end_column, 8); }
698
699 #[test]
700 fn test_default_config() {
701 let rule = MD009TrailingSpaces::default();
702 let config = rule.default_config_section();
703 assert!(config.is_some());
704 let (name, _value) = config.unwrap();
705 assert_eq!(name, "MD009");
706 }
707
708 #[test]
709 fn test_from_config() {
710 let mut config = crate::config::Config::default();
711 let mut rule_config = crate::config::RuleConfig::default();
712 rule_config
713 .values
714 .insert("br_spaces".to_string(), toml::Value::Integer(3));
715 rule_config
716 .values
717 .insert("strict".to_string(), toml::Value::Boolean(true));
718 config.rules.insert("MD009".to_string(), rule_config);
719
720 let rule = MD009TrailingSpaces::from_config(&config);
721 let content = "Line ";
722 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
723 let result = rule.check(&ctx).unwrap();
724 assert_eq!(result.len(), 1);
725
726 let fixed = rule.fix(&ctx).unwrap();
728 assert_eq!(fixed, "Line");
729 }
730
731 #[test]
732 fn test_list_item_empty_lines() {
733 let config = MD009Config {
735 list_item_empty_lines: true,
736 ..Default::default()
737 };
738 let rule = MD009TrailingSpaces::from_config_struct(config);
739
740 let content = "- First item\n \n- Second item";
742 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
743 let result = rule.check(&ctx).unwrap();
744 assert!(result.is_empty());
746
747 let content = "1. First item\n \n2. Second item";
749 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
750 let result = rule.check(&ctx).unwrap();
751 assert!(result.is_empty());
752
753 let content = "Normal paragraph\n \nAnother paragraph";
755 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
756 let result = rule.check(&ctx).unwrap();
757 assert_eq!(result.len(), 1);
758 assert_eq!(result[0].line, 2);
759 }
760
761 #[test]
762 fn test_list_item_empty_lines_disabled() {
763 let rule = MD009TrailingSpaces::default();
765
766 let content = "- First item\n \n- Second item";
767 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
768 let result = rule.check(&ctx).unwrap();
769 assert_eq!(result.len(), 1);
771 assert_eq!(result[0].line, 2);
772 }
773
774 #[test]
775 fn test_performance_large_document() {
776 let rule = MD009TrailingSpaces::default();
777 let mut content = String::new();
778 for i in 0..1000 {
779 content.push_str(&format!("Line {i} with spaces \n"));
780 }
781 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
782 let result = rule.check(&ctx).unwrap();
783 assert_eq!(result.len(), 0);
785 }
786
787 #[test]
788 fn test_preserve_content_after_fix() {
789 let rule = MD009TrailingSpaces::new(2, false);
790 let content = "**Bold** text \n*Italic* text \n[Link](url) ";
791 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
792 let fixed = rule.fix(&ctx).unwrap();
793 assert_eq!(fixed, "**Bold** text \n*Italic* text \n[Link](url)");
794 }
795
796 #[test]
797 fn test_nested_blockquotes() {
798 let rule = MD009TrailingSpaces::default();
799 let content = "> > Nested \n> > \n> Normal ";
800 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
801 let result = rule.check(&ctx).unwrap();
802 assert_eq!(result.len(), 2);
804 assert_eq!(result[0].line, 2);
805 assert_eq!(result[1].line, 3);
806
807 let fixed = rule.fix(&ctx).unwrap();
808 assert_eq!(fixed, "> > Nested \n> >\n> Normal");
812 }
813
814 #[test]
815 fn test_normalized_line_endings() {
816 let rule = MD009TrailingSpaces::default();
817 let content = "Line with spaces \nAnother line ";
819 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
820 let result = rule.check(&ctx).unwrap();
821 assert_eq!(result.len(), 1);
824 assert_eq!(result[0].line, 2);
825 }
826
827 #[test]
828 fn test_issue_80_no_space_normalization() {
829 let rule = MD009TrailingSpaces::new(2, false); let content = "Line with one space \nNext line";
834 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
835 let result = rule.check(&ctx).unwrap();
836 assert_eq!(result.len(), 1);
837 assert_eq!(result[0].line, 1);
838 assert_eq!(result[0].message, "Trailing space found");
839
840 let fixed = rule.fix(&ctx).unwrap();
841 assert_eq!(fixed, "Line with one space\nNext line");
842
843 let content = "Line with three spaces \nNext line";
845 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
846 let result = rule.check(&ctx).unwrap();
847 assert_eq!(result.len(), 1);
848 assert_eq!(result[0].line, 1);
849 assert_eq!(result[0].message, "3 trailing spaces found");
850
851 let fixed = rule.fix(&ctx).unwrap();
852 assert_eq!(fixed, "Line with three spaces\nNext line");
853
854 let content = "Line with two spaces \nNext line";
856 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
857 let result = rule.check(&ctx).unwrap();
858 assert_eq!(result.len(), 0); let fixed = rule.fix(&ctx).unwrap();
861 assert_eq!(fixed, "Line with two spaces \nNext line");
862 }
863
864 #[test]
865 fn test_unicode_whitespace_idempotent_fix() {
866 let rule = MD009TrailingSpaces::default(); let content = "> 0\u{2000} ";
872 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
873 let result = rule.check(&ctx).unwrap();
874 assert_eq!(result.len(), 1, "Should detect trailing Unicode+ASCII whitespace");
875
876 let fixed = rule.fix(&ctx).unwrap();
877 assert_eq!(fixed, "> 0", "Should strip all trailing whitespace in one pass");
878
879 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
881 let fixed2 = rule.fix(&ctx2).unwrap();
882 assert_eq!(fixed, fixed2, "Fix must be idempotent");
883 }
884
885 #[test]
886 fn test_unicode_whitespace_variants() {
887 let rule = MD009TrailingSpaces::default();
888
889 let content = "text\u{2000}\n";
891 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
892 let result = rule.check(&ctx).unwrap();
893 assert_eq!(result.len(), 1);
894 let fixed = rule.fix(&ctx).unwrap();
895 assert_eq!(fixed, "text\n");
896
897 let content = "text\u{2001}\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);
902 let fixed = rule.fix(&ctx).unwrap();
903 assert_eq!(fixed, "text\n");
904
905 let content = "text\u{3000}\n";
907 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
908 let result = rule.check(&ctx).unwrap();
909 assert_eq!(result.len(), 1);
910 let fixed = rule.fix(&ctx).unwrap();
911 assert_eq!(fixed, "text\n");
912
913 let content = "text\u{2000} \n";
917 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
918 let result = rule.check(&ctx).unwrap();
919 assert_eq!(result.len(), 1, "Unicode+ASCII mix should be flagged");
920 let fixed = rule.fix(&ctx).unwrap();
921 assert_eq!(
922 fixed, "text\n",
923 "All trailing whitespace should be stripped when mix includes Unicode"
924 );
925 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
927 let fixed2 = rule.fix(&ctx2).unwrap();
928 assert_eq!(fixed, fixed2, "Fix must be idempotent");
929
930 let content = "text \nnext\n";
932 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
933 let result = rule.check(&ctx).unwrap();
934 assert_eq!(result.len(), 0, "Pure ASCII br_spaces should still be preserved");
935 }
936
937 #[test]
938 fn test_unicode_whitespace_strict_mode() {
939 let rule = MD009TrailingSpaces::new(2, true);
940
941 let content = "text\u{2000}\nmore\u{3000}\n";
943 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
944 let fixed = rule.fix(&ctx).unwrap();
945 assert_eq!(fixed, "text\nmore\n");
946 }
947
948 fn assert_fix_roundtrip(rule: &MD009TrailingSpaces, content: &str) {
950 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
951 let fixed = rule.fix(&ctx).unwrap();
952 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
953 let remaining = rule.check(&ctx2).unwrap();
954 assert!(
955 remaining.is_empty(),
956 "After fix(), check() should find 0 violations.\nOriginal: {content:?}\nFixed: {fixed:?}\nRemaining: {remaining:?}"
957 );
958 }
959
960 #[test]
961 fn test_roundtrip_basic_trailing_spaces() {
962 let rule = MD009TrailingSpaces::default();
963 assert_fix_roundtrip(&rule, "Line with spaces \nAnother line \nClean line");
964 }
965
966 #[test]
967 fn test_roundtrip_strict_mode() {
968 let rule = MD009TrailingSpaces::new(2, true);
969 assert_fix_roundtrip(
970 &rule,
971 "Line with spaces \nCode block: \n``` \nCode with spaces \n``` ",
972 );
973 }
974
975 #[test]
976 fn test_roundtrip_empty_lines() {
977 let rule = MD009TrailingSpaces::default();
978 assert_fix_roundtrip(&rule, "Normal line\n \n \nAnother line");
979 }
980
981 #[test]
982 fn test_roundtrip_br_spaces_preservation() {
983 let rule = MD009TrailingSpaces::new(2, false);
984 assert_fix_roundtrip(
985 &rule,
986 "Line with two spaces \nLine with three spaces \nLine with one space ",
987 );
988 }
989
990 #[test]
991 fn test_roundtrip_last_line_no_newline() {
992 let rule = MD009TrailingSpaces::new(2, false);
993 assert_fix_roundtrip(&rule, "First line \nLast line ");
994 }
995
996 #[test]
997 fn test_roundtrip_last_line_with_newline() {
998 let rule = MD009TrailingSpaces::new(2, false);
999 assert_fix_roundtrip(&rule, "First line \nLast line \n");
1000 }
1001
1002 #[test]
1003 fn test_roundtrip_unicode_whitespace() {
1004 let rule = MD009TrailingSpaces::default();
1005 assert_fix_roundtrip(&rule, "> 0\u{2000} ");
1006 assert_fix_roundtrip(&rule, "text\u{2000}\n");
1007 assert_fix_roundtrip(&rule, "text\u{3000}\n");
1008 assert_fix_roundtrip(&rule, "text\u{2000} \n");
1009 }
1010
1011 #[test]
1012 fn test_roundtrip_code_blocks_non_strict() {
1013 let rule = MD009TrailingSpaces::new(2, false);
1014 assert_fix_roundtrip(
1015 &rule,
1016 "Line with spaces \n```\nCode with spaces \n```\nOutside code ",
1017 );
1018 }
1019
1020 #[test]
1021 fn test_roundtrip_blockquotes() {
1022 let rule = MD009TrailingSpaces::default();
1023 assert_fix_roundtrip(&rule, "> Quote\n> \n> More quote");
1024 assert_fix_roundtrip(&rule, "> > Nested \n> > \n> Normal ");
1025 }
1026
1027 #[test]
1028 fn test_roundtrip_list_item_empty_lines() {
1029 let config = MD009Config {
1030 list_item_empty_lines: true,
1031 ..Default::default()
1032 };
1033 let rule = MD009TrailingSpaces::from_config_struct(config);
1034 assert_fix_roundtrip(&rule, "- First item\n \n- Second item");
1035 assert_fix_roundtrip(&rule, "Normal paragraph\n \nAnother paragraph");
1036 }
1037
1038 #[test]
1039 fn test_roundtrip_complex_document() {
1040 let rule = MD009TrailingSpaces::default();
1041 assert_fix_roundtrip(
1042 &rule,
1043 "# Title \n\nParagraph \n\n- List \n - Nested \n\n```\ncode \n```\n\n> Quote \n> \n\nEnd ",
1044 );
1045 }
1046
1047 #[test]
1048 fn test_roundtrip_multibyte() {
1049 let rule = MD009TrailingSpaces::new(2, true);
1050 assert_fix_roundtrip(&rule, "- 1€ expenses \n");
1051 assert_fix_roundtrip(&rule, "€100 + €50 = €150 \n");
1052 assert_fix_roundtrip(&rule, "Hello 你好世界 \n");
1053 assert_fix_roundtrip(&rule, "Party 🎉🎉🎉 \n");
1054 assert_fix_roundtrip(&rule, "안녕하세요 \n");
1055 }
1056
1057 #[test]
1058 fn test_roundtrip_mixed_tabs_and_spaces() {
1059 let rule = MD009TrailingSpaces::default();
1060 assert_fix_roundtrip(&rule, "Line with tab\t\nLine with spaces ");
1061 assert_fix_roundtrip(&rule, "Line\t \nAnother\n");
1062 }
1063
1064 #[test]
1065 fn test_roundtrip_heading_with_br_spaces() {
1066 let rule = MD009TrailingSpaces::new(2, false);
1069 let content = "# Heading \nParagraph\n";
1070 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1071 let warnings = rule.check(&ctx).unwrap();
1072 assert!(
1074 warnings.is_empty(),
1075 "check() should not flag heading with exactly br_spaces trailing spaces"
1076 );
1077 assert_fix_roundtrip(&rule, content);
1078 }
1079
1080 #[test]
1081 fn test_fix_replacement_always_removes_trailing_spaces() {
1082 let rule = MD009TrailingSpaces::new(2, false);
1085
1086 let content = "Hello \nWorld\n";
1089 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1090 let result = rule.check(&ctx).unwrap();
1091 assert_eq!(result.len(), 1);
1092
1093 let fix = result[0].fix.as_ref().expect("Should have a fix");
1094 assert_eq!(
1095 fix.replacement, "",
1096 "Fix replacement should always be empty string (remove trailing spaces)"
1097 );
1098
1099 let fixed = rule.fix(&ctx).unwrap();
1101 assert_eq!(fixed, "Hello\nWorld\n");
1102 }
1103}