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