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 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 = &ctx.line_index;
64
65 let mut warnings = Vec::new();
66
67 let lines: Vec<&str> = content.lines().collect();
70
71 for (line_num, &line) in lines.iter().enumerate() {
72 let trailing_spaces = Self::count_trailing_spaces(line);
73
74 if trailing_spaces == 0 {
76 continue;
77 }
78
79 if line.trim().is_empty() {
81 if trailing_spaces > 0 {
82 let prev_line = if line_num > 0 { Some(lines[line_num - 1]) } else { None };
84 if self.config.list_item_empty_lines && Self::is_empty_list_item_line(line, prev_line) {
85 continue;
86 }
87
88 let (start_line, start_col, end_line, end_col) = calculate_trailing_range(line_num + 1, line, 0);
90
91 warnings.push(LintWarning {
92 rule_name: Some(self.name().to_string()),
93 line: start_line,
94 column: start_col,
95 end_line,
96 end_column: end_col,
97 message: "Empty line has trailing spaces".to_string(),
98 severity: Severity::Warning,
99 fix: Some(Fix {
100 range: _line_index.line_col_to_byte_range_with_length(line_num + 1, 1, line.len()),
101 replacement: String::new(),
102 }),
103 });
104 }
105 continue;
106 }
107
108 if !self.config.strict {
110 if let Some(line_info) = ctx.line_info(line_num + 1)
112 && line_info.in_code_block
113 {
114 continue;
115 }
116 }
117
118 let is_truly_last_line = line_num == lines.len() - 1 && !content.ends_with('\n');
122 if !self.config.strict && !is_truly_last_line && trailing_spaces == self.config.br_spaces.get() {
123 continue;
124 }
125
126 let trimmed = line.trim_end();
129 let is_empty_blockquote_with_space = trimmed.chars().all(|c| c == '>' || c == ' ' || c == '\t')
130 && trimmed.contains('>')
131 && trailing_spaces == 1;
132
133 if is_empty_blockquote_with_space {
134 continue; }
136 let (start_line, start_col, end_line, end_col) =
138 calculate_trailing_range(line_num + 1, line, trimmed.len());
139
140 warnings.push(LintWarning {
141 rule_name: Some(self.name().to_string()),
142 line: start_line,
143 column: start_col,
144 end_line,
145 end_column: end_col,
146 message: if trailing_spaces == 1 {
147 "Trailing space found".to_string()
148 } else {
149 format!("{trailing_spaces} trailing spaces found")
150 },
151 severity: Severity::Warning,
152 fix: Some(Fix {
153 range: _line_index.line_col_to_byte_range_with_length(
154 line_num + 1,
155 trimmed.len() + 1,
156 trailing_spaces,
157 ),
158 replacement: if !self.config.strict
159 && !is_truly_last_line
160 && trailing_spaces == self.config.br_spaces.get()
161 {
162 " ".repeat(self.config.br_spaces.get())
163 } else {
164 String::new()
165 },
166 }),
167 });
168 }
169
170 Ok(warnings)
171 }
172
173 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
174 let content = ctx.content;
175
176 if self.config.strict {
178 return Ok(get_cached_regex(r"(?m) +$")
180 .unwrap()
181 .replace_all(content, "")
182 .to_string());
183 }
184
185 let lines: Vec<&str> = content.lines().collect();
188 let mut result = String::with_capacity(content.len()); for (i, line) in lines.iter().enumerate() {
191 if !line.ends_with(' ') {
193 result.push_str(line);
194 result.push('\n');
195 continue;
196 }
197
198 let trimmed = line.trim_end();
199 let trailing_spaces = Self::count_trailing_spaces(line);
200
201 if trimmed.is_empty() {
203 let prev_line = if i > 0 { Some(lines[i - 1]) } else { None };
205 if self.config.list_item_empty_lines && Self::is_empty_list_item_line(line, prev_line) {
206 result.push_str(line);
207 } else {
208 }
210 result.push('\n');
211 continue;
212 }
213
214 if let Some(line_info) = ctx.line_info(i + 1)
216 && line_info.in_code_block
217 {
218 result.push_str(line);
219 result.push('\n');
220 continue;
221 }
222
223 let is_truly_last_line = i == lines.len() - 1 && !content.ends_with('\n');
227
228 result.push_str(trimmed);
229
230 let is_heading = if let Some(line_info) = ctx.line_info(i + 1) {
232 line_info.heading.is_some()
233 } else {
234 trimmed.starts_with('#')
236 };
237
238 let is_empty_blockquote = if let Some(line_info) = ctx.line_info(i + 1) {
240 line_info.blockquote.as_ref().is_some_and(|bq| bq.content.is_empty())
241 } else {
242 false
243 };
244
245 if !self.config.strict
248 && !is_truly_last_line
249 && trailing_spaces == self.config.br_spaces.get()
250 && !is_heading
251 && !is_empty_blockquote
252 {
253 match self.config.br_spaces.get() {
255 0 => {}
256 1 => result.push(' '),
257 2 => result.push_str(" "),
258 n => result.push_str(&" ".repeat(n)),
259 }
260 }
261 result.push('\n');
262 }
263
264 if !content.ends_with('\n') && result.ends_with('\n') {
266 result.pop();
267 }
268
269 Ok(result)
270 }
271
272 fn as_any(&self) -> &dyn std::any::Any {
273 self
274 }
275
276 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
277 ctx.content.is_empty() || !ctx.content.contains(' ')
279 }
280
281 fn category(&self) -> RuleCategory {
282 RuleCategory::Whitespace
283 }
284
285 fn default_config_section(&self) -> Option<(String, toml::Value)> {
286 let default_config = MD009Config::default();
287 let json_value = serde_json::to_value(&default_config).ok()?;
288 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
289
290 if let toml::Value::Table(table) = toml_value {
291 if !table.is_empty() {
292 Some((MD009Config::RULE_NAME.to_string(), toml::Value::Table(table)))
293 } else {
294 None
295 }
296 } else {
297 None
298 }
299 }
300
301 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
302 where
303 Self: Sized,
304 {
305 let rule_config = crate::rule_config_serde::load_rule_config::<MD009Config>(config);
306 Box::new(Self::from_config_struct(rule_config))
307 }
308}
309
310#[cfg(test)]
311mod tests {
312 use super::*;
313 use crate::lint_context::LintContext;
314 use crate::rule::Rule;
315
316 #[test]
317 fn test_no_trailing_spaces() {
318 let rule = MD009TrailingSpaces::default();
319 let content = "This is a line\nAnother line\nNo trailing spaces";
320 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
321 let result = rule.check(&ctx).unwrap();
322 assert!(result.is_empty());
323 }
324
325 #[test]
326 fn test_basic_trailing_spaces() {
327 let rule = MD009TrailingSpaces::default();
328 let content = "Line with spaces \nAnother line \nClean line";
329 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
330 let result = rule.check(&ctx).unwrap();
331 assert_eq!(result.len(), 1);
333 assert_eq!(result[0].line, 1);
334 assert_eq!(result[0].message, "3 trailing spaces found");
335 }
336
337 #[test]
338 fn test_fix_basic_trailing_spaces() {
339 let rule = MD009TrailingSpaces::default();
340 let content = "Line with spaces \nAnother line \nClean line";
341 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
342 let fixed = rule.fix(&ctx).unwrap();
343 assert_eq!(fixed, "Line with spaces\nAnother line \nClean line");
347 }
348
349 #[test]
350 fn test_strict_mode() {
351 let rule = MD009TrailingSpaces::new(2, true);
352 let content = "Line with spaces \nCode block: \n``` \nCode with spaces \n``` ";
353 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
354 let result = rule.check(&ctx).unwrap();
355 assert_eq!(result.len(), 5);
357
358 let fixed = rule.fix(&ctx).unwrap();
359 assert_eq!(fixed, "Line with spaces\nCode block:\n```\nCode with spaces\n```");
360 }
361
362 #[test]
363 fn test_non_strict_mode_with_code_blocks() {
364 let rule = MD009TrailingSpaces::new(2, false);
365 let content = "Line with spaces \n```\nCode with spaces \n```\nOutside code ";
366 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
367 let result = rule.check(&ctx).unwrap();
368 assert_eq!(result.len(), 1);
372 assert_eq!(result[0].line, 5);
373 }
374
375 #[test]
376 fn test_br_spaces_preservation() {
377 let rule = MD009TrailingSpaces::new(2, false);
378 let content = "Line with two spaces \nLine with three spaces \nLine with one space ";
379 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
380 let result = rule.check(&ctx).unwrap();
381 assert_eq!(result.len(), 2);
385 assert_eq!(result[0].line, 2);
386 assert_eq!(result[1].line, 3);
387
388 let fixed = rule.fix(&ctx).unwrap();
389 assert_eq!(
393 fixed,
394 "Line with two spaces \nLine with three spaces\nLine with one space"
395 );
396 }
397
398 #[test]
399 fn test_empty_lines_with_spaces() {
400 let rule = MD009TrailingSpaces::default();
401 let content = "Normal line\n \n \nAnother line";
402 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
403 let result = rule.check(&ctx).unwrap();
404 assert_eq!(result.len(), 2);
405 assert_eq!(result[0].message, "Empty line has trailing spaces");
406 assert_eq!(result[1].message, "Empty line has trailing spaces");
407
408 let fixed = rule.fix(&ctx).unwrap();
409 assert_eq!(fixed, "Normal line\n\n\nAnother line");
410 }
411
412 #[test]
413 fn test_empty_blockquote_lines() {
414 let rule = MD009TrailingSpaces::default();
415 let content = "> Quote\n> \n> More quote";
416 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
417 let result = rule.check(&ctx).unwrap();
418 assert_eq!(result.len(), 1);
419 assert_eq!(result[0].line, 2);
420 assert_eq!(result[0].message, "3 trailing spaces found");
421
422 let fixed = rule.fix(&ctx).unwrap();
423 assert_eq!(fixed, "> Quote\n>\n> More quote"); }
425
426 #[test]
427 fn test_last_line_handling() {
428 let rule = MD009TrailingSpaces::new(2, false);
429
430 let content = "First line \nLast line ";
432 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
433 let result = rule.check(&ctx).unwrap();
434 assert_eq!(result.len(), 1);
436 assert_eq!(result[0].line, 2);
437
438 let fixed = rule.fix(&ctx).unwrap();
439 assert_eq!(fixed, "First line \nLast line");
440
441 let content_with_newline = "First line \nLast line \n";
443 let ctx = LintContext::new(content_with_newline, crate::config::MarkdownFlavor::Standard);
444 let result = rule.check(&ctx).unwrap();
445 assert!(result.is_empty());
447 }
448
449 #[test]
450 fn test_single_trailing_space() {
451 let rule = MD009TrailingSpaces::new(2, false);
452 let content = "Line with one space ";
453 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
454 let result = rule.check(&ctx).unwrap();
455 assert_eq!(result.len(), 1);
456 assert_eq!(result[0].message, "Trailing space found");
457 }
458
459 #[test]
460 fn test_tabs_not_spaces() {
461 let rule = MD009TrailingSpaces::default();
462 let content = "Line with tab\t\nLine with spaces ";
463 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
464 let result = rule.check(&ctx).unwrap();
465 assert_eq!(result.len(), 1);
467 assert_eq!(result[0].line, 2);
468 }
469
470 #[test]
471 fn test_mixed_content() {
472 let rule = MD009TrailingSpaces::new(2, false);
473 let mut content = String::new();
475 content.push_str("# Heading");
476 content.push_str(" "); content.push('\n');
478 content.push_str("Normal paragraph\n> Blockquote\n>\n```\nCode block\n```\n- List item\n");
479
480 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard);
481 let result = rule.check(&ctx).unwrap();
482 assert_eq!(result.len(), 1);
484 assert_eq!(result[0].line, 1);
485 assert!(result[0].message.contains("trailing spaces"));
486 }
487
488 #[test]
489 fn test_column_positions() {
490 let rule = MD009TrailingSpaces::default();
491 let content = "Text ";
492 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
493 let result = rule.check(&ctx).unwrap();
494 assert_eq!(result.len(), 1);
495 assert_eq!(result[0].column, 5); assert_eq!(result[0].end_column, 8); }
498
499 #[test]
500 fn test_default_config() {
501 let rule = MD009TrailingSpaces::default();
502 let config = rule.default_config_section();
503 assert!(config.is_some());
504 let (name, _value) = config.unwrap();
505 assert_eq!(name, "MD009");
506 }
507
508 #[test]
509 fn test_from_config() {
510 let mut config = crate::config::Config::default();
511 let mut rule_config = crate::config::RuleConfig::default();
512 rule_config
513 .values
514 .insert("br_spaces".to_string(), toml::Value::Integer(3));
515 rule_config
516 .values
517 .insert("strict".to_string(), toml::Value::Boolean(true));
518 config.rules.insert("MD009".to_string(), rule_config);
519
520 let rule = MD009TrailingSpaces::from_config(&config);
521 let content = "Line ";
522 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
523 let result = rule.check(&ctx).unwrap();
524 assert_eq!(result.len(), 1);
525
526 let fixed = rule.fix(&ctx).unwrap();
528 assert_eq!(fixed, "Line");
529 }
530
531 #[test]
532 fn test_list_item_empty_lines() {
533 let config = MD009Config {
535 list_item_empty_lines: true,
536 ..Default::default()
537 };
538 let rule = MD009TrailingSpaces::from_config_struct(config);
539
540 let content = "- First item\n \n- Second item";
542 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
543 let result = rule.check(&ctx).unwrap();
544 assert!(result.is_empty());
546
547 let content = "1. First item\n \n2. Second item";
549 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
550 let result = rule.check(&ctx).unwrap();
551 assert!(result.is_empty());
552
553 let content = "Normal paragraph\n \nAnother paragraph";
555 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
556 let result = rule.check(&ctx).unwrap();
557 assert_eq!(result.len(), 1);
558 assert_eq!(result[0].line, 2);
559 }
560
561 #[test]
562 fn test_list_item_empty_lines_disabled() {
563 let rule = MD009TrailingSpaces::default();
565
566 let content = "- First item\n \n- Second item";
567 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
568 let result = rule.check(&ctx).unwrap();
569 assert_eq!(result.len(), 1);
571 assert_eq!(result[0].line, 2);
572 }
573
574 #[test]
575 fn test_performance_large_document() {
576 let rule = MD009TrailingSpaces::default();
577 let mut content = String::new();
578 for i in 0..1000 {
579 content.push_str(&format!("Line {i} with spaces \n"));
580 }
581 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard);
582 let result = rule.check(&ctx).unwrap();
583 assert_eq!(result.len(), 0);
585 }
586
587 #[test]
588 fn test_preserve_content_after_fix() {
589 let rule = MD009TrailingSpaces::new(2, false);
590 let content = "**Bold** text \n*Italic* text \n[Link](url) ";
591 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
592 let fixed = rule.fix(&ctx).unwrap();
593 assert_eq!(fixed, "**Bold** text \n*Italic* text \n[Link](url)");
594 }
595
596 #[test]
597 fn test_nested_blockquotes() {
598 let rule = MD009TrailingSpaces::default();
599 let content = "> > Nested \n> > \n> Normal ";
600 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
601 let result = rule.check(&ctx).unwrap();
602 assert_eq!(result.len(), 2);
604 assert_eq!(result[0].line, 2);
605 assert_eq!(result[1].line, 3);
606
607 let fixed = rule.fix(&ctx).unwrap();
608 assert_eq!(fixed, "> > Nested \n> >\n> Normal");
612 }
613
614 #[test]
615 fn test_normalized_line_endings() {
616 let rule = MD009TrailingSpaces::default();
617 let content = "Line with spaces \nAnother line ";
619 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
620 let result = rule.check(&ctx).unwrap();
621 assert_eq!(result.len(), 1);
624 assert_eq!(result[0].line, 2);
625 }
626
627 #[test]
628 fn test_issue_80_no_space_normalization() {
629 let rule = MD009TrailingSpaces::new(2, false); let content = "Line with one space \nNext line";
634 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
635 let result = rule.check(&ctx).unwrap();
636 assert_eq!(result.len(), 1);
637 assert_eq!(result[0].line, 1);
638 assert_eq!(result[0].message, "Trailing space found");
639
640 let fixed = rule.fix(&ctx).unwrap();
641 assert_eq!(fixed, "Line with one space\nNext line");
642
643 let content = "Line with three spaces \nNext line";
645 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
646 let result = rule.check(&ctx).unwrap();
647 assert_eq!(result.len(), 1);
648 assert_eq!(result[0].line, 1);
649 assert_eq!(result[0].message, "3 trailing spaces found");
650
651 let fixed = rule.fix(&ctx).unwrap();
652 assert_eq!(fixed, "Line with three spaces\nNext line");
653
654 let content = "Line with two spaces \nNext line";
656 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
657 let result = rule.check(&ctx).unwrap();
658 assert_eq!(result.len(), 0); let fixed = rule.fix(&ctx).unwrap();
661 assert_eq!(fixed, "Line with two spaces \nNext line");
662 }
663}