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