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