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