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 = ctx.raw_lines();
90
91 for (line_num, &line) in lines.iter().enumerate() {
92 if ctx.line_info(line_num + 1).is_some_and(|info| info.in_pymdown_block) {
94 continue;
95 }
96
97 let line_is_ascii = line.is_ascii();
98 let trailing_spaces = if line_is_ascii {
99 Self::count_trailing_spaces_ascii(line)
100 } else {
101 Self::count_trailing_spaces(line)
102 };
103
104 if trailing_spaces == 0 {
106 continue;
107 }
108
109 let trimmed_len = if line_is_ascii {
111 Self::trimmed_len_ascii_whitespace(line)
112 } else {
113 line.trim_end().len()
114 };
115 if trimmed_len == 0 {
116 if trailing_spaces > 0 {
117 let prev_line = if line_num > 0 { Some(lines[line_num - 1]) } else { None };
119 if self.config.list_item_empty_lines && Self::is_empty_list_item_line(line, prev_line) {
120 continue;
121 }
122
123 let (start_line, start_col, end_line, end_col) = if line_is_ascii {
125 Self::calculate_trailing_range_ascii(line_num + 1, line.len(), 0)
126 } else {
127 calculate_trailing_range(line_num + 1, line, 0)
128 };
129 let line_start = *ctx.line_offsets.get(line_num).unwrap_or(&0);
130 let fix_range = if line_is_ascii {
131 line_start..line_start + line.len()
132 } else {
133 _line_index.line_col_to_byte_range_with_length(line_num + 1, 1, line.len())
134 };
135
136 warnings.push(LintWarning {
137 rule_name: Some(self.name().to_string()),
138 line: start_line,
139 column: start_col,
140 end_line,
141 end_column: end_col,
142 message: "Empty line has trailing spaces".to_string(),
143 severity: Severity::Warning,
144 fix: Some(Fix {
145 range: fix_range,
146 replacement: String::new(),
147 }),
148 });
149 }
150 continue;
151 }
152
153 if !self.config.strict {
155 if let Some(line_info) = ctx.line_info(line_num + 1)
157 && line_info.in_code_block
158 {
159 continue;
160 }
161 }
162
163 let is_truly_last_line = line_num == lines.len() - 1 && !content.ends_with('\n');
167 if !self.config.strict && !is_truly_last_line && trailing_spaces == self.config.br_spaces.get() {
168 continue;
169 }
170
171 let trimmed = if line_is_ascii {
174 &line[..trimmed_len]
175 } else {
176 line.trim_end()
177 };
178 let is_empty_blockquote_with_space = trimmed.chars().all(|c| c == '>' || c == ' ' || c == '\t')
179 && trimmed.contains('>')
180 && trailing_spaces == 1;
181
182 if is_empty_blockquote_with_space {
183 continue; }
185 let (start_line, start_col, end_line, end_col) = if line_is_ascii {
187 Self::calculate_trailing_range_ascii(line_num + 1, line.len(), trimmed.len())
188 } else {
189 calculate_trailing_range(line_num + 1, line, trimmed.len())
190 };
191 let line_start = *ctx.line_offsets.get(line_num).unwrap_or(&0);
192 let fix_range = if line_is_ascii {
193 let start = line_start + trimmed.len();
194 let end = start + trailing_spaces;
195 start..end
196 } else {
197 _line_index.line_col_to_byte_range_with_length(
198 line_num + 1,
199 trimmed.chars().count() + 1,
200 trailing_spaces,
201 )
202 };
203
204 warnings.push(LintWarning {
205 rule_name: Some(self.name().to_string()),
206 line: start_line,
207 column: start_col,
208 end_line,
209 end_column: end_col,
210 message: if trailing_spaces == 1 {
211 "Trailing space found".to_string()
212 } else {
213 format!("{trailing_spaces} trailing spaces found")
214 },
215 severity: Severity::Warning,
216 fix: Some(Fix {
217 range: fix_range,
218 replacement: if !self.config.strict
219 && !is_truly_last_line
220 && trailing_spaces == self.config.br_spaces.get()
221 {
222 " ".repeat(self.config.br_spaces.get())
223 } else {
224 String::new()
225 },
226 }),
227 });
228 }
229
230 Ok(warnings)
231 }
232
233 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
234 let content = ctx.content;
235
236 if self.config.strict {
238 return Ok(get_cached_regex(r"(?m) +$")
240 .unwrap()
241 .replace_all(content, "")
242 .to_string());
243 }
244
245 let lines = ctx.raw_lines();
248 let mut result = String::with_capacity(content.len()); for (i, line) in lines.iter().enumerate() {
251 if !line.ends_with(' ') {
253 result.push_str(line);
254 result.push('\n');
255 continue;
256 }
257
258 let trimmed = line.trim_end();
259 let trailing_spaces = Self::count_trailing_spaces(line);
260
261 if trimmed.is_empty() {
263 let prev_line = if i > 0 { Some(lines[i - 1]) } else { None };
265 if self.config.list_item_empty_lines && Self::is_empty_list_item_line(line, prev_line) {
266 result.push_str(line);
267 } else {
268 }
270 result.push('\n');
271 continue;
272 }
273
274 if let Some(line_info) = ctx.line_info(i + 1)
276 && line_info.in_code_block
277 {
278 result.push_str(line);
279 result.push('\n');
280 continue;
281 }
282
283 let is_truly_last_line = i == lines.len() - 1 && !content.ends_with('\n');
287
288 result.push_str(trimmed);
289
290 let is_heading = if let Some(line_info) = ctx.line_info(i + 1) {
292 line_info.heading.is_some()
293 } else {
294 trimmed.starts_with('#')
296 };
297
298 let is_empty_blockquote = if let Some(line_info) = ctx.line_info(i + 1) {
300 line_info.blockquote.as_ref().is_some_and(|bq| bq.content.is_empty())
301 } else {
302 false
303 };
304
305 if !self.config.strict
308 && !is_truly_last_line
309 && trailing_spaces == self.config.br_spaces.get()
310 && !is_heading
311 && !is_empty_blockquote
312 {
313 match self.config.br_spaces.get() {
315 0 => {}
316 1 => result.push(' '),
317 2 => result.push_str(" "),
318 n => result.push_str(&" ".repeat(n)),
319 }
320 }
321 result.push('\n');
322 }
323
324 if !content.ends_with('\n') && result.ends_with('\n') {
326 result.pop();
327 }
328
329 Ok(result)
330 }
331
332 fn as_any(&self) -> &dyn std::any::Any {
333 self
334 }
335
336 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
337 ctx.content.is_empty() || !ctx.content.contains(' ')
339 }
340
341 fn category(&self) -> RuleCategory {
342 RuleCategory::Whitespace
343 }
344
345 fn default_config_section(&self) -> Option<(String, toml::Value)> {
346 let default_config = MD009Config::default();
347 let json_value = serde_json::to_value(&default_config).ok()?;
348 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
349
350 if let toml::Value::Table(table) = toml_value {
351 if !table.is_empty() {
352 Some((MD009Config::RULE_NAME.to_string(), toml::Value::Table(table)))
353 } else {
354 None
355 }
356 } else {
357 None
358 }
359 }
360
361 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
362 where
363 Self: Sized,
364 {
365 let rule_config = crate::rule_config_serde::load_rule_config::<MD009Config>(config);
366 Box::new(Self::from_config_struct(rule_config))
367 }
368}
369
370#[cfg(test)]
371mod tests {
372 use super::*;
373 use crate::lint_context::LintContext;
374 use crate::rule::Rule;
375
376 #[test]
377 fn test_no_trailing_spaces() {
378 let rule = MD009TrailingSpaces::default();
379 let content = "This is a line\nAnother line\nNo trailing spaces";
380 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
381 let result = rule.check(&ctx).unwrap();
382 assert!(result.is_empty());
383 }
384
385 #[test]
386 fn test_basic_trailing_spaces() {
387 let rule = MD009TrailingSpaces::default();
388 let content = "Line with spaces \nAnother line \nClean line";
389 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
390 let result = rule.check(&ctx).unwrap();
391 assert_eq!(result.len(), 1);
393 assert_eq!(result[0].line, 1);
394 assert_eq!(result[0].message, "3 trailing spaces found");
395 }
396
397 #[test]
398 fn test_fix_basic_trailing_spaces() {
399 let rule = MD009TrailingSpaces::default();
400 let content = "Line with spaces \nAnother line \nClean line";
401 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
402 let fixed = rule.fix(&ctx).unwrap();
403 assert_eq!(fixed, "Line with spaces\nAnother line \nClean line");
407 }
408
409 #[test]
410 fn test_strict_mode() {
411 let rule = MD009TrailingSpaces::new(2, true);
412 let content = "Line with spaces \nCode block: \n``` \nCode with spaces \n``` ";
413 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
414 let result = rule.check(&ctx).unwrap();
415 assert_eq!(result.len(), 5);
417
418 let fixed = rule.fix(&ctx).unwrap();
419 assert_eq!(fixed, "Line with spaces\nCode block:\n```\nCode with spaces\n```");
420 }
421
422 #[test]
423 fn test_non_strict_mode_with_code_blocks() {
424 let rule = MD009TrailingSpaces::new(2, false);
425 let content = "Line with spaces \n```\nCode with spaces \n```\nOutside code ";
426 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
427 let result = rule.check(&ctx).unwrap();
428 assert_eq!(result.len(), 1);
432 assert_eq!(result[0].line, 5);
433 }
434
435 #[test]
436 fn test_br_spaces_preservation() {
437 let rule = MD009TrailingSpaces::new(2, false);
438 let content = "Line with two spaces \nLine with three spaces \nLine 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(), 2);
445 assert_eq!(result[0].line, 2);
446 assert_eq!(result[1].line, 3);
447
448 let fixed = rule.fix(&ctx).unwrap();
449 assert_eq!(
453 fixed,
454 "Line with two spaces \nLine with three spaces\nLine with one space"
455 );
456 }
457
458 #[test]
459 fn test_empty_lines_with_spaces() {
460 let rule = MD009TrailingSpaces::default();
461 let content = "Normal line\n \n \nAnother line";
462 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
463 let result = rule.check(&ctx).unwrap();
464 assert_eq!(result.len(), 2);
465 assert_eq!(result[0].message, "Empty line has trailing spaces");
466 assert_eq!(result[1].message, "Empty line has trailing spaces");
467
468 let fixed = rule.fix(&ctx).unwrap();
469 assert_eq!(fixed, "Normal line\n\n\nAnother line");
470 }
471
472 #[test]
473 fn test_empty_blockquote_lines() {
474 let rule = MD009TrailingSpaces::default();
475 let content = "> Quote\n> \n> More quote";
476 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
477 let result = rule.check(&ctx).unwrap();
478 assert_eq!(result.len(), 1);
479 assert_eq!(result[0].line, 2);
480 assert_eq!(result[0].message, "3 trailing spaces found");
481
482 let fixed = rule.fix(&ctx).unwrap();
483 assert_eq!(fixed, "> Quote\n>\n> More quote"); }
485
486 #[test]
487 fn test_last_line_handling() {
488 let rule = MD009TrailingSpaces::new(2, false);
489
490 let content = "First line \nLast line ";
492 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
493 let result = rule.check(&ctx).unwrap();
494 assert_eq!(result.len(), 1);
496 assert_eq!(result[0].line, 2);
497
498 let fixed = rule.fix(&ctx).unwrap();
499 assert_eq!(fixed, "First line \nLast line");
500
501 let content_with_newline = "First line \nLast line \n";
503 let ctx = LintContext::new(content_with_newline, crate::config::MarkdownFlavor::Standard, None);
504 let result = rule.check(&ctx).unwrap();
505 assert!(result.is_empty());
507 }
508
509 #[test]
510 fn test_single_trailing_space() {
511 let rule = MD009TrailingSpaces::new(2, false);
512 let content = "Line with one space ";
513 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
514 let result = rule.check(&ctx).unwrap();
515 assert_eq!(result.len(), 1);
516 assert_eq!(result[0].message, "Trailing space found");
517 }
518
519 #[test]
520 fn test_tabs_not_spaces() {
521 let rule = MD009TrailingSpaces::default();
522 let content = "Line with tab\t\nLine with spaces ";
523 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
524 let result = rule.check(&ctx).unwrap();
525 assert_eq!(result.len(), 1);
527 assert_eq!(result[0].line, 2);
528 }
529
530 #[test]
531 fn test_mixed_content() {
532 let rule = MD009TrailingSpaces::new(2, false);
533 let mut content = String::new();
535 content.push_str("# Heading");
536 content.push_str(" "); content.push('\n');
538 content.push_str("Normal paragraph\n> Blockquote\n>\n```\nCode block\n```\n- List item\n");
539
540 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
541 let result = rule.check(&ctx).unwrap();
542 assert_eq!(result.len(), 1);
544 assert_eq!(result[0].line, 1);
545 assert!(result[0].message.contains("trailing spaces"));
546 }
547
548 #[test]
549 fn test_column_positions() {
550 let rule = MD009TrailingSpaces::default();
551 let content = "Text ";
552 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
553 let result = rule.check(&ctx).unwrap();
554 assert_eq!(result.len(), 1);
555 assert_eq!(result[0].column, 5); assert_eq!(result[0].end_column, 8); }
558
559 #[test]
560 fn test_default_config() {
561 let rule = MD009TrailingSpaces::default();
562 let config = rule.default_config_section();
563 assert!(config.is_some());
564 let (name, _value) = config.unwrap();
565 assert_eq!(name, "MD009");
566 }
567
568 #[test]
569 fn test_from_config() {
570 let mut config = crate::config::Config::default();
571 let mut rule_config = crate::config::RuleConfig::default();
572 rule_config
573 .values
574 .insert("br_spaces".to_string(), toml::Value::Integer(3));
575 rule_config
576 .values
577 .insert("strict".to_string(), toml::Value::Boolean(true));
578 config.rules.insert("MD009".to_string(), rule_config);
579
580 let rule = MD009TrailingSpaces::from_config(&config);
581 let content = "Line ";
582 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
583 let result = rule.check(&ctx).unwrap();
584 assert_eq!(result.len(), 1);
585
586 let fixed = rule.fix(&ctx).unwrap();
588 assert_eq!(fixed, "Line");
589 }
590
591 #[test]
592 fn test_list_item_empty_lines() {
593 let config = MD009Config {
595 list_item_empty_lines: true,
596 ..Default::default()
597 };
598 let rule = MD009TrailingSpaces::from_config_struct(config);
599
600 let content = "- First item\n \n- Second item";
602 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
603 let result = rule.check(&ctx).unwrap();
604 assert!(result.is_empty());
606
607 let content = "1. First item\n \n2. Second item";
609 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
610 let result = rule.check(&ctx).unwrap();
611 assert!(result.is_empty());
612
613 let content = "Normal paragraph\n \nAnother paragraph";
615 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
616 let result = rule.check(&ctx).unwrap();
617 assert_eq!(result.len(), 1);
618 assert_eq!(result[0].line, 2);
619 }
620
621 #[test]
622 fn test_list_item_empty_lines_disabled() {
623 let rule = MD009TrailingSpaces::default();
625
626 let content = "- First item\n \n- Second item";
627 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
628 let result = rule.check(&ctx).unwrap();
629 assert_eq!(result.len(), 1);
631 assert_eq!(result[0].line, 2);
632 }
633
634 #[test]
635 fn test_performance_large_document() {
636 let rule = MD009TrailingSpaces::default();
637 let mut content = String::new();
638 for i in 0..1000 {
639 content.push_str(&format!("Line {i} with spaces \n"));
640 }
641 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
642 let result = rule.check(&ctx).unwrap();
643 assert_eq!(result.len(), 0);
645 }
646
647 #[test]
648 fn test_preserve_content_after_fix() {
649 let rule = MD009TrailingSpaces::new(2, false);
650 let content = "**Bold** text \n*Italic* text \n[Link](url) ";
651 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
652 let fixed = rule.fix(&ctx).unwrap();
653 assert_eq!(fixed, "**Bold** text \n*Italic* text \n[Link](url)");
654 }
655
656 #[test]
657 fn test_nested_blockquotes() {
658 let rule = MD009TrailingSpaces::default();
659 let content = "> > Nested \n> > \n> Normal ";
660 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
661 let result = rule.check(&ctx).unwrap();
662 assert_eq!(result.len(), 2);
664 assert_eq!(result[0].line, 2);
665 assert_eq!(result[1].line, 3);
666
667 let fixed = rule.fix(&ctx).unwrap();
668 assert_eq!(fixed, "> > Nested \n> >\n> Normal");
672 }
673
674 #[test]
675 fn test_normalized_line_endings() {
676 let rule = MD009TrailingSpaces::default();
677 let content = "Line with spaces \nAnother line ";
679 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
680 let result = rule.check(&ctx).unwrap();
681 assert_eq!(result.len(), 1);
684 assert_eq!(result[0].line, 2);
685 }
686
687 #[test]
688 fn test_issue_80_no_space_normalization() {
689 let rule = MD009TrailingSpaces::new(2, false); let content = "Line with one space \nNext line";
694 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
695 let result = rule.check(&ctx).unwrap();
696 assert_eq!(result.len(), 1);
697 assert_eq!(result[0].line, 1);
698 assert_eq!(result[0].message, "Trailing space found");
699
700 let fixed = rule.fix(&ctx).unwrap();
701 assert_eq!(fixed, "Line with one space\nNext line");
702
703 let content = "Line with three spaces \nNext line";
705 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
706 let result = rule.check(&ctx).unwrap();
707 assert_eq!(result.len(), 1);
708 assert_eq!(result[0].line, 1);
709 assert_eq!(result[0].message, "3 trailing spaces found");
710
711 let fixed = rule.fix(&ctx).unwrap();
712 assert_eq!(fixed, "Line with three spaces\nNext line");
713
714 let content = "Line with two spaces \nNext line";
716 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
717 let result = rule.check(&ctx).unwrap();
718 assert_eq!(result.len(), 0); let fixed = rule.fix(&ctx).unwrap();
721 assert_eq!(fixed, "Line with two spaces \nNext line");
722 }
723}