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