Skip to main content

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::blockquote::parse_blockquote_prefix;
32use crate::utils::sentence_utils::is_after_sentence_ending;
33use crate::utils::skip_context::is_table_line;
34use serde::{Deserialize, Serialize};
35use std::collections::HashSet;
36use std::sync::Arc;
37
38/// Regex to find multiple consecutive spaces (2 or more)
39use regex::Regex;
40use std::sync::LazyLock;
41
42static MULTIPLE_SPACES_REGEX: LazyLock<Regex> = LazyLock::new(|| {
43    // Match 2 or more consecutive spaces
44    Regex::new(r" {2,}").unwrap()
45});
46
47/// Configuration for MD064 (No multiple consecutive spaces)
48#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
49#[serde(rename_all = "kebab-case")]
50pub struct MD064Config {
51    /// Allow exactly two spaces after sentence-ending punctuation (default: false)
52    ///
53    /// When enabled, allows exactly 2 spaces after sentence-ending punctuation
54    /// (`.`, `!`, `?`) while still flagging multiple spaces elsewhere. This
55    /// supports the traditional typewriter convention of two spaces after sentences.
56    ///
57    /// Sentence-ending punctuation includes:
58    /// - Period: `.`
59    /// - Exclamation mark: `!`
60    /// - Question mark: `?`
61    ///
62    /// Also recognizes closing punctuation after sentence endings:
63    /// - Quotes: `."`, `!"`, `?"`, `.'`, `!'`, `?'`
64    /// - Parentheses: `.)`, `!)`, `?)`
65    /// - Brackets: `.]`, `!]`, `?]`
66    /// - Ellipsis: `...`
67    ///
68    /// Example with `allow-sentence-double-space = true`:
69    /// ```markdown
70    /// First sentence.  Second sentence.    <- OK (2 spaces after period)
71    /// Multiple   spaces here.              <- Flagged (3 spaces, not after sentence)
72    /// Word  word in middle.                <- Flagged (2 spaces, not after sentence)
73    /// ```
74    #[serde(
75        default = "default_allow_sentence_double_space",
76        alias = "allow_sentence_double_space"
77    )]
78    pub allow_sentence_double_space: bool,
79}
80
81fn default_allow_sentence_double_space() -> bool {
82    false
83}
84
85impl Default for MD064Config {
86    fn default() -> Self {
87        Self {
88            allow_sentence_double_space: default_allow_sentence_double_space(),
89        }
90    }
91}
92
93impl RuleConfig for MD064Config {
94    const RULE_NAME: &'static str = "MD064";
95}
96
97#[derive(Debug, Clone)]
98pub struct MD064NoMultipleConsecutiveSpaces {
99    config: MD064Config,
100}
101
102impl Default for MD064NoMultipleConsecutiveSpaces {
103    fn default() -> Self {
104        Self::new()
105    }
106}
107
108impl MD064NoMultipleConsecutiveSpaces {
109    pub fn new() -> Self {
110        Self {
111            config: MD064Config::default(),
112        }
113    }
114
115    pub fn from_config_struct(config: MD064Config) -> Self {
116        Self { config }
117    }
118
119    /// Check if a byte position is inside an inline code span
120    fn is_in_code_span(&self, code_spans: &[crate::lint_context::CodeSpan], byte_pos: usize) -> bool {
121        code_spans
122            .iter()
123            .any(|span| byte_pos >= span.byte_offset && byte_pos < span.byte_end)
124    }
125
126    /// Check if a match is trailing whitespace at the end of a line
127    /// Trailing spaces are handled by MD009, so MD064 should skip them entirely
128    fn is_trailing_whitespace(&self, line: &str, match_end: usize) -> bool {
129        // If the match extends to the end of the line, it's trailing whitespace
130        let remaining = &line[match_end..];
131        remaining.is_empty() || remaining.chars().all(|c| c == '\n' || c == '\r')
132    }
133
134    /// Check if the match is part of leading indentation
135    fn is_leading_indentation(&self, line: &str, match_start: usize) -> bool {
136        // Check if everything before the match is whitespace
137        line[..match_start].chars().all(|c| c == ' ' || c == '\t')
138    }
139
140    /// Check if the match is immediately after a list marker (handled by MD030)
141    fn is_after_list_marker(&self, line: &str, match_start: usize) -> bool {
142        // Strip blockquote prefix to handle lists inside blockquotes (e.g., "> 1.  item")
143        let before_text = if let Some(parsed) = parse_blockquote_prefix(line) {
144            let prefix_len = parsed.prefix.len();
145            if match_start <= prefix_len {
146                return false;
147            }
148            line[prefix_len..match_start].trim_start()
149        } else {
150            line[..match_start].trim_start()
151        };
152
153        // Unordered list markers: *, -, +
154        if before_text == "*" || before_text == "-" || before_text == "+" {
155            return true;
156        }
157
158        // Ordered list markers: digits followed by . or )
159        // Examples: "1.", "2)", "10.", "123)"
160        if before_text.len() >= 2 {
161            let last_char = before_text.chars().last().unwrap();
162            if last_char == '.' || last_char == ')' {
163                let prefix = &before_text[..before_text.len() - 1];
164                if !prefix.is_empty() && prefix.chars().all(|c| c.is_ascii_digit()) {
165                    return true;
166                }
167            }
168        }
169
170        false
171    }
172
173    /// Check if the match is immediately after a blockquote marker (handled by MD027)
174    /// Patterns: "> ", ">  ", ">>", "> > "
175    fn is_after_blockquote_marker(&self, line: &str, match_start: usize) -> bool {
176        let before = line[..match_start].trim_start();
177
178        // Check if it's only blockquote markers (> characters, possibly with spaces between)
179        if before.is_empty() {
180            return false;
181        }
182
183        // Pattern: one or more '>' characters, optionally followed by space and more '>'
184        let trimmed = before.trim_end();
185        if trimmed.chars().all(|c| c == '>') {
186            return true;
187        }
188
189        // Pattern: "> " at end (nested blockquote with space)
190        if trimmed.ends_with('>') {
191            let inner = trimmed.trim_end_matches('>').trim();
192            if inner.is_empty() || inner.chars().all(|c| c == '>') {
193                return true;
194            }
195        }
196
197        false
198    }
199
200    /// Check if the space count looks like a tab replacement (multiple of 4)
201    /// Tab replacements (4, 8, 12, etc. spaces) are intentional and should not be collapsed.
202    /// This prevents MD064 from undoing MD010's tab-to-spaces conversion.
203    fn is_tab_replacement_pattern(&self, space_count: usize) -> bool {
204        space_count >= 4 && space_count.is_multiple_of(4)
205    }
206
207    /// Check if the match is inside or after a reference link definition
208    /// Pattern: [label]: URL or [label]:  URL
209    fn is_reference_link_definition(&self, line: &str, match_start: usize) -> bool {
210        let trimmed = line.trim_start();
211        let leading_spaces = line.len() - trimmed.len();
212
213        // Reference link pattern: [label]: URL
214        if trimmed.starts_with('[')
215            && let Some(bracket_end) = trimmed.find("]:")
216        {
217            let colon_pos = leading_spaces + bracket_end + 2;
218            // Check if the match is right after the ]: marker
219            if match_start >= colon_pos - 1 && match_start <= colon_pos + 1 {
220                return true;
221            }
222        }
223
224        false
225    }
226
227    /// Check if the match is after a footnote marker
228    /// Pattern: [^label]:  text
229    fn is_after_footnote_marker(&self, line: &str, match_start: usize) -> bool {
230        let trimmed = line.trim_start();
231
232        // Footnote pattern: [^label]: text
233        if trimmed.starts_with("[^")
234            && let Some(bracket_end) = trimmed.find("]:")
235        {
236            let leading_spaces = line.len() - trimmed.len();
237            let colon_pos = leading_spaces + bracket_end + 2;
238            // Check if the match is right after the ]: marker
239            if match_start >= colon_pos.saturating_sub(1) && match_start <= colon_pos + 1 {
240                return true;
241            }
242        }
243
244        false
245    }
246
247    /// Check if the match is after a definition list marker
248    /// Pattern: :   Definition text
249    fn is_after_definition_marker(&self, line: &str, match_start: usize) -> bool {
250        let before = line[..match_start].trim_start();
251
252        // Definition list marker is just ":"
253        before == ":"
254    }
255
256    /// Check if the match is immediately after a task list checkbox.
257    /// Standard GFM: only `[ ]`, `[x]`, `[X]` are valid checkboxes.
258    /// Obsidian flavor: any single character inside brackets is a valid checkbox
259    /// (e.g., `[/]`, `[-]`, `[>]`, `[✓]`).
260    fn is_after_task_checkbox(&self, line: &str, match_start: usize, flavor: crate::config::MarkdownFlavor) -> bool {
261        let before = line[..match_start].trim_start();
262
263        // Zero-allocation iterator-based check for: marker + space + '[' + char + ']'
264        let mut chars = before.chars();
265        let pattern = (
266            chars.next(),
267            chars.next(),
268            chars.next(),
269            chars.next(),
270            chars.next(),
271            chars.next(),
272        );
273
274        match pattern {
275            (Some('*' | '-' | '+'), Some(' '), Some('['), Some(c), Some(']'), None) => {
276                if flavor == crate::config::MarkdownFlavor::Obsidian {
277                    // Obsidian: any single character is a valid checkbox state
278                    true
279                } else {
280                    // Standard GFM: only space, 'x', or 'X' are valid
281                    matches!(c, ' ' | 'x' | 'X')
282                }
283            }
284            _ => false,
285        }
286    }
287
288    /// Collect line numbers that belong to a "column-aligned" list.
289    ///
290    /// A list is column-aligned when it has at least two items and every
291    /// item's first line contains a run of two or more consecutive spaces
292    /// somewhere in its post-marker content (excluding trailing whitespace).
293    /// In that case the spacing is intentional column alignment — common in
294    /// `cdk init`-style "Useful commands" sections — and per-line MD064 fixes
295    /// would corrupt the layout while leaving lint green. Skipping the rule
296    /// for those item lines keeps detection list-consistent and the fix safe.
297    ///
298    /// Each list is judged on its own items: a nested list is a different list
299    /// from its parent, so an unaligned child does not disqualify the aligned
300    /// parent items around it, and vice versa.
301    fn aligned_list_item_lines(&self, ctx: &crate::lint_context::LintContext) -> HashSet<usize> {
302        let mut aligned = HashSet::new();
303        for block in &ctx.list_blocks {
304            for items in ctx.list_block_item_groups(block) {
305                if items.len() < 2 {
306                    continue;
307                }
308                let all_aligned = items
309                    .iter()
310                    .all(|&line_num| self.item_line_has_internal_alignment(ctx, line_num));
311                if all_aligned {
312                    aligned.extend(items);
313                }
314            }
315        }
316        aligned
317    }
318
319    /// True when the given list-item line has a run of 2+ consecutive spaces
320    /// in its post-marker, non-trailing content.
321    fn item_line_has_internal_alignment(&self, ctx: &crate::lint_context::LintContext, line_num: usize) -> bool {
322        let Some(line_info) = ctx.line_info(line_num) else {
323            return false;
324        };
325        let Some(item) = &line_info.list_item else {
326            return false;
327        };
328        let content = line_info.content(ctx.content);
329        if item.content_column >= content.len() {
330            return false;
331        }
332        content[item.content_column..].trim_end().contains("  ")
333    }
334
335    /// Check if this is a table row without outer pipes (GFM extension)
336    /// Pattern: text | text | text (no leading/trailing pipe)
337    fn is_table_without_outer_pipes(&self, line: &str) -> bool {
338        let trimmed = line.trim();
339
340        // Must contain at least one pipe but not start or end with pipe
341        if !trimmed.contains('|') {
342            return false;
343        }
344
345        // If it starts or ends with |, it's a normal table (handled by is_table_line)
346        if trimmed.starts_with('|') || trimmed.ends_with('|') {
347            return false;
348        }
349
350        // Check if it looks like a table row: has multiple pipe-separated cells
351        // Could be data row (word | word) or separator row (--- | ---)
352        // Table cells can be empty, so we just check for at least 2 parts
353        let parts: Vec<&str> = trimmed.split('|').collect();
354        if parts.len() >= 2 {
355            // At least first or last cell should have content (not just whitespace)
356            // to distinguish from accidental pipes in text
357            let first_has_content = !parts.first().unwrap_or(&"").trim().is_empty();
358            let last_has_content = !parts.last().unwrap_or(&"").trim().is_empty();
359            if first_has_content || last_has_content {
360                return true;
361            }
362        }
363
364        false
365    }
366}
367
368impl Rule for MD064NoMultipleConsecutiveSpaces {
369    fn name(&self) -> &'static str {
370        "MD064"
371    }
372
373    fn description(&self) -> &'static str {
374        "Multiple consecutive spaces"
375    }
376
377    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
378        let content = ctx.content;
379
380        // Early return: if no double spaces at all, skip
381        if !content.contains("  ") {
382            return Ok(vec![]);
383        }
384
385        // Config is already correct - engine applies inline overrides before calling check()
386        let mut warnings = Vec::new();
387        let code_spans: Arc<Vec<crate::lint_context::CodeSpan>> = ctx.code_spans();
388
389        // Pre-compute lines belonging to column-aligned list blocks. The
390        // alignment whitespace there is intentional, and per-line fixes would
391        // corrupt the surrounding layout — see `aligned_list_item_lines`.
392        let aligned_lines = self.aligned_list_item_lines(ctx);
393
394        // Process content lines, automatically skipping front matter, code blocks, HTML, PyMdown blocks, and Obsidian comments
395        for line in ctx
396            .filtered_lines()
397            .skip_front_matter()
398            .skip_code_blocks()
399            .skip_html_blocks()
400            .skip_html_comments()
401            .skip_mkdocstrings()
402            .skip_esm_blocks()
403            .skip_jsx_expressions()
404            .skip_mdx_comments()
405            .skip_pymdown_blocks()
406            .skip_obsidian_comments()
407        {
408            // Quick check: skip if line doesn't contain double spaces
409            if !line.content.contains("  ") {
410                continue;
411            }
412
413            // Skip lines that belong to a column-aligned list block. The
414            // surrounding items use the same intentional alignment, so flagging
415            // outliers here would produce a destructive partial fix.
416            if aligned_lines.contains(&line.line_num) {
417                continue;
418            }
419
420            // Skip table rows (alignment padding is intentional)
421            if is_table_line(line.content) {
422                continue;
423            }
424
425            // Skip tables without outer pipes (GFM extension)
426            if self.is_table_without_outer_pipes(line.content) {
427                continue;
428            }
429
430            let line_start_byte = ctx.line_start_byte(line.line_num).unwrap_or(0);
431
432            // Find all occurrences of multiple consecutive spaces
433            for mat in MULTIPLE_SPACES_REGEX.find_iter(line.content) {
434                let match_start = mat.start();
435                let match_end = mat.end();
436                let space_count = match_end - match_start;
437
438                // Skip if this is leading indentation
439                if self.is_leading_indentation(line.content, match_start) {
440                    continue;
441                }
442
443                // Skip trailing whitespace (handled by MD009)
444                if self.is_trailing_whitespace(line.content, match_end) {
445                    continue;
446                }
447
448                // Skip tab replacement patterns (4, 8, 12, etc. spaces)
449                // This prevents MD064 from undoing MD010's tab-to-spaces conversion
450                if self.is_tab_replacement_pattern(space_count) {
451                    continue;
452                }
453
454                // Skip spaces after list markers (handled by MD030)
455                if self.is_after_list_marker(line.content, match_start) {
456                    continue;
457                }
458
459                // Skip spaces after blockquote markers (handled by MD027)
460                if self.is_after_blockquote_marker(line.content, match_start) {
461                    continue;
462                }
463
464                // Skip spaces after footnote markers
465                if self.is_after_footnote_marker(line.content, match_start) {
466                    continue;
467                }
468
469                // Skip spaces after reference link definition markers
470                if self.is_reference_link_definition(line.content, match_start) {
471                    continue;
472                }
473
474                // Skip spaces after definition list markers
475                if self.is_after_definition_marker(line.content, match_start) {
476                    continue;
477                }
478
479                // Skip spaces after task list checkboxes
480                if self.is_after_task_checkbox(line.content, match_start, ctx.flavor) {
481                    continue;
482                }
483
484                // Allow exactly 2 spaces after sentence-ending punctuation if configured
485                // This supports the traditional typewriter convention of two spaces after sentences
486                if self.config.allow_sentence_double_space
487                    && space_count == 2
488                    && is_after_sentence_ending(line.content, match_start)
489                {
490                    continue;
491                }
492
493                // Calculate absolute byte position
494                let abs_byte_start = line_start_byte + match_start;
495
496                // Skip if inside an inline code span
497                if self.is_in_code_span(&code_spans, abs_byte_start) {
498                    continue;
499                }
500
501                // Calculate byte range for the fix
502                let abs_byte_end = line_start_byte + match_end;
503
504                // Determine the replacement: if allow_sentence_double_space is enabled
505                // and this is after a sentence ending, collapse to 2 spaces, otherwise to 1
506                let replacement =
507                    if self.config.allow_sentence_double_space && is_after_sentence_ending(line.content, match_start) {
508                        "  ".to_string() // Collapse to two spaces after sentence
509                    } else {
510                        " ".to_string() // Collapse to single space
511                    };
512
513                warnings.push(LintWarning {
514                    rule_name: Some(self.name().to_string()),
515                    message: format!("Multiple consecutive spaces ({space_count}) found"),
516                    line: line.line_num,
517                    column: match_start + 1, // 1-indexed
518                    end_line: line.line_num,
519                    end_column: match_end + 1, // 1-indexed
520                    severity: Severity::Warning,
521                    fix: Some(Fix::new(abs_byte_start..abs_byte_end, replacement)),
522                });
523            }
524        }
525
526        Ok(warnings)
527    }
528
529    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
530        let content = ctx.content;
531
532        // Early return if no double spaces
533        if !content.contains("  ") {
534            return Ok(content.to_string());
535        }
536
537        // Get warnings to identify what needs to be fixed
538        let warnings = self.check(ctx)?;
539        let warnings =
540            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
541        if warnings.is_empty() {
542            return Ok(content.to_string());
543        }
544
545        // Collect all fixes and sort by position (reverse order to avoid position shifts)
546        let mut fixes: Vec<(std::ops::Range<usize>, String)> = warnings
547            .into_iter()
548            .filter_map(|w| w.fix.map(|f| (f.range, f.replacement)))
549            .collect();
550
551        fixes.sort_by_key(|(range, _)| std::cmp::Reverse(range.start));
552
553        // Apply fixes
554        let mut result = content.to_string();
555        for (range, replacement) in fixes {
556            if range.start < result.len() && range.end <= result.len() {
557                result.replace_range(range, &replacement);
558            }
559        }
560
561        Ok(result)
562    }
563
564    /// Get the category of this rule for selective processing
565    fn category(&self) -> RuleCategory {
566        RuleCategory::Whitespace
567    }
568
569    /// Check if this rule should be skipped
570    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
571        ctx.content.is_empty() || !ctx.content.contains("  ")
572    }
573
574    fn as_any(&self) -> &dyn std::any::Any {
575        self
576    }
577
578    crate::impl_rule_config_methods!(MD064Config);
579}
580
581#[cfg(test)]
582mod tests {
583    use super::*;
584    use crate::lint_context::LintContext;
585
586    #[test]
587    fn test_basic_multiple_spaces() {
588        let rule = MD064NoMultipleConsecutiveSpaces::new();
589
590        // Should flag multiple spaces
591        let content = "This is   a sentence with extra spaces.";
592        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
593        let result = rule.check(&ctx).unwrap();
594        assert_eq!(result.len(), 1);
595        assert_eq!(result[0].line, 1);
596        assert_eq!(result[0].column, 8); // Position of first extra space
597    }
598
599    #[test]
600    fn test_no_issues_single_spaces() {
601        let rule = MD064NoMultipleConsecutiveSpaces::new();
602
603        // Should not flag single spaces
604        let content = "This is a normal sentence with single spaces.";
605        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
606        let result = rule.check(&ctx).unwrap();
607        assert!(result.is_empty());
608    }
609
610    #[test]
611    fn test_skip_inline_code() {
612        let rule = MD064NoMultipleConsecutiveSpaces::new();
613
614        // Should not flag spaces inside inline code
615        let content = "Use `code   with   spaces` for formatting.";
616        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
617        let result = rule.check(&ctx).unwrap();
618        assert!(result.is_empty());
619    }
620
621    #[test]
622    fn test_skip_code_blocks() {
623        let rule = MD064NoMultipleConsecutiveSpaces::new();
624
625        // Should not flag spaces inside code blocks
626        let content = "# Heading\n\n```\ncode   with   spaces\n```\n\nNormal text.";
627        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
628        let result = rule.check(&ctx).unwrap();
629        assert!(result.is_empty());
630    }
631
632    #[test]
633    fn test_skip_leading_indentation() {
634        let rule = MD064NoMultipleConsecutiveSpaces::new();
635
636        // Should not flag leading indentation
637        let content = "    This is indented text.";
638        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
639        let result = rule.check(&ctx).unwrap();
640        assert!(result.is_empty());
641    }
642
643    #[test]
644    fn test_skip_trailing_spaces() {
645        let rule = MD064NoMultipleConsecutiveSpaces::new();
646
647        // Should not flag trailing spaces (handled by MD009)
648        let content = "Line with trailing spaces   \nNext line.";
649        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
650        let result = rule.check(&ctx).unwrap();
651        assert!(result.is_empty());
652    }
653
654    #[test]
655    fn test_skip_all_trailing_spaces() {
656        let rule = MD064NoMultipleConsecutiveSpaces::new();
657
658        // Should not flag any trailing spaces regardless of count
659        let content = "Two spaces  \nThree spaces   \nFour spaces    \n";
660        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
661        let result = rule.check(&ctx).unwrap();
662        assert!(result.is_empty());
663    }
664
665    #[test]
666    fn test_skip_front_matter() {
667        let rule = MD064NoMultipleConsecutiveSpaces::new();
668
669        // Should not flag spaces in front matter
670        let content = "---\ntitle:   Test   Title\n---\n\nContent here.";
671        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
672        let result = rule.check(&ctx).unwrap();
673        assert!(result.is_empty());
674    }
675
676    #[test]
677    fn test_skip_html_comments() {
678        let rule = MD064NoMultipleConsecutiveSpaces::new();
679
680        // Should not flag spaces in HTML comments
681        let content = "<!-- comment   with   spaces -->\n\nContent here.";
682        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
683        let result = rule.check(&ctx).unwrap();
684        assert!(result.is_empty());
685    }
686
687    #[test]
688    fn test_multiple_issues_one_line() {
689        let rule = MD064NoMultipleConsecutiveSpaces::new();
690
691        // Should flag multiple occurrences on one line
692        let content = "This   has   multiple   issues.";
693        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
694        let result = rule.check(&ctx).unwrap();
695        assert_eq!(result.len(), 3, "Should flag all 3 occurrences");
696    }
697
698    #[test]
699    fn test_fix_collapses_spaces() {
700        let rule = MD064NoMultipleConsecutiveSpaces::new();
701
702        let content = "This is   a sentence   with extra   spaces.";
703        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
704        let fixed = rule.fix(&ctx).unwrap();
705        assert_eq!(fixed, "This is a sentence with extra spaces.");
706    }
707
708    #[test]
709    fn test_fix_preserves_inline_code() {
710        let rule = MD064NoMultipleConsecutiveSpaces::new();
711
712        let content = "Text   here `code   inside` and   more.";
713        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
714        let fixed = rule.fix(&ctx).unwrap();
715        assert_eq!(fixed, "Text here `code   inside` and more.");
716    }
717
718    #[test]
719    fn test_fix_preserves_trailing_spaces() {
720        let rule = MD064NoMultipleConsecutiveSpaces::new();
721
722        // Trailing spaces should be preserved (handled by MD009)
723        let content = "Line with   extra and trailing   \nNext line.";
724        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
725        let fixed = rule.fix(&ctx).unwrap();
726        // Only the internal "   " gets fixed to " ", trailing spaces are preserved
727        assert_eq!(fixed, "Line with extra and trailing   \nNext line.");
728    }
729
730    #[test]
731    fn test_list_items_with_extra_spaces() {
732        let rule = MD064NoMultipleConsecutiveSpaces::new();
733
734        // Multi-space within list-item content must be flagged when the
735        // surrounding block isn't column-aligned. The third item has no
736        // internal multi-space, so the "every item is aligned" heuristic
737        // doesn't apply and items 1 and 2 are reported normally.
738        let content = "- Item   one\n- Item   two\n- Item three\n";
739        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
740        let result = rule.check(&ctx).unwrap();
741        assert_eq!(result.len(), 2, "Should flag spaces in list items");
742    }
743
744    #[test]
745    fn test_blockquote_with_extra_spaces_in_content() {
746        let rule = MD064NoMultipleConsecutiveSpaces::new();
747
748        // Extra spaces in blockquote CONTENT should be flagged
749        let content = "> Quote   with extra   spaces\n";
750        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
751        let result = rule.check(&ctx).unwrap();
752        assert_eq!(result.len(), 2, "Should flag spaces in blockquote content");
753    }
754
755    #[test]
756    fn test_skip_blockquote_marker_spaces() {
757        let rule = MD064NoMultipleConsecutiveSpaces::new();
758
759        // Extra spaces after blockquote marker are handled by MD027
760        let content = ">  Text with extra space after marker\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        // Three spaces after marker
766        let content = ">   Text with three spaces after marker\n";
767        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
768        let result = rule.check(&ctx).unwrap();
769        assert!(result.is_empty());
770
771        // Nested blockquotes
772        let content = ">>  Nested blockquote\n";
773        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
774        let result = rule.check(&ctx).unwrap();
775        assert!(result.is_empty());
776    }
777
778    #[test]
779    fn test_mixed_content() {
780        let rule = MD064NoMultipleConsecutiveSpaces::new();
781
782        let content = r#"# Heading
783
784This   has extra spaces.
785
786```
787code   here  is  fine
788```
789
790- List   item
791
792> Quote   text
793
794Normal paragraph.
795"#;
796        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
797        let result = rule.check(&ctx).unwrap();
798        // Should flag: "This   has" (1), "List   item" (1), "Quote   text" (1)
799        assert_eq!(result.len(), 3, "Should flag only content outside code blocks");
800    }
801
802    #[test]
803    fn test_multibyte_utf8() {
804        let rule = MD064NoMultipleConsecutiveSpaces::new();
805
806        // Test with multi-byte UTF-8 characters
807        let content = "日本語   テスト   文字列";
808        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
809        let result = rule.check(&ctx);
810        assert!(result.is_ok(), "Should handle multi-byte UTF-8 characters");
811
812        let warnings = result.unwrap();
813        assert_eq!(warnings.len(), 2, "Should find 2 occurrences of multiple spaces");
814    }
815
816    #[test]
817    fn test_table_rows_skipped() {
818        let rule = MD064NoMultipleConsecutiveSpaces::new();
819
820        // Table rows with alignment padding should be skipped
821        let content = "| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1   | Cell 2   |";
822        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
823        let result = rule.check(&ctx).unwrap();
824        // Table rows should be skipped (alignment padding is intentional)
825        assert!(result.is_empty());
826    }
827
828    #[test]
829    fn test_link_text_with_extra_spaces() {
830        let rule = MD064NoMultipleConsecutiveSpaces::new();
831
832        // Link text with extra spaces (should be flagged)
833        let content = "[Link   text](https://example.com)";
834        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
835        let result = rule.check(&ctx).unwrap();
836        assert_eq!(result.len(), 1, "Should flag extra spaces in link text");
837    }
838
839    #[test]
840    fn test_image_alt_with_extra_spaces() {
841        let rule = MD064NoMultipleConsecutiveSpaces::new();
842
843        // Image alt text with extra spaces (should be flagged)
844        let content = "![Alt   text](image.png)";
845        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
846        let result = rule.check(&ctx).unwrap();
847        assert_eq!(result.len(), 1, "Should flag extra spaces in image alt text");
848    }
849
850    #[test]
851    fn test_skip_list_marker_spaces() {
852        let rule = MD064NoMultipleConsecutiveSpaces::new();
853
854        // Spaces after list markers are handled by MD030, not MD064
855        let content = "*   Item with extra spaces after marker\n-   Another item\n+   Third item\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        // Ordered list markers
861        let content = "1.  Item one\n2.  Item two\n10. Item ten\n";
862        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
863        let result = rule.check(&ctx).unwrap();
864        assert!(result.is_empty());
865
866        // Indented list items should also be skipped
867        let content = "  *   Indented item\n    1.  Nested numbered item\n";
868        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
869        let result = rule.check(&ctx).unwrap();
870        assert!(result.is_empty());
871    }
872
873    #[test]
874    fn test_skip_blockquoted_list_marker_spaces() {
875        let rule = MD064NoMultipleConsecutiveSpaces::new();
876
877        // Blockquoted ordered list with 2-space markers (MD030 ol-single=2)
878        let content = "# Title\n\n> 1.  Hello.\n>     This is a list item.\n> 2.  This is another list item\n";
879        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
880        let result = rule.check(&ctx).unwrap();
881        assert!(
882            result.is_empty(),
883            "Should not flag spaces after list markers in blockquotes"
884        );
885
886        // Blockquoted unordered list
887        let content = "> *   Item one\n> -   Item two\n> +   Item three\n";
888        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
889        let result = rule.check(&ctx).unwrap();
890        assert!(
891            result.is_empty(),
892            "Should not flag spaces after unordered list markers in blockquotes"
893        );
894
895        // Nested blockquoted list
896        let content = "> > 1.  Nested blockquote list item\n";
897        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
898        let result = rule.check(&ctx).unwrap();
899        assert!(
900            result.is_empty(),
901            "Should not flag spaces after list markers in nested blockquotes"
902        );
903
904        // Parenthesis-style ordered markers in blockquote
905        let content = "> 1)  First item\n> 2)  Second item\n";
906        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
907        let result = rule.check(&ctx).unwrap();
908        assert!(
909            result.is_empty(),
910            "Should not flag spaces after parenthesis-style ordered markers in blockquotes"
911        );
912
913        // Extra whitespace between blockquote marker and list marker
914        let content = ">  1.  Item with extra space after >\n";
915        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
916        let result = rule.check(&ctx).unwrap();
917        // The spaces after "1." should not be flagged (list marker context)
918        // The spaces after ">" are handled by is_after_blockquote_marker
919        assert!(
920            result.is_empty(),
921            "Should not flag list marker spaces even with extra space after blockquote marker"
922        );
923
924        // Multiple spaces in blockquoted list *content* should still be flagged
925        let content = "> 1.  Item with   extra spaces in content\n";
926        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
927        let result = rule.check(&ctx).unwrap();
928        assert_eq!(
929            result.len(),
930            1,
931            "Should still flag extra spaces in blockquoted list content"
932        );
933    }
934
935    #[test]
936    fn test_flag_spaces_in_list_content() {
937        let rule = MD064NoMultipleConsecutiveSpaces::new();
938
939        // Multiple spaces WITHIN list content should still be flagged
940        let content = "* Item with   extra spaces in content\n";
941        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
942        let result = rule.check(&ctx).unwrap();
943        assert_eq!(result.len(), 1, "Should flag extra spaces in list content");
944    }
945
946    #[test]
947    fn test_skip_reference_link_definition_spaces() {
948        let rule = MD064NoMultipleConsecutiveSpaces::new();
949
950        // Reference link definitions may have multiple spaces after the colon
951        let content = "[ref]:  https://example.com\n";
952        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
953        let result = rule.check(&ctx).unwrap();
954        assert!(result.is_empty());
955
956        // Multiple spaces
957        let content = "[reference-link]:   https://example.com \"Title\"\n";
958        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
959        let result = rule.check(&ctx).unwrap();
960        assert!(result.is_empty());
961    }
962
963    #[test]
964    fn test_skip_footnote_marker_spaces() {
965        let rule = MD064NoMultipleConsecutiveSpaces::new();
966
967        // Footnote definitions may have multiple spaces after the colon
968        let content = "[^1]:  Footnote with extra space\n";
969        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
970        let result = rule.check(&ctx).unwrap();
971        assert!(result.is_empty());
972
973        // Footnote with longer label
974        let content = "[^footnote-label]:   This is the footnote text.\n";
975        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
976        let result = rule.check(&ctx).unwrap();
977        assert!(result.is_empty());
978    }
979
980    #[test]
981    fn test_skip_definition_list_marker_spaces() {
982        let rule = MD064NoMultipleConsecutiveSpaces::new();
983
984        // Definition list markers (PHP Markdown Extra / Pandoc)
985        let content = "Term\n:   Definition with extra spaces\n";
986        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
987        let result = rule.check(&ctx).unwrap();
988        assert!(result.is_empty());
989
990        // Multiple definitions
991        let content = ":    Another definition\n";
992        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
993        let result = rule.check(&ctx).unwrap();
994        assert!(result.is_empty());
995    }
996
997    #[test]
998    fn test_skip_task_list_checkbox_spaces() {
999        let rule = MD064NoMultipleConsecutiveSpaces::new();
1000
1001        // Task list items may have extra spaces after checkbox
1002        let content = "- [ ]  Task with extra space\n";
1003        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1004        let result = rule.check(&ctx).unwrap();
1005        assert!(result.is_empty());
1006
1007        // Checked task
1008        let content = "- [x]  Completed task\n";
1009        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1010        let result = rule.check(&ctx).unwrap();
1011        assert!(result.is_empty());
1012
1013        // With asterisk marker
1014        let content = "* [ ]  Task with asterisk marker\n";
1015        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1016        let result = rule.check(&ctx).unwrap();
1017        assert!(result.is_empty());
1018    }
1019
1020    #[test]
1021    fn test_skip_extended_task_checkbox_spaces_obsidian() {
1022        // Extended checkboxes are only recognized in Obsidian flavor
1023        let rule = MD064NoMultipleConsecutiveSpaces::new();
1024
1025        // Extended Obsidian checkboxes: [/] in progress
1026        let content = "- [/]  In progress task\n";
1027        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1028        let result = rule.check(&ctx).unwrap();
1029        assert!(result.is_empty(), "Should skip [/] checkbox in Obsidian");
1030
1031        // Extended Obsidian checkboxes: [-] cancelled
1032        let content = "- [-]  Cancelled task\n";
1033        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1034        let result = rule.check(&ctx).unwrap();
1035        assert!(result.is_empty(), "Should skip [-] checkbox in Obsidian");
1036
1037        // Extended Obsidian checkboxes: [>] deferred
1038        let content = "- [>]  Deferred task\n";
1039        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1040        let result = rule.check(&ctx).unwrap();
1041        assert!(result.is_empty(), "Should skip [>] checkbox in Obsidian");
1042
1043        // Extended Obsidian checkboxes: [<] scheduled
1044        let content = "- [<]  Scheduled task\n";
1045        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1046        let result = rule.check(&ctx).unwrap();
1047        assert!(result.is_empty(), "Should skip [<] checkbox in Obsidian");
1048
1049        // Extended Obsidian checkboxes: [?] question
1050        let content = "- [?]  Question task\n";
1051        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1052        let result = rule.check(&ctx).unwrap();
1053        assert!(result.is_empty(), "Should skip [?] checkbox in Obsidian");
1054
1055        // Extended Obsidian checkboxes: [!] important
1056        let content = "- [!]  Important task\n";
1057        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1058        let result = rule.check(&ctx).unwrap();
1059        assert!(result.is_empty(), "Should skip [!] checkbox in Obsidian");
1060
1061        // Extended Obsidian checkboxes: [*] star/highlight
1062        let content = "- [*]  Starred task\n";
1063        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1064        let result = rule.check(&ctx).unwrap();
1065        assert!(result.is_empty(), "Should skip [*] checkbox in Obsidian");
1066
1067        // With asterisk list marker and extended checkbox
1068        let content = "* [/]  In progress with asterisk\n";
1069        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1070        let result = rule.check(&ctx).unwrap();
1071        assert!(result.is_empty(), "Should skip extended checkbox with * marker");
1072
1073        // With plus list marker and extended checkbox
1074        let content = "+ [-]  Cancelled with plus\n";
1075        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1076        let result = rule.check(&ctx).unwrap();
1077        assert!(result.is_empty(), "Should skip extended checkbox with + marker");
1078
1079        // Multi-byte UTF-8 checkboxes (Unicode checkmarks)
1080        let content = "- [✓]  Completed with checkmark\n";
1081        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1082        let result = rule.check(&ctx).unwrap();
1083        assert!(result.is_empty(), "Should skip Unicode checkmark [✓]");
1084
1085        let content = "- [✗]  Failed with X mark\n";
1086        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1087        let result = rule.check(&ctx).unwrap();
1088        assert!(result.is_empty(), "Should skip Unicode X mark [✗]");
1089
1090        let content = "- [→]  Forwarded with arrow\n";
1091        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1092        let result = rule.check(&ctx).unwrap();
1093        assert!(result.is_empty(), "Should skip Unicode arrow [→]");
1094    }
1095
1096    #[test]
1097    fn test_flag_extended_checkboxes_in_standard_flavor() {
1098        // Extended checkboxes should be flagged in Standard flavor (GFM only recognizes [ ], [x], [X])
1099        let rule = MD064NoMultipleConsecutiveSpaces::new();
1100
1101        let content = "- [/]  In progress task\n";
1102        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1103        let result = rule.check(&ctx).unwrap();
1104        assert_eq!(result.len(), 1, "Should flag [/] in Standard flavor");
1105
1106        let content = "- [-]  Cancelled task\n";
1107        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1108        let result = rule.check(&ctx).unwrap();
1109        assert_eq!(result.len(), 1, "Should flag [-] in Standard flavor");
1110
1111        let content = "- [✓]  Unicode checkbox\n";
1112        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1113        let result = rule.check(&ctx).unwrap();
1114        assert_eq!(result.len(), 1, "Should flag [✓] in Standard flavor");
1115    }
1116
1117    #[test]
1118    fn test_extended_checkboxes_with_indentation() {
1119        let rule = MD064NoMultipleConsecutiveSpaces::new();
1120
1121        // Space-indented task list with extended checkbox (Obsidian)
1122        // 2 spaces is not enough for code block, so this is clearly a list item
1123        let content = "  - [/]  In progress task\n";
1124        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1125        let result = rule.check(&ctx).unwrap();
1126        assert!(
1127            result.is_empty(),
1128            "Should skip space-indented extended checkbox in Obsidian"
1129        );
1130
1131        // 3 spaces - still not a code block
1132        let content = "   - [-]  Cancelled task\n";
1133        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1134        let result = rule.check(&ctx).unwrap();
1135        assert!(
1136            result.is_empty(),
1137            "Should skip 3-space indented extended checkbox in Obsidian"
1138        );
1139
1140        // Tab-indented with list context (parent list makes nested item clear)
1141        // Without context, a tab-indented line is treated as a code block
1142        let content = "- Parent item\n\t- [/]  In progress task\n";
1143        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1144        let result = rule.check(&ctx).unwrap();
1145        assert!(
1146            result.is_empty(),
1147            "Should skip tab-indented nested extended checkbox in Obsidian"
1148        );
1149
1150        // Space-indented extended checkbox should be flagged in Standard flavor
1151        let content = "  - [/]  In progress task\n";
1152        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1153        let result = rule.check(&ctx).unwrap();
1154        assert_eq!(result.len(), 1, "Should flag indented [/] in Standard flavor");
1155
1156        // 3-space indented extended checkbox should be flagged in Standard flavor
1157        let content = "   - [-]  Cancelled task\n";
1158        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1159        let result = rule.check(&ctx).unwrap();
1160        assert_eq!(result.len(), 1, "Should flag 3-space indented [-] in Standard flavor");
1161
1162        // Tab-indented nested list should be flagged in Standard flavor
1163        let content = "- Parent item\n\t- [-]  Cancelled task\n";
1164        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1165        let result = rule.check(&ctx).unwrap();
1166        assert_eq!(
1167            result.len(),
1168            1,
1169            "Should flag tab-indented nested [-] in Standard flavor"
1170        );
1171
1172        // Standard checkboxes should still work when indented (both flavors)
1173        let content = "  - [x]  Completed task\n";
1174        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1175        let result = rule.check(&ctx).unwrap();
1176        assert!(
1177            result.is_empty(),
1178            "Should skip indented standard [x] checkbox in Standard flavor"
1179        );
1180
1181        // Tab-indented with list context and standard checkbox
1182        let content = "- Parent\n\t- [ ]  Pending task\n";
1183        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1184        let result = rule.check(&ctx).unwrap();
1185        assert!(
1186            result.is_empty(),
1187            "Should skip tab-indented nested standard [ ] checkbox"
1188        );
1189    }
1190
1191    #[test]
1192    fn test_skip_table_without_outer_pipes() {
1193        let rule = MD064NoMultipleConsecutiveSpaces::new();
1194
1195        // GFM tables without outer pipes should be skipped
1196        let content = "Col1      | Col2      | Col3\n";
1197        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1198        let result = rule.check(&ctx).unwrap();
1199        assert!(result.is_empty());
1200
1201        // Separator row
1202        let content = "--------- | --------- | ---------\n";
1203        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1204        let result = rule.check(&ctx).unwrap();
1205        assert!(result.is_empty());
1206
1207        // Data row
1208        let content = "Data1     | Data2     | Data3\n";
1209        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1210        let result = rule.check(&ctx).unwrap();
1211        assert!(result.is_empty());
1212    }
1213
1214    #[test]
1215    fn test_flag_spaces_in_footnote_content() {
1216        let rule = MD064NoMultipleConsecutiveSpaces::new();
1217
1218        // Extra spaces WITHIN footnote text content should be flagged
1219        let content = "[^1]: Footnote with   extra spaces in content.\n";
1220        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1221        let result = rule.check(&ctx).unwrap();
1222        assert_eq!(result.len(), 1, "Should flag extra spaces in footnote content");
1223    }
1224
1225    #[test]
1226    fn test_flag_spaces_in_reference_content() {
1227        let rule = MD064NoMultipleConsecutiveSpaces::new();
1228
1229        // Extra spaces in the title of a reference link should be flagged
1230        let content = "[ref]: https://example.com \"Title   with extra spaces\"\n";
1231        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1232        let result = rule.check(&ctx).unwrap();
1233        assert_eq!(result.len(), 1, "Should flag extra spaces in reference link title");
1234    }
1235
1236    // === allow-sentence-double-space tests ===
1237
1238    #[test]
1239    fn test_sentence_double_space_disabled_by_default() {
1240        // Default config should flag double spaces after sentences
1241        let rule = MD064NoMultipleConsecutiveSpaces::new();
1242        let content = "First sentence.  Second sentence.";
1243        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1244        let result = rule.check(&ctx).unwrap();
1245        assert_eq!(result.len(), 1, "Default should flag 2 spaces after period");
1246    }
1247
1248    #[test]
1249    fn test_sentence_double_space_enabled_allows_period() {
1250        // With allow_sentence_double_space, 2 spaces after period should be OK
1251        let config = MD064Config {
1252            allow_sentence_double_space: true,
1253        };
1254        let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1255
1256        let content = "First sentence.  Second sentence.";
1257        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1258        let result = rule.check(&ctx).unwrap();
1259        assert!(result.is_empty(), "Should allow 2 spaces after period");
1260    }
1261
1262    #[test]
1263    fn test_sentence_double_space_enabled_allows_exclamation() {
1264        let config = MD064Config {
1265            allow_sentence_double_space: true,
1266        };
1267        let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1268
1269        let content = "Wow!  That was great.";
1270        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1271        let result = rule.check(&ctx).unwrap();
1272        assert!(result.is_empty(), "Should allow 2 spaces after exclamation");
1273    }
1274
1275    #[test]
1276    fn test_sentence_double_space_enabled_allows_question() {
1277        let config = MD064Config {
1278            allow_sentence_double_space: true,
1279        };
1280        let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1281
1282        let content = "Is this OK?  Yes it is.";
1283        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1284        let result = rule.check(&ctx).unwrap();
1285        assert!(result.is_empty(), "Should allow 2 spaces after question mark");
1286    }
1287
1288    #[test]
1289    fn test_sentence_double_space_flags_mid_sentence() {
1290        // Even with allow_sentence_double_space, mid-sentence double spaces should be flagged
1291        let config = MD064Config {
1292            allow_sentence_double_space: true,
1293        };
1294        let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1295
1296        let content = "Word  word in the middle.";
1297        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1298        let result = rule.check(&ctx).unwrap();
1299        assert_eq!(result.len(), 1, "Should flag 2 spaces mid-sentence");
1300    }
1301
1302    #[test]
1303    fn test_sentence_double_space_flags_triple_after_period() {
1304        // 3+ spaces after sentence should still be flagged
1305        let config = MD064Config {
1306            allow_sentence_double_space: true,
1307        };
1308        let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1309
1310        let content = "First sentence.   Three spaces here.";
1311        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1312        let result = rule.check(&ctx).unwrap();
1313        assert_eq!(result.len(), 1, "Should flag 3 spaces even after period");
1314    }
1315
1316    #[test]
1317    fn test_sentence_double_space_with_closing_quote() {
1318        // "Quoted sentence."  Next sentence.
1319        let config = MD064Config {
1320            allow_sentence_double_space: true,
1321        };
1322        let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1323
1324        let content = r#"He said "Hello."  Then he left."#;
1325        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1326        let result = rule.check(&ctx).unwrap();
1327        assert!(result.is_empty(), "Should allow 2 spaces after .\" ");
1328
1329        // With single quote
1330        let content = "She said 'Goodbye.'  And she was gone.";
1331        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1332        let result = rule.check(&ctx).unwrap();
1333        assert!(result.is_empty(), "Should allow 2 spaces after .' ");
1334    }
1335
1336    #[test]
1337    fn test_sentence_double_space_with_curly_quotes() {
1338        let config = MD064Config {
1339            allow_sentence_double_space: true,
1340        };
1341        let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1342
1343        // Curly double quote: U+201C (") and U+201D (")
1344        // Build string with actual Unicode characters
1345        let content = format!(
1346            "He said {}Hello.{}  Then left.",
1347            '\u{201C}', // "
1348            '\u{201D}'  // "
1349        );
1350        let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1351        let result = rule.check(&ctx).unwrap();
1352        assert!(result.is_empty(), "Should allow 2 spaces after curly double quote");
1353
1354        // Curly single quote: U+2018 (') and U+2019 (')
1355        let content = format!(
1356            "She said {}Hi.{}  And left.",
1357            '\u{2018}', // '
1358            '\u{2019}'  // '
1359        );
1360        let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1361        let result = rule.check(&ctx).unwrap();
1362        assert!(result.is_empty(), "Should allow 2 spaces after curly single quote");
1363    }
1364
1365    #[test]
1366    fn test_sentence_double_space_with_closing_paren() {
1367        let config = MD064Config {
1368            allow_sentence_double_space: true,
1369        };
1370        let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1371
1372        let content = "(See reference.)  The next point is.";
1373        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1374        let result = rule.check(&ctx).unwrap();
1375        assert!(result.is_empty(), "Should allow 2 spaces after .) ");
1376    }
1377
1378    #[test]
1379    fn test_sentence_double_space_with_closing_bracket() {
1380        let config = MD064Config {
1381            allow_sentence_double_space: true,
1382        };
1383        let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1384
1385        let content = "[Citation needed.]  More text here.";
1386        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1387        let result = rule.check(&ctx).unwrap();
1388        assert!(result.is_empty(), "Should allow 2 spaces after .] ");
1389    }
1390
1391    #[test]
1392    fn test_sentence_double_space_with_ellipsis() {
1393        let config = MD064Config {
1394            allow_sentence_double_space: true,
1395        };
1396        let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1397
1398        let content = "He paused...  Then continued.";
1399        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1400        let result = rule.check(&ctx).unwrap();
1401        assert!(result.is_empty(), "Should allow 2 spaces after ellipsis");
1402    }
1403
1404    #[test]
1405    fn test_sentence_double_space_complex_ending() {
1406        // Multiple closing punctuation: .")
1407        let config = MD064Config {
1408            allow_sentence_double_space: true,
1409        };
1410        let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1411
1412        let content = r#"(He said "Yes.")  Then they agreed."#;
1413        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1414        let result = rule.check(&ctx).unwrap();
1415        assert!(result.is_empty(), "Should allow 2 spaces after .\") ");
1416    }
1417
1418    #[test]
1419    fn test_sentence_double_space_mixed_content() {
1420        // Mix of sentence endings and mid-sentence spaces
1421        let config = MD064Config {
1422            allow_sentence_double_space: true,
1423        };
1424        let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1425
1426        let content = "Good sentence.  Bad  mid-sentence.  Another good one!  OK?  Yes.";
1427        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1428        let result = rule.check(&ctx).unwrap();
1429        assert_eq!(result.len(), 1, "Should only flag mid-sentence double space");
1430        assert!(
1431            result[0].column > 15 && result[0].column < 25,
1432            "Should flag the 'Bad  mid' double space"
1433        );
1434    }
1435
1436    #[test]
1437    fn test_sentence_double_space_fix_collapses_to_two() {
1438        // Fix should collapse 3+ spaces to 2 after sentence, 1 elsewhere
1439        let config = MD064Config {
1440            allow_sentence_double_space: true,
1441        };
1442        let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1443
1444        let content = "Sentence.   Three spaces here.";
1445        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1446        let fixed = rule.fix(&ctx).unwrap();
1447        assert_eq!(
1448            fixed, "Sentence.  Three spaces here.",
1449            "Should collapse to 2 spaces after sentence"
1450        );
1451    }
1452
1453    #[test]
1454    fn test_sentence_double_space_fix_collapses_mid_sentence_to_one() {
1455        // Fix should collapse mid-sentence spaces to 1
1456        let config = MD064Config {
1457            allow_sentence_double_space: true,
1458        };
1459        let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1460
1461        let content = "Word  word here.";
1462        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1463        let fixed = rule.fix(&ctx).unwrap();
1464        assert_eq!(fixed, "Word word here.", "Should collapse to 1 space mid-sentence");
1465    }
1466
1467    #[test]
1468    fn test_sentence_double_space_config_kebab_case() {
1469        let toml_str = r#"
1470            allow-sentence-double-space = true
1471        "#;
1472        let config: MD064Config = toml::from_str(toml_str).unwrap();
1473        assert!(config.allow_sentence_double_space);
1474    }
1475
1476    #[test]
1477    fn test_sentence_double_space_config_snake_case() {
1478        let toml_str = r#"
1479            allow_sentence_double_space = true
1480        "#;
1481        let config: MD064Config = toml::from_str(toml_str).unwrap();
1482        assert!(config.allow_sentence_double_space);
1483    }
1484
1485    #[test]
1486    fn test_sentence_double_space_at_line_start() {
1487        // Period at very start shouldn't cause issues
1488        let config = MD064Config {
1489            allow_sentence_double_space: true,
1490        };
1491        let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1492
1493        // This is an edge case - spaces at start are leading indentation
1494        let content = ".  Text after period at start.";
1495        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1496        // This should not panic
1497        let _result = rule.check(&ctx).unwrap();
1498    }
1499
1500    #[test]
1501    fn test_sentence_double_space_guillemets() {
1502        // French-style quotes (guillemets)
1503        let config = MD064Config {
1504            allow_sentence_double_space: true,
1505        };
1506        let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1507
1508        let content = "Il a dit «Oui.»  Puis il est parti.";
1509        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1510        let result = rule.check(&ctx).unwrap();
1511        assert!(result.is_empty(), "Should allow 2 spaces after .» (guillemet)");
1512    }
1513
1514    #[test]
1515    fn test_sentence_double_space_multiple_sentences() {
1516        // Multiple consecutive sentences with double spacing
1517        let config = MD064Config {
1518            allow_sentence_double_space: true,
1519        };
1520        let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1521
1522        let content = "First.  Second.  Third.  Fourth.";
1523        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1524        let result = rule.check(&ctx).unwrap();
1525        assert!(result.is_empty(), "Should allow all sentence-ending double spaces");
1526    }
1527
1528    #[test]
1529    fn test_sentence_double_space_abbreviation_detection() {
1530        // Known abbreviations should NOT be treated as sentence endings
1531        let config = MD064Config {
1532            allow_sentence_double_space: true,
1533        };
1534        let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1535
1536        // "Dr.  Smith" - Dr. is a known abbreviation, should be flagged
1537        let content = "Dr.  Smith arrived.";
1538        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1539        let result = rule.check(&ctx).unwrap();
1540        assert_eq!(result.len(), 1, "Should flag Dr. as abbreviation, not sentence ending");
1541
1542        // "Prof.  Williams" - Prof. is a known abbreviation
1543        let content = "Prof.  Williams teaches.";
1544        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1545        let result = rule.check(&ctx).unwrap();
1546        assert_eq!(result.len(), 1, "Should flag Prof. as abbreviation");
1547
1548        // "e.g.  this" - e.g. is a known abbreviation
1549        let content = "Use e.g.  this example.";
1550        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1551        let result = rule.check(&ctx).unwrap();
1552        assert_eq!(result.len(), 1, "Should flag e.g. as abbreviation");
1553
1554        // Unknown abbreviation-like words are treated as potential sentence endings
1555        // "Inc.  Next" - Inc. is NOT in our abbreviation list
1556        let content = "Acme Inc.  Next company.";
1557        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1558        let result = rule.check(&ctx).unwrap();
1559        assert!(
1560            result.is_empty(),
1561            "Inc. not in abbreviation list, treated as sentence end"
1562        );
1563    }
1564
1565    #[test]
1566    fn test_sentence_double_space_default_config_has_correct_defaults() {
1567        let config = MD064Config::default();
1568        assert!(
1569            !config.allow_sentence_double_space,
1570            "Default allow_sentence_double_space should be false"
1571        );
1572    }
1573
1574    #[test]
1575    fn test_sentence_double_space_from_config_integration() {
1576        use crate::config::Config;
1577        use std::collections::BTreeMap;
1578
1579        let mut config = Config::default();
1580        let mut values = BTreeMap::new();
1581        values.insert("allow-sentence-double-space".to_string(), toml::Value::Boolean(true));
1582        config.rules.insert(
1583            "MD064".to_string(),
1584            crate::config::RuleConfig { severity: None, values },
1585        );
1586
1587        let rule = MD064NoMultipleConsecutiveSpaces::from_config(&config);
1588
1589        // Verify the rule uses the loaded config
1590        let content = "Sentence.  Two spaces OK.  But three   is not.";
1591        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1592        let result = rule.check(&ctx).unwrap();
1593        assert_eq!(result.len(), 1, "Should only flag the triple spaces");
1594    }
1595
1596    #[test]
1597    fn test_sentence_double_space_after_inline_code() {
1598        // Issue #345: Sentence ending with inline code should allow double space
1599        let config = MD064Config {
1600            allow_sentence_double_space: true,
1601        };
1602        let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1603
1604        // Basic case from issue report
1605        let content = "Hello from `backticks`.  How's it going?";
1606        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1607        let result = rule.check(&ctx).unwrap();
1608        assert!(
1609            result.is_empty(),
1610            "Should allow 2 spaces after inline code ending with period"
1611        );
1612
1613        // Multiple inline code spans
1614        let content = "Use `foo` and `bar`.  Next sentence.";
1615        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1616        let result = rule.check(&ctx).unwrap();
1617        assert!(result.is_empty(), "Should allow 2 spaces after code at end of sentence");
1618
1619        // With exclamation mark
1620        let content = "The `code` worked!  Celebrate.";
1621        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1622        let result = rule.check(&ctx).unwrap();
1623        assert!(result.is_empty(), "Should allow 2 spaces after code with exclamation");
1624
1625        // With question mark
1626        let content = "Is `null` falsy?  Yes.";
1627        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1628        let result = rule.check(&ctx).unwrap();
1629        assert!(result.is_empty(), "Should allow 2 spaces after code with question mark");
1630
1631        // Inline code mid-sentence (not at end) - double space SHOULD be flagged
1632        let content = "The `code`  is here.";
1633        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1634        let result = rule.check(&ctx).unwrap();
1635        assert_eq!(result.len(), 1, "Should flag 2 spaces after code mid-sentence");
1636    }
1637
1638    #[test]
1639    fn test_sentence_double_space_code_with_closing_punctuation() {
1640        // Inline code followed by period in parentheses
1641        let config = MD064Config {
1642            allow_sentence_double_space: true,
1643        };
1644        let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1645
1646        // Code in parentheses
1647        let content = "(see `example`).  Next sentence.";
1648        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1649        let result = rule.check(&ctx).unwrap();
1650        assert!(result.is_empty(), "Should allow 2 spaces after code in parentheses");
1651
1652        // Code in quotes
1653        let content = "He said \"use `code`\".  Then left.";
1654        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1655        let result = rule.check(&ctx).unwrap();
1656        assert!(result.is_empty(), "Should allow 2 spaces after code in quotes");
1657    }
1658
1659    #[test]
1660    fn test_sentence_double_space_after_emphasis() {
1661        // Sentence ending with emphasis should allow double space
1662        let config = MD064Config {
1663            allow_sentence_double_space: true,
1664        };
1665        let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1666
1667        // Asterisk emphasis
1668        let content = "The word is *important*.  Next sentence.";
1669        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1670        let result = rule.check(&ctx).unwrap();
1671        assert!(result.is_empty(), "Should allow 2 spaces after emphasis");
1672
1673        // Underscore emphasis
1674        let content = "The word is _important_.  Next sentence.";
1675        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1676        let result = rule.check(&ctx).unwrap();
1677        assert!(result.is_empty(), "Should allow 2 spaces after underscore emphasis");
1678
1679        // Bold (asterisk)
1680        let content = "The word is **critical**.  Next sentence.";
1681        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1682        let result = rule.check(&ctx).unwrap();
1683        assert!(result.is_empty(), "Should allow 2 spaces after bold");
1684
1685        // Bold (underscore)
1686        let content = "The word is __critical__.  Next sentence.";
1687        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1688        let result = rule.check(&ctx).unwrap();
1689        assert!(result.is_empty(), "Should allow 2 spaces after underscore bold");
1690    }
1691
1692    #[test]
1693    fn test_sentence_double_space_after_strikethrough() {
1694        // Sentence ending with strikethrough should allow double space
1695        let config = MD064Config {
1696            allow_sentence_double_space: true,
1697        };
1698        let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1699
1700        let content = "This is ~~wrong~~.  Next sentence.";
1701        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1702        let result = rule.check(&ctx).unwrap();
1703        assert!(result.is_empty(), "Should allow 2 spaces after strikethrough");
1704
1705        // With exclamation
1706        let content = "That was ~~bad~~!  Learn from it.";
1707        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1708        let result = rule.check(&ctx).unwrap();
1709        assert!(
1710            result.is_empty(),
1711            "Should allow 2 spaces after strikethrough with exclamation"
1712        );
1713    }
1714
1715    #[test]
1716    fn test_sentence_double_space_after_extended_markdown() {
1717        // Extended markdown syntax (highlight, superscript)
1718        let config = MD064Config {
1719            allow_sentence_double_space: true,
1720        };
1721        let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1722
1723        // Highlight syntax
1724        let content = "This is ==highlighted==.  Next sentence.";
1725        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1726        let result = rule.check(&ctx).unwrap();
1727        assert!(result.is_empty(), "Should allow 2 spaces after highlight");
1728
1729        // Superscript
1730        let content = "E equals mc^2^.  Einstein said.";
1731        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1732        let result = rule.check(&ctx).unwrap();
1733        assert!(result.is_empty(), "Should allow 2 spaces after superscript");
1734    }
1735
1736    #[test]
1737    fn test_inline_config_allow_sentence_double_space() {
1738        // Issue #364: Inline configure-file comments should work
1739        // Tests the automatic inline config support via Config::merge_with_inline_config
1740
1741        let rule = MD064NoMultipleConsecutiveSpaces::new(); // Default config (disabled)
1742
1743        // Without inline config, should flag
1744        let content = "`<svg>`.  Fortunately";
1745        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1746        let result = rule.check(&ctx).unwrap();
1747        assert_eq!(result.len(), 1, "Default config should flag double spaces");
1748
1749        // With inline config, should allow
1750        // Simulate engine behavior: parse inline config, merge with base config, recreate rule
1751        let content = r#"<!-- rumdl-configure-file { "MD064": { "allow-sentence-double-space": true } } -->
1752
1753`<svg>`.  Fortunately"#;
1754        let inline_config = crate::inline_config::InlineConfig::from_content(content);
1755        let base_config = crate::config::Config::default();
1756        let merged_config = base_config.merge_with_inline_config(&inline_config);
1757        let effective_rule = MD064NoMultipleConsecutiveSpaces::from_config(&merged_config);
1758        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1759        let result = effective_rule.check(&ctx).unwrap();
1760        assert!(
1761            result.is_empty(),
1762            "Inline config should allow double spaces after sentence"
1763        );
1764
1765        // Also test with markdownlint prefix
1766        let content = r#"<!-- markdownlint-configure-file { "MD064": { "allow-sentence-double-space": true } } -->
1767
1768**scalable**.  Pick"#;
1769        let inline_config = crate::inline_config::InlineConfig::from_content(content);
1770        let merged_config = base_config.merge_with_inline_config(&inline_config);
1771        let effective_rule = MD064NoMultipleConsecutiveSpaces::from_config(&merged_config);
1772        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1773        let result = effective_rule.check(&ctx).unwrap();
1774        assert!(result.is_empty(), "Inline config with markdownlint prefix should work");
1775    }
1776
1777    #[test]
1778    fn test_inline_config_allow_sentence_double_space_issue_364() {
1779        // Full test case from issue #364
1780        // Tests the automatic inline config support via Config::merge_with_inline_config
1781
1782        let content = r#"<!-- rumdl-configure-file { "MD064": { "allow-sentence-double-space": true } } -->
1783
1784# Title
1785
1786what the font size is for the toplevel `<svg>`.  Fortunately, librsvg
1787
1788And here is where I want to say, SVG documents are **scalable**.  Pick
1789
1790That's right, no `width`, no `height`, no `viewBox`.  There is no easy
1791
1792**SVG documents are scalable**.  That's their whole reason for being!"#;
1793
1794        // Simulate engine behavior: parse inline config, merge with base config, recreate rule
1795        let inline_config = crate::inline_config::InlineConfig::from_content(content);
1796        let base_config = crate::config::Config::default();
1797        let merged_config = base_config.merge_with_inline_config(&inline_config);
1798        let effective_rule = MD064NoMultipleConsecutiveSpaces::from_config(&merged_config);
1799        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1800        let result = effective_rule.check(&ctx).unwrap();
1801        assert!(
1802            result.is_empty(),
1803            "Issue #364: All sentence-ending double spaces should be allowed with inline config. Found {} warnings",
1804            result.len()
1805        );
1806    }
1807
1808    #[test]
1809    fn test_indented_reference_link_not_flagged() {
1810        // Bug: Reference link definitions with leading whitespace had incorrect
1811        // colon_pos calculation (leading whitespace count was always 0)
1812        let rule = MD064NoMultipleConsecutiveSpaces::default();
1813
1814        // Indented reference link with extra spaces after ]: should not be flagged
1815        let content = "   [label]:  https://example.com";
1816        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1817        let result = rule.check(&ctx).unwrap();
1818        assert!(
1819            result.is_empty(),
1820            "Indented reference link definitions should not be flagged, got: {:?}",
1821            result
1822                .iter()
1823                .map(|w| format!("col={}: {}", w.column, &w.message))
1824                .collect::<Vec<_>>()
1825        );
1826
1827        // Non-indented reference link should still not be flagged
1828        let content = "[label]:  https://example.com";
1829        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1830        let result = rule.check(&ctx).unwrap();
1831        assert!(result.is_empty(), "Reference link definitions should not be flagged");
1832    }
1833
1834    #[test]
1835    fn test_pre_block_with_blank_line_not_flagged() {
1836        // Content inside <pre> must not be flagged even when a blank line
1837        // precedes the flagged text within the block (CommonMark §4.6 Type 1).
1838        let rule = MD064NoMultipleConsecutiveSpaces::default();
1839
1840        let content = "# Heading\n\n<pre>\n\nhello  world\n</pre>\n";
1841        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1842        let result = rule.check(&ctx).unwrap();
1843        assert!(
1844            result.is_empty(),
1845            "MD064 must not fire inside <pre> when a blank line precedes the content, got: {result:?}"
1846        );
1847    }
1848
1849    #[test]
1850    fn test_textarea_block_with_blank_line_not_flagged() {
1851        // <textarea> is a CommonMark Type-1 block — blank lines inside it
1852        // must not terminate the HTML block or expose content to lint rules.
1853        let rule = MD064NoMultipleConsecutiveSpaces::default();
1854
1855        let content = "<textarea>\n\ninner  content\n</textarea>\n";
1856        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1857        let result = rule.check(&ctx).unwrap();
1858        assert!(
1859            result.is_empty(),
1860            "MD064 must not fire inside <textarea> when a blank line precedes the content, got: {result:?}"
1861        );
1862    }
1863
1864    #[test]
1865    fn test_div_with_blank_line_content_is_flagged() {
1866        // <div> is a Type-6 block — it terminates at the first blank line,
1867        // so content after a blank line inside <div> is treated as normal
1868        // Markdown text and must be flagged by MD064.
1869        let rule = MD064NoMultipleConsecutiveSpaces::default();
1870
1871        let content = "<div>\ninner\n\nafter  blank\n</div>\n";
1872        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1873        let result = rule.check(&ctx).unwrap();
1874        assert!(
1875            !result.is_empty(),
1876            "MD064 must fire on content after a blank line inside a <div> block"
1877        );
1878    }
1879
1880    #[test]
1881    fn test_column_aligned_list_not_flagged_cdk_template() {
1882        // The "Useful commands" block produced by `cdk init` has every item
1883        // column-aligned with a multi-space gap before the description. The
1884        // alignment is intentional, so MD064 must report zero issues — and
1885        // therefore offer no destructive partial fix.
1886        let rule = MD064NoMultipleConsecutiveSpaces::default();
1887
1888        let content = "# Useful commands\n\n\
1889            - `cdk ls`          list all stacks in the app\n\
1890            - `cdk synth`       emits the synthesized CloudFormation template\n\
1891            - `cdk deploy`      deploy this stack to your default AWS account/region\n\
1892            - `cdk diff`        compare deployed stack with current state\n\
1893            - `cdk docs`        open CDK documentation\n";
1894        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1895        let result = rule.check(&ctx).unwrap();
1896        assert!(
1897            result.is_empty(),
1898            "Column-aligned list (cdk init template) must not be flagged, got: {:?}",
1899            result
1900                .iter()
1901                .map(|w| format!("L{}C{}: {}", w.line, w.column, w.message))
1902                .collect::<Vec<_>>()
1903        );
1904
1905        // And `fix` must leave the file unchanged.
1906        let fixed = rule.fix(&ctx).unwrap();
1907        assert_eq!(fixed, content, "fix must not collapse intentional alignment");
1908    }
1909
1910    #[test]
1911    fn test_column_aligned_two_item_list_not_flagged() {
1912        // Smallest qualifying case: two items, both with internal multi-space.
1913        let rule = MD064NoMultipleConsecutiveSpaces::default();
1914
1915        let content = "- `a`     alpha\n- `b`     beta\n";
1916        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1917        let result = rule.check(&ctx).unwrap();
1918        assert!(
1919            result.is_empty(),
1920            "Two-item aligned list must not be flagged, got: {result:?}"
1921        );
1922    }
1923
1924    #[test]
1925    fn test_partially_aligned_list_is_flagged_normally() {
1926        // When alignment is broken (one item has no internal multi-space), the
1927        // block is not column-aligned and MD064 must fire on the items that
1928        // actually contain extra spaces.
1929        let rule = MD064NoMultipleConsecutiveSpaces::default();
1930
1931        let content = "- `a`     alpha\n- short\n- `c`     gamma\n";
1932        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1933        let result = rule.check(&ctx).unwrap();
1934        assert_eq!(
1935            result.len(),
1936            2,
1937            "Items with multi-space must be flagged when the surrounding list is not uniformly aligned, got: {result:?}"
1938        );
1939    }
1940
1941    #[test]
1942    fn test_single_item_list_with_multi_space_still_flagged() {
1943        // A list with only one item provides no surrounding alignment context,
1944        // so the multi-space run is treated as an ordinary violation.
1945        let rule = MD064NoMultipleConsecutiveSpaces::default();
1946
1947        let content = "- `a`     alpha\n";
1948        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1949        let result = rule.check(&ctx).unwrap();
1950        assert_eq!(
1951            result.len(),
1952            1,
1953            "Single-item list must still be flagged, got: {result:?}"
1954        );
1955    }
1956
1957    #[test]
1958    fn test_nested_aligned_lists_evaluated_independently() {
1959        // Both the outer and the inner list are column-aligned; both must be
1960        // skipped without one masking the other.
1961        let rule = MD064NoMultipleConsecutiveSpaces::default();
1962
1963        let content = "- `cmd1`     outer one\n\
1964            \x20\x20- `sub-a`   inner one\n\
1965            \x20\x20- `sub-b`   inner two\n\
1966            - `cmd2`     outer two\n";
1967        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1968        let result = rule.check(&ctx).unwrap();
1969        assert!(
1970            result.is_empty(),
1971            "Nested column-aligned lists must not be flagged, got: {result:?}"
1972        );
1973    }
1974
1975    #[test]
1976    fn test_siblings_at_a_different_indent_share_the_alignment_exemption() {
1977        // Items whose markers sit at different indents are still one list, so
1978        // an aligned pair is exempt together; and a sibling of the parent is
1979        // not judged with the nested list that happens to share its column.
1980        let rule = MD064NoMultipleConsecutiveSpaces::default();
1981        for content in [
1982            " - `cmd1`   one\n  - `cmd2`   two\n",
1983            " - `cmd1`   one\n   - sub\n  - `cmd2`   two\n",
1984        ] {
1985            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1986            let result = rule.check(&ctx).unwrap();
1987            assert!(result.is_empty(), "{content:?}: {result:?}");
1988            assert_eq!(
1989                rule.fix(&ctx).unwrap(),
1990                content,
1991                "{content:?}: fix must keep the alignment"
1992            );
1993        }
1994
1995        // Positive controls: with one sibling unaligned the pair is not an
1996        // aligned list, so the aligned sibling is reported; and a padded
1997        // nested item is a one-item list of its own, reported alone, rather
1998        // than an aligned pair with the sibling that shares its column.
1999        for (content, line) in [
2000            (" - `cmd1`   one\n  - `cmd2` two\n", 1),
2001            (" - `cmd1`   one\n   - `sub`   padded\n  - `cmd2`   two\n", 2),
2002        ] {
2003            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2004            let result = rule.check(&ctx).unwrap();
2005            assert_eq!(result.len(), 1, "{content:?}: {result:?}");
2006            assert_eq!(result[0].line, line, "{content:?}");
2007        }
2008
2009        // Inside a blockquote the columns count from the quote's content, so
2010        // the indent before the `>` does not turn a nested item into a
2011        // sibling: parent and child are one-item lists, each reported.
2012        let content = " > - `a`   parent\n>   - `b`   child\n";
2013        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2014        let result = rule.check(&ctx).unwrap();
2015        assert_eq!(result.len(), 2, "{content:?}: {result:?}");
2016        assert_eq!(result[0].line, 1, "{content:?}");
2017        assert_eq!(result[1].line, 2, "{content:?}");
2018        // The same pair with the quote markers aligned is the same structure.
2019        let content = "> - `a`   parent\n>   - `b`   child\n";
2020        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2021        let result = rule.check(&ctx).unwrap();
2022        assert_eq!(result.len(), 2, "{content:?}: {result:?}");
2023
2024        // A `>` left of the item's content starts a blockquote outside the
2025        // item, so `c` is not a sibling of `b` in the quote inside `p`, even
2026        // though both markers sit at the same column inside their quotes.
2027        let content = "- p\n  >- `b`   x\n>- `c`   x\n";
2028        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2029        let result = rule.check(&ctx).unwrap();
2030        assert_eq!(result.len(), 2, "{content:?}: {result:?}");
2031        assert_eq!(result[0].line, 2, "{content:?}");
2032        assert_eq!(result[1].line, 3, "{content:?}");
2033
2034        // A fence at column 0 ends `p` and its list, so the blockquote after
2035        // it is not inside `p`: `b` and `c` are siblings in one quote, an
2036        // aligned pair. Without the fence the quote is inside `p`, and the
2037        // `>` at column 0 starts another one, so each item is judged alone.
2038        let content = "- p\n```\n```\n  > - `b`   x\n  lazy\n> - `c`   x\n";
2039        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2040        let result = rule.check(&ctx).unwrap();
2041        assert!(result.is_empty(), "{content:?}: {result:?}");
2042        assert_eq!(
2043            rule.fix(&ctx).unwrap(),
2044            content,
2045            "{content:?}: fix must keep the alignment"
2046        );
2047        let content = "- p\n  > - `b`   x\n  lazy\n> - `c`   x\n";
2048        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2049        let result = rule.check(&ctx).unwrap();
2050        assert_eq!(result.len(), 2, "{content:?}: {result:?}");
2051        assert_eq!(result[0].line, 2, "{content:?}");
2052        assert_eq!(result[1].line, 4, "{content:?}");
2053    }
2054
2055    #[test]
2056    fn test_unaligned_nested_item_does_not_break_parent_alignment() {
2057        // The parent list is column-aligned; a nested child with no alignment
2058        // gap of its own belongs to a different list and must not disqualify
2059        // the parent items, whose spacing stays intentional. Space and tab
2060        // nesting behave the same.
2061        let rule = MD064NoMultipleConsecutiveSpaces::default();
2062        for (label, content) in [
2063            (
2064                "spaces",
2065                "- first  aligned\n- second  aligned\n- third  aligned\n  1. child\n",
2066            ),
2067            (
2068                "tab",
2069                "- first  aligned\n- second  aligned\n- third  aligned\n\t1. child\n",
2070            ),
2071        ] {
2072            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2073            let result = rule.check(&ctx).unwrap();
2074            assert!(result.is_empty(), "{label}: {result:?}");
2075            assert_eq!(rule.fix(&ctx).unwrap(), content, "{label}: fix must keep the alignment");
2076        }
2077
2078        // Positive control: the child itself carries a multi-space run and is
2079        // a single-item list, so it is reported while the parent items are not.
2080        let content = "- first  aligned\n- second  aligned\n  1. child  padded\n";
2081        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2082        let result = rule.check(&ctx).unwrap();
2083        assert_eq!(result.len(), 1, "{result:?}");
2084        assert_eq!(result[0].line, 3);
2085
2086        // Parent content between two nested lists (an HTML comment, a bare
2087        // `>` opening a blockquote) ends the first, so a padded single item
2088        // after it is its own list and gets no exemption from the aligned
2089        // pair before it.
2090        for (content, line) in [
2091            (
2092                "- parent\n  - a  aligned\n  - b  aligned\n  <!-- parent block -->\n  - only  padded\n- next\n",
2093                5,
2094            ),
2095            (
2096                "- parent\n  - a  aligned\n  - b  aligned\n  >\n  - only  padded\n- next\n",
2097                5,
2098            ),
2099            (
2100                "- parent\n  - a  aligned\n  - b  aligned\n    >\n  parent\n  - only  padded\n- next\n",
2101                6,
2102            ),
2103        ] {
2104            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2105            let result = rule.check(&ctx).unwrap();
2106            assert_eq!(result.len(), 1, "{content:?}: {result:?}");
2107            assert_eq!(result[0].line, line, "{content:?}");
2108        }
2109    }
2110}