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 let lines = ctx.raw_lines();
257 let mut result = String::with_capacity(content.len());
258 for (i, line) in lines.iter().enumerate() {
259 let line_num = i + 1;
260 if ctx.inline_config().is_rule_disabled(self.name(), line_num) {
261 result.push_str(line);
262 } else {
263 result.push_str(
264 &get_cached_regex(r"[\p{White_Space}&&[^\n\r]]+$")
265 .unwrap()
266 .replace_all(line, ""),
267 );
268 }
269 if i < lines.len() - 1 || content.ends_with('\n') {
270 result.push('\n');
271 }
272 }
273 if !content.ends_with('\n') && result.ends_with('\n') {
274 result.pop();
275 }
276 return Ok(result);
277 }
278
279 let lines = ctx.raw_lines();
282 let mut result = String::with_capacity(content.len()); for (i, line) in lines.iter().enumerate() {
285 let line_num = i + 1;
286 if ctx.inline_config().is_rule_disabled(self.name(), line_num) {
288 result.push_str(line);
289 result.push('\n');
290 continue;
291 }
292
293 let line_is_ascii = line.is_ascii();
294 let needs_processing = if line_is_ascii {
297 line.ends_with(' ')
298 } else {
299 Self::has_trailing_whitespace(line)
300 };
301 if !needs_processing {
302 result.push_str(line);
303 result.push('\n');
304 continue;
305 }
306
307 let trimmed = line.trim_end();
308 let trailing_ascii_spaces = Self::count_trailing_spaces(line);
310 let trailing_all_whitespace = if line_is_ascii {
312 trailing_ascii_spaces
313 } else {
314 Self::count_trailing_whitespace(line)
315 };
316 let has_only_ascii_trailing = trailing_ascii_spaces == trailing_all_whitespace;
318
319 if trimmed.is_empty() {
321 let prev_line = if i > 0 { Some(lines[i - 1]) } else { None };
323 if self.config.list_item_empty_lines && Self::is_empty_list_item_line(line, prev_line) {
324 result.push_str(line);
325 } else {
326 }
328 result.push('\n');
329 continue;
330 }
331
332 if let Some(line_info) = ctx.line_info(i + 1)
334 && line_info.in_code_block
335 {
336 result.push_str(line);
337 result.push('\n');
338 continue;
339 }
340
341 let is_truly_last_line = i == lines.len() - 1 && !content.ends_with('\n');
345
346 result.push_str(trimmed);
347
348 let is_heading = if let Some(line_info) = ctx.line_info(i + 1) {
350 line_info.heading.is_some()
351 } else {
352 trimmed.starts_with('#')
354 };
355
356 let is_empty_blockquote = if let Some(line_info) = ctx.line_info(i + 1) {
358 line_info.blockquote.as_ref().is_some_and(|bq| bq.content.is_empty())
359 } else {
360 false
361 };
362
363 if !self.config.strict
367 && !is_truly_last_line
368 && has_only_ascii_trailing
369 && trailing_ascii_spaces == self.config.br_spaces.get()
370 && !is_heading
371 && !is_empty_blockquote
372 {
373 match self.config.br_spaces.get() {
375 0 => {}
376 1 => result.push(' '),
377 2 => result.push_str(" "),
378 n => result.push_str(&" ".repeat(n)),
379 }
380 }
381 result.push('\n');
382 }
383
384 if !content.ends_with('\n') && result.ends_with('\n') {
386 result.pop();
387 }
388
389 Ok(result)
390 }
391
392 fn as_any(&self) -> &dyn std::any::Any {
393 self
394 }
395
396 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
397 ctx.content.is_empty()
402 }
403
404 fn category(&self) -> RuleCategory {
405 RuleCategory::Whitespace
406 }
407
408 fn default_config_section(&self) -> Option<(String, toml::Value)> {
409 let default_config = MD009Config::default();
410 let json_value = serde_json::to_value(&default_config).ok()?;
411 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
412
413 if let toml::Value::Table(table) = toml_value {
414 if !table.is_empty() {
415 Some((MD009Config::RULE_NAME.to_string(), toml::Value::Table(table)))
416 } else {
417 None
418 }
419 } else {
420 None
421 }
422 }
423
424 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
425 where
426 Self: Sized,
427 {
428 let rule_config = crate::rule_config_serde::load_rule_config::<MD009Config>(config);
429 Box::new(Self::from_config_struct(rule_config))
430 }
431}
432
433#[cfg(test)]
434mod tests {
435 use super::*;
436 use crate::lint_context::LintContext;
437 use crate::rule::Rule;
438
439 #[test]
440 fn test_no_trailing_spaces() {
441 let rule = MD009TrailingSpaces::default();
442 let content = "This is a line\nAnother line\nNo trailing spaces";
443 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
444 let result = rule.check(&ctx).unwrap();
445 assert!(result.is_empty());
446 }
447
448 #[test]
449 fn test_basic_trailing_spaces() {
450 let rule = MD009TrailingSpaces::default();
451 let content = "Line with spaces \nAnother line \nClean line";
452 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
453 let result = rule.check(&ctx).unwrap();
454 assert_eq!(result.len(), 1);
456 assert_eq!(result[0].line, 1);
457 assert_eq!(result[0].message, "3 trailing spaces found");
458 }
459
460 #[test]
461 fn test_fix_basic_trailing_spaces() {
462 let rule = MD009TrailingSpaces::default();
463 let content = "Line with spaces \nAnother line \nClean line";
464 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
465 let fixed = rule.fix(&ctx).unwrap();
466 assert_eq!(fixed, "Line with spaces\nAnother line \nClean line");
470 }
471
472 #[test]
473 fn test_strict_mode() {
474 let rule = MD009TrailingSpaces::new(2, true);
475 let content = "Line with spaces \nCode block: \n``` \nCode with spaces \n``` ";
476 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
477 let result = rule.check(&ctx).unwrap();
478 assert_eq!(result.len(), 5);
480
481 let fixed = rule.fix(&ctx).unwrap();
482 assert_eq!(fixed, "Line with spaces\nCode block:\n```\nCode with spaces\n```");
483 }
484
485 #[test]
486 fn test_non_strict_mode_with_code_blocks() {
487 let rule = MD009TrailingSpaces::new(2, false);
488 let content = "Line with spaces \n```\nCode with spaces \n```\nOutside code ";
489 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
490 let result = rule.check(&ctx).unwrap();
491 assert_eq!(result.len(), 1);
495 assert_eq!(result[0].line, 5);
496 }
497
498 #[test]
499 fn test_br_spaces_preservation() {
500 let rule = MD009TrailingSpaces::new(2, false);
501 let content = "Line with two spaces \nLine with three spaces \nLine with one space ";
502 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
503 let result = rule.check(&ctx).unwrap();
504 assert_eq!(result.len(), 2);
508 assert_eq!(result[0].line, 2);
509 assert_eq!(result[1].line, 3);
510
511 let fixed = rule.fix(&ctx).unwrap();
512 assert_eq!(
516 fixed,
517 "Line with two spaces \nLine with three spaces\nLine with one space"
518 );
519 }
520
521 #[test]
522 fn test_empty_lines_with_spaces() {
523 let rule = MD009TrailingSpaces::default();
524 let content = "Normal line\n \n \nAnother line";
525 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
526 let result = rule.check(&ctx).unwrap();
527 assert_eq!(result.len(), 2);
528 assert_eq!(result[0].message, "Empty line has trailing spaces");
529 assert_eq!(result[1].message, "Empty line has trailing spaces");
530
531 let fixed = rule.fix(&ctx).unwrap();
532 assert_eq!(fixed, "Normal line\n\n\nAnother line");
533 }
534
535 #[test]
536 fn test_empty_blockquote_lines() {
537 let rule = MD009TrailingSpaces::default();
538 let content = "> Quote\n> \n> More quote";
539 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
540 let result = rule.check(&ctx).unwrap();
541 assert_eq!(result.len(), 1);
542 assert_eq!(result[0].line, 2);
543 assert_eq!(result[0].message, "3 trailing spaces found");
544
545 let fixed = rule.fix(&ctx).unwrap();
546 assert_eq!(fixed, "> Quote\n>\n> More quote"); }
548
549 #[test]
550 fn test_last_line_handling() {
551 let rule = MD009TrailingSpaces::new(2, false);
552
553 let content = "First line \nLast line ";
555 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
556 let result = rule.check(&ctx).unwrap();
557 assert_eq!(result.len(), 1);
559 assert_eq!(result[0].line, 2);
560
561 let fixed = rule.fix(&ctx).unwrap();
562 assert_eq!(fixed, "First line \nLast line");
563
564 let content_with_newline = "First line \nLast line \n";
566 let ctx = LintContext::new(content_with_newline, crate::config::MarkdownFlavor::Standard, None);
567 let result = rule.check(&ctx).unwrap();
568 assert!(result.is_empty());
570 }
571
572 #[test]
573 fn test_single_trailing_space() {
574 let rule = MD009TrailingSpaces::new(2, false);
575 let content = "Line with one space ";
576 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
577 let result = rule.check(&ctx).unwrap();
578 assert_eq!(result.len(), 1);
579 assert_eq!(result[0].message, "Trailing space found");
580 }
581
582 #[test]
583 fn test_tabs_not_spaces() {
584 let rule = MD009TrailingSpaces::default();
585 let content = "Line with tab\t\nLine with spaces ";
586 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
587 let result = rule.check(&ctx).unwrap();
588 assert_eq!(result.len(), 1);
590 assert_eq!(result[0].line, 2);
591 }
592
593 #[test]
594 fn test_mixed_content() {
595 let rule = MD009TrailingSpaces::new(2, false);
596 let mut content = String::new();
598 content.push_str("# Heading");
599 content.push_str(" "); content.push('\n');
601 content.push_str("Normal paragraph\n> Blockquote\n>\n```\nCode block\n```\n- List item\n");
602
603 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
604 let result = rule.check(&ctx).unwrap();
605 assert_eq!(result.len(), 1);
607 assert_eq!(result[0].line, 1);
608 assert!(result[0].message.contains("trailing spaces"));
609 }
610
611 #[test]
612 fn test_column_positions() {
613 let rule = MD009TrailingSpaces::default();
614 let content = "Text ";
615 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
616 let result = rule.check(&ctx).unwrap();
617 assert_eq!(result.len(), 1);
618 assert_eq!(result[0].column, 5); assert_eq!(result[0].end_column, 8); }
621
622 #[test]
623 fn test_default_config() {
624 let rule = MD009TrailingSpaces::default();
625 let config = rule.default_config_section();
626 assert!(config.is_some());
627 let (name, _value) = config.unwrap();
628 assert_eq!(name, "MD009");
629 }
630
631 #[test]
632 fn test_from_config() {
633 let mut config = crate::config::Config::default();
634 let mut rule_config = crate::config::RuleConfig::default();
635 rule_config
636 .values
637 .insert("br_spaces".to_string(), toml::Value::Integer(3));
638 rule_config
639 .values
640 .insert("strict".to_string(), toml::Value::Boolean(true));
641 config.rules.insert("MD009".to_string(), rule_config);
642
643 let rule = MD009TrailingSpaces::from_config(&config);
644 let content = "Line ";
645 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
646 let result = rule.check(&ctx).unwrap();
647 assert_eq!(result.len(), 1);
648
649 let fixed = rule.fix(&ctx).unwrap();
651 assert_eq!(fixed, "Line");
652 }
653
654 #[test]
655 fn test_list_item_empty_lines() {
656 let config = MD009Config {
658 list_item_empty_lines: true,
659 ..Default::default()
660 };
661 let rule = MD009TrailingSpaces::from_config_struct(config);
662
663 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!(result.is_empty());
669
670 let content = "1. First item\n \n2. Second item";
672 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
673 let result = rule.check(&ctx).unwrap();
674 assert!(result.is_empty());
675
676 let content = "Normal paragraph\n \nAnother paragraph";
678 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
679 let result = rule.check(&ctx).unwrap();
680 assert_eq!(result.len(), 1);
681 assert_eq!(result[0].line, 2);
682 }
683
684 #[test]
685 fn test_list_item_empty_lines_disabled() {
686 let rule = MD009TrailingSpaces::default();
688
689 let content = "- First item\n \n- Second item";
690 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
691 let result = rule.check(&ctx).unwrap();
692 assert_eq!(result.len(), 1);
694 assert_eq!(result[0].line, 2);
695 }
696
697 #[test]
698 fn test_performance_large_document() {
699 let rule = MD009TrailingSpaces::default();
700 let mut content = String::new();
701 for i in 0..1000 {
702 content.push_str(&format!("Line {i} with spaces \n"));
703 }
704 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
705 let result = rule.check(&ctx).unwrap();
706 assert_eq!(result.len(), 0);
708 }
709
710 #[test]
711 fn test_preserve_content_after_fix() {
712 let rule = MD009TrailingSpaces::new(2, false);
713 let content = "**Bold** text \n*Italic* text \n[Link](url) ";
714 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
715 let fixed = rule.fix(&ctx).unwrap();
716 assert_eq!(fixed, "**Bold** text \n*Italic* text \n[Link](url)");
717 }
718
719 #[test]
720 fn test_nested_blockquotes() {
721 let rule = MD009TrailingSpaces::default();
722 let content = "> > Nested \n> > \n> Normal ";
723 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
724 let result = rule.check(&ctx).unwrap();
725 assert_eq!(result.len(), 2);
727 assert_eq!(result[0].line, 2);
728 assert_eq!(result[1].line, 3);
729
730 let fixed = rule.fix(&ctx).unwrap();
731 assert_eq!(fixed, "> > Nested \n> >\n> Normal");
735 }
736
737 #[test]
738 fn test_normalized_line_endings() {
739 let rule = MD009TrailingSpaces::default();
740 let content = "Line with spaces \nAnother 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);
747 assert_eq!(result[0].line, 2);
748 }
749
750 #[test]
751 fn test_issue_80_no_space_normalization() {
752 let rule = MD009TrailingSpaces::new(2, false); let content = "Line with one space \nNext line";
757 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
758 let result = rule.check(&ctx).unwrap();
759 assert_eq!(result.len(), 1);
760 assert_eq!(result[0].line, 1);
761 assert_eq!(result[0].message, "Trailing space found");
762
763 let fixed = rule.fix(&ctx).unwrap();
764 assert_eq!(fixed, "Line with one space\nNext line");
765
766 let content = "Line with three spaces \nNext line";
768 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
769 let result = rule.check(&ctx).unwrap();
770 assert_eq!(result.len(), 1);
771 assert_eq!(result[0].line, 1);
772 assert_eq!(result[0].message, "3 trailing spaces found");
773
774 let fixed = rule.fix(&ctx).unwrap();
775 assert_eq!(fixed, "Line with three spaces\nNext line");
776
777 let content = "Line with two spaces \nNext line";
779 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
780 let result = rule.check(&ctx).unwrap();
781 assert_eq!(result.len(), 0); let fixed = rule.fix(&ctx).unwrap();
784 assert_eq!(fixed, "Line with two spaces \nNext line");
785 }
786
787 #[test]
788 fn test_unicode_whitespace_idempotent_fix() {
789 let rule = MD009TrailingSpaces::default(); let content = "> 0\u{2000} ";
795 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
796 let result = rule.check(&ctx).unwrap();
797 assert_eq!(result.len(), 1, "Should detect trailing Unicode+ASCII whitespace");
798
799 let fixed = rule.fix(&ctx).unwrap();
800 assert_eq!(fixed, "> 0", "Should strip all trailing whitespace in one pass");
801
802 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
804 let fixed2 = rule.fix(&ctx2).unwrap();
805 assert_eq!(fixed, fixed2, "Fix must be idempotent");
806 }
807
808 #[test]
809 fn test_unicode_whitespace_variants() {
810 let rule = MD009TrailingSpaces::default();
811
812 let content = "text\u{2000}\n";
814 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
815 let result = rule.check(&ctx).unwrap();
816 assert_eq!(result.len(), 1);
817 let fixed = rule.fix(&ctx).unwrap();
818 assert_eq!(fixed, "text\n");
819
820 let content = "text\u{2001}\n";
822 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
823 let result = rule.check(&ctx).unwrap();
824 assert_eq!(result.len(), 1);
825 let fixed = rule.fix(&ctx).unwrap();
826 assert_eq!(fixed, "text\n");
827
828 let content = "text\u{3000}\n";
830 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
831 let result = rule.check(&ctx).unwrap();
832 assert_eq!(result.len(), 1);
833 let fixed = rule.fix(&ctx).unwrap();
834 assert_eq!(fixed, "text\n");
835
836 let content = "text\u{2000} \n";
840 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
841 let result = rule.check(&ctx).unwrap();
842 assert_eq!(result.len(), 1, "Unicode+ASCII mix should be flagged");
843 let fixed = rule.fix(&ctx).unwrap();
844 assert_eq!(
845 fixed, "text\n",
846 "All trailing whitespace should be stripped when mix includes Unicode"
847 );
848 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
850 let fixed2 = rule.fix(&ctx2).unwrap();
851 assert_eq!(fixed, fixed2, "Fix must be idempotent");
852
853 let content = "text \nnext\n";
855 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
856 let result = rule.check(&ctx).unwrap();
857 assert_eq!(result.len(), 0, "Pure ASCII br_spaces should still be preserved");
858 }
859
860 #[test]
861 fn test_unicode_whitespace_strict_mode() {
862 let rule = MD009TrailingSpaces::new(2, true);
863
864 let content = "text\u{2000}\nmore\u{3000}\n";
866 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
867 let fixed = rule.fix(&ctx).unwrap();
868 assert_eq!(fixed, "text\nmore\n");
869 }
870
871 #[test]
872 fn test_fix_replacement_always_removes_trailing_spaces() {
873 let rule = MD009TrailingSpaces::new(2, false);
876
877 let content = "Hello \nWorld\n";
880 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
881 let result = rule.check(&ctx).unwrap();
882 assert_eq!(result.len(), 1);
883
884 let fix = result[0].fix.as_ref().expect("Should have a fix");
885 assert_eq!(
886 fix.replacement, "",
887 "Fix replacement should always be empty string (remove trailing spaces)"
888 );
889
890 let fixed = rule.fix(&ctx).unwrap();
892 assert_eq!(fixed, "Hello\nWorld\n");
893 }
894}