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