Skip to main content

rumdl_lib/rules/
md009_trailing_spaces.rs

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