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