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