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