Skip to main content

rumdl_lib/rules/
md009_trailing_spaces.rs

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