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