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: if !self.config.strict
242 && !is_truly_last_line
243 && has_only_ascii_trailing
244 && trailing_ascii_spaces == self.config.br_spaces.get()
245 {
246 " ".repeat(self.config.br_spaces.get())
247 } else {
248 String::new()
249 },
250 }),
251 });
252 }
253
254 Ok(warnings)
255 }
256
257 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
258 let content = ctx.content;
259
260 if self.config.strict {
262 return Ok(get_cached_regex(r"(?m)[\p{White_Space}&&[^\n\r]]+$")
265 .unwrap()
266 .replace_all(content, "")
267 .to_string());
268 }
269
270 let lines = ctx.raw_lines();
273 let mut result = String::with_capacity(content.len()); for (i, line) in lines.iter().enumerate() {
276 let line_is_ascii = line.is_ascii();
277 let needs_processing = if line_is_ascii {
280 line.ends_with(' ')
281 } else {
282 Self::has_trailing_whitespace(line)
283 };
284 if !needs_processing {
285 result.push_str(line);
286 result.push('\n');
287 continue;
288 }
289
290 let trimmed = line.trim_end();
291 let trailing_ascii_spaces = Self::count_trailing_spaces(line);
293 let trailing_all_whitespace = if line_is_ascii {
295 trailing_ascii_spaces
296 } else {
297 Self::count_trailing_whitespace(line)
298 };
299 let has_only_ascii_trailing = trailing_ascii_spaces == trailing_all_whitespace;
301
302 if trimmed.is_empty() {
304 let prev_line = if i > 0 { Some(lines[i - 1]) } else { None };
306 if self.config.list_item_empty_lines && Self::is_empty_list_item_line(line, prev_line) {
307 result.push_str(line);
308 } else {
309 }
311 result.push('\n');
312 continue;
313 }
314
315 if let Some(line_info) = ctx.line_info(i + 1)
317 && line_info.in_code_block
318 {
319 result.push_str(line);
320 result.push('\n');
321 continue;
322 }
323
324 let is_truly_last_line = i == lines.len() - 1 && !content.ends_with('\n');
328
329 result.push_str(trimmed);
330
331 let is_heading = if let Some(line_info) = ctx.line_info(i + 1) {
333 line_info.heading.is_some()
334 } else {
335 trimmed.starts_with('#')
337 };
338
339 let is_empty_blockquote = if let Some(line_info) = ctx.line_info(i + 1) {
341 line_info.blockquote.as_ref().is_some_and(|bq| bq.content.is_empty())
342 } else {
343 false
344 };
345
346 if !self.config.strict
350 && !is_truly_last_line
351 && has_only_ascii_trailing
352 && trailing_ascii_spaces == self.config.br_spaces.get()
353 && !is_heading
354 && !is_empty_blockquote
355 {
356 match self.config.br_spaces.get() {
358 0 => {}
359 1 => result.push(' '),
360 2 => result.push_str(" "),
361 n => result.push_str(&" ".repeat(n)),
362 }
363 }
364 result.push('\n');
365 }
366
367 if !content.ends_with('\n') && result.ends_with('\n') {
369 result.pop();
370 }
371
372 Ok(result)
373 }
374
375 fn as_any(&self) -> &dyn std::any::Any {
376 self
377 }
378
379 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
380 ctx.content.is_empty()
385 }
386
387 fn category(&self) -> RuleCategory {
388 RuleCategory::Whitespace
389 }
390
391 fn default_config_section(&self) -> Option<(String, toml::Value)> {
392 let default_config = MD009Config::default();
393 let json_value = serde_json::to_value(&default_config).ok()?;
394 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
395
396 if let toml::Value::Table(table) = toml_value {
397 if !table.is_empty() {
398 Some((MD009Config::RULE_NAME.to_string(), toml::Value::Table(table)))
399 } else {
400 None
401 }
402 } else {
403 None
404 }
405 }
406
407 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
408 where
409 Self: Sized,
410 {
411 let rule_config = crate::rule_config_serde::load_rule_config::<MD009Config>(config);
412 Box::new(Self::from_config_struct(rule_config))
413 }
414}
415
416#[cfg(test)]
417mod tests {
418 use super::*;
419 use crate::lint_context::LintContext;
420 use crate::rule::Rule;
421
422 #[test]
423 fn test_no_trailing_spaces() {
424 let rule = MD009TrailingSpaces::default();
425 let content = "This is a line\nAnother line\nNo trailing spaces";
426 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
427 let result = rule.check(&ctx).unwrap();
428 assert!(result.is_empty());
429 }
430
431 #[test]
432 fn test_basic_trailing_spaces() {
433 let rule = MD009TrailingSpaces::default();
434 let content = "Line with spaces \nAnother line \nClean line";
435 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
436 let result = rule.check(&ctx).unwrap();
437 assert_eq!(result.len(), 1);
439 assert_eq!(result[0].line, 1);
440 assert_eq!(result[0].message, "3 trailing spaces found");
441 }
442
443 #[test]
444 fn test_fix_basic_trailing_spaces() {
445 let rule = MD009TrailingSpaces::default();
446 let content = "Line with spaces \nAnother line \nClean line";
447 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
448 let fixed = rule.fix(&ctx).unwrap();
449 assert_eq!(fixed, "Line with spaces\nAnother line \nClean line");
453 }
454
455 #[test]
456 fn test_strict_mode() {
457 let rule = MD009TrailingSpaces::new(2, true);
458 let content = "Line with spaces \nCode block: \n``` \nCode with spaces \n``` ";
459 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
460 let result = rule.check(&ctx).unwrap();
461 assert_eq!(result.len(), 5);
463
464 let fixed = rule.fix(&ctx).unwrap();
465 assert_eq!(fixed, "Line with spaces\nCode block:\n```\nCode with spaces\n```");
466 }
467
468 #[test]
469 fn test_non_strict_mode_with_code_blocks() {
470 let rule = MD009TrailingSpaces::new(2, false);
471 let content = "Line with spaces \n```\nCode with spaces \n```\nOutside code ";
472 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
473 let result = rule.check(&ctx).unwrap();
474 assert_eq!(result.len(), 1);
478 assert_eq!(result[0].line, 5);
479 }
480
481 #[test]
482 fn test_br_spaces_preservation() {
483 let rule = MD009TrailingSpaces::new(2, false);
484 let content = "Line with two spaces \nLine with three spaces \nLine with one space ";
485 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
486 let result = rule.check(&ctx).unwrap();
487 assert_eq!(result.len(), 2);
491 assert_eq!(result[0].line, 2);
492 assert_eq!(result[1].line, 3);
493
494 let fixed = rule.fix(&ctx).unwrap();
495 assert_eq!(
499 fixed,
500 "Line with two spaces \nLine with three spaces\nLine with one space"
501 );
502 }
503
504 #[test]
505 fn test_empty_lines_with_spaces() {
506 let rule = MD009TrailingSpaces::default();
507 let content = "Normal line\n \n \nAnother line";
508 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
509 let result = rule.check(&ctx).unwrap();
510 assert_eq!(result.len(), 2);
511 assert_eq!(result[0].message, "Empty line has trailing spaces");
512 assert_eq!(result[1].message, "Empty line has trailing spaces");
513
514 let fixed = rule.fix(&ctx).unwrap();
515 assert_eq!(fixed, "Normal line\n\n\nAnother line");
516 }
517
518 #[test]
519 fn test_empty_blockquote_lines() {
520 let rule = MD009TrailingSpaces::default();
521 let content = "> Quote\n> \n> More quote";
522 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
523 let result = rule.check(&ctx).unwrap();
524 assert_eq!(result.len(), 1);
525 assert_eq!(result[0].line, 2);
526 assert_eq!(result[0].message, "3 trailing spaces found");
527
528 let fixed = rule.fix(&ctx).unwrap();
529 assert_eq!(fixed, "> Quote\n>\n> More quote"); }
531
532 #[test]
533 fn test_last_line_handling() {
534 let rule = MD009TrailingSpaces::new(2, false);
535
536 let content = "First line \nLast line ";
538 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
539 let result = rule.check(&ctx).unwrap();
540 assert_eq!(result.len(), 1);
542 assert_eq!(result[0].line, 2);
543
544 let fixed = rule.fix(&ctx).unwrap();
545 assert_eq!(fixed, "First line \nLast line");
546
547 let content_with_newline = "First line \nLast line \n";
549 let ctx = LintContext::new(content_with_newline, crate::config::MarkdownFlavor::Standard, None);
550 let result = rule.check(&ctx).unwrap();
551 assert!(result.is_empty());
553 }
554
555 #[test]
556 fn test_single_trailing_space() {
557 let rule = MD009TrailingSpaces::new(2, false);
558 let content = "Line with one space ";
559 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
560 let result = rule.check(&ctx).unwrap();
561 assert_eq!(result.len(), 1);
562 assert_eq!(result[0].message, "Trailing space found");
563 }
564
565 #[test]
566 fn test_tabs_not_spaces() {
567 let rule = MD009TrailingSpaces::default();
568 let content = "Line with tab\t\nLine with spaces ";
569 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
570 let result = rule.check(&ctx).unwrap();
571 assert_eq!(result.len(), 1);
573 assert_eq!(result[0].line, 2);
574 }
575
576 #[test]
577 fn test_mixed_content() {
578 let rule = MD009TrailingSpaces::new(2, false);
579 let mut content = String::new();
581 content.push_str("# Heading");
582 content.push_str(" "); content.push('\n');
584 content.push_str("Normal paragraph\n> Blockquote\n>\n```\nCode block\n```\n- List item\n");
585
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, 1);
591 assert!(result[0].message.contains("trailing spaces"));
592 }
593
594 #[test]
595 fn test_column_positions() {
596 let rule = MD009TrailingSpaces::default();
597 let content = "Text ";
598 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
599 let result = rule.check(&ctx).unwrap();
600 assert_eq!(result.len(), 1);
601 assert_eq!(result[0].column, 5); assert_eq!(result[0].end_column, 8); }
604
605 #[test]
606 fn test_default_config() {
607 let rule = MD009TrailingSpaces::default();
608 let config = rule.default_config_section();
609 assert!(config.is_some());
610 let (name, _value) = config.unwrap();
611 assert_eq!(name, "MD009");
612 }
613
614 #[test]
615 fn test_from_config() {
616 let mut config = crate::config::Config::default();
617 let mut rule_config = crate::config::RuleConfig::default();
618 rule_config
619 .values
620 .insert("br_spaces".to_string(), toml::Value::Integer(3));
621 rule_config
622 .values
623 .insert("strict".to_string(), toml::Value::Boolean(true));
624 config.rules.insert("MD009".to_string(), rule_config);
625
626 let rule = MD009TrailingSpaces::from_config(&config);
627 let content = "Line ";
628 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
629 let result = rule.check(&ctx).unwrap();
630 assert_eq!(result.len(), 1);
631
632 let fixed = rule.fix(&ctx).unwrap();
634 assert_eq!(fixed, "Line");
635 }
636
637 #[test]
638 fn test_list_item_empty_lines() {
639 let config = MD009Config {
641 list_item_empty_lines: true,
642 ..Default::default()
643 };
644 let rule = MD009TrailingSpaces::from_config_struct(config);
645
646 let content = "- First item\n \n- Second item";
648 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
649 let result = rule.check(&ctx).unwrap();
650 assert!(result.is_empty());
652
653 let content = "1. First item\n \n2. Second item";
655 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
656 let result = rule.check(&ctx).unwrap();
657 assert!(result.is_empty());
658
659 let content = "Normal paragraph\n \nAnother paragraph";
661 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
662 let result = rule.check(&ctx).unwrap();
663 assert_eq!(result.len(), 1);
664 assert_eq!(result[0].line, 2);
665 }
666
667 #[test]
668 fn test_list_item_empty_lines_disabled() {
669 let rule = MD009TrailingSpaces::default();
671
672 let content = "- First item\n \n- Second item";
673 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
674 let result = rule.check(&ctx).unwrap();
675 assert_eq!(result.len(), 1);
677 assert_eq!(result[0].line, 2);
678 }
679
680 #[test]
681 fn test_performance_large_document() {
682 let rule = MD009TrailingSpaces::default();
683 let mut content = String::new();
684 for i in 0..1000 {
685 content.push_str(&format!("Line {i} with spaces \n"));
686 }
687 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
688 let result = rule.check(&ctx).unwrap();
689 assert_eq!(result.len(), 0);
691 }
692
693 #[test]
694 fn test_preserve_content_after_fix() {
695 let rule = MD009TrailingSpaces::new(2, false);
696 let content = "**Bold** text \n*Italic* text \n[Link](url) ";
697 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
698 let fixed = rule.fix(&ctx).unwrap();
699 assert_eq!(fixed, "**Bold** text \n*Italic* text \n[Link](url)");
700 }
701
702 #[test]
703 fn test_nested_blockquotes() {
704 let rule = MD009TrailingSpaces::default();
705 let content = "> > Nested \n> > \n> Normal ";
706 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
707 let result = rule.check(&ctx).unwrap();
708 assert_eq!(result.len(), 2);
710 assert_eq!(result[0].line, 2);
711 assert_eq!(result[1].line, 3);
712
713 let fixed = rule.fix(&ctx).unwrap();
714 assert_eq!(fixed, "> > Nested \n> >\n> Normal");
718 }
719
720 #[test]
721 fn test_normalized_line_endings() {
722 let rule = MD009TrailingSpaces::default();
723 let content = "Line with spaces \nAnother line ";
725 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
726 let result = rule.check(&ctx).unwrap();
727 assert_eq!(result.len(), 1);
730 assert_eq!(result[0].line, 2);
731 }
732
733 #[test]
734 fn test_issue_80_no_space_normalization() {
735 let rule = MD009TrailingSpaces::new(2, false); let content = "Line with one space \nNext line";
740 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
741 let result = rule.check(&ctx).unwrap();
742 assert_eq!(result.len(), 1);
743 assert_eq!(result[0].line, 1);
744 assert_eq!(result[0].message, "Trailing space found");
745
746 let fixed = rule.fix(&ctx).unwrap();
747 assert_eq!(fixed, "Line with one space\nNext line");
748
749 let content = "Line with three spaces \nNext line";
751 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
752 let result = rule.check(&ctx).unwrap();
753 assert_eq!(result.len(), 1);
754 assert_eq!(result[0].line, 1);
755 assert_eq!(result[0].message, "3 trailing spaces found");
756
757 let fixed = rule.fix(&ctx).unwrap();
758 assert_eq!(fixed, "Line with three spaces\nNext line");
759
760 let content = "Line with two spaces \nNext line";
762 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
763 let result = rule.check(&ctx).unwrap();
764 assert_eq!(result.len(), 0); let fixed = rule.fix(&ctx).unwrap();
767 assert_eq!(fixed, "Line with two spaces \nNext line");
768 }
769
770 #[test]
771 fn test_unicode_whitespace_idempotent_fix() {
772 let rule = MD009TrailingSpaces::default(); let content = "> 0\u{2000} ";
778 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
779 let result = rule.check(&ctx).unwrap();
780 assert_eq!(result.len(), 1, "Should detect trailing Unicode+ASCII whitespace");
781
782 let fixed = rule.fix(&ctx).unwrap();
783 assert_eq!(fixed, "> 0", "Should strip all trailing whitespace in one pass");
784
785 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
787 let fixed2 = rule.fix(&ctx2).unwrap();
788 assert_eq!(fixed, fixed2, "Fix must be idempotent");
789 }
790
791 #[test]
792 fn test_unicode_whitespace_variants() {
793 let rule = MD009TrailingSpaces::default();
794
795 let content = "text\u{2000}\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{2001}\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{3000}\n";
813 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
814 let result = rule.check(&ctx).unwrap();
815 assert_eq!(result.len(), 1);
816 let fixed = rule.fix(&ctx).unwrap();
817 assert_eq!(fixed, "text\n");
818
819 let content = "text\u{2000} \n";
823 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
824 let result = rule.check(&ctx).unwrap();
825 assert_eq!(result.len(), 1, "Unicode+ASCII mix should be flagged");
826 let fixed = rule.fix(&ctx).unwrap();
827 assert_eq!(
828 fixed, "text\n",
829 "All trailing whitespace should be stripped when mix includes Unicode"
830 );
831 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
833 let fixed2 = rule.fix(&ctx2).unwrap();
834 assert_eq!(fixed, fixed2, "Fix must be idempotent");
835
836 let content = "text \nnext\n";
838 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
839 let result = rule.check(&ctx).unwrap();
840 assert_eq!(result.len(), 0, "Pure ASCII br_spaces should still be preserved");
841 }
842
843 #[test]
844 fn test_unicode_whitespace_strict_mode() {
845 let rule = MD009TrailingSpaces::new(2, true);
846
847 let content = "text\u{2000}\nmore\u{3000}\n";
849 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
850 let fixed = rule.fix(&ctx).unwrap();
851 assert_eq!(fixed, "text\nmore\n");
852 }
853}