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