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::rule_config_serde::RuleConfig;
31use crate::utils::skip_context::is_table_line;
32use serde::{Deserialize, Serialize};
33use std::sync::Arc;
34
35/// Regex to find multiple consecutive spaces (2 or more)
36use regex::Regex;
37use std::sync::LazyLock;
38
39static MULTIPLE_SPACES_REGEX: LazyLock<Regex> = LazyLock::new(|| {
40    // Match 2 or more consecutive spaces
41    Regex::new(r" {2,}").unwrap()
42});
43
44/// Configuration for MD064 (No multiple consecutive spaces)
45#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
46#[serde(rename_all = "kebab-case")]
47pub struct MD064Config {
48    /// Maximum allowed consecutive spaces (default: 1)
49    ///
50    /// When set to 1 (default), any occurrence of 2+ consecutive spaces is flagged.
51    /// When set to 2, allows exactly 2 consecutive spaces (useful for two-space
52    /// sentence spacing convention) but flags 3+ spaces.
53    ///
54    /// Example with `max-consecutive-spaces = 2`:
55    /// ```markdown
56    /// This is fine.  Two spaces here.   <- 3 spaces flagged
57    /// ```
58    #[serde(default = "default_max_consecutive_spaces", alias = "max_consecutive_spaces")]
59    pub max_consecutive_spaces: usize,
60}
61
62fn default_max_consecutive_spaces() -> usize {
63    1
64}
65
66impl Default for MD064Config {
67    fn default() -> Self {
68        Self {
69            max_consecutive_spaces: default_max_consecutive_spaces(),
70        }
71    }
72}
73
74impl RuleConfig for MD064Config {
75    const RULE_NAME: &'static str = "MD064";
76}
77
78#[derive(Debug, Clone)]
79pub struct MD064NoMultipleConsecutiveSpaces {
80    config: MD064Config,
81}
82
83impl Default for MD064NoMultipleConsecutiveSpaces {
84    fn default() -> Self {
85        Self::new()
86    }
87}
88
89impl MD064NoMultipleConsecutiveSpaces {
90    pub fn new() -> Self {
91        Self {
92            config: MD064Config::default(),
93        }
94    }
95
96    pub fn from_config_struct(config: MD064Config) -> Self {
97        Self { config }
98    }
99
100    /// Check if a byte position is inside an inline code span
101    fn is_in_code_span(&self, code_spans: &[crate::lint_context::CodeSpan], byte_pos: usize) -> bool {
102        code_spans
103            .iter()
104            .any(|span| byte_pos >= span.byte_offset && byte_pos < span.byte_end)
105    }
106
107    /// Check if a match is trailing whitespace at the end of a line
108    /// Trailing spaces are handled by MD009, so MD064 should skip them entirely
109    fn is_trailing_whitespace(&self, line: &str, match_end: usize) -> bool {
110        // If the match extends to the end of the line, it's trailing whitespace
111        let remaining = &line[match_end..];
112        remaining.is_empty() || remaining.chars().all(|c| c == '\n' || c == '\r')
113    }
114
115    /// Check if the match is part of leading indentation
116    fn is_leading_indentation(&self, line: &str, match_start: usize) -> bool {
117        // Check if everything before the match is whitespace
118        line[..match_start].chars().all(|c| c == ' ' || c == '\t')
119    }
120
121    /// Check if the match is immediately after a list marker (handled by MD030)
122    fn is_after_list_marker(&self, line: &str, match_start: usize) -> bool {
123        let before = line[..match_start].trim_start();
124
125        // Unordered list markers: *, -, +
126        if before == "*" || before == "-" || before == "+" {
127            return true;
128        }
129
130        // Ordered list markers: digits followed by . or )
131        // Examples: "1.", "2)", "10.", "123)"
132        if before.len() >= 2 {
133            let last_char = before.chars().last().unwrap();
134            if last_char == '.' || last_char == ')' {
135                let prefix = &before[..before.len() - 1];
136                if !prefix.is_empty() && prefix.chars().all(|c| c.is_ascii_digit()) {
137                    return true;
138                }
139            }
140        }
141
142        false
143    }
144
145    /// Check if the match is immediately after a blockquote marker (handled by MD027)
146    /// Patterns: "> ", ">  ", ">>", "> > "
147    fn is_after_blockquote_marker(&self, line: &str, match_start: usize) -> bool {
148        let before = line[..match_start].trim_start();
149
150        // Check if it's only blockquote markers (> characters, possibly with spaces between)
151        if before.is_empty() {
152            return false;
153        }
154
155        // Pattern: one or more '>' characters, optionally followed by space and more '>'
156        let trimmed = before.trim_end();
157        if trimmed.chars().all(|c| c == '>') {
158            return true;
159        }
160
161        // Pattern: "> " at end (nested blockquote with space)
162        if trimmed.ends_with('>') {
163            let inner = trimmed.trim_end_matches('>').trim();
164            if inner.is_empty() || inner.chars().all(|c| c == '>') {
165                return true;
166            }
167        }
168
169        false
170    }
171
172    /// Check if the space count looks like a tab replacement (multiple of 4)
173    /// Tab replacements (4, 8, 12, etc. spaces) are intentional and should not be collapsed.
174    /// This prevents MD064 from undoing MD010's tab-to-spaces conversion.
175    fn is_tab_replacement_pattern(&self, space_count: usize) -> bool {
176        space_count >= 4 && space_count.is_multiple_of(4)
177    }
178
179    /// Check if the match is inside or after a reference link definition
180    /// Pattern: [label]: URL or [label]:  URL
181    fn is_reference_link_definition(&self, line: &str, match_start: usize) -> bool {
182        let trimmed = line.trim_start();
183
184        // Reference link pattern: [label]: URL
185        if trimmed.starts_with('[')
186            && let Some(bracket_end) = trimmed.find("]:")
187        {
188            let colon_pos = trimmed.len() - trimmed.trim_start().len() + bracket_end + 2;
189            // Check if the match is right after the ]: marker
190            if match_start >= colon_pos - 1 && match_start <= colon_pos + 1 {
191                return true;
192            }
193        }
194
195        false
196    }
197
198    /// Check if the match is after a footnote marker
199    /// Pattern: [^label]:  text
200    fn is_after_footnote_marker(&self, line: &str, match_start: usize) -> bool {
201        let trimmed = line.trim_start();
202
203        // Footnote pattern: [^label]: text
204        if trimmed.starts_with("[^")
205            && let Some(bracket_end) = trimmed.find("]:")
206        {
207            let leading_spaces = line.len() - trimmed.len();
208            let colon_pos = leading_spaces + bracket_end + 2;
209            // Check if the match is right after the ]: marker
210            if match_start >= colon_pos.saturating_sub(1) && match_start <= colon_pos + 1 {
211                return true;
212            }
213        }
214
215        false
216    }
217
218    /// Check if the match is after a definition list marker
219    /// Pattern: :   Definition text
220    fn is_after_definition_marker(&self, line: &str, match_start: usize) -> bool {
221        let before = line[..match_start].trim_start();
222
223        // Definition list marker is just ":"
224        before == ":"
225    }
226
227    /// Check if the match is inside a task list checkbox
228    /// Pattern: - [ ]  or - [x]  (spaces after checkbox)
229    fn is_after_task_checkbox(&self, line: &str, match_start: usize) -> bool {
230        let before = line[..match_start].trim_start();
231
232        // Task list patterns: *, -, + followed by [ ], [x], or [X]
233        // Examples: "- [ ]", "* [x]", "+ [X]"
234        if before.len() >= 4 {
235            let patterns = [
236                "- [ ]", "- [x]", "- [X]", "* [ ]", "* [x]", "* [X]", "+ [ ]", "+ [x]", "+ [X]",
237            ];
238            for pattern in patterns {
239                if before == pattern {
240                    return true;
241                }
242            }
243        }
244
245        false
246    }
247
248    /// Check if this is a table row without outer pipes (GFM extension)
249    /// Pattern: text | text | text (no leading/trailing pipe)
250    fn is_table_without_outer_pipes(&self, line: &str) -> bool {
251        let trimmed = line.trim();
252
253        // Must contain at least one pipe but not start or end with pipe
254        if !trimmed.contains('|') {
255            return false;
256        }
257
258        // If it starts or ends with |, it's a normal table (handled by is_table_line)
259        if trimmed.starts_with('|') || trimmed.ends_with('|') {
260            return false;
261        }
262
263        // Check if it looks like a table row: has multiple pipe-separated cells
264        // Could be data row (word | word) or separator row (--- | ---)
265        // Table cells can be empty, so we just check for at least 2 parts
266        let parts: Vec<&str> = trimmed.split('|').collect();
267        if parts.len() >= 2 {
268            // At least first or last cell should have content (not just whitespace)
269            // to distinguish from accidental pipes in text
270            let first_has_content = !parts.first().unwrap_or(&"").trim().is_empty();
271            let last_has_content = !parts.last().unwrap_or(&"").trim().is_empty();
272            if first_has_content || last_has_content {
273                return true;
274            }
275        }
276
277        false
278    }
279}
280
281impl Rule for MD064NoMultipleConsecutiveSpaces {
282    fn name(&self) -> &'static str {
283        "MD064"
284    }
285
286    fn description(&self) -> &'static str {
287        "Multiple consecutive spaces"
288    }
289
290    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
291        let content = ctx.content;
292
293        // Early return: if no double spaces at all, skip
294        if !content.contains("  ") {
295            return Ok(vec![]);
296        }
297
298        let mut warnings = Vec::new();
299        let code_spans: Arc<Vec<crate::lint_context::CodeSpan>> = ctx.code_spans();
300        let line_index = &ctx.line_index;
301
302        // Process content lines, automatically skipping front matter, code blocks, HTML
303        for line in ctx
304            .filtered_lines()
305            .skip_front_matter()
306            .skip_code_blocks()
307            .skip_html_blocks()
308            .skip_html_comments()
309            .skip_mkdocstrings()
310            .skip_esm_blocks()
311        {
312            // Quick check: skip if line doesn't contain double spaces
313            if !line.content.contains("  ") {
314                continue;
315            }
316
317            // Skip table rows (alignment padding is intentional)
318            if is_table_line(line.content) {
319                continue;
320            }
321
322            // Skip tables without outer pipes (GFM extension)
323            if self.is_table_without_outer_pipes(line.content) {
324                continue;
325            }
326
327            let line_start_byte = line_index.get_line_start_byte(line.line_num).unwrap_or(0);
328
329            // Find all occurrences of multiple consecutive spaces
330            for mat in MULTIPLE_SPACES_REGEX.find_iter(line.content) {
331                let match_start = mat.start();
332                let match_end = mat.end();
333                let space_count = match_end - match_start;
334
335                // Skip if space count is within the allowed threshold
336                if space_count <= self.config.max_consecutive_spaces {
337                    continue;
338                }
339
340                // Skip if this is leading indentation
341                if self.is_leading_indentation(line.content, match_start) {
342                    continue;
343                }
344
345                // Skip trailing whitespace (handled by MD009)
346                if self.is_trailing_whitespace(line.content, match_end) {
347                    continue;
348                }
349
350                // Skip tab replacement patterns (4, 8, 12, etc. spaces)
351                // This prevents MD064 from undoing MD010's tab-to-spaces conversion
352                if self.is_tab_replacement_pattern(space_count) {
353                    continue;
354                }
355
356                // Skip spaces after list markers (handled by MD030)
357                if self.is_after_list_marker(line.content, match_start) {
358                    continue;
359                }
360
361                // Skip spaces after blockquote markers (handled by MD027)
362                if self.is_after_blockquote_marker(line.content, match_start) {
363                    continue;
364                }
365
366                // Skip spaces after footnote markers
367                if self.is_after_footnote_marker(line.content, match_start) {
368                    continue;
369                }
370
371                // Skip spaces after reference link definition markers
372                if self.is_reference_link_definition(line.content, match_start) {
373                    continue;
374                }
375
376                // Skip spaces after definition list markers
377                if self.is_after_definition_marker(line.content, match_start) {
378                    continue;
379                }
380
381                // Skip spaces after task list checkboxes
382                if self.is_after_task_checkbox(line.content, match_start) {
383                    continue;
384                }
385
386                // Calculate absolute byte position
387                let abs_byte_start = line_start_byte + match_start;
388
389                // Skip if inside an inline code span
390                if self.is_in_code_span(&code_spans, abs_byte_start) {
391                    continue;
392                }
393
394                // Calculate byte range for the fix
395                let abs_byte_end = line_start_byte + match_end;
396
397                warnings.push(LintWarning {
398                    rule_name: Some(self.name().to_string()),
399                    message: format!("Multiple consecutive spaces ({space_count}) found"),
400                    line: line.line_num,
401                    column: match_start + 1, // 1-indexed
402                    end_line: line.line_num,
403                    end_column: match_end + 1, // 1-indexed
404                    severity: Severity::Warning,
405                    fix: Some(Fix {
406                        range: abs_byte_start..abs_byte_end,
407                        replacement: " ".to_string(), // Collapse to single space
408                    }),
409                });
410            }
411        }
412
413        Ok(warnings)
414    }
415
416    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
417        let content = ctx.content;
418
419        // Early return if no double spaces
420        if !content.contains("  ") {
421            return Ok(content.to_string());
422        }
423
424        // Get warnings to identify what needs to be fixed
425        let warnings = self.check(ctx)?;
426        if warnings.is_empty() {
427            return Ok(content.to_string());
428        }
429
430        // Collect all fixes and sort by position (reverse order to avoid position shifts)
431        let mut fixes: Vec<(std::ops::Range<usize>, String)> = warnings
432            .into_iter()
433            .filter_map(|w| w.fix.map(|f| (f.range, f.replacement)))
434            .collect();
435
436        fixes.sort_by_key(|(range, _)| std::cmp::Reverse(range.start));
437
438        // Apply fixes
439        let mut result = content.to_string();
440        for (range, replacement) in fixes {
441            if range.start < result.len() && range.end <= result.len() {
442                result.replace_range(range, &replacement);
443            }
444        }
445
446        Ok(result)
447    }
448
449    /// Get the category of this rule for selective processing
450    fn category(&self) -> RuleCategory {
451        RuleCategory::Whitespace
452    }
453
454    /// Check if this rule should be skipped
455    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
456        ctx.content.is_empty() || !ctx.content.contains("  ")
457    }
458
459    fn as_any(&self) -> &dyn std::any::Any {
460        self
461    }
462
463    fn default_config_section(&self) -> Option<(String, toml::Value)> {
464        let default_config = MD064Config::default();
465        let json_value = serde_json::to_value(&default_config).ok()?;
466        let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
467
468        if let toml::Value::Table(table) = toml_value {
469            if !table.is_empty() {
470                Some((MD064Config::RULE_NAME.to_string(), toml::Value::Table(table)))
471            } else {
472                None
473            }
474        } else {
475            None
476        }
477    }
478
479    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
480    where
481        Self: Sized,
482    {
483        let rule_config = crate::rule_config_serde::load_rule_config::<MD064Config>(config);
484        Box::new(MD064NoMultipleConsecutiveSpaces::from_config_struct(rule_config))
485    }
486}
487
488#[cfg(test)]
489mod tests {
490    use super::*;
491    use crate::lint_context::LintContext;
492
493    #[test]
494    fn test_basic_multiple_spaces() {
495        let rule = MD064NoMultipleConsecutiveSpaces::new();
496
497        // Should flag multiple spaces
498        let content = "This is   a sentence with extra spaces.";
499        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
500        let result = rule.check(&ctx).unwrap();
501        assert_eq!(result.len(), 1);
502        assert_eq!(result[0].line, 1);
503        assert_eq!(result[0].column, 8); // Position of first extra space
504    }
505
506    #[test]
507    fn test_no_issues_single_spaces() {
508        let rule = MD064NoMultipleConsecutiveSpaces::new();
509
510        // Should not flag single spaces
511        let content = "This is a normal sentence with single spaces.";
512        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
513        let result = rule.check(&ctx).unwrap();
514        assert!(result.is_empty());
515    }
516
517    #[test]
518    fn test_skip_inline_code() {
519        let rule = MD064NoMultipleConsecutiveSpaces::new();
520
521        // Should not flag spaces inside inline code
522        let content = "Use `code   with   spaces` for formatting.";
523        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
524        let result = rule.check(&ctx).unwrap();
525        assert!(result.is_empty());
526    }
527
528    #[test]
529    fn test_skip_code_blocks() {
530        let rule = MD064NoMultipleConsecutiveSpaces::new();
531
532        // Should not flag spaces inside code blocks
533        let content = "# Heading\n\n```\ncode   with   spaces\n```\n\nNormal text.";
534        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
535        let result = rule.check(&ctx).unwrap();
536        assert!(result.is_empty());
537    }
538
539    #[test]
540    fn test_skip_leading_indentation() {
541        let rule = MD064NoMultipleConsecutiveSpaces::new();
542
543        // Should not flag leading indentation
544        let content = "    This is indented text.";
545        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
546        let result = rule.check(&ctx).unwrap();
547        assert!(result.is_empty());
548    }
549
550    #[test]
551    fn test_skip_trailing_spaces() {
552        let rule = MD064NoMultipleConsecutiveSpaces::new();
553
554        // Should not flag trailing spaces (handled by MD009)
555        let content = "Line with trailing spaces   \nNext line.";
556        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
557        let result = rule.check(&ctx).unwrap();
558        assert!(result.is_empty());
559    }
560
561    #[test]
562    fn test_skip_all_trailing_spaces() {
563        let rule = MD064NoMultipleConsecutiveSpaces::new();
564
565        // Should not flag any trailing spaces regardless of count
566        let content = "Two spaces  \nThree spaces   \nFour spaces    \n";
567        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
568        let result = rule.check(&ctx).unwrap();
569        assert!(result.is_empty());
570    }
571
572    #[test]
573    fn test_skip_front_matter() {
574        let rule = MD064NoMultipleConsecutiveSpaces::new();
575
576        // Should not flag spaces in front matter
577        let content = "---\ntitle:   Test   Title\n---\n\nContent here.";
578        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
579        let result = rule.check(&ctx).unwrap();
580        assert!(result.is_empty());
581    }
582
583    #[test]
584    fn test_skip_html_comments() {
585        let rule = MD064NoMultipleConsecutiveSpaces::new();
586
587        // Should not flag spaces in HTML comments
588        let content = "<!-- comment   with   spaces -->\n\nContent here.";
589        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
590        let result = rule.check(&ctx).unwrap();
591        assert!(result.is_empty());
592    }
593
594    #[test]
595    fn test_multiple_issues_one_line() {
596        let rule = MD064NoMultipleConsecutiveSpaces::new();
597
598        // Should flag multiple occurrences on one line
599        let content = "This   has   multiple   issues.";
600        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
601        let result = rule.check(&ctx).unwrap();
602        assert_eq!(result.len(), 3, "Should flag all 3 occurrences");
603    }
604
605    #[test]
606    fn test_fix_collapses_spaces() {
607        let rule = MD064NoMultipleConsecutiveSpaces::new();
608
609        let content = "This is   a sentence   with extra   spaces.";
610        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
611        let fixed = rule.fix(&ctx).unwrap();
612        assert_eq!(fixed, "This is a sentence with extra spaces.");
613    }
614
615    #[test]
616    fn test_fix_preserves_inline_code() {
617        let rule = MD064NoMultipleConsecutiveSpaces::new();
618
619        let content = "Text   here `code   inside` and   more.";
620        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
621        let fixed = rule.fix(&ctx).unwrap();
622        assert_eq!(fixed, "Text here `code   inside` and more.");
623    }
624
625    #[test]
626    fn test_fix_preserves_trailing_spaces() {
627        let rule = MD064NoMultipleConsecutiveSpaces::new();
628
629        // Trailing spaces should be preserved (handled by MD009)
630        let content = "Line with   extra and trailing   \nNext line.";
631        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
632        let fixed = rule.fix(&ctx).unwrap();
633        // Only the internal "   " gets fixed to " ", trailing spaces are preserved
634        assert_eq!(fixed, "Line with extra and trailing   \nNext line.");
635    }
636
637    #[test]
638    fn test_list_items_with_extra_spaces() {
639        let rule = MD064NoMultipleConsecutiveSpaces::new();
640
641        let content = "- Item   one\n- Item   two\n";
642        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
643        let result = rule.check(&ctx).unwrap();
644        assert_eq!(result.len(), 2, "Should flag spaces in list items");
645    }
646
647    #[test]
648    fn test_blockquote_with_extra_spaces_in_content() {
649        let rule = MD064NoMultipleConsecutiveSpaces::new();
650
651        // Extra spaces in blockquote CONTENT should be flagged
652        let content = "> Quote   with extra   spaces\n";
653        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
654        let result = rule.check(&ctx).unwrap();
655        assert_eq!(result.len(), 2, "Should flag spaces in blockquote content");
656    }
657
658    #[test]
659    fn test_skip_blockquote_marker_spaces() {
660        let rule = MD064NoMultipleConsecutiveSpaces::new();
661
662        // Extra spaces after blockquote marker are handled by MD027
663        let content = ">  Text with extra space after marker\n";
664        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
665        let result = rule.check(&ctx).unwrap();
666        assert!(result.is_empty());
667
668        // Three spaces after marker
669        let content = ">   Text with three spaces after marker\n";
670        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
671        let result = rule.check(&ctx).unwrap();
672        assert!(result.is_empty());
673
674        // Nested blockquotes
675        let content = ">>  Nested blockquote\n";
676        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
677        let result = rule.check(&ctx).unwrap();
678        assert!(result.is_empty());
679    }
680
681    #[test]
682    fn test_mixed_content() {
683        let rule = MD064NoMultipleConsecutiveSpaces::new();
684
685        let content = r#"# Heading
686
687This   has extra spaces.
688
689```
690code   here  is  fine
691```
692
693- List   item
694
695> Quote   text
696
697Normal paragraph.
698"#;
699        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
700        let result = rule.check(&ctx).unwrap();
701        // Should flag: "This   has" (1), "List   item" (1), "Quote   text" (1)
702        assert_eq!(result.len(), 3, "Should flag only content outside code blocks");
703    }
704
705    #[test]
706    fn test_multibyte_utf8() {
707        let rule = MD064NoMultipleConsecutiveSpaces::new();
708
709        // Test with multi-byte UTF-8 characters
710        let content = "日本語   テスト   文字列";
711        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
712        let result = rule.check(&ctx);
713        assert!(result.is_ok(), "Should handle multi-byte UTF-8 characters");
714
715        let warnings = result.unwrap();
716        assert_eq!(warnings.len(), 2, "Should find 2 occurrences of multiple spaces");
717    }
718
719    #[test]
720    fn test_table_rows_skipped() {
721        let rule = MD064NoMultipleConsecutiveSpaces::new();
722
723        // Table rows with alignment padding should be skipped
724        let content = "| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1   | Cell 2   |";
725        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
726        let result = rule.check(&ctx).unwrap();
727        // Table rows should be skipped (alignment padding is intentional)
728        assert!(result.is_empty());
729    }
730
731    #[test]
732    fn test_link_text_with_extra_spaces() {
733        let rule = MD064NoMultipleConsecutiveSpaces::new();
734
735        // Link text with extra spaces (should be flagged)
736        let content = "[Link   text](https://example.com)";
737        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
738        let result = rule.check(&ctx).unwrap();
739        assert_eq!(result.len(), 1, "Should flag extra spaces in link text");
740    }
741
742    #[test]
743    fn test_image_alt_with_extra_spaces() {
744        let rule = MD064NoMultipleConsecutiveSpaces::new();
745
746        // Image alt text with extra spaces (should be flagged)
747        let content = "![Alt   text](image.png)";
748        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
749        let result = rule.check(&ctx).unwrap();
750        assert_eq!(result.len(), 1, "Should flag extra spaces in image alt text");
751    }
752
753    #[test]
754    fn test_skip_list_marker_spaces() {
755        let rule = MD064NoMultipleConsecutiveSpaces::new();
756
757        // Spaces after list markers are handled by MD030, not MD064
758        let content = "*   Item with extra spaces after marker\n-   Another item\n+   Third item\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        // Ordered list markers
764        let content = "1.  Item one\n2.  Item two\n10. Item ten\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        // Indented list items should also be skipped
770        let content = "  *   Indented item\n    1.  Nested numbered item\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_flag_spaces_in_list_content() {
778        let rule = MD064NoMultipleConsecutiveSpaces::new();
779
780        // Multiple spaces WITHIN list content should still be flagged
781        let content = "* Item with   extra spaces in content\n";
782        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
783        let result = rule.check(&ctx).unwrap();
784        assert_eq!(result.len(), 1, "Should flag extra spaces in list content");
785    }
786
787    #[test]
788    fn test_skip_reference_link_definition_spaces() {
789        let rule = MD064NoMultipleConsecutiveSpaces::new();
790
791        // Reference link definitions may have multiple spaces after the colon
792        let content = "[ref]:  https://example.com\n";
793        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
794        let result = rule.check(&ctx).unwrap();
795        assert!(result.is_empty());
796
797        // Multiple spaces
798        let content = "[reference-link]:   https://example.com \"Title\"\n";
799        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
800        let result = rule.check(&ctx).unwrap();
801        assert!(result.is_empty());
802    }
803
804    #[test]
805    fn test_skip_footnote_marker_spaces() {
806        let rule = MD064NoMultipleConsecutiveSpaces::new();
807
808        // Footnote definitions may have multiple spaces after the colon
809        let content = "[^1]:  Footnote with extra space\n";
810        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
811        let result = rule.check(&ctx).unwrap();
812        assert!(result.is_empty());
813
814        // Footnote with longer label
815        let content = "[^footnote-label]:   This is the footnote text.\n";
816        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
817        let result = rule.check(&ctx).unwrap();
818        assert!(result.is_empty());
819    }
820
821    #[test]
822    fn test_skip_definition_list_marker_spaces() {
823        let rule = MD064NoMultipleConsecutiveSpaces::new();
824
825        // Definition list markers (PHP Markdown Extra / Pandoc)
826        let content = "Term\n:   Definition with extra spaces\n";
827        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
828        let result = rule.check(&ctx).unwrap();
829        assert!(result.is_empty());
830
831        // Multiple definitions
832        let content = ":    Another definition\n";
833        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
834        let result = rule.check(&ctx).unwrap();
835        assert!(result.is_empty());
836    }
837
838    #[test]
839    fn test_skip_task_list_checkbox_spaces() {
840        let rule = MD064NoMultipleConsecutiveSpaces::new();
841
842        // Task list items may have extra spaces after checkbox
843        let content = "- [ ]  Task with extra space\n";
844        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
845        let result = rule.check(&ctx).unwrap();
846        assert!(result.is_empty());
847
848        // Checked task
849        let content = "- [x]  Completed task\n";
850        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
851        let result = rule.check(&ctx).unwrap();
852        assert!(result.is_empty());
853
854        // With asterisk marker
855        let content = "* [ ]  Task with asterisk marker\n";
856        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
857        let result = rule.check(&ctx).unwrap();
858        assert!(result.is_empty());
859    }
860
861    #[test]
862    fn test_skip_table_without_outer_pipes() {
863        let rule = MD064NoMultipleConsecutiveSpaces::new();
864
865        // GFM tables without outer pipes should be skipped
866        let content = "Col1      | Col2      | Col3\n";
867        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
868        let result = rule.check(&ctx).unwrap();
869        assert!(result.is_empty());
870
871        // Separator row
872        let content = "--------- | --------- | ---------\n";
873        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
874        let result = rule.check(&ctx).unwrap();
875        assert!(result.is_empty());
876
877        // Data row
878        let content = "Data1     | Data2     | Data3\n";
879        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
880        let result = rule.check(&ctx).unwrap();
881        assert!(result.is_empty());
882    }
883
884    #[test]
885    fn test_flag_spaces_in_footnote_content() {
886        let rule = MD064NoMultipleConsecutiveSpaces::new();
887
888        // Extra spaces WITHIN footnote text content should be flagged
889        let content = "[^1]: Footnote with   extra spaces in content.\n";
890        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
891        let result = rule.check(&ctx).unwrap();
892        assert_eq!(result.len(), 1, "Should flag extra spaces in footnote content");
893    }
894
895    #[test]
896    fn test_flag_spaces_in_reference_content() {
897        let rule = MD064NoMultipleConsecutiveSpaces::new();
898
899        // Extra spaces in the title of a reference link should be flagged
900        let content = "[ref]: https://example.com \"Title   with extra spaces\"\n";
901        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
902        let result = rule.check(&ctx).unwrap();
903        assert_eq!(result.len(), 1, "Should flag extra spaces in reference link title");
904    }
905
906    // Config tests
907
908    #[test]
909    fn test_default_config() {
910        let config = MD064Config::default();
911        assert_eq!(config.max_consecutive_spaces, 1);
912    }
913
914    #[test]
915    fn test_config_kebab_case() {
916        let toml_str = r#"
917            max-consecutive-spaces = 2
918        "#;
919        let config: MD064Config = toml::from_str(toml_str).unwrap();
920        assert_eq!(config.max_consecutive_spaces, 2);
921    }
922
923    #[test]
924    fn test_config_snake_case_backwards_compatibility() {
925        let toml_str = r#"
926            max_consecutive_spaces = 2
927        "#;
928        let config: MD064Config = toml::from_str(toml_str).unwrap();
929        assert_eq!(config.max_consecutive_spaces, 2);
930    }
931
932    #[test]
933    fn test_max_consecutive_spaces_two_allows_double_spaces() {
934        // With max_consecutive_spaces = 2, double spaces should be allowed
935        let config = MD064Config {
936            max_consecutive_spaces: 2,
937        };
938        let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
939
940        let content = "This is fine.  Two spaces between sentences.";
941        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
942        let result = rule.check(&ctx).unwrap();
943        assert!(result.is_empty(), "Double spaces should be allowed with max=2");
944    }
945
946    #[test]
947    fn test_max_consecutive_spaces_two_flags_triple_spaces() {
948        // With max_consecutive_spaces = 2, triple spaces should still be flagged
949        let config = MD064Config {
950            max_consecutive_spaces: 2,
951        };
952        let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
953
954        let content = "This has   three spaces here.";
955        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
956        let result = rule.check(&ctx).unwrap();
957        assert_eq!(result.len(), 1, "Triple spaces should be flagged with max=2");
958    }
959
960    #[test]
961    fn test_max_consecutive_spaces_mixed() {
962        // Mix of 2 and 3 spaces - only 3 should be flagged
963        let config = MD064Config {
964            max_consecutive_spaces: 2,
965        };
966        let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
967
968        let content = "Two spaces.  OK here.   Three spaces flagged.";
969        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
970        let result = rule.check(&ctx).unwrap();
971        assert_eq!(result.len(), 1, "Only triple spaces should be flagged");
972        assert_eq!(result[0].column, 22, "Should flag the triple space at column 22");
973    }
974
975    #[test]
976    fn test_fix_respects_max_consecutive_spaces() {
977        // Fix should collapse to max_consecutive_spaces, not always to 1
978        let config = MD064Config {
979            max_consecutive_spaces: 2,
980        };
981        let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
982
983        let content = "Has   three spaces here.";
984        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
985        let fixed = rule.fix(&ctx).unwrap();
986        // Note: current fix always collapses to 1 space, which is acceptable
987        // The important thing is that it fixes the violation
988        assert!(!fixed.contains("   "), "Triple spaces should be fixed");
989    }
990
991    #[test]
992    fn test_default_config_section_returns_schema() {
993        let rule = MD064NoMultipleConsecutiveSpaces::new();
994        let config_section = rule.default_config_section();
995
996        assert!(config_section.is_some(), "Should return config section");
997
998        let (rule_name, toml_value) = config_section.unwrap();
999        assert_eq!(rule_name, "MD064");
1000
1001        // Should be a table with max-consecutive-spaces key
1002        if let toml::Value::Table(table) = toml_value {
1003            assert!(
1004                table.contains_key("max-consecutive-spaces"),
1005                "Should contain max-consecutive-spaces key"
1006            );
1007        } else {
1008            panic!("Expected a toml table");
1009        }
1010    }
1011
1012    #[test]
1013    fn test_max_consecutive_spaces_zero_flags_all_double_spaces() {
1014        // Edge case: max=0 should flag everything (2+ spaces always exceeds 0)
1015        let config = MD064Config {
1016            max_consecutive_spaces: 0,
1017        };
1018        let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1019
1020        let content = "Double  spaces here.";
1021        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1022        let result = rule.check(&ctx).unwrap();
1023        assert_eq!(result.len(), 1, "max=0 should flag double spaces");
1024    }
1025
1026    #[test]
1027    fn test_max_consecutive_spaces_three_allows_triple() {
1028        // Higher threshold: max=3 allows up to 3 consecutive spaces
1029        let config = MD064Config {
1030            max_consecutive_spaces: 3,
1031        };
1032        let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1033
1034        // Use 5 spaces (not 4, as 4 is skipped as tab replacement pattern)
1035        let content = "Two  spaces OK.   Three spaces OK.     Five spaces flagged.";
1036        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1037        let result = rule.check(&ctx).unwrap();
1038        assert_eq!(result.len(), 1, "Only 5 spaces should be flagged with max=3");
1039    }
1040
1041    #[test]
1042    fn test_exact_boundary_at_threshold() {
1043        // Exact boundary: spaces exactly at threshold should be allowed
1044        // Use max=3 to avoid tab replacement pattern (multiples of 4 are skipped)
1045        let config = MD064Config {
1046            max_consecutive_spaces: 3,
1047        };
1048        let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1049
1050        // 3 spaces exactly at threshold - should be allowed
1051        let content = "Three   spaces exactly.";
1052        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1053        let result = rule.check(&ctx).unwrap();
1054        assert!(result.is_empty(), "Exactly 3 spaces should be allowed with max=3");
1055
1056        // 5 spaces - two over threshold - should be flagged (skip 4 as it's tab pattern)
1057        let content = "Five     spaces here.";
1058        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1059        let result = rule.check(&ctx).unwrap();
1060        assert_eq!(result.len(), 1, "5 spaces should be flagged with max=3");
1061    }
1062
1063    #[test]
1064    fn test_config_with_skip_contexts() {
1065        // Verify skip contexts still work with custom threshold
1066        let config = MD064Config {
1067            max_consecutive_spaces: 2,
1068        };
1069        let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1070
1071        // Code span should be skipped even with custom config
1072        let content = "Text `code   with   spaces` more   text.";
1073        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1074        let result = rule.check(&ctx).unwrap();
1075        // Only "more   text" should be flagged (3 spaces), not code span
1076        assert_eq!(result.len(), 1, "Only content outside code span flagged");
1077        assert!(
1078            result[0].column > 25,
1079            "Warning should be for 'more   text' not code span"
1080        );
1081    }
1082
1083    #[test]
1084    fn test_from_config_integration() {
1085        // Test the full config loading path
1086        use crate::config::Config;
1087        use std::collections::BTreeMap;
1088
1089        let mut config = Config::default();
1090        let mut values = BTreeMap::new();
1091        values.insert("max-consecutive-spaces".to_string(), toml::Value::Integer(2));
1092        config.rules.insert(
1093            "MD064".to_string(),
1094            crate::config::RuleConfig { severity: None, values },
1095        );
1096
1097        let rule = MD064NoMultipleConsecutiveSpaces::from_config(&config);
1098
1099        // Verify the rule uses the loaded config
1100        let content = "Two  spaces OK.   Three spaces flagged.";
1101        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1102        let result = rule.check(&ctx).unwrap();
1103        assert_eq!(result.len(), 1, "Should use loaded config value of 2");
1104    }
1105
1106    #[test]
1107    fn test_very_large_threshold_effectively_disables_rule() {
1108        // Very large threshold should effectively disable the rule
1109        let config = MD064Config {
1110            max_consecutive_spaces: usize::MAX,
1111        };
1112        let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1113
1114        // Even many spaces should be allowed
1115        let content = "Many          spaces here.";
1116        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1117        let result = rule.check(&ctx).unwrap();
1118        assert!(
1119            result.is_empty(),
1120            "Very large threshold should allow any number of spaces"
1121        );
1122    }
1123}