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