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