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 trimmed_len_ascii_whitespace(line: &str) -> usize {
40 line.as_bytes()
41 .iter()
42 .rposition(|b| !b.is_ascii_whitespace())
43 .map(|idx| idx + 1)
44 .unwrap_or(0)
45 }
46
47 fn calculate_trailing_range_ascii(
48 line: usize,
49 line_len: usize,
50 content_end: usize,
51 ) -> (usize, usize, usize, usize) {
52 (line, content_end + 1, line, line_len + 1)
54 }
55
56 fn is_empty_list_item_line(line: &str, prev_line: Option<&str>) -> bool {
57 if !line.trim().is_empty() {
61 return false;
62 }
63
64 if let Some(prev) = prev_line {
65 UNORDERED_LIST_MARKER_REGEX.is_match(prev) || ORDERED_LIST_MARKER_REGEX.is_match(prev)
67 } else {
68 false
69 }
70 }
71}
72
73impl Rule for MD009TrailingSpaces {
74 fn name(&self) -> &'static str {
75 "MD009"
76 }
77
78 fn description(&self) -> &'static str {
79 "Trailing spaces should be removed"
80 }
81
82 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
83 let content = ctx.content;
84 let _line_index = &ctx.line_index;
85
86 let mut warnings = Vec::new();
87
88 let lines: Vec<&str> = content.lines().collect();
91
92 for (line_num, &line) in lines.iter().enumerate() {
93 let line_is_ascii = line.is_ascii();
94 let trailing_spaces = if line_is_ascii {
95 Self::count_trailing_spaces_ascii(line)
96 } else {
97 Self::count_trailing_spaces(line)
98 };
99
100 if trailing_spaces == 0 {
102 continue;
103 }
104
105 let trimmed_len = if line_is_ascii {
107 Self::trimmed_len_ascii_whitespace(line)
108 } else {
109 line.trim_end().len()
110 };
111 if trimmed_len == 0 {
112 if trailing_spaces > 0 {
113 let prev_line = if line_num > 0 { Some(lines[line_num - 1]) } else { None };
115 if self.config.list_item_empty_lines && Self::is_empty_list_item_line(line, prev_line) {
116 continue;
117 }
118
119 let (start_line, start_col, end_line, end_col) = if line_is_ascii {
121 Self::calculate_trailing_range_ascii(line_num + 1, line.len(), 0)
122 } else {
123 calculate_trailing_range(line_num + 1, line, 0)
124 };
125 let line_start = *ctx.line_offsets.get(line_num).unwrap_or(&0);
126 let fix_range = if line_is_ascii {
127 line_start..line_start + line.len()
128 } else {
129 _line_index.line_col_to_byte_range_with_length(line_num + 1, 1, line.len())
130 };
131
132 warnings.push(LintWarning {
133 rule_name: Some(self.name().to_string()),
134 line: start_line,
135 column: start_col,
136 end_line,
137 end_column: end_col,
138 message: "Empty line has trailing spaces".to_string(),
139 severity: Severity::Warning,
140 fix: Some(Fix {
141 range: fix_range,
142 replacement: String::new(),
143 }),
144 });
145 }
146 continue;
147 }
148
149 if !self.config.strict {
151 if let Some(line_info) = ctx.line_info(line_num + 1)
153 && line_info.in_code_block
154 {
155 continue;
156 }
157 }
158
159 let is_truly_last_line = line_num == lines.len() - 1 && !content.ends_with('\n');
163 if !self.config.strict && !is_truly_last_line && trailing_spaces == self.config.br_spaces.get() {
164 continue;
165 }
166
167 let trimmed = if line_is_ascii {
170 &line[..trimmed_len]
171 } else {
172 line.trim_end()
173 };
174 let is_empty_blockquote_with_space = trimmed.chars().all(|c| c == '>' || c == ' ' || c == '\t')
175 && trimmed.contains('>')
176 && trailing_spaces == 1;
177
178 if is_empty_blockquote_with_space {
179 continue; }
181 let (start_line, start_col, end_line, end_col) = if line_is_ascii {
183 Self::calculate_trailing_range_ascii(line_num + 1, line.len(), trimmed.len())
184 } else {
185 calculate_trailing_range(line_num + 1, line, trimmed.len())
186 };
187 let line_start = *ctx.line_offsets.get(line_num).unwrap_or(&0);
188 let fix_range = if line_is_ascii {
189 let start = line_start + trimmed.len();
190 let end = start + trailing_spaces;
191 start..end
192 } else {
193 _line_index.line_col_to_byte_range_with_length(
194 line_num + 1,
195 trimmed.chars().count() + 1,
196 trailing_spaces,
197 )
198 };
199
200 warnings.push(LintWarning {
201 rule_name: Some(self.name().to_string()),
202 line: start_line,
203 column: start_col,
204 end_line,
205 end_column: end_col,
206 message: if trailing_spaces == 1 {
207 "Trailing space found".to_string()
208 } else {
209 format!("{trailing_spaces} trailing spaces found")
210 },
211 severity: Severity::Warning,
212 fix: Some(Fix {
213 range: fix_range,
214 replacement: if !self.config.strict
215 && !is_truly_last_line
216 && trailing_spaces == self.config.br_spaces.get()
217 {
218 " ".repeat(self.config.br_spaces.get())
219 } else {
220 String::new()
221 },
222 }),
223 });
224 }
225
226 Ok(warnings)
227 }
228
229 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
230 let content = ctx.content;
231
232 if self.config.strict {
234 return Ok(get_cached_regex(r"(?m) +$")
236 .unwrap()
237 .replace_all(content, "")
238 .to_string());
239 }
240
241 let lines: Vec<&str> = content.lines().collect();
244 let mut result = String::with_capacity(content.len()); for (i, line) in lines.iter().enumerate() {
247 if !line.ends_with(' ') {
249 result.push_str(line);
250 result.push('\n');
251 continue;
252 }
253
254 let trimmed = line.trim_end();
255 let trailing_spaces = Self::count_trailing_spaces(line);
256
257 if trimmed.is_empty() {
259 let prev_line = if i > 0 { Some(lines[i - 1]) } else { None };
261 if self.config.list_item_empty_lines && Self::is_empty_list_item_line(line, prev_line) {
262 result.push_str(line);
263 } else {
264 }
266 result.push('\n');
267 continue;
268 }
269
270 if let Some(line_info) = ctx.line_info(i + 1)
272 && line_info.in_code_block
273 {
274 result.push_str(line);
275 result.push('\n');
276 continue;
277 }
278
279 let is_truly_last_line = i == lines.len() - 1 && !content.ends_with('\n');
283
284 result.push_str(trimmed);
285
286 let is_heading = if let Some(line_info) = ctx.line_info(i + 1) {
288 line_info.heading.is_some()
289 } else {
290 trimmed.starts_with('#')
292 };
293
294 let is_empty_blockquote = if let Some(line_info) = ctx.line_info(i + 1) {
296 line_info.blockquote.as_ref().is_some_and(|bq| bq.content.is_empty())
297 } else {
298 false
299 };
300
301 if !self.config.strict
304 && !is_truly_last_line
305 && trailing_spaces == self.config.br_spaces.get()
306 && !is_heading
307 && !is_empty_blockquote
308 {
309 match self.config.br_spaces.get() {
311 0 => {}
312 1 => result.push(' '),
313 2 => result.push_str(" "),
314 n => result.push_str(&" ".repeat(n)),
315 }
316 }
317 result.push('\n');
318 }
319
320 if !content.ends_with('\n') && result.ends_with('\n') {
322 result.pop();
323 }
324
325 Ok(result)
326 }
327
328 fn as_any(&self) -> &dyn std::any::Any {
329 self
330 }
331
332 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
333 ctx.content.is_empty() || !ctx.content.contains(' ')
335 }
336
337 fn category(&self) -> RuleCategory {
338 RuleCategory::Whitespace
339 }
340
341 fn default_config_section(&self) -> Option<(String, toml::Value)> {
342 let default_config = MD009Config::default();
343 let json_value = serde_json::to_value(&default_config).ok()?;
344 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
345
346 if let toml::Value::Table(table) = toml_value {
347 if !table.is_empty() {
348 Some((MD009Config::RULE_NAME.to_string(), toml::Value::Table(table)))
349 } else {
350 None
351 }
352 } else {
353 None
354 }
355 }
356
357 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
358 where
359 Self: Sized,
360 {
361 let rule_config = crate::rule_config_serde::load_rule_config::<MD009Config>(config);
362 Box::new(Self::from_config_struct(rule_config))
363 }
364}
365
366#[cfg(test)]
367mod tests {
368 use super::*;
369 use crate::lint_context::LintContext;
370 use crate::rule::Rule;
371
372 #[test]
373 fn test_no_trailing_spaces() {
374 let rule = MD009TrailingSpaces::default();
375 let content = "This is a line\nAnother line\nNo trailing spaces";
376 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
377 let result = rule.check(&ctx).unwrap();
378 assert!(result.is_empty());
379 }
380
381 #[test]
382 fn test_basic_trailing_spaces() {
383 let rule = MD009TrailingSpaces::default();
384 let content = "Line with spaces \nAnother line \nClean line";
385 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
386 let result = rule.check(&ctx).unwrap();
387 assert_eq!(result.len(), 1);
389 assert_eq!(result[0].line, 1);
390 assert_eq!(result[0].message, "3 trailing spaces found");
391 }
392
393 #[test]
394 fn test_fix_basic_trailing_spaces() {
395 let rule = MD009TrailingSpaces::default();
396 let content = "Line with spaces \nAnother line \nClean line";
397 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
398 let fixed = rule.fix(&ctx).unwrap();
399 assert_eq!(fixed, "Line with spaces\nAnother line \nClean line");
403 }
404
405 #[test]
406 fn test_strict_mode() {
407 let rule = MD009TrailingSpaces::new(2, true);
408 let content = "Line with spaces \nCode block: \n``` \nCode with spaces \n``` ";
409 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
410 let result = rule.check(&ctx).unwrap();
411 assert_eq!(result.len(), 5);
413
414 let fixed = rule.fix(&ctx).unwrap();
415 assert_eq!(fixed, "Line with spaces\nCode block:\n```\nCode with spaces\n```");
416 }
417
418 #[test]
419 fn test_non_strict_mode_with_code_blocks() {
420 let rule = MD009TrailingSpaces::new(2, false);
421 let content = "Line with spaces \n```\nCode with spaces \n```\nOutside code ";
422 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
423 let result = rule.check(&ctx).unwrap();
424 assert_eq!(result.len(), 1);
428 assert_eq!(result[0].line, 5);
429 }
430
431 #[test]
432 fn test_br_spaces_preservation() {
433 let rule = MD009TrailingSpaces::new(2, false);
434 let content = "Line with two spaces \nLine with three spaces \nLine with one space ";
435 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
436 let result = rule.check(&ctx).unwrap();
437 assert_eq!(result.len(), 2);
441 assert_eq!(result[0].line, 2);
442 assert_eq!(result[1].line, 3);
443
444 let fixed = rule.fix(&ctx).unwrap();
445 assert_eq!(
449 fixed,
450 "Line with two spaces \nLine with three spaces\nLine with one space"
451 );
452 }
453
454 #[test]
455 fn test_empty_lines_with_spaces() {
456 let rule = MD009TrailingSpaces::default();
457 let content = "Normal line\n \n \nAnother line";
458 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
459 let result = rule.check(&ctx).unwrap();
460 assert_eq!(result.len(), 2);
461 assert_eq!(result[0].message, "Empty line has trailing spaces");
462 assert_eq!(result[1].message, "Empty line has trailing spaces");
463
464 let fixed = rule.fix(&ctx).unwrap();
465 assert_eq!(fixed, "Normal line\n\n\nAnother line");
466 }
467
468 #[test]
469 fn test_empty_blockquote_lines() {
470 let rule = MD009TrailingSpaces::default();
471 let content = "> Quote\n> \n> More quote";
472 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
473 let result = rule.check(&ctx).unwrap();
474 assert_eq!(result.len(), 1);
475 assert_eq!(result[0].line, 2);
476 assert_eq!(result[0].message, "3 trailing spaces found");
477
478 let fixed = rule.fix(&ctx).unwrap();
479 assert_eq!(fixed, "> Quote\n>\n> More quote"); }
481
482 #[test]
483 fn test_last_line_handling() {
484 let rule = MD009TrailingSpaces::new(2, false);
485
486 let content = "First line \nLast line ";
488 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
489 let result = rule.check(&ctx).unwrap();
490 assert_eq!(result.len(), 1);
492 assert_eq!(result[0].line, 2);
493
494 let fixed = rule.fix(&ctx).unwrap();
495 assert_eq!(fixed, "First line \nLast line");
496
497 let content_with_newline = "First line \nLast line \n";
499 let ctx = LintContext::new(content_with_newline, crate::config::MarkdownFlavor::Standard, None);
500 let result = rule.check(&ctx).unwrap();
501 assert!(result.is_empty());
503 }
504
505 #[test]
506 fn test_single_trailing_space() {
507 let rule = MD009TrailingSpaces::new(2, false);
508 let content = "Line with one space ";
509 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
510 let result = rule.check(&ctx).unwrap();
511 assert_eq!(result.len(), 1);
512 assert_eq!(result[0].message, "Trailing space found");
513 }
514
515 #[test]
516 fn test_tabs_not_spaces() {
517 let rule = MD009TrailingSpaces::default();
518 let content = "Line with tab\t\nLine with spaces ";
519 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
520 let result = rule.check(&ctx).unwrap();
521 assert_eq!(result.len(), 1);
523 assert_eq!(result[0].line, 2);
524 }
525
526 #[test]
527 fn test_mixed_content() {
528 let rule = MD009TrailingSpaces::new(2, false);
529 let mut content = String::new();
531 content.push_str("# Heading");
532 content.push_str(" "); content.push('\n');
534 content.push_str("Normal paragraph\n> Blockquote\n>\n```\nCode block\n```\n- List item\n");
535
536 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
537 let result = rule.check(&ctx).unwrap();
538 assert_eq!(result.len(), 1);
540 assert_eq!(result[0].line, 1);
541 assert!(result[0].message.contains("trailing spaces"));
542 }
543
544 #[test]
545 fn test_column_positions() {
546 let rule = MD009TrailingSpaces::default();
547 let content = "Text ";
548 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
549 let result = rule.check(&ctx).unwrap();
550 assert_eq!(result.len(), 1);
551 assert_eq!(result[0].column, 5); assert_eq!(result[0].end_column, 8); }
554
555 #[test]
556 fn test_default_config() {
557 let rule = MD009TrailingSpaces::default();
558 let config = rule.default_config_section();
559 assert!(config.is_some());
560 let (name, _value) = config.unwrap();
561 assert_eq!(name, "MD009");
562 }
563
564 #[test]
565 fn test_from_config() {
566 let mut config = crate::config::Config::default();
567 let mut rule_config = crate::config::RuleConfig::default();
568 rule_config
569 .values
570 .insert("br_spaces".to_string(), toml::Value::Integer(3));
571 rule_config
572 .values
573 .insert("strict".to_string(), toml::Value::Boolean(true));
574 config.rules.insert("MD009".to_string(), rule_config);
575
576 let rule = MD009TrailingSpaces::from_config(&config);
577 let content = "Line ";
578 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
579 let result = rule.check(&ctx).unwrap();
580 assert_eq!(result.len(), 1);
581
582 let fixed = rule.fix(&ctx).unwrap();
584 assert_eq!(fixed, "Line");
585 }
586
587 #[test]
588 fn test_list_item_empty_lines() {
589 let config = MD009Config {
591 list_item_empty_lines: true,
592 ..Default::default()
593 };
594 let rule = MD009TrailingSpaces::from_config_struct(config);
595
596 let content = "- First item\n \n- Second item";
598 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
599 let result = rule.check(&ctx).unwrap();
600 assert!(result.is_empty());
602
603 let content = "1. First item\n \n2. Second item";
605 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
606 let result = rule.check(&ctx).unwrap();
607 assert!(result.is_empty());
608
609 let content = "Normal paragraph\n \nAnother paragraph";
611 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
612 let result = rule.check(&ctx).unwrap();
613 assert_eq!(result.len(), 1);
614 assert_eq!(result[0].line, 2);
615 }
616
617 #[test]
618 fn test_list_item_empty_lines_disabled() {
619 let rule = MD009TrailingSpaces::default();
621
622 let content = "- First item\n \n- Second item";
623 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
624 let result = rule.check(&ctx).unwrap();
625 assert_eq!(result.len(), 1);
627 assert_eq!(result[0].line, 2);
628 }
629
630 #[test]
631 fn test_performance_large_document() {
632 let rule = MD009TrailingSpaces::default();
633 let mut content = String::new();
634 for i in 0..1000 {
635 content.push_str(&format!("Line {i} with spaces \n"));
636 }
637 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
638 let result = rule.check(&ctx).unwrap();
639 assert_eq!(result.len(), 0);
641 }
642
643 #[test]
644 fn test_preserve_content_after_fix() {
645 let rule = MD009TrailingSpaces::new(2, false);
646 let content = "**Bold** text \n*Italic* text \n[Link](url) ";
647 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
648 let fixed = rule.fix(&ctx).unwrap();
649 assert_eq!(fixed, "**Bold** text \n*Italic* text \n[Link](url)");
650 }
651
652 #[test]
653 fn test_nested_blockquotes() {
654 let rule = MD009TrailingSpaces::default();
655 let content = "> > Nested \n> > \n> Normal ";
656 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
657 let result = rule.check(&ctx).unwrap();
658 assert_eq!(result.len(), 2);
660 assert_eq!(result[0].line, 2);
661 assert_eq!(result[1].line, 3);
662
663 let fixed = rule.fix(&ctx).unwrap();
664 assert_eq!(fixed, "> > Nested \n> >\n> Normal");
668 }
669
670 #[test]
671 fn test_normalized_line_endings() {
672 let rule = MD009TrailingSpaces::default();
673 let content = "Line with spaces \nAnother line ";
675 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
676 let result = rule.check(&ctx).unwrap();
677 assert_eq!(result.len(), 1);
680 assert_eq!(result[0].line, 2);
681 }
682
683 #[test]
684 fn test_issue_80_no_space_normalization() {
685 let rule = MD009TrailingSpaces::new(2, false); let content = "Line with one space \nNext line";
690 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
691 let result = rule.check(&ctx).unwrap();
692 assert_eq!(result.len(), 1);
693 assert_eq!(result[0].line, 1);
694 assert_eq!(result[0].message, "Trailing space found");
695
696 let fixed = rule.fix(&ctx).unwrap();
697 assert_eq!(fixed, "Line with one space\nNext line");
698
699 let content = "Line with three spaces \nNext line";
701 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
702 let result = rule.check(&ctx).unwrap();
703 assert_eq!(result.len(), 1);
704 assert_eq!(result[0].line, 1);
705 assert_eq!(result[0].message, "3 trailing spaces found");
706
707 let fixed = rule.fix(&ctx).unwrap();
708 assert_eq!(fixed, "Line with three spaces\nNext line");
709
710 let content = "Line with two spaces \nNext line";
712 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
713 let result = rule.check(&ctx).unwrap();
714 assert_eq!(result.len(), 0); let fixed = rule.fix(&ctx).unwrap();
717 assert_eq!(fixed, "Line with two spaces \nNext line");
718 }
719}