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