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