1use crate::filtered_lines::FilteredLinesExt;
2use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
3use crate::utils::hard_break::br_produces_useful_break;
4use crate::utils::range_utils::calculate_trailing_range;
5use crate::utils::regex_cache::{ORDERED_LIST_MARKER_REGEX, UNORDERED_LIST_MARKER_REGEX};
6
7mod md009_config;
8use md009_config::MD009Config;
9
10#[derive(Debug, Clone, Default)]
11pub struct MD009TrailingSpaces {
12 config: MD009Config,
13}
14
15impl MD009TrailingSpaces {
16 pub fn new(br_spaces: usize, strict: bool) -> Self {
17 Self {
18 config: MD009Config {
19 br_spaces: crate::types::BrSpaces::new(br_spaces),
20 strict,
21 list_item_empty_lines: false,
22 },
23 }
24 }
25
26 pub const fn from_config_struct(config: MD009Config) -> Self {
27 Self { config }
28 }
29
30 fn count_trailing_spaces(line: &str) -> usize {
31 line.chars().rev().take_while(|&c| c == ' ').count()
32 }
33
34 fn count_trailing_spaces_ascii(line: &str) -> usize {
35 line.as_bytes().iter().rev().take_while(|&&b| b == b' ').count()
36 }
37
38 fn count_trailing_whitespace(line: &str) -> usize {
41 line.chars().rev().take_while(|c| c.is_whitespace()).count()
42 }
43
44 fn trimmed_len_ascii_whitespace(line: &str) -> usize {
45 line.as_bytes()
46 .iter()
47 .rposition(|b| !b.is_ascii_whitespace())
48 .map_or(0, |idx| idx + 1)
49 }
50
51 fn calculate_trailing_range_ascii(
52 line: usize,
53 line_len: usize,
54 content_end: usize,
55 ) -> (usize, usize, usize, usize) {
56 (line, content_end + 1, line, line_len + 1)
58 }
59
60 fn is_empty_list_item_line(line: &str, prev_line: Option<&str>) -> bool {
61 if !line.trim().is_empty() {
65 return false;
66 }
67
68 if let Some(prev) = prev_line {
69 UNORDERED_LIST_MARKER_REGEX.is_match(prev) || ORDERED_LIST_MARKER_REGEX.is_match(prev)
71 } else {
72 false
73 }
74 }
75}
76
77impl Rule for MD009TrailingSpaces {
78 fn name(&self) -> &'static str {
79 "MD009"
80 }
81
82 fn description(&self) -> &'static str {
83 "Trailing spaces should be removed"
84 }
85
86 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
87 let content = ctx.content;
88
89 let mut warnings = Vec::new();
90
91 let lines = ctx.raw_lines();
93
94 let mut filtered = ctx
95 .filtered_lines()
96 .skip_front_matter()
97 .skip_pymdown_blocks()
98 .skip_esm_blocks();
99 if !self.config.strict {
100 filtered = filtered.skip_code_blocks();
101 }
102
103 for filtered_line in filtered {
104 let line_num = filtered_line.line_num - 1;
105 let line = filtered_line.content;
106
107 let line_is_ascii = line.is_ascii();
108 let trailing_ascii_spaces = if line_is_ascii {
110 Self::count_trailing_spaces_ascii(line)
111 } else {
112 Self::count_trailing_spaces(line)
113 };
114 let trailing_all_whitespace = if line_is_ascii {
117 trailing_ascii_spaces
118 } else {
119 Self::count_trailing_whitespace(line)
120 };
121
122 if trailing_all_whitespace == 0 {
124 continue;
125 }
126
127 let trimmed_len = if line_is_ascii {
129 Self::trimmed_len_ascii_whitespace(line)
130 } else {
131 line.trim_end().len()
132 };
133 let whitespace_start = ctx.line_offsets[line_num] + trimmed_len;
137 let whitespace_end = ctx.line_offsets[line_num] + line.len();
138 if ctx.overlaps_mdx_inline_code(whitespace_start, whitespace_end)
139 || (ctx.flavor.supports_jsx()
140 && (ctx.is_in_html_tag(whitespace_start) || ctx.is_in_jsx_component_tag(whitespace_start)))
141 || ctx.is_in_jinja_string(whitespace_start)
142 || ctx.is_in_shortcode(whitespace_start)
143 {
144 continue;
145 }
146
147 if trimmed_len == 0 {
148 if trailing_all_whitespace > 0 {
149 let prev_line = if line_num > 0 { Some(lines[line_num - 1]) } else { None };
151 if self.config.list_item_empty_lines && Self::is_empty_list_item_line(line, prev_line) {
152 continue;
153 }
154
155 let (start_line, start_col, end_line, end_col) = if line_is_ascii {
157 Self::calculate_trailing_range_ascii(line_num + 1, line.len(), 0)
158 } else {
159 calculate_trailing_range(line_num + 1, line, 0)
160 };
161 let line_start = *ctx.line_offsets.get(line_num).unwrap_or(&0);
162 let fix_range = if line_is_ascii {
163 line_start..line_start + line.len()
164 } else {
165 ctx.line_column_byte_range_with_length(line_num + 1, 1, line.chars().count())
166 };
167
168 warnings.push(LintWarning {
169 rule_name: Some(self.name().to_string()),
170 line: start_line,
171 column: start_col,
172 end_line,
173 end_column: end_col,
174 message: "Empty line has trailing spaces".to_string(),
175 severity: Severity::Warning,
176 fix: Some(Fix::new(fix_range, String::new())),
177 });
178 }
179 continue;
180 }
181
182 let is_truly_last_line = line_num == lines.len() - 1 && !content.ends_with('\n');
192 let has_only_ascii_trailing = trailing_ascii_spaces == trailing_all_whitespace;
193 let matches_br_spaces = self.config.br_spaces.line_break() == Some(trailing_ascii_spaces);
194 if !is_truly_last_line && has_only_ascii_trailing && matches_br_spaces {
195 let allow = if self.config.strict {
196 br_produces_useful_break(ctx, line_num)
197 } else {
198 true
199 };
200 if allow {
201 continue;
202 }
203 }
204
205 let trimmed = if line_is_ascii {
208 &line[..trimmed_len]
209 } else {
210 line.trim_end()
211 };
212 let is_empty_blockquote_with_space = trimmed.chars().all(|c| c == '>' || c == ' ' || c == '\t')
213 && trimmed.contains('>')
214 && has_only_ascii_trailing
215 && trailing_ascii_spaces == 1;
216
217 if is_empty_blockquote_with_space {
218 continue; }
220 let (start_line, start_col, end_line, end_col) = if line_is_ascii {
222 Self::calculate_trailing_range_ascii(line_num + 1, line.len(), trimmed.len())
223 } else {
224 calculate_trailing_range(line_num + 1, line, trimmed.len())
225 };
226 let line_start = *ctx.line_offsets.get(line_num).unwrap_or(&0);
227 let fix_range = if line_is_ascii {
228 let start = line_start + trimmed.len();
229 let end = start + trailing_all_whitespace;
230 start..end
231 } else {
232 ctx.line_column_byte_range_with_length(
233 line_num + 1,
234 trimmed.chars().count() + 1,
235 trailing_all_whitespace,
236 )
237 };
238
239 warnings.push(LintWarning {
240 rule_name: Some(self.name().to_string()),
241 line: start_line,
242 column: start_col,
243 end_line,
244 end_column: end_col,
245 message: if trailing_all_whitespace == 1 {
246 "Trailing space found".to_string()
247 } else {
248 format!("{trailing_all_whitespace} trailing spaces found")
249 },
250 severity: Severity::Warning,
251 fix: Some(Fix::new(fix_range, String::new())),
252 });
253 }
254
255 Ok(warnings)
256 }
257
258 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
259 if self.should_skip(ctx) {
260 return Ok(ctx.content.to_string());
261 }
262 let warnings = self.check(ctx)?;
263 if warnings.is_empty() {
264 return Ok(ctx.content.to_string());
265 }
266 let warnings =
267 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
268 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings).map_err(LintError::InvalidInput)
269 }
270
271 fn as_any(&self) -> &dyn std::any::Any {
272 self
273 }
274
275 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
276 ctx.content.is_empty()
281 }
282
283 fn category(&self) -> RuleCategory {
284 RuleCategory::Whitespace
285 }
286
287 crate::impl_rule_config_methods!(MD009Config);
288}
289
290#[cfg(test)]
291mod tests {
292 use super::*;
293 use crate::lint_context::LintContext;
294 use crate::rule::Rule;
295
296 #[test]
297 fn test_mdx_module_trailing_spaces_preserve_template_string_values() {
298 for strict in [false, true] {
299 let rule = MD009TrailingSpaces::new(0, strict);
300 let source = "export const value = `left \n \nright`\n\nVisible prose \n";
301 let expected = "export const value = `left \n \nright`\n\nVisible prose\n";
302 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::MDX, None);
303 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
304 assert_eq!(rule.fix(&ctx).unwrap(), expected);
305 }
306 }
307
308 #[test]
309 fn test_mdx_expression_trailing_spaces_preserve_values() {
310 for strict in [false, true] {
311 let rule = MD009TrailingSpaces::new(0, strict);
312 for expression in ["{`left \n \nright`}", "{/* c */ `内容\u{2000}\nvalue`}"] {
313 let source = format!("export const unused = 1\n\n{expression} \n");
314 let expected = format!("export const unused = 1\n\n{expression}\n");
315 let ctx = LintContext::new(&source, crate::config::MarkdownFlavor::MDX, None);
316 assert_eq!(
317 rule.check(&ctx).unwrap().len(),
318 1,
319 "only prose whitespace is lintable: {source}"
320 );
321 assert_eq!(rule.fix(&ctx).unwrap(), expected);
322 }
323 }
324 }
325
326 #[test]
327 fn test_mdx_attribute_trailing_spaces_preserve_string_data() {
328 for strict in [false, true] {
329 let rule = MD009TrailingSpaces::new(0, strict);
330 let source = "Before <span title={`left \nright`}>text</span> after \n";
331 let expected = "Before <span title={`left \nright`}>text</span> after\n";
332 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::MDX, None);
333 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
334 assert_eq!(rule.fix(&ctx).unwrap(), expected);
335 }
336 }
337
338 #[test]
339 fn test_jinja_quoted_trailing_spaces_preserve_string_data() {
340 for strict in [false, true] {
341 let rule = MD009TrailingSpaces::new(0, strict);
342 let source = "Before {{ 'left \n \nright' }} after \n";
343 let expected = "Before {{ 'left \n \nright' }} after\n";
344 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
345 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
346 assert_eq!(rule.fix(&ctx).unwrap(), expected);
347 }
348 }
349
350 #[test]
351 fn test_hugo_raw_argument_trailing_spaces_preserve_values() {
352 for strict in [false, true] {
353 let rule = MD009TrailingSpaces::new(0, strict);
354 let source = "Before {{< note title=`left \n \nright` >}} after \n";
355 let expected = "Before {{< note title=`left \n \nright` >}} after\n";
356 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Hugo, None);
357 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
358 assert_eq!(rule.fix(&ctx).unwrap(), expected);
359 }
360 }
361
362 #[test]
363 fn test_no_trailing_spaces() {
364 let rule = MD009TrailingSpaces::default();
365 let content = "This is a line\nAnother line\nNo trailing spaces";
366 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
367 let result = rule.check(&ctx).unwrap();
368 assert!(result.is_empty());
369 }
370
371 #[test]
372 fn test_basic_trailing_spaces() {
373 let rule = MD009TrailingSpaces::default();
374 let content = "Line with spaces \nAnother line \nClean line";
375 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
376 let result = rule.check(&ctx).unwrap();
377 assert_eq!(result.len(), 1);
379 assert_eq!(result[0].line, 1);
380 assert_eq!(result[0].message, "3 trailing spaces found");
381 }
382
383 #[test]
384 fn test_md009_front_matter() {
385 let rule = MD009TrailingSpaces::default();
386 let content = "---\ntitle: Test \n---\nBody ";
387 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
388 let result = rule.check(&ctx).unwrap();
389 assert_eq!(result.len(), 1);
391 assert_eq!(result[0].line, 4);
392 }
393
394 #[test]
395 fn test_fix_basic_trailing_spaces() {
396 let rule = MD009TrailingSpaces::default();
397 let content = "Line with spaces \nAnother line \nClean line";
398 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
399 let fixed = rule.fix(&ctx).unwrap();
400 assert_eq!(fixed, "Line with spaces\nAnother line \nClean line");
404 }
405
406 #[test]
407 fn test_strict_mode() {
408 let rule = MD009TrailingSpaces::new(2, true);
409 let content = "Line with spaces \nCode block: \n``` \nCode with spaces \n``` ";
417 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
418 let result = rule.check(&ctx).unwrap();
419 let lines_flagged: Vec<usize> = result.iter().map(|w| w.line).collect();
420 assert_eq!(lines_flagged, vec![2, 3, 4, 5], "got: {result:?}");
421
422 let fixed = rule.fix(&ctx).unwrap();
423 assert_eq!(fixed, "Line with spaces \nCode block:\n```\nCode with spaces\n```");
424 }
425
426 #[test]
427 fn test_strict_mode_allows_br_spaces_on_paragraph_lines() {
428 let rule = MD009TrailingSpaces::new(2, true);
435 let content = "> Note: \n> This is in a new line due to 2 spaces behind \"Note:\".\n";
436 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
437 let result = rule.check(&ctx).unwrap();
438 assert!(
439 result.is_empty(),
440 "strict mode should allow br_spaces on paragraph-context lines, got: {result:?}"
441 );
442
443 let fixed = rule.fix(&ctx).unwrap();
445 assert_eq!(fixed, content);
446 }
447
448 #[test]
449 fn test_strict_mode_flags_br_spaces_on_heading() {
450 let rule = MD009TrailingSpaces::new(2, true);
452 let content = "# Heading \nFollow-up paragraph.\n";
453 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
454 let result = rule.check(&ctx).unwrap();
455 assert_eq!(result.len(), 1, "strict should flag heading br_spaces, got: {result:?}");
456 assert_eq!(result[0].line, 1);
457 }
458
459 #[test]
460 fn test_strict_mode_flags_br_spaces_on_last_paragraph_line() {
461 let rule = MD009TrailingSpaces::new(2, true);
465 let content = "Paragraph \n\nNext paragraph.\n";
466 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
467 let result = rule.check(&ctx).unwrap();
468 assert_eq!(
469 result.iter().map(|w| w.line).collect::<Vec<_>>(),
470 vec![1],
471 "strict should flag br_spaces on a single-line paragraph, got: {result:?}"
472 );
473 }
474
475 #[test]
476 fn test_strict_mode_flags_br_spaces_between_list_items() {
477 let rule = MD009TrailingSpaces::new(2, true);
480 let content = "- item 1 \n- item 2\n";
481 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
482 let result = rule.check(&ctx).unwrap();
483 assert_eq!(
484 result.iter().map(|w| w.line).collect::<Vec<_>>(),
485 vec![1],
486 "strict should flag br_spaces at the end of a list item, got: {result:?}"
487 );
488 }
489
490 #[test]
491 fn test_strict_mode_allows_br_spaces_in_list_item_continuation() {
492 let rule = MD009TrailingSpaces::new(2, true);
495 let content = "- first line \n second line of same item\n";
496 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
497 let result = rule.check(&ctx).unwrap();
498 assert!(
499 result.is_empty(),
500 "strict should allow br_spaces between a list item and its continuation, got: {result:?}"
501 );
502 }
503
504 #[test]
505 fn test_strict_mode_flags_br_spaces_before_heading() {
506 let rule = MD009TrailingSpaces::new(2, true);
509 let content = "Paragraph \n# Heading\n";
510 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
511 let result = rule.check(&ctx).unwrap();
512 assert_eq!(
513 result.iter().map(|w| w.line).collect::<Vec<_>>(),
514 vec![1],
515 "strict should flag br_spaces on the line before a heading, got: {result:?}"
516 );
517 }
518
519 #[test]
520 fn test_strict_mode_flags_br_spaces_on_setext_heading_text() {
521 let rule = MD009TrailingSpaces::new(2, true);
524 let content = "Setext heading \n===\n\nFollow-up paragraph.\n";
525 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
526 let result = rule.check(&ctx).unwrap();
527 assert_eq!(
528 result.iter().map(|w| w.line).collect::<Vec<_>>(),
529 vec![1],
530 "strict should flag setext heading text trailing spaces, got: {result:?}"
531 );
532 }
533
534 #[test]
535 fn test_strict_mode_flags_br_spaces_on_setext_underline() {
536 let rule = MD009TrailingSpaces::new(2, true);
540 let content = "Setext heading\n=== \n\nFollow-up paragraph.\n";
541 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
542 let result = rule.check(&ctx).unwrap();
543 assert_eq!(
544 result.iter().map(|w| w.line).collect::<Vec<_>>(),
545 vec![2],
546 "strict should flag setext underline trailing spaces, got: {result:?}"
547 );
548 }
549
550 #[test]
551 fn test_strict_mode_flags_br_spaces_in_indented_code_block() {
552 let rule = MD009TrailingSpaces::new(2, true);
556 let content = "Paragraph above.\n\n code line \n another code \n\nParagraph below.\n";
557 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
558 let result = rule.check(&ctx).unwrap();
559 assert_eq!(
560 result.iter().map(|w| w.line).collect::<Vec<_>>(),
561 vec![3, 4],
562 "strict should flag indented code block trailing spaces, got: {result:?}"
563 );
564 }
565
566 #[test]
567 fn test_strict_mode_allows_br_spaces_in_table_row() {
568 let rule = MD009TrailingSpaces::new(2, true);
573 let content = "| col |\n| --- |\n| cell |\n\nParagraph.\n";
574 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
575 let result = rule.check(&ctx).unwrap();
576 assert!(
577 result.is_empty(),
578 "rows that don't actually have trailing whitespace shouldn't trigger MD009, got: {result:?}"
579 );
580 }
581
582 #[test]
583 fn test_non_strict_mode_with_code_blocks() {
584 let rule = MD009TrailingSpaces::new(2, false);
585 let content = "Line with spaces \n```\nCode with spaces \n```\nOutside code ";
586 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
587 let result = rule.check(&ctx).unwrap();
588 assert_eq!(result.len(), 1);
592 assert_eq!(result[0].line, 5);
593 }
594
595 #[test]
596 fn test_br_spaces_preservation() {
597 let rule = MD009TrailingSpaces::new(2, false);
598 let content = "Line with two spaces \nLine with three spaces \nLine with one space ";
599 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
600 let result = rule.check(&ctx).unwrap();
601 assert_eq!(result.len(), 2);
605 assert_eq!(result[0].line, 2);
606 assert_eq!(result[1].line, 3);
607
608 let fixed = rule.fix(&ctx).unwrap();
609 assert_eq!(
613 fixed,
614 "Line with two spaces \nLine with three spaces\nLine with one space"
615 );
616 }
617
618 #[test]
619 fn test_empty_lines_with_spaces() {
620 let rule = MD009TrailingSpaces::default();
621 let content = "Normal line\n \n \nAnother line";
622 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
623 let result = rule.check(&ctx).unwrap();
624 assert_eq!(result.len(), 2);
625 assert_eq!(result[0].message, "Empty line has trailing spaces");
626 assert_eq!(result[1].message, "Empty line has trailing spaces");
627
628 let fixed = rule.fix(&ctx).unwrap();
629 assert_eq!(fixed, "Normal line\n\n\nAnother line");
630 }
631
632 #[test]
633 fn test_empty_blockquote_lines() {
634 let rule = MD009TrailingSpaces::default();
635 let content = "> Quote\n> \n> More quote";
636 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
637 let result = rule.check(&ctx).unwrap();
638 assert_eq!(result.len(), 1);
639 assert_eq!(result[0].line, 2);
640 assert_eq!(result[0].message, "3 trailing spaces found");
641
642 let fixed = rule.fix(&ctx).unwrap();
643 assert_eq!(fixed, "> Quote\n>\n> More quote"); }
645
646 #[test]
647 fn test_last_line_handling() {
648 let rule = MD009TrailingSpaces::new(2, false);
649
650 let content = "First line \nLast line ";
652 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
653 let result = rule.check(&ctx).unwrap();
654 assert_eq!(result.len(), 1);
656 assert_eq!(result[0].line, 2);
657
658 let fixed = rule.fix(&ctx).unwrap();
659 assert_eq!(fixed, "First line \nLast line");
660
661 let content_with_newline = "First line \nLast line \n";
663 let ctx = LintContext::new(content_with_newline, crate::config::MarkdownFlavor::Standard, None);
664 let result = rule.check(&ctx).unwrap();
665 assert!(result.is_empty());
667 }
668
669 #[test]
670 fn test_single_trailing_space() {
671 let rule = MD009TrailingSpaces::new(2, false);
672 let content = "Line with one space ";
673 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
674 let result = rule.check(&ctx).unwrap();
675 assert_eq!(result.len(), 1);
676 assert_eq!(result[0].message, "Trailing space found");
677 }
678
679 #[test]
680 fn test_tabs_not_spaces() {
681 let rule = MD009TrailingSpaces::default();
682 let content = "Line with tab\t\nLine with spaces ";
683 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
684 let result = rule.check(&ctx).unwrap();
685 assert_eq!(result.len(), 1);
687 assert_eq!(result[0].line, 2);
688 }
689
690 #[test]
691 fn test_mixed_content() {
692 let rule = MD009TrailingSpaces::new(2, false);
693 let mut content = String::new();
695 content.push_str("# Heading");
696 content.push_str(" "); content.push('\n');
698 content.push_str("Normal paragraph\n> Blockquote\n>\n```\nCode block\n```\n- List item\n");
699
700 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
701 let result = rule.check(&ctx).unwrap();
702 assert_eq!(result.len(), 1);
704 assert_eq!(result[0].line, 1);
705 assert!(result[0].message.contains("trailing spaces"));
706 }
707
708 #[test]
709 fn test_column_positions() {
710 let rule = MD009TrailingSpaces::default();
711 let content = "Text ";
712 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
713 let result = rule.check(&ctx).unwrap();
714 assert_eq!(result.len(), 1);
715 assert_eq!(result[0].column, 5); assert_eq!(result[0].end_column, 8); }
718
719 #[test]
720 fn test_default_config() {
721 let rule = MD009TrailingSpaces::default();
722 let config = rule.default_config_section();
723 assert!(config.is_some());
724 let (name, _value) = config.unwrap();
725 assert_eq!(name, "MD009");
726 }
727
728 #[test]
729 fn test_from_config() {
730 let mut config = crate::config::Config::default();
731 let mut rule_config = crate::config::RuleConfig::default();
732 rule_config
733 .values
734 .insert("br_spaces".to_string(), toml::Value::Integer(3));
735 rule_config
736 .values
737 .insert("strict".to_string(), toml::Value::Boolean(true));
738 config.rules.insert("MD009".to_string(), rule_config);
739
740 let rule = MD009TrailingSpaces::from_config(&config);
741 let content = "Line ";
742 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
743 let result = rule.check(&ctx).unwrap();
744 assert_eq!(result.len(), 1);
745
746 let fixed = rule.fix(&ctx).unwrap();
748 assert_eq!(fixed, "Line");
749 }
750
751 #[test]
752 fn test_list_item_empty_lines() {
753 let config = MD009Config {
755 list_item_empty_lines: true,
756 ..Default::default()
757 };
758 let rule = MD009TrailingSpaces::from_config_struct(config);
759
760 let content = "- First item\n \n- Second item";
762 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
763 let result = rule.check(&ctx).unwrap();
764 assert!(result.is_empty());
766
767 let content = "1. First item\n \n2. Second item";
769 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
770 let result = rule.check(&ctx).unwrap();
771 assert!(result.is_empty());
772
773 let content = "Normal paragraph\n \nAnother paragraph";
775 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
776 let result = rule.check(&ctx).unwrap();
777 assert_eq!(result.len(), 1);
778 assert_eq!(result[0].line, 2);
779 }
780
781 #[test]
782 fn test_list_item_empty_lines_disabled() {
783 let rule = MD009TrailingSpaces::default();
785
786 let content = "- First item\n \n- Second item";
787 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
788 let result = rule.check(&ctx).unwrap();
789 assert_eq!(result.len(), 1);
791 assert_eq!(result[0].line, 2);
792 }
793
794 #[test]
795 fn test_performance_large_document() {
796 let rule = MD009TrailingSpaces::default();
797 let mut content = String::new();
798 for i in 0..1000 {
799 content.push_str(&format!("Line {i} with spaces \n"));
800 }
801 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
802 let result = rule.check(&ctx).unwrap();
803 assert_eq!(result.len(), 0);
805 }
806
807 #[test]
808 fn test_preserve_content_after_fix() {
809 let rule = MD009TrailingSpaces::new(2, false);
810 let content = "**Bold** text \n*Italic* text \n[Link](url) ";
811 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
812 let fixed = rule.fix(&ctx).unwrap();
813 assert_eq!(fixed, "**Bold** text \n*Italic* text \n[Link](url)");
814 }
815
816 #[test]
817 fn test_nested_blockquotes() {
818 let rule = MD009TrailingSpaces::default();
819 let content = "> > Nested \n> > \n> Normal ";
820 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
821 let result = rule.check(&ctx).unwrap();
822 assert_eq!(result.len(), 2);
824 assert_eq!(result[0].line, 2);
825 assert_eq!(result[1].line, 3);
826
827 let fixed = rule.fix(&ctx).unwrap();
828 assert_eq!(fixed, "> > Nested \n> >\n> Normal");
832 }
833
834 #[test]
835 fn test_normalized_line_endings() {
836 let rule = MD009TrailingSpaces::default();
837 let content = "Line with spaces \nAnother line ";
839 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
840 let result = rule.check(&ctx).unwrap();
841 assert_eq!(result.len(), 1);
844 assert_eq!(result[0].line, 2);
845 }
846
847 #[test]
848 fn test_issue_80_no_space_normalization() {
849 let rule = MD009TrailingSpaces::new(2, false); let content = "Line with one space \nNext line";
854 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
855 let result = rule.check(&ctx).unwrap();
856 assert_eq!(result.len(), 1);
857 assert_eq!(result[0].line, 1);
858 assert_eq!(result[0].message, "Trailing space found");
859
860 let fixed = rule.fix(&ctx).unwrap();
861 assert_eq!(fixed, "Line with one space\nNext line");
862
863 let content = "Line with three spaces \nNext line";
865 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
866 let result = rule.check(&ctx).unwrap();
867 assert_eq!(result.len(), 1);
868 assert_eq!(result[0].line, 1);
869 assert_eq!(result[0].message, "3 trailing spaces found");
870
871 let fixed = rule.fix(&ctx).unwrap();
872 assert_eq!(fixed, "Line with three spaces\nNext line");
873
874 let content = "Line with two spaces \nNext line";
876 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
877 let result = rule.check(&ctx).unwrap();
878 assert_eq!(result.len(), 0); let fixed = rule.fix(&ctx).unwrap();
881 assert_eq!(fixed, "Line with two spaces \nNext line");
882 }
883
884 #[test]
885 fn test_unicode_whitespace_idempotent_fix() {
886 let rule = MD009TrailingSpaces::default(); let content = "> 0\u{2000} ";
892 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
893 let result = rule.check(&ctx).unwrap();
894 assert_eq!(result.len(), 1, "Should detect trailing Unicode+ASCII whitespace");
895
896 let fixed = rule.fix(&ctx).unwrap();
897 assert_eq!(fixed, "> 0", "Should strip all trailing whitespace in one pass");
898
899 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
901 let fixed2 = rule.fix(&ctx2).unwrap();
902 assert_eq!(fixed, fixed2, "Fix must be idempotent");
903 }
904
905 #[test]
906 fn test_unicode_whitespace_variants() {
907 let rule = MD009TrailingSpaces::default();
908
909 let content = "text\u{2000}\n";
911 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
912 let result = rule.check(&ctx).unwrap();
913 assert_eq!(result.len(), 1);
914 let fixed = rule.fix(&ctx).unwrap();
915 assert_eq!(fixed, "text\n");
916
917 let content = "text\u{2001}\n";
919 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
920 let result = rule.check(&ctx).unwrap();
921 assert_eq!(result.len(), 1);
922 let fixed = rule.fix(&ctx).unwrap();
923 assert_eq!(fixed, "text\n");
924
925 let content = "text\u{3000}\n";
927 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
928 let result = rule.check(&ctx).unwrap();
929 assert_eq!(result.len(), 1);
930 let fixed = rule.fix(&ctx).unwrap();
931 assert_eq!(fixed, "text\n");
932
933 let content = "text\u{2000} \n";
937 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
938 let result = rule.check(&ctx).unwrap();
939 assert_eq!(result.len(), 1, "Unicode+ASCII mix should be flagged");
940 let fixed = rule.fix(&ctx).unwrap();
941 assert_eq!(
942 fixed, "text\n",
943 "All trailing whitespace should be stripped when mix includes Unicode"
944 );
945 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
947 let fixed2 = rule.fix(&ctx2).unwrap();
948 assert_eq!(fixed, fixed2, "Fix must be idempotent");
949
950 let content = "text \nnext\n";
952 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
953 let result = rule.check(&ctx).unwrap();
954 assert_eq!(result.len(), 0, "Pure ASCII br_spaces should still be preserved");
955 }
956
957 #[test]
958 fn test_unicode_whitespace_strict_mode() {
959 let rule = MD009TrailingSpaces::new(2, true);
960
961 let content = "text\u{2000}\nmore\u{3000}\n";
963 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
964 let fixed = rule.fix(&ctx).unwrap();
965 assert_eq!(fixed, "text\nmore\n");
966 }
967
968 fn assert_fix_roundtrip(rule: &MD009TrailingSpaces, content: &str) {
970 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
971 let fixed = rule.fix(&ctx).unwrap();
972 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
973 let remaining = rule.check(&ctx2).unwrap();
974 assert!(
975 remaining.is_empty(),
976 "After fix(), check() should find 0 violations.\nOriginal: {content:?}\nFixed: {fixed:?}\nRemaining: {remaining:?}"
977 );
978 }
979
980 #[test]
981 fn test_roundtrip_basic_trailing_spaces() {
982 let rule = MD009TrailingSpaces::default();
983 assert_fix_roundtrip(&rule, "Line with spaces \nAnother line \nClean line");
984 }
985
986 #[test]
987 fn test_roundtrip_strict_mode() {
988 let rule = MD009TrailingSpaces::new(2, true);
989 assert_fix_roundtrip(
990 &rule,
991 "Line with spaces \nCode block: \n``` \nCode with spaces \n``` ",
992 );
993 }
994
995 #[test]
996 fn test_roundtrip_empty_lines() {
997 let rule = MD009TrailingSpaces::default();
998 assert_fix_roundtrip(&rule, "Normal line\n \n \nAnother line");
999 }
1000
1001 #[test]
1002 fn test_roundtrip_br_spaces_preservation() {
1003 let rule = MD009TrailingSpaces::new(2, false);
1004 assert_fix_roundtrip(
1005 &rule,
1006 "Line with two spaces \nLine with three spaces \nLine with one space ",
1007 );
1008 }
1009
1010 #[test]
1011 fn test_roundtrip_last_line_no_newline() {
1012 let rule = MD009TrailingSpaces::new(2, false);
1013 assert_fix_roundtrip(&rule, "First line \nLast line ");
1014 }
1015
1016 #[test]
1017 fn test_roundtrip_last_line_with_newline() {
1018 let rule = MD009TrailingSpaces::new(2, false);
1019 assert_fix_roundtrip(&rule, "First line \nLast line \n");
1020 }
1021
1022 #[test]
1023 fn test_roundtrip_unicode_whitespace() {
1024 let rule = MD009TrailingSpaces::default();
1025 assert_fix_roundtrip(&rule, "> 0\u{2000} ");
1026 assert_fix_roundtrip(&rule, "text\u{2000}\n");
1027 assert_fix_roundtrip(&rule, "text\u{3000}\n");
1028 assert_fix_roundtrip(&rule, "text\u{2000} \n");
1029 }
1030
1031 #[test]
1032 fn test_roundtrip_code_blocks_non_strict() {
1033 let rule = MD009TrailingSpaces::new(2, false);
1034 assert_fix_roundtrip(
1035 &rule,
1036 "Line with spaces \n```\nCode with spaces \n```\nOutside code ",
1037 );
1038 }
1039
1040 #[test]
1041 fn test_roundtrip_blockquotes() {
1042 let rule = MD009TrailingSpaces::default();
1043 assert_fix_roundtrip(&rule, "> Quote\n> \n> More quote");
1044 assert_fix_roundtrip(&rule, "> > Nested \n> > \n> Normal ");
1045 }
1046
1047 #[test]
1048 fn test_roundtrip_list_item_empty_lines() {
1049 let config = MD009Config {
1050 list_item_empty_lines: true,
1051 ..Default::default()
1052 };
1053 let rule = MD009TrailingSpaces::from_config_struct(config);
1054 assert_fix_roundtrip(&rule, "- First item\n \n- Second item");
1055 assert_fix_roundtrip(&rule, "Normal paragraph\n \nAnother paragraph");
1056 }
1057
1058 #[test]
1059 fn test_roundtrip_complex_document() {
1060 let rule = MD009TrailingSpaces::default();
1061 assert_fix_roundtrip(
1062 &rule,
1063 "# Title \n\nParagraph \n\n- List \n - Nested \n\n```\ncode \n```\n\n> Quote \n> \n\nEnd ",
1064 );
1065 }
1066
1067 #[test]
1068 fn test_roundtrip_multibyte() {
1069 let rule = MD009TrailingSpaces::new(2, true);
1070 assert_fix_roundtrip(&rule, "- 1€ expenses \n");
1071 assert_fix_roundtrip(&rule, "€100 + €50 = €150 \n");
1072 assert_fix_roundtrip(&rule, "Hello 你好世界 \n");
1073 assert_fix_roundtrip(&rule, "Party 🎉🎉🎉 \n");
1074 assert_fix_roundtrip(&rule, "안녕하세요 \n");
1075 }
1076
1077 #[test]
1078 fn test_roundtrip_mixed_tabs_and_spaces() {
1079 let rule = MD009TrailingSpaces::default();
1080 assert_fix_roundtrip(&rule, "Line with tab\t\nLine with spaces ");
1081 assert_fix_roundtrip(&rule, "Line\t \nAnother\n");
1082 }
1083
1084 #[test]
1085 fn test_roundtrip_heading_with_br_spaces() {
1086 let rule = MD009TrailingSpaces::new(2, false);
1089 let content = "# Heading \nParagraph\n";
1090 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1091 let warnings = rule.check(&ctx).unwrap();
1092 assert!(
1094 warnings.is_empty(),
1095 "check() should not flag heading with exactly br_spaces trailing spaces"
1096 );
1097 assert_fix_roundtrip(&rule, content);
1098 }
1099
1100 #[test]
1101 fn test_fix_replacement_always_removes_trailing_spaces() {
1102 let rule = MD009TrailingSpaces::new(2, false);
1105
1106 let content = "Hello \nWorld\n";
1109 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1110 let result = rule.check(&ctx).unwrap();
1111 assert_eq!(result.len(), 1);
1112
1113 let fix = result[0].fix.as_ref().expect("Should have a fix");
1114 assert_eq!(
1115 fix.replacement, "",
1116 "Fix replacement should always be empty string (remove trailing spaces)"
1117 );
1118
1119 let fixed = rule.fix(&ctx).unwrap();
1121 assert_eq!(fixed, "Hello\nWorld\n");
1122 }
1123}