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 the trailing spaces and tabs.
39    ///
40    /// CommonMark strips only spaces and tabs at the end of a line, so any other
41    /// Unicode space (U+00A0, U+2000..U+200A, U+3000, ...) is text. Counting it would
42    /// make the fix rewrite the document's content, and would turn a line holding only
43    /// such a character into a "blank" line that splits its paragraph.
44    fn count_trailing_whitespace(line: &str) -> usize {
45        line.chars().rev().take_while(|&c| Self::is_trailing_char(c)).count()
46    }
47
48    fn is_trailing_char(c: char) -> bool {
49        c == ' ' || c == '\t'
50    }
51
52    fn trimmed_len_ascii_whitespace(line: &str) -> usize {
53        line.as_bytes()
54            .iter()
55            .rposition(|&b| b != b' ' && b != b'\t')
56            .map_or(0, |idx| idx + 1)
57    }
58
59    fn calculate_trailing_range_ascii(
60        line: usize,
61        line_len: usize,
62        content_end: usize,
63    ) -> (usize, usize, usize, usize) {
64        // Return 1-indexed columns to match calculate_trailing_range behavior
65        (line, content_end + 1, line, line_len + 1)
66    }
67
68    fn is_empty_list_item_line(line: &str, prev_line: Option<&str>) -> bool {
69        // A line is an empty list item line if:
70        // 1. It's blank or only contains spaces
71        // 2. The previous line is a list item
72        if !line.trim_matches(Self::is_trailing_char).is_empty() {
73            return false;
74        }
75
76        if let Some(prev) = prev_line {
77            // Check for unordered list markers (*, -, +) with proper formatting
78            UNORDERED_LIST_MARKER_REGEX.is_match(prev) || ORDERED_LIST_MARKER_REGEX.is_match(prev)
79        } else {
80            false
81        }
82    }
83}
84
85impl Rule for MD009TrailingSpaces {
86    fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
87        (self.config == Default::default()).then_some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(9))
88    }
89
90    fn name(&self) -> &'static str {
91        "MD009"
92    }
93
94    fn description(&self) -> &'static str {
95        "Trailing spaces should be removed"
96    }
97
98    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
99        let content = ctx.content;
100
101        let mut warnings = Vec::new();
102
103        // Use pre-computed lines (needed for looking back at prev_line)
104        let lines = ctx.raw_lines();
105
106        let mut filtered = ctx
107            .filtered_lines()
108            .skip_front_matter()
109            .skip_pymdown_blocks()
110            .skip_esm_blocks();
111        if !self.config.strict {
112            filtered = filtered.skip_code_blocks();
113        }
114
115        for filtered_line in filtered {
116            let line_num = filtered_line.line_num - 1;
117            let line = filtered_line.content;
118
119            let line_is_ascii = line.is_ascii();
120            // Count ASCII trailing spaces for br_spaces comparison
121            let trailing_ascii_spaces = if line_is_ascii {
122                Self::count_trailing_spaces_ascii(line)
123            } else {
124                Self::count_trailing_spaces(line)
125            };
126            // The run the fix removes. On a non-ASCII line that is every trailing space
127            // and tab; an ASCII line keeps the spaces-only count its hard-break
128            // comparison below is written against.
129            let trailing_all_whitespace = if line_is_ascii {
130                trailing_ascii_spaces
131            } else {
132                Self::count_trailing_whitespace(line)
133            };
134
135            // A line is reported only when it ends in a space: a bare trailing tab is
136            // MD010's. The run the fix removes is every space and tab at the end.
137            if trailing_ascii_spaces == 0 {
138                continue;
139            }
140
141            // Handle empty lines
142            let trimmed_len = if line_is_ascii {
143                Self::trimmed_len_ascii_whitespace(line)
144            } else {
145                line.trim_end_matches(Self::is_trailing_char).len()
146            };
147            // In MDX and templates, trailing whitespace may be string data.
148            // Protect the actual run so whitespace after a closing expression
149            // remains lintable, including in strict mode and on blank lines.
150            let whitespace_start = ctx.line_offsets[line_num] + trimmed_len;
151            let whitespace_end = ctx.line_offsets[line_num] + line.len();
152            // A run that ends a line inside a multi-line code span is part of the
153            // span's text, which a renderer keeps, so it is neither trailing space
154            // nor removable. The run is judged by its first byte: a span ends with
155            // a backtick, so a run cannot begin before the span and end inside it,
156            // and a run after the closing fence starts outside the span. The
157            // parser's own spans are not enough: MkDocs container bodies are
158            // supplemented in `code_spans()`, and a reader without math finds
159            // spans that rumdl's math reading hides.
160            if ctx.is_code_span_text_byte(whitespace_start) {
161                continue;
162            }
163            if ctx.overlaps_mdx_inline_code(whitespace_start, whitespace_end)
164                || (ctx.flavor.supports_jsx()
165                    && (ctx.is_in_html_tag(whitespace_start) || ctx.is_in_jsx_component_tag(whitespace_start)))
166                || ctx.is_in_jinja_string(whitespace_start)
167                || ctx.is_in_shortcode(whitespace_start)
168            {
169                continue;
170            }
171
172            if trimmed_len == 0 {
173                if trailing_all_whitespace > 0 {
174                    // Check if this is an empty list item line and config allows it
175                    let prev_line = if line_num > 0 { Some(lines[line_num - 1]) } else { None };
176                    if self.config.list_item_empty_lines && Self::is_empty_list_item_line(line, prev_line) {
177                        continue;
178                    }
179
180                    // Calculate precise character range for all trailing whitespace on empty line
181                    let (start_line, start_col, end_line, end_col) = if line_is_ascii {
182                        Self::calculate_trailing_range_ascii(line_num + 1, line.len(), 0)
183                    } else {
184                        calculate_trailing_range(line_num + 1, line, 0)
185                    };
186                    let line_start = *ctx.line_offsets.get(line_num).unwrap_or(&0);
187                    let fix_range = if line_is_ascii {
188                        line_start..line_start + line.len()
189                    } else {
190                        ctx.line_column_byte_range_with_length(line_num + 1, 1, line.chars().count())
191                    };
192
193                    warnings.push(LintWarning {
194                        rule_name: Some(self.name().to_string()),
195                        line: start_line,
196                        column: start_col,
197                        end_line,
198                        end_column: end_col,
199                        message: "Empty line has trailing spaces".to_string(),
200                        severity: Severity::Warning,
201                        fix: Some(Fix::new(fix_range, String::new())),
202                    });
203                }
204                continue;
205            }
206
207            // Check if it's a valid line break (only ASCII spaces count for br_spaces).
208            // The br_spaces exception applies when the trailing whitespace produces a `<br>`;
209            // a br_spaces below 2 turns the exception off, so nothing matches it.
210            // In non-strict mode we accept br_spaces anywhere that isn't the literal final
211            // line of the document. In strict mode (markdownlint parity) we additionally
212            // require the line to be a non-last line of a paragraph: structural lines
213            // (headings, fences, setext underlines, horizontal rules, ...) and last lines
214            // of paragraphs (single-line paragraphs, lines before blanks, list-item ends,
215            // ...) all get flagged because the `<br>` they would emit is unobservable.
216            let is_truly_last_line = line_num == lines.len() - 1 && !content.ends_with('\n');
217            let has_only_ascii_trailing = trailing_ascii_spaces == trailing_all_whitespace;
218            let matches_br_spaces = self.config.br_spaces.line_break() == Some(trailing_ascii_spaces);
219            if !is_truly_last_line && has_only_ascii_trailing && matches_br_spaces {
220                let allow = if self.config.strict {
221                    br_produces_useful_break(ctx, line_num)
222                } else {
223                    true
224                };
225                if allow {
226                    continue;
227                }
228            }
229
230            // Check if this is an empty blockquote line ("> " or ">> " etc)
231            // These are allowed by MD028 to have a single trailing ASCII space
232            let trimmed = if line_is_ascii {
233                &line[..trimmed_len]
234            } else {
235                line.trim_end_matches(Self::is_trailing_char)
236            };
237            let is_empty_blockquote_with_space = trimmed.chars().all(|c| c == '>' || c == ' ' || c == '\t')
238                && trimmed.contains('>')
239                && has_only_ascii_trailing
240                && trailing_ascii_spaces == 1;
241
242            if is_empty_blockquote_with_space {
243                continue; // Allow single trailing ASCII space for empty blockquote lines
244            }
245            // Calculate precise character range for all trailing whitespace
246            let (start_line, start_col, end_line, end_col) = if line_is_ascii {
247                Self::calculate_trailing_range_ascii(line_num + 1, line.len(), trimmed.len())
248            } else {
249                calculate_trailing_range(line_num + 1, line, trimmed.len())
250            };
251            let line_start = *ctx.line_offsets.get(line_num).unwrap_or(&0);
252            let fix_range = if line_is_ascii {
253                let start = line_start + trimmed.len();
254                let end = start + trailing_all_whitespace;
255                start..end
256            } else {
257                ctx.line_column_byte_range_with_length(
258                    line_num + 1,
259                    trimmed.chars().count() + 1,
260                    trailing_all_whitespace,
261                )
262            };
263
264            warnings.push(LintWarning {
265                rule_name: Some(self.name().to_string()),
266                line: start_line,
267                column: start_col,
268                end_line,
269                end_column: end_col,
270                message: if trailing_all_whitespace == 1 {
271                    "Trailing space found".to_string()
272                } else {
273                    format!("{trailing_all_whitespace} trailing spaces found")
274                },
275                severity: Severity::Warning,
276                fix: Some(Fix::new(fix_range, String::new())),
277            });
278        }
279
280        Ok(warnings)
281    }
282
283    fn fixes_from_diagnostics(&self) -> bool {
284        true
285    }
286
287    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
288        if self.should_skip(ctx) {
289            return Ok(ctx.content.to_string());
290        }
291        let warnings = self.check(ctx)?;
292        if warnings.is_empty() {
293            return Ok(ctx.content.to_string());
294        }
295        let warnings =
296            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
297        crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings).map_err(LintError::InvalidInput)
298    }
299
300    fn as_any(&self) -> &dyn std::any::Any {
301        self
302    }
303
304    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
305        // Skip if content is empty.
306        // The per-line is_ascii fast path in check()/fix() handles performance.
307        ctx.content.is_empty()
308    }
309
310    fn category(&self) -> RuleCategory {
311        RuleCategory::Whitespace
312    }
313
314    crate::impl_rule_config_methods!(MD009Config);
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320    use crate::lint_context::LintContext;
321    use crate::rule::Rule;
322
323    #[test]
324    fn test_mdx_module_trailing_spaces_preserve_template_string_values() {
325        for strict in [false, true] {
326            let rule = MD009TrailingSpaces::new(0, strict);
327            let source = "export const value = `left   \n   \nright`\n\nVisible prose   \n";
328            let expected = "export const value = `left   \n   \nright`\n\nVisible prose\n";
329            let ctx = LintContext::new(source, crate::config::MarkdownFlavor::MDX, None);
330            assert_eq!(rule.check(&ctx).unwrap().len(), 1);
331            assert_eq!(rule.fix(&ctx).unwrap(), expected);
332        }
333    }
334
335    #[test]
336    fn test_mdx_expression_trailing_spaces_preserve_values() {
337        for strict in [false, true] {
338            let rule = MD009TrailingSpaces::new(0, strict);
339            for expression in ["{`left   \n   \nright`}", "{/* c */ `内容\u{2000}\nvalue`}"] {
340                let source = format!("export const unused = 1\n\n{expression}   \n");
341                let expected = format!("export const unused = 1\n\n{expression}\n");
342                let ctx = LintContext::new(&source, crate::config::MarkdownFlavor::MDX, None);
343                assert_eq!(
344                    rule.check(&ctx).unwrap().len(),
345                    1,
346                    "only prose whitespace is lintable: {source}"
347                );
348                assert_eq!(rule.fix(&ctx).unwrap(), expected);
349            }
350        }
351    }
352
353    #[test]
354    fn test_mdx_attribute_trailing_spaces_preserve_string_data() {
355        for strict in [false, true] {
356            let rule = MD009TrailingSpaces::new(0, strict);
357            let source = "Before <span title={`left   \nright`}>text</span> after   \n";
358            let expected = "Before <span title={`left   \nright`}>text</span> after\n";
359            let ctx = LintContext::new(source, crate::config::MarkdownFlavor::MDX, None);
360            assert_eq!(rule.check(&ctx).unwrap().len(), 1);
361            assert_eq!(rule.fix(&ctx).unwrap(), expected);
362        }
363    }
364
365    #[test]
366    fn test_jinja_quoted_trailing_spaces_preserve_string_data() {
367        for strict in [false, true] {
368            let rule = MD009TrailingSpaces::new(0, strict);
369            let source = "Before {{ 'left   \n   \nright' }} after   \n";
370            let expected = "Before {{ 'left   \n   \nright' }} after\n";
371            let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
372            assert_eq!(rule.check(&ctx).unwrap().len(), 1);
373            assert_eq!(rule.fix(&ctx).unwrap(), expected);
374        }
375    }
376
377    #[test]
378    fn test_hugo_raw_argument_trailing_spaces_preserve_values() {
379        for strict in [false, true] {
380            let rule = MD009TrailingSpaces::new(0, strict);
381            let source = "Before {{< note title=`left   \n   \nright` >}} after   \n";
382            let expected = "Before {{< note title=`left   \n   \nright` >}} after\n";
383            let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Hugo, None);
384            assert_eq!(rule.check(&ctx).unwrap().len(), 1);
385            assert_eq!(rule.fix(&ctx).unwrap(), expected);
386        }
387    }
388
389    #[test]
390    fn test_no_trailing_spaces() {
391        let rule = MD009TrailingSpaces::default();
392        let content = "This is a line\nAnother line\nNo trailing spaces";
393        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
394        let result = rule.check(&ctx).unwrap();
395        assert!(result.is_empty());
396    }
397
398    #[test]
399    fn test_basic_trailing_spaces() {
400        let rule = MD009TrailingSpaces::default();
401        let content = "Line with spaces   \nAnother line  \nClean line";
402        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
403        let result = rule.check(&ctx).unwrap();
404        // Default br_spaces=2, so line with 2 spaces is OK
405        assert_eq!(result.len(), 1);
406        assert_eq!(result[0].line, 1);
407        assert_eq!(result[0].message, "3 trailing spaces found");
408    }
409
410    #[test]
411    fn test_md009_front_matter() {
412        let rule = MD009TrailingSpaces::default();
413        let content = "---\ntitle: Test   \n---\nBody   ";
414        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
415        let result = rule.check(&ctx).unwrap();
416        // Should only flag the one in the body (line 4), not the one in front-matter (line 2)
417        assert_eq!(result.len(), 1);
418        assert_eq!(result[0].line, 4);
419    }
420
421    #[test]
422    fn test_fix_basic_trailing_spaces() {
423        let rule = MD009TrailingSpaces::default();
424        let content = "Line with spaces   \nAnother line  \nClean line";
425        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
426        let fixed = rule.fix(&ctx).unwrap();
427        // Line 1: 3 spaces -> removed (doesn't match br_spaces=2)
428        // Line 2: 2 spaces -> kept (matches br_spaces=2)
429        // Line 3: no spaces -> unchanged
430        assert_eq!(fixed, "Line with spaces\nAnother line  \nClean line");
431    }
432
433    #[test]
434    fn test_strict_mode() {
435        let rule = MD009TrailingSpaces::new(2, true);
436        // Strict mode keeps the br_spaces exception only on non-last paragraph lines:
437        //   - line 1 ("Line with spaces  ") has paragraph continuation on line 2 -> allowed
438        //   - line 2 ("Code block:  ") is followed by a code fence (non-paragraph next)
439        //     so its `<br>` is wasted -> flagged
440        //   - lines 3 and 5 are fence boundaries (non-paragraph) -> flagged
441        //   - line 4 is inside the code block; rumdl's strict mode is intentionally
442        //     more thorough than markdownlint and flags trailing whitespace there too
443        let content = "Line with spaces  \nCode block:  \n```  \nCode with spaces  \n```  ";
444        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
445        let result = rule.check(&ctx).unwrap();
446        let lines_flagged: Vec<usize> = result.iter().map(|w| w.line).collect();
447        assert_eq!(lines_flagged, vec![2, 3, 4, 5], "got: {result:?}");
448
449        let fixed = rule.fix(&ctx).unwrap();
450        assert_eq!(fixed, "Line with spaces  \nCode block:\n```\nCode with spaces\n```");
451    }
452
453    #[test]
454    fn test_strict_mode_allows_br_spaces_on_paragraph_lines() {
455        // markdownlint parity: when `strict = true`, the br_spaces (2-space) line break
456        // is still allowed on paragraph-context lines because the trailing spaces
457        // produce a real <br>. Strict only flags trailing spaces on lines that can't
458        // produce a useful line break (headings, code blocks, last line, etc.).
459        //
460        // Reproduction from issue #593: blockquote prose with a 2-space line break.
461        let rule = MD009TrailingSpaces::new(2, true);
462        let content = "> Note:  \n> This is in a new line due to 2 spaces behind \"Note:\".\n";
463        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
464        let result = rule.check(&ctx).unwrap();
465        assert!(
466            result.is_empty(),
467            "strict mode should allow br_spaces on paragraph-context lines, got: {result:?}"
468        );
469
470        // The fix() must not strip those spaces either, since the rule didn't flag them.
471        let fixed = rule.fix(&ctx).unwrap();
472        assert_eq!(fixed, content);
473    }
474
475    #[test]
476    fn test_strict_mode_flags_br_spaces_on_heading() {
477        // Headings don't produce a <br> from trailing spaces, so strict mode flags them.
478        let rule = MD009TrailingSpaces::new(2, true);
479        let content = "# Heading  \nFollow-up paragraph.\n";
480        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
481        let result = rule.check(&ctx).unwrap();
482        assert_eq!(result.len(), 1, "strict should flag heading br_spaces, got: {result:?}");
483        assert_eq!(result[0].line, 1);
484    }
485
486    #[test]
487    fn test_strict_mode_flags_br_spaces_on_last_paragraph_line() {
488        // markdownlint parity: a `<br>` from trailing spaces on the last line of a
489        // paragraph is unobservable (the paragraph already ends at the next blank
490        // line), so strict mode flags it.
491        let rule = MD009TrailingSpaces::new(2, true);
492        let content = "Paragraph  \n\nNext paragraph.\n";
493        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
494        let result = rule.check(&ctx).unwrap();
495        assert_eq!(
496            result.iter().map(|w| w.line).collect::<Vec<_>>(),
497            vec![1],
498            "strict should flag br_spaces on a single-line paragraph, got: {result:?}"
499        );
500    }
501
502    #[test]
503    fn test_strict_mode_flags_br_spaces_between_list_items() {
504        // markdownlint parity: each top-level list item is its own paragraph block.
505        // Trailing br_spaces on item 1 don't bridge into item 2; strict flags them.
506        let rule = MD009TrailingSpaces::new(2, true);
507        let content = "- item 1  \n- item 2\n";
508        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
509        let result = rule.check(&ctx).unwrap();
510        assert_eq!(
511            result.iter().map(|w| w.line).collect::<Vec<_>>(),
512            vec![1],
513            "strict should flag br_spaces at the end of a list item, got: {result:?}"
514        );
515    }
516
517    #[test]
518    fn test_strict_mode_allows_br_spaces_in_list_item_continuation() {
519        // markdownlint parity: a list item with a continuation line is a single
520        // paragraph; a trailing 2-space line break inside it is meaningful.
521        let rule = MD009TrailingSpaces::new(2, true);
522        let content = "- first line  \n  second line of same item\n";
523        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
524        let result = rule.check(&ctx).unwrap();
525        assert!(
526            result.is_empty(),
527            "strict should allow br_spaces between a list item and its continuation, got: {result:?}"
528        );
529    }
530
531    #[test]
532    fn test_strict_mode_flags_br_spaces_before_heading() {
533        // markdownlint parity: an ATX heading interrupts the paragraph above it,
534        // so the line before it is a paragraph end — strict flags trailing br_spaces.
535        let rule = MD009TrailingSpaces::new(2, true);
536        let content = "Paragraph  \n# Heading\n";
537        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
538        let result = rule.check(&ctx).unwrap();
539        assert_eq!(
540            result.iter().map(|w| w.line).collect::<Vec<_>>(),
541            vec![1],
542            "strict should flag br_spaces on the line before a heading, got: {result:?}"
543        );
544    }
545
546    #[test]
547    fn test_strict_mode_flags_br_spaces_on_setext_heading_text() {
548        // Setext heading text line is a heading, not a paragraph, so trailing spaces
549        // on it can't produce a <br>. Strict mode flags it.
550        let rule = MD009TrailingSpaces::new(2, true);
551        let content = "Setext heading  \n===\n\nFollow-up paragraph.\n";
552        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
553        let result = rule.check(&ctx).unwrap();
554        assert_eq!(
555            result.iter().map(|w| w.line).collect::<Vec<_>>(),
556            vec![1],
557            "strict should flag setext heading text trailing spaces, got: {result:?}"
558        );
559    }
560
561    #[test]
562    fn test_strict_mode_flags_br_spaces_on_setext_underline() {
563        // The setext underline is part of the heading block, not a paragraph.
564        // rumdl marks `heading` only on the text line; the underline is detected by
565        // looking back. Strict mode flags trailing spaces on it.
566        let rule = MD009TrailingSpaces::new(2, true);
567        let content = "Setext heading\n===  \n\nFollow-up paragraph.\n";
568        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
569        let result = rule.check(&ctx).unwrap();
570        assert_eq!(
571            result.iter().map(|w| w.line).collect::<Vec<_>>(),
572            vec![2],
573            "strict should flag setext underline trailing spaces, got: {result:?}"
574        );
575    }
576
577    #[test]
578    fn test_strict_mode_flags_br_spaces_in_indented_code_block() {
579        // Indented code blocks aren't paragraphs. rumdl's strict mode is intentionally
580        // stricter than markdownlint here (markdownlint excludes code blocks entirely
581        // unless `code_blocks: true`); we surface the trailing whitespace as a warning.
582        let rule = MD009TrailingSpaces::new(2, true);
583        let content = "Paragraph above.\n\n    code line  \n    another code  \n\nParagraph below.\n";
584        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
585        let result = rule.check(&ctx).unwrap();
586        assert_eq!(
587            result.iter().map(|w| w.line).collect::<Vec<_>>(),
588            vec![3, 4],
589            "strict should flag indented code block trailing spaces, got: {result:?}"
590        );
591    }
592
593    #[test]
594    fn test_strict_mode_allows_br_spaces_in_table_row() {
595        // GFM table rows close with `|`; the trailing whitespace before that pipe is
596        // inside the cell, not at the line end. This test guards against future
597        // refactors mistakenly treating table rows as a special non-paragraph context
598        // when there is in fact no end-of-line whitespace on the row.
599        let rule = MD009TrailingSpaces::new(2, true);
600        let content = "| col |\n| --- |\n| cell  |\n\nParagraph.\n";
601        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
602        let result = rule.check(&ctx).unwrap();
603        assert!(
604            result.is_empty(),
605            "rows that don't actually have trailing whitespace shouldn't trigger MD009, got: {result:?}"
606        );
607    }
608
609    #[test]
610    fn test_non_strict_mode_with_code_blocks() {
611        let rule = MD009TrailingSpaces::new(2, false);
612        let content = "Line with spaces  \n```\nCode with spaces  \n```\nOutside code  ";
613        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
614        let result = rule.check(&ctx).unwrap();
615        // In non-strict mode, code blocks are not checked
616        // Line 1 has 2 spaces (= br_spaces), so it's OK
617        // Line 5 is last line without newline, so trailing spaces are flagged
618        assert_eq!(result.len(), 1);
619        assert_eq!(result[0].line, 5);
620    }
621
622    #[test]
623    fn test_br_spaces_preservation() {
624        let rule = MD009TrailingSpaces::new(2, false);
625        let content = "Line with two spaces  \nLine with three spaces   \nLine with one space ";
626        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
627        let result = rule.check(&ctx).unwrap();
628        // br_spaces=2, so lines with exactly 2 spaces are OK
629        // Line 2 has 3 spaces (should be removed, not normalized)
630        // Line 3 has 1 space and is last line without newline (will be removed)
631        assert_eq!(result.len(), 2);
632        assert_eq!(result[0].line, 2);
633        assert_eq!(result[1].line, 3);
634
635        let fixed = rule.fix(&ctx).unwrap();
636        // Line 1: keeps 2 spaces (exact match with br_spaces)
637        // Line 2: removes all 3 spaces (doesn't match br_spaces)
638        // Line 3: last line without newline, spaces removed
639        assert_eq!(
640            fixed,
641            "Line with two spaces  \nLine with three spaces\nLine with one space"
642        );
643    }
644
645    #[test]
646    fn test_empty_lines_with_spaces() {
647        let rule = MD009TrailingSpaces::default();
648        let content = "Normal line\n   \n  \nAnother line";
649        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
650        let result = rule.check(&ctx).unwrap();
651        assert_eq!(result.len(), 2);
652        assert_eq!(result[0].message, "Empty line has trailing spaces");
653        assert_eq!(result[1].message, "Empty line has trailing spaces");
654
655        let fixed = rule.fix(&ctx).unwrap();
656        assert_eq!(fixed, "Normal line\n\n\nAnother line");
657    }
658
659    #[test]
660    fn test_empty_blockquote_lines() {
661        let rule = MD009TrailingSpaces::default();
662        let content = "> Quote\n>   \n> More quote";
663        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
664        let result = rule.check(&ctx).unwrap();
665        assert_eq!(result.len(), 1);
666        assert_eq!(result[0].line, 2);
667        assert_eq!(result[0].message, "3 trailing spaces found");
668
669        let fixed = rule.fix(&ctx).unwrap();
670        assert_eq!(fixed, "> Quote\n>\n> More quote"); // All trailing spaces removed
671    }
672
673    #[test]
674    fn test_last_line_handling() {
675        let rule = MD009TrailingSpaces::new(2, false);
676
677        // Content without final newline
678        let content = "First line  \nLast line  ";
679        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
680        let result = rule.check(&ctx).unwrap();
681        // Last line without newline should have trailing spaces removed
682        assert_eq!(result.len(), 1);
683        assert_eq!(result[0].line, 2);
684
685        let fixed = rule.fix(&ctx).unwrap();
686        assert_eq!(fixed, "First line  \nLast line");
687
688        // Content with final newline
689        let content_with_newline = "First line  \nLast line  \n";
690        let ctx = LintContext::new(content_with_newline, crate::config::MarkdownFlavor::Standard, None);
691        let result = rule.check(&ctx).unwrap();
692        // Both lines should preserve br_spaces
693        assert!(result.is_empty());
694    }
695
696    #[test]
697    fn test_single_trailing_space() {
698        let rule = MD009TrailingSpaces::new(2, false);
699        let content = "Line with one space ";
700        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
701        let result = rule.check(&ctx).unwrap();
702        assert_eq!(result.len(), 1);
703        assert_eq!(result[0].message, "Trailing space found");
704    }
705
706    #[test]
707    fn test_tabs_not_spaces() {
708        let rule = MD009TrailingSpaces::default();
709        let content = "Line with tab\t\nLine with spaces  ";
710        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
711        let result = rule.check(&ctx).unwrap();
712        // Only spaces are checked, not tabs
713        assert_eq!(result.len(), 1);
714        assert_eq!(result[0].line, 2);
715    }
716
717    #[test]
718    fn test_mixed_content() {
719        let rule = MD009TrailingSpaces::new(2, false);
720        // Construct content with actual trailing spaces using string concatenation
721        let mut content = String::new();
722        content.push_str("# Heading");
723        content.push_str("   "); // Add 3 trailing spaces (more than br_spaces=2)
724        content.push('\n');
725        content.push_str("Normal paragraph\n> Blockquote\n>\n```\nCode block\n```\n- List item\n");
726
727        let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
728        let result = rule.check(&ctx).unwrap();
729        // Should flag the line with trailing spaces
730        assert_eq!(result.len(), 1);
731        assert_eq!(result[0].line, 1);
732        assert!(result[0].message.contains("trailing spaces"));
733    }
734
735    #[test]
736    fn test_column_positions() {
737        let rule = MD009TrailingSpaces::default();
738        let content = "Text   ";
739        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
740        let result = rule.check(&ctx).unwrap();
741        assert_eq!(result.len(), 1);
742        assert_eq!(result[0].column, 5); // After "Text"
743        assert_eq!(result[0].end_column, 8); // After all spaces
744    }
745
746    #[test]
747    fn test_default_config() {
748        let rule = MD009TrailingSpaces::default();
749        let config = rule.default_config_section();
750        assert!(config.is_some());
751        let (name, _value) = config.unwrap();
752        assert_eq!(name, "MD009");
753    }
754
755    #[test]
756    fn test_from_config() {
757        let mut config = crate::config::Config::default();
758        let mut rule_config = crate::config::RuleConfig::default();
759        rule_config
760            .values
761            .insert("br_spaces".to_string(), toml::Value::Integer(3));
762        rule_config
763            .values
764            .insert("strict".to_string(), toml::Value::Boolean(true));
765        config.rules.insert("MD009".to_string(), rule_config);
766
767        let rule = MD009TrailingSpaces::from_config(&config);
768        let content = "Line   ";
769        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
770        let result = rule.check(&ctx).unwrap();
771        assert_eq!(result.len(), 1);
772
773        // In strict mode, should remove all spaces
774        let fixed = rule.fix(&ctx).unwrap();
775        assert_eq!(fixed, "Line");
776    }
777
778    #[test]
779    fn test_list_item_empty_lines() {
780        // Create rule with list_item_empty_lines enabled
781        let config = MD009Config {
782            list_item_empty_lines: true,
783            ..Default::default()
784        };
785        let rule = MD009TrailingSpaces::from_config_struct(config);
786
787        // Test unordered list with empty line
788        let content = "- First item\n  \n- Second item";
789        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
790        let result = rule.check(&ctx).unwrap();
791        // Should not flag the empty line with spaces after list item
792        assert!(result.is_empty());
793
794        // Test ordered list with empty line
795        let content = "1. First item\n  \n2. Second item";
796        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
797        let result = rule.check(&ctx).unwrap();
798        assert!(result.is_empty());
799
800        // Test that non-list empty lines are still flagged
801        let content = "Normal paragraph\n  \nAnother paragraph";
802        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
803        let result = rule.check(&ctx).unwrap();
804        assert_eq!(result.len(), 1);
805        assert_eq!(result[0].line, 2);
806    }
807
808    #[test]
809    fn test_list_item_empty_lines_disabled() {
810        // Default config has list_item_empty_lines disabled
811        let rule = MD009TrailingSpaces::default();
812
813        let content = "- First item\n  \n- Second item";
814        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
815        let result = rule.check(&ctx).unwrap();
816        // Should flag the empty line with spaces
817        assert_eq!(result.len(), 1);
818        assert_eq!(result[0].line, 2);
819    }
820
821    #[test]
822    fn test_performance_large_document() {
823        let rule = MD009TrailingSpaces::default();
824        let mut content = String::new();
825        for i in 0..1000 {
826            content.push_str(&format!("Line {i} with spaces  \n"));
827        }
828        let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
829        let result = rule.check(&ctx).unwrap();
830        // Default br_spaces=2, so all lines with 2 spaces are OK
831        assert_eq!(result.len(), 0);
832    }
833
834    #[test]
835    fn test_preserve_content_after_fix() {
836        let rule = MD009TrailingSpaces::new(2, false);
837        let content = "**Bold** text  \n*Italic* text  \n[Link](url)  ";
838        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
839        let fixed = rule.fix(&ctx).unwrap();
840        assert_eq!(fixed, "**Bold** text  \n*Italic* text  \n[Link](url)");
841    }
842
843    #[test]
844    fn test_nested_blockquotes() {
845        let rule = MD009TrailingSpaces::default();
846        let content = "> > Nested  \n> >   \n> Normal  ";
847        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
848        let result = rule.check(&ctx).unwrap();
849        // Line 2 has empty blockquote with 3 spaces, line 3 is last line without newline
850        assert_eq!(result.len(), 2);
851        assert_eq!(result[0].line, 2);
852        assert_eq!(result[1].line, 3);
853
854        let fixed = rule.fix(&ctx).unwrap();
855        // Line 1: Keeps 2 spaces (exact match with br_spaces)
856        // Line 2: Empty blockquote with 3 spaces -> removes all (doesn't match br_spaces)
857        // Line 3: Last line without newline -> removes all spaces
858        assert_eq!(fixed, "> > Nested  \n> >\n> Normal");
859    }
860
861    #[test]
862    fn test_normalized_line_endings() {
863        let rule = MD009TrailingSpaces::default();
864        // In production, content is normalized to LF at I/O boundary
865        let content = "Line with spaces  \nAnother line  ";
866        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
867        let result = rule.check(&ctx).unwrap();
868        // Line 1 has 2 spaces (= br_spaces) so it's OK
869        // Line 2 is last line without newline, so it's flagged
870        assert_eq!(result.len(), 1);
871        assert_eq!(result[0].line, 2);
872    }
873
874    #[test]
875    fn test_issue_80_no_space_normalization() {
876        // Test for GitHub issue #80 - MD009 should not add spaces when removing trailing spaces
877        let rule = MD009TrailingSpaces::new(2, false); // br_spaces=2
878
879        // Test that 1 trailing space is removed, not normalized to 2
880        let content = "Line with one space \nNext line";
881        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
882        let result = rule.check(&ctx).unwrap();
883        assert_eq!(result.len(), 1);
884        assert_eq!(result[0].line, 1);
885        assert_eq!(result[0].message, "Trailing space found");
886
887        let fixed = rule.fix(&ctx).unwrap();
888        assert_eq!(fixed, "Line with one space\nNext line");
889
890        // Test that 3 trailing spaces are removed, not normalized to 2
891        let content = "Line with three spaces   \nNext line";
892        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
893        let result = rule.check(&ctx).unwrap();
894        assert_eq!(result.len(), 1);
895        assert_eq!(result[0].line, 1);
896        assert_eq!(result[0].message, "3 trailing spaces found");
897
898        let fixed = rule.fix(&ctx).unwrap();
899        assert_eq!(fixed, "Line with three spaces\nNext line");
900
901        // Test that exactly 2 trailing spaces are preserved
902        let content = "Line with two spaces  \nNext line";
903        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
904        let result = rule.check(&ctx).unwrap();
905        assert_eq!(result.len(), 0); // Should not flag lines with exact br_spaces
906
907        let fixed = rule.fix(&ctx).unwrap();
908        assert_eq!(fixed, "Line with two spaces  \nNext line");
909    }
910
911    #[test]
912    fn test_unicode_whitespace_idempotent_fix() {
913        // Only the ASCII space after the U+2000 is trailing whitespace. The U+2000
914        // is text, so it stays and the fix reaches its fixed point in one pass.
915        let rule = MD009TrailingSpaces::default(); // br_spaces=2
916
917        let content = "> 0\u{2000} ";
918        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
919        let result = rule.check(&ctx).unwrap();
920        assert_eq!(result.len(), 1, "The ASCII space after the Unicode space is trailing");
921
922        let fixed = rule.fix(&ctx).unwrap();
923        assert_eq!(fixed, "> 0\u{2000}", "Only the ASCII space is removed");
924
925        let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
926        let fixed2 = rule.fix(&ctx2).unwrap();
927        assert_eq!(fixed, fixed2, "Fix must be idempotent");
928    }
929
930    #[test]
931    fn test_unicode_whitespace_is_text_not_trailing_whitespace() {
932        // CommonMark trims only spaces and tabs at the end of a line, so every other
933        // Unicode space is content: reporting or removing it changes the document.
934        for strict in [false, true] {
935            let rule = MD009TrailingSpaces::new(2, strict);
936            for ws in ['\u{a0}', '\u{2000}', '\u{2001}', '\u{2003}', '\u{3000}'] {
937                for content in [
938                    format!("text{ws}\n"),
939                    format!("text{ws}\t\n"),
940                    format!("# head{ws}\n"),
941                    format!("> quote{ws}\n"),
942                    format!("- item{ws}\nnext\n"),
943                    format!("a\n{ws}\nb\n"),
944                    format!("{ws}\n"),
945                ] {
946                    let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
947                    assert!(
948                        rule.check(&ctx).unwrap().is_empty(),
949                        "strict={strict}: flagged text {content:?}"
950                    );
951                    assert_eq!(rule.fix(&ctx).unwrap(), content);
952                }
953            }
954        }
955    }
956
957    #[test]
958    fn test_ascii_space_after_unicode_whitespace_is_still_trailing() {
959        let rule = MD009TrailingSpaces::default();
960        // The Unicode space stays, the ASCII run after it is removed. The run
961        // of 2 would be a hard break, so use 3 to make the fix observable.
962        for (content, expected) in [
963            ("text\u{2000}   \n", "text\u{2000}\n"),
964            ("text\u{a0}\t \n", "text\u{a0}\n"),
965            ("text\u{3000}\t   \n", "text\u{3000}\n"),
966        ] {
967            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
968            assert_eq!(rule.check(&ctx).unwrap().len(), 1, "{content:?}");
969            let fixed = rule.fix(&ctx).unwrap();
970            assert_eq!(fixed, expected);
971            let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
972            assert_eq!(rule.fix(&ctx2).unwrap(), fixed, "idempotent: {content:?}");
973        }
974    }
975
976    /// Helper: after fix(), run check() on the result and assert zero violations remain.
977    fn assert_fix_roundtrip(rule: &MD009TrailingSpaces, content: &str) {
978        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
979        let fixed = rule.fix(&ctx).unwrap();
980        let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
981        let remaining = rule.check(&ctx2).unwrap();
982        assert!(
983            remaining.is_empty(),
984            "After fix(), check() should find 0 violations.\nOriginal: {content:?}\nFixed: {fixed:?}\nRemaining: {remaining:?}"
985        );
986    }
987
988    #[test]
989    fn test_roundtrip_basic_trailing_spaces() {
990        let rule = MD009TrailingSpaces::default();
991        assert_fix_roundtrip(&rule, "Line with spaces   \nAnother line  \nClean line");
992    }
993
994    #[test]
995    fn test_roundtrip_strict_mode() {
996        let rule = MD009TrailingSpaces::new(2, true);
997        assert_fix_roundtrip(
998            &rule,
999            "Line with spaces  \nCode block:  \n```  \nCode with spaces  \n```  ",
1000        );
1001    }
1002
1003    #[test]
1004    fn test_roundtrip_empty_lines() {
1005        let rule = MD009TrailingSpaces::default();
1006        assert_fix_roundtrip(&rule, "Normal line\n   \n  \nAnother line");
1007    }
1008
1009    #[test]
1010    fn test_roundtrip_br_spaces_preservation() {
1011        let rule = MD009TrailingSpaces::new(2, false);
1012        assert_fix_roundtrip(
1013            &rule,
1014            "Line with two spaces  \nLine with three spaces   \nLine with one space ",
1015        );
1016    }
1017
1018    #[test]
1019    fn test_roundtrip_last_line_no_newline() {
1020        let rule = MD009TrailingSpaces::new(2, false);
1021        assert_fix_roundtrip(&rule, "First line  \nLast line  ");
1022    }
1023
1024    #[test]
1025    fn test_roundtrip_last_line_with_newline() {
1026        let rule = MD009TrailingSpaces::new(2, false);
1027        assert_fix_roundtrip(&rule, "First line  \nLast line  \n");
1028    }
1029
1030    #[test]
1031    fn test_roundtrip_unicode_whitespace() {
1032        let rule = MD009TrailingSpaces::default();
1033        assert_fix_roundtrip(&rule, "> 0\u{2000} ");
1034        assert_fix_roundtrip(&rule, "text\u{2000}\n");
1035        assert_fix_roundtrip(&rule, "text\u{3000}\n");
1036        assert_fix_roundtrip(&rule, "text\u{2000}   \n");
1037    }
1038
1039    #[test]
1040    fn test_roundtrip_code_blocks_non_strict() {
1041        let rule = MD009TrailingSpaces::new(2, false);
1042        assert_fix_roundtrip(
1043            &rule,
1044            "Line with spaces  \n```\nCode with spaces  \n```\nOutside code  ",
1045        );
1046    }
1047
1048    #[test]
1049    fn test_roundtrip_blockquotes() {
1050        let rule = MD009TrailingSpaces::default();
1051        assert_fix_roundtrip(&rule, "> Quote\n>   \n> More quote");
1052        assert_fix_roundtrip(&rule, "> > Nested  \n> >   \n> Normal  ");
1053    }
1054
1055    #[test]
1056    fn test_roundtrip_list_item_empty_lines() {
1057        let config = MD009Config {
1058            list_item_empty_lines: true,
1059            ..Default::default()
1060        };
1061        let rule = MD009TrailingSpaces::from_config_struct(config);
1062        assert_fix_roundtrip(&rule, "- First item\n  \n- Second item");
1063        assert_fix_roundtrip(&rule, "Normal paragraph\n  \nAnother paragraph");
1064    }
1065
1066    #[test]
1067    fn test_roundtrip_complex_document() {
1068        let rule = MD009TrailingSpaces::default();
1069        assert_fix_roundtrip(
1070            &rule,
1071            "# Title   \n\nParagraph  \n\n- List   \n  - Nested  \n\n```\ncode   \n```\n\n> Quote   \n>    \n\nEnd  ",
1072        );
1073    }
1074
1075    #[test]
1076    fn test_roundtrip_multibyte() {
1077        let rule = MD009TrailingSpaces::new(2, true);
1078        assert_fix_roundtrip(&rule, "- 1€ expenses \n");
1079        assert_fix_roundtrip(&rule, "€100 + €50 = €150   \n");
1080        assert_fix_roundtrip(&rule, "Hello 你好世界   \n");
1081        assert_fix_roundtrip(&rule, "Party 🎉🎉🎉   \n");
1082        assert_fix_roundtrip(&rule, "안녕하세요   \n");
1083    }
1084
1085    #[test]
1086    fn test_roundtrip_mixed_tabs_and_spaces() {
1087        let rule = MD009TrailingSpaces::default();
1088        assert_fix_roundtrip(&rule, "Line with tab\t\nLine with spaces  ");
1089        assert_fix_roundtrip(&rule, "Line\t  \nAnother\n");
1090    }
1091
1092    #[test]
1093    fn test_roundtrip_heading_with_br_spaces() {
1094        // Headings with exactly br_spaces trailing spaces: check() does not flag them,
1095        // so fix() should not remove them. This tests consistency.
1096        let rule = MD009TrailingSpaces::new(2, false);
1097        let content = "# Heading  \nParagraph\n";
1098        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1099        let warnings = rule.check(&ctx).unwrap();
1100        // check() allows br_spaces on headings (does not flag)
1101        assert!(
1102            warnings.is_empty(),
1103            "check() should not flag heading with exactly br_spaces trailing spaces"
1104        );
1105        assert_fix_roundtrip(&rule, content);
1106    }
1107
1108    #[test]
1109    fn test_fix_replacement_always_removes_trailing_spaces() {
1110        // The fix replacement must always be an empty string, fully removing
1111        // trailing spaces that do not match the br_spaces allowance.
1112        let rule = MD009TrailingSpaces::new(2, false);
1113
1114        // 3 trailing spaces (not matching br_spaces=2) should produce a warning
1115        // with an empty replacement that removes them entirely
1116        let content = "Hello   \nWorld\n";
1117        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1118        let result = rule.check(&ctx).unwrap();
1119        assert_eq!(result.len(), 1);
1120
1121        let fix = result[0].fix.as_ref().expect("Should have a fix");
1122        assert_eq!(
1123            fix.replacement, "",
1124            "Fix replacement should always be empty string (remove trailing spaces)"
1125        );
1126
1127        // Also verify via fix() method
1128        let fixed = rule.fix(&ctx).unwrap();
1129        assert_eq!(fixed, "Hello\nWorld\n");
1130    }
1131
1132    /// The rule configurations a code span must survive: the default, strict
1133    /// mode, and `br-spaces = 0`, which turns the hard-break exemption off.
1134    fn span_configs() -> [MD009TrailingSpaces; 3] {
1135        [
1136            MD009TrailingSpaces::default(),
1137            MD009TrailingSpaces::new(2, true),
1138            MD009TrailingSpaces::new(0, false),
1139        ]
1140    }
1141
1142    fn assert_kept_whole(source: &str, flavor: crate::config::MarkdownFlavor) {
1143        for rule in span_configs() {
1144            let ctx = LintContext::new(source, flavor, None);
1145            let warnings = rule.check(&ctx).unwrap();
1146            assert!(
1147                warnings.is_empty(),
1148                "whitespace inside a code span is its text, not trailing space: {source:?} {:?} {warnings:?}",
1149                rule.config
1150            );
1151            assert_eq!(rule.fix(&ctx).unwrap(), source, "fix changed a code span: {source:?}");
1152        }
1153    }
1154
1155    #[test]
1156    fn trailing_spaces_inside_a_multi_line_code_span_are_kept() {
1157        assert_kept_whole("`a   \nb`\n", crate::config::MarkdownFlavor::Standard);
1158    }
1159
1160    #[test]
1161    fn code_span_whitespace_is_kept_in_every_shape_a_span_can_take() {
1162        for source in [
1163            // The count of spaces does not matter, including the hard-break count.
1164            "`a \nb`\n",
1165            "`a  \nb`\n",
1166            "`a    \nb`\n",
1167            // A run right after the opening fence.
1168            "` \n`\n",
1169            "``a   \nb``\n",
1170            "``a`  \nb``\n",
1171            // Three lines: every inner run is text, whatever its length.
1172            "`a   \nmid  \nb`\n",
1173            "`a   \nmid    \nb`\n",
1174            // Tabs and Unicode spaces are whitespace the rule would also remove.
1175            "`a \t \nb`\n",
1176            "`a\u{2000}\nb`\n",
1177            "`\u{e9}   \nb`\n",
1178            // Containers keep their prefix on the continuation line.
1179            "- `a   \n  b`\n",
1180            "1. `a   \n   b`\n",
1181            "- x\n  - `a   \n    b`\n",
1182            "> `a   \n> b`\n",
1183            "> > `a   \n> > b`\n",
1184            "- > `a   \n  > b`\n",
1185            "> - `a   \n>   b`\n",
1186            "> `a   \nb`\n",
1187            // CRLF documents.
1188            "`a   \r\nb`\r\n",
1189            // Spans that share a paragraph with other text, spans, or markup.
1190            "text `a   \nb` more\n",
1191            "`a   \nb` and `c   \nd`\n",
1192            "*`a   \nb`*\n",
1193            "[`a   \nb`](u)\n",
1194            "<span>`a   \nb`</span>\n",
1195            // No newline after the closing fence.
1196            "`a   \nb`",
1197            // Math: a backtick inside `$...$` opens no span for rumdl but does
1198            // for a reader without math, and the text must hold under both.
1199            "$x$ `a   \nb`\n",
1200            "A $x `y$   \nz` end\n",
1201        ] {
1202            assert_kept_whole(source, crate::config::MarkdownFlavor::Standard);
1203        }
1204    }
1205
1206    #[test]
1207    fn code_span_whitespace_is_kept_in_a_mkdocs_container() {
1208        // pulldown-cmark reads the indented admonition body as indented code, so
1209        // the span is only known through the container supplement.
1210        assert_kept_whole("!!! note\n\n    `a   \n    b`\n", crate::config::MarkdownFlavor::MkDocs);
1211        for strict in [false, true] {
1212            let rule = MD009TrailingSpaces::new(2, strict);
1213            let source = "!!! note\n\n    `a   \n    b`\n\nouter   \n";
1214            let ctx = LintContext::new(source, crate::config::MarkdownFlavor::MkDocs, None);
1215            assert_eq!(rule.fix(&ctx).unwrap(), "!!! note\n\n    `a   \n    b`\n\nouter\n");
1216        }
1217    }
1218
1219    #[test]
1220    fn spaces_after_a_span_on_its_closing_line_are_still_trailing() {
1221        for strict in [false, true] {
1222            let rule = MD009TrailingSpaces::new(2, strict);
1223            let source = "`a   \nb` tail   \nnext\n";
1224            let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1225            let warnings = rule.check(&ctx).unwrap();
1226            assert_eq!(warnings.len(), 1, "only the run after the span: {warnings:?}");
1227            assert_eq!(warnings[0].line, 2);
1228            assert_eq!(rule.fix(&ctx).unwrap(), "`a   \nb` tail\nnext\n");
1229        }
1230    }
1231
1232    #[test]
1233    fn trailing_whitespace_outside_a_code_span_is_still_removed() {
1234        for (source, expected) in [
1235            // Right after the closing fence, on the line that closes the span.
1236            ("`a`   \nnext\n", "`a`\nnext\n"),
1237            ("text   \nnext\n", "text\nnext\n"),
1238            // A backtick that never closes opens no span.
1239            ("`a   \nb\n", "`a\nb\n"),
1240            // The span would have to cross a blank line, so there is none.
1241            ("`a   \n\nb`\n", "`a\n\nb`\n"),
1242            // Raw HTML is not a code span.
1243            ("<code>a   \nb</code>\n", "<code>a\nb</code>\n"),
1244            // A span that ends before the run is over.
1245            ("`a` and `b`   \nnext\n", "`a` and `b`\nnext\n"),
1246        ] {
1247            for strict in [false, true] {
1248                let rule = MD009TrailingSpaces::new(2, strict);
1249                let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1250                assert_eq!(rule.fix(&ctx).unwrap(), expected, "strict={strict}: {source:?}");
1251            }
1252        }
1253    }
1254
1255    #[test]
1256    fn a_hard_break_next_to_a_code_span_is_kept() {
1257        let rule = MD009TrailingSpaces::default();
1258        let source = "`a`  \nnext `b`  \nlast\n";
1259        let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1260        assert!(rule.check(&ctx).unwrap().is_empty());
1261        assert_eq!(rule.fix(&ctx).unwrap(), source);
1262    }
1263
1264    #[test]
1265    fn br_spaces_zero_still_removes_a_two_space_run_outside_a_code_span() {
1266        let rule = MD009TrailingSpaces::new(0, false);
1267        let source = "text  \nnext\n\n`a  \nb`\n";
1268        let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1269        assert_eq!(rule.fix(&ctx).unwrap(), "text\nnext\n\n`a  \nb`\n");
1270    }
1271
1272    #[test]
1273    fn fenced_code_is_skipped_unless_strict_and_a_span_in_it_is_not_special() {
1274        let source = "```\n`a   \nb`\n```\n";
1275        let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1276        assert_eq!(MD009TrailingSpaces::default().fix(&ctx).unwrap(), source);
1277    }
1278
1279    #[test]
1280    fn test_strict_hard_break_survives_a_unicode_space_line() {
1281        // A line holding only U+00A0 is a paragraph line, so the break before it
1282        // (and a break on it) still renders and must be kept.
1283        let rule = MD009TrailingSpaces::new(2, true);
1284        for content in ["a  \n\u{a0}\nb\n", "\u{a0}  \nb\n"] {
1285            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1286            assert!(rule.check(&ctx).unwrap().is_empty(), "{content:?}");
1287            assert_eq!(rule.fix(&ctx).unwrap(), content);
1288        }
1289        // Positive control: a real blank line after the break makes it useless.
1290        let content = "a  \n\nb\n";
1291        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1292        assert_eq!(rule.fix(&ctx).unwrap(), "a\n\nb\n");
1293    }
1294}