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