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