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