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