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 {
45 line.chars().rev().take_while(|&c| Self::is_trailing_char(c)).count()
46 }
47
48 fn is_trailing_char(c: char) -> bool {
49 c == ' ' || c == '\t'
50 }
51
52 fn trimmed_len_ascii_whitespace(line: &str) -> usize {
53 line.as_bytes()
54 .iter()
55 .rposition(|&b| b != b' ' && b != b'\t')
56 .map_or(0, |idx| idx + 1)
57 }
58
59 fn calculate_trailing_range_ascii(
60 line: usize,
61 line_len: usize,
62 content_end: usize,
63 ) -> (usize, usize, usize, usize) {
64 (line, content_end + 1, line, line_len + 1)
66 }
67
68 fn is_empty_list_item_line(line: &str, prev_line: Option<&str>) -> bool {
69 if !line.trim_matches(Self::is_trailing_char).is_empty() {
73 return false;
74 }
75
76 if let Some(prev) = prev_line {
77 UNORDERED_LIST_MARKER_REGEX.is_match(prev) || ORDERED_LIST_MARKER_REGEX.is_match(prev)
79 } else {
80 false
81 }
82 }
83}
84
85impl Rule for MD009TrailingSpaces {
86 fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
87 (self.config == Default::default()).then_some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(9))
88 }
89
90 fn name(&self) -> &'static str {
91 "MD009"
92 }
93
94 fn description(&self) -> &'static str {
95 "Trailing spaces should be removed"
96 }
97
98 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
99 let content = ctx.content;
100
101 let mut warnings = Vec::new();
102
103 let lines = ctx.raw_lines();
105
106 let mut filtered = ctx
107 .filtered_lines()
108 .skip_front_matter()
109 .skip_pymdown_blocks()
110 .skip_esm_blocks();
111 if !self.config.strict {
112 filtered = filtered.skip_code_blocks();
113 }
114
115 for filtered_line in filtered {
116 let line_num = filtered_line.line_num - 1;
117 let line = filtered_line.content;
118
119 let line_is_ascii = line.is_ascii();
120 let trailing_ascii_spaces = if line_is_ascii {
122 Self::count_trailing_spaces_ascii(line)
123 } else {
124 Self::count_trailing_spaces(line)
125 };
126 let trailing_all_whitespace = if line_is_ascii {
130 trailing_ascii_spaces
131 } else {
132 Self::count_trailing_whitespace(line)
133 };
134
135 if trailing_ascii_spaces == 0 {
138 continue;
139 }
140
141 let trimmed_len = if line_is_ascii {
143 Self::trimmed_len_ascii_whitespace(line)
144 } else {
145 line.trim_end_matches(Self::is_trailing_char).len()
146 };
147 let whitespace_start = ctx.line_offsets[line_num] + trimmed_len;
151 let whitespace_end = ctx.line_offsets[line_num] + line.len();
152 if ctx.is_code_span_text_byte(whitespace_start) {
161 continue;
162 }
163 if ctx.overlaps_mdx_inline_code(whitespace_start, whitespace_end)
164 || (ctx.flavor.supports_jsx()
165 && (ctx.is_in_html_tag(whitespace_start) || ctx.is_in_jsx_component_tag(whitespace_start)))
166 || ctx.is_in_jinja_string(whitespace_start)
167 || ctx.is_in_shortcode(whitespace_start)
168 {
169 continue;
170 }
171
172 if trimmed_len == 0 {
173 if trailing_all_whitespace > 0 {
174 let prev_line = if line_num > 0 { Some(lines[line_num - 1]) } else { None };
176 if self.config.list_item_empty_lines && Self::is_empty_list_item_line(line, prev_line) {
177 continue;
178 }
179
180 let (start_line, start_col, end_line, end_col) = if line_is_ascii {
182 Self::calculate_trailing_range_ascii(line_num + 1, line.len(), 0)
183 } else {
184 calculate_trailing_range(line_num + 1, line, 0)
185 };
186 let line_start = *ctx.line_offsets.get(line_num).unwrap_or(&0);
187 let fix_range = if line_is_ascii {
188 line_start..line_start + line.len()
189 } else {
190 ctx.line_column_byte_range_with_length(line_num + 1, 1, line.chars().count())
191 };
192
193 warnings.push(LintWarning {
194 rule_name: Some(self.name().to_string()),
195 line: start_line,
196 column: start_col,
197 end_line,
198 end_column: end_col,
199 message: "Empty line has trailing spaces".to_string(),
200 severity: Severity::Warning,
201 fix: Some(Fix::new(fix_range, String::new())),
202 });
203 }
204 continue;
205 }
206
207 let is_truly_last_line = line_num == lines.len() - 1 && !content.ends_with('\n');
217 let has_only_ascii_trailing = trailing_ascii_spaces == trailing_all_whitespace;
218 let matches_br_spaces = self.config.br_spaces.line_break() == Some(trailing_ascii_spaces);
219 if !is_truly_last_line && has_only_ascii_trailing && matches_br_spaces {
220 let allow = if self.config.strict {
221 br_produces_useful_break(ctx, line_num)
222 } else {
223 true
224 };
225 if allow {
226 continue;
227 }
228 }
229
230 let trimmed = if line_is_ascii {
233 &line[..trimmed_len]
234 } else {
235 line.trim_end_matches(Self::is_trailing_char)
236 };
237 let is_empty_blockquote_with_space = trimmed.chars().all(|c| c == '>' || c == ' ' || c == '\t')
238 && trimmed.contains('>')
239 && has_only_ascii_trailing
240 && trailing_ascii_spaces == 1;
241
242 if is_empty_blockquote_with_space {
243 continue; }
245 let (start_line, start_col, end_line, end_col) = if line_is_ascii {
247 Self::calculate_trailing_range_ascii(line_num + 1, line.len(), trimmed.len())
248 } else {
249 calculate_trailing_range(line_num + 1, line, trimmed.len())
250 };
251 let line_start = *ctx.line_offsets.get(line_num).unwrap_or(&0);
252 let fix_range = if line_is_ascii {
253 let start = line_start + trimmed.len();
254 let end = start + trailing_all_whitespace;
255 start..end
256 } else {
257 ctx.line_column_byte_range_with_length(
258 line_num + 1,
259 trimmed.chars().count() + 1,
260 trailing_all_whitespace,
261 )
262 };
263
264 warnings.push(LintWarning {
265 rule_name: Some(self.name().to_string()),
266 line: start_line,
267 column: start_col,
268 end_line,
269 end_column: end_col,
270 message: if trailing_all_whitespace == 1 {
271 "Trailing space found".to_string()
272 } else {
273 format!("{trailing_all_whitespace} trailing spaces found")
274 },
275 severity: Severity::Warning,
276 fix: Some(Fix::new(fix_range, String::new())),
277 });
278 }
279
280 Ok(warnings)
281 }
282
283 fn fixes_from_diagnostics(&self) -> bool {
284 true
285 }
286
287 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
288 if self.should_skip(ctx) {
289 return Ok(ctx.content.to_string());
290 }
291 let warnings = self.check(ctx)?;
292 if warnings.is_empty() {
293 return Ok(ctx.content.to_string());
294 }
295 let warnings =
296 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
297 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings).map_err(LintError::InvalidInput)
298 }
299
300 fn as_any(&self) -> &dyn std::any::Any {
301 self
302 }
303
304 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
305 ctx.content.is_empty()
308 }
309
310 fn category(&self) -> RuleCategory {
311 RuleCategory::Whitespace
312 }
313
314 crate::impl_rule_config_methods!(MD009Config);
315}
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320 use crate::lint_context::LintContext;
321 use crate::rule::Rule;
322
323 #[test]
324 fn test_mdx_module_trailing_spaces_preserve_template_string_values() {
325 for strict in [false, true] {
326 let rule = MD009TrailingSpaces::new(0, strict);
327 let source = "export const value = `left \n \nright`\n\nVisible prose \n";
328 let expected = "export const value = `left \n \nright`\n\nVisible prose\n";
329 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::MDX, None);
330 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
331 assert_eq!(rule.fix(&ctx).unwrap(), expected);
332 }
333 }
334
335 #[test]
336 fn test_mdx_expression_trailing_spaces_preserve_values() {
337 for strict in [false, true] {
338 let rule = MD009TrailingSpaces::new(0, strict);
339 for expression in ["{`left \n \nright`}", "{/* c */ `内容\u{2000}\nvalue`}"] {
340 let source = format!("export const unused = 1\n\n{expression} \n");
341 let expected = format!("export const unused = 1\n\n{expression}\n");
342 let ctx = LintContext::new(&source, crate::config::MarkdownFlavor::MDX, None);
343 assert_eq!(
344 rule.check(&ctx).unwrap().len(),
345 1,
346 "only prose whitespace is lintable: {source}"
347 );
348 assert_eq!(rule.fix(&ctx).unwrap(), expected);
349 }
350 }
351 }
352
353 #[test]
354 fn test_mdx_attribute_trailing_spaces_preserve_string_data() {
355 for strict in [false, true] {
356 let rule = MD009TrailingSpaces::new(0, strict);
357 let source = "Before <span title={`left \nright`}>text</span> after \n";
358 let expected = "Before <span title={`left \nright`}>text</span> after\n";
359 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::MDX, None);
360 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
361 assert_eq!(rule.fix(&ctx).unwrap(), expected);
362 }
363 }
364
365 #[test]
366 fn test_jinja_quoted_trailing_spaces_preserve_string_data() {
367 for strict in [false, true] {
368 let rule = MD009TrailingSpaces::new(0, strict);
369 let source = "Before {{ 'left \n \nright' }} after \n";
370 let expected = "Before {{ 'left \n \nright' }} after\n";
371 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
372 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
373 assert_eq!(rule.fix(&ctx).unwrap(), expected);
374 }
375 }
376
377 #[test]
378 fn test_hugo_raw_argument_trailing_spaces_preserve_values() {
379 for strict in [false, true] {
380 let rule = MD009TrailingSpaces::new(0, strict);
381 let source = "Before {{< note title=`left \n \nright` >}} after \n";
382 let expected = "Before {{< note title=`left \n \nright` >}} after\n";
383 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Hugo, None);
384 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
385 assert_eq!(rule.fix(&ctx).unwrap(), expected);
386 }
387 }
388
389 #[test]
390 fn test_no_trailing_spaces() {
391 let rule = MD009TrailingSpaces::default();
392 let content = "This is a line\nAnother line\nNo trailing spaces";
393 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
394 let result = rule.check(&ctx).unwrap();
395 assert!(result.is_empty());
396 }
397
398 #[test]
399 fn test_basic_trailing_spaces() {
400 let rule = MD009TrailingSpaces::default();
401 let content = "Line with spaces \nAnother line \nClean line";
402 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
403 let result = rule.check(&ctx).unwrap();
404 assert_eq!(result.len(), 1);
406 assert_eq!(result[0].line, 1);
407 assert_eq!(result[0].message, "3 trailing spaces found");
408 }
409
410 #[test]
411 fn test_md009_front_matter() {
412 let rule = MD009TrailingSpaces::default();
413 let content = "---\ntitle: Test \n---\nBody ";
414 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
415 let result = rule.check(&ctx).unwrap();
416 assert_eq!(result.len(), 1);
418 assert_eq!(result[0].line, 4);
419 }
420
421 #[test]
422 fn test_fix_basic_trailing_spaces() {
423 let rule = MD009TrailingSpaces::default();
424 let content = "Line with spaces \nAnother line \nClean line";
425 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
426 let fixed = rule.fix(&ctx).unwrap();
427 assert_eq!(fixed, "Line with spaces\nAnother line \nClean line");
431 }
432
433 #[test]
434 fn test_strict_mode() {
435 let rule = MD009TrailingSpaces::new(2, true);
436 let content = "Line with spaces \nCode block: \n``` \nCode with spaces \n``` ";
444 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
445 let result = rule.check(&ctx).unwrap();
446 let lines_flagged: Vec<usize> = result.iter().map(|w| w.line).collect();
447 assert_eq!(lines_flagged, vec![2, 3, 4, 5], "got: {result:?}");
448
449 let fixed = rule.fix(&ctx).unwrap();
450 assert_eq!(fixed, "Line with spaces \nCode block:\n```\nCode with spaces\n```");
451 }
452
453 #[test]
454 fn test_strict_mode_allows_br_spaces_on_paragraph_lines() {
455 let rule = MD009TrailingSpaces::new(2, true);
462 let content = "> Note: \n> This is in a new line due to 2 spaces behind \"Note:\".\n";
463 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
464 let result = rule.check(&ctx).unwrap();
465 assert!(
466 result.is_empty(),
467 "strict mode should allow br_spaces on paragraph-context lines, got: {result:?}"
468 );
469
470 let fixed = rule.fix(&ctx).unwrap();
472 assert_eq!(fixed, content);
473 }
474
475 #[test]
476 fn test_strict_mode_flags_br_spaces_on_heading() {
477 let rule = MD009TrailingSpaces::new(2, true);
479 let content = "# Heading \nFollow-up paragraph.\n";
480 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
481 let result = rule.check(&ctx).unwrap();
482 assert_eq!(result.len(), 1, "strict should flag heading br_spaces, got: {result:?}");
483 assert_eq!(result[0].line, 1);
484 }
485
486 #[test]
487 fn test_strict_mode_flags_br_spaces_on_last_paragraph_line() {
488 let rule = MD009TrailingSpaces::new(2, true);
492 let content = "Paragraph \n\nNext paragraph.\n";
493 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
494 let result = rule.check(&ctx).unwrap();
495 assert_eq!(
496 result.iter().map(|w| w.line).collect::<Vec<_>>(),
497 vec![1],
498 "strict should flag br_spaces on a single-line paragraph, got: {result:?}"
499 );
500 }
501
502 #[test]
503 fn test_strict_mode_flags_br_spaces_between_list_items() {
504 let rule = MD009TrailingSpaces::new(2, true);
507 let content = "- item 1 \n- item 2\n";
508 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
509 let result = rule.check(&ctx).unwrap();
510 assert_eq!(
511 result.iter().map(|w| w.line).collect::<Vec<_>>(),
512 vec![1],
513 "strict should flag br_spaces at the end of a list item, got: {result:?}"
514 );
515 }
516
517 #[test]
518 fn test_strict_mode_allows_br_spaces_in_list_item_continuation() {
519 let rule = MD009TrailingSpaces::new(2, true);
522 let content = "- first line \n second line of same item\n";
523 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
524 let result = rule.check(&ctx).unwrap();
525 assert!(
526 result.is_empty(),
527 "strict should allow br_spaces between a list item and its continuation, got: {result:?}"
528 );
529 }
530
531 #[test]
532 fn test_strict_mode_flags_br_spaces_before_heading() {
533 let rule = MD009TrailingSpaces::new(2, true);
536 let content = "Paragraph \n# Heading\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![1],
542 "strict should flag br_spaces on the line before a heading, got: {result:?}"
543 );
544 }
545
546 #[test]
547 fn test_strict_mode_flags_br_spaces_on_setext_heading_text() {
548 let rule = MD009TrailingSpaces::new(2, true);
551 let content = "Setext heading \n===\n\nFollow-up paragraph.\n";
552 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
553 let result = rule.check(&ctx).unwrap();
554 assert_eq!(
555 result.iter().map(|w| w.line).collect::<Vec<_>>(),
556 vec![1],
557 "strict should flag setext heading text trailing spaces, got: {result:?}"
558 );
559 }
560
561 #[test]
562 fn test_strict_mode_flags_br_spaces_on_setext_underline() {
563 let rule = MD009TrailingSpaces::new(2, true);
567 let content = "Setext heading\n=== \n\nFollow-up paragraph.\n";
568 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
569 let result = rule.check(&ctx).unwrap();
570 assert_eq!(
571 result.iter().map(|w| w.line).collect::<Vec<_>>(),
572 vec![2],
573 "strict should flag setext underline trailing spaces, got: {result:?}"
574 );
575 }
576
577 #[test]
578 fn test_strict_mode_flags_br_spaces_in_indented_code_block() {
579 let rule = MD009TrailingSpaces::new(2, true);
583 let content = "Paragraph above.\n\n code line \n another code \n\nParagraph below.\n";
584 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
585 let result = rule.check(&ctx).unwrap();
586 assert_eq!(
587 result.iter().map(|w| w.line).collect::<Vec<_>>(),
588 vec![3, 4],
589 "strict should flag indented code block trailing spaces, got: {result:?}"
590 );
591 }
592
593 #[test]
594 fn test_strict_mode_allows_br_spaces_in_table_row() {
595 let rule = MD009TrailingSpaces::new(2, true);
600 let content = "| col |\n| --- |\n| cell |\n\nParagraph.\n";
601 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
602 let result = rule.check(&ctx).unwrap();
603 assert!(
604 result.is_empty(),
605 "rows that don't actually have trailing whitespace shouldn't trigger MD009, got: {result:?}"
606 );
607 }
608
609 #[test]
610 fn test_non_strict_mode_with_code_blocks() {
611 let rule = MD009TrailingSpaces::new(2, false);
612 let content = "Line with spaces \n```\nCode with spaces \n```\nOutside code ";
613 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
614 let result = rule.check(&ctx).unwrap();
615 assert_eq!(result.len(), 1);
619 assert_eq!(result[0].line, 5);
620 }
621
622 #[test]
623 fn test_br_spaces_preservation() {
624 let rule = MD009TrailingSpaces::new(2, false);
625 let content = "Line with two spaces \nLine with three spaces \nLine with one space ";
626 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
627 let result = rule.check(&ctx).unwrap();
628 assert_eq!(result.len(), 2);
632 assert_eq!(result[0].line, 2);
633 assert_eq!(result[1].line, 3);
634
635 let fixed = rule.fix(&ctx).unwrap();
636 assert_eq!(
640 fixed,
641 "Line with two spaces \nLine with three spaces\nLine with one space"
642 );
643 }
644
645 #[test]
646 fn test_empty_lines_with_spaces() {
647 let rule = MD009TrailingSpaces::default();
648 let content = "Normal line\n \n \nAnother line";
649 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
650 let result = rule.check(&ctx).unwrap();
651 assert_eq!(result.len(), 2);
652 assert_eq!(result[0].message, "Empty line has trailing spaces");
653 assert_eq!(result[1].message, "Empty line has trailing spaces");
654
655 let fixed = rule.fix(&ctx).unwrap();
656 assert_eq!(fixed, "Normal line\n\n\nAnother line");
657 }
658
659 #[test]
660 fn test_empty_blockquote_lines() {
661 let rule = MD009TrailingSpaces::default();
662 let content = "> Quote\n> \n> More quote";
663 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
664 let result = rule.check(&ctx).unwrap();
665 assert_eq!(result.len(), 1);
666 assert_eq!(result[0].line, 2);
667 assert_eq!(result[0].message, "3 trailing spaces found");
668
669 let fixed = rule.fix(&ctx).unwrap();
670 assert_eq!(fixed, "> Quote\n>\n> More quote"); }
672
673 #[test]
674 fn test_last_line_handling() {
675 let rule = MD009TrailingSpaces::new(2, false);
676
677 let content = "First line \nLast line ";
679 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
680 let result = rule.check(&ctx).unwrap();
681 assert_eq!(result.len(), 1);
683 assert_eq!(result[0].line, 2);
684
685 let fixed = rule.fix(&ctx).unwrap();
686 assert_eq!(fixed, "First line \nLast line");
687
688 let content_with_newline = "First line \nLast line \n";
690 let ctx = LintContext::new(content_with_newline, crate::config::MarkdownFlavor::Standard, None);
691 let result = rule.check(&ctx).unwrap();
692 assert!(result.is_empty());
694 }
695
696 #[test]
697 fn test_single_trailing_space() {
698 let rule = MD009TrailingSpaces::new(2, false);
699 let content = "Line with one space ";
700 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
701 let result = rule.check(&ctx).unwrap();
702 assert_eq!(result.len(), 1);
703 assert_eq!(result[0].message, "Trailing space found");
704 }
705
706 #[test]
707 fn test_tabs_not_spaces() {
708 let rule = MD009TrailingSpaces::default();
709 let content = "Line with tab\t\nLine with spaces ";
710 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
711 let result = rule.check(&ctx).unwrap();
712 assert_eq!(result.len(), 1);
714 assert_eq!(result[0].line, 2);
715 }
716
717 #[test]
718 fn test_mixed_content() {
719 let rule = MD009TrailingSpaces::new(2, false);
720 let mut content = String::new();
722 content.push_str("# Heading");
723 content.push_str(" "); content.push('\n');
725 content.push_str("Normal paragraph\n> Blockquote\n>\n```\nCode block\n```\n- List item\n");
726
727 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
728 let result = rule.check(&ctx).unwrap();
729 assert_eq!(result.len(), 1);
731 assert_eq!(result[0].line, 1);
732 assert!(result[0].message.contains("trailing spaces"));
733 }
734
735 #[test]
736 fn test_column_positions() {
737 let rule = MD009TrailingSpaces::default();
738 let content = "Text ";
739 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
740 let result = rule.check(&ctx).unwrap();
741 assert_eq!(result.len(), 1);
742 assert_eq!(result[0].column, 5); assert_eq!(result[0].end_column, 8); }
745
746 #[test]
747 fn test_default_config() {
748 let rule = MD009TrailingSpaces::default();
749 let config = rule.default_config_section();
750 assert!(config.is_some());
751 let (name, _value) = config.unwrap();
752 assert_eq!(name, "MD009");
753 }
754
755 #[test]
756 fn test_from_config() {
757 let mut config = crate::config::Config::default();
758 let mut rule_config = crate::config::RuleConfig::default();
759 rule_config
760 .values
761 .insert("br_spaces".to_string(), toml::Value::Integer(3));
762 rule_config
763 .values
764 .insert("strict".to_string(), toml::Value::Boolean(true));
765 config.rules.insert("MD009".to_string(), rule_config);
766
767 let rule = MD009TrailingSpaces::from_config(&config);
768 let content = "Line ";
769 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
770 let result = rule.check(&ctx).unwrap();
771 assert_eq!(result.len(), 1);
772
773 let fixed = rule.fix(&ctx).unwrap();
775 assert_eq!(fixed, "Line");
776 }
777
778 #[test]
779 fn test_list_item_empty_lines() {
780 let config = MD009Config {
782 list_item_empty_lines: true,
783 ..Default::default()
784 };
785 let rule = MD009TrailingSpaces::from_config_struct(config);
786
787 let content = "- First item\n \n- Second item";
789 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
790 let result = rule.check(&ctx).unwrap();
791 assert!(result.is_empty());
793
794 let content = "1. First item\n \n2. Second item";
796 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
797 let result = rule.check(&ctx).unwrap();
798 assert!(result.is_empty());
799
800 let content = "Normal paragraph\n \nAnother paragraph";
802 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
803 let result = rule.check(&ctx).unwrap();
804 assert_eq!(result.len(), 1);
805 assert_eq!(result[0].line, 2);
806 }
807
808 #[test]
809 fn test_list_item_empty_lines_disabled() {
810 let rule = MD009TrailingSpaces::default();
812
813 let content = "- First item\n \n- Second item";
814 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
815 let result = rule.check(&ctx).unwrap();
816 assert_eq!(result.len(), 1);
818 assert_eq!(result[0].line, 2);
819 }
820
821 #[test]
822 fn test_performance_large_document() {
823 let rule = MD009TrailingSpaces::default();
824 let mut content = String::new();
825 for i in 0..1000 {
826 content.push_str(&format!("Line {i} with spaces \n"));
827 }
828 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
829 let result = rule.check(&ctx).unwrap();
830 assert_eq!(result.len(), 0);
832 }
833
834 #[test]
835 fn test_preserve_content_after_fix() {
836 let rule = MD009TrailingSpaces::new(2, false);
837 let content = "**Bold** text \n*Italic* text \n[Link](url) ";
838 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
839 let fixed = rule.fix(&ctx).unwrap();
840 assert_eq!(fixed, "**Bold** text \n*Italic* text \n[Link](url)");
841 }
842
843 #[test]
844 fn test_nested_blockquotes() {
845 let rule = MD009TrailingSpaces::default();
846 let content = "> > Nested \n> > \n> Normal ";
847 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
848 let result = rule.check(&ctx).unwrap();
849 assert_eq!(result.len(), 2);
851 assert_eq!(result[0].line, 2);
852 assert_eq!(result[1].line, 3);
853
854 let fixed = rule.fix(&ctx).unwrap();
855 assert_eq!(fixed, "> > Nested \n> >\n> Normal");
859 }
860
861 #[test]
862 fn test_normalized_line_endings() {
863 let rule = MD009TrailingSpaces::default();
864 let content = "Line with spaces \nAnother line ";
866 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
867 let result = rule.check(&ctx).unwrap();
868 assert_eq!(result.len(), 1);
871 assert_eq!(result[0].line, 2);
872 }
873
874 #[test]
875 fn test_issue_80_no_space_normalization() {
876 let rule = MD009TrailingSpaces::new(2, false); let content = "Line with one space \nNext line";
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 assert_eq!(result[0].line, 1);
885 assert_eq!(result[0].message, "Trailing space found");
886
887 let fixed = rule.fix(&ctx).unwrap();
888 assert_eq!(fixed, "Line with one space\nNext line");
889
890 let content = "Line with three spaces \nNext line";
892 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
893 let result = rule.check(&ctx).unwrap();
894 assert_eq!(result.len(), 1);
895 assert_eq!(result[0].line, 1);
896 assert_eq!(result[0].message, "3 trailing spaces found");
897
898 let fixed = rule.fix(&ctx).unwrap();
899 assert_eq!(fixed, "Line with three spaces\nNext line");
900
901 let content = "Line with two spaces \nNext line";
903 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
904 let result = rule.check(&ctx).unwrap();
905 assert_eq!(result.len(), 0); let fixed = rule.fix(&ctx).unwrap();
908 assert_eq!(fixed, "Line with two spaces \nNext line");
909 }
910
911 #[test]
912 fn test_unicode_whitespace_idempotent_fix() {
913 let rule = MD009TrailingSpaces::default(); let content = "> 0\u{2000} ";
918 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
919 let result = rule.check(&ctx).unwrap();
920 assert_eq!(result.len(), 1, "The ASCII space after the Unicode space is trailing");
921
922 let fixed = rule.fix(&ctx).unwrap();
923 assert_eq!(fixed, "> 0\u{2000}", "Only the ASCII space is removed");
924
925 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
926 let fixed2 = rule.fix(&ctx2).unwrap();
927 assert_eq!(fixed, fixed2, "Fix must be idempotent");
928 }
929
930 #[test]
931 fn test_unicode_whitespace_is_text_not_trailing_whitespace() {
932 for strict in [false, true] {
935 let rule = MD009TrailingSpaces::new(2, strict);
936 for ws in ['\u{a0}', '\u{2000}', '\u{2001}', '\u{2003}', '\u{3000}'] {
937 for content in [
938 format!("text{ws}\n"),
939 format!("text{ws}\t\n"),
940 format!("# head{ws}\n"),
941 format!("> quote{ws}\n"),
942 format!("- item{ws}\nnext\n"),
943 format!("a\n{ws}\nb\n"),
944 format!("{ws}\n"),
945 ] {
946 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
947 assert!(
948 rule.check(&ctx).unwrap().is_empty(),
949 "strict={strict}: flagged text {content:?}"
950 );
951 assert_eq!(rule.fix(&ctx).unwrap(), content);
952 }
953 }
954 }
955 }
956
957 #[test]
958 fn test_ascii_space_after_unicode_whitespace_is_still_trailing() {
959 let rule = MD009TrailingSpaces::default();
960 for (content, expected) in [
963 ("text\u{2000} \n", "text\u{2000}\n"),
964 ("text\u{a0}\t \n", "text\u{a0}\n"),
965 ("text\u{3000}\t \n", "text\u{3000}\n"),
966 ] {
967 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
968 assert_eq!(rule.check(&ctx).unwrap().len(), 1, "{content:?}");
969 let fixed = rule.fix(&ctx).unwrap();
970 assert_eq!(fixed, expected);
971 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
972 assert_eq!(rule.fix(&ctx2).unwrap(), fixed, "idempotent: {content:?}");
973 }
974 }
975
976 fn assert_fix_roundtrip(rule: &MD009TrailingSpaces, content: &str) {
978 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
979 let fixed = rule.fix(&ctx).unwrap();
980 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
981 let remaining = rule.check(&ctx2).unwrap();
982 assert!(
983 remaining.is_empty(),
984 "After fix(), check() should find 0 violations.\nOriginal: {content:?}\nFixed: {fixed:?}\nRemaining: {remaining:?}"
985 );
986 }
987
988 #[test]
989 fn test_roundtrip_basic_trailing_spaces() {
990 let rule = MD009TrailingSpaces::default();
991 assert_fix_roundtrip(&rule, "Line with spaces \nAnother line \nClean line");
992 }
993
994 #[test]
995 fn test_roundtrip_strict_mode() {
996 let rule = MD009TrailingSpaces::new(2, true);
997 assert_fix_roundtrip(
998 &rule,
999 "Line with spaces \nCode block: \n``` \nCode with spaces \n``` ",
1000 );
1001 }
1002
1003 #[test]
1004 fn test_roundtrip_empty_lines() {
1005 let rule = MD009TrailingSpaces::default();
1006 assert_fix_roundtrip(&rule, "Normal line\n \n \nAnother line");
1007 }
1008
1009 #[test]
1010 fn test_roundtrip_br_spaces_preservation() {
1011 let rule = MD009TrailingSpaces::new(2, false);
1012 assert_fix_roundtrip(
1013 &rule,
1014 "Line with two spaces \nLine with three spaces \nLine with one space ",
1015 );
1016 }
1017
1018 #[test]
1019 fn test_roundtrip_last_line_no_newline() {
1020 let rule = MD009TrailingSpaces::new(2, false);
1021 assert_fix_roundtrip(&rule, "First line \nLast line ");
1022 }
1023
1024 #[test]
1025 fn test_roundtrip_last_line_with_newline() {
1026 let rule = MD009TrailingSpaces::new(2, false);
1027 assert_fix_roundtrip(&rule, "First line \nLast line \n");
1028 }
1029
1030 #[test]
1031 fn test_roundtrip_unicode_whitespace() {
1032 let rule = MD009TrailingSpaces::default();
1033 assert_fix_roundtrip(&rule, "> 0\u{2000} ");
1034 assert_fix_roundtrip(&rule, "text\u{2000}\n");
1035 assert_fix_roundtrip(&rule, "text\u{3000}\n");
1036 assert_fix_roundtrip(&rule, "text\u{2000} \n");
1037 }
1038
1039 #[test]
1040 fn test_roundtrip_code_blocks_non_strict() {
1041 let rule = MD009TrailingSpaces::new(2, false);
1042 assert_fix_roundtrip(
1043 &rule,
1044 "Line with spaces \n```\nCode with spaces \n```\nOutside code ",
1045 );
1046 }
1047
1048 #[test]
1049 fn test_roundtrip_blockquotes() {
1050 let rule = MD009TrailingSpaces::default();
1051 assert_fix_roundtrip(&rule, "> Quote\n> \n> More quote");
1052 assert_fix_roundtrip(&rule, "> > Nested \n> > \n> Normal ");
1053 }
1054
1055 #[test]
1056 fn test_roundtrip_list_item_empty_lines() {
1057 let config = MD009Config {
1058 list_item_empty_lines: true,
1059 ..Default::default()
1060 };
1061 let rule = MD009TrailingSpaces::from_config_struct(config);
1062 assert_fix_roundtrip(&rule, "- First item\n \n- Second item");
1063 assert_fix_roundtrip(&rule, "Normal paragraph\n \nAnother paragraph");
1064 }
1065
1066 #[test]
1067 fn test_roundtrip_complex_document() {
1068 let rule = MD009TrailingSpaces::default();
1069 assert_fix_roundtrip(
1070 &rule,
1071 "# Title \n\nParagraph \n\n- List \n - Nested \n\n```\ncode \n```\n\n> Quote \n> \n\nEnd ",
1072 );
1073 }
1074
1075 #[test]
1076 fn test_roundtrip_multibyte() {
1077 let rule = MD009TrailingSpaces::new(2, true);
1078 assert_fix_roundtrip(&rule, "- 1€ expenses \n");
1079 assert_fix_roundtrip(&rule, "€100 + €50 = €150 \n");
1080 assert_fix_roundtrip(&rule, "Hello 你好世界 \n");
1081 assert_fix_roundtrip(&rule, "Party 🎉🎉🎉 \n");
1082 assert_fix_roundtrip(&rule, "안녕하세요 \n");
1083 }
1084
1085 #[test]
1086 fn test_roundtrip_mixed_tabs_and_spaces() {
1087 let rule = MD009TrailingSpaces::default();
1088 assert_fix_roundtrip(&rule, "Line with tab\t\nLine with spaces ");
1089 assert_fix_roundtrip(&rule, "Line\t \nAnother\n");
1090 }
1091
1092 #[test]
1093 fn test_roundtrip_heading_with_br_spaces() {
1094 let rule = MD009TrailingSpaces::new(2, false);
1097 let content = "# Heading \nParagraph\n";
1098 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1099 let warnings = rule.check(&ctx).unwrap();
1100 assert!(
1102 warnings.is_empty(),
1103 "check() should not flag heading with exactly br_spaces trailing spaces"
1104 );
1105 assert_fix_roundtrip(&rule, content);
1106 }
1107
1108 #[test]
1109 fn test_fix_replacement_always_removes_trailing_spaces() {
1110 let rule = MD009TrailingSpaces::new(2, false);
1113
1114 let content = "Hello \nWorld\n";
1117 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1118 let result = rule.check(&ctx).unwrap();
1119 assert_eq!(result.len(), 1);
1120
1121 let fix = result[0].fix.as_ref().expect("Should have a fix");
1122 assert_eq!(
1123 fix.replacement, "",
1124 "Fix replacement should always be empty string (remove trailing spaces)"
1125 );
1126
1127 let fixed = rule.fix(&ctx).unwrap();
1129 assert_eq!(fixed, "Hello\nWorld\n");
1130 }
1131
1132 fn span_configs() -> [MD009TrailingSpaces; 3] {
1135 [
1136 MD009TrailingSpaces::default(),
1137 MD009TrailingSpaces::new(2, true),
1138 MD009TrailingSpaces::new(0, false),
1139 ]
1140 }
1141
1142 fn assert_kept_whole(source: &str, flavor: crate::config::MarkdownFlavor) {
1143 for rule in span_configs() {
1144 let ctx = LintContext::new(source, flavor, None);
1145 let warnings = rule.check(&ctx).unwrap();
1146 assert!(
1147 warnings.is_empty(),
1148 "whitespace inside a code span is its text, not trailing space: {source:?} {:?} {warnings:?}",
1149 rule.config
1150 );
1151 assert_eq!(rule.fix(&ctx).unwrap(), source, "fix changed a code span: {source:?}");
1152 }
1153 }
1154
1155 #[test]
1156 fn trailing_spaces_inside_a_multi_line_code_span_are_kept() {
1157 assert_kept_whole("`a \nb`\n", crate::config::MarkdownFlavor::Standard);
1158 }
1159
1160 #[test]
1161 fn code_span_whitespace_is_kept_in_every_shape_a_span_can_take() {
1162 for source in [
1163 "`a \nb`\n",
1165 "`a \nb`\n",
1166 "`a \nb`\n",
1167 "` \n`\n",
1169 "``a \nb``\n",
1170 "``a` \nb``\n",
1171 "`a \nmid \nb`\n",
1173 "`a \nmid \nb`\n",
1174 "`a \t \nb`\n",
1176 "`a\u{2000}\nb`\n",
1177 "`\u{e9} \nb`\n",
1178 "- `a \n b`\n",
1180 "1. `a \n b`\n",
1181 "- x\n - `a \n b`\n",
1182 "> `a \n> b`\n",
1183 "> > `a \n> > b`\n",
1184 "- > `a \n > b`\n",
1185 "> - `a \n> b`\n",
1186 "> `a \nb`\n",
1187 "`a \r\nb`\r\n",
1189 "text `a \nb` more\n",
1191 "`a \nb` and `c \nd`\n",
1192 "*`a \nb`*\n",
1193 "[`a \nb`](u)\n",
1194 "<span>`a \nb`</span>\n",
1195 "`a \nb`",
1197 "$x$ `a \nb`\n",
1200 "A $x `y$ \nz` end\n",
1201 ] {
1202 assert_kept_whole(source, crate::config::MarkdownFlavor::Standard);
1203 }
1204 }
1205
1206 #[test]
1207 fn code_span_whitespace_is_kept_in_a_mkdocs_container() {
1208 assert_kept_whole("!!! note\n\n `a \n b`\n", crate::config::MarkdownFlavor::MkDocs);
1211 for strict in [false, true] {
1212 let rule = MD009TrailingSpaces::new(2, strict);
1213 let source = "!!! note\n\n `a \n b`\n\nouter \n";
1214 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::MkDocs, None);
1215 assert_eq!(rule.fix(&ctx).unwrap(), "!!! note\n\n `a \n b`\n\nouter\n");
1216 }
1217 }
1218
1219 #[test]
1220 fn spaces_after_a_span_on_its_closing_line_are_still_trailing() {
1221 for strict in [false, true] {
1222 let rule = MD009TrailingSpaces::new(2, strict);
1223 let source = "`a \nb` tail \nnext\n";
1224 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1225 let warnings = rule.check(&ctx).unwrap();
1226 assert_eq!(warnings.len(), 1, "only the run after the span: {warnings:?}");
1227 assert_eq!(warnings[0].line, 2);
1228 assert_eq!(rule.fix(&ctx).unwrap(), "`a \nb` tail\nnext\n");
1229 }
1230 }
1231
1232 #[test]
1233 fn trailing_whitespace_outside_a_code_span_is_still_removed() {
1234 for (source, expected) in [
1235 ("`a` \nnext\n", "`a`\nnext\n"),
1237 ("text \nnext\n", "text\nnext\n"),
1238 ("`a \nb\n", "`a\nb\n"),
1240 ("`a \n\nb`\n", "`a\n\nb`\n"),
1242 ("<code>a \nb</code>\n", "<code>a\nb</code>\n"),
1244 ("`a` and `b` \nnext\n", "`a` and `b`\nnext\n"),
1246 ] {
1247 for strict in [false, true] {
1248 let rule = MD009TrailingSpaces::new(2, strict);
1249 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1250 assert_eq!(rule.fix(&ctx).unwrap(), expected, "strict={strict}: {source:?}");
1251 }
1252 }
1253 }
1254
1255 #[test]
1256 fn a_hard_break_next_to_a_code_span_is_kept() {
1257 let rule = MD009TrailingSpaces::default();
1258 let source = "`a` \nnext `b` \nlast\n";
1259 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1260 assert!(rule.check(&ctx).unwrap().is_empty());
1261 assert_eq!(rule.fix(&ctx).unwrap(), source);
1262 }
1263
1264 #[test]
1265 fn br_spaces_zero_still_removes_a_two_space_run_outside_a_code_span() {
1266 let rule = MD009TrailingSpaces::new(0, false);
1267 let source = "text \nnext\n\n`a \nb`\n";
1268 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1269 assert_eq!(rule.fix(&ctx).unwrap(), "text\nnext\n\n`a \nb`\n");
1270 }
1271
1272 #[test]
1273 fn fenced_code_is_skipped_unless_strict_and_a_span_in_it_is_not_special() {
1274 let source = "```\n`a \nb`\n```\n";
1275 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1276 assert_eq!(MD009TrailingSpaces::default().fix(&ctx).unwrap(), source);
1277 }
1278
1279 #[test]
1280 fn test_strict_hard_break_survives_a_unicode_space_line() {
1281 let rule = MD009TrailingSpaces::new(2, true);
1284 for content in ["a \n\u{a0}\nb\n", "\u{a0} \nb\n"] {
1285 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1286 assert!(rule.check(&ctx).unwrap().is_empty(), "{content:?}");
1287 assert_eq!(rule.fix(&ctx).unwrap(), content);
1288 }
1289 let content = "a \n\nb\n";
1291 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1292 assert_eq!(rule.fix(&ctx).unwrap(), "a\n\nb\n");
1293 }
1294}