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