1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2use crate::rule_config_serde::RuleConfig;
3use crate::utils::range_utils::calculate_trailing_range;
4use crate::utils::regex_cache::{ORDERED_LIST_MARKER_REGEX, UNORDERED_LIST_MARKER_REGEX, get_cached_regex};
5
6mod md009_config;
7use md009_config::MD009Config;
8
9#[derive(Debug, Clone, Default)]
12pub struct MD009TrailingSpaces {
13 config: MD009Config,
14}
15
16impl MD009TrailingSpaces {
17 pub fn new(br_spaces: usize, strict: bool) -> Self {
18 Self {
19 config: MD009Config {
20 br_spaces: crate::types::BrSpaces::from_const(br_spaces),
21 strict,
22 list_item_empty_lines: false,
23 },
24 }
25 }
26
27 pub const fn from_config_struct(config: MD009Config) -> Self {
28 Self { config }
29 }
30
31 fn count_trailing_spaces(line: &str) -> usize {
32 line.chars().rev().take_while(|&c| c == ' ').count()
33 }
34
35 fn count_trailing_spaces_ascii(line: &str) -> usize {
36 line.as_bytes().iter().rev().take_while(|&&b| b == b' ').count()
37 }
38
39 fn count_trailing_whitespace(line: &str) -> usize {
42 line.chars().rev().take_while(|c| c.is_whitespace()).count()
43 }
44
45 fn has_trailing_whitespace(line: &str) -> bool {
47 line.chars().next_back().is_some_and(|c| c.is_whitespace())
48 }
49
50 fn trimmed_len_ascii_whitespace(line: &str) -> usize {
51 line.as_bytes()
52 .iter()
53 .rposition(|b| !b.is_ascii_whitespace())
54 .map(|idx| idx + 1)
55 .unwrap_or(0)
56 }
57
58 fn calculate_trailing_range_ascii(
59 line: usize,
60 line_len: usize,
61 content_end: usize,
62 ) -> (usize, usize, usize, usize) {
63 (line, content_end + 1, line, line_len + 1)
65 }
66
67 fn is_empty_list_item_line(line: &str, prev_line: Option<&str>) -> bool {
68 if !line.trim().is_empty() {
72 return false;
73 }
74
75 if let Some(prev) = prev_line {
76 UNORDERED_LIST_MARKER_REGEX.is_match(prev) || ORDERED_LIST_MARKER_REGEX.is_match(prev)
78 } else {
79 false
80 }
81 }
82}
83
84impl Rule for MD009TrailingSpaces {
85 fn name(&self) -> &'static str {
86 "MD009"
87 }
88
89 fn description(&self) -> &'static str {
90 "Trailing spaces should be removed"
91 }
92
93 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
94 let content = ctx.content;
95 let _line_index = &ctx.line_index;
96
97 let mut warnings = Vec::new();
98
99 let lines = ctx.raw_lines();
101
102 for (line_num, &line) in lines.iter().enumerate() {
103 if ctx.line_info(line_num + 1).is_some_and(|info| info.in_pymdown_block) {
105 continue;
106 }
107
108 let line_is_ascii = line.is_ascii();
109 let trailing_ascii_spaces = if line_is_ascii {
111 Self::count_trailing_spaces_ascii(line)
112 } else {
113 Self::count_trailing_spaces(line)
114 };
115 let trailing_all_whitespace = if line_is_ascii {
118 trailing_ascii_spaces
119 } else {
120 Self::count_trailing_whitespace(line)
121 };
122
123 if trailing_all_whitespace == 0 {
125 continue;
126 }
127
128 let trimmed_len = if line_is_ascii {
130 Self::trimmed_len_ascii_whitespace(line)
131 } else {
132 line.trim_end().len()
133 };
134 if trimmed_len == 0 {
135 if trailing_all_whitespace > 0 {
136 let prev_line = if line_num > 0 { Some(lines[line_num - 1]) } else { None };
138 if self.config.list_item_empty_lines && Self::is_empty_list_item_line(line, prev_line) {
139 continue;
140 }
141
142 let (start_line, start_col, end_line, end_col) = if line_is_ascii {
144 Self::calculate_trailing_range_ascii(line_num + 1, line.len(), 0)
145 } else {
146 calculate_trailing_range(line_num + 1, line, 0)
147 };
148 let line_start = *ctx.line_offsets.get(line_num).unwrap_or(&0);
149 let fix_range = if line_is_ascii {
150 line_start..line_start + line.len()
151 } else {
152 _line_index.line_col_to_byte_range_with_length(line_num + 1, 1, line.chars().count())
153 };
154
155 warnings.push(LintWarning {
156 rule_name: Some(self.name().to_string()),
157 line: start_line,
158 column: start_col,
159 end_line,
160 end_column: end_col,
161 message: "Empty line has trailing spaces".to_string(),
162 severity: Severity::Warning,
163 fix: Some(Fix {
164 range: fix_range,
165 replacement: String::new(),
166 }),
167 });
168 }
169 continue;
170 }
171
172 if !self.config.strict {
174 if let Some(line_info) = ctx.line_info(line_num + 1)
176 && line_info.in_code_block
177 {
178 continue;
179 }
180 }
181
182 let is_truly_last_line = line_num == lines.len() - 1 && !content.ends_with('\n');
184 let has_only_ascii_trailing = trailing_ascii_spaces == trailing_all_whitespace;
185 if !self.config.strict
186 && !is_truly_last_line
187 && has_only_ascii_trailing
188 && trailing_ascii_spaces == self.config.br_spaces.get()
189 {
190 continue;
191 }
192
193 let trimmed = if line_is_ascii {
196 &line[..trimmed_len]
197 } else {
198 line.trim_end()
199 };
200 let is_empty_blockquote_with_space = trimmed.chars().all(|c| c == '>' || c == ' ' || c == '\t')
201 && trimmed.contains('>')
202 && has_only_ascii_trailing
203 && trailing_ascii_spaces == 1;
204
205 if is_empty_blockquote_with_space {
206 continue; }
208 let (start_line, start_col, end_line, end_col) = if line_is_ascii {
210 Self::calculate_trailing_range_ascii(line_num + 1, line.len(), trimmed.len())
211 } else {
212 calculate_trailing_range(line_num + 1, line, trimmed.len())
213 };
214 let line_start = *ctx.line_offsets.get(line_num).unwrap_or(&0);
215 let fix_range = if line_is_ascii {
216 let start = line_start + trimmed.len();
217 let end = start + trailing_all_whitespace;
218 start..end
219 } else {
220 _line_index.line_col_to_byte_range_with_length(
221 line_num + 1,
222 trimmed.chars().count() + 1,
223 trailing_all_whitespace,
224 )
225 };
226
227 warnings.push(LintWarning {
228 rule_name: Some(self.name().to_string()),
229 line: start_line,
230 column: start_col,
231 end_line,
232 end_column: end_col,
233 message: if trailing_all_whitespace == 1 {
234 "Trailing space found".to_string()
235 } else {
236 format!("{trailing_all_whitespace} trailing spaces found")
237 },
238 severity: Severity::Warning,
239 fix: Some(Fix {
240 range: fix_range,
241 replacement: String::new(),
242 }),
243 });
244 }
245
246 Ok(warnings)
247 }
248
249 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
250 let content = ctx.content;
251
252 if self.config.strict {
254 return Ok(get_cached_regex(r"(?m)[\p{White_Space}&&[^\n\r]]+$")
257 .unwrap()
258 .replace_all(content, "")
259 .to_string());
260 }
261
262 let lines = ctx.raw_lines();
265 let mut result = String::with_capacity(content.len()); for (i, line) in lines.iter().enumerate() {
268 let line_is_ascii = line.is_ascii();
269 let needs_processing = if line_is_ascii {
272 line.ends_with(' ')
273 } else {
274 Self::has_trailing_whitespace(line)
275 };
276 if !needs_processing {
277 result.push_str(line);
278 result.push('\n');
279 continue;
280 }
281
282 let trimmed = line.trim_end();
283 let trailing_ascii_spaces = Self::count_trailing_spaces(line);
285 let trailing_all_whitespace = if line_is_ascii {
287 trailing_ascii_spaces
288 } else {
289 Self::count_trailing_whitespace(line)
290 };
291 let has_only_ascii_trailing = trailing_ascii_spaces == trailing_all_whitespace;
293
294 if trimmed.is_empty() {
296 let prev_line = if i > 0 { Some(lines[i - 1]) } else { None };
298 if self.config.list_item_empty_lines && Self::is_empty_list_item_line(line, prev_line) {
299 result.push_str(line);
300 } else {
301 }
303 result.push('\n');
304 continue;
305 }
306
307 if let Some(line_info) = ctx.line_info(i + 1)
309 && line_info.in_code_block
310 {
311 result.push_str(line);
312 result.push('\n');
313 continue;
314 }
315
316 let is_truly_last_line = i == lines.len() - 1 && !content.ends_with('\n');
320
321 result.push_str(trimmed);
322
323 let is_heading = if let Some(line_info) = ctx.line_info(i + 1) {
325 line_info.heading.is_some()
326 } else {
327 trimmed.starts_with('#')
329 };
330
331 let is_empty_blockquote = if let Some(line_info) = ctx.line_info(i + 1) {
333 line_info.blockquote.as_ref().is_some_and(|bq| bq.content.is_empty())
334 } else {
335 false
336 };
337
338 if !self.config.strict
342 && !is_truly_last_line
343 && has_only_ascii_trailing
344 && trailing_ascii_spaces == self.config.br_spaces.get()
345 && !is_heading
346 && !is_empty_blockquote
347 {
348 match self.config.br_spaces.get() {
350 0 => {}
351 1 => result.push(' '),
352 2 => result.push_str(" "),
353 n => result.push_str(&" ".repeat(n)),
354 }
355 }
356 result.push('\n');
357 }
358
359 if !content.ends_with('\n') && result.ends_with('\n') {
361 result.pop();
362 }
363
364 Ok(result)
365 }
366
367 fn as_any(&self) -> &dyn std::any::Any {
368 self
369 }
370
371 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
372 ctx.content.is_empty()
377 }
378
379 fn category(&self) -> RuleCategory {
380 RuleCategory::Whitespace
381 }
382
383 fn default_config_section(&self) -> Option<(String, toml::Value)> {
384 let default_config = MD009Config::default();
385 let json_value = serde_json::to_value(&default_config).ok()?;
386 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
387
388 if let toml::Value::Table(table) = toml_value {
389 if !table.is_empty() {
390 Some((MD009Config::RULE_NAME.to_string(), toml::Value::Table(table)))
391 } else {
392 None
393 }
394 } else {
395 None
396 }
397 }
398
399 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
400 where
401 Self: Sized,
402 {
403 let rule_config = crate::rule_config_serde::load_rule_config::<MD009Config>(config);
404 Box::new(Self::from_config_struct(rule_config))
405 }
406}
407
408#[cfg(test)]
409mod tests {
410 use super::*;
411 use crate::lint_context::LintContext;
412 use crate::rule::Rule;
413
414 #[test]
415 fn test_no_trailing_spaces() {
416 let rule = MD009TrailingSpaces::default();
417 let content = "This is a line\nAnother line\nNo trailing spaces";
418 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
419 let result = rule.check(&ctx).unwrap();
420 assert!(result.is_empty());
421 }
422
423 #[test]
424 fn test_basic_trailing_spaces() {
425 let rule = MD009TrailingSpaces::default();
426 let content = "Line with spaces \nAnother line \nClean line";
427 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
428 let result = rule.check(&ctx).unwrap();
429 assert_eq!(result.len(), 1);
431 assert_eq!(result[0].line, 1);
432 assert_eq!(result[0].message, "3 trailing spaces found");
433 }
434
435 #[test]
436 fn test_fix_basic_trailing_spaces() {
437 let rule = MD009TrailingSpaces::default();
438 let content = "Line with spaces \nAnother line \nClean line";
439 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
440 let fixed = rule.fix(&ctx).unwrap();
441 assert_eq!(fixed, "Line with spaces\nAnother line \nClean line");
445 }
446
447 #[test]
448 fn test_strict_mode() {
449 let rule = MD009TrailingSpaces::new(2, true);
450 let content = "Line with spaces \nCode block: \n``` \nCode with spaces \n``` ";
451 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
452 let result = rule.check(&ctx).unwrap();
453 assert_eq!(result.len(), 5);
455
456 let fixed = rule.fix(&ctx).unwrap();
457 assert_eq!(fixed, "Line with spaces\nCode block:\n```\nCode with spaces\n```");
458 }
459
460 #[test]
461 fn test_non_strict_mode_with_code_blocks() {
462 let rule = MD009TrailingSpaces::new(2, false);
463 let content = "Line with spaces \n```\nCode with spaces \n```\nOutside code ";
464 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
465 let result = rule.check(&ctx).unwrap();
466 assert_eq!(result.len(), 1);
470 assert_eq!(result[0].line, 5);
471 }
472
473 #[test]
474 fn test_br_spaces_preservation() {
475 let rule = MD009TrailingSpaces::new(2, false);
476 let content = "Line with two spaces \nLine with three spaces \nLine with one space ";
477 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
478 let result = rule.check(&ctx).unwrap();
479 assert_eq!(result.len(), 2);
483 assert_eq!(result[0].line, 2);
484 assert_eq!(result[1].line, 3);
485
486 let fixed = rule.fix(&ctx).unwrap();
487 assert_eq!(
491 fixed,
492 "Line with two spaces \nLine with three spaces\nLine with one space"
493 );
494 }
495
496 #[test]
497 fn test_empty_lines_with_spaces() {
498 let rule = MD009TrailingSpaces::default();
499 let content = "Normal line\n \n \nAnother line";
500 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
501 let result = rule.check(&ctx).unwrap();
502 assert_eq!(result.len(), 2);
503 assert_eq!(result[0].message, "Empty line has trailing spaces");
504 assert_eq!(result[1].message, "Empty line has trailing spaces");
505
506 let fixed = rule.fix(&ctx).unwrap();
507 assert_eq!(fixed, "Normal line\n\n\nAnother line");
508 }
509
510 #[test]
511 fn test_empty_blockquote_lines() {
512 let rule = MD009TrailingSpaces::default();
513 let content = "> Quote\n> \n> More quote";
514 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
515 let result = rule.check(&ctx).unwrap();
516 assert_eq!(result.len(), 1);
517 assert_eq!(result[0].line, 2);
518 assert_eq!(result[0].message, "3 trailing spaces found");
519
520 let fixed = rule.fix(&ctx).unwrap();
521 assert_eq!(fixed, "> Quote\n>\n> More quote"); }
523
524 #[test]
525 fn test_last_line_handling() {
526 let rule = MD009TrailingSpaces::new(2, false);
527
528 let content = "First line \nLast line ";
530 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
531 let result = rule.check(&ctx).unwrap();
532 assert_eq!(result.len(), 1);
534 assert_eq!(result[0].line, 2);
535
536 let fixed = rule.fix(&ctx).unwrap();
537 assert_eq!(fixed, "First line \nLast line");
538
539 let content_with_newline = "First line \nLast line \n";
541 let ctx = LintContext::new(content_with_newline, crate::config::MarkdownFlavor::Standard, None);
542 let result = rule.check(&ctx).unwrap();
543 assert!(result.is_empty());
545 }
546
547 #[test]
548 fn test_single_trailing_space() {
549 let rule = MD009TrailingSpaces::new(2, false);
550 let content = "Line with one space ";
551 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
552 let result = rule.check(&ctx).unwrap();
553 assert_eq!(result.len(), 1);
554 assert_eq!(result[0].message, "Trailing space found");
555 }
556
557 #[test]
558 fn test_tabs_not_spaces() {
559 let rule = MD009TrailingSpaces::default();
560 let content = "Line with tab\t\nLine with spaces ";
561 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
562 let result = rule.check(&ctx).unwrap();
563 assert_eq!(result.len(), 1);
565 assert_eq!(result[0].line, 2);
566 }
567
568 #[test]
569 fn test_mixed_content() {
570 let rule = MD009TrailingSpaces::new(2, false);
571 let mut content = String::new();
573 content.push_str("# Heading");
574 content.push_str(" "); content.push('\n');
576 content.push_str("Normal paragraph\n> Blockquote\n>\n```\nCode block\n```\n- List item\n");
577
578 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
579 let result = rule.check(&ctx).unwrap();
580 assert_eq!(result.len(), 1);
582 assert_eq!(result[0].line, 1);
583 assert!(result[0].message.contains("trailing spaces"));
584 }
585
586 #[test]
587 fn test_column_positions() {
588 let rule = MD009TrailingSpaces::default();
589 let content = "Text ";
590 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
591 let result = rule.check(&ctx).unwrap();
592 assert_eq!(result.len(), 1);
593 assert_eq!(result[0].column, 5); assert_eq!(result[0].end_column, 8); }
596
597 #[test]
598 fn test_default_config() {
599 let rule = MD009TrailingSpaces::default();
600 let config = rule.default_config_section();
601 assert!(config.is_some());
602 let (name, _value) = config.unwrap();
603 assert_eq!(name, "MD009");
604 }
605
606 #[test]
607 fn test_from_config() {
608 let mut config = crate::config::Config::default();
609 let mut rule_config = crate::config::RuleConfig::default();
610 rule_config
611 .values
612 .insert("br_spaces".to_string(), toml::Value::Integer(3));
613 rule_config
614 .values
615 .insert("strict".to_string(), toml::Value::Boolean(true));
616 config.rules.insert("MD009".to_string(), rule_config);
617
618 let rule = MD009TrailingSpaces::from_config(&config);
619 let content = "Line ";
620 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
621 let result = rule.check(&ctx).unwrap();
622 assert_eq!(result.len(), 1);
623
624 let fixed = rule.fix(&ctx).unwrap();
626 assert_eq!(fixed, "Line");
627 }
628
629 #[test]
630 fn test_list_item_empty_lines() {
631 let config = MD009Config {
633 list_item_empty_lines: true,
634 ..Default::default()
635 };
636 let rule = MD009TrailingSpaces::from_config_struct(config);
637
638 let content = "- First item\n \n- Second item";
640 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
641 let result = rule.check(&ctx).unwrap();
642 assert!(result.is_empty());
644
645 let content = "1. First item\n \n2. Second item";
647 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
648 let result = rule.check(&ctx).unwrap();
649 assert!(result.is_empty());
650
651 let content = "Normal paragraph\n \nAnother paragraph";
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].line, 2);
657 }
658
659 #[test]
660 fn test_list_item_empty_lines_disabled() {
661 let rule = MD009TrailingSpaces::default();
663
664 let content = "- First item\n \n- Second item";
665 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
666 let result = rule.check(&ctx).unwrap();
667 assert_eq!(result.len(), 1);
669 assert_eq!(result[0].line, 2);
670 }
671
672 #[test]
673 fn test_performance_large_document() {
674 let rule = MD009TrailingSpaces::default();
675 let mut content = String::new();
676 for i in 0..1000 {
677 content.push_str(&format!("Line {i} with spaces \n"));
678 }
679 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
680 let result = rule.check(&ctx).unwrap();
681 assert_eq!(result.len(), 0);
683 }
684
685 #[test]
686 fn test_preserve_content_after_fix() {
687 let rule = MD009TrailingSpaces::new(2, false);
688 let content = "**Bold** text \n*Italic* text \n[Link](url) ";
689 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
690 let fixed = rule.fix(&ctx).unwrap();
691 assert_eq!(fixed, "**Bold** text \n*Italic* text \n[Link](url)");
692 }
693
694 #[test]
695 fn test_nested_blockquotes() {
696 let rule = MD009TrailingSpaces::default();
697 let content = "> > Nested \n> > \n> Normal ";
698 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
699 let result = rule.check(&ctx).unwrap();
700 assert_eq!(result.len(), 2);
702 assert_eq!(result[0].line, 2);
703 assert_eq!(result[1].line, 3);
704
705 let fixed = rule.fix(&ctx).unwrap();
706 assert_eq!(fixed, "> > Nested \n> >\n> Normal");
710 }
711
712 #[test]
713 fn test_normalized_line_endings() {
714 let rule = MD009TrailingSpaces::default();
715 let content = "Line with spaces \nAnother line ";
717 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
718 let result = rule.check(&ctx).unwrap();
719 assert_eq!(result.len(), 1);
722 assert_eq!(result[0].line, 2);
723 }
724
725 #[test]
726 fn test_issue_80_no_space_normalization() {
727 let rule = MD009TrailingSpaces::new(2, false); let content = "Line with one space \nNext line";
732 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
733 let result = rule.check(&ctx).unwrap();
734 assert_eq!(result.len(), 1);
735 assert_eq!(result[0].line, 1);
736 assert_eq!(result[0].message, "Trailing space found");
737
738 let fixed = rule.fix(&ctx).unwrap();
739 assert_eq!(fixed, "Line with one space\nNext line");
740
741 let content = "Line with three spaces \nNext line";
743 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
744 let result = rule.check(&ctx).unwrap();
745 assert_eq!(result.len(), 1);
746 assert_eq!(result[0].line, 1);
747 assert_eq!(result[0].message, "3 trailing spaces found");
748
749 let fixed = rule.fix(&ctx).unwrap();
750 assert_eq!(fixed, "Line with three spaces\nNext line");
751
752 let content = "Line with two spaces \nNext line";
754 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
755 let result = rule.check(&ctx).unwrap();
756 assert_eq!(result.len(), 0); let fixed = rule.fix(&ctx).unwrap();
759 assert_eq!(fixed, "Line with two spaces \nNext line");
760 }
761
762 #[test]
763 fn test_unicode_whitespace_idempotent_fix() {
764 let rule = MD009TrailingSpaces::default(); let content = "> 0\u{2000} ";
770 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
771 let result = rule.check(&ctx).unwrap();
772 assert_eq!(result.len(), 1, "Should detect trailing Unicode+ASCII whitespace");
773
774 let fixed = rule.fix(&ctx).unwrap();
775 assert_eq!(fixed, "> 0", "Should strip all trailing whitespace in one pass");
776
777 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
779 let fixed2 = rule.fix(&ctx2).unwrap();
780 assert_eq!(fixed, fixed2, "Fix must be idempotent");
781 }
782
783 #[test]
784 fn test_unicode_whitespace_variants() {
785 let rule = MD009TrailingSpaces::default();
786
787 let content = "text\u{2000}\n";
789 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
790 let result = rule.check(&ctx).unwrap();
791 assert_eq!(result.len(), 1);
792 let fixed = rule.fix(&ctx).unwrap();
793 assert_eq!(fixed, "text\n");
794
795 let content = "text\u{2001}\n";
797 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
798 let result = rule.check(&ctx).unwrap();
799 assert_eq!(result.len(), 1);
800 let fixed = rule.fix(&ctx).unwrap();
801 assert_eq!(fixed, "text\n");
802
803 let content = "text\u{3000}\n";
805 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
806 let result = rule.check(&ctx).unwrap();
807 assert_eq!(result.len(), 1);
808 let fixed = rule.fix(&ctx).unwrap();
809 assert_eq!(fixed, "text\n");
810
811 let content = "text\u{2000} \n";
815 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
816 let result = rule.check(&ctx).unwrap();
817 assert_eq!(result.len(), 1, "Unicode+ASCII mix should be flagged");
818 let fixed = rule.fix(&ctx).unwrap();
819 assert_eq!(
820 fixed, "text\n",
821 "All trailing whitespace should be stripped when mix includes Unicode"
822 );
823 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
825 let fixed2 = rule.fix(&ctx2).unwrap();
826 assert_eq!(fixed, fixed2, "Fix must be idempotent");
827
828 let content = "text \nnext\n";
830 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
831 let result = rule.check(&ctx).unwrap();
832 assert_eq!(result.len(), 0, "Pure ASCII br_spaces should still be preserved");
833 }
834
835 #[test]
836 fn test_unicode_whitespace_strict_mode() {
837 let rule = MD009TrailingSpaces::new(2, true);
838
839 let content = "text\u{2000}\nmore\u{3000}\n";
841 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
842 let fixed = rule.fix(&ctx).unwrap();
843 assert_eq!(fixed, "text\nmore\n");
844 }
845
846 #[test]
847 fn test_fix_replacement_always_removes_trailing_spaces() {
848 let rule = MD009TrailingSpaces::new(2, false);
851
852 let content = "Hello \nWorld\n";
855 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
856 let result = rule.check(&ctx).unwrap();
857 assert_eq!(result.len(), 1);
858
859 let fix = result[0].fix.as_ref().expect("Should have a fix");
860 assert_eq!(
861 fix.replacement, "",
862 "Fix replacement should always be empty string (remove trailing spaces)"
863 );
864
865 let fixed = rule.fix(&ctx).unwrap();
867 assert_eq!(fixed, "Hello\nWorld\n");
868 }
869}