Skip to main content

rumdl_lib/rules/
md027_multiple_spaces_blockquote.rs

1use crate::utils::range_utils::calculate_match_range;
2
3use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, Severity};
4use regex::Regex;
5use std::sync::LazyLock;
6
7// New patterns for detecting malformed blockquote attempts where user intent is clear
8static MALFORMED_BLOCKQUOTE_PATTERNS: LazyLock<Vec<(Regex, &'static str)>> = LazyLock::new(|| {
9    vec![
10        // Double > without space: >>text (looks like nested but missing spaces)
11        (
12            Regex::new(r"^(\s*)>>([^\s>].*|$)").unwrap(),
13            "missing spaces in nested blockquote",
14        ),
15        // Triple > without space: >>>text
16        (
17            Regex::new(r"^(\s*)>>>([^\s>].*|$)").unwrap(),
18            "missing spaces in deeply nested blockquote",
19        ),
20        // Space then > then text: > >text (extra > by mistake)
21        (
22            Regex::new(r"^(\s*)>\s+>([^\s>].*|$)").unwrap(),
23            "extra blockquote marker",
24        ),
25        // Multiple spaces then >: (spaces)>text (indented blockquote without space)
26        (
27            Regex::new(r"^(\s{4,})>([^\s].*|$)").unwrap(),
28            "indented blockquote missing space",
29        ),
30    ]
31});
32
33// Cached regex for blockquote validation
34static BLOCKQUOTE_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*>").unwrap());
35
36/// Rule MD027: No multiple spaces after blockquote symbol
37///
38/// See [docs/md027.md](../../docs/md027.md) for full documentation, configuration, and examples.
39
40#[derive(Debug, Default, Clone)]
41pub struct MD027MultipleSpacesBlockquote;
42
43impl Rule for MD027MultipleSpacesBlockquote {
44    fn name(&self) -> &'static str {
45        "MD027"
46    }
47
48    fn description(&self) -> &'static str {
49        "Multiple spaces after quote marker (>)"
50    }
51
52    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
53        let mut warnings = Vec::new();
54
55        for (line_idx, line_info) in ctx.lines.iter().enumerate() {
56            let line_num = line_idx + 1;
57
58            // Skip lines in code blocks and HTML blocks
59            if line_info.in_code_block || line_info.in_html_block {
60                continue;
61            }
62
63            // Check if this line is a blockquote using cached info
64            if let Some(blockquote) = &line_info.blockquote {
65                // Part 1: Check for multiple spaces after the blockquote marker
66                // Skip if line is in a list block - extra spaces may be list continuation indent
67                // Also skip if previous line in same blockquote context had a list item
68                // (covers cases where list block detection doesn't catch all continuation lines)
69                let is_likely_list_continuation =
70                    ctx.is_in_list_block(line_num) || self.previous_blockquote_line_had_list(ctx, line_idx);
71                if blockquote.has_multiple_spaces_after_marker && !is_likely_list_continuation {
72                    // Find where the extra spaces start in the line
73                    // We need to find the position after the markers and first space/tab
74                    let mut byte_pos = 0;
75                    let mut found_markers = 0;
76                    let mut found_first_space = false;
77
78                    for (i, ch) in line_info.content(ctx.content).char_indices() {
79                        if found_markers < blockquote.nesting_level {
80                            if ch == '>' {
81                                found_markers += 1;
82                            }
83                        } else if !found_first_space && (ch == ' ' || ch == '\t') {
84                            // This is the first space/tab after markers
85                            found_first_space = true;
86                        } else if found_first_space && (ch == ' ' || ch == '\t') {
87                            // This is where extra spaces start
88                            byte_pos = i;
89                            break;
90                        }
91                    }
92
93                    // Count how many extra spaces/tabs there are
94                    let extra_spaces_bytes = line_info.content(ctx.content)[byte_pos..]
95                        .chars()
96                        .take_while(|&c| c == ' ' || c == '\t')
97                        .fold(0, |acc, ch| acc + ch.len_utf8());
98
99                    if extra_spaces_bytes > 0 {
100                        let (start_line, start_col, end_line, end_col) = calculate_match_range(
101                            line_num,
102                            line_info.content(ctx.content),
103                            byte_pos,
104                            extra_spaces_bytes,
105                        );
106
107                        warnings.push(LintWarning {
108                            rule_name: Some(self.name().to_string()),
109                            line: start_line,
110                            column: start_col,
111                            end_line,
112                            end_column: end_col,
113                            message: "Multiple spaces after quote marker (>)".to_string(),
114                            severity: Severity::Warning,
115                            fix: Some(Fix {
116                                range: {
117                                    let start_byte = ctx.line_index.line_col_to_byte_range(line_num, start_col).start;
118                                    let end_byte = ctx.line_index.line_col_to_byte_range(line_num, end_col).start;
119                                    start_byte..end_byte
120                                },
121                                replacement: "".to_string(), // Remove the extra spaces
122                            }),
123                        });
124                    }
125                }
126            } else {
127                // Part 2: Check for malformed blockquote attempts on non-blockquote lines
128                let malformed_attempts = self.detect_malformed_blockquote_attempts(line_info.content(ctx.content));
129                for (start, len, fixed_line, description) in malformed_attempts {
130                    let (start_line, start_col, end_line, end_col) =
131                        calculate_match_range(line_num, line_info.content(ctx.content), start, len);
132
133                    warnings.push(LintWarning {
134                        rule_name: Some(self.name().to_string()),
135                        line: start_line,
136                        column: start_col,
137                        end_line,
138                        end_column: end_col,
139                        message: format!("Malformed quote: {description}"),
140                        severity: Severity::Warning,
141                        fix: Some(Fix {
142                            range: ctx.line_index.line_col_to_byte_range_with_length(
143                                line_num,
144                                1,
145                                line_info.content(ctx.content).chars().count(),
146                            ),
147                            replacement: fixed_line,
148                        }),
149                    });
150                }
151            }
152        }
153
154        Ok(warnings)
155    }
156
157    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
158        let mut result = Vec::with_capacity(ctx.lines.len());
159
160        for (line_idx, line_info) in ctx.lines.iter().enumerate() {
161            let line_num = line_idx + 1;
162
163            // Skip lines where this rule is disabled by inline config
164            if ctx.inline_config().is_rule_disabled(self.name(), line_num) {
165                result.push(line_info.content(ctx.content).to_string());
166                continue;
167            }
168
169            if let Some(blockquote) = &line_info.blockquote {
170                // Fix blockquotes with multiple spaces after the marker
171                // Skip if line is in a list block - extra spaces are list continuation indent
172                let is_likely_list_continuation =
173                    ctx.is_in_list_block(line_num) || self.previous_blockquote_line_had_list(ctx, line_idx);
174                if blockquote.has_multiple_spaces_after_marker && !is_likely_list_continuation {
175                    // Rebuild the line with exactly one space after the markers
176                    // But don't add a space if the content is empty to avoid MD009 conflicts
177                    let fixed_line = if blockquote.content.is_empty() {
178                        format!("{}{}", blockquote.indent, ">".repeat(blockquote.nesting_level))
179                    } else {
180                        format!(
181                            "{}{} {}",
182                            blockquote.indent,
183                            ">".repeat(blockquote.nesting_level),
184                            blockquote.content
185                        )
186                    };
187                    result.push(fixed_line);
188                } else {
189                    result.push(line_info.content(ctx.content).to_string());
190                }
191            } else {
192                // Check for malformed blockquote attempts
193                let malformed_attempts = self.detect_malformed_blockquote_attempts(line_info.content(ctx.content));
194                if !malformed_attempts.is_empty() {
195                    // Use the first fix (there should only be one per line)
196                    let (_, _, fixed_line, _) = &malformed_attempts[0];
197                    result.push(fixed_line.clone());
198                } else {
199                    result.push(line_info.content(ctx.content).to_string());
200                }
201            }
202        }
203
204        // Preserve trailing newline if original content had one
205        Ok(result.join("\n") + if ctx.content.ends_with('\n') { "\n" } else { "" })
206    }
207
208    fn as_any(&self) -> &dyn std::any::Any {
209        self
210    }
211
212    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
213    where
214        Self: Sized,
215    {
216        Box::new(MD027MultipleSpacesBlockquote)
217    }
218
219    /// Check if this rule should be skipped
220    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
221        ctx.content.is_empty() || !ctx.likely_has_blockquotes()
222    }
223}
224
225impl MD027MultipleSpacesBlockquote {
226    /// Check if a previous line in the same blockquote context had a list item
227    /// This helps identify list continuation lines even when list block detection
228    /// doesn't catch all continuation lines
229    fn previous_blockquote_line_had_list(&self, ctx: &crate::lint_context::LintContext, line_idx: usize) -> bool {
230        // Look backwards for a blockquote line with a list item
231        // Stop when we hit a non-blockquote line or find a list item
232        for prev_idx in (0..line_idx).rev() {
233            let prev_line = &ctx.lines[prev_idx];
234
235            // If previous line is not a blockquote, stop searching
236            if prev_line.blockquote.is_none() {
237                return false;
238            }
239
240            // If previous line has a list item, this could be list continuation
241            if prev_line.list_item.is_some() {
242                return true;
243            }
244
245            // If it's in a list block, that's also good enough
246            if ctx.is_in_list_block(prev_idx + 1) {
247                return true;
248            }
249        }
250        false
251    }
252
253    /// Detect malformed blockquote attempts where user intent is clear
254    fn detect_malformed_blockquote_attempts(&self, line: &str) -> Vec<(usize, usize, String, String)> {
255        let mut results = Vec::new();
256
257        for (pattern, issue_type) in MALFORMED_BLOCKQUOTE_PATTERNS.iter() {
258            if let Some(cap) = pattern.captures(line) {
259                let match_obj = cap.get(0).unwrap();
260                let start = match_obj.start();
261                let len = match_obj.len();
262
263                // Extract potential blockquote components
264                if let Some((fixed_line, description)) = self.extract_blockquote_fix_from_match(&cap, issue_type, line)
265                {
266                    // Only proceed if this looks like a genuine blockquote attempt
267                    if self.looks_like_blockquote_attempt(line, &fixed_line) {
268                        results.push((start, len, fixed_line, description));
269                    }
270                }
271            }
272        }
273
274        results
275    }
276
277    /// Extract the proper blockquote format from a malformed match
278    fn extract_blockquote_fix_from_match(
279        &self,
280        cap: &regex::Captures,
281        issue_type: &str,
282        _original_line: &str,
283    ) -> Option<(String, String)> {
284        match issue_type {
285            "missing spaces in nested blockquote" => {
286                // >>text -> > > text
287                let indent = cap.get(1).map_or("", |m| m.as_str());
288                let content = cap.get(2).map_or("", |m| m.as_str());
289                Some((
290                    format!("{}> > {}", indent, content.trim()),
291                    "Missing spaces in nested blockquote".to_string(),
292                ))
293            }
294            "missing spaces in deeply nested blockquote" => {
295                // >>>text -> > > > text
296                let indent = cap.get(1).map_or("", |m| m.as_str());
297                let content = cap.get(2).map_or("", |m| m.as_str());
298                Some((
299                    format!("{}> > > {}", indent, content.trim()),
300                    "Missing spaces in deeply nested blockquote".to_string(),
301                ))
302            }
303            "extra blockquote marker" => {
304                // > >text -> > text
305                let indent = cap.get(1).map_or("", |m| m.as_str());
306                let content = cap.get(2).map_or("", |m| m.as_str());
307                Some((
308                    format!("{}> {}", indent, content.trim()),
309                    "Extra blockquote marker".to_string(),
310                ))
311            }
312            "indented blockquote missing space" => {
313                // (spaces)>text -> (spaces)> text
314                let indent = cap.get(1).map_or("", |m| m.as_str());
315                let content = cap.get(2).map_or("", |m| m.as_str());
316                Some((
317                    format!("{}> {}", indent, content.trim()),
318                    "Indented blockquote missing space".to_string(),
319                ))
320            }
321            _ => None,
322        }
323    }
324
325    /// Check if the pattern looks like a genuine blockquote attempt
326    fn looks_like_blockquote_attempt(&self, original: &str, fixed: &str) -> bool {
327        // Basic heuristics to avoid false positives
328
329        // 1. Content should not be too short (avoid flagging things like ">>>" alone)
330        let trimmed_original = original.trim();
331        if trimmed_original.len() < 5 {
332            // More restrictive
333            return false;
334        }
335
336        // 2. Should contain some text content after the markers
337        let content_after_markers = trimmed_original.trim_start_matches('>').trim_start_matches(' ');
338        if content_after_markers.is_empty() || content_after_markers.len() < 3 {
339            // More restrictive
340            return false;
341        }
342
343        // 3. Content should contain some alphabetic characters (not just symbols)
344        if !content_after_markers.chars().any(|c| c.is_alphabetic()) {
345            return false;
346        }
347
348        // 4. Fixed version should actually be a valid blockquote
349        // Check if it starts with optional whitespace followed by >
350        if !BLOCKQUOTE_PATTERN.is_match(fixed) {
351            return false;
352        }
353
354        // 5. Avoid flagging things that might be code or special syntax
355        if content_after_markers.starts_with('#') // Headers
356            || content_after_markers.starts_with('[') // Links
357            || content_after_markers.starts_with('`') // Code
358            || content_after_markers.starts_with("http") // URLs
359            || content_after_markers.starts_with("www.") // URLs
360            || content_after_markers.starts_with("ftp")
361        // URLs
362        {
363            return false;
364        }
365
366        // 6. Content should look like prose, not code or markup
367        let word_count = content_after_markers.split_whitespace().count();
368        if word_count < 3 {
369            // Should be at least 3 words to look like prose
370            return false;
371        }
372
373        true
374    }
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380    use crate::lint_context::LintContext;
381
382    #[test]
383    fn test_valid_blockquote() {
384        let rule = MD027MultipleSpacesBlockquote;
385        let content = "> This is a blockquote\n> > Nested quote";
386        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
387        let result = rule.check(&ctx).unwrap();
388        assert!(result.is_empty(), "Valid blockquotes should not be flagged");
389    }
390
391    #[test]
392    fn test_multiple_spaces_after_marker() {
393        let rule = MD027MultipleSpacesBlockquote;
394        let content = ">  This has two spaces\n>   This has three spaces";
395        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
396        let result = rule.check(&ctx).unwrap();
397        assert_eq!(result.len(), 2);
398        assert_eq!(result[0].line, 1);
399        assert_eq!(result[0].column, 3); // Points to the extra space (after > and first space)
400        assert_eq!(result[0].message, "Multiple spaces after quote marker (>)");
401        assert_eq!(result[1].line, 2);
402        assert_eq!(result[1].column, 3);
403    }
404
405    #[test]
406    fn test_nested_multiple_spaces() {
407        let rule = MD027MultipleSpacesBlockquote;
408        // LintContext sees these as single-level blockquotes because of the space between markers
409        let content = ">  Two spaces after marker\n>>  Two spaces in nested blockquote";
410        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
411        let result = rule.check(&ctx).unwrap();
412        assert_eq!(result.len(), 2);
413        assert!(result[0].message.contains("Multiple spaces"));
414        assert!(result[1].message.contains("Multiple spaces"));
415    }
416
417    #[test]
418    fn test_malformed_nested_quote() {
419        let rule = MD027MultipleSpacesBlockquote;
420        // LintContext sees >>text as a valid nested blockquote with no space after marker
421        // MD027 doesn't flag this as malformed, only as missing space after marker
422        let content = ">>This is a nested blockquote without space after markers";
423        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
424        let result = rule.check(&ctx).unwrap();
425        // This should not be flagged at all since >>text is valid CommonMark
426        assert_eq!(result.len(), 0);
427    }
428
429    #[test]
430    fn test_malformed_deeply_nested() {
431        let rule = MD027MultipleSpacesBlockquote;
432        // LintContext sees >>>text as a valid triple-nested blockquote
433        let content = ">>>This is deeply nested without spaces after markers";
434        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
435        let result = rule.check(&ctx).unwrap();
436        // This should not be flagged - >>>text is valid CommonMark
437        assert_eq!(result.len(), 0);
438    }
439
440    #[test]
441    fn test_extra_quote_marker() {
442        let rule = MD027MultipleSpacesBlockquote;
443        // "> >text" is parsed as single-level blockquote with ">text" as content
444        // This is valid CommonMark and not detected as malformed
445        let content = "> >This looks like nested but is actually single level with >This as content";
446        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
447        let result = rule.check(&ctx).unwrap();
448        assert_eq!(result.len(), 0);
449    }
450
451    #[test]
452    fn test_indented_missing_space() {
453        let rule = MD027MultipleSpacesBlockquote;
454        // 4+ spaces makes this a code block, not a blockquote
455        let content = "   >This has 3 spaces indent and no space after marker";
456        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
457        let result = rule.check(&ctx).unwrap();
458        // LintContext sees this as a blockquote with no space after marker
459        // MD027 doesn't flag this as malformed
460        assert_eq!(result.len(), 0);
461    }
462
463    #[test]
464    fn test_fix_multiple_spaces() {
465        let rule = MD027MultipleSpacesBlockquote;
466        let content = ">  Two spaces\n>   Three spaces";
467        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
468        let fixed = rule.fix(&ctx).unwrap();
469        assert_eq!(fixed, "> Two spaces\n> Three spaces");
470    }
471
472    #[test]
473    fn test_fix_malformed_quotes() {
474        let rule = MD027MultipleSpacesBlockquote;
475        // These are valid nested blockquotes, not malformed
476        let content = ">>Nested without spaces\n>>>Deeply nested without spaces";
477        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
478        let fixed = rule.fix(&ctx).unwrap();
479        // No fix needed - these are valid
480        assert_eq!(fixed, content);
481    }
482
483    #[test]
484    fn test_fix_extra_marker() {
485        let rule = MD027MultipleSpacesBlockquote;
486        // This is valid - single blockquote with >Extra as content
487        let content = "> >Extra marker here";
488        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
489        let fixed = rule.fix(&ctx).unwrap();
490        // No fix needed
491        assert_eq!(fixed, content);
492    }
493
494    #[test]
495    fn test_code_block_ignored() {
496        let rule = MD027MultipleSpacesBlockquote;
497        let content = "```\n>  This is in a code block\n```";
498        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
499        let result = rule.check(&ctx).unwrap();
500        assert!(result.is_empty(), "Code blocks should be ignored");
501    }
502
503    #[test]
504    fn test_short_content_not_flagged() {
505        let rule = MD027MultipleSpacesBlockquote;
506        let content = ">>>\n>>";
507        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
508        let result = rule.check(&ctx).unwrap();
509        assert!(result.is_empty(), "Very short content should not be flagged");
510    }
511
512    #[test]
513    fn test_non_prose_not_flagged() {
514        let rule = MD027MultipleSpacesBlockquote;
515        let content = ">>#header\n>>[link]\n>>`code`\n>>http://example.com";
516        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
517        let result = rule.check(&ctx).unwrap();
518        assert!(result.is_empty(), "Non-prose content should not be flagged");
519    }
520
521    #[test]
522    fn test_preserve_trailing_newline() {
523        let rule = MD027MultipleSpacesBlockquote;
524        let content = ">  Two spaces\n";
525        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
526        let fixed = rule.fix(&ctx).unwrap();
527        assert_eq!(fixed, "> Two spaces\n");
528
529        let content_no_newline = ">  Two spaces";
530        let ctx2 = LintContext::new(content_no_newline, crate::config::MarkdownFlavor::Standard, None);
531        let fixed2 = rule.fix(&ctx2).unwrap();
532        assert_eq!(fixed2, "> Two spaces");
533    }
534
535    #[test]
536    fn test_mixed_issues() {
537        let rule = MD027MultipleSpacesBlockquote;
538        let content = ">  Multiple spaces here\n>>Normal nested quote\n> Normal quote";
539        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
540        let result = rule.check(&ctx).unwrap();
541        assert_eq!(result.len(), 1, "Should only flag the multiple spaces");
542        assert_eq!(result[0].line, 1);
543    }
544
545    #[test]
546    fn test_looks_like_blockquote_attempt() {
547        let rule = MD027MultipleSpacesBlockquote;
548
549        // Should return true for genuine attempts
550        assert!(rule.looks_like_blockquote_attempt(
551            ">>This is a real blockquote attempt with text",
552            "> > This is a real blockquote attempt with text"
553        ));
554
555        // Should return false for too short
556        assert!(!rule.looks_like_blockquote_attempt(">>>", "> > >"));
557
558        // Should return false for no alphabetic content
559        assert!(!rule.looks_like_blockquote_attempt(">>123", "> > 123"));
560
561        // Should return false for code-like content
562        assert!(!rule.looks_like_blockquote_attempt(">>#header", "> > #header"));
563    }
564
565    #[test]
566    fn test_extract_blockquote_fix() {
567        let rule = MD027MultipleSpacesBlockquote;
568        let regex = Regex::new(r"^(\s*)>>([^\s>].*|$)").unwrap();
569        let cap = regex.captures(">>content").unwrap();
570
571        let result = rule.extract_blockquote_fix_from_match(&cap, "missing spaces in nested blockquote", ">>content");
572        assert!(result.is_some());
573        let (fixed, desc) = result.unwrap();
574        assert_eq!(fixed, "> > content");
575        assert!(desc.contains("Missing spaces"));
576    }
577
578    #[test]
579    fn test_empty_blockquote() {
580        let rule = MD027MultipleSpacesBlockquote;
581        let content = ">\n>  \n> content";
582        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
583        let result = rule.check(&ctx).unwrap();
584        // Empty blockquotes with multiple spaces should still be flagged
585        assert_eq!(result.len(), 1);
586        assert_eq!(result[0].line, 2);
587    }
588
589    #[test]
590    fn test_fix_preserves_indentation() {
591        let rule = MD027MultipleSpacesBlockquote;
592        let content = "  >  Indented with multiple spaces";
593        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
594        let fixed = rule.fix(&ctx).unwrap();
595        assert_eq!(fixed, "  > Indented with multiple spaces");
596    }
597
598    #[test]
599    fn test_tabs_after_marker_not_flagged() {
600        // MD027 only flags multiple SPACES, not tabs
601        // Tabs after blockquote markers are handled by MD010 (no-hard-tabs)
602        // This matches markdownlint reference behavior
603        let rule = MD027MultipleSpacesBlockquote;
604
605        // Tab after marker - NOT flagged by MD027 (that's MD010's job)
606        let content = ">\tTab after marker";
607        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
608        let result = rule.check(&ctx).unwrap();
609        assert_eq!(result.len(), 0, "Single tab should not be flagged by MD027");
610
611        // Two tabs after marker - NOT flagged by MD027
612        let content2 = ">\t\tTwo tabs";
613        let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
614        let result2 = rule.check(&ctx2).unwrap();
615        assert_eq!(result2.len(), 0, "Tabs should not be flagged by MD027");
616    }
617
618    #[test]
619    fn test_mixed_spaces_and_tabs() {
620        let rule = MD027MultipleSpacesBlockquote;
621        // Space then tab - only flags if there are multiple spaces
622        // The tab itself is MD010's domain
623        let content = ">  Space Space";
624        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
625        let result = rule.check(&ctx).unwrap();
626        assert_eq!(result.len(), 1);
627        assert_eq!(result[0].column, 3); // Points to the extra space
628
629        // Three spaces should be flagged
630        let content2 = ">   Three spaces";
631        let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
632        let result2 = rule.check(&ctx2).unwrap();
633        assert_eq!(result2.len(), 1);
634    }
635
636    #[test]
637    fn test_fix_multiple_spaces_various() {
638        let rule = MD027MultipleSpacesBlockquote;
639        // Fix should remove extra spaces
640        let content = ">   Three spaces";
641        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
642        let fixed = rule.fix(&ctx).unwrap();
643        assert_eq!(fixed, "> Three spaces");
644
645        // Fix multiple spaces
646        let content2 = ">    Four spaces";
647        let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
648        let fixed2 = rule.fix(&ctx2).unwrap();
649        assert_eq!(fixed2, "> Four spaces");
650    }
651
652    #[test]
653    fn test_list_continuation_inside_blockquote_not_flagged() {
654        // List continuation indentation inside blockquotes should NOT be flagged
655        // This matches markdownlint-cli behavior
656        let rule = MD027MultipleSpacesBlockquote;
657
658        // List with continuation inside blockquote
659        let content = "> - Item starts here\n>   This continues the item\n> - Another item";
660        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
661        let result = rule.check(&ctx).unwrap();
662        assert!(
663            result.is_empty(),
664            "List continuation inside blockquote should not be flagged, got: {result:?}"
665        );
666
667        // Multiple list items with continuations
668        let content2 = "> * First item\n>   First item continuation\n>   Still continuing\n> * Second item";
669        let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
670        let result2 = rule.check(&ctx2).unwrap();
671        assert!(
672            result2.is_empty(),
673            "List continuations should not be flagged, got: {result2:?}"
674        );
675    }
676
677    #[test]
678    fn test_list_continuation_fix_preserves_indentation() {
679        // Ensure fix doesn't break list continuation indentation
680        let rule = MD027MultipleSpacesBlockquote;
681
682        let content = "> - Item\n>   continuation";
683        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
684        let fixed = rule.fix(&ctx).unwrap();
685        // Should preserve the list continuation indentation
686        assert_eq!(fixed, "> - Item\n>   continuation");
687    }
688
689    #[test]
690    fn test_non_list_multiple_spaces_still_flagged() {
691        // Non-list lines with multiple spaces should still be flagged
692        let rule = MD027MultipleSpacesBlockquote;
693
694        // Just extra spaces, not a list
695        let content = ">  This has extra spaces";
696        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
697        let result = rule.check(&ctx).unwrap();
698        assert_eq!(result.len(), 1, "Non-list line should be flagged");
699    }
700}