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 inside a task list checkbox
245    /// Pattern: - [ ]  or - [x]  (spaces after checkbox)
246    fn is_after_task_checkbox(&self, line: &str, match_start: usize) -> bool {
247        let before = line[..match_start].trim_start();
248
249        // Task list patterns: *, -, + followed by [ ], [x], or [X]
250        // Examples: "- [ ]", "* [x]", "+ [X]"
251        if before.len() >= 4 {
252            let patterns = [
253                "- [ ]", "- [x]", "- [X]", "* [ ]", "* [x]", "* [X]", "+ [ ]", "+ [x]", "+ [X]",
254            ];
255            for pattern in patterns {
256                if before == pattern {
257                    return true;
258                }
259            }
260        }
261
262        false
263    }
264
265    /// Check if this is a table row without outer pipes (GFM extension)
266    /// Pattern: text | text | text (no leading/trailing pipe)
267    fn is_table_without_outer_pipes(&self, line: &str) -> bool {
268        let trimmed = line.trim();
269
270        // Must contain at least one pipe but not start or end with pipe
271        if !trimmed.contains('|') {
272            return false;
273        }
274
275        // If it starts or ends with |, it's a normal table (handled by is_table_line)
276        if trimmed.starts_with('|') || trimmed.ends_with('|') {
277            return false;
278        }
279
280        // Check if it looks like a table row: has multiple pipe-separated cells
281        // Could be data row (word | word) or separator row (--- | ---)
282        // Table cells can be empty, so we just check for at least 2 parts
283        let parts: Vec<&str> = trimmed.split('|').collect();
284        if parts.len() >= 2 {
285            // At least first or last cell should have content (not just whitespace)
286            // to distinguish from accidental pipes in text
287            let first_has_content = !parts.first().unwrap_or(&"").trim().is_empty();
288            let last_has_content = !parts.last().unwrap_or(&"").trim().is_empty();
289            if first_has_content || last_has_content {
290                return true;
291            }
292        }
293
294        false
295    }
296}
297
298impl Rule for MD064NoMultipleConsecutiveSpaces {
299    fn name(&self) -> &'static str {
300        "MD064"
301    }
302
303    fn description(&self) -> &'static str {
304        "Multiple consecutive spaces"
305    }
306
307    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
308        let content = ctx.content;
309
310        // Early return: if no double spaces at all, skip
311        if !content.contains("  ") {
312            return Ok(vec![]);
313        }
314
315        let mut warnings = Vec::new();
316        let code_spans: Arc<Vec<crate::lint_context::CodeSpan>> = ctx.code_spans();
317        let line_index = &ctx.line_index;
318
319        // Process content lines, automatically skipping front matter, code blocks, HTML
320        for line in ctx
321            .filtered_lines()
322            .skip_front_matter()
323            .skip_code_blocks()
324            .skip_html_blocks()
325            .skip_html_comments()
326            .skip_mkdocstrings()
327            .skip_esm_blocks()
328        {
329            // Quick check: skip if line doesn't contain double spaces
330            if !line.content.contains("  ") {
331                continue;
332            }
333
334            // Skip table rows (alignment padding is intentional)
335            if is_table_line(line.content) {
336                continue;
337            }
338
339            // Skip tables without outer pipes (GFM extension)
340            if self.is_table_without_outer_pipes(line.content) {
341                continue;
342            }
343
344            let line_start_byte = line_index.get_line_start_byte(line.line_num).unwrap_or(0);
345
346            // Find all occurrences of multiple consecutive spaces
347            for mat in MULTIPLE_SPACES_REGEX.find_iter(line.content) {
348                let match_start = mat.start();
349                let match_end = mat.end();
350                let space_count = match_end - match_start;
351
352                // Skip if this is leading indentation
353                if self.is_leading_indentation(line.content, match_start) {
354                    continue;
355                }
356
357                // Skip trailing whitespace (handled by MD009)
358                if self.is_trailing_whitespace(line.content, match_end) {
359                    continue;
360                }
361
362                // Skip tab replacement patterns (4, 8, 12, etc. spaces)
363                // This prevents MD064 from undoing MD010's tab-to-spaces conversion
364                if self.is_tab_replacement_pattern(space_count) {
365                    continue;
366                }
367
368                // Skip spaces after list markers (handled by MD030)
369                if self.is_after_list_marker(line.content, match_start) {
370                    continue;
371                }
372
373                // Skip spaces after blockquote markers (handled by MD027)
374                if self.is_after_blockquote_marker(line.content, match_start) {
375                    continue;
376                }
377
378                // Skip spaces after footnote markers
379                if self.is_after_footnote_marker(line.content, match_start) {
380                    continue;
381                }
382
383                // Skip spaces after reference link definition markers
384                if self.is_reference_link_definition(line.content, match_start) {
385                    continue;
386                }
387
388                // Skip spaces after definition list markers
389                if self.is_after_definition_marker(line.content, match_start) {
390                    continue;
391                }
392
393                // Skip spaces after task list checkboxes
394                if self.is_after_task_checkbox(line.content, match_start) {
395                    continue;
396                }
397
398                // Allow exactly 2 spaces after sentence-ending punctuation if configured
399                // This supports the traditional typewriter convention of two spaces after sentences
400                if self.config.allow_sentence_double_space
401                    && space_count == 2
402                    && is_after_sentence_ending(line.content, match_start)
403                {
404                    continue;
405                }
406
407                // Calculate absolute byte position
408                let abs_byte_start = line_start_byte + match_start;
409
410                // Skip if inside an inline code span
411                if self.is_in_code_span(&code_spans, abs_byte_start) {
412                    continue;
413                }
414
415                // Calculate byte range for the fix
416                let abs_byte_end = line_start_byte + match_end;
417
418                // Determine the replacement: if allow_sentence_double_space is enabled
419                // and this is after a sentence ending, collapse to 2 spaces, otherwise to 1
420                let replacement =
421                    if self.config.allow_sentence_double_space && is_after_sentence_ending(line.content, match_start) {
422                        "  ".to_string() // Collapse to two spaces after sentence
423                    } else {
424                        " ".to_string() // Collapse to single space
425                    };
426
427                warnings.push(LintWarning {
428                    rule_name: Some(self.name().to_string()),
429                    message: format!("Multiple consecutive spaces ({space_count}) found"),
430                    line: line.line_num,
431                    column: match_start + 1, // 1-indexed
432                    end_line: line.line_num,
433                    end_column: match_end + 1, // 1-indexed
434                    severity: Severity::Warning,
435                    fix: Some(Fix {
436                        range: abs_byte_start..abs_byte_end,
437                        replacement,
438                    }),
439                });
440            }
441        }
442
443        Ok(warnings)
444    }
445
446    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
447        let content = ctx.content;
448
449        // Early return if no double spaces
450        if !content.contains("  ") {
451            return Ok(content.to_string());
452        }
453
454        // Get warnings to identify what needs to be fixed
455        let warnings = self.check(ctx)?;
456        if warnings.is_empty() {
457            return Ok(content.to_string());
458        }
459
460        // Collect all fixes and sort by position (reverse order to avoid position shifts)
461        let mut fixes: Vec<(std::ops::Range<usize>, String)> = warnings
462            .into_iter()
463            .filter_map(|w| w.fix.map(|f| (f.range, f.replacement)))
464            .collect();
465
466        fixes.sort_by_key(|(range, _)| std::cmp::Reverse(range.start));
467
468        // Apply fixes
469        let mut result = content.to_string();
470        for (range, replacement) in fixes {
471            if range.start < result.len() && range.end <= result.len() {
472                result.replace_range(range, &replacement);
473            }
474        }
475
476        Ok(result)
477    }
478
479    /// Get the category of this rule for selective processing
480    fn category(&self) -> RuleCategory {
481        RuleCategory::Whitespace
482    }
483
484    /// Check if this rule should be skipped
485    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
486        ctx.content.is_empty() || !ctx.content.contains("  ")
487    }
488
489    fn as_any(&self) -> &dyn std::any::Any {
490        self
491    }
492
493    fn default_config_section(&self) -> Option<(String, toml::Value)> {
494        let default_config = MD064Config::default();
495        let json_value = serde_json::to_value(&default_config).ok()?;
496        let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
497
498        if let toml::Value::Table(table) = toml_value {
499            if !table.is_empty() {
500                Some((MD064Config::RULE_NAME.to_string(), toml::Value::Table(table)))
501            } else {
502                None
503            }
504        } else {
505            None
506        }
507    }
508
509    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
510    where
511        Self: Sized,
512    {
513        let rule_config = crate::rule_config_serde::load_rule_config::<MD064Config>(config);
514        Box::new(MD064NoMultipleConsecutiveSpaces::from_config_struct(rule_config))
515    }
516}
517
518#[cfg(test)]
519mod tests {
520    use super::*;
521    use crate::lint_context::LintContext;
522
523    #[test]
524    fn test_basic_multiple_spaces() {
525        let rule = MD064NoMultipleConsecutiveSpaces::new();
526
527        // Should flag multiple spaces
528        let content = "This is   a sentence with extra spaces.";
529        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
530        let result = rule.check(&ctx).unwrap();
531        assert_eq!(result.len(), 1);
532        assert_eq!(result[0].line, 1);
533        assert_eq!(result[0].column, 8); // Position of first extra space
534    }
535
536    #[test]
537    fn test_no_issues_single_spaces() {
538        let rule = MD064NoMultipleConsecutiveSpaces::new();
539
540        // Should not flag single spaces
541        let content = "This is a normal sentence with single spaces.";
542        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
543        let result = rule.check(&ctx).unwrap();
544        assert!(result.is_empty());
545    }
546
547    #[test]
548    fn test_skip_inline_code() {
549        let rule = MD064NoMultipleConsecutiveSpaces::new();
550
551        // Should not flag spaces inside inline code
552        let content = "Use `code   with   spaces` for formatting.";
553        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
554        let result = rule.check(&ctx).unwrap();
555        assert!(result.is_empty());
556    }
557
558    #[test]
559    fn test_skip_code_blocks() {
560        let rule = MD064NoMultipleConsecutiveSpaces::new();
561
562        // Should not flag spaces inside code blocks
563        let content = "# Heading\n\n```\ncode   with   spaces\n```\n\nNormal text.";
564        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
565        let result = rule.check(&ctx).unwrap();
566        assert!(result.is_empty());
567    }
568
569    #[test]
570    fn test_skip_leading_indentation() {
571        let rule = MD064NoMultipleConsecutiveSpaces::new();
572
573        // Should not flag leading indentation
574        let content = "    This is indented text.";
575        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
576        let result = rule.check(&ctx).unwrap();
577        assert!(result.is_empty());
578    }
579
580    #[test]
581    fn test_skip_trailing_spaces() {
582        let rule = MD064NoMultipleConsecutiveSpaces::new();
583
584        // Should not flag trailing spaces (handled by MD009)
585        let content = "Line with trailing spaces   \nNext line.";
586        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
587        let result = rule.check(&ctx).unwrap();
588        assert!(result.is_empty());
589    }
590
591    #[test]
592    fn test_skip_all_trailing_spaces() {
593        let rule = MD064NoMultipleConsecutiveSpaces::new();
594
595        // Should not flag any trailing spaces regardless of count
596        let content = "Two spaces  \nThree spaces   \nFour spaces    \n";
597        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
598        let result = rule.check(&ctx).unwrap();
599        assert!(result.is_empty());
600    }
601
602    #[test]
603    fn test_skip_front_matter() {
604        let rule = MD064NoMultipleConsecutiveSpaces::new();
605
606        // Should not flag spaces in front matter
607        let content = "---\ntitle:   Test   Title\n---\n\nContent here.";
608        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
609        let result = rule.check(&ctx).unwrap();
610        assert!(result.is_empty());
611    }
612
613    #[test]
614    fn test_skip_html_comments() {
615        let rule = MD064NoMultipleConsecutiveSpaces::new();
616
617        // Should not flag spaces in HTML comments
618        let content = "<!-- comment   with   spaces -->\n\nContent here.";
619        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
620        let result = rule.check(&ctx).unwrap();
621        assert!(result.is_empty());
622    }
623
624    #[test]
625    fn test_multiple_issues_one_line() {
626        let rule = MD064NoMultipleConsecutiveSpaces::new();
627
628        // Should flag multiple occurrences on one line
629        let content = "This   has   multiple   issues.";
630        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
631        let result = rule.check(&ctx).unwrap();
632        assert_eq!(result.len(), 3, "Should flag all 3 occurrences");
633    }
634
635    #[test]
636    fn test_fix_collapses_spaces() {
637        let rule = MD064NoMultipleConsecutiveSpaces::new();
638
639        let content = "This is   a sentence   with extra   spaces.";
640        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
641        let fixed = rule.fix(&ctx).unwrap();
642        assert_eq!(fixed, "This is a sentence with extra spaces.");
643    }
644
645    #[test]
646    fn test_fix_preserves_inline_code() {
647        let rule = MD064NoMultipleConsecutiveSpaces::new();
648
649        let content = "Text   here `code   inside` and   more.";
650        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
651        let fixed = rule.fix(&ctx).unwrap();
652        assert_eq!(fixed, "Text here `code   inside` and more.");
653    }
654
655    #[test]
656    fn test_fix_preserves_trailing_spaces() {
657        let rule = MD064NoMultipleConsecutiveSpaces::new();
658
659        // Trailing spaces should be preserved (handled by MD009)
660        let content = "Line with   extra and trailing   \nNext line.";
661        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
662        let fixed = rule.fix(&ctx).unwrap();
663        // Only the internal "   " gets fixed to " ", trailing spaces are preserved
664        assert_eq!(fixed, "Line with extra and trailing   \nNext line.");
665    }
666
667    #[test]
668    fn test_list_items_with_extra_spaces() {
669        let rule = MD064NoMultipleConsecutiveSpaces::new();
670
671        let content = "- Item   one\n- Item   two\n";
672        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
673        let result = rule.check(&ctx).unwrap();
674        assert_eq!(result.len(), 2, "Should flag spaces in list items");
675    }
676
677    #[test]
678    fn test_blockquote_with_extra_spaces_in_content() {
679        let rule = MD064NoMultipleConsecutiveSpaces::new();
680
681        // Extra spaces in blockquote CONTENT should be flagged
682        let content = "> Quote   with extra   spaces\n";
683        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
684        let result = rule.check(&ctx).unwrap();
685        assert_eq!(result.len(), 2, "Should flag spaces in blockquote content");
686    }
687
688    #[test]
689    fn test_skip_blockquote_marker_spaces() {
690        let rule = MD064NoMultipleConsecutiveSpaces::new();
691
692        // Extra spaces after blockquote marker are handled by MD027
693        let content = ">  Text with extra space after marker\n";
694        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
695        let result = rule.check(&ctx).unwrap();
696        assert!(result.is_empty());
697
698        // Three spaces after marker
699        let content = ">   Text with three spaces after marker\n";
700        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
701        let result = rule.check(&ctx).unwrap();
702        assert!(result.is_empty());
703
704        // Nested blockquotes
705        let content = ">>  Nested blockquote\n";
706        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
707        let result = rule.check(&ctx).unwrap();
708        assert!(result.is_empty());
709    }
710
711    #[test]
712    fn test_mixed_content() {
713        let rule = MD064NoMultipleConsecutiveSpaces::new();
714
715        let content = r#"# Heading
716
717This   has extra spaces.
718
719```
720code   here  is  fine
721```
722
723- List   item
724
725> Quote   text
726
727Normal paragraph.
728"#;
729        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
730        let result = rule.check(&ctx).unwrap();
731        // Should flag: "This   has" (1), "List   item" (1), "Quote   text" (1)
732        assert_eq!(result.len(), 3, "Should flag only content outside code blocks");
733    }
734
735    #[test]
736    fn test_multibyte_utf8() {
737        let rule = MD064NoMultipleConsecutiveSpaces::new();
738
739        // Test with multi-byte UTF-8 characters
740        let content = "日本語   テスト   文字列";
741        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
742        let result = rule.check(&ctx);
743        assert!(result.is_ok(), "Should handle multi-byte UTF-8 characters");
744
745        let warnings = result.unwrap();
746        assert_eq!(warnings.len(), 2, "Should find 2 occurrences of multiple spaces");
747    }
748
749    #[test]
750    fn test_table_rows_skipped() {
751        let rule = MD064NoMultipleConsecutiveSpaces::new();
752
753        // Table rows with alignment padding should be skipped
754        let content = "| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1   | Cell 2   |";
755        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
756        let result = rule.check(&ctx).unwrap();
757        // Table rows should be skipped (alignment padding is intentional)
758        assert!(result.is_empty());
759    }
760
761    #[test]
762    fn test_link_text_with_extra_spaces() {
763        let rule = MD064NoMultipleConsecutiveSpaces::new();
764
765        // Link text with extra spaces (should be flagged)
766        let content = "[Link   text](https://example.com)";
767        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
768        let result = rule.check(&ctx).unwrap();
769        assert_eq!(result.len(), 1, "Should flag extra spaces in link text");
770    }
771
772    #[test]
773    fn test_image_alt_with_extra_spaces() {
774        let rule = MD064NoMultipleConsecutiveSpaces::new();
775
776        // Image alt text with extra spaces (should be flagged)
777        let content = "![Alt   text](image.png)";
778        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
779        let result = rule.check(&ctx).unwrap();
780        assert_eq!(result.len(), 1, "Should flag extra spaces in image alt text");
781    }
782
783    #[test]
784    fn test_skip_list_marker_spaces() {
785        let rule = MD064NoMultipleConsecutiveSpaces::new();
786
787        // Spaces after list markers are handled by MD030, not MD064
788        let content = "*   Item with extra spaces after marker\n-   Another item\n+   Third item\n";
789        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
790        let result = rule.check(&ctx).unwrap();
791        assert!(result.is_empty());
792
793        // Ordered list markers
794        let content = "1.  Item one\n2.  Item two\n10. Item ten\n";
795        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
796        let result = rule.check(&ctx).unwrap();
797        assert!(result.is_empty());
798
799        // Indented list items should also be skipped
800        let content = "  *   Indented item\n    1.  Nested numbered item\n";
801        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
802        let result = rule.check(&ctx).unwrap();
803        assert!(result.is_empty());
804    }
805
806    #[test]
807    fn test_flag_spaces_in_list_content() {
808        let rule = MD064NoMultipleConsecutiveSpaces::new();
809
810        // Multiple spaces WITHIN list content should still be flagged
811        let content = "* Item with   extra spaces in content\n";
812        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
813        let result = rule.check(&ctx).unwrap();
814        assert_eq!(result.len(), 1, "Should flag extra spaces in list content");
815    }
816
817    #[test]
818    fn test_skip_reference_link_definition_spaces() {
819        let rule = MD064NoMultipleConsecutiveSpaces::new();
820
821        // Reference link definitions may have multiple spaces after the colon
822        let content = "[ref]:  https://example.com\n";
823        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
824        let result = rule.check(&ctx).unwrap();
825        assert!(result.is_empty());
826
827        // Multiple spaces
828        let content = "[reference-link]:   https://example.com \"Title\"\n";
829        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
830        let result = rule.check(&ctx).unwrap();
831        assert!(result.is_empty());
832    }
833
834    #[test]
835    fn test_skip_footnote_marker_spaces() {
836        let rule = MD064NoMultipleConsecutiveSpaces::new();
837
838        // Footnote definitions may have multiple spaces after the colon
839        let content = "[^1]:  Footnote with extra space\n";
840        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
841        let result = rule.check(&ctx).unwrap();
842        assert!(result.is_empty());
843
844        // Footnote with longer label
845        let content = "[^footnote-label]:   This is the footnote text.\n";
846        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
847        let result = rule.check(&ctx).unwrap();
848        assert!(result.is_empty());
849    }
850
851    #[test]
852    fn test_skip_definition_list_marker_spaces() {
853        let rule = MD064NoMultipleConsecutiveSpaces::new();
854
855        // Definition list markers (PHP Markdown Extra / Pandoc)
856        let content = "Term\n:   Definition with extra spaces\n";
857        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
858        let result = rule.check(&ctx).unwrap();
859        assert!(result.is_empty());
860
861        // Multiple definitions
862        let content = ":    Another definition\n";
863        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
864        let result = rule.check(&ctx).unwrap();
865        assert!(result.is_empty());
866    }
867
868    #[test]
869    fn test_skip_task_list_checkbox_spaces() {
870        let rule = MD064NoMultipleConsecutiveSpaces::new();
871
872        // Task list items may have extra spaces after checkbox
873        let content = "- [ ]  Task with extra space\n";
874        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
875        let result = rule.check(&ctx).unwrap();
876        assert!(result.is_empty());
877
878        // Checked task
879        let content = "- [x]  Completed task\n";
880        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
881        let result = rule.check(&ctx).unwrap();
882        assert!(result.is_empty());
883
884        // With asterisk marker
885        let content = "* [ ]  Task with asterisk marker\n";
886        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
887        let result = rule.check(&ctx).unwrap();
888        assert!(result.is_empty());
889    }
890
891    #[test]
892    fn test_skip_table_without_outer_pipes() {
893        let rule = MD064NoMultipleConsecutiveSpaces::new();
894
895        // GFM tables without outer pipes should be skipped
896        let content = "Col1      | Col2      | Col3\n";
897        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
898        let result = rule.check(&ctx).unwrap();
899        assert!(result.is_empty());
900
901        // Separator row
902        let content = "--------- | --------- | ---------\n";
903        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
904        let result = rule.check(&ctx).unwrap();
905        assert!(result.is_empty());
906
907        // Data row
908        let content = "Data1     | Data2     | Data3\n";
909        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
910        let result = rule.check(&ctx).unwrap();
911        assert!(result.is_empty());
912    }
913
914    #[test]
915    fn test_flag_spaces_in_footnote_content() {
916        let rule = MD064NoMultipleConsecutiveSpaces::new();
917
918        // Extra spaces WITHIN footnote text content should be flagged
919        let content = "[^1]: Footnote with   extra spaces in content.\n";
920        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
921        let result = rule.check(&ctx).unwrap();
922        assert_eq!(result.len(), 1, "Should flag extra spaces in footnote content");
923    }
924
925    #[test]
926    fn test_flag_spaces_in_reference_content() {
927        let rule = MD064NoMultipleConsecutiveSpaces::new();
928
929        // Extra spaces in the title of a reference link should be flagged
930        let content = "[ref]: https://example.com \"Title   with extra spaces\"\n";
931        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
932        let result = rule.check(&ctx).unwrap();
933        assert_eq!(result.len(), 1, "Should flag extra spaces in reference link title");
934    }
935
936    // === allow-sentence-double-space tests ===
937
938    #[test]
939    fn test_sentence_double_space_disabled_by_default() {
940        // Default config should flag double spaces after sentences
941        let rule = MD064NoMultipleConsecutiveSpaces::new();
942        let content = "First sentence.  Second sentence.";
943        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
944        let result = rule.check(&ctx).unwrap();
945        assert_eq!(result.len(), 1, "Default should flag 2 spaces after period");
946    }
947
948    #[test]
949    fn test_sentence_double_space_enabled_allows_period() {
950        // With allow_sentence_double_space, 2 spaces after period should be OK
951        let config = MD064Config {
952            allow_sentence_double_space: true,
953        };
954        let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
955
956        let content = "First sentence.  Second sentence.";
957        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
958        let result = rule.check(&ctx).unwrap();
959        assert!(result.is_empty(), "Should allow 2 spaces after period");
960    }
961
962    #[test]
963    fn test_sentence_double_space_enabled_allows_exclamation() {
964        let config = MD064Config {
965            allow_sentence_double_space: true,
966        };
967        let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
968
969        let content = "Wow!  That was great.";
970        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
971        let result = rule.check(&ctx).unwrap();
972        assert!(result.is_empty(), "Should allow 2 spaces after exclamation");
973    }
974
975    #[test]
976    fn test_sentence_double_space_enabled_allows_question() {
977        let config = MD064Config {
978            allow_sentence_double_space: true,
979        };
980        let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
981
982        let content = "Is this OK?  Yes it is.";
983        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
984        let result = rule.check(&ctx).unwrap();
985        assert!(result.is_empty(), "Should allow 2 spaces after question mark");
986    }
987
988    #[test]
989    fn test_sentence_double_space_flags_mid_sentence() {
990        // Even with allow_sentence_double_space, mid-sentence double spaces should be flagged
991        let config = MD064Config {
992            allow_sentence_double_space: true,
993        };
994        let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
995
996        let content = "Word  word in the middle.";
997        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
998        let result = rule.check(&ctx).unwrap();
999        assert_eq!(result.len(), 1, "Should flag 2 spaces mid-sentence");
1000    }
1001
1002    #[test]
1003    fn test_sentence_double_space_flags_triple_after_period() {
1004        // 3+ spaces after sentence should still be flagged
1005        let config = MD064Config {
1006            allow_sentence_double_space: true,
1007        };
1008        let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1009
1010        let content = "First sentence.   Three spaces here.";
1011        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1012        let result = rule.check(&ctx).unwrap();
1013        assert_eq!(result.len(), 1, "Should flag 3 spaces even after period");
1014    }
1015
1016    #[test]
1017    fn test_sentence_double_space_with_closing_quote() {
1018        // "Quoted sentence."  Next sentence.
1019        let config = MD064Config {
1020            allow_sentence_double_space: true,
1021        };
1022        let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1023
1024        let content = r#"He said "Hello."  Then he left."#;
1025        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1026        let result = rule.check(&ctx).unwrap();
1027        assert!(result.is_empty(), "Should allow 2 spaces after .\" ");
1028
1029        // With single quote
1030        let content = "She said 'Goodbye.'  And she was gone.";
1031        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1032        let result = rule.check(&ctx).unwrap();
1033        assert!(result.is_empty(), "Should allow 2 spaces after .' ");
1034    }
1035
1036    #[test]
1037    fn test_sentence_double_space_with_curly_quotes() {
1038        let config = MD064Config {
1039            allow_sentence_double_space: true,
1040        };
1041        let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1042
1043        // Curly double quote: U+201C (") and U+201D (")
1044        // Build string with actual Unicode characters
1045        let content = format!(
1046            "He said {}Hello.{}  Then left.",
1047            '\u{201C}', // "
1048            '\u{201D}'  // "
1049        );
1050        let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1051        let result = rule.check(&ctx).unwrap();
1052        assert!(result.is_empty(), "Should allow 2 spaces after curly double quote");
1053
1054        // Curly single quote: U+2018 (') and U+2019 (')
1055        let content = format!(
1056            "She said {}Hi.{}  And left.",
1057            '\u{2018}', // '
1058            '\u{2019}'  // '
1059        );
1060        let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1061        let result = rule.check(&ctx).unwrap();
1062        assert!(result.is_empty(), "Should allow 2 spaces after curly single quote");
1063    }
1064
1065    #[test]
1066    fn test_sentence_double_space_with_closing_paren() {
1067        let config = MD064Config {
1068            allow_sentence_double_space: true,
1069        };
1070        let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1071
1072        let content = "(See reference.)  The next point is.";
1073        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1074        let result = rule.check(&ctx).unwrap();
1075        assert!(result.is_empty(), "Should allow 2 spaces after .) ");
1076    }
1077
1078    #[test]
1079    fn test_sentence_double_space_with_closing_bracket() {
1080        let config = MD064Config {
1081            allow_sentence_double_space: true,
1082        };
1083        let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1084
1085        let content = "[Citation needed.]  More text here.";
1086        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1087        let result = rule.check(&ctx).unwrap();
1088        assert!(result.is_empty(), "Should allow 2 spaces after .] ");
1089    }
1090
1091    #[test]
1092    fn test_sentence_double_space_with_ellipsis() {
1093        let config = MD064Config {
1094            allow_sentence_double_space: true,
1095        };
1096        let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1097
1098        let content = "He paused...  Then continued.";
1099        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1100        let result = rule.check(&ctx).unwrap();
1101        assert!(result.is_empty(), "Should allow 2 spaces after ellipsis");
1102    }
1103
1104    #[test]
1105    fn test_sentence_double_space_complex_ending() {
1106        // Multiple closing punctuation: .")
1107        let config = MD064Config {
1108            allow_sentence_double_space: true,
1109        };
1110        let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1111
1112        let content = r#"(He said "Yes.")  Then they agreed."#;
1113        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1114        let result = rule.check(&ctx).unwrap();
1115        assert!(result.is_empty(), "Should allow 2 spaces after .\") ");
1116    }
1117
1118    #[test]
1119    fn test_sentence_double_space_mixed_content() {
1120        // Mix of sentence endings and mid-sentence spaces
1121        let config = MD064Config {
1122            allow_sentence_double_space: true,
1123        };
1124        let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1125
1126        let content = "Good sentence.  Bad  mid-sentence.  Another good one!  OK?  Yes.";
1127        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1128        let result = rule.check(&ctx).unwrap();
1129        assert_eq!(result.len(), 1, "Should only flag mid-sentence double space");
1130        assert!(
1131            result[0].column > 15 && result[0].column < 25,
1132            "Should flag the 'Bad  mid' double space"
1133        );
1134    }
1135
1136    #[test]
1137    fn test_sentence_double_space_fix_collapses_to_two() {
1138        // Fix should collapse 3+ spaces to 2 after sentence, 1 elsewhere
1139        let config = MD064Config {
1140            allow_sentence_double_space: true,
1141        };
1142        let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1143
1144        let content = "Sentence.   Three spaces here.";
1145        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1146        let fixed = rule.fix(&ctx).unwrap();
1147        assert_eq!(
1148            fixed, "Sentence.  Three spaces here.",
1149            "Should collapse to 2 spaces after sentence"
1150        );
1151    }
1152
1153    #[test]
1154    fn test_sentence_double_space_fix_collapses_mid_sentence_to_one() {
1155        // Fix should collapse mid-sentence spaces to 1
1156        let config = MD064Config {
1157            allow_sentence_double_space: true,
1158        };
1159        let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1160
1161        let content = "Word  word here.";
1162        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1163        let fixed = rule.fix(&ctx).unwrap();
1164        assert_eq!(fixed, "Word word here.", "Should collapse to 1 space mid-sentence");
1165    }
1166
1167    #[test]
1168    fn test_sentence_double_space_config_kebab_case() {
1169        let toml_str = r#"
1170            allow-sentence-double-space = true
1171        "#;
1172        let config: MD064Config = toml::from_str(toml_str).unwrap();
1173        assert!(config.allow_sentence_double_space);
1174    }
1175
1176    #[test]
1177    fn test_sentence_double_space_config_snake_case() {
1178        let toml_str = r#"
1179            allow_sentence_double_space = true
1180        "#;
1181        let config: MD064Config = toml::from_str(toml_str).unwrap();
1182        assert!(config.allow_sentence_double_space);
1183    }
1184
1185    #[test]
1186    fn test_sentence_double_space_at_line_start() {
1187        // Period at very start shouldn't cause issues
1188        let config = MD064Config {
1189            allow_sentence_double_space: true,
1190        };
1191        let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1192
1193        // This is an edge case - spaces at start are leading indentation
1194        let content = ".  Text after period at start.";
1195        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1196        // This should not panic
1197        let _result = rule.check(&ctx).unwrap();
1198    }
1199
1200    #[test]
1201    fn test_sentence_double_space_guillemets() {
1202        // French-style quotes (guillemets)
1203        let config = MD064Config {
1204            allow_sentence_double_space: true,
1205        };
1206        let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1207
1208        let content = "Il a dit «Oui.»  Puis il est parti.";
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 .» (guillemet)");
1212    }
1213
1214    #[test]
1215    fn test_sentence_double_space_multiple_sentences() {
1216        // Multiple consecutive sentences with double spacing
1217        let config = MD064Config {
1218            allow_sentence_double_space: true,
1219        };
1220        let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1221
1222        let content = "First.  Second.  Third.  Fourth.";
1223        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1224        let result = rule.check(&ctx).unwrap();
1225        assert!(result.is_empty(), "Should allow all sentence-ending double spaces");
1226    }
1227
1228    #[test]
1229    fn test_sentence_double_space_abbreviation_detection() {
1230        // Known abbreviations should NOT be treated as sentence endings
1231        let config = MD064Config {
1232            allow_sentence_double_space: true,
1233        };
1234        let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1235
1236        // "Dr.  Smith" - Dr. is a known abbreviation, should be flagged
1237        let content = "Dr.  Smith arrived.";
1238        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1239        let result = rule.check(&ctx).unwrap();
1240        assert_eq!(result.len(), 1, "Should flag Dr. as abbreviation, not sentence ending");
1241
1242        // "Prof.  Williams" - Prof. is a known abbreviation
1243        let content = "Prof.  Williams teaches.";
1244        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1245        let result = rule.check(&ctx).unwrap();
1246        assert_eq!(result.len(), 1, "Should flag Prof. as abbreviation");
1247
1248        // "e.g.  this" - e.g. is a known abbreviation
1249        let content = "Use e.g.  this example.";
1250        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1251        let result = rule.check(&ctx).unwrap();
1252        assert_eq!(result.len(), 1, "Should flag e.g. as abbreviation");
1253
1254        // Unknown abbreviation-like words are treated as potential sentence endings
1255        // "Inc.  Next" - Inc. is NOT in our abbreviation list
1256        let content = "Acme Inc.  Next company.";
1257        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1258        let result = rule.check(&ctx).unwrap();
1259        assert!(
1260            result.is_empty(),
1261            "Inc. not in abbreviation list, treated as sentence end"
1262        );
1263    }
1264
1265    #[test]
1266    fn test_sentence_double_space_default_config_has_correct_defaults() {
1267        let config = MD064Config::default();
1268        assert!(
1269            !config.allow_sentence_double_space,
1270            "Default allow_sentence_double_space should be false"
1271        );
1272    }
1273
1274    #[test]
1275    fn test_sentence_double_space_from_config_integration() {
1276        use crate::config::Config;
1277        use std::collections::BTreeMap;
1278
1279        let mut config = Config::default();
1280        let mut values = BTreeMap::new();
1281        values.insert("allow-sentence-double-space".to_string(), toml::Value::Boolean(true));
1282        config.rules.insert(
1283            "MD064".to_string(),
1284            crate::config::RuleConfig { severity: None, values },
1285        );
1286
1287        let rule = MD064NoMultipleConsecutiveSpaces::from_config(&config);
1288
1289        // Verify the rule uses the loaded config
1290        let content = "Sentence.  Two spaces OK.  But three   is not.";
1291        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1292        let result = rule.check(&ctx).unwrap();
1293        assert_eq!(result.len(), 1, "Should only flag the triple spaces");
1294    }
1295}