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