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::{LineIndex, calculate_trailing_range};
4use crate::utils::regex_cache::{ORDERED_LIST_MARKER_REGEX, UNORDERED_LIST_MARKER_REGEX, get_cached_regex};
5
6mod md009_config;
7use md009_config::MD009Config;
8
9// No need for lazy_static, we'll use get_cached_regex directly
10
11#[derive(Debug, Clone, Default)]
12pub struct MD009TrailingSpaces {
13    config: MD009Config,
14}
15
16impl MD009TrailingSpaces {
17    pub fn new(br_spaces: usize, strict: bool) -> Self {
18        Self {
19            config: MD009Config {
20                br_spaces,
21                strict,
22                list_item_empty_lines: false,
23            },
24        }
25    }
26
27    pub fn from_config_struct(config: MD009Config) -> Self {
28        Self { config }
29    }
30
31    fn count_trailing_spaces(line: &str) -> usize {
32        line.chars().rev().take_while(|&c| c == ' ').count()
33    }
34
35    fn is_empty_list_item_line(line: &str, prev_line: Option<&str>) -> bool {
36        // A line is an empty list item line if:
37        // 1. It's blank or only contains spaces
38        // 2. The previous line is a list item
39        if !line.trim().is_empty() {
40            return false;
41        }
42
43        if let Some(prev) = prev_line {
44            // Check for unordered list markers (*, -, +) with proper formatting
45            UNORDERED_LIST_MARKER_REGEX.is_match(prev) || ORDERED_LIST_MARKER_REGEX.is_match(prev)
46        } else {
47            false
48        }
49    }
50}
51
52impl Rule for MD009TrailingSpaces {
53    fn name(&self) -> &'static str {
54        "MD009"
55    }
56
57    fn description(&self) -> &'static str {
58        "Trailing spaces should be removed"
59    }
60
61    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
62        let content = ctx.content;
63        let _line_index = LineIndex::new(content.to_string());
64
65        let mut warnings = Vec::new();
66
67        let lines: Vec<&str> = content.lines().collect();
68
69        for (line_num, &line) in lines.iter().enumerate() {
70            let trailing_spaces = Self::count_trailing_spaces(line);
71
72            // Skip if no trailing spaces
73            if trailing_spaces == 0 {
74                continue;
75            }
76
77            // Handle empty lines
78            if line.trim().is_empty() {
79                if trailing_spaces > 0 {
80                    // Check if this is an empty list item line and config allows it
81                    let prev_line = if line_num > 0 { Some(lines[line_num - 1]) } else { None };
82                    if self.config.list_item_empty_lines && Self::is_empty_list_item_line(line, prev_line) {
83                        continue;
84                    }
85
86                    // Calculate precise character range for all trailing spaces on empty line
87                    let (start_line, start_col, end_line, end_col) = calculate_trailing_range(line_num + 1, line, 0);
88
89                    warnings.push(LintWarning {
90                        rule_name: Some(self.name()),
91                        line: start_line,
92                        column: start_col,
93                        end_line,
94                        end_column: end_col,
95                        message: "Empty line has trailing spaces".to_string(),
96                        severity: Severity::Warning,
97                        fix: Some(Fix {
98                            range: _line_index.line_col_to_byte_range_with_length(line_num + 1, 1, line.len()),
99                            replacement: String::new(),
100                        }),
101                    });
102                }
103                continue;
104            }
105
106            // Handle code blocks if not in strict mode
107            if !self.config.strict {
108                // Use pre-computed line info
109                if let Some(line_info) = ctx.line_info(line_num + 1)
110                    && line_info.in_code_block
111                {
112                    continue;
113                }
114            }
115
116            // Check if it's a valid line break
117            // Special handling: if the content ends with a newline, the last line from .lines()
118            // is not really the "last line" in terms of trailing spaces rules
119            let is_truly_last_line = line_num == lines.len() - 1 && !content.ends_with('\n');
120            if !self.config.strict && !is_truly_last_line && trailing_spaces == self.config.br_spaces {
121                continue;
122            }
123
124            // Check if this is an empty blockquote line ("> " or ">> " etc)
125            // These are allowed by MD028 to have a single trailing space
126            let trimmed = line.trim_end();
127            let is_empty_blockquote_with_space = trimmed.chars().all(|c| c == '>' || c == ' ' || c == '\t')
128                && trimmed.contains('>')
129                && trailing_spaces == 1;
130
131            if is_empty_blockquote_with_space {
132                continue; // Allow single trailing space for empty blockquote lines
133            }
134            // Calculate precise character range for all trailing spaces
135            let (start_line, start_col, end_line, end_col) =
136                calculate_trailing_range(line_num + 1, line, trimmed.len());
137
138            warnings.push(LintWarning {
139                rule_name: Some(self.name()),
140                line: start_line,
141                column: start_col,
142                end_line,
143                end_column: end_col,
144                message: if trailing_spaces == 1 {
145                    "Trailing space found".to_string()
146                } else {
147                    format!("{trailing_spaces} trailing spaces found")
148                },
149                severity: Severity::Warning,
150                fix: Some(Fix {
151                    range: _line_index.line_col_to_byte_range_with_length(
152                        line_num + 1,
153                        trimmed.len() + 1,
154                        trailing_spaces,
155                    ),
156                    replacement: if !self.config.strict
157                        && !is_truly_last_line
158                        && trailing_spaces == self.config.br_spaces
159                    {
160                        " ".repeat(self.config.br_spaces)
161                    } else {
162                        String::new()
163                    },
164                }),
165            });
166        }
167
168        Ok(warnings)
169    }
170
171    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
172        let content = ctx.content;
173
174        // For simple cases (strict mode), use fast regex approach
175        if self.config.strict {
176            // In strict mode, remove ALL trailing spaces everywhere
177            return Ok(get_cached_regex(r"(?m) +$")
178                .unwrap()
179                .replace_all(content, "")
180                .to_string());
181        }
182
183        // For complex cases, we need line-by-line processing but with optimizations
184        let lines: Vec<&str> = content.lines().collect();
185        let mut result = String::with_capacity(content.len()); // Pre-allocate capacity
186
187        for (i, line) in lines.iter().enumerate() {
188            // Fast path: if no trailing spaces, just add the line
189            if !line.ends_with(' ') {
190                result.push_str(line);
191                result.push('\n');
192                continue;
193            }
194
195            let trimmed = line.trim_end();
196            let trailing_spaces = Self::count_trailing_spaces(line);
197
198            // Handle empty lines - fast regex replacement
199            if trimmed.is_empty() {
200                // Check if this is an empty list item line and config allows it
201                let prev_line = if i > 0 { Some(lines[i - 1]) } else { None };
202                if self.config.list_item_empty_lines && Self::is_empty_list_item_line(line, prev_line) {
203                    result.push_str(line);
204                } else {
205                    // Remove all trailing spaces - line is empty so don't add anything
206                }
207                result.push('\n');
208                continue;
209            }
210
211            // Handle code blocks if not in strict mode
212            if let Some(line_info) = ctx.line_info(i + 1)
213                && line_info.in_code_block
214            {
215                result.push_str(line);
216                result.push('\n');
217                continue;
218            }
219
220            // No special handling for empty blockquote lines - treat them like regular lines
221
222            // Handle lines with trailing spaces
223            let is_truly_last_line = i == lines.len() - 1 && !content.ends_with('\n');
224
225            result.push_str(trimmed);
226
227            // Check if this line is a heading - headings should never have trailing spaces
228            let is_heading = if let Some(line_info) = ctx.line_info(i + 1) {
229                line_info.heading.is_some()
230            } else {
231                // Fallback: check if line starts with #
232                trimmed.starts_with('#')
233            };
234
235            // Check if this is an empty blockquote line (just ">")
236            let is_empty_blockquote = if let Some(line_info) = ctx.line_info(i + 1) {
237                line_info.blockquote.as_ref().is_some_and(|bq| bq.content.is_empty())
238            } else {
239                false
240            };
241
242            // In non-strict mode, preserve line breaks ONLY if they have exactly br_spaces
243            // BUT: Never preserve trailing spaces in headings or empty blockquotes as they serve no purpose
244            if !self.config.strict
245                && !is_truly_last_line
246                && trailing_spaces == self.config.br_spaces
247                && !is_heading
248                && !is_empty_blockquote
249            {
250                // Preserve the exact number of spaces for hard line breaks
251                match self.config.br_spaces {
252                    0 => {}
253                    1 => result.push(' '),
254                    2 => result.push_str("  "),
255                    n => result.push_str(&" ".repeat(n)),
256                }
257            }
258            result.push('\n');
259        }
260
261        // Preserve original ending (with or without final newline)
262        if !content.ends_with('\n') && result.ends_with('\n') {
263            result.pop();
264        }
265
266        Ok(result)
267    }
268
269    fn as_any(&self) -> &dyn std::any::Any {
270        self
271    }
272
273    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
274        // Skip if content is empty or has no spaces at all
275        ctx.content.is_empty() || !ctx.content.contains(' ')
276    }
277
278    fn category(&self) -> RuleCategory {
279        RuleCategory::Whitespace
280    }
281
282    fn default_config_section(&self) -> Option<(String, toml::Value)> {
283        let default_config = MD009Config::default();
284        let json_value = serde_json::to_value(&default_config).ok()?;
285        let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
286
287        if let toml::Value::Table(table) = toml_value {
288            if !table.is_empty() {
289                Some((MD009Config::RULE_NAME.to_string(), toml::Value::Table(table)))
290            } else {
291                None
292            }
293        } else {
294            None
295        }
296    }
297
298    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
299    where
300        Self: Sized,
301    {
302        let rule_config = crate::rule_config_serde::load_rule_config::<MD009Config>(config);
303        Box::new(Self::from_config_struct(rule_config))
304    }
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310    use crate::lint_context::LintContext;
311    use crate::rule::Rule;
312
313    #[test]
314    fn test_no_trailing_spaces() {
315        let rule = MD009TrailingSpaces::default();
316        let content = "This is a line\nAnother line\nNo trailing spaces";
317        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
318        let result = rule.check(&ctx).unwrap();
319        assert!(result.is_empty());
320    }
321
322    #[test]
323    fn test_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);
327        let result = rule.check(&ctx).unwrap();
328        // Default br_spaces=2, so line with 2 spaces is OK
329        assert_eq!(result.len(), 1);
330        assert_eq!(result[0].line, 1);
331        assert_eq!(result[0].message, "3 trailing spaces found");
332    }
333
334    #[test]
335    fn test_fix_basic_trailing_spaces() {
336        let rule = MD009TrailingSpaces::default();
337        let content = "Line with spaces   \nAnother line  \nClean line";
338        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
339        let fixed = rule.fix(&ctx).unwrap();
340        // Line 1: 3 spaces -> removed (doesn't match br_spaces=2)
341        // Line 2: 2 spaces -> kept (matches br_spaces=2)
342        // Line 3: no spaces -> unchanged
343        assert_eq!(fixed, "Line with spaces\nAnother line  \nClean line");
344    }
345
346    #[test]
347    fn test_strict_mode() {
348        let rule = MD009TrailingSpaces::new(2, true);
349        let content = "Line with spaces  \nCode block:  \n```  \nCode with spaces  \n```  ";
350        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
351        let result = rule.check(&ctx).unwrap();
352        // In strict mode, all trailing spaces are flagged
353        assert_eq!(result.len(), 5);
354
355        let fixed = rule.fix(&ctx).unwrap();
356        assert_eq!(fixed, "Line with spaces\nCode block:\n```\nCode with spaces\n```");
357    }
358
359    #[test]
360    fn test_non_strict_mode_with_code_blocks() {
361        let rule = MD009TrailingSpaces::new(2, false);
362        let content = "Line with spaces  \n```\nCode with spaces  \n```\nOutside code  ";
363        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
364        let result = rule.check(&ctx).unwrap();
365        // In non-strict mode, code blocks are not checked
366        // Line 1 has 2 spaces (= br_spaces), so it's OK
367        // Line 5 is last line without newline, so trailing spaces are flagged
368        assert_eq!(result.len(), 1);
369        assert_eq!(result[0].line, 5);
370    }
371
372    #[test]
373    fn test_br_spaces_preservation() {
374        let rule = MD009TrailingSpaces::new(2, false);
375        let content = "Line with two spaces  \nLine with three spaces   \nLine with one space ";
376        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
377        let result = rule.check(&ctx).unwrap();
378        // br_spaces=2, so lines with exactly 2 spaces are OK
379        // Line 2 has 3 spaces (should be removed, not normalized)
380        // Line 3 has 1 space and is last line without newline (will be removed)
381        assert_eq!(result.len(), 2);
382        assert_eq!(result[0].line, 2);
383        assert_eq!(result[1].line, 3);
384
385        let fixed = rule.fix(&ctx).unwrap();
386        // Line 1: keeps 2 spaces (exact match with br_spaces)
387        // Line 2: removes all 3 spaces (doesn't match br_spaces)
388        // Line 3: last line without newline, spaces removed
389        assert_eq!(
390            fixed,
391            "Line with two spaces  \nLine with three spaces\nLine with one space"
392        );
393    }
394
395    #[test]
396    fn test_empty_lines_with_spaces() {
397        let rule = MD009TrailingSpaces::default();
398        let content = "Normal line\n   \n  \nAnother line";
399        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
400        let result = rule.check(&ctx).unwrap();
401        assert_eq!(result.len(), 2);
402        assert_eq!(result[0].message, "Empty line has trailing spaces");
403        assert_eq!(result[1].message, "Empty line has trailing spaces");
404
405        let fixed = rule.fix(&ctx).unwrap();
406        assert_eq!(fixed, "Normal line\n\n\nAnother line");
407    }
408
409    #[test]
410    fn test_empty_blockquote_lines() {
411        let rule = MD009TrailingSpaces::default();
412        let content = "> Quote\n>   \n> More quote";
413        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
414        let result = rule.check(&ctx).unwrap();
415        assert_eq!(result.len(), 1);
416        assert_eq!(result[0].line, 2);
417        assert_eq!(result[0].message, "3 trailing spaces found");
418
419        let fixed = rule.fix(&ctx).unwrap();
420        assert_eq!(fixed, "> Quote\n>\n> More quote"); // All trailing spaces removed
421    }
422
423    #[test]
424    fn test_last_line_handling() {
425        let rule = MD009TrailingSpaces::new(2, false);
426
427        // Content without final newline
428        let content = "First line  \nLast line  ";
429        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
430        let result = rule.check(&ctx).unwrap();
431        // Last line without newline should have trailing spaces removed
432        assert_eq!(result.len(), 1);
433        assert_eq!(result[0].line, 2);
434
435        let fixed = rule.fix(&ctx).unwrap();
436        assert_eq!(fixed, "First line  \nLast line");
437
438        // Content with final newline
439        let content_with_newline = "First line  \nLast line  \n";
440        let ctx = LintContext::new(content_with_newline, crate::config::MarkdownFlavor::Standard);
441        let result = rule.check(&ctx).unwrap();
442        // Both lines should preserve br_spaces
443        assert!(result.is_empty());
444    }
445
446    #[test]
447    fn test_single_trailing_space() {
448        let rule = MD009TrailingSpaces::new(2, false);
449        let content = "Line with one space ";
450        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
451        let result = rule.check(&ctx).unwrap();
452        assert_eq!(result.len(), 1);
453        assert_eq!(result[0].message, "Trailing space found");
454    }
455
456    #[test]
457    fn test_tabs_not_spaces() {
458        let rule = MD009TrailingSpaces::default();
459        let content = "Line with tab\t\nLine with spaces  ";
460        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
461        let result = rule.check(&ctx).unwrap();
462        // Only spaces are checked, not tabs
463        assert_eq!(result.len(), 1);
464        assert_eq!(result[0].line, 2);
465    }
466
467    #[test]
468    fn test_mixed_content() {
469        let rule = MD009TrailingSpaces::new(2, false);
470        // Construct content with actual trailing spaces using string concatenation
471        let mut content = String::new();
472        content.push_str("# Heading");
473        content.push_str("   "); // Add 3 trailing spaces (more than br_spaces=2)
474        content.push('\n');
475        content.push_str("Normal paragraph\n> Blockquote\n>\n```\nCode block\n```\n- List item\n");
476
477        let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard);
478        let result = rule.check(&ctx).unwrap();
479        // Should flag the line with trailing spaces
480        assert_eq!(result.len(), 1);
481        assert_eq!(result[0].line, 1);
482        assert!(result[0].message.contains("trailing spaces"));
483    }
484
485    #[test]
486    fn test_column_positions() {
487        let rule = MD009TrailingSpaces::default();
488        let content = "Text   ";
489        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
490        let result = rule.check(&ctx).unwrap();
491        assert_eq!(result.len(), 1);
492        assert_eq!(result[0].column, 5); // After "Text"
493        assert_eq!(result[0].end_column, 8); // After all spaces
494    }
495
496    #[test]
497    fn test_default_config() {
498        let rule = MD009TrailingSpaces::default();
499        let config = rule.default_config_section();
500        assert!(config.is_some());
501        let (name, _value) = config.unwrap();
502        assert_eq!(name, "MD009");
503    }
504
505    #[test]
506    fn test_from_config() {
507        let mut config = crate::config::Config::default();
508        let mut rule_config = crate::config::RuleConfig::default();
509        rule_config
510            .values
511            .insert("br_spaces".to_string(), toml::Value::Integer(3));
512        rule_config
513            .values
514            .insert("strict".to_string(), toml::Value::Boolean(true));
515        config.rules.insert("MD009".to_string(), rule_config);
516
517        let rule = MD009TrailingSpaces::from_config(&config);
518        let content = "Line   ";
519        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
520        let result = rule.check(&ctx).unwrap();
521        assert_eq!(result.len(), 1);
522
523        // In strict mode, should remove all spaces
524        let fixed = rule.fix(&ctx).unwrap();
525        assert_eq!(fixed, "Line");
526    }
527
528    #[test]
529    fn test_list_item_empty_lines() {
530        // Create rule with list_item_empty_lines enabled
531        let config = MD009Config {
532            list_item_empty_lines: true,
533            ..Default::default()
534        };
535        let rule = MD009TrailingSpaces::from_config_struct(config);
536
537        // Test unordered list with empty line
538        let content = "- First item\n  \n- Second item";
539        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
540        let result = rule.check(&ctx).unwrap();
541        // Should not flag the empty line with spaces after list item
542        assert!(result.is_empty());
543
544        // Test ordered list with empty line
545        let content = "1. First item\n  \n2. Second item";
546        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
547        let result = rule.check(&ctx).unwrap();
548        assert!(result.is_empty());
549
550        // Test that non-list empty lines are still flagged
551        let content = "Normal paragraph\n  \nAnother paragraph";
552        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
553        let result = rule.check(&ctx).unwrap();
554        assert_eq!(result.len(), 1);
555        assert_eq!(result[0].line, 2);
556    }
557
558    #[test]
559    fn test_list_item_empty_lines_disabled() {
560        // Default config has list_item_empty_lines disabled
561        let rule = MD009TrailingSpaces::default();
562
563        let content = "- First item\n  \n- Second item";
564        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
565        let result = rule.check(&ctx).unwrap();
566        // Should flag the empty line with spaces
567        assert_eq!(result.len(), 1);
568        assert_eq!(result[0].line, 2);
569    }
570
571    #[test]
572    fn test_performance_large_document() {
573        let rule = MD009TrailingSpaces::default();
574        let mut content = String::new();
575        for i in 0..1000 {
576            content.push_str(&format!("Line {i} with spaces  \n"));
577        }
578        let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard);
579        let result = rule.check(&ctx).unwrap();
580        // Default br_spaces=2, so all lines with 2 spaces are OK
581        assert_eq!(result.len(), 0);
582    }
583
584    #[test]
585    fn test_preserve_content_after_fix() {
586        let rule = MD009TrailingSpaces::new(2, false);
587        let content = "**Bold** text  \n*Italic* text  \n[Link](url)  ";
588        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
589        let fixed = rule.fix(&ctx).unwrap();
590        assert_eq!(fixed, "**Bold** text  \n*Italic* text  \n[Link](url)");
591    }
592
593    #[test]
594    fn test_nested_blockquotes() {
595        let rule = MD009TrailingSpaces::default();
596        let content = "> > Nested  \n> >   \n> Normal  ";
597        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
598        let result = rule.check(&ctx).unwrap();
599        // Line 2 has empty blockquote with 3 spaces, line 3 is last line without newline
600        assert_eq!(result.len(), 2);
601        assert_eq!(result[0].line, 2);
602        assert_eq!(result[1].line, 3);
603
604        let fixed = rule.fix(&ctx).unwrap();
605        // Line 1: Keeps 2 spaces (exact match with br_spaces)
606        // Line 2: Empty blockquote with 3 spaces -> removes all (doesn't match br_spaces)
607        // Line 3: Last line without newline -> removes all spaces
608        assert_eq!(fixed, "> > Nested  \n> >\n> Normal");
609    }
610
611    #[test]
612    fn test_normalized_line_endings() {
613        let rule = MD009TrailingSpaces::default();
614        // In production, content is normalized to LF at I/O boundary
615        let content = "Line with spaces  \nAnother line  ";
616        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
617        let result = rule.check(&ctx).unwrap();
618        // Line 1 has 2 spaces (= br_spaces) so it's OK
619        // Line 2 is last line without newline, so it's flagged
620        assert_eq!(result.len(), 1);
621        assert_eq!(result[0].line, 2);
622    }
623
624    #[test]
625    fn test_issue_80_no_space_normalization() {
626        // Test for GitHub issue #80 - MD009 should not add spaces when removing trailing spaces
627        let rule = MD009TrailingSpaces::new(2, false); // br_spaces=2
628
629        // Test that 1 trailing space is removed, not normalized to 2
630        let content = "Line with one space \nNext line";
631        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
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, "Trailing space found");
636
637        let fixed = rule.fix(&ctx).unwrap();
638        assert_eq!(fixed, "Line with one space\nNext line");
639
640        // Test that 3 trailing spaces are removed, not normalized to 2
641        let content = "Line with three spaces   \nNext line";
642        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
643        let result = rule.check(&ctx).unwrap();
644        assert_eq!(result.len(), 1);
645        assert_eq!(result[0].line, 1);
646        assert_eq!(result[0].message, "3 trailing spaces found");
647
648        let fixed = rule.fix(&ctx).unwrap();
649        assert_eq!(fixed, "Line with three spaces\nNext line");
650
651        // Test that exactly 2 trailing spaces are preserved
652        let content = "Line with two spaces  \nNext line";
653        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
654        let result = rule.check(&ctx).unwrap();
655        assert_eq!(result.len(), 0); // Should not flag lines with exact br_spaces
656
657        let fixed = rule.fix(&ctx).unwrap();
658        assert_eq!(fixed, "Line with two spaces  \nNext line");
659    }
660
661    #[test]
662    fn test_different_br_spaces_values() {
663        // Test with br_spaces=0 (no trailing spaces allowed)
664        let rule = MD009TrailingSpaces::new(0, false);
665        let content = "Line with one space \nLine with two spaces  ";
666        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
667        let result = rule.check(&ctx).unwrap();
668        assert_eq!(result.len(), 2); // Both lines should be flagged
669
670        let fixed = rule.fix(&ctx).unwrap();
671        assert_eq!(fixed, "Line with one space\nLine with two spaces");
672
673        // Test with br_spaces=1 (exactly 1 trailing space for line breaks)
674        let rule = MD009TrailingSpaces::new(1, false);
675        let content = "Line with one space \nLine with two spaces  ";
676        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
677        let result = rule.check(&ctx).unwrap();
678        assert_eq!(result.len(), 1); // Only line 2 should be flagged
679        assert_eq!(result[0].line, 2);
680
681        let fixed = rule.fix(&ctx).unwrap();
682        assert_eq!(fixed, "Line with one space \nLine with two spaces");
683    }
684}