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