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