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