rumdl_lib/rules/
md064_no_multiple_consecutive_spaces.rs

1/// Rule MD064: No multiple consecutive spaces
2///
3/// See [docs/md064.md](../../docs/md064.md) for full documentation, configuration, and examples.
4///
5/// This rule is triggered when multiple consecutive spaces are found in markdown content.
6/// Multiple spaces between words serve no purpose and can indicate formatting issues.
7///
8/// For example:
9///
10/// ```markdown
11/// This is   a sentence with extra spaces.
12/// ```
13///
14/// Should be:
15///
16/// ```markdown
17/// This is a sentence with extra spaces.
18/// ```
19///
20/// This rule does NOT flag:
21/// - Spaces inside inline code spans (`` `code   here` ``)
22/// - Spaces inside fenced or indented code blocks
23/// - Leading whitespace (indentation)
24/// - Trailing whitespace (handled by MD009)
25/// - Spaces inside HTML comments or HTML blocks
26/// - Table rows (alignment padding is intentional)
27/// - Front matter content
28use crate::filtered_lines::FilteredLinesExt;
29use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
30use crate::utils::skip_context::is_table_line;
31use std::sync::Arc;
32
33/// Regex to find multiple consecutive spaces (2 or more)
34use regex::Regex;
35use std::sync::LazyLock;
36
37static MULTIPLE_SPACES_REGEX: LazyLock<Regex> = LazyLock::new(|| {
38    // Match 2 or more consecutive spaces
39    Regex::new(r" {2,}").unwrap()
40});
41
42#[derive(Debug, Clone, Default)]
43pub struct MD064NoMultipleConsecutiveSpaces;
44
45impl MD064NoMultipleConsecutiveSpaces {
46    pub fn new() -> Self {
47        Self
48    }
49
50    /// Check if a byte position is inside an inline code span
51    fn is_in_code_span(&self, code_spans: &[crate::lint_context::CodeSpan], byte_pos: usize) -> bool {
52        code_spans
53            .iter()
54            .any(|span| byte_pos >= span.byte_offset && byte_pos < span.byte_end)
55    }
56
57    /// Check if a match is trailing whitespace at the end of a line
58    /// Trailing spaces are handled by MD009, so MD064 should skip them entirely
59    fn is_trailing_whitespace(&self, line: &str, match_end: usize) -> bool {
60        // If the match extends to the end of the line, it's trailing whitespace
61        let remaining = &line[match_end..];
62        remaining.is_empty() || remaining.chars().all(|c| c == '\n' || c == '\r')
63    }
64
65    /// Check if the match is part of leading indentation
66    fn is_leading_indentation(&self, line: &str, match_start: usize) -> bool {
67        // Check if everything before the match is whitespace
68        line[..match_start].chars().all(|c| c == ' ' || c == '\t')
69    }
70
71    /// Check if the match is immediately after a list marker (handled by MD030)
72    fn is_after_list_marker(&self, line: &str, match_start: usize) -> bool {
73        let before = line[..match_start].trim_start();
74
75        // Unordered list markers: *, -, +
76        if before == "*" || before == "-" || before == "+" {
77            return true;
78        }
79
80        // Ordered list markers: digits followed by . or )
81        // Examples: "1.", "2)", "10.", "123)"
82        if before.len() >= 2 {
83            let last_char = before.chars().last().unwrap();
84            if last_char == '.' || last_char == ')' {
85                let prefix = &before[..before.len() - 1];
86                if !prefix.is_empty() && prefix.chars().all(|c| c.is_ascii_digit()) {
87                    return true;
88                }
89            }
90        }
91
92        false
93    }
94
95    /// Check if the match is immediately after a blockquote marker (handled by MD027)
96    /// Patterns: "> ", ">  ", ">>", "> > "
97    fn is_after_blockquote_marker(&self, line: &str, match_start: usize) -> bool {
98        let before = line[..match_start].trim_start();
99
100        // Check if it's only blockquote markers (> characters, possibly with spaces between)
101        if before.is_empty() {
102            return false;
103        }
104
105        // Pattern: one or more '>' characters, optionally followed by space and more '>'
106        let trimmed = before.trim_end();
107        if trimmed.chars().all(|c| c == '>') {
108            return true;
109        }
110
111        // Pattern: "> " at end (nested blockquote with space)
112        if trimmed.ends_with('>') {
113            let inner = trimmed.trim_end_matches('>').trim();
114            if inner.is_empty() || inner.chars().all(|c| c == '>') {
115                return true;
116            }
117        }
118
119        false
120    }
121
122    /// Check if the space count looks like a tab replacement (multiple of 4)
123    /// Tab replacements (4, 8, 12, etc. spaces) are intentional and should not be collapsed.
124    /// This prevents MD064 from undoing MD010's tab-to-spaces conversion.
125    fn is_tab_replacement_pattern(&self, space_count: usize) -> bool {
126        space_count >= 4 && space_count.is_multiple_of(4)
127    }
128
129    /// Check if the match is inside or after a reference link definition
130    /// Pattern: [label]: URL or [label]:  URL
131    fn is_reference_link_definition(&self, line: &str, match_start: usize) -> bool {
132        let trimmed = line.trim_start();
133
134        // Reference link pattern: [label]: URL
135        if trimmed.starts_with('[')
136            && let Some(bracket_end) = trimmed.find("]:")
137        {
138            let colon_pos = trimmed.len() - trimmed.trim_start().len() + bracket_end + 2;
139            // Check if the match is right after the ]: marker
140            if match_start >= colon_pos - 1 && match_start <= colon_pos + 1 {
141                return true;
142            }
143        }
144
145        false
146    }
147
148    /// Check if the match is after a footnote marker
149    /// Pattern: [^label]:  text
150    fn is_after_footnote_marker(&self, line: &str, match_start: usize) -> bool {
151        let trimmed = line.trim_start();
152
153        // Footnote pattern: [^label]: text
154        if trimmed.starts_with("[^")
155            && let Some(bracket_end) = trimmed.find("]:")
156        {
157            let leading_spaces = line.len() - trimmed.len();
158            let colon_pos = leading_spaces + bracket_end + 2;
159            // Check if the match is right after the ]: marker
160            if match_start >= colon_pos.saturating_sub(1) && match_start <= colon_pos + 1 {
161                return true;
162            }
163        }
164
165        false
166    }
167
168    /// Check if the match is after a definition list marker
169    /// Pattern: :   Definition text
170    fn is_after_definition_marker(&self, line: &str, match_start: usize) -> bool {
171        let before = line[..match_start].trim_start();
172
173        // Definition list marker is just ":"
174        before == ":"
175    }
176
177    /// Check if the match is inside a task list checkbox
178    /// Pattern: - [ ]  or - [x]  (spaces after checkbox)
179    fn is_after_task_checkbox(&self, line: &str, match_start: usize) -> bool {
180        let before = line[..match_start].trim_start();
181
182        // Task list patterns: *, -, + followed by [ ], [x], or [X]
183        // Examples: "- [ ]", "* [x]", "+ [X]"
184        if before.len() >= 4 {
185            let patterns = [
186                "- [ ]", "- [x]", "- [X]", "* [ ]", "* [x]", "* [X]", "+ [ ]", "+ [x]", "+ [X]",
187            ];
188            for pattern in patterns {
189                if before == pattern {
190                    return true;
191                }
192            }
193        }
194
195        false
196    }
197
198    /// Check if this is a table row without outer pipes (GFM extension)
199    /// Pattern: text | text | text (no leading/trailing pipe)
200    fn is_table_without_outer_pipes(&self, line: &str) -> bool {
201        let trimmed = line.trim();
202
203        // Must contain at least one pipe but not start or end with pipe
204        if !trimmed.contains('|') {
205            return false;
206        }
207
208        // If it starts or ends with |, it's a normal table (handled by is_table_line)
209        if trimmed.starts_with('|') || trimmed.ends_with('|') {
210            return false;
211        }
212
213        // Check if it looks like a table row: has multiple pipe-separated cells
214        // Could be data row (word | word) or separator row (--- | ---)
215        // Table cells can be empty, so we just check for at least 2 parts
216        let parts: Vec<&str> = trimmed.split('|').collect();
217        if parts.len() >= 2 {
218            // At least first or last cell should have content (not just whitespace)
219            // to distinguish from accidental pipes in text
220            let first_has_content = !parts.first().unwrap_or(&"").trim().is_empty();
221            let last_has_content = !parts.last().unwrap_or(&"").trim().is_empty();
222            if first_has_content || last_has_content {
223                return true;
224            }
225        }
226
227        false
228    }
229}
230
231impl Rule for MD064NoMultipleConsecutiveSpaces {
232    fn name(&self) -> &'static str {
233        "MD064"
234    }
235
236    fn description(&self) -> &'static str {
237        "Multiple consecutive spaces"
238    }
239
240    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
241        let content = ctx.content;
242
243        // Early return: if no double spaces at all, skip
244        if !content.contains("  ") {
245            return Ok(vec![]);
246        }
247
248        let mut warnings = Vec::new();
249        let code_spans: Arc<Vec<crate::lint_context::CodeSpan>> = ctx.code_spans();
250        let line_index = &ctx.line_index;
251
252        // Process content lines, automatically skipping front matter, code blocks, HTML
253        for line in ctx
254            .filtered_lines()
255            .skip_front_matter()
256            .skip_code_blocks()
257            .skip_html_blocks()
258            .skip_html_comments()
259            .skip_mkdocstrings()
260            .skip_esm_blocks()
261        {
262            // Quick check: skip if line doesn't contain double spaces
263            if !line.content.contains("  ") {
264                continue;
265            }
266
267            // Skip table rows (alignment padding is intentional)
268            if is_table_line(line.content) {
269                continue;
270            }
271
272            // Skip tables without outer pipes (GFM extension)
273            if self.is_table_without_outer_pipes(line.content) {
274                continue;
275            }
276
277            let line_start_byte = line_index.get_line_start_byte(line.line_num).unwrap_or(0);
278
279            // Find all occurrences of multiple consecutive spaces
280            for mat in MULTIPLE_SPACES_REGEX.find_iter(line.content) {
281                let match_start = mat.start();
282                let match_end = mat.end();
283                let space_count = match_end - match_start;
284
285                // Skip if this is leading indentation
286                if self.is_leading_indentation(line.content, match_start) {
287                    continue;
288                }
289
290                // Skip trailing whitespace (handled by MD009)
291                if self.is_trailing_whitespace(line.content, match_end) {
292                    continue;
293                }
294
295                // Skip tab replacement patterns (4, 8, 12, etc. spaces)
296                // This prevents MD064 from undoing MD010's tab-to-spaces conversion
297                if self.is_tab_replacement_pattern(space_count) {
298                    continue;
299                }
300
301                // Skip spaces after list markers (handled by MD030)
302                if self.is_after_list_marker(line.content, match_start) {
303                    continue;
304                }
305
306                // Skip spaces after blockquote markers (handled by MD027)
307                if self.is_after_blockquote_marker(line.content, match_start) {
308                    continue;
309                }
310
311                // Skip spaces after footnote markers
312                if self.is_after_footnote_marker(line.content, match_start) {
313                    continue;
314                }
315
316                // Skip spaces after reference link definition markers
317                if self.is_reference_link_definition(line.content, match_start) {
318                    continue;
319                }
320
321                // Skip spaces after definition list markers
322                if self.is_after_definition_marker(line.content, match_start) {
323                    continue;
324                }
325
326                // Skip spaces after task list checkboxes
327                if self.is_after_task_checkbox(line.content, match_start) {
328                    continue;
329                }
330
331                // Calculate absolute byte position
332                let abs_byte_start = line_start_byte + match_start;
333
334                // Skip if inside an inline code span
335                if self.is_in_code_span(&code_spans, abs_byte_start) {
336                    continue;
337                }
338
339                // Calculate byte range for the fix
340                let abs_byte_end = line_start_byte + match_end;
341
342                warnings.push(LintWarning {
343                    rule_name: Some(self.name().to_string()),
344                    message: format!("Multiple consecutive spaces ({space_count}) found"),
345                    line: line.line_num,
346                    column: match_start + 1, // 1-indexed
347                    end_line: line.line_num,
348                    end_column: match_end + 1, // 1-indexed
349                    severity: Severity::Warning,
350                    fix: Some(Fix {
351                        range: abs_byte_start..abs_byte_end,
352                        replacement: " ".to_string(), // Collapse to single space
353                    }),
354                });
355            }
356        }
357
358        Ok(warnings)
359    }
360
361    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
362        let content = ctx.content;
363
364        // Early return if no double spaces
365        if !content.contains("  ") {
366            return Ok(content.to_string());
367        }
368
369        // Get warnings to identify what needs to be fixed
370        let warnings = self.check(ctx)?;
371        if warnings.is_empty() {
372            return Ok(content.to_string());
373        }
374
375        // Collect all fixes and sort by position (reverse order to avoid position shifts)
376        let mut fixes: Vec<(std::ops::Range<usize>, String)> = warnings
377            .into_iter()
378            .filter_map(|w| w.fix.map(|f| (f.range, f.replacement)))
379            .collect();
380
381        fixes.sort_by_key(|(range, _)| std::cmp::Reverse(range.start));
382
383        // Apply fixes
384        let mut result = content.to_string();
385        for (range, replacement) in fixes {
386            if range.start < result.len() && range.end <= result.len() {
387                result.replace_range(range, &replacement);
388            }
389        }
390
391        Ok(result)
392    }
393
394    /// Get the category of this rule for selective processing
395    fn category(&self) -> RuleCategory {
396        RuleCategory::Whitespace
397    }
398
399    /// Check if this rule should be skipped
400    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
401        ctx.content.is_empty() || !ctx.content.contains("  ")
402    }
403
404    fn as_any(&self) -> &dyn std::any::Any {
405        self
406    }
407
408    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
409    where
410        Self: Sized,
411    {
412        Box::new(MD064NoMultipleConsecutiveSpaces::new())
413    }
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419    use crate::lint_context::LintContext;
420
421    #[test]
422    fn test_basic_multiple_spaces() {
423        let rule = MD064NoMultipleConsecutiveSpaces::new();
424
425        // Should flag multiple spaces
426        let content = "This is   a sentence with extra spaces.";
427        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
428        let result = rule.check(&ctx).unwrap();
429        assert_eq!(result.len(), 1);
430        assert_eq!(result[0].line, 1);
431        assert_eq!(result[0].column, 8); // Position of first extra space
432    }
433
434    #[test]
435    fn test_no_issues_single_spaces() {
436        let rule = MD064NoMultipleConsecutiveSpaces::new();
437
438        // Should not flag single spaces
439        let content = "This is a normal sentence with single spaces.";
440        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
441        let result = rule.check(&ctx).unwrap();
442        assert!(result.is_empty());
443    }
444
445    #[test]
446    fn test_skip_inline_code() {
447        let rule = MD064NoMultipleConsecutiveSpaces::new();
448
449        // Should not flag spaces inside inline code
450        let content = "Use `code   with   spaces` for formatting.";
451        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
452        let result = rule.check(&ctx).unwrap();
453        assert!(result.is_empty());
454    }
455
456    #[test]
457    fn test_skip_code_blocks() {
458        let rule = MD064NoMultipleConsecutiveSpaces::new();
459
460        // Should not flag spaces inside code blocks
461        let content = "# Heading\n\n```\ncode   with   spaces\n```\n\nNormal text.";
462        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
463        let result = rule.check(&ctx).unwrap();
464        assert!(result.is_empty());
465    }
466
467    #[test]
468    fn test_skip_leading_indentation() {
469        let rule = MD064NoMultipleConsecutiveSpaces::new();
470
471        // Should not flag leading indentation
472        let content = "    This is indented text.";
473        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
474        let result = rule.check(&ctx).unwrap();
475        assert!(result.is_empty());
476    }
477
478    #[test]
479    fn test_skip_trailing_spaces() {
480        let rule = MD064NoMultipleConsecutiveSpaces::new();
481
482        // Should not flag trailing spaces (handled by MD009)
483        let content = "Line with trailing spaces   \nNext line.";
484        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
485        let result = rule.check(&ctx).unwrap();
486        assert!(result.is_empty());
487    }
488
489    #[test]
490    fn test_skip_all_trailing_spaces() {
491        let rule = MD064NoMultipleConsecutiveSpaces::new();
492
493        // Should not flag any trailing spaces regardless of count
494        let content = "Two spaces  \nThree spaces   \nFour spaces    \n";
495        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
496        let result = rule.check(&ctx).unwrap();
497        assert!(result.is_empty());
498    }
499
500    #[test]
501    fn test_skip_front_matter() {
502        let rule = MD064NoMultipleConsecutiveSpaces::new();
503
504        // Should not flag spaces in front matter
505        let content = "---\ntitle:   Test   Title\n---\n\nContent here.";
506        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
507        let result = rule.check(&ctx).unwrap();
508        assert!(result.is_empty());
509    }
510
511    #[test]
512    fn test_skip_html_comments() {
513        let rule = MD064NoMultipleConsecutiveSpaces::new();
514
515        // Should not flag spaces in HTML comments
516        let content = "<!-- comment   with   spaces -->\n\nContent here.";
517        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
518        let result = rule.check(&ctx).unwrap();
519        assert!(result.is_empty());
520    }
521
522    #[test]
523    fn test_multiple_issues_one_line() {
524        let rule = MD064NoMultipleConsecutiveSpaces::new();
525
526        // Should flag multiple occurrences on one line
527        let content = "This   has   multiple   issues.";
528        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
529        let result = rule.check(&ctx).unwrap();
530        assert_eq!(result.len(), 3, "Should flag all 3 occurrences");
531    }
532
533    #[test]
534    fn test_fix_collapses_spaces() {
535        let rule = MD064NoMultipleConsecutiveSpaces::new();
536
537        let content = "This is   a sentence   with extra   spaces.";
538        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
539        let fixed = rule.fix(&ctx).unwrap();
540        assert_eq!(fixed, "This is a sentence with extra spaces.");
541    }
542
543    #[test]
544    fn test_fix_preserves_inline_code() {
545        let rule = MD064NoMultipleConsecutiveSpaces::new();
546
547        let content = "Text   here `code   inside` and   more.";
548        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
549        let fixed = rule.fix(&ctx).unwrap();
550        assert_eq!(fixed, "Text here `code   inside` and more.");
551    }
552
553    #[test]
554    fn test_fix_preserves_trailing_spaces() {
555        let rule = MD064NoMultipleConsecutiveSpaces::new();
556
557        // Trailing spaces should be preserved (handled by MD009)
558        let content = "Line with   extra and trailing   \nNext line.";
559        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
560        let fixed = rule.fix(&ctx).unwrap();
561        // Only the internal "   " gets fixed to " ", trailing spaces are preserved
562        assert_eq!(fixed, "Line with extra and trailing   \nNext line.");
563    }
564
565    #[test]
566    fn test_list_items_with_extra_spaces() {
567        let rule = MD064NoMultipleConsecutiveSpaces::new();
568
569        let content = "- Item   one\n- Item   two\n";
570        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
571        let result = rule.check(&ctx).unwrap();
572        assert_eq!(result.len(), 2, "Should flag spaces in list items");
573    }
574
575    #[test]
576    fn test_blockquote_with_extra_spaces_in_content() {
577        let rule = MD064NoMultipleConsecutiveSpaces::new();
578
579        // Extra spaces in blockquote CONTENT should be flagged
580        let content = "> Quote   with extra   spaces\n";
581        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
582        let result = rule.check(&ctx).unwrap();
583        assert_eq!(result.len(), 2, "Should flag spaces in blockquote content");
584    }
585
586    #[test]
587    fn test_skip_blockquote_marker_spaces() {
588        let rule = MD064NoMultipleConsecutiveSpaces::new();
589
590        // Extra spaces after blockquote marker are handled by MD027
591        let content = ">  Text with extra space after marker\n";
592        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
593        let result = rule.check(&ctx).unwrap();
594        assert!(result.is_empty());
595
596        // Three spaces after marker
597        let content = ">   Text with three spaces after marker\n";
598        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
599        let result = rule.check(&ctx).unwrap();
600        assert!(result.is_empty());
601
602        // Nested blockquotes
603        let content = ">>  Nested blockquote\n";
604        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
605        let result = rule.check(&ctx).unwrap();
606        assert!(result.is_empty());
607    }
608
609    #[test]
610    fn test_mixed_content() {
611        let rule = MD064NoMultipleConsecutiveSpaces::new();
612
613        let content = r#"# Heading
614
615This   has extra spaces.
616
617```
618code   here  is  fine
619```
620
621- List   item
622
623> Quote   text
624
625Normal paragraph.
626"#;
627        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
628        let result = rule.check(&ctx).unwrap();
629        // Should flag: "This   has" (1), "List   item" (1), "Quote   text" (1)
630        assert_eq!(result.len(), 3, "Should flag only content outside code blocks");
631    }
632
633    #[test]
634    fn test_multibyte_utf8() {
635        let rule = MD064NoMultipleConsecutiveSpaces::new();
636
637        // Test with multi-byte UTF-8 characters
638        let content = "日本語   テスト   文字列";
639        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
640        let result = rule.check(&ctx);
641        assert!(result.is_ok(), "Should handle multi-byte UTF-8 characters");
642
643        let warnings = result.unwrap();
644        assert_eq!(warnings.len(), 2, "Should find 2 occurrences of multiple spaces");
645    }
646
647    #[test]
648    fn test_table_rows_skipped() {
649        let rule = MD064NoMultipleConsecutiveSpaces::new();
650
651        // Table rows with alignment padding should be skipped
652        let content = "| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1   | Cell 2   |";
653        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
654        let result = rule.check(&ctx).unwrap();
655        // Table rows should be skipped (alignment padding is intentional)
656        assert!(result.is_empty());
657    }
658
659    #[test]
660    fn test_link_text_with_extra_spaces() {
661        let rule = MD064NoMultipleConsecutiveSpaces::new();
662
663        // Link text with extra spaces (should be flagged)
664        let content = "[Link   text](https://example.com)";
665        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
666        let result = rule.check(&ctx).unwrap();
667        assert_eq!(result.len(), 1, "Should flag extra spaces in link text");
668    }
669
670    #[test]
671    fn test_image_alt_with_extra_spaces() {
672        let rule = MD064NoMultipleConsecutiveSpaces::new();
673
674        // Image alt text with extra spaces (should be flagged)
675        let content = "![Alt   text](image.png)";
676        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
677        let result = rule.check(&ctx).unwrap();
678        assert_eq!(result.len(), 1, "Should flag extra spaces in image alt text");
679    }
680
681    #[test]
682    fn test_skip_list_marker_spaces() {
683        let rule = MD064NoMultipleConsecutiveSpaces::new();
684
685        // Spaces after list markers are handled by MD030, not MD064
686        let content = "*   Item with extra spaces after marker\n-   Another item\n+   Third item\n";
687        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
688        let result = rule.check(&ctx).unwrap();
689        assert!(result.is_empty());
690
691        // Ordered list markers
692        let content = "1.  Item one\n2.  Item two\n10. Item ten\n";
693        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
694        let result = rule.check(&ctx).unwrap();
695        assert!(result.is_empty());
696
697        // Indented list items should also be skipped
698        let content = "  *   Indented item\n    1.  Nested numbered item\n";
699        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
700        let result = rule.check(&ctx).unwrap();
701        assert!(result.is_empty());
702    }
703
704    #[test]
705    fn test_flag_spaces_in_list_content() {
706        let rule = MD064NoMultipleConsecutiveSpaces::new();
707
708        // Multiple spaces WITHIN list content should still be flagged
709        let content = "* Item with   extra spaces in content\n";
710        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
711        let result = rule.check(&ctx).unwrap();
712        assert_eq!(result.len(), 1, "Should flag extra spaces in list content");
713    }
714
715    #[test]
716    fn test_skip_reference_link_definition_spaces() {
717        let rule = MD064NoMultipleConsecutiveSpaces::new();
718
719        // Reference link definitions may have multiple spaces after the colon
720        let content = "[ref]:  https://example.com\n";
721        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
722        let result = rule.check(&ctx).unwrap();
723        assert!(result.is_empty());
724
725        // Multiple spaces
726        let content = "[reference-link]:   https://example.com \"Title\"\n";
727        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
728        let result = rule.check(&ctx).unwrap();
729        assert!(result.is_empty());
730    }
731
732    #[test]
733    fn test_skip_footnote_marker_spaces() {
734        let rule = MD064NoMultipleConsecutiveSpaces::new();
735
736        // Footnote definitions may have multiple spaces after the colon
737        let content = "[^1]:  Footnote with extra space\n";
738        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
739        let result = rule.check(&ctx).unwrap();
740        assert!(result.is_empty());
741
742        // Footnote with longer label
743        let content = "[^footnote-label]:   This is the footnote text.\n";
744        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
745        let result = rule.check(&ctx).unwrap();
746        assert!(result.is_empty());
747    }
748
749    #[test]
750    fn test_skip_definition_list_marker_spaces() {
751        let rule = MD064NoMultipleConsecutiveSpaces::new();
752
753        // Definition list markers (PHP Markdown Extra / Pandoc)
754        let content = "Term\n:   Definition with extra spaces\n";
755        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
756        let result = rule.check(&ctx).unwrap();
757        assert!(result.is_empty());
758
759        // Multiple definitions
760        let content = ":    Another definition\n";
761        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
762        let result = rule.check(&ctx).unwrap();
763        assert!(result.is_empty());
764    }
765
766    #[test]
767    fn test_skip_task_list_checkbox_spaces() {
768        let rule = MD064NoMultipleConsecutiveSpaces::new();
769
770        // Task list items may have extra spaces after checkbox
771        let content = "- [ ]  Task with extra space\n";
772        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
773        let result = rule.check(&ctx).unwrap();
774        assert!(result.is_empty());
775
776        // Checked task
777        let content = "- [x]  Completed task\n";
778        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
779        let result = rule.check(&ctx).unwrap();
780        assert!(result.is_empty());
781
782        // With asterisk marker
783        let content = "* [ ]  Task with asterisk marker\n";
784        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
785        let result = rule.check(&ctx).unwrap();
786        assert!(result.is_empty());
787    }
788
789    #[test]
790    fn test_skip_table_without_outer_pipes() {
791        let rule = MD064NoMultipleConsecutiveSpaces::new();
792
793        // GFM tables without outer pipes should be skipped
794        let content = "Col1      | Col2      | Col3\n";
795        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
796        let result = rule.check(&ctx).unwrap();
797        assert!(result.is_empty());
798
799        // Separator row
800        let content = "--------- | --------- | ---------\n";
801        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
802        let result = rule.check(&ctx).unwrap();
803        assert!(result.is_empty());
804
805        // Data row
806        let content = "Data1     | Data2     | Data3\n";
807        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
808        let result = rule.check(&ctx).unwrap();
809        assert!(result.is_empty());
810    }
811
812    #[test]
813    fn test_flag_spaces_in_footnote_content() {
814        let rule = MD064NoMultipleConsecutiveSpaces::new();
815
816        // Extra spaces WITHIN footnote text content should be flagged
817        let content = "[^1]: Footnote with   extra spaces in content.\n";
818        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
819        let result = rule.check(&ctx).unwrap();
820        assert_eq!(result.len(), 1, "Should flag extra spaces in footnote content");
821    }
822
823    #[test]
824    fn test_flag_spaces_in_reference_content() {
825        let rule = MD064NoMultipleConsecutiveSpaces::new();
826
827        // Extra spaces in the title of a reference link should be flagged
828        let content = "[ref]: https://example.com \"Title   with extra spaces\"\n";
829        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
830        let result = rule.check(&ctx).unwrap();
831        assert_eq!(result.len(), 1, "Should flag extra spaces in reference link title");
832    }
833}