1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2use crate::rule_config_serde::RuleConfig;
3use crate::utils::range_utils::{LineIndex, 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,
21 strict,
22 list_item_empty_lines: false,
23 },
24 }
25 }
26
27 pub 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 is_empty_list_item_line(line: &str, prev_line: Option<&str>) -> bool {
36 if !line.trim().is_empty() {
40 return false;
41 }
42
43 if let Some(prev) = prev_line {
44 UNORDERED_LIST_MARKER_REGEX.is_match(prev) || ORDERED_LIST_MARKER_REGEX.is_match(prev)
46 } else {
47 false
48 }
49 }
50}
51
52impl Rule for MD009TrailingSpaces {
53 fn name(&self) -> &'static str {
54 "MD009"
55 }
56
57 fn description(&self) -> &'static str {
58 "Trailing spaces should be removed"
59 }
60
61 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
62 let content = ctx.content;
63 let _line_index = LineIndex::new(content.to_string());
64
65 let mut warnings = Vec::new();
66
67 let lines: Vec<&str> = content.lines().collect();
68
69 for (line_num, &line) in lines.iter().enumerate() {
70 let trailing_spaces = Self::count_trailing_spaces(line);
71
72 if trailing_spaces == 0 {
74 continue;
75 }
76
77 if line.trim().is_empty() {
79 if trailing_spaces > 0 {
80 let prev_line = if line_num > 0 { Some(lines[line_num - 1]) } else { None };
82 if self.config.list_item_empty_lines && Self::is_empty_list_item_line(line, prev_line) {
83 continue;
84 }
85
86 let (start_line, start_col, end_line, end_col) = calculate_trailing_range(line_num + 1, line, 0);
88
89 warnings.push(LintWarning {
90 rule_name: Some(self.name()),
91 line: start_line,
92 column: start_col,
93 end_line,
94 end_column: end_col,
95 message: "Empty line has trailing spaces".to_string(),
96 severity: Severity::Warning,
97 fix: Some(Fix {
98 range: _line_index.line_col_to_byte_range_with_length(line_num + 1, 1, line.len()),
99 replacement: String::new(),
100 }),
101 });
102 }
103 continue;
104 }
105
106 if !self.config.strict {
108 if let Some(line_info) = ctx.line_info(line_num + 1)
110 && line_info.in_code_block
111 {
112 continue;
113 }
114 }
115
116 let is_truly_last_line = line_num == lines.len() - 1 && !content.ends_with('\n');
120 if !self.config.strict && !is_truly_last_line && trailing_spaces == self.config.br_spaces {
121 continue;
122 }
123
124 let trimmed = line.trim_end();
127 let is_empty_blockquote_with_space = trimmed.chars().all(|c| c == '>' || c == ' ' || c == '\t')
128 && trimmed.contains('>')
129 && trailing_spaces == 1;
130
131 if is_empty_blockquote_with_space {
132 continue; }
134 let (start_line, start_col, end_line, end_col) =
136 calculate_trailing_range(line_num + 1, line, trimmed.len());
137
138 warnings.push(LintWarning {
139 rule_name: Some(self.name()),
140 line: start_line,
141 column: start_col,
142 end_line,
143 end_column: end_col,
144 message: if trailing_spaces == 1 {
145 "Trailing space found".to_string()
146 } else {
147 format!("{trailing_spaces} trailing spaces found")
148 },
149 severity: Severity::Warning,
150 fix: Some(Fix {
151 range: _line_index.line_col_to_byte_range_with_length(
152 line_num + 1,
153 trimmed.len() + 1,
154 trailing_spaces,
155 ),
156 replacement: if !self.config.strict
157 && !is_truly_last_line
158 && trailing_spaces == self.config.br_spaces
159 {
160 " ".repeat(self.config.br_spaces)
161 } else {
162 String::new()
163 },
164 }),
165 });
166 }
167
168 Ok(warnings)
169 }
170
171 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
172 let content = ctx.content;
173
174 if self.config.strict {
176 return Ok(get_cached_regex(r"(?m) +$")
178 .unwrap()
179 .replace_all(content, "")
180 .to_string());
181 }
182
183 let lines: Vec<&str> = content.lines().collect();
185 let mut result = String::with_capacity(content.len()); for (i, line) in lines.iter().enumerate() {
188 if !line.ends_with(' ') {
190 result.push_str(line);
191 result.push('\n');
192 continue;
193 }
194
195 let trimmed = line.trim_end();
196 let trailing_spaces = Self::count_trailing_spaces(line);
197
198 if trimmed.is_empty() {
200 let prev_line = if i > 0 { Some(lines[i - 1]) } else { None };
202 if self.config.list_item_empty_lines && Self::is_empty_list_item_line(line, prev_line) {
203 result.push_str(line);
204 } else {
205 }
207 result.push('\n');
208 continue;
209 }
210
211 if let Some(line_info) = ctx.line_info(i + 1)
213 && line_info.in_code_block
214 {
215 result.push_str(line);
216 result.push('\n');
217 continue;
218 }
219
220 let is_truly_last_line = i == lines.len() - 1 && !content.ends_with('\n');
224
225 result.push_str(trimmed);
226
227 let is_heading = if let Some(line_info) = ctx.line_info(i + 1) {
229 line_info.heading.is_some()
230 } else {
231 trimmed.starts_with('#')
233 };
234
235 let is_empty_blockquote = if let Some(line_info) = ctx.line_info(i + 1) {
237 line_info.blockquote.as_ref().is_some_and(|bq| bq.content.is_empty())
238 } else {
239 false
240 };
241
242 if !self.config.strict
245 && !is_truly_last_line
246 && trailing_spaces == self.config.br_spaces
247 && !is_heading
248 && !is_empty_blockquote
249 {
250 match self.config.br_spaces {
252 0 => {}
253 1 => result.push(' '),
254 2 => result.push_str(" "),
255 n => result.push_str(&" ".repeat(n)),
256 }
257 }
258 result.push('\n');
259 }
260
261 if !content.ends_with('\n') && result.ends_with('\n') {
263 result.pop();
264 }
265
266 Ok(result)
267 }
268
269 fn as_any(&self) -> &dyn std::any::Any {
270 self
271 }
272
273 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
274 ctx.content.is_empty() || !ctx.content.contains(' ')
276 }
277
278 fn category(&self) -> RuleCategory {
279 RuleCategory::Whitespace
280 }
281
282 fn default_config_section(&self) -> Option<(String, toml::Value)> {
283 let default_config = MD009Config::default();
284 let json_value = serde_json::to_value(&default_config).ok()?;
285 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
286
287 if let toml::Value::Table(table) = toml_value {
288 if !table.is_empty() {
289 Some((MD009Config::RULE_NAME.to_string(), toml::Value::Table(table)))
290 } else {
291 None
292 }
293 } else {
294 None
295 }
296 }
297
298 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
299 where
300 Self: Sized,
301 {
302 let rule_config = crate::rule_config_serde::load_rule_config::<MD009Config>(config);
303 Box::new(Self::from_config_struct(rule_config))
304 }
305}
306
307#[cfg(test)]
308mod tests {
309 use super::*;
310 use crate::lint_context::LintContext;
311 use crate::rule::Rule;
312
313 #[test]
314 fn test_no_trailing_spaces() {
315 let rule = MD009TrailingSpaces::default();
316 let content = "This is a line\nAnother line\nNo trailing spaces";
317 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
318 let result = rule.check(&ctx).unwrap();
319 assert!(result.is_empty());
320 }
321
322 #[test]
323 fn test_basic_trailing_spaces() {
324 let rule = MD009TrailingSpaces::default();
325 let content = "Line with spaces \nAnother line \nClean line";
326 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
327 let result = rule.check(&ctx).unwrap();
328 assert_eq!(result.len(), 1);
330 assert_eq!(result[0].line, 1);
331 assert_eq!(result[0].message, "3 trailing spaces found");
332 }
333
334 #[test]
335 fn test_fix_basic_trailing_spaces() {
336 let rule = MD009TrailingSpaces::default();
337 let content = "Line with spaces \nAnother line \nClean line";
338 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
339 let fixed = rule.fix(&ctx).unwrap();
340 assert_eq!(fixed, "Line with spaces\nAnother line \nClean line");
344 }
345
346 #[test]
347 fn test_strict_mode() {
348 let rule = MD009TrailingSpaces::new(2, true);
349 let content = "Line with spaces \nCode block: \n``` \nCode with spaces \n``` ";
350 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
351 let result = rule.check(&ctx).unwrap();
352 assert_eq!(result.len(), 5);
354
355 let fixed = rule.fix(&ctx).unwrap();
356 assert_eq!(fixed, "Line with spaces\nCode block:\n```\nCode with spaces\n```");
357 }
358
359 #[test]
360 fn test_non_strict_mode_with_code_blocks() {
361 let rule = MD009TrailingSpaces::new(2, false);
362 let content = "Line with spaces \n```\nCode with spaces \n```\nOutside code ";
363 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
364 let result = rule.check(&ctx).unwrap();
365 assert_eq!(result.len(), 1);
369 assert_eq!(result[0].line, 5);
370 }
371
372 #[test]
373 fn test_br_spaces_preservation() {
374 let rule = MD009TrailingSpaces::new(2, false);
375 let content = "Line with two spaces \nLine with three spaces \nLine with one space ";
376 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
377 let result = rule.check(&ctx).unwrap();
378 assert_eq!(result.len(), 2);
382 assert_eq!(result[0].line, 2);
383 assert_eq!(result[1].line, 3);
384
385 let fixed = rule.fix(&ctx).unwrap();
386 assert_eq!(
390 fixed,
391 "Line with two spaces \nLine with three spaces\nLine with one space"
392 );
393 }
394
395 #[test]
396 fn test_empty_lines_with_spaces() {
397 let rule = MD009TrailingSpaces::default();
398 let content = "Normal line\n \n \nAnother line";
399 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
400 let result = rule.check(&ctx).unwrap();
401 assert_eq!(result.len(), 2);
402 assert_eq!(result[0].message, "Empty line has trailing spaces");
403 assert_eq!(result[1].message, "Empty line has trailing spaces");
404
405 let fixed = rule.fix(&ctx).unwrap();
406 assert_eq!(fixed, "Normal line\n\n\nAnother line");
407 }
408
409 #[test]
410 fn test_empty_blockquote_lines() {
411 let rule = MD009TrailingSpaces::default();
412 let content = "> Quote\n> \n> More quote";
413 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
414 let result = rule.check(&ctx).unwrap();
415 assert_eq!(result.len(), 1);
416 assert_eq!(result[0].line, 2);
417 assert_eq!(result[0].message, "3 trailing spaces found");
418
419 let fixed = rule.fix(&ctx).unwrap();
420 assert_eq!(fixed, "> Quote\n>\n> More quote"); }
422
423 #[test]
424 fn test_last_line_handling() {
425 let rule = MD009TrailingSpaces::new(2, false);
426
427 let content = "First line \nLast line ";
429 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
430 let result = rule.check(&ctx).unwrap();
431 assert_eq!(result.len(), 1);
433 assert_eq!(result[0].line, 2);
434
435 let fixed = rule.fix(&ctx).unwrap();
436 assert_eq!(fixed, "First line \nLast line");
437
438 let content_with_newline = "First line \nLast line \n";
440 let ctx = LintContext::new(content_with_newline, crate::config::MarkdownFlavor::Standard);
441 let result = rule.check(&ctx).unwrap();
442 assert!(result.is_empty());
444 }
445
446 #[test]
447 fn test_single_trailing_space() {
448 let rule = MD009TrailingSpaces::new(2, false);
449 let content = "Line with one space ";
450 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
451 let result = rule.check(&ctx).unwrap();
452 assert_eq!(result.len(), 1);
453 assert_eq!(result[0].message, "Trailing space found");
454 }
455
456 #[test]
457 fn test_tabs_not_spaces() {
458 let rule = MD009TrailingSpaces::default();
459 let content = "Line with tab\t\nLine with spaces ";
460 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
461 let result = rule.check(&ctx).unwrap();
462 assert_eq!(result.len(), 1);
464 assert_eq!(result[0].line, 2);
465 }
466
467 #[test]
468 fn test_mixed_content() {
469 let rule = MD009TrailingSpaces::new(2, false);
470 let mut content = String::new();
472 content.push_str("# Heading");
473 content.push_str(" "); content.push('\n');
475 content.push_str("Normal paragraph\n> Blockquote\n>\n```\nCode block\n```\n- List item\n");
476
477 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard);
478 let result = rule.check(&ctx).unwrap();
479 assert_eq!(result.len(), 1);
481 assert_eq!(result[0].line, 1);
482 assert!(result[0].message.contains("trailing spaces"));
483 }
484
485 #[test]
486 fn test_column_positions() {
487 let rule = MD009TrailingSpaces::default();
488 let content = "Text ";
489 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
490 let result = rule.check(&ctx).unwrap();
491 assert_eq!(result.len(), 1);
492 assert_eq!(result[0].column, 5); assert_eq!(result[0].end_column, 8); }
495
496 #[test]
497 fn test_default_config() {
498 let rule = MD009TrailingSpaces::default();
499 let config = rule.default_config_section();
500 assert!(config.is_some());
501 let (name, _value) = config.unwrap();
502 assert_eq!(name, "MD009");
503 }
504
505 #[test]
506 fn test_from_config() {
507 let mut config = crate::config::Config::default();
508 let mut rule_config = crate::config::RuleConfig::default();
509 rule_config
510 .values
511 .insert("br_spaces".to_string(), toml::Value::Integer(3));
512 rule_config
513 .values
514 .insert("strict".to_string(), toml::Value::Boolean(true));
515 config.rules.insert("MD009".to_string(), rule_config);
516
517 let rule = MD009TrailingSpaces::from_config(&config);
518 let content = "Line ";
519 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
520 let result = rule.check(&ctx).unwrap();
521 assert_eq!(result.len(), 1);
522
523 let fixed = rule.fix(&ctx).unwrap();
525 assert_eq!(fixed, "Line");
526 }
527
528 #[test]
529 fn test_list_item_empty_lines() {
530 let config = MD009Config {
532 list_item_empty_lines: true,
533 ..Default::default()
534 };
535 let rule = MD009TrailingSpaces::from_config_struct(config);
536
537 let content = "- First item\n \n- Second item";
539 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
540 let result = rule.check(&ctx).unwrap();
541 assert!(result.is_empty());
543
544 let content = "1. First item\n \n2. Second item";
546 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
547 let result = rule.check(&ctx).unwrap();
548 assert!(result.is_empty());
549
550 let content = "Normal paragraph\n \nAnother paragraph";
552 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
553 let result = rule.check(&ctx).unwrap();
554 assert_eq!(result.len(), 1);
555 assert_eq!(result[0].line, 2);
556 }
557
558 #[test]
559 fn test_list_item_empty_lines_disabled() {
560 let rule = MD009TrailingSpaces::default();
562
563 let content = "- First item\n \n- Second item";
564 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
565 let result = rule.check(&ctx).unwrap();
566 assert_eq!(result.len(), 1);
568 assert_eq!(result[0].line, 2);
569 }
570
571 #[test]
572 fn test_performance_large_document() {
573 let rule = MD009TrailingSpaces::default();
574 let mut content = String::new();
575 for i in 0..1000 {
576 content.push_str(&format!("Line {i} with spaces \n"));
577 }
578 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard);
579 let result = rule.check(&ctx).unwrap();
580 assert_eq!(result.len(), 0);
582 }
583
584 #[test]
585 fn test_preserve_content_after_fix() {
586 let rule = MD009TrailingSpaces::new(2, false);
587 let content = "**Bold** text \n*Italic* text \n[Link](url) ";
588 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
589 let fixed = rule.fix(&ctx).unwrap();
590 assert_eq!(fixed, "**Bold** text \n*Italic* text \n[Link](url)");
591 }
592
593 #[test]
594 fn test_nested_blockquotes() {
595 let rule = MD009TrailingSpaces::default();
596 let content = "> > Nested \n> > \n> Normal ";
597 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
598 let result = rule.check(&ctx).unwrap();
599 assert_eq!(result.len(), 2);
601 assert_eq!(result[0].line, 2);
602 assert_eq!(result[1].line, 3);
603
604 let fixed = rule.fix(&ctx).unwrap();
605 assert_eq!(fixed, "> > Nested \n> >\n> Normal");
609 }
610
611 #[test]
612 fn test_windows_line_endings() {
613 let rule = MD009TrailingSpaces::default();
614 let content = "Line with spaces \r\nAnother line ";
616 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
617 let result = rule.check(&ctx).unwrap();
618 assert_eq!(result.len(), 1);
621 assert_eq!(result[0].line, 2);
622 }
623
624 #[test]
625 fn test_issue_80_no_space_normalization() {
626 let rule = MD009TrailingSpaces::new(2, false); let content = "Line with one space \nNext line";
631 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
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, "Trailing space found");
636
637 let fixed = rule.fix(&ctx).unwrap();
638 assert_eq!(fixed, "Line with one space\nNext line");
639
640 let content = "Line with three spaces \nNext line";
642 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
643 let result = rule.check(&ctx).unwrap();
644 assert_eq!(result.len(), 1);
645 assert_eq!(result[0].line, 1);
646 assert_eq!(result[0].message, "3 trailing spaces found");
647
648 let fixed = rule.fix(&ctx).unwrap();
649 assert_eq!(fixed, "Line with three spaces\nNext line");
650
651 let content = "Line with two spaces \nNext line";
653 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
654 let result = rule.check(&ctx).unwrap();
655 assert_eq!(result.len(), 0); let fixed = rule.fix(&ctx).unwrap();
658 assert_eq!(fixed, "Line with two spaces \nNext line");
659 }
660
661 #[test]
662 fn test_different_br_spaces_values() {
663 let rule = MD009TrailingSpaces::new(0, false);
665 let content = "Line with one space \nLine with two spaces ";
666 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
667 let result = rule.check(&ctx).unwrap();
668 assert_eq!(result.len(), 2); let fixed = rule.fix(&ctx).unwrap();
671 assert_eq!(fixed, "Line with one space\nLine with two spaces");
672
673 let rule = MD009TrailingSpaces::new(1, false);
675 let content = "Line with one space \nLine with two spaces ";
676 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
677 let result = rule.check(&ctx).unwrap();
678 assert_eq!(result.len(), 1); assert_eq!(result[0].line, 2);
680
681 let fixed = rule.fix(&ctx).unwrap();
682 assert_eq!(fixed, "Line with one space \nLine with two spaces");
683 }
684}