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