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::new(fix_range, String::new())),
156 });
157 }
158 continue;
159 }
160
161 if !self.config.strict {
163 if let Some(line_info) = ctx.line_info(line_num + 1)
165 && line_info.in_code_block
166 {
167 continue;
168 }
169 }
170
171 let is_truly_last_line = line_num == lines.len() - 1 && !content.ends_with('\n');
173 let has_only_ascii_trailing = trailing_ascii_spaces == trailing_all_whitespace;
174 if !self.config.strict
175 && !is_truly_last_line
176 && has_only_ascii_trailing
177 && trailing_ascii_spaces == self.config.br_spaces.get()
178 {
179 continue;
180 }
181
182 let trimmed = if line_is_ascii {
185 &line[..trimmed_len]
186 } else {
187 line.trim_end()
188 };
189 let is_empty_blockquote_with_space = trimmed.chars().all(|c| c == '>' || c == ' ' || c == '\t')
190 && trimmed.contains('>')
191 && has_only_ascii_trailing
192 && trailing_ascii_spaces == 1;
193
194 if is_empty_blockquote_with_space {
195 continue; }
197 let (start_line, start_col, end_line, end_col) = if line_is_ascii {
199 Self::calculate_trailing_range_ascii(line_num + 1, line.len(), trimmed.len())
200 } else {
201 calculate_trailing_range(line_num + 1, line, trimmed.len())
202 };
203 let line_start = *ctx.line_offsets.get(line_num).unwrap_or(&0);
204 let fix_range = if line_is_ascii {
205 let start = line_start + trimmed.len();
206 let end = start + trailing_all_whitespace;
207 start..end
208 } else {
209 line_index.line_col_to_byte_range_with_length(
210 line_num + 1,
211 trimmed.chars().count() + 1,
212 trailing_all_whitespace,
213 )
214 };
215
216 warnings.push(LintWarning {
217 rule_name: Some(self.name().to_string()),
218 line: start_line,
219 column: start_col,
220 end_line,
221 end_column: end_col,
222 message: if trailing_all_whitespace == 1 {
223 "Trailing space found".to_string()
224 } else {
225 format!("{trailing_all_whitespace} trailing spaces found")
226 },
227 severity: Severity::Warning,
228 fix: Some(Fix::new(fix_range, String::new())),
229 });
230 }
231
232 Ok(warnings)
233 }
234
235 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
236 if self.should_skip(ctx) {
237 return Ok(ctx.content.to_string());
238 }
239 let warnings = self.check(ctx)?;
240 if warnings.is_empty() {
241 return Ok(ctx.content.to_string());
242 }
243 let warnings =
244 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
245 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings).map_err(LintError::InvalidInput)
246 }
247
248 fn as_any(&self) -> &dyn std::any::Any {
249 self
250 }
251
252 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
253 ctx.content.is_empty()
258 }
259
260 fn category(&self) -> RuleCategory {
261 RuleCategory::Whitespace
262 }
263
264 fn default_config_section(&self) -> Option<(String, toml::Value)> {
265 let default_config = MD009Config::default();
266 let json_value = serde_json::to_value(&default_config).ok()?;
267 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
268
269 if let toml::Value::Table(table) = toml_value {
270 if !table.is_empty() {
271 Some((MD009Config::RULE_NAME.to_string(), toml::Value::Table(table)))
272 } else {
273 None
274 }
275 } else {
276 None
277 }
278 }
279
280 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
281 where
282 Self: Sized,
283 {
284 let rule_config = crate::rule_config_serde::load_rule_config::<MD009Config>(config);
285 Box::new(Self::from_config_struct(rule_config))
286 }
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292 use crate::lint_context::LintContext;
293 use crate::rule::Rule;
294
295 #[test]
296 fn test_no_trailing_spaces() {
297 let rule = MD009TrailingSpaces::default();
298 let content = "This is a line\nAnother line\nNo trailing spaces";
299 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
300 let result = rule.check(&ctx).unwrap();
301 assert!(result.is_empty());
302 }
303
304 #[test]
305 fn test_basic_trailing_spaces() {
306 let rule = MD009TrailingSpaces::default();
307 let content = "Line with spaces \nAnother line \nClean line";
308 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
309 let result = rule.check(&ctx).unwrap();
310 assert_eq!(result.len(), 1);
312 assert_eq!(result[0].line, 1);
313 assert_eq!(result[0].message, "3 trailing spaces found");
314 }
315
316 #[test]
317 fn test_fix_basic_trailing_spaces() {
318 let rule = MD009TrailingSpaces::default();
319 let content = "Line with spaces \nAnother line \nClean line";
320 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
321 let fixed = rule.fix(&ctx).unwrap();
322 assert_eq!(fixed, "Line with spaces\nAnother line \nClean line");
326 }
327
328 #[test]
329 fn test_strict_mode() {
330 let rule = MD009TrailingSpaces::new(2, true);
331 let content = "Line with spaces \nCode block: \n``` \nCode with spaces \n``` ";
332 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
333 let result = rule.check(&ctx).unwrap();
334 assert_eq!(result.len(), 5);
336
337 let fixed = rule.fix(&ctx).unwrap();
338 assert_eq!(fixed, "Line with spaces\nCode block:\n```\nCode with spaces\n```");
339 }
340
341 #[test]
342 fn test_non_strict_mode_with_code_blocks() {
343 let rule = MD009TrailingSpaces::new(2, false);
344 let content = "Line with spaces \n```\nCode with spaces \n```\nOutside code ";
345 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
346 let result = rule.check(&ctx).unwrap();
347 assert_eq!(result.len(), 1);
351 assert_eq!(result[0].line, 5);
352 }
353
354 #[test]
355 fn test_br_spaces_preservation() {
356 let rule = MD009TrailingSpaces::new(2, false);
357 let content = "Line with two spaces \nLine with three spaces \nLine with one space ";
358 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
359 let result = rule.check(&ctx).unwrap();
360 assert_eq!(result.len(), 2);
364 assert_eq!(result[0].line, 2);
365 assert_eq!(result[1].line, 3);
366
367 let fixed = rule.fix(&ctx).unwrap();
368 assert_eq!(
372 fixed,
373 "Line with two spaces \nLine with three spaces\nLine with one space"
374 );
375 }
376
377 #[test]
378 fn test_empty_lines_with_spaces() {
379 let rule = MD009TrailingSpaces::default();
380 let content = "Normal line\n \n \nAnother line";
381 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
382 let result = rule.check(&ctx).unwrap();
383 assert_eq!(result.len(), 2);
384 assert_eq!(result[0].message, "Empty line has trailing spaces");
385 assert_eq!(result[1].message, "Empty line has trailing spaces");
386
387 let fixed = rule.fix(&ctx).unwrap();
388 assert_eq!(fixed, "Normal line\n\n\nAnother line");
389 }
390
391 #[test]
392 fn test_empty_blockquote_lines() {
393 let rule = MD009TrailingSpaces::default();
394 let content = "> Quote\n> \n> More quote";
395 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
396 let result = rule.check(&ctx).unwrap();
397 assert_eq!(result.len(), 1);
398 assert_eq!(result[0].line, 2);
399 assert_eq!(result[0].message, "3 trailing spaces found");
400
401 let fixed = rule.fix(&ctx).unwrap();
402 assert_eq!(fixed, "> Quote\n>\n> More quote"); }
404
405 #[test]
406 fn test_last_line_handling() {
407 let rule = MD009TrailingSpaces::new(2, false);
408
409 let content = "First line \nLast line ";
411 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
412 let result = rule.check(&ctx).unwrap();
413 assert_eq!(result.len(), 1);
415 assert_eq!(result[0].line, 2);
416
417 let fixed = rule.fix(&ctx).unwrap();
418 assert_eq!(fixed, "First line \nLast line");
419
420 let content_with_newline = "First line \nLast line \n";
422 let ctx = LintContext::new(content_with_newline, crate::config::MarkdownFlavor::Standard, None);
423 let result = rule.check(&ctx).unwrap();
424 assert!(result.is_empty());
426 }
427
428 #[test]
429 fn test_single_trailing_space() {
430 let rule = MD009TrailingSpaces::new(2, false);
431 let content = "Line with one space ";
432 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
433 let result = rule.check(&ctx).unwrap();
434 assert_eq!(result.len(), 1);
435 assert_eq!(result[0].message, "Trailing space found");
436 }
437
438 #[test]
439 fn test_tabs_not_spaces() {
440 let rule = MD009TrailingSpaces::default();
441 let content = "Line with tab\t\nLine with spaces ";
442 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
443 let result = rule.check(&ctx).unwrap();
444 assert_eq!(result.len(), 1);
446 assert_eq!(result[0].line, 2);
447 }
448
449 #[test]
450 fn test_mixed_content() {
451 let rule = MD009TrailingSpaces::new(2, false);
452 let mut content = String::new();
454 content.push_str("# Heading");
455 content.push_str(" "); content.push('\n');
457 content.push_str("Normal paragraph\n> Blockquote\n>\n```\nCode block\n```\n- List item\n");
458
459 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
460 let result = rule.check(&ctx).unwrap();
461 assert_eq!(result.len(), 1);
463 assert_eq!(result[0].line, 1);
464 assert!(result[0].message.contains("trailing spaces"));
465 }
466
467 #[test]
468 fn test_column_positions() {
469 let rule = MD009TrailingSpaces::default();
470 let content = "Text ";
471 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
472 let result = rule.check(&ctx).unwrap();
473 assert_eq!(result.len(), 1);
474 assert_eq!(result[0].column, 5); assert_eq!(result[0].end_column, 8); }
477
478 #[test]
479 fn test_default_config() {
480 let rule = MD009TrailingSpaces::default();
481 let config = rule.default_config_section();
482 assert!(config.is_some());
483 let (name, _value) = config.unwrap();
484 assert_eq!(name, "MD009");
485 }
486
487 #[test]
488 fn test_from_config() {
489 let mut config = crate::config::Config::default();
490 let mut rule_config = crate::config::RuleConfig::default();
491 rule_config
492 .values
493 .insert("br_spaces".to_string(), toml::Value::Integer(3));
494 rule_config
495 .values
496 .insert("strict".to_string(), toml::Value::Boolean(true));
497 config.rules.insert("MD009".to_string(), rule_config);
498
499 let rule = MD009TrailingSpaces::from_config(&config);
500 let content = "Line ";
501 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
502 let result = rule.check(&ctx).unwrap();
503 assert_eq!(result.len(), 1);
504
505 let fixed = rule.fix(&ctx).unwrap();
507 assert_eq!(fixed, "Line");
508 }
509
510 #[test]
511 fn test_list_item_empty_lines() {
512 let config = MD009Config {
514 list_item_empty_lines: true,
515 ..Default::default()
516 };
517 let rule = MD009TrailingSpaces::from_config_struct(config);
518
519 let content = "- First item\n \n- Second item";
521 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
522 let result = rule.check(&ctx).unwrap();
523 assert!(result.is_empty());
525
526 let content = "1. First item\n \n2. Second item";
528 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
529 let result = rule.check(&ctx).unwrap();
530 assert!(result.is_empty());
531
532 let content = "Normal paragraph\n \nAnother paragraph";
534 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
535 let result = rule.check(&ctx).unwrap();
536 assert_eq!(result.len(), 1);
537 assert_eq!(result[0].line, 2);
538 }
539
540 #[test]
541 fn test_list_item_empty_lines_disabled() {
542 let rule = MD009TrailingSpaces::default();
544
545 let content = "- First item\n \n- Second item";
546 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
547 let result = rule.check(&ctx).unwrap();
548 assert_eq!(result.len(), 1);
550 assert_eq!(result[0].line, 2);
551 }
552
553 #[test]
554 fn test_performance_large_document() {
555 let rule = MD009TrailingSpaces::default();
556 let mut content = String::new();
557 for i in 0..1000 {
558 content.push_str(&format!("Line {i} with spaces \n"));
559 }
560 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
561 let result = rule.check(&ctx).unwrap();
562 assert_eq!(result.len(), 0);
564 }
565
566 #[test]
567 fn test_preserve_content_after_fix() {
568 let rule = MD009TrailingSpaces::new(2, false);
569 let content = "**Bold** text \n*Italic* text \n[Link](url) ";
570 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
571 let fixed = rule.fix(&ctx).unwrap();
572 assert_eq!(fixed, "**Bold** text \n*Italic* text \n[Link](url)");
573 }
574
575 #[test]
576 fn test_nested_blockquotes() {
577 let rule = MD009TrailingSpaces::default();
578 let content = "> > Nested \n> > \n> Normal ";
579 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
580 let result = rule.check(&ctx).unwrap();
581 assert_eq!(result.len(), 2);
583 assert_eq!(result[0].line, 2);
584 assert_eq!(result[1].line, 3);
585
586 let fixed = rule.fix(&ctx).unwrap();
587 assert_eq!(fixed, "> > Nested \n> >\n> Normal");
591 }
592
593 #[test]
594 fn test_normalized_line_endings() {
595 let rule = MD009TrailingSpaces::default();
596 let content = "Line with spaces \nAnother line ";
598 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
599 let result = rule.check(&ctx).unwrap();
600 assert_eq!(result.len(), 1);
603 assert_eq!(result[0].line, 2);
604 }
605
606 #[test]
607 fn test_issue_80_no_space_normalization() {
608 let rule = MD009TrailingSpaces::new(2, false); let content = "Line with one space \nNext line";
613 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
614 let result = rule.check(&ctx).unwrap();
615 assert_eq!(result.len(), 1);
616 assert_eq!(result[0].line, 1);
617 assert_eq!(result[0].message, "Trailing space found");
618
619 let fixed = rule.fix(&ctx).unwrap();
620 assert_eq!(fixed, "Line with one space\nNext line");
621
622 let content = "Line with three spaces \nNext line";
624 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
625 let result = rule.check(&ctx).unwrap();
626 assert_eq!(result.len(), 1);
627 assert_eq!(result[0].line, 1);
628 assert_eq!(result[0].message, "3 trailing spaces found");
629
630 let fixed = rule.fix(&ctx).unwrap();
631 assert_eq!(fixed, "Line with three spaces\nNext line");
632
633 let content = "Line with two spaces \nNext line";
635 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
636 let result = rule.check(&ctx).unwrap();
637 assert_eq!(result.len(), 0); let fixed = rule.fix(&ctx).unwrap();
640 assert_eq!(fixed, "Line with two spaces \nNext line");
641 }
642
643 #[test]
644 fn test_unicode_whitespace_idempotent_fix() {
645 let rule = MD009TrailingSpaces::default(); let content = "> 0\u{2000} ";
651 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
652 let result = rule.check(&ctx).unwrap();
653 assert_eq!(result.len(), 1, "Should detect trailing Unicode+ASCII whitespace");
654
655 let fixed = rule.fix(&ctx).unwrap();
656 assert_eq!(fixed, "> 0", "Should strip all trailing whitespace in one pass");
657
658 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
660 let fixed2 = rule.fix(&ctx2).unwrap();
661 assert_eq!(fixed, fixed2, "Fix must be idempotent");
662 }
663
664 #[test]
665 fn test_unicode_whitespace_variants() {
666 let rule = MD009TrailingSpaces::default();
667
668 let content = "text\u{2000}\n";
670 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
671 let result = rule.check(&ctx).unwrap();
672 assert_eq!(result.len(), 1);
673 let fixed = rule.fix(&ctx).unwrap();
674 assert_eq!(fixed, "text\n");
675
676 let content = "text\u{2001}\n";
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 let fixed = rule.fix(&ctx).unwrap();
682 assert_eq!(fixed, "text\n");
683
684 let content = "text\u{3000}\n";
686 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
687 let result = rule.check(&ctx).unwrap();
688 assert_eq!(result.len(), 1);
689 let fixed = rule.fix(&ctx).unwrap();
690 assert_eq!(fixed, "text\n");
691
692 let content = "text\u{2000} \n";
696 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
697 let result = rule.check(&ctx).unwrap();
698 assert_eq!(result.len(), 1, "Unicode+ASCII mix should be flagged");
699 let fixed = rule.fix(&ctx).unwrap();
700 assert_eq!(
701 fixed, "text\n",
702 "All trailing whitespace should be stripped when mix includes Unicode"
703 );
704 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
706 let fixed2 = rule.fix(&ctx2).unwrap();
707 assert_eq!(fixed, fixed2, "Fix must be idempotent");
708
709 let content = "text \nnext\n";
711 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
712 let result = rule.check(&ctx).unwrap();
713 assert_eq!(result.len(), 0, "Pure ASCII br_spaces should still be preserved");
714 }
715
716 #[test]
717 fn test_unicode_whitespace_strict_mode() {
718 let rule = MD009TrailingSpaces::new(2, true);
719
720 let content = "text\u{2000}\nmore\u{3000}\n";
722 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
723 let fixed = rule.fix(&ctx).unwrap();
724 assert_eq!(fixed, "text\nmore\n");
725 }
726
727 fn assert_fix_roundtrip(rule: &MD009TrailingSpaces, content: &str) {
729 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
730 let fixed = rule.fix(&ctx).unwrap();
731 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
732 let remaining = rule.check(&ctx2).unwrap();
733 assert!(
734 remaining.is_empty(),
735 "After fix(), check() should find 0 violations.\nOriginal: {content:?}\nFixed: {fixed:?}\nRemaining: {remaining:?}"
736 );
737 }
738
739 #[test]
740 fn test_roundtrip_basic_trailing_spaces() {
741 let rule = MD009TrailingSpaces::default();
742 assert_fix_roundtrip(&rule, "Line with spaces \nAnother line \nClean line");
743 }
744
745 #[test]
746 fn test_roundtrip_strict_mode() {
747 let rule = MD009TrailingSpaces::new(2, true);
748 assert_fix_roundtrip(
749 &rule,
750 "Line with spaces \nCode block: \n``` \nCode with spaces \n``` ",
751 );
752 }
753
754 #[test]
755 fn test_roundtrip_empty_lines() {
756 let rule = MD009TrailingSpaces::default();
757 assert_fix_roundtrip(&rule, "Normal line\n \n \nAnother line");
758 }
759
760 #[test]
761 fn test_roundtrip_br_spaces_preservation() {
762 let rule = MD009TrailingSpaces::new(2, false);
763 assert_fix_roundtrip(
764 &rule,
765 "Line with two spaces \nLine with three spaces \nLine with one space ",
766 );
767 }
768
769 #[test]
770 fn test_roundtrip_last_line_no_newline() {
771 let rule = MD009TrailingSpaces::new(2, false);
772 assert_fix_roundtrip(&rule, "First line \nLast line ");
773 }
774
775 #[test]
776 fn test_roundtrip_last_line_with_newline() {
777 let rule = MD009TrailingSpaces::new(2, false);
778 assert_fix_roundtrip(&rule, "First line \nLast line \n");
779 }
780
781 #[test]
782 fn test_roundtrip_unicode_whitespace() {
783 let rule = MD009TrailingSpaces::default();
784 assert_fix_roundtrip(&rule, "> 0\u{2000} ");
785 assert_fix_roundtrip(&rule, "text\u{2000}\n");
786 assert_fix_roundtrip(&rule, "text\u{3000}\n");
787 assert_fix_roundtrip(&rule, "text\u{2000} \n");
788 }
789
790 #[test]
791 fn test_roundtrip_code_blocks_non_strict() {
792 let rule = MD009TrailingSpaces::new(2, false);
793 assert_fix_roundtrip(
794 &rule,
795 "Line with spaces \n```\nCode with spaces \n```\nOutside code ",
796 );
797 }
798
799 #[test]
800 fn test_roundtrip_blockquotes() {
801 let rule = MD009TrailingSpaces::default();
802 assert_fix_roundtrip(&rule, "> Quote\n> \n> More quote");
803 assert_fix_roundtrip(&rule, "> > Nested \n> > \n> Normal ");
804 }
805
806 #[test]
807 fn test_roundtrip_list_item_empty_lines() {
808 let config = MD009Config {
809 list_item_empty_lines: true,
810 ..Default::default()
811 };
812 let rule = MD009TrailingSpaces::from_config_struct(config);
813 assert_fix_roundtrip(&rule, "- First item\n \n- Second item");
814 assert_fix_roundtrip(&rule, "Normal paragraph\n \nAnother paragraph");
815 }
816
817 #[test]
818 fn test_roundtrip_complex_document() {
819 let rule = MD009TrailingSpaces::default();
820 assert_fix_roundtrip(
821 &rule,
822 "# Title \n\nParagraph \n\n- List \n - Nested \n\n```\ncode \n```\n\n> Quote \n> \n\nEnd ",
823 );
824 }
825
826 #[test]
827 fn test_roundtrip_multibyte() {
828 let rule = MD009TrailingSpaces::new(2, true);
829 assert_fix_roundtrip(&rule, "- 1€ expenses \n");
830 assert_fix_roundtrip(&rule, "€100 + €50 = €150 \n");
831 assert_fix_roundtrip(&rule, "Hello 你好世界 \n");
832 assert_fix_roundtrip(&rule, "Party 🎉🎉🎉 \n");
833 assert_fix_roundtrip(&rule, "안녕하세요 \n");
834 }
835
836 #[test]
837 fn test_roundtrip_mixed_tabs_and_spaces() {
838 let rule = MD009TrailingSpaces::default();
839 assert_fix_roundtrip(&rule, "Line with tab\t\nLine with spaces ");
840 assert_fix_roundtrip(&rule, "Line\t \nAnother\n");
841 }
842
843 #[test]
844 fn test_roundtrip_heading_with_br_spaces() {
845 let rule = MD009TrailingSpaces::new(2, false);
848 let content = "# Heading \nParagraph\n";
849 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
850 let warnings = rule.check(&ctx).unwrap();
851 assert!(
853 warnings.is_empty(),
854 "check() should not flag heading with exactly br_spaces trailing spaces"
855 );
856 assert_fix_roundtrip(&rule, content);
857 }
858
859 #[test]
860 fn test_fix_replacement_always_removes_trailing_spaces() {
861 let rule = MD009TrailingSpaces::new(2, false);
864
865 let content = "Hello \nWorld\n";
868 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
869 let result = rule.check(&ctx).unwrap();
870 assert_eq!(result.len(), 1);
871
872 let fix = result[0].fix.as_ref().expect("Should have a fix");
873 assert_eq!(
874 fix.replacement, "",
875 "Fix replacement should always be empty string (remove trailing spaces)"
876 );
877
878 let fixed = rule.fix(&ctx).unwrap();
880 assert_eq!(fixed, "Hello\nWorld\n");
881 }
882}