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