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: if !self.config.strict
242                        && !is_truly_last_line
243                        && has_only_ascii_trailing
244                        && trailing_ascii_spaces == self.config.br_spaces.get()
245                    {
246                        " ".repeat(self.config.br_spaces.get())
247                    } else {
248                        String::new()
249                    },
250                }),
251            });
252        }
253
254        Ok(warnings)
255    }
256
257    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
258        let content = ctx.content;
259
260        // For simple cases (strict mode), use fast regex approach
261        if self.config.strict {
262            // In strict mode, remove ALL trailing whitespace everywhere
263            // Use \p{White_Space} to match Unicode whitespace characters too
264            return Ok(get_cached_regex(r"(?m)[\p{White_Space}&&[^\n\r]]+$")
265                .unwrap()
266                .replace_all(content, "")
267                .to_string());
268        }
269
270        // For complex cases, we need line-by-line processing but with optimizations
271        // Use pre-computed lines since we need to look at previous lines for list item checks
272        let lines = ctx.raw_lines();
273        let mut result = String::with_capacity(content.len()); // Pre-allocate capacity
274
275        for (i, line) in lines.iter().enumerate() {
276            let line_is_ascii = line.is_ascii();
277            // Fast path: check if line has any trailing spaces (ASCII) or
278            // trailing whitespace (Unicode) that we need to handle
279            let needs_processing = if line_is_ascii {
280                line.ends_with(' ')
281            } else {
282                Self::has_trailing_whitespace(line)
283            };
284            if !needs_processing {
285                result.push_str(line);
286                result.push('\n');
287                continue;
288            }
289
290            let trimmed = line.trim_end();
291            // Count ASCII trailing spaces for br_spaces comparison
292            let trailing_ascii_spaces = Self::count_trailing_spaces(line);
293            // Count all trailing whitespace to detect Unicode whitespace presence
294            let trailing_all_whitespace = if line_is_ascii {
295                trailing_ascii_spaces
296            } else {
297                Self::count_trailing_whitespace(line)
298            };
299            // Only consider pure ASCII trailing spaces for br_spaces preservation
300            let has_only_ascii_trailing = trailing_ascii_spaces == trailing_all_whitespace;
301
302            // Handle empty lines - fast regex replacement
303            if trimmed.is_empty() {
304                // Check if this is an empty list item line and config allows it
305                let prev_line = if i > 0 { Some(lines[i - 1]) } else { None };
306                if self.config.list_item_empty_lines && Self::is_empty_list_item_line(line, prev_line) {
307                    result.push_str(line);
308                } else {
309                    // Remove all trailing spaces - line is empty so don't add anything
310                }
311                result.push('\n');
312                continue;
313            }
314
315            // Handle code blocks if not in strict mode
316            if let Some(line_info) = ctx.line_info(i + 1)
317                && line_info.in_code_block
318            {
319                result.push_str(line);
320                result.push('\n');
321                continue;
322            }
323
324            // No special handling for empty blockquote lines - treat them like regular lines
325
326            // Handle lines with trailing spaces
327            let is_truly_last_line = i == lines.len() - 1 && !content.ends_with('\n');
328
329            result.push_str(trimmed);
330
331            // Check if this line is a heading - headings should never have trailing spaces
332            let is_heading = if let Some(line_info) = ctx.line_info(i + 1) {
333                line_info.heading.is_some()
334            } else {
335                // Fallback: check if line starts with #
336                trimmed.starts_with('#')
337            };
338
339            // Check if this is an empty blockquote line (just ">")
340            let is_empty_blockquote = if let Some(line_info) = ctx.line_info(i + 1) {
341                line_info.blockquote.as_ref().is_some_and(|bq| bq.content.is_empty())
342            } else {
343                false
344            };
345
346            // In non-strict mode, preserve line breaks ONLY if they have exactly br_spaces
347            // of pure ASCII trailing spaces (no Unicode whitespace mixed in).
348            // Never preserve trailing spaces in headings or empty blockquotes.
349            if !self.config.strict
350                && !is_truly_last_line
351                && has_only_ascii_trailing
352                && trailing_ascii_spaces == self.config.br_spaces.get()
353                && !is_heading
354                && !is_empty_blockquote
355            {
356                // Preserve the exact number of spaces for hard line breaks
357                match self.config.br_spaces.get() {
358                    0 => {}
359                    1 => result.push(' '),
360                    2 => result.push_str("  "),
361                    n => result.push_str(&" ".repeat(n)),
362                }
363            }
364            result.push('\n');
365        }
366
367        // Preserve original ending (with or without final newline)
368        if !content.ends_with('\n') && result.ends_with('\n') {
369            result.pop();
370        }
371
372        Ok(result)
373    }
374
375    fn as_any(&self) -> &dyn std::any::Any {
376        self
377    }
378
379    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
380        // Skip if content is empty.
381        // We cannot skip based on ASCII-space-only check because Unicode whitespace
382        // characters (e.g., U+2000 EN QUAD) also count as trailing whitespace.
383        // The per-line is_ascii fast path in check()/fix() handles performance.
384        ctx.content.is_empty()
385    }
386
387    fn category(&self) -> RuleCategory {
388        RuleCategory::Whitespace
389    }
390
391    fn default_config_section(&self) -> Option<(String, toml::Value)> {
392        let default_config = MD009Config::default();
393        let json_value = serde_json::to_value(&default_config).ok()?;
394        let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
395
396        if let toml::Value::Table(table) = toml_value {
397            if !table.is_empty() {
398                Some((MD009Config::RULE_NAME.to_string(), toml::Value::Table(table)))
399            } else {
400                None
401            }
402        } else {
403            None
404        }
405    }
406
407    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
408    where
409        Self: Sized,
410    {
411        let rule_config = crate::rule_config_serde::load_rule_config::<MD009Config>(config);
412        Box::new(Self::from_config_struct(rule_config))
413    }
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419    use crate::lint_context::LintContext;
420    use crate::rule::Rule;
421
422    #[test]
423    fn test_no_trailing_spaces() {
424        let rule = MD009TrailingSpaces::default();
425        let content = "This is a line\nAnother line\nNo trailing spaces";
426        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
427        let result = rule.check(&ctx).unwrap();
428        assert!(result.is_empty());
429    }
430
431    #[test]
432    fn test_basic_trailing_spaces() {
433        let rule = MD009TrailingSpaces::default();
434        let content = "Line with spaces   \nAnother line  \nClean line";
435        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
436        let result = rule.check(&ctx).unwrap();
437        // Default br_spaces=2, so line with 2 spaces is OK
438        assert_eq!(result.len(), 1);
439        assert_eq!(result[0].line, 1);
440        assert_eq!(result[0].message, "3 trailing spaces found");
441    }
442
443    #[test]
444    fn test_fix_basic_trailing_spaces() {
445        let rule = MD009TrailingSpaces::default();
446        let content = "Line with spaces   \nAnother line  \nClean line";
447        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
448        let fixed = rule.fix(&ctx).unwrap();
449        // Line 1: 3 spaces -> removed (doesn't match br_spaces=2)
450        // Line 2: 2 spaces -> kept (matches br_spaces=2)
451        // Line 3: no spaces -> unchanged
452        assert_eq!(fixed, "Line with spaces\nAnother line  \nClean line");
453    }
454
455    #[test]
456    fn test_strict_mode() {
457        let rule = MD009TrailingSpaces::new(2, true);
458        let content = "Line with spaces  \nCode block:  \n```  \nCode with spaces  \n```  ";
459        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
460        let result = rule.check(&ctx).unwrap();
461        // In strict mode, all trailing spaces are flagged
462        assert_eq!(result.len(), 5);
463
464        let fixed = rule.fix(&ctx).unwrap();
465        assert_eq!(fixed, "Line with spaces\nCode block:\n```\nCode with spaces\n```");
466    }
467
468    #[test]
469    fn test_non_strict_mode_with_code_blocks() {
470        let rule = MD009TrailingSpaces::new(2, false);
471        let content = "Line with spaces  \n```\nCode with spaces  \n```\nOutside code  ";
472        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
473        let result = rule.check(&ctx).unwrap();
474        // In non-strict mode, code blocks are not checked
475        // Line 1 has 2 spaces (= br_spaces), so it's OK
476        // Line 5 is last line without newline, so trailing spaces are flagged
477        assert_eq!(result.len(), 1);
478        assert_eq!(result[0].line, 5);
479    }
480
481    #[test]
482    fn test_br_spaces_preservation() {
483        let rule = MD009TrailingSpaces::new(2, false);
484        let content = "Line with two spaces  \nLine with three spaces   \nLine with one space ";
485        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
486        let result = rule.check(&ctx).unwrap();
487        // br_spaces=2, so lines with exactly 2 spaces are OK
488        // Line 2 has 3 spaces (should be removed, not normalized)
489        // Line 3 has 1 space and is last line without newline (will be removed)
490        assert_eq!(result.len(), 2);
491        assert_eq!(result[0].line, 2);
492        assert_eq!(result[1].line, 3);
493
494        let fixed = rule.fix(&ctx).unwrap();
495        // Line 1: keeps 2 spaces (exact match with br_spaces)
496        // Line 2: removes all 3 spaces (doesn't match br_spaces)
497        // Line 3: last line without newline, spaces removed
498        assert_eq!(
499            fixed,
500            "Line with two spaces  \nLine with three spaces\nLine with one space"
501        );
502    }
503
504    #[test]
505    fn test_empty_lines_with_spaces() {
506        let rule = MD009TrailingSpaces::default();
507        let content = "Normal line\n   \n  \nAnother line";
508        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
509        let result = rule.check(&ctx).unwrap();
510        assert_eq!(result.len(), 2);
511        assert_eq!(result[0].message, "Empty line has trailing spaces");
512        assert_eq!(result[1].message, "Empty line has trailing spaces");
513
514        let fixed = rule.fix(&ctx).unwrap();
515        assert_eq!(fixed, "Normal line\n\n\nAnother line");
516    }
517
518    #[test]
519    fn test_empty_blockquote_lines() {
520        let rule = MD009TrailingSpaces::default();
521        let content = "> Quote\n>   \n> More quote";
522        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
523        let result = rule.check(&ctx).unwrap();
524        assert_eq!(result.len(), 1);
525        assert_eq!(result[0].line, 2);
526        assert_eq!(result[0].message, "3 trailing spaces found");
527
528        let fixed = rule.fix(&ctx).unwrap();
529        assert_eq!(fixed, "> Quote\n>\n> More quote"); // All trailing spaces removed
530    }
531
532    #[test]
533    fn test_last_line_handling() {
534        let rule = MD009TrailingSpaces::new(2, false);
535
536        // Content without final newline
537        let content = "First line  \nLast line  ";
538        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
539        let result = rule.check(&ctx).unwrap();
540        // Last line without newline should have trailing spaces removed
541        assert_eq!(result.len(), 1);
542        assert_eq!(result[0].line, 2);
543
544        let fixed = rule.fix(&ctx).unwrap();
545        assert_eq!(fixed, "First line  \nLast line");
546
547        // Content with final newline
548        let content_with_newline = "First line  \nLast line  \n";
549        let ctx = LintContext::new(content_with_newline, crate::config::MarkdownFlavor::Standard, None);
550        let result = rule.check(&ctx).unwrap();
551        // Both lines should preserve br_spaces
552        assert!(result.is_empty());
553    }
554
555    #[test]
556    fn test_single_trailing_space() {
557        let rule = MD009TrailingSpaces::new(2, false);
558        let content = "Line with one space ";
559        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
560        let result = rule.check(&ctx).unwrap();
561        assert_eq!(result.len(), 1);
562        assert_eq!(result[0].message, "Trailing space found");
563    }
564
565    #[test]
566    fn test_tabs_not_spaces() {
567        let rule = MD009TrailingSpaces::default();
568        let content = "Line with tab\t\nLine with spaces  ";
569        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
570        let result = rule.check(&ctx).unwrap();
571        // Only spaces are checked, not tabs
572        assert_eq!(result.len(), 1);
573        assert_eq!(result[0].line, 2);
574    }
575
576    #[test]
577    fn test_mixed_content() {
578        let rule = MD009TrailingSpaces::new(2, false);
579        // Construct content with actual trailing spaces using string concatenation
580        let mut content = String::new();
581        content.push_str("# Heading");
582        content.push_str("   "); // Add 3 trailing spaces (more than br_spaces=2)
583        content.push('\n');
584        content.push_str("Normal paragraph\n> Blockquote\n>\n```\nCode block\n```\n- List item\n");
585
586        let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
587        let result = rule.check(&ctx).unwrap();
588        // Should flag the line with trailing spaces
589        assert_eq!(result.len(), 1);
590        assert_eq!(result[0].line, 1);
591        assert!(result[0].message.contains("trailing spaces"));
592    }
593
594    #[test]
595    fn test_column_positions() {
596        let rule = MD009TrailingSpaces::default();
597        let content = "Text   ";
598        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
599        let result = rule.check(&ctx).unwrap();
600        assert_eq!(result.len(), 1);
601        assert_eq!(result[0].column, 5); // After "Text"
602        assert_eq!(result[0].end_column, 8); // After all spaces
603    }
604
605    #[test]
606    fn test_default_config() {
607        let rule = MD009TrailingSpaces::default();
608        let config = rule.default_config_section();
609        assert!(config.is_some());
610        let (name, _value) = config.unwrap();
611        assert_eq!(name, "MD009");
612    }
613
614    #[test]
615    fn test_from_config() {
616        let mut config = crate::config::Config::default();
617        let mut rule_config = crate::config::RuleConfig::default();
618        rule_config
619            .values
620            .insert("br_spaces".to_string(), toml::Value::Integer(3));
621        rule_config
622            .values
623            .insert("strict".to_string(), toml::Value::Boolean(true));
624        config.rules.insert("MD009".to_string(), rule_config);
625
626        let rule = MD009TrailingSpaces::from_config(&config);
627        let content = "Line   ";
628        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
629        let result = rule.check(&ctx).unwrap();
630        assert_eq!(result.len(), 1);
631
632        // In strict mode, should remove all spaces
633        let fixed = rule.fix(&ctx).unwrap();
634        assert_eq!(fixed, "Line");
635    }
636
637    #[test]
638    fn test_list_item_empty_lines() {
639        // Create rule with list_item_empty_lines enabled
640        let config = MD009Config {
641            list_item_empty_lines: true,
642            ..Default::default()
643        };
644        let rule = MD009TrailingSpaces::from_config_struct(config);
645
646        // Test unordered list with empty line
647        let content = "- First item\n  \n- Second item";
648        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
649        let result = rule.check(&ctx).unwrap();
650        // Should not flag the empty line with spaces after list item
651        assert!(result.is_empty());
652
653        // Test ordered list with empty line
654        let content = "1. First item\n  \n2. Second item";
655        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
656        let result = rule.check(&ctx).unwrap();
657        assert!(result.is_empty());
658
659        // Test that non-list empty lines are still flagged
660        let content = "Normal paragraph\n  \nAnother paragraph";
661        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
662        let result = rule.check(&ctx).unwrap();
663        assert_eq!(result.len(), 1);
664        assert_eq!(result[0].line, 2);
665    }
666
667    #[test]
668    fn test_list_item_empty_lines_disabled() {
669        // Default config has list_item_empty_lines disabled
670        let rule = MD009TrailingSpaces::default();
671
672        let content = "- First item\n  \n- Second item";
673        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
674        let result = rule.check(&ctx).unwrap();
675        // Should flag the empty line with spaces
676        assert_eq!(result.len(), 1);
677        assert_eq!(result[0].line, 2);
678    }
679
680    #[test]
681    fn test_performance_large_document() {
682        let rule = MD009TrailingSpaces::default();
683        let mut content = String::new();
684        for i in 0..1000 {
685            content.push_str(&format!("Line {i} with spaces  \n"));
686        }
687        let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
688        let result = rule.check(&ctx).unwrap();
689        // Default br_spaces=2, so all lines with 2 spaces are OK
690        assert_eq!(result.len(), 0);
691    }
692
693    #[test]
694    fn test_preserve_content_after_fix() {
695        let rule = MD009TrailingSpaces::new(2, false);
696        let content = "**Bold** text  \n*Italic* text  \n[Link](url)  ";
697        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
698        let fixed = rule.fix(&ctx).unwrap();
699        assert_eq!(fixed, "**Bold** text  \n*Italic* text  \n[Link](url)");
700    }
701
702    #[test]
703    fn test_nested_blockquotes() {
704        let rule = MD009TrailingSpaces::default();
705        let content = "> > Nested  \n> >   \n> Normal  ";
706        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
707        let result = rule.check(&ctx).unwrap();
708        // Line 2 has empty blockquote with 3 spaces, line 3 is last line without newline
709        assert_eq!(result.len(), 2);
710        assert_eq!(result[0].line, 2);
711        assert_eq!(result[1].line, 3);
712
713        let fixed = rule.fix(&ctx).unwrap();
714        // Line 1: Keeps 2 spaces (exact match with br_spaces)
715        // Line 2: Empty blockquote with 3 spaces -> removes all (doesn't match br_spaces)
716        // Line 3: Last line without newline -> removes all spaces
717        assert_eq!(fixed, "> > Nested  \n> >\n> Normal");
718    }
719
720    #[test]
721    fn test_normalized_line_endings() {
722        let rule = MD009TrailingSpaces::default();
723        // In production, content is normalized to LF at I/O boundary
724        let content = "Line with spaces  \nAnother line  ";
725        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
726        let result = rule.check(&ctx).unwrap();
727        // Line 1 has 2 spaces (= br_spaces) so it's OK
728        // Line 2 is last line without newline, so it's flagged
729        assert_eq!(result.len(), 1);
730        assert_eq!(result[0].line, 2);
731    }
732
733    #[test]
734    fn test_issue_80_no_space_normalization() {
735        // Test for GitHub issue #80 - MD009 should not add spaces when removing trailing spaces
736        let rule = MD009TrailingSpaces::new(2, false); // br_spaces=2
737
738        // Test that 1 trailing space is removed, not normalized to 2
739        let content = "Line with one space \nNext line";
740        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
741        let result = rule.check(&ctx).unwrap();
742        assert_eq!(result.len(), 1);
743        assert_eq!(result[0].line, 1);
744        assert_eq!(result[0].message, "Trailing space found");
745
746        let fixed = rule.fix(&ctx).unwrap();
747        assert_eq!(fixed, "Line with one space\nNext line");
748
749        // Test that 3 trailing spaces are removed, not normalized to 2
750        let content = "Line with three spaces   \nNext line";
751        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
752        let result = rule.check(&ctx).unwrap();
753        assert_eq!(result.len(), 1);
754        assert_eq!(result[0].line, 1);
755        assert_eq!(result[0].message, "3 trailing spaces found");
756
757        let fixed = rule.fix(&ctx).unwrap();
758        assert_eq!(fixed, "Line with three spaces\nNext line");
759
760        // Test that exactly 2 trailing spaces are preserved
761        let content = "Line with two spaces  \nNext line";
762        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
763        let result = rule.check(&ctx).unwrap();
764        assert_eq!(result.len(), 0); // Should not flag lines with exact br_spaces
765
766        let fixed = rule.fix(&ctx).unwrap();
767        assert_eq!(fixed, "Line with two spaces  \nNext line");
768    }
769
770    #[test]
771    fn test_unicode_whitespace_idempotent_fix() {
772        // Regression: U+2000 (EN QUAD) mixed with ASCII space caused non-idempotent fixes.
773        // The fix must strip ALL trailing whitespace (Unicode and ASCII) in one pass.
774        let rule = MD009TrailingSpaces::default(); // br_spaces=2
775
776        // Case from proptest: blockquote with U+2000 and ASCII space
777        let content = "> 0\u{2000} ";
778        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
779        let result = rule.check(&ctx).unwrap();
780        assert_eq!(result.len(), 1, "Should detect trailing Unicode+ASCII whitespace");
781
782        let fixed = rule.fix(&ctx).unwrap();
783        assert_eq!(fixed, "> 0", "Should strip all trailing whitespace in one pass");
784
785        // Verify idempotency: fixing again should produce same result
786        let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
787        let fixed2 = rule.fix(&ctx2).unwrap();
788        assert_eq!(fixed, fixed2, "Fix must be idempotent");
789    }
790
791    #[test]
792    fn test_unicode_whitespace_variants() {
793        let rule = MD009TrailingSpaces::default();
794
795        // U+2000 EN QUAD
796        let content = "text\u{2000}\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+2001 EM QUAD
804        let content = "text\u{2001}\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        // U+3000 IDEOGRAPHIC SPACE
812        let content = "text\u{3000}\n";
813        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
814        let result = rule.check(&ctx).unwrap();
815        assert_eq!(result.len(), 1);
816        let fixed = rule.fix(&ctx).unwrap();
817        assert_eq!(fixed, "text\n");
818
819        // Mixed: Unicode space + ASCII spaces
820        // The trailing 2 ASCII spaces match br_spaces, so they are preserved.
821        // The U+2000 between content and the spaces is removed.
822        let content = "text\u{2000}  \n";
823        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
824        let result = rule.check(&ctx).unwrap();
825        assert_eq!(result.len(), 1, "Unicode+ASCII mix should be flagged");
826        let fixed = rule.fix(&ctx).unwrap();
827        assert_eq!(
828            fixed, "text\n",
829            "All trailing whitespace should be stripped when mix includes Unicode"
830        );
831        // Verify idempotency
832        let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
833        let fixed2 = rule.fix(&ctx2).unwrap();
834        assert_eq!(fixed, fixed2, "Fix must be idempotent");
835
836        // Pure ASCII 2 spaces should still be preserved as br_spaces
837        let content = "text  \nnext\n";
838        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
839        let result = rule.check(&ctx).unwrap();
840        assert_eq!(result.len(), 0, "Pure ASCII br_spaces should still be preserved");
841    }
842
843    #[test]
844    fn test_unicode_whitespace_strict_mode() {
845        let rule = MD009TrailingSpaces::new(2, true);
846
847        // Strict mode should remove all Unicode whitespace too
848        let content = "text\u{2000}\nmore\u{3000}\n";
849        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
850        let fixed = rule.fix(&ctx).unwrap();
851        assert_eq!(fixed, "text\nmore\n");
852    }
853}