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