Skip to main content

rumdl_lib/rules/
md009_trailing_spaces.rs

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