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