Skip to main content

rumdl_lib/rules/
md009_trailing_spaces.rs

1use crate::filtered_lines::FilteredLinesExt;
2use crate::lint_context::LintContext;
3use crate::lint_context::types::HeadingStyle;
4use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
5use crate::utils::range_utils::calculate_trailing_range;
6use crate::utils::regex_cache::{ORDERED_LIST_MARKER_REGEX, UNORDERED_LIST_MARKER_REGEX};
7
8mod md009_config;
9use md009_config::MD009Config;
10
11/// Whether the line at `line_num` (0-indexed) is a setext heading underline (`===` or `---`).
12///
13/// rumdl marks `LineInfo::heading` only on the setext text line, not the underline; the
14/// underline still parses as `is_paragraph_context` per its own flags. Detect it by looking
15/// back to the previous line's heading style.
16fn is_setext_underline(ctx: &LintContext, line_num: usize) -> bool {
17    if line_num == 0 {
18        return false;
19    }
20    ctx.line_info(line_num).is_some_and(|prev| {
21        prev.heading
22            .as_ref()
23            .is_some_and(|h| matches!(h.style, HeadingStyle::Setext1 | HeadingStyle::Setext2))
24    })
25}
26
27/// Whether a `<br>` produced by trailing spaces on the line at `line_num` (0-indexed)
28/// would be meaningful — i.e. the line is paragraph-context AND the next line continues
29/// the same paragraph.
30///
31/// Mirrors markdownlint's MD009 strict logic, which only allows the `br_spaces` exception
32/// on lines covered by `[paragraph.startLine, paragraph.endLine - 1]`. The last line of a
33/// paragraph (single-line paragraph, line before a blank, line before a heading, separated
34/// list items, etc.) gets no useful break and is flagged.
35fn br_produces_useful_break(ctx: &LintContext, line_num: usize) -> bool {
36    let lines = ctx.raw_lines();
37    let Some(current) = ctx.line_info(line_num + 1) else {
38        return false;
39    };
40    if !current.is_paragraph_context() || is_setext_underline(ctx, line_num) {
41        return false;
42    }
43    let next_idx = line_num + 1;
44    if next_idx >= lines.len() {
45        return false;
46    }
47    let Some(next) = ctx.line_info(next_idx + 1) else {
48        return false;
49    };
50    if next.is_blank || !next.is_paragraph_context() || next.list_item.is_some() || is_setext_underline(ctx, next_idx) {
51        return false;
52    }
53    true
54}
55
56#[derive(Debug, Clone, Default)]
57pub struct MD009TrailingSpaces {
58    config: MD009Config,
59}
60
61impl MD009TrailingSpaces {
62    pub fn new(br_spaces: usize, strict: bool) -> Self {
63        Self {
64            config: MD009Config {
65                br_spaces: crate::types::BrSpaces::from_const(br_spaces),
66                strict,
67                list_item_empty_lines: false,
68            },
69        }
70    }
71
72    pub const fn from_config_struct(config: MD009Config) -> Self {
73        Self { config }
74    }
75
76    fn count_trailing_spaces(line: &str) -> usize {
77        line.chars().rev().take_while(|&c| c == ' ').count()
78    }
79
80    fn count_trailing_spaces_ascii(line: &str) -> usize {
81        line.as_bytes().iter().rev().take_while(|&&b| b == b' ').count()
82    }
83
84    /// Count all trailing whitespace characters (ASCII and Unicode).
85    /// This includes U+2000..U+200A (various Unicode spaces), ASCII space, tab, etc.
86    fn count_trailing_whitespace(line: &str) -> usize {
87        line.chars().rev().take_while(|c| c.is_whitespace()).count()
88    }
89
90    fn trimmed_len_ascii_whitespace(line: &str) -> usize {
91        line.as_bytes()
92            .iter()
93            .rposition(|b| !b.is_ascii_whitespace())
94            .map_or(0, |idx| idx + 1)
95    }
96
97    fn calculate_trailing_range_ascii(
98        line: usize,
99        line_len: usize,
100        content_end: usize,
101    ) -> (usize, usize, usize, usize) {
102        // Return 1-indexed columns to match calculate_trailing_range behavior
103        (line, content_end + 1, line, line_len + 1)
104    }
105
106    fn is_empty_list_item_line(line: &str, prev_line: Option<&str>) -> bool {
107        // A line is an empty list item line if:
108        // 1. It's blank or only contains spaces
109        // 2. The previous line is a list item
110        if !line.trim().is_empty() {
111            return false;
112        }
113
114        if let Some(prev) = prev_line {
115            // Check for unordered list markers (*, -, +) with proper formatting
116            UNORDERED_LIST_MARKER_REGEX.is_match(prev) || ORDERED_LIST_MARKER_REGEX.is_match(prev)
117        } else {
118            false
119        }
120    }
121}
122
123impl Rule for MD009TrailingSpaces {
124    fn name(&self) -> &'static str {
125        "MD009"
126    }
127
128    fn description(&self) -> &'static str {
129        "Trailing spaces should be removed"
130    }
131
132    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
133        let content = ctx.content;
134
135        let mut warnings = Vec::new();
136
137        // Use pre-computed lines (needed for looking back at prev_line)
138        let lines = ctx.raw_lines();
139
140        let mut filtered = ctx.filtered_lines().skip_front_matter().skip_pymdown_blocks();
141        if !self.config.strict {
142            filtered = filtered.skip_code_blocks();
143        }
144
145        for filtered_line in filtered {
146            let line_num = filtered_line.line_num - 1;
147            let line = filtered_line.content;
148
149            let line_is_ascii = line.is_ascii();
150            // Count ASCII trailing spaces for br_spaces comparison
151            let trailing_ascii_spaces = if line_is_ascii {
152                Self::count_trailing_spaces_ascii(line)
153            } else {
154                Self::count_trailing_spaces(line)
155            };
156            // For non-ASCII lines, also count all trailing whitespace (including Unicode)
157            // to ensure the fix range covers everything that trim_end() removes
158            let trailing_all_whitespace = if line_is_ascii {
159                trailing_ascii_spaces
160            } else {
161                Self::count_trailing_whitespace(line)
162            };
163
164            // Skip if no trailing whitespace
165            if trailing_all_whitespace == 0 {
166                continue;
167            }
168
169            // Handle empty lines
170            let trimmed_len = if line_is_ascii {
171                Self::trimmed_len_ascii_whitespace(line)
172            } else {
173                line.trim_end().len()
174            };
175            if trimmed_len == 0 {
176                if trailing_all_whitespace > 0 {
177                    // Check if this is an empty list item line and config allows it
178                    let prev_line = if line_num > 0 { Some(lines[line_num - 1]) } else { None };
179                    if self.config.list_item_empty_lines && Self::is_empty_list_item_line(line, prev_line) {
180                        continue;
181                    }
182
183                    // Calculate precise character range for all trailing whitespace on empty line
184                    let (start_line, start_col, end_line, end_col) = if line_is_ascii {
185                        Self::calculate_trailing_range_ascii(line_num + 1, line.len(), 0)
186                    } else {
187                        calculate_trailing_range(line_num + 1, line, 0)
188                    };
189                    let line_start = *ctx.line_offsets.get(line_num).unwrap_or(&0);
190                    let fix_range = if line_is_ascii {
191                        line_start..line_start + line.len()
192                    } else {
193                        ctx.line_column_byte_range_with_length(line_num + 1, 1, line.chars().count())
194                    };
195
196                    warnings.push(LintWarning {
197                        rule_name: Some(self.name().to_string()),
198                        line: start_line,
199                        column: start_col,
200                        end_line,
201                        end_column: end_col,
202                        message: "Empty line has trailing spaces".to_string(),
203                        severity: Severity::Warning,
204                        fix: Some(Fix::new(fix_range, String::new())),
205                    });
206                }
207                continue;
208            }
209
210            // Check if it's a valid line break (only ASCII spaces count for br_spaces).
211            // The br_spaces exception applies when the trailing whitespace produces a `<br>`.
212            // In non-strict mode we accept br_spaces anywhere that isn't the literal final
213            // line of the document. In strict mode (markdownlint parity) we additionally
214            // require the line to be a non-last line of a paragraph: structural lines
215            // (headings, fences, setext underlines, horizontal rules, ...) and last lines
216            // of paragraphs (single-line paragraphs, lines before blanks, list-item ends,
217            // ...) all get flagged because the `<br>` they would emit is unobservable.
218            let is_truly_last_line = line_num == lines.len() - 1 && !content.ends_with('\n');
219            let has_only_ascii_trailing = trailing_ascii_spaces == trailing_all_whitespace;
220            let matches_br_spaces = trailing_ascii_spaces == self.config.br_spaces.get();
221            if !is_truly_last_line && has_only_ascii_trailing && matches_br_spaces {
222                let allow = if self.config.strict {
223                    br_produces_useful_break(ctx, line_num)
224                } else {
225                    true
226                };
227                if allow {
228                    continue;
229                }
230            }
231
232            // Check if this is an empty blockquote line ("> " or ">> " etc)
233            // These are allowed by MD028 to have a single trailing ASCII space
234            let trimmed = if line_is_ascii {
235                &line[..trimmed_len]
236            } else {
237                line.trim_end()
238            };
239            let is_empty_blockquote_with_space = trimmed.chars().all(|c| c == '>' || c == ' ' || c == '\t')
240                && trimmed.contains('>')
241                && has_only_ascii_trailing
242                && trailing_ascii_spaces == 1;
243
244            if is_empty_blockquote_with_space {
245                continue; // Allow single trailing ASCII space for empty blockquote lines
246            }
247            // Calculate precise character range for all trailing whitespace
248            let (start_line, start_col, end_line, end_col) = if line_is_ascii {
249                Self::calculate_trailing_range_ascii(line_num + 1, line.len(), trimmed.len())
250            } else {
251                calculate_trailing_range(line_num + 1, line, trimmed.len())
252            };
253            let line_start = *ctx.line_offsets.get(line_num).unwrap_or(&0);
254            let fix_range = if line_is_ascii {
255                let start = line_start + trimmed.len();
256                let end = start + trailing_all_whitespace;
257                start..end
258            } else {
259                ctx.line_column_byte_range_with_length(
260                    line_num + 1,
261                    trimmed.chars().count() + 1,
262                    trailing_all_whitespace,
263                )
264            };
265
266            warnings.push(LintWarning {
267                rule_name: Some(self.name().to_string()),
268                line: start_line,
269                column: start_col,
270                end_line,
271                end_column: end_col,
272                message: if trailing_all_whitespace == 1 {
273                    "Trailing space found".to_string()
274                } else {
275                    format!("{trailing_all_whitespace} trailing spaces found")
276                },
277                severity: Severity::Warning,
278                fix: Some(Fix::new(fix_range, String::new())),
279            });
280        }
281
282        Ok(warnings)
283    }
284
285    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
286        if self.should_skip(ctx) {
287            return Ok(ctx.content.to_string());
288        }
289        let warnings = self.check(ctx)?;
290        if warnings.is_empty() {
291            return Ok(ctx.content.to_string());
292        }
293        let warnings =
294            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
295        crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings).map_err(LintError::InvalidInput)
296    }
297
298    fn as_any(&self) -> &dyn std::any::Any {
299        self
300    }
301
302    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
303        // Skip if content is empty.
304        // We cannot skip based on ASCII-space-only check because Unicode whitespace
305        // characters (e.g., U+2000 EN QUAD) also count as trailing whitespace.
306        // The per-line is_ascii fast path in check()/fix() handles performance.
307        ctx.content.is_empty()
308    }
309
310    fn category(&self) -> RuleCategory {
311        RuleCategory::Whitespace
312    }
313
314    crate::impl_rule_config_methods!(MD009Config);
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320    use crate::lint_context::LintContext;
321    use crate::rule::Rule;
322
323    #[test]
324    fn test_no_trailing_spaces() {
325        let rule = MD009TrailingSpaces::default();
326        let content = "This is a line\nAnother line\nNo trailing spaces";
327        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
328        let result = rule.check(&ctx).unwrap();
329        assert!(result.is_empty());
330    }
331
332    #[test]
333    fn test_basic_trailing_spaces() {
334        let rule = MD009TrailingSpaces::default();
335        let content = "Line with spaces   \nAnother line  \nClean line";
336        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
337        let result = rule.check(&ctx).unwrap();
338        // Default br_spaces=2, so line with 2 spaces is OK
339        assert_eq!(result.len(), 1);
340        assert_eq!(result[0].line, 1);
341        assert_eq!(result[0].message, "3 trailing spaces found");
342    }
343
344    #[test]
345    fn test_md009_front_matter() {
346        let rule = MD009TrailingSpaces::default();
347        let content = "---\ntitle: Test   \n---\nBody   ";
348        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
349        let result = rule.check(&ctx).unwrap();
350        // Should only flag the one in the body (line 4), not the one in front-matter (line 2)
351        assert_eq!(result.len(), 1);
352        assert_eq!(result[0].line, 4);
353    }
354
355    #[test]
356    fn test_fix_basic_trailing_spaces() {
357        let rule = MD009TrailingSpaces::default();
358        let content = "Line with spaces   \nAnother line  \nClean line";
359        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
360        let fixed = rule.fix(&ctx).unwrap();
361        // Line 1: 3 spaces -> removed (doesn't match br_spaces=2)
362        // Line 2: 2 spaces -> kept (matches br_spaces=2)
363        // Line 3: no spaces -> unchanged
364        assert_eq!(fixed, "Line with spaces\nAnother line  \nClean line");
365    }
366
367    #[test]
368    fn test_strict_mode() {
369        let rule = MD009TrailingSpaces::new(2, true);
370        // Strict mode keeps the br_spaces exception only on non-last paragraph lines:
371        //   - line 1 ("Line with spaces  ") has paragraph continuation on line 2 -> allowed
372        //   - line 2 ("Code block:  ") is followed by a code fence (non-paragraph next)
373        //     so its `<br>` is wasted -> flagged
374        //   - lines 3 and 5 are fence boundaries (non-paragraph) -> flagged
375        //   - line 4 is inside the code block; rumdl's strict mode is intentionally
376        //     more thorough than markdownlint and flags trailing whitespace there too
377        let content = "Line with spaces  \nCode block:  \n```  \nCode with spaces  \n```  ";
378        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
379        let result = rule.check(&ctx).unwrap();
380        let lines_flagged: Vec<usize> = result.iter().map(|w| w.line).collect();
381        assert_eq!(lines_flagged, vec![2, 3, 4, 5], "got: {result:?}");
382
383        let fixed = rule.fix(&ctx).unwrap();
384        assert_eq!(fixed, "Line with spaces  \nCode block:\n```\nCode with spaces\n```");
385    }
386
387    #[test]
388    fn test_strict_mode_allows_br_spaces_on_paragraph_lines() {
389        // markdownlint parity: when `strict = true`, the br_spaces (2-space) line break
390        // is still allowed on paragraph-context lines because the trailing spaces
391        // produce a real <br>. Strict only flags trailing spaces on lines that can't
392        // produce a useful line break (headings, code blocks, last line, etc.).
393        //
394        // Reproduction from issue #593: blockquote prose with a 2-space line break.
395        let rule = MD009TrailingSpaces::new(2, true);
396        let content = "> Note:  \n> This is in a new line due to 2 spaces behind \"Note:\".\n";
397        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
398        let result = rule.check(&ctx).unwrap();
399        assert!(
400            result.is_empty(),
401            "strict mode should allow br_spaces on paragraph-context lines, got: {result:?}"
402        );
403
404        // The fix() must not strip those spaces either, since the rule didn't flag them.
405        let fixed = rule.fix(&ctx).unwrap();
406        assert_eq!(fixed, content);
407    }
408
409    #[test]
410    fn test_strict_mode_flags_br_spaces_on_heading() {
411        // Headings don't produce a <br> from trailing spaces, so strict mode flags them.
412        let rule = MD009TrailingSpaces::new(2, true);
413        let content = "# Heading  \nFollow-up paragraph.\n";
414        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
415        let result = rule.check(&ctx).unwrap();
416        assert_eq!(result.len(), 1, "strict should flag heading br_spaces, got: {result:?}");
417        assert_eq!(result[0].line, 1);
418    }
419
420    #[test]
421    fn test_strict_mode_flags_br_spaces_on_last_paragraph_line() {
422        // markdownlint parity: a `<br>` from trailing spaces on the last line of a
423        // paragraph is unobservable (the paragraph already ends at the next blank
424        // line), so strict mode flags it.
425        let rule = MD009TrailingSpaces::new(2, true);
426        let content = "Paragraph  \n\nNext paragraph.\n";
427        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
428        let result = rule.check(&ctx).unwrap();
429        assert_eq!(
430            result.iter().map(|w| w.line).collect::<Vec<_>>(),
431            vec![1],
432            "strict should flag br_spaces on a single-line paragraph, got: {result:?}"
433        );
434    }
435
436    #[test]
437    fn test_strict_mode_flags_br_spaces_between_list_items() {
438        // markdownlint parity: each top-level list item is its own paragraph block.
439        // Trailing br_spaces on item 1 don't bridge into item 2; strict flags them.
440        let rule = MD009TrailingSpaces::new(2, true);
441        let content = "- item 1  \n- item 2\n";
442        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
443        let result = rule.check(&ctx).unwrap();
444        assert_eq!(
445            result.iter().map(|w| w.line).collect::<Vec<_>>(),
446            vec![1],
447            "strict should flag br_spaces at the end of a list item, got: {result:?}"
448        );
449    }
450
451    #[test]
452    fn test_strict_mode_allows_br_spaces_in_list_item_continuation() {
453        // markdownlint parity: a list item with a continuation line is a single
454        // paragraph; a trailing 2-space line break inside it is meaningful.
455        let rule = MD009TrailingSpaces::new(2, true);
456        let content = "- first line  \n  second line of same item\n";
457        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
458        let result = rule.check(&ctx).unwrap();
459        assert!(
460            result.is_empty(),
461            "strict should allow br_spaces between a list item and its continuation, got: {result:?}"
462        );
463    }
464
465    #[test]
466    fn test_strict_mode_flags_br_spaces_before_heading() {
467        // markdownlint parity: an ATX heading interrupts the paragraph above it,
468        // so the line before it is a paragraph end — strict flags trailing br_spaces.
469        let rule = MD009TrailingSpaces::new(2, true);
470        let content = "Paragraph  \n# Heading\n";
471        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
472        let result = rule.check(&ctx).unwrap();
473        assert_eq!(
474            result.iter().map(|w| w.line).collect::<Vec<_>>(),
475            vec![1],
476            "strict should flag br_spaces on the line before a heading, got: {result:?}"
477        );
478    }
479
480    #[test]
481    fn test_strict_mode_flags_br_spaces_on_setext_heading_text() {
482        // Setext heading text line is a heading, not a paragraph, so trailing spaces
483        // on it can't produce a <br>. Strict mode flags it.
484        let rule = MD009TrailingSpaces::new(2, true);
485        let content = "Setext heading  \n===\n\nFollow-up paragraph.\n";
486        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
487        let result = rule.check(&ctx).unwrap();
488        assert_eq!(
489            result.iter().map(|w| w.line).collect::<Vec<_>>(),
490            vec![1],
491            "strict should flag setext heading text trailing spaces, got: {result:?}"
492        );
493    }
494
495    #[test]
496    fn test_strict_mode_flags_br_spaces_on_setext_underline() {
497        // The setext underline is part of the heading block, not a paragraph.
498        // rumdl marks `heading` only on the text line; the underline is detected by
499        // looking back. Strict mode flags trailing spaces on it.
500        let rule = MD009TrailingSpaces::new(2, true);
501        let content = "Setext heading\n===  \n\nFollow-up paragraph.\n";
502        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
503        let result = rule.check(&ctx).unwrap();
504        assert_eq!(
505            result.iter().map(|w| w.line).collect::<Vec<_>>(),
506            vec![2],
507            "strict should flag setext underline trailing spaces, got: {result:?}"
508        );
509    }
510
511    #[test]
512    fn test_strict_mode_flags_br_spaces_in_indented_code_block() {
513        // Indented code blocks aren't paragraphs. rumdl's strict mode is intentionally
514        // stricter than markdownlint here (markdownlint excludes code blocks entirely
515        // unless `code_blocks: true`); we surface the trailing whitespace as a warning.
516        let rule = MD009TrailingSpaces::new(2, true);
517        let content = "Paragraph above.\n\n    code line  \n    another code  \n\nParagraph below.\n";
518        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
519        let result = rule.check(&ctx).unwrap();
520        assert_eq!(
521            result.iter().map(|w| w.line).collect::<Vec<_>>(),
522            vec![3, 4],
523            "strict should flag indented code block trailing spaces, got: {result:?}"
524        );
525    }
526
527    #[test]
528    fn test_strict_mode_allows_br_spaces_in_table_row() {
529        // GFM table rows close with `|`; the trailing whitespace before that pipe is
530        // inside the cell, not at the line end. This test guards against future
531        // refactors mistakenly treating table rows as a special non-paragraph context
532        // when there is in fact no end-of-line whitespace on the row.
533        let rule = MD009TrailingSpaces::new(2, true);
534        let content = "| col |\n| --- |\n| cell  |\n\nParagraph.\n";
535        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
536        let result = rule.check(&ctx).unwrap();
537        assert!(
538            result.is_empty(),
539            "rows that don't actually have trailing whitespace shouldn't trigger MD009, got: {result:?}"
540        );
541    }
542
543    #[test]
544    fn test_non_strict_mode_with_code_blocks() {
545        let rule = MD009TrailingSpaces::new(2, false);
546        let content = "Line with spaces  \n```\nCode with spaces  \n```\nOutside code  ";
547        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
548        let result = rule.check(&ctx).unwrap();
549        // In non-strict mode, code blocks are not checked
550        // Line 1 has 2 spaces (= br_spaces), so it's OK
551        // Line 5 is last line without newline, so trailing spaces are flagged
552        assert_eq!(result.len(), 1);
553        assert_eq!(result[0].line, 5);
554    }
555
556    #[test]
557    fn test_br_spaces_preservation() {
558        let rule = MD009TrailingSpaces::new(2, false);
559        let content = "Line with two spaces  \nLine with three spaces   \nLine with one space ";
560        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
561        let result = rule.check(&ctx).unwrap();
562        // br_spaces=2, so lines with exactly 2 spaces are OK
563        // Line 2 has 3 spaces (should be removed, not normalized)
564        // Line 3 has 1 space and is last line without newline (will be removed)
565        assert_eq!(result.len(), 2);
566        assert_eq!(result[0].line, 2);
567        assert_eq!(result[1].line, 3);
568
569        let fixed = rule.fix(&ctx).unwrap();
570        // Line 1: keeps 2 spaces (exact match with br_spaces)
571        // Line 2: removes all 3 spaces (doesn't match br_spaces)
572        // Line 3: last line without newline, spaces removed
573        assert_eq!(
574            fixed,
575            "Line with two spaces  \nLine with three spaces\nLine with one space"
576        );
577    }
578
579    #[test]
580    fn test_empty_lines_with_spaces() {
581        let rule = MD009TrailingSpaces::default();
582        let content = "Normal line\n   \n  \nAnother line";
583        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
584        let result = rule.check(&ctx).unwrap();
585        assert_eq!(result.len(), 2);
586        assert_eq!(result[0].message, "Empty line has trailing spaces");
587        assert_eq!(result[1].message, "Empty line has trailing spaces");
588
589        let fixed = rule.fix(&ctx).unwrap();
590        assert_eq!(fixed, "Normal line\n\n\nAnother line");
591    }
592
593    #[test]
594    fn test_empty_blockquote_lines() {
595        let rule = MD009TrailingSpaces::default();
596        let content = "> Quote\n>   \n> More quote";
597        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
598        let result = rule.check(&ctx).unwrap();
599        assert_eq!(result.len(), 1);
600        assert_eq!(result[0].line, 2);
601        assert_eq!(result[0].message, "3 trailing spaces found");
602
603        let fixed = rule.fix(&ctx).unwrap();
604        assert_eq!(fixed, "> Quote\n>\n> More quote"); // All trailing spaces removed
605    }
606
607    #[test]
608    fn test_last_line_handling() {
609        let rule = MD009TrailingSpaces::new(2, false);
610
611        // Content without final newline
612        let content = "First line  \nLast line  ";
613        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
614        let result = rule.check(&ctx).unwrap();
615        // Last line without newline should have trailing spaces removed
616        assert_eq!(result.len(), 1);
617        assert_eq!(result[0].line, 2);
618
619        let fixed = rule.fix(&ctx).unwrap();
620        assert_eq!(fixed, "First line  \nLast line");
621
622        // Content with final newline
623        let content_with_newline = "First line  \nLast line  \n";
624        let ctx = LintContext::new(content_with_newline, crate::config::MarkdownFlavor::Standard, None);
625        let result = rule.check(&ctx).unwrap();
626        // Both lines should preserve br_spaces
627        assert!(result.is_empty());
628    }
629
630    #[test]
631    fn test_single_trailing_space() {
632        let rule = MD009TrailingSpaces::new(2, false);
633        let content = "Line with one space ";
634        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
635        let result = rule.check(&ctx).unwrap();
636        assert_eq!(result.len(), 1);
637        assert_eq!(result[0].message, "Trailing space found");
638    }
639
640    #[test]
641    fn test_tabs_not_spaces() {
642        let rule = MD009TrailingSpaces::default();
643        let content = "Line with tab\t\nLine with spaces  ";
644        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
645        let result = rule.check(&ctx).unwrap();
646        // Only spaces are checked, not tabs
647        assert_eq!(result.len(), 1);
648        assert_eq!(result[0].line, 2);
649    }
650
651    #[test]
652    fn test_mixed_content() {
653        let rule = MD009TrailingSpaces::new(2, false);
654        // Construct content with actual trailing spaces using string concatenation
655        let mut content = String::new();
656        content.push_str("# Heading");
657        content.push_str("   "); // Add 3 trailing spaces (more than br_spaces=2)
658        content.push('\n');
659        content.push_str("Normal paragraph\n> Blockquote\n>\n```\nCode block\n```\n- List item\n");
660
661        let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
662        let result = rule.check(&ctx).unwrap();
663        // Should flag the line with trailing spaces
664        assert_eq!(result.len(), 1);
665        assert_eq!(result[0].line, 1);
666        assert!(result[0].message.contains("trailing spaces"));
667    }
668
669    #[test]
670    fn test_column_positions() {
671        let rule = MD009TrailingSpaces::default();
672        let content = "Text   ";
673        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
674        let result = rule.check(&ctx).unwrap();
675        assert_eq!(result.len(), 1);
676        assert_eq!(result[0].column, 5); // After "Text"
677        assert_eq!(result[0].end_column, 8); // After all spaces
678    }
679
680    #[test]
681    fn test_default_config() {
682        let rule = MD009TrailingSpaces::default();
683        let config = rule.default_config_section();
684        assert!(config.is_some());
685        let (name, _value) = config.unwrap();
686        assert_eq!(name, "MD009");
687    }
688
689    #[test]
690    fn test_from_config() {
691        let mut config = crate::config::Config::default();
692        let mut rule_config = crate::config::RuleConfig::default();
693        rule_config
694            .values
695            .insert("br_spaces".to_string(), toml::Value::Integer(3));
696        rule_config
697            .values
698            .insert("strict".to_string(), toml::Value::Boolean(true));
699        config.rules.insert("MD009".to_string(), rule_config);
700
701        let rule = MD009TrailingSpaces::from_config(&config);
702        let content = "Line   ";
703        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
704        let result = rule.check(&ctx).unwrap();
705        assert_eq!(result.len(), 1);
706
707        // In strict mode, should remove all spaces
708        let fixed = rule.fix(&ctx).unwrap();
709        assert_eq!(fixed, "Line");
710    }
711
712    #[test]
713    fn test_list_item_empty_lines() {
714        // Create rule with list_item_empty_lines enabled
715        let config = MD009Config {
716            list_item_empty_lines: true,
717            ..Default::default()
718        };
719        let rule = MD009TrailingSpaces::from_config_struct(config);
720
721        // Test unordered list with empty line
722        let content = "- First item\n  \n- Second item";
723        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
724        let result = rule.check(&ctx).unwrap();
725        // Should not flag the empty line with spaces after list item
726        assert!(result.is_empty());
727
728        // Test ordered list with empty line
729        let content = "1. First item\n  \n2. Second item";
730        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
731        let result = rule.check(&ctx).unwrap();
732        assert!(result.is_empty());
733
734        // Test that non-list empty lines are still flagged
735        let content = "Normal paragraph\n  \nAnother paragraph";
736        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
737        let result = rule.check(&ctx).unwrap();
738        assert_eq!(result.len(), 1);
739        assert_eq!(result[0].line, 2);
740    }
741
742    #[test]
743    fn test_list_item_empty_lines_disabled() {
744        // Default config has list_item_empty_lines disabled
745        let rule = MD009TrailingSpaces::default();
746
747        let content = "- First item\n  \n- Second item";
748        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
749        let result = rule.check(&ctx).unwrap();
750        // Should flag the empty line with spaces
751        assert_eq!(result.len(), 1);
752        assert_eq!(result[0].line, 2);
753    }
754
755    #[test]
756    fn test_performance_large_document() {
757        let rule = MD009TrailingSpaces::default();
758        let mut content = String::new();
759        for i in 0..1000 {
760            content.push_str(&format!("Line {i} with spaces  \n"));
761        }
762        let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
763        let result = rule.check(&ctx).unwrap();
764        // Default br_spaces=2, so all lines with 2 spaces are OK
765        assert_eq!(result.len(), 0);
766    }
767
768    #[test]
769    fn test_preserve_content_after_fix() {
770        let rule = MD009TrailingSpaces::new(2, false);
771        let content = "**Bold** text  \n*Italic* text  \n[Link](url)  ";
772        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
773        let fixed = rule.fix(&ctx).unwrap();
774        assert_eq!(fixed, "**Bold** text  \n*Italic* text  \n[Link](url)");
775    }
776
777    #[test]
778    fn test_nested_blockquotes() {
779        let rule = MD009TrailingSpaces::default();
780        let content = "> > Nested  \n> >   \n> Normal  ";
781        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
782        let result = rule.check(&ctx).unwrap();
783        // Line 2 has empty blockquote with 3 spaces, line 3 is last line without newline
784        assert_eq!(result.len(), 2);
785        assert_eq!(result[0].line, 2);
786        assert_eq!(result[1].line, 3);
787
788        let fixed = rule.fix(&ctx).unwrap();
789        // Line 1: Keeps 2 spaces (exact match with br_spaces)
790        // Line 2: Empty blockquote with 3 spaces -> removes all (doesn't match br_spaces)
791        // Line 3: Last line without newline -> removes all spaces
792        assert_eq!(fixed, "> > Nested  \n> >\n> Normal");
793    }
794
795    #[test]
796    fn test_normalized_line_endings() {
797        let rule = MD009TrailingSpaces::default();
798        // In production, content is normalized to LF at I/O boundary
799        let content = "Line with spaces  \nAnother line  ";
800        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
801        let result = rule.check(&ctx).unwrap();
802        // Line 1 has 2 spaces (= br_spaces) so it's OK
803        // Line 2 is last line without newline, so it's flagged
804        assert_eq!(result.len(), 1);
805        assert_eq!(result[0].line, 2);
806    }
807
808    #[test]
809    fn test_issue_80_no_space_normalization() {
810        // Test for GitHub issue #80 - MD009 should not add spaces when removing trailing spaces
811        let rule = MD009TrailingSpaces::new(2, false); // br_spaces=2
812
813        // Test that 1 trailing space is removed, not normalized to 2
814        let content = "Line with one space \nNext line";
815        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
816        let result = rule.check(&ctx).unwrap();
817        assert_eq!(result.len(), 1);
818        assert_eq!(result[0].line, 1);
819        assert_eq!(result[0].message, "Trailing space found");
820
821        let fixed = rule.fix(&ctx).unwrap();
822        assert_eq!(fixed, "Line with one space\nNext line");
823
824        // Test that 3 trailing spaces are removed, not normalized to 2
825        let content = "Line with three spaces   \nNext line";
826        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
827        let result = rule.check(&ctx).unwrap();
828        assert_eq!(result.len(), 1);
829        assert_eq!(result[0].line, 1);
830        assert_eq!(result[0].message, "3 trailing spaces found");
831
832        let fixed = rule.fix(&ctx).unwrap();
833        assert_eq!(fixed, "Line with three spaces\nNext line");
834
835        // Test that exactly 2 trailing spaces are preserved
836        let content = "Line with two spaces  \nNext line";
837        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
838        let result = rule.check(&ctx).unwrap();
839        assert_eq!(result.len(), 0); // Should not flag lines with exact br_spaces
840
841        let fixed = rule.fix(&ctx).unwrap();
842        assert_eq!(fixed, "Line with two spaces  \nNext line");
843    }
844
845    #[test]
846    fn test_unicode_whitespace_idempotent_fix() {
847        // Verify that mixed Unicode (U+2000 EN QUAD) and ASCII trailing whitespace
848        // is stripped in a single idempotent pass.
849        let rule = MD009TrailingSpaces::default(); // br_spaces=2
850
851        // Case from proptest: blockquote with U+2000 and ASCII space
852        let content = "> 0\u{2000} ";
853        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
854        let result = rule.check(&ctx).unwrap();
855        assert_eq!(result.len(), 1, "Should detect trailing Unicode+ASCII whitespace");
856
857        let fixed = rule.fix(&ctx).unwrap();
858        assert_eq!(fixed, "> 0", "Should strip all trailing whitespace in one pass");
859
860        // Verify idempotency: fixing again should produce same result
861        let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
862        let fixed2 = rule.fix(&ctx2).unwrap();
863        assert_eq!(fixed, fixed2, "Fix must be idempotent");
864    }
865
866    #[test]
867    fn test_unicode_whitespace_variants() {
868        let rule = MD009TrailingSpaces::default();
869
870        // U+2000 EN QUAD
871        let content = "text\u{2000}\n";
872        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
873        let result = rule.check(&ctx).unwrap();
874        assert_eq!(result.len(), 1);
875        let fixed = rule.fix(&ctx).unwrap();
876        assert_eq!(fixed, "text\n");
877
878        // U+2001 EM QUAD
879        let content = "text\u{2001}\n";
880        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
881        let result = rule.check(&ctx).unwrap();
882        assert_eq!(result.len(), 1);
883        let fixed = rule.fix(&ctx).unwrap();
884        assert_eq!(fixed, "text\n");
885
886        // U+3000 IDEOGRAPHIC SPACE
887        let content = "text\u{3000}\n";
888        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
889        let result = rule.check(&ctx).unwrap();
890        assert_eq!(result.len(), 1);
891        let fixed = rule.fix(&ctx).unwrap();
892        assert_eq!(fixed, "text\n");
893
894        // Mixed: Unicode space + ASCII spaces
895        // The trailing 2 ASCII spaces match br_spaces, so they are preserved.
896        // The U+2000 between content and the spaces is removed.
897        let content = "text\u{2000}  \n";
898        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
899        let result = rule.check(&ctx).unwrap();
900        assert_eq!(result.len(), 1, "Unicode+ASCII mix should be flagged");
901        let fixed = rule.fix(&ctx).unwrap();
902        assert_eq!(
903            fixed, "text\n",
904            "All trailing whitespace should be stripped when mix includes Unicode"
905        );
906        // Verify idempotency
907        let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
908        let fixed2 = rule.fix(&ctx2).unwrap();
909        assert_eq!(fixed, fixed2, "Fix must be idempotent");
910
911        // Pure ASCII 2 spaces should still be preserved as br_spaces
912        let content = "text  \nnext\n";
913        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
914        let result = rule.check(&ctx).unwrap();
915        assert_eq!(result.len(), 0, "Pure ASCII br_spaces should still be preserved");
916    }
917
918    #[test]
919    fn test_unicode_whitespace_strict_mode() {
920        let rule = MD009TrailingSpaces::new(2, true);
921
922        // Strict mode should remove all Unicode whitespace too
923        let content = "text\u{2000}\nmore\u{3000}\n";
924        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
925        let fixed = rule.fix(&ctx).unwrap();
926        assert_eq!(fixed, "text\nmore\n");
927    }
928
929    /// Helper: after fix(), run check() on the result and assert zero violations remain.
930    fn assert_fix_roundtrip(rule: &MD009TrailingSpaces, content: &str) {
931        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
932        let fixed = rule.fix(&ctx).unwrap();
933        let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
934        let remaining = rule.check(&ctx2).unwrap();
935        assert!(
936            remaining.is_empty(),
937            "After fix(), check() should find 0 violations.\nOriginal: {content:?}\nFixed: {fixed:?}\nRemaining: {remaining:?}"
938        );
939    }
940
941    #[test]
942    fn test_roundtrip_basic_trailing_spaces() {
943        let rule = MD009TrailingSpaces::default();
944        assert_fix_roundtrip(&rule, "Line with spaces   \nAnother line  \nClean line");
945    }
946
947    #[test]
948    fn test_roundtrip_strict_mode() {
949        let rule = MD009TrailingSpaces::new(2, true);
950        assert_fix_roundtrip(
951            &rule,
952            "Line with spaces  \nCode block:  \n```  \nCode with spaces  \n```  ",
953        );
954    }
955
956    #[test]
957    fn test_roundtrip_empty_lines() {
958        let rule = MD009TrailingSpaces::default();
959        assert_fix_roundtrip(&rule, "Normal line\n   \n  \nAnother line");
960    }
961
962    #[test]
963    fn test_roundtrip_br_spaces_preservation() {
964        let rule = MD009TrailingSpaces::new(2, false);
965        assert_fix_roundtrip(
966            &rule,
967            "Line with two spaces  \nLine with three spaces   \nLine with one space ",
968        );
969    }
970
971    #[test]
972    fn test_roundtrip_last_line_no_newline() {
973        let rule = MD009TrailingSpaces::new(2, false);
974        assert_fix_roundtrip(&rule, "First line  \nLast line  ");
975    }
976
977    #[test]
978    fn test_roundtrip_last_line_with_newline() {
979        let rule = MD009TrailingSpaces::new(2, false);
980        assert_fix_roundtrip(&rule, "First line  \nLast line  \n");
981    }
982
983    #[test]
984    fn test_roundtrip_unicode_whitespace() {
985        let rule = MD009TrailingSpaces::default();
986        assert_fix_roundtrip(&rule, "> 0\u{2000} ");
987        assert_fix_roundtrip(&rule, "text\u{2000}\n");
988        assert_fix_roundtrip(&rule, "text\u{3000}\n");
989        assert_fix_roundtrip(&rule, "text\u{2000}  \n");
990    }
991
992    #[test]
993    fn test_roundtrip_code_blocks_non_strict() {
994        let rule = MD009TrailingSpaces::new(2, false);
995        assert_fix_roundtrip(
996            &rule,
997            "Line with spaces  \n```\nCode with spaces  \n```\nOutside code  ",
998        );
999    }
1000
1001    #[test]
1002    fn test_roundtrip_blockquotes() {
1003        let rule = MD009TrailingSpaces::default();
1004        assert_fix_roundtrip(&rule, "> Quote\n>   \n> More quote");
1005        assert_fix_roundtrip(&rule, "> > Nested  \n> >   \n> Normal  ");
1006    }
1007
1008    #[test]
1009    fn test_roundtrip_list_item_empty_lines() {
1010        let config = MD009Config {
1011            list_item_empty_lines: true,
1012            ..Default::default()
1013        };
1014        let rule = MD009TrailingSpaces::from_config_struct(config);
1015        assert_fix_roundtrip(&rule, "- First item\n  \n- Second item");
1016        assert_fix_roundtrip(&rule, "Normal paragraph\n  \nAnother paragraph");
1017    }
1018
1019    #[test]
1020    fn test_roundtrip_complex_document() {
1021        let rule = MD009TrailingSpaces::default();
1022        assert_fix_roundtrip(
1023            &rule,
1024            "# Title   \n\nParagraph  \n\n- List   \n  - Nested  \n\n```\ncode   \n```\n\n> Quote   \n>    \n\nEnd  ",
1025        );
1026    }
1027
1028    #[test]
1029    fn test_roundtrip_multibyte() {
1030        let rule = MD009TrailingSpaces::new(2, true);
1031        assert_fix_roundtrip(&rule, "- 1€ expenses \n");
1032        assert_fix_roundtrip(&rule, "€100 + €50 = €150   \n");
1033        assert_fix_roundtrip(&rule, "Hello 你好世界   \n");
1034        assert_fix_roundtrip(&rule, "Party 🎉🎉🎉   \n");
1035        assert_fix_roundtrip(&rule, "안녕하세요   \n");
1036    }
1037
1038    #[test]
1039    fn test_roundtrip_mixed_tabs_and_spaces() {
1040        let rule = MD009TrailingSpaces::default();
1041        assert_fix_roundtrip(&rule, "Line with tab\t\nLine with spaces  ");
1042        assert_fix_roundtrip(&rule, "Line\t  \nAnother\n");
1043    }
1044
1045    #[test]
1046    fn test_roundtrip_heading_with_br_spaces() {
1047        // Headings with exactly br_spaces trailing spaces: check() does not flag them,
1048        // so fix() should not remove them. This tests consistency.
1049        let rule = MD009TrailingSpaces::new(2, false);
1050        let content = "# Heading  \nParagraph\n";
1051        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1052        let warnings = rule.check(&ctx).unwrap();
1053        // check() allows br_spaces on headings (does not flag)
1054        assert!(
1055            warnings.is_empty(),
1056            "check() should not flag heading with exactly br_spaces trailing spaces"
1057        );
1058        assert_fix_roundtrip(&rule, content);
1059    }
1060
1061    #[test]
1062    fn test_fix_replacement_always_removes_trailing_spaces() {
1063        // The fix replacement must always be an empty string, fully removing
1064        // trailing spaces that do not match the br_spaces allowance.
1065        let rule = MD009TrailingSpaces::new(2, false);
1066
1067        // 3 trailing spaces (not matching br_spaces=2) should produce a warning
1068        // with an empty replacement that removes them entirely
1069        let content = "Hello   \nWorld\n";
1070        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1071        let result = rule.check(&ctx).unwrap();
1072        assert_eq!(result.len(), 1);
1073
1074        let fix = result[0].fix.as_ref().expect("Should have a fix");
1075        assert_eq!(
1076            fix.replacement, "",
1077            "Fix replacement should always be empty string (remove trailing spaces)"
1078        );
1079
1080        // Also verify via fix() method
1081        let fixed = rule.fix(&ctx).unwrap();
1082        assert_eq!(fixed, "Hello\nWorld\n");
1083    }
1084}