Skip to main content

rumdl_lib/rules/
md064_no_multiple_consecutive_spaces.rs

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