Skip to main content

rumdl_lib/rules/
md064_no_multiple_consecutive_spaces.rs

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