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};
5
6mod md009_config;
7use md009_config::MD009Config;
8
9#[derive(Debug, Clone, Default)]
10pub struct MD009TrailingSpaces {
11 config: MD009Config,
12}
13
14impl MD009TrailingSpaces {
15 pub fn new(br_spaces: usize, strict: bool) -> Self {
16 Self {
17 config: MD009Config {
18 br_spaces: crate::types::BrSpaces::from_const(br_spaces),
19 strict,
20 list_item_empty_lines: false,
21 },
22 }
23 }
24
25 pub const fn from_config_struct(config: MD009Config) -> Self {
26 Self { config }
27 }
28
29 fn count_trailing_spaces(line: &str) -> usize {
30 line.chars().rev().take_while(|&c| c == ' ').count()
31 }
32
33 fn count_trailing_spaces_ascii(line: &str) -> usize {
34 line.as_bytes().iter().rev().take_while(|&&b| b == b' ').count()
35 }
36
37 fn count_trailing_whitespace(line: &str) -> usize {
40 line.chars().rev().take_while(|c| c.is_whitespace()).count()
41 }
42
43 fn trimmed_len_ascii_whitespace(line: &str) -> usize {
44 line.as_bytes()
45 .iter()
46 .rposition(|b| !b.is_ascii_whitespace())
47 .map_or(0, |idx| idx + 1)
48 }
49
50 fn calculate_trailing_range_ascii(
51 line: usize,
52 line_len: usize,
53 content_end: usize,
54 ) -> (usize, usize, usize, usize) {
55 (line, content_end + 1, line, line_len + 1)
57 }
58
59 fn is_empty_list_item_line(line: &str, prev_line: Option<&str>) -> bool {
60 if !line.trim().is_empty() {
64 return false;
65 }
66
67 if let Some(prev) = prev_line {
68 UNORDERED_LIST_MARKER_REGEX.is_match(prev) || ORDERED_LIST_MARKER_REGEX.is_match(prev)
70 } else {
71 false
72 }
73 }
74}
75
76impl Rule for MD009TrailingSpaces {
77 fn name(&self) -> &'static str {
78 "MD009"
79 }
80
81 fn description(&self) -> &'static str {
82 "Trailing spaces should be removed"
83 }
84
85 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
86 let content = ctx.content;
87 let line_index = &ctx.line_index;
88
89 let mut warnings = Vec::new();
90
91 let lines = ctx.raw_lines();
93
94 for (line_num, &line) in lines.iter().enumerate() {
95 if ctx.line_info(line_num + 1).is_some_and(|info| info.in_pymdown_block) {
97 continue;
98 }
99
100 let line_is_ascii = line.is_ascii();
101 let trailing_ascii_spaces = if line_is_ascii {
103 Self::count_trailing_spaces_ascii(line)
104 } else {
105 Self::count_trailing_spaces(line)
106 };
107 let trailing_all_whitespace = if line_is_ascii {
110 trailing_ascii_spaces
111 } else {
112 Self::count_trailing_whitespace(line)
113 };
114
115 if trailing_all_whitespace == 0 {
117 continue;
118 }
119
120 let trimmed_len = if line_is_ascii {
122 Self::trimmed_len_ascii_whitespace(line)
123 } else {
124 line.trim_end().len()
125 };
126 if trimmed_len == 0 {
127 if trailing_all_whitespace > 0 {
128 let prev_line = if line_num > 0 { Some(lines[line_num - 1]) } else { None };
130 if self.config.list_item_empty_lines && Self::is_empty_list_item_line(line, prev_line) {
131 continue;
132 }
133
134 let (start_line, start_col, end_line, end_col) = if line_is_ascii {
136 Self::calculate_trailing_range_ascii(line_num + 1, line.len(), 0)
137 } else {
138 calculate_trailing_range(line_num + 1, line, 0)
139 };
140 let line_start = *ctx.line_offsets.get(line_num).unwrap_or(&0);
141 let fix_range = if line_is_ascii {
142 line_start..line_start + line.len()
143 } else {
144 line_index.line_col_to_byte_range_with_length(line_num + 1, 1, line.chars().count())
145 };
146
147 warnings.push(LintWarning {
148 rule_name: Some(self.name().to_string()),
149 line: start_line,
150 column: start_col,
151 end_line,
152 end_column: end_col,
153 message: "Empty line has trailing spaces".to_string(),
154 severity: Severity::Warning,
155 fix: Some(Fix {
156 range: fix_range,
157 replacement: String::new(),
158 }),
159 });
160 }
161 continue;
162 }
163
164 if !self.config.strict {
166 if let Some(line_info) = ctx.line_info(line_num + 1)
168 && line_info.in_code_block
169 {
170 continue;
171 }
172 }
173
174 let is_truly_last_line = line_num == lines.len() - 1 && !content.ends_with('\n');
176 let has_only_ascii_trailing = trailing_ascii_spaces == trailing_all_whitespace;
177 if !self.config.strict
178 && !is_truly_last_line
179 && has_only_ascii_trailing
180 && trailing_ascii_spaces == self.config.br_spaces.get()
181 {
182 continue;
183 }
184
185 let trimmed = if line_is_ascii {
188 &line[..trimmed_len]
189 } else {
190 line.trim_end()
191 };
192 let is_empty_blockquote_with_space = trimmed.chars().all(|c| c == '>' || c == ' ' || c == '\t')
193 && trimmed.contains('>')
194 && has_only_ascii_trailing
195 && trailing_ascii_spaces == 1;
196
197 if is_empty_blockquote_with_space {
198 continue; }
200 let (start_line, start_col, end_line, end_col) = if line_is_ascii {
202 Self::calculate_trailing_range_ascii(line_num + 1, line.len(), trimmed.len())
203 } else {
204 calculate_trailing_range(line_num + 1, line, trimmed.len())
205 };
206 let line_start = *ctx.line_offsets.get(line_num).unwrap_or(&0);
207 let fix_range = if line_is_ascii {
208 let start = line_start + trimmed.len();
209 let end = start + trailing_all_whitespace;
210 start..end
211 } else {
212 line_index.line_col_to_byte_range_with_length(
213 line_num + 1,
214 trimmed.chars().count() + 1,
215 trailing_all_whitespace,
216 )
217 };
218
219 warnings.push(LintWarning {
220 rule_name: Some(self.name().to_string()),
221 line: start_line,
222 column: start_col,
223 end_line,
224 end_column: end_col,
225 message: if trailing_all_whitespace == 1 {
226 "Trailing space found".to_string()
227 } else {
228 format!("{trailing_all_whitespace} trailing spaces found")
229 },
230 severity: Severity::Warning,
231 fix: Some(Fix {
232 range: fix_range,
233 replacement: String::new(),
234 }),
235 });
236 }
237
238 Ok(warnings)
239 }
240
241 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
242 if self.should_skip(ctx) {
243 return Ok(ctx.content.to_string());
244 }
245 let warnings = self.check(ctx)?;
246 if warnings.is_empty() {
247 return Ok(ctx.content.to_string());
248 }
249 let warnings =
250 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
251 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings).map_err(LintError::InvalidInput)
252 }
253
254 fn as_any(&self) -> &dyn std::any::Any {
255 self
256 }
257
258 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
259 ctx.content.is_empty()
264 }
265
266 fn category(&self) -> RuleCategory {
267 RuleCategory::Whitespace
268 }
269
270 fn default_config_section(&self) -> Option<(String, toml::Value)> {
271 let default_config = MD009Config::default();
272 let json_value = serde_json::to_value(&default_config).ok()?;
273 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
274
275 if let toml::Value::Table(table) = toml_value {
276 if !table.is_empty() {
277 Some((MD009Config::RULE_NAME.to_string(), toml::Value::Table(table)))
278 } else {
279 None
280 }
281 } else {
282 None
283 }
284 }
285
286 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
287 where
288 Self: Sized,
289 {
290 let rule_config = crate::rule_config_serde::load_rule_config::<MD009Config>(config);
291 Box::new(Self::from_config_struct(rule_config))
292 }
293}
294
295#[cfg(test)]
296mod tests {
297 use super::*;
298 use crate::lint_context::LintContext;
299 use crate::rule::Rule;
300
301 #[test]
302 fn test_no_trailing_spaces() {
303 let rule = MD009TrailingSpaces::default();
304 let content = "This is a line\nAnother line\nNo trailing spaces";
305 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
306 let result = rule.check(&ctx).unwrap();
307 assert!(result.is_empty());
308 }
309
310 #[test]
311 fn test_basic_trailing_spaces() {
312 let rule = MD009TrailingSpaces::default();
313 let content = "Line with spaces \nAnother line \nClean line";
314 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
315 let result = rule.check(&ctx).unwrap();
316 assert_eq!(result.len(), 1);
318 assert_eq!(result[0].line, 1);
319 assert_eq!(result[0].message, "3 trailing spaces found");
320 }
321
322 #[test]
323 fn test_fix_basic_trailing_spaces() {
324 let rule = MD009TrailingSpaces::default();
325 let content = "Line with spaces \nAnother line \nClean line";
326 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
327 let fixed = rule.fix(&ctx).unwrap();
328 assert_eq!(fixed, "Line with spaces\nAnother line \nClean line");
332 }
333
334 #[test]
335 fn test_strict_mode() {
336 let rule = MD009TrailingSpaces::new(2, true);
337 let content = "Line with spaces \nCode block: \n``` \nCode with spaces \n``` ";
338 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
339 let result = rule.check(&ctx).unwrap();
340 assert_eq!(result.len(), 5);
342
343 let fixed = rule.fix(&ctx).unwrap();
344 assert_eq!(fixed, "Line with spaces\nCode block:\n```\nCode with spaces\n```");
345 }
346
347 #[test]
348 fn test_non_strict_mode_with_code_blocks() {
349 let rule = MD009TrailingSpaces::new(2, false);
350 let content = "Line with spaces \n```\nCode with spaces \n```\nOutside code ";
351 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
352 let result = rule.check(&ctx).unwrap();
353 assert_eq!(result.len(), 1);
357 assert_eq!(result[0].line, 5);
358 }
359
360 #[test]
361 fn test_br_spaces_preservation() {
362 let rule = MD009TrailingSpaces::new(2, false);
363 let content = "Line with two spaces \nLine with three spaces \nLine with one space ";
364 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
365 let result = rule.check(&ctx).unwrap();
366 assert_eq!(result.len(), 2);
370 assert_eq!(result[0].line, 2);
371 assert_eq!(result[1].line, 3);
372
373 let fixed = rule.fix(&ctx).unwrap();
374 assert_eq!(
378 fixed,
379 "Line with two spaces \nLine with three spaces\nLine with one space"
380 );
381 }
382
383 #[test]
384 fn test_empty_lines_with_spaces() {
385 let rule = MD009TrailingSpaces::default();
386 let content = "Normal line\n \n \nAnother line";
387 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
388 let result = rule.check(&ctx).unwrap();
389 assert_eq!(result.len(), 2);
390 assert_eq!(result[0].message, "Empty line has trailing spaces");
391 assert_eq!(result[1].message, "Empty line has trailing spaces");
392
393 let fixed = rule.fix(&ctx).unwrap();
394 assert_eq!(fixed, "Normal line\n\n\nAnother line");
395 }
396
397 #[test]
398 fn test_empty_blockquote_lines() {
399 let rule = MD009TrailingSpaces::default();
400 let content = "> Quote\n> \n> More quote";
401 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
402 let result = rule.check(&ctx).unwrap();
403 assert_eq!(result.len(), 1);
404 assert_eq!(result[0].line, 2);
405 assert_eq!(result[0].message, "3 trailing spaces found");
406
407 let fixed = rule.fix(&ctx).unwrap();
408 assert_eq!(fixed, "> Quote\n>\n> More quote"); }
410
411 #[test]
412 fn test_last_line_handling() {
413 let rule = MD009TrailingSpaces::new(2, false);
414
415 let content = "First line \nLast line ";
417 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
418 let result = rule.check(&ctx).unwrap();
419 assert_eq!(result.len(), 1);
421 assert_eq!(result[0].line, 2);
422
423 let fixed = rule.fix(&ctx).unwrap();
424 assert_eq!(fixed, "First line \nLast line");
425
426 let content_with_newline = "First line \nLast line \n";
428 let ctx = LintContext::new(content_with_newline, crate::config::MarkdownFlavor::Standard, None);
429 let result = rule.check(&ctx).unwrap();
430 assert!(result.is_empty());
432 }
433
434 #[test]
435 fn test_single_trailing_space() {
436 let rule = MD009TrailingSpaces::new(2, false);
437 let content = "Line with one space ";
438 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
439 let result = rule.check(&ctx).unwrap();
440 assert_eq!(result.len(), 1);
441 assert_eq!(result[0].message, "Trailing space found");
442 }
443
444 #[test]
445 fn test_tabs_not_spaces() {
446 let rule = MD009TrailingSpaces::default();
447 let content = "Line with tab\t\nLine with spaces ";
448 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
449 let result = rule.check(&ctx).unwrap();
450 assert_eq!(result.len(), 1);
452 assert_eq!(result[0].line, 2);
453 }
454
455 #[test]
456 fn test_mixed_content() {
457 let rule = MD009TrailingSpaces::new(2, false);
458 let mut content = String::new();
460 content.push_str("# Heading");
461 content.push_str(" "); content.push('\n');
463 content.push_str("Normal paragraph\n> Blockquote\n>\n```\nCode block\n```\n- List item\n");
464
465 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
466 let result = rule.check(&ctx).unwrap();
467 assert_eq!(result.len(), 1);
469 assert_eq!(result[0].line, 1);
470 assert!(result[0].message.contains("trailing spaces"));
471 }
472
473 #[test]
474 fn test_column_positions() {
475 let rule = MD009TrailingSpaces::default();
476 let content = "Text ";
477 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
478 let result = rule.check(&ctx).unwrap();
479 assert_eq!(result.len(), 1);
480 assert_eq!(result[0].column, 5); assert_eq!(result[0].end_column, 8); }
483
484 #[test]
485 fn test_default_config() {
486 let rule = MD009TrailingSpaces::default();
487 let config = rule.default_config_section();
488 assert!(config.is_some());
489 let (name, _value) = config.unwrap();
490 assert_eq!(name, "MD009");
491 }
492
493 #[test]
494 fn test_from_config() {
495 let mut config = crate::config::Config::default();
496 let mut rule_config = crate::config::RuleConfig::default();
497 rule_config
498 .values
499 .insert("br_spaces".to_string(), toml::Value::Integer(3));
500 rule_config
501 .values
502 .insert("strict".to_string(), toml::Value::Boolean(true));
503 config.rules.insert("MD009".to_string(), rule_config);
504
505 let rule = MD009TrailingSpaces::from_config(&config);
506 let content = "Line ";
507 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
508 let result = rule.check(&ctx).unwrap();
509 assert_eq!(result.len(), 1);
510
511 let fixed = rule.fix(&ctx).unwrap();
513 assert_eq!(fixed, "Line");
514 }
515
516 #[test]
517 fn test_list_item_empty_lines() {
518 let config = MD009Config {
520 list_item_empty_lines: true,
521 ..Default::default()
522 };
523 let rule = MD009TrailingSpaces::from_config_struct(config);
524
525 let content = "- First item\n \n- Second item";
527 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
528 let result = rule.check(&ctx).unwrap();
529 assert!(result.is_empty());
531
532 let content = "1. First item\n \n2. Second item";
534 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
535 let result = rule.check(&ctx).unwrap();
536 assert!(result.is_empty());
537
538 let content = "Normal paragraph\n \nAnother paragraph";
540 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
541 let result = rule.check(&ctx).unwrap();
542 assert_eq!(result.len(), 1);
543 assert_eq!(result[0].line, 2);
544 }
545
546 #[test]
547 fn test_list_item_empty_lines_disabled() {
548 let rule = MD009TrailingSpaces::default();
550
551 let content = "- First item\n \n- Second item";
552 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
553 let result = rule.check(&ctx).unwrap();
554 assert_eq!(result.len(), 1);
556 assert_eq!(result[0].line, 2);
557 }
558
559 #[test]
560 fn test_performance_large_document() {
561 let rule = MD009TrailingSpaces::default();
562 let mut content = String::new();
563 for i in 0..1000 {
564 content.push_str(&format!("Line {i} with spaces \n"));
565 }
566 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
567 let result = rule.check(&ctx).unwrap();
568 assert_eq!(result.len(), 0);
570 }
571
572 #[test]
573 fn test_preserve_content_after_fix() {
574 let rule = MD009TrailingSpaces::new(2, false);
575 let content = "**Bold** text \n*Italic* text \n[Link](url) ";
576 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
577 let fixed = rule.fix(&ctx).unwrap();
578 assert_eq!(fixed, "**Bold** text \n*Italic* text \n[Link](url)");
579 }
580
581 #[test]
582 fn test_nested_blockquotes() {
583 let rule = MD009TrailingSpaces::default();
584 let content = "> > Nested \n> > \n> Normal ";
585 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
586 let result = rule.check(&ctx).unwrap();
587 assert_eq!(result.len(), 2);
589 assert_eq!(result[0].line, 2);
590 assert_eq!(result[1].line, 3);
591
592 let fixed = rule.fix(&ctx).unwrap();
593 assert_eq!(fixed, "> > Nested \n> >\n> Normal");
597 }
598
599 #[test]
600 fn test_normalized_line_endings() {
601 let rule = MD009TrailingSpaces::default();
602 let content = "Line with spaces \nAnother line ";
604 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
605 let result = rule.check(&ctx).unwrap();
606 assert_eq!(result.len(), 1);
609 assert_eq!(result[0].line, 2);
610 }
611
612 #[test]
613 fn test_issue_80_no_space_normalization() {
614 let rule = MD009TrailingSpaces::new(2, false); let content = "Line with one space \nNext line";
619 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
620 let result = rule.check(&ctx).unwrap();
621 assert_eq!(result.len(), 1);
622 assert_eq!(result[0].line, 1);
623 assert_eq!(result[0].message, "Trailing space found");
624
625 let fixed = rule.fix(&ctx).unwrap();
626 assert_eq!(fixed, "Line with one space\nNext line");
627
628 let content = "Line with three spaces \nNext line";
630 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
631 let result = rule.check(&ctx).unwrap();
632 assert_eq!(result.len(), 1);
633 assert_eq!(result[0].line, 1);
634 assert_eq!(result[0].message, "3 trailing spaces found");
635
636 let fixed = rule.fix(&ctx).unwrap();
637 assert_eq!(fixed, "Line with three spaces\nNext line");
638
639 let content = "Line with two spaces \nNext line";
641 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
642 let result = rule.check(&ctx).unwrap();
643 assert_eq!(result.len(), 0); let fixed = rule.fix(&ctx).unwrap();
646 assert_eq!(fixed, "Line with two spaces \nNext line");
647 }
648
649 #[test]
650 fn test_unicode_whitespace_idempotent_fix() {
651 let rule = MD009TrailingSpaces::default(); let content = "> 0\u{2000} ";
657 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
658 let result = rule.check(&ctx).unwrap();
659 assert_eq!(result.len(), 1, "Should detect trailing Unicode+ASCII whitespace");
660
661 let fixed = rule.fix(&ctx).unwrap();
662 assert_eq!(fixed, "> 0", "Should strip all trailing whitespace in one pass");
663
664 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
666 let fixed2 = rule.fix(&ctx2).unwrap();
667 assert_eq!(fixed, fixed2, "Fix must be idempotent");
668 }
669
670 #[test]
671 fn test_unicode_whitespace_variants() {
672 let rule = MD009TrailingSpaces::default();
673
674 let content = "text\u{2000}\n";
676 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
677 let result = rule.check(&ctx).unwrap();
678 assert_eq!(result.len(), 1);
679 let fixed = rule.fix(&ctx).unwrap();
680 assert_eq!(fixed, "text\n");
681
682 let content = "text\u{2001}\n";
684 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
685 let result = rule.check(&ctx).unwrap();
686 assert_eq!(result.len(), 1);
687 let fixed = rule.fix(&ctx).unwrap();
688 assert_eq!(fixed, "text\n");
689
690 let content = "text\u{3000}\n";
692 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
693 let result = rule.check(&ctx).unwrap();
694 assert_eq!(result.len(), 1);
695 let fixed = rule.fix(&ctx).unwrap();
696 assert_eq!(fixed, "text\n");
697
698 let content = "text\u{2000} \n";
702 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
703 let result = rule.check(&ctx).unwrap();
704 assert_eq!(result.len(), 1, "Unicode+ASCII mix should be flagged");
705 let fixed = rule.fix(&ctx).unwrap();
706 assert_eq!(
707 fixed, "text\n",
708 "All trailing whitespace should be stripped when mix includes Unicode"
709 );
710 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
712 let fixed2 = rule.fix(&ctx2).unwrap();
713 assert_eq!(fixed, fixed2, "Fix must be idempotent");
714
715 let content = "text \nnext\n";
717 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
718 let result = rule.check(&ctx).unwrap();
719 assert_eq!(result.len(), 0, "Pure ASCII br_spaces should still be preserved");
720 }
721
722 #[test]
723 fn test_unicode_whitespace_strict_mode() {
724 let rule = MD009TrailingSpaces::new(2, true);
725
726 let content = "text\u{2000}\nmore\u{3000}\n";
728 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
729 let fixed = rule.fix(&ctx).unwrap();
730 assert_eq!(fixed, "text\nmore\n");
731 }
732
733 fn assert_fix_roundtrip(rule: &MD009TrailingSpaces, content: &str) {
735 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
736 let fixed = rule.fix(&ctx).unwrap();
737 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
738 let remaining = rule.check(&ctx2).unwrap();
739 assert!(
740 remaining.is_empty(),
741 "After fix(), check() should find 0 violations.\nOriginal: {content:?}\nFixed: {fixed:?}\nRemaining: {remaining:?}"
742 );
743 }
744
745 #[test]
746 fn test_roundtrip_basic_trailing_spaces() {
747 let rule = MD009TrailingSpaces::default();
748 assert_fix_roundtrip(&rule, "Line with spaces \nAnother line \nClean line");
749 }
750
751 #[test]
752 fn test_roundtrip_strict_mode() {
753 let rule = MD009TrailingSpaces::new(2, true);
754 assert_fix_roundtrip(
755 &rule,
756 "Line with spaces \nCode block: \n``` \nCode with spaces \n``` ",
757 );
758 }
759
760 #[test]
761 fn test_roundtrip_empty_lines() {
762 let rule = MD009TrailingSpaces::default();
763 assert_fix_roundtrip(&rule, "Normal line\n \n \nAnother line");
764 }
765
766 #[test]
767 fn test_roundtrip_br_spaces_preservation() {
768 let rule = MD009TrailingSpaces::new(2, false);
769 assert_fix_roundtrip(
770 &rule,
771 "Line with two spaces \nLine with three spaces \nLine with one space ",
772 );
773 }
774
775 #[test]
776 fn test_roundtrip_last_line_no_newline() {
777 let rule = MD009TrailingSpaces::new(2, false);
778 assert_fix_roundtrip(&rule, "First line \nLast line ");
779 }
780
781 #[test]
782 fn test_roundtrip_last_line_with_newline() {
783 let rule = MD009TrailingSpaces::new(2, false);
784 assert_fix_roundtrip(&rule, "First line \nLast line \n");
785 }
786
787 #[test]
788 fn test_roundtrip_unicode_whitespace() {
789 let rule = MD009TrailingSpaces::default();
790 assert_fix_roundtrip(&rule, "> 0\u{2000} ");
791 assert_fix_roundtrip(&rule, "text\u{2000}\n");
792 assert_fix_roundtrip(&rule, "text\u{3000}\n");
793 assert_fix_roundtrip(&rule, "text\u{2000} \n");
794 }
795
796 #[test]
797 fn test_roundtrip_code_blocks_non_strict() {
798 let rule = MD009TrailingSpaces::new(2, false);
799 assert_fix_roundtrip(
800 &rule,
801 "Line with spaces \n```\nCode with spaces \n```\nOutside code ",
802 );
803 }
804
805 #[test]
806 fn test_roundtrip_blockquotes() {
807 let rule = MD009TrailingSpaces::default();
808 assert_fix_roundtrip(&rule, "> Quote\n> \n> More quote");
809 assert_fix_roundtrip(&rule, "> > Nested \n> > \n> Normal ");
810 }
811
812 #[test]
813 fn test_roundtrip_list_item_empty_lines() {
814 let config = MD009Config {
815 list_item_empty_lines: true,
816 ..Default::default()
817 };
818 let rule = MD009TrailingSpaces::from_config_struct(config);
819 assert_fix_roundtrip(&rule, "- First item\n \n- Second item");
820 assert_fix_roundtrip(&rule, "Normal paragraph\n \nAnother paragraph");
821 }
822
823 #[test]
824 fn test_roundtrip_complex_document() {
825 let rule = MD009TrailingSpaces::default();
826 assert_fix_roundtrip(
827 &rule,
828 "# Title \n\nParagraph \n\n- List \n - Nested \n\n```\ncode \n```\n\n> Quote \n> \n\nEnd ",
829 );
830 }
831
832 #[test]
833 fn test_roundtrip_multibyte() {
834 let rule = MD009TrailingSpaces::new(2, true);
835 assert_fix_roundtrip(&rule, "- 1€ expenses \n");
836 assert_fix_roundtrip(&rule, "€100 + €50 = €150 \n");
837 assert_fix_roundtrip(&rule, "Hello 你好世界 \n");
838 assert_fix_roundtrip(&rule, "Party 🎉🎉🎉 \n");
839 assert_fix_roundtrip(&rule, "안녕하세요 \n");
840 }
841
842 #[test]
843 fn test_roundtrip_mixed_tabs_and_spaces() {
844 let rule = MD009TrailingSpaces::default();
845 assert_fix_roundtrip(&rule, "Line with tab\t\nLine with spaces ");
846 assert_fix_roundtrip(&rule, "Line\t \nAnother\n");
847 }
848
849 #[test]
850 fn test_roundtrip_heading_with_br_spaces() {
851 let rule = MD009TrailingSpaces::new(2, false);
854 let content = "# Heading \nParagraph\n";
855 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
856 let warnings = rule.check(&ctx).unwrap();
857 assert!(
859 warnings.is_empty(),
860 "check() should not flag heading with exactly br_spaces trailing spaces"
861 );
862 assert_fix_roundtrip(&rule, content);
863 }
864
865 #[test]
866 fn test_fix_replacement_always_removes_trailing_spaces() {
867 let rule = MD009TrailingSpaces::new(2, false);
870
871 let content = "Hello \nWorld\n";
874 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
875 let result = rule.check(&ctx).unwrap();
876 assert_eq!(result.len(), 1);
877
878 let fix = result[0].fix.as_ref().expect("Should have a fix");
879 assert_eq!(
880 fix.replacement, "",
881 "Fix replacement should always be empty string (remove trailing spaces)"
882 );
883
884 let fixed = rule.fix(&ctx).unwrap();
886 assert_eq!(fixed, "Hello\nWorld\n");
887 }
888}