Skip to main content

rumdl_lib/rules/
md027_multiple_spaces_blockquote.rs

1use crate::utils::html_block::{opens_tag_line_html_block, opens_untagged_html_block, parse_html_block_start};
2use crate::utils::list_fix_guard::{Allowed, drop_mdx_structure_changing_fixes, drop_structure_changing_fixes};
3use crate::utils::range_utils::calculate_match_range;
4
5use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
6use crate::rule_config_serde::{RuleConfig, load_rule_config};
7use regex::Regex;
8use serde::{Deserialize, Serialize};
9use std::sync::LazyLock;
10
11/// Configuration for MD027 (Multiple spaces after blockquote symbol).
12///
13/// `list_items` mirrors markdownlint's option but rumdl's default is `false`
14/// rather than `true`. See `docs/markdownlint-comparison.md` for the rationale:
15/// list items inside blockquotes inherently need extra indentation, so flagging
16/// them by default produces noise. Set `list-items = true` to opt into the
17/// strict markdownlint behavior.
18#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
19#[serde(rename_all = "kebab-case")]
20pub struct MD027Config {
21    /// When `true`, also flag blockquoted lines that introduce or continue a
22    /// list item. When `false` (default), such lines are skipped.
23    #[serde(default, alias = "list_items")]
24    pub list_items: bool,
25}
26
27impl RuleConfig for MD027Config {
28    const RULE_NAME: &'static str = "MD027";
29}
30
31// New patterns for detecting malformed blockquote attempts where user intent is clear
32static MALFORMED_BLOCKQUOTE_PATTERNS: LazyLock<Vec<(Regex, &'static str)>> = LazyLock::new(|| {
33    vec![
34        // Double > without space: >>text (looks like nested but missing spaces)
35        (
36            Regex::new(r"^(\s*)>>([^\s>].*|$)").unwrap(),
37            "missing spaces in nested blockquote",
38        ),
39        // Triple > without space: >>>text
40        (
41            Regex::new(r"^(\s*)>>>([^\s>].*|$)").unwrap(),
42            "missing spaces in deeply nested blockquote",
43        ),
44        // Space then > then text: > >text (extra > by mistake)
45        (
46            Regex::new(r"^(\s*)>\s+>([^\s>].*|$)").unwrap(),
47            "extra blockquote marker",
48        ),
49        // Multiple spaces then >: (spaces)>text (indented blockquote without space)
50        (
51            Regex::new(r"^(\s{4,})>([^\s].*|$)").unwrap(),
52            "indented blockquote missing space",
53        ),
54    ]
55});
56
57// Cached regex for blockquote validation
58static BLOCKQUOTE_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*>").unwrap());
59
60/// An item of a list inside a quote, in columns counted from the first
61/// character after the quote markers and the one space that may follow them.
62#[derive(Clone, Copy)]
63struct QuotedItem {
64    /// The marker character, or the delimiter of an ordered marker with the top
65    /// bit set: items of one list share it.
66    kind: u8,
67    /// The column a continuation of the item has to reach.
68    content_column: usize,
69    /// Nothing has followed the marker yet: a blank line then ends the item.
70    empty: bool,
71}
72
73/// The list items open at a quoted line.
74///
75/// Whitespace that reaches an open item's content column nests or continues the
76/// item, and a marker of another kind than the item it closes opens a new list.
77/// Neither is spacing after the quote marker. The indentation of a sibling in the
78/// same list is.
79#[derive(Default)]
80struct QuotedItems {
81    quote_level: usize,
82    items: Vec<QuotedItem>,
83    /// A paragraph runs into the next line, which may then continue it lazily.
84    paragraph: bool,
85    /// The line above is a row of a table, which no later line continues lazily.
86    table: bool,
87    /// The line above is inside an HTML block, which runs to the next blank line.
88    html: bool,
89    /// The cells of the header a delimiter row on the next line would belong to:
90    /// the line above when it opened the running paragraph.
91    header_cells: Option<usize>,
92    /// The quotes this one is nested in, outermost first, as their lines left them.
93    parents: Vec<QuotedItems>,
94}
95
96impl QuotedItems {
97    /// Reads the quoted `line` (nested `level` deep) and returns whether its
98    /// whitespace belongs to the list structure of an item opened earlier.
99    fn enters(&mut self, line: &str, level: usize) -> bool {
100        if level > self.quote_level && self.quote_level > 0 {
101            self.close_before_nested_quote(line);
102        }
103        self.enter_quote(level);
104        let mut seen = 0;
105        let mut rest = line;
106        while seen < level {
107            let Some(at) = rest.find('>') else { return false };
108            rest = &rest[at + 1..];
109            seen += 1;
110        }
111        let (edge, rest) = Self::quote_content(line, rest);
112        let text = rest.trim_start_matches([' ', '\t']);
113        let indent = Self::columns(&line[..line.len() - text.len()]) - edge;
114        if text.is_empty() {
115            if self.items.last().is_some_and(|item| item.empty) {
116                self.items.pop();
117            }
118            self.paragraph = false;
119            self.table = false;
120            self.html = false;
121            self.header_cells = None;
122            return false;
123        }
124        let breaks = Self::is_thematic_break(text);
125        let mut marker = Self::marker_len(text).filter(|_| !breaks);
126        // A line of plain text under a running paragraph continues it lazily and
127        // keeps the items open; anything else is measured against them.
128        let lazy = self.paragraph && marker.is_none() && !breaks && !self.items.is_empty() && !Self::starts_block(text);
129        let mut shallowest_closed = None;
130        if !lazy {
131            while let Some(item) = self.items.last().filter(|item| item.content_column > indent) {
132                shallowest_closed = Some(*item);
133                self.items.pop();
134            }
135        }
136        if shallowest_closed.is_some() {
137            self.table = false;
138            self.html = false;
139        }
140        // A paragraph running into the line, inside the container the line
141        // re-enters, is only interrupted by a marker that can start a list while
142        // it runs: not an empty item, and not an ordered one starting past 1.
143        if self.paragraph && shallowest_closed.is_none() && marker.is_some_and(|(.., interrupts)| !interrupts) {
144            marker = None;
145        }
146        if let Some(item) = self.items.last_mut() {
147            item.empty = false;
148        }
149        let mut belongs = self.items.iter().any(|item| item.content_column <= indent);
150        // Four columns past the enclosing item's content make code or a
151        // paragraph continuation, not a marker.
152        let base = self.items.last().map_or(0, |item| item.content_column);
153        // Written four columns past that, a line starts no block and no list.
154        let shallow = indent - base.min(indent) < 4;
155        let starts_block = |text: &str| shallow && Self::starts_block(text);
156        let breaks = shallow && breaks;
157        let marker = marker.filter(|_| shallow);
158        let mut first_text = marker.map_or(text, |(width, ..)| text[width..].trim_start_matches([' ', '\t']));
159        if let Some(first) = marker {
160            belongs |= shallowest_closed.is_some_and(|item| item.kind != first.2);
161            // Markers written one after another (`- - a`) each open an item
162            // inside the one before.
163            let mut current = Some(first);
164            let mut at = text;
165            let mut column = indent;
166            while let Some((width, padding, kind, _)) = current {
167                let after = &at[width..];
168                let marker_end = Self::columns(&line[..line.len() - after.len()]);
169                let rest = after.trim_start_matches([' ', '\t']);
170                let gap = Self::columns(&line[..line.len() - rest.len()]) - marker_end;
171                let spaced = (1..=4).contains(&gap) && padding < after.len();
172                let content_column = column + width + if spaced { gap } else { 1 };
173                self.items.push(QuotedItem {
174                    kind,
175                    content_column,
176                    empty: rest.is_empty(),
177                });
178                at = rest;
179                column = content_column;
180                current = Some(at)
181                    .filter(|at| spaced && !at.is_empty() && !Self::is_thematic_break(at))
182                    .and_then(Self::marker_len);
183            }
184            first_text = at;
185        }
186        // A run of `=` or `-` written inside the container of the paragraph above
187        // turns it into a heading, which no later line continues.
188        let run = text.trim_end_matches([' ', '\t']);
189        let reentered = self.items.iter().all(|item| item.content_column <= indent);
190        let underlines = self.paragraph
191            && shallow
192            && marker.is_none()
193            && (run.bytes().all(|byte| byte == b'=') || run.bytes().all(|byte| byte == b'-'))
194            && reentered;
195        // A delimiter row turns the paragraph above into the header of a table,
196        // whose rows then run on until a line that is not one.
197        self.table = marker.is_none()
198            && !breaks
199            && !starts_block(text)
200            && (self.table
201                || (self.paragraph
202                    && shallow
203                    && !underlines
204                    && reentered
205                    && Self::is_table_delimiter(text)
206                    && self.header_cells == Some(Self::cells(text))));
207        let continues = self.paragraph && marker.is_none();
208        self.html |= first_text.starts_with('<')
209            && (starts_block(first_text) || (!continues && opens_tag_line_html_block(first_text)));
210        self.paragraph =
211            !breaks && !underlines && !self.table && !self.html && !first_text.is_empty() && !starts_block(first_text);
212        self.header_cells = self.paragraph.then(|| Self::cells(first_text));
213        belongs
214    }
215
216    /// Reads a line without a quote marker: plain text under a running paragraph
217    /// continues it lazily and keeps the quote; any other line ends the quote.
218    fn unquoted_line(&mut self, line: &str) {
219        let text = line.trim_start_matches([' ', '\t']);
220        let deep = Self::columns(&line[..line.len() - text.len()]) >= 4;
221        let lazy = self.paragraph
222            && !text.is_empty()
223            && (deep
224                || (!Self::starts_block(text) && !Self::is_thematic_break(text) && Self::marker_len(text).is_none()));
225        if !lazy {
226            *self = Self::default();
227        }
228    }
229
230    /// A quote nested inside this one stays in the items it is indented into; the
231    /// items it is written to the left of are closed by it.
232    fn close_before_nested_quote(&mut self, line: &str) {
233        let mut rest = line;
234        for _ in 0..self.quote_level {
235            let Some(at) = rest.find('>') else { return };
236            rest = &rest[at + 1..];
237        }
238        let (edge, rest) = Self::quote_content(line, rest);
239        let text = rest.trim_start_matches([' ', '\t']);
240        let indent = Self::columns(&line[..line.len() - text.len()]) - edge;
241        while self.items.last().is_some_and(|item| item.content_column > indent) {
242            self.items.pop();
243        }
244    }
245
246    /// Switches to the items of the quote nested `level` deep. A quote nested
247    /// inside an item leaves the quote around it as it was, to be picked up again
248    /// when a line returns to that depth; a depth not seen before starts empty.
249    fn enter_quote(&mut self, level: usize) {
250        if level == self.quote_level {
251            return;
252        }
253        let nested_paragraph = self.paragraph;
254        let mut outer = std::mem::take(&mut self.parents);
255        if level > self.quote_level {
256            if self.quote_level > 0 {
257                outer.push(std::mem::take(self));
258            }
259        } else {
260            while outer.last().is_some_and(|parent| parent.quote_level > level) {
261                outer.pop();
262            }
263        }
264        *self = match outer.pop_if(|parent| parent.quote_level == level) {
265            // The line back in the outer quote continues the paragraph the nested
266            // one ended in, if any, and nothing the outer one had running.
267            Some(parent) => Self {
268                paragraph: level < self.quote_level && nested_paragraph,
269                table: false,
270                html: false,
271                header_cells: None,
272                ..parent
273            },
274            None => Self {
275                quote_level: level,
276                ..Self::default()
277            },
278        };
279        self.parents = outer;
280    }
281
282    /// Reads a line inside a code block of the quote: it closes the items it is
283    /// not indented into and leaves no paragraph running.
284    fn code_line(&mut self, line: &str, level: usize) {
285        if level > self.quote_level && self.quote_level > 0 {
286            self.close_before_nested_quote(line);
287        }
288        self.enter_quote(level);
289        let mut rest = line;
290        for _ in 0..level {
291            let Some(at) = rest.find('>') else { return };
292            rest = &rest[at + 1..];
293        }
294        let (edge, rest) = Self::quote_content(line, rest);
295        let text = rest.trim_start_matches([' ', '\t']);
296        let indent = Self::columns(&line[..line.len() - text.len()]) - edge;
297        if !text.is_empty() {
298            while self.items.last().is_some_and(|item| item.content_column > indent) {
299                self.items.pop();
300            }
301        }
302        self.paragraph = false;
303    }
304
305    /// The column a line's text reaches after `prefix`, with a tab running to the
306    /// next multiple of four.
307    fn columns(prefix: &str) -> usize {
308        prefix
309            .chars()
310            .fold(0, |column, c| if c == '\t' { (column / 4 + 1) * 4 } else { column + 1 })
311    }
312
313    /// The column the content of the quote starts at, and the text from there.
314    /// `after_marker` is what follows the innermost `>` of `line`; the `>` takes
315    /// one space or tab, and a tab reaching further leaves the rest as indent.
316    fn quote_content<'a>(line: &str, after_marker: &'a str) -> (usize, &'a str) {
317        let marker_end = Self::columns(&line[..line.len() - after_marker.len()]);
318        match after_marker.strip_prefix([' ', '\t']) {
319            Some(rest) => (marker_end + 1, rest),
320            None => (marker_end, after_marker),
321        }
322    }
323
324    /// Whether `text` opens a block that is not paragraph text: it cannot be a
325    /// lazy continuation line, and no paragraph runs on from it.
326    fn starts_block(text: &str) -> bool {
327        let hashes = text.bytes().take_while(|&byte| byte == b'#').count();
328        match text.as_bytes()[0] {
329            b'#' => (1..=6).contains(&hashes) && (text.len() == hashes || text[hashes..].starts_with([' ', '\t'])),
330            b'>' => true,
331            b'<' => parse_html_block_start(text).is_some() || opens_untagged_html_block(text),
332            b'`' => {
333                let ticks = text.bytes().take_while(|&byte| byte == b'`').count();
334                ticks >= 3 && !text[ticks..].contains('`')
335            }
336            b'~' => text.starts_with("~~~"),
337            _ => false,
338        }
339    }
340
341    /// How many cells a table row of `text` has: what lies between its pipes,
342    /// the outer ones optional.
343    fn cells(text: &str) -> usize {
344        let row = text.trim_matches([' ', '\t']);
345        let row = row.strip_prefix('|').unwrap_or(row);
346        let row = row.strip_suffix('|').filter(|row| !row.ends_with('\\')).unwrap_or(row);
347        let mut escaped = false;
348        1 + row
349            .chars()
350            .filter(|&c| {
351                let separator = c == '|' && !escaped;
352                escaped = c == '\\' && !escaped;
353                separator
354            })
355            .count()
356    }
357
358    /// A table's delimiter row: every cell is dashes with optional colons at
359    /// either end, and the row has a pipe.
360    fn is_table_delimiter(text: &str) -> bool {
361        let row = text.trim_matches([' ', '\t']);
362        text.contains('|')
363            && row
364                .strip_prefix('|')
365                .unwrap_or(row)
366                .strip_suffix('|')
367                .unwrap_or_else(|| row.strip_prefix('|').unwrap_or(row))
368                .split('|')
369                .all(|cell| {
370                    let cell = cell.trim_matches([' ', '\t']);
371                    let dashes = cell.strip_prefix(':').unwrap_or(cell);
372                    let dashes = dashes.strip_suffix(':').unwrap_or(dashes);
373                    !dashes.is_empty() && dashes.bytes().all(|byte| byte == b'-')
374                })
375    }
376
377    /// A line of three or more `-`, `_` or `*`, spaces allowed between them.
378    fn is_thematic_break(text: &str) -> bool {
379        let Some(first) = text.chars().next().filter(|c| matches!(c, '-' | '_' | '*')) else {
380            return false;
381        };
382        let mut count = 0;
383        for c in text.chars() {
384            if c == first {
385                count += 1;
386            } else if !matches!(c, ' ' | '\t') {
387                return false;
388            }
389        }
390        count >= 3
391    }
392
393    /// Width of a list marker opening `text`, the whitespace that follows it, the
394    /// marker's kind, and whether it could interrupt a running paragraph.
395    fn marker_len(text: &str) -> Option<(usize, usize, u8, bool)> {
396        let bytes = text.as_bytes();
397        let (width, kind, starts_list) = match bytes[0] {
398            b @ (b'-' | b'+' | b'*') => (1, b, true),
399            b'0'..=b'9' => {
400                let digits = bytes.iter().take_while(|b| b.is_ascii_digit()).count();
401                match bytes.get(digits) {
402                    Some(&d @ (b'.' | b')')) if digits <= 9 => (digits + 1, d | 0x80, text[..digits].parse() == Ok(1)),
403                    _ => return None,
404                }
405            }
406            _ => return None,
407        };
408        let padding = match bytes.get(width) {
409            None => 0,
410            Some(b' ' | b'\t') => text[width..].len() - text[width..].trim_start_matches([' ', '\t']).len(),
411            _ => return None,
412        };
413        let interrupts = starts_list && padding < text.len() - width;
414        Some((width, padding, kind, interrupts))
415    }
416}
417
418/// Rule MD027: No multiple spaces after blockquote symbol
419///
420/// See [docs/md027.md](../../docs/md027.md) for full documentation, configuration, and examples.
421#[derive(Debug, Default, Clone)]
422pub struct MD027MultipleSpacesBlockquote {
423    config: MD027Config,
424}
425
426impl MD027MultipleSpacesBlockquote {
427    pub fn new() -> Self {
428        Self::default()
429    }
430
431    pub fn with_config(config: MD027Config) -> Self {
432        Self { config }
433    }
434}
435
436impl Rule for MD027MultipleSpacesBlockquote {
437    fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
438        (self.config == Default::default()).then_some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(27))
439    }
440
441    fn name(&self) -> &'static str {
442        "MD027"
443    }
444
445    fn description(&self) -> &'static str {
446        "Multiple spaces after quote marker (>)"
447    }
448
449    fn category(&self) -> RuleCategory {
450        RuleCategory::Blockquote
451    }
452
453    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
454        // Spacing fixes only trim whitespace, so they must leave the document's
455        // structure alone. Malformed-quote fixes exist to create a quote, so they
456        // are held to nothing.
457        let mut spacing = Vec::new();
458        let mut malformed = Vec::new();
459        let mut quoted_items = QuotedItems::default();
460
461        let previous_blockquote_line_had_list = if self.config.list_items {
462            Vec::new()
463        } else {
464            Self::previous_blockquote_lines_had_list(ctx)
465        };
466        for (line_idx, line_info) in ctx.lines.iter().enumerate() {
467            let line_num = line_idx + 1;
468            let marker_byte = line_info.byte_offset + line_info.indent;
469            if line_info.in_esm_block
470                || ctx.overlaps_template_code(marker_byte, marker_byte + 1)
471                || ctx.overlaps_mdx_inline_code(marker_byte, marker_byte + 1)
472            {
473                continue;
474            }
475
476            if let Some(blockquote) = &line_info.blockquote {
477                if line_info.in_code_block {
478                    // An indented block can begin on the line of a list marker, past
479                    // the marker's padding: the marker still opens its item.
480                    let line_end = line_info.byte_offset + line_info.content(ctx.content).len();
481                    let opens_on_line = ctx
482                        .code_block_details
483                        .iter()
484                        .any(|block| !block.is_fenced && (line_info.byte_offset..line_end).contains(&block.start));
485                    if opens_on_line {
486                        quoted_items.enters(line_info.content(ctx.content), blockquote.nesting_level);
487                        quoted_items.paragraph = false;
488                    } else {
489                        quoted_items.code_line(line_info.content(ctx.content), blockquote.nesting_level);
490                    }
491                }
492            } else {
493                quoted_items.unquoted_line(line_info.content(ctx.content));
494            }
495
496            // Skip lines in code blocks, HTML blocks, and other skippable regions
497            if line_info.in_code_block
498                || line_info.in_html_block
499                || line_info.in_html_comment
500                || line_info.in_mdx_comment
501                || line_info.in_front_matter
502                || line_info.in_mkdocstrings
503                || line_info.in_jsx_block
504                || line_info.in_kramdown_extension_block
505            {
506                continue;
507            }
508
509            // Check if this line is a blockquote using cached info
510            if let Some(blockquote) = &line_info.blockquote {
511                let mut inside_item = quoted_items.enters(line_info.content(ctx.content), blockquote.nesting_level);
512                // A definition's later paragraph is indented to belong to it; that
513                // indentation is structure, not spacing after the quote marker.
514                inside_item |= ctx
515                    .definition_text_at(line_num)
516                    .is_some_and(|text| text.marker_prefix_len.is_none());
517                // Part 1: Check for multiple spaces after the blockquote marker.
518                //
519                // When `list_items = false` (rumdl default), skip lines that are part
520                // of a list inside a blockquote — the extra spaces are list-indent,
521                // not formatting noise. When `list_items = true` (markdownlint default),
522                // flag those lines too.
523                let skip_list_lines = !self.config.list_items;
524                let is_likely_list_continuation = skip_list_lines
525                    && (ctx.is_in_list_block(line_num)
526                        || line_info.list_item.is_some()
527                        || previous_blockquote_line_had_list[line_idx]);
528                if blockquote.has_multiple_spaces_after_marker && !is_likely_list_continuation && !inside_item {
529                    // Find where the extra spaces start in the line
530                    // We need to find the position after the markers and first space/tab
531                    let mut byte_pos = 0;
532                    let mut found_markers = 0;
533                    let mut found_first_space = false;
534
535                    for (i, ch) in line_info.content(ctx.content).char_indices() {
536                        if found_markers < blockquote.nesting_level {
537                            if ch == '>' {
538                                found_markers += 1;
539                            }
540                        } else if !found_first_space && (ch == ' ' || ch == '\t') {
541                            // This is the first space/tab after markers
542                            found_first_space = true;
543                        } else if found_first_space && (ch == ' ' || ch == '\t') {
544                            // This is where extra spaces start
545                            byte_pos = i;
546                            break;
547                        }
548                    }
549
550                    // Count how many extra spaces/tabs there are
551                    let extra_spaces_bytes = line_info.content(ctx.content)[byte_pos..]
552                        .chars()
553                        .take_while(|&c| c == ' ' || c == '\t')
554                        .fold(0, |acc, ch| acc + ch.len_utf8());
555
556                    if extra_spaces_bytes > 0 {
557                        // When blockquote content is empty, remove all spaces
558                        // after the marker to avoid creating trailing whitespace
559                        let (fix_byte_pos, fix_bytes) = if blockquote.content.is_empty() {
560                            // Remove the first space too (byte_pos - 1 points to
561                            // the first space we skipped)
562                            let first_space_pos = byte_pos - 1;
563                            let all_spaces_bytes = line_info.content(ctx.content)[first_space_pos..]
564                                .chars()
565                                .take_while(|&c| c == ' ' || c == '\t')
566                                .fold(0, |acc, ch| acc + ch.len_utf8());
567                            (first_space_pos, all_spaces_bytes)
568                        } else {
569                            (byte_pos, extra_spaces_bytes)
570                        };
571
572                        let (start_line, start_col, end_line, end_col) =
573                            calculate_match_range(line_num, line_info.content(ctx.content), fix_byte_pos, fix_bytes);
574
575                        spacing.push(LintWarning {
576                            rule_name: Some(self.name().to_string()),
577                            line: start_line,
578                            column: start_col,
579                            end_line,
580                            end_column: end_col,
581                            message: "Multiple spaces after quote marker (>)".to_string(),
582                            severity: Severity::Warning,
583                            fix: Some(Fix::new(
584                                {
585                                    let start_byte = ctx.line_column_byte_range(line_num, start_col).start;
586                                    let end_byte = ctx.line_column_byte_range(line_num, end_col).start;
587                                    start_byte..end_byte
588                                },
589                                String::new(),
590                            )),
591                        });
592                    }
593                }
594            } else {
595                quoted_items.unquoted_line(line_info.content(ctx.content));
596                // Part 2: Check for malformed blockquote attempts on non-blockquote lines
597                let malformed_attempts = self.detect_malformed_blockquote_attempts(line_info.content(ctx.content));
598                for (start, len, fixed_line, description) in malformed_attempts {
599                    let (start_line, start_col, end_line, end_col) =
600                        calculate_match_range(line_num, line_info.content(ctx.content), start, len);
601
602                    malformed.push(LintWarning {
603                        rule_name: Some(self.name().to_string()),
604                        line: start_line,
605                        column: start_col,
606                        end_line,
607                        end_column: end_col,
608                        message: format!("Malformed quote: {description}"),
609                        severity: Severity::Warning,
610                        fix: Some(Fix::new(
611                            ctx.line_column_byte_range_with_length(
612                                line_num,
613                                1,
614                                line_info.content(ctx.content).chars().count(),
615                            ),
616                            fixed_line,
617                        )),
618                    });
619                }
620            }
621        }
622
623        // A line indented four or more columns past the marker continues the
624        // paragraph above it. Whatever it is shaped like, one space would turn it
625        // into a heading, list, fence or break, so it stays reported and unfixed.
626        // MDX reads no indented code, so its own parse decides what a fix changes.
627        if ctx.flavor == crate::config::MarkdownFlavor::MDX {
628            drop_mdx_structure_changing_fixes(ctx, &mut spacing);
629        } else {
630            drop_structure_changing_fixes(ctx, &mut spacing, Allowed::Nothing);
631        }
632
633        let mut warnings = spacing;
634        warnings.extend(malformed);
635        warnings.sort_by_key(|warning| warning.line);
636        Ok(warnings)
637    }
638
639    fn fixes_from_diagnostics(&self) -> bool {
640        true
641    }
642
643    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
644        if self.should_skip(ctx) {
645            return Ok(ctx.content.to_string());
646        }
647        let warnings = self.check(ctx)?;
648        if warnings.is_empty() {
649            return Ok(ctx.content.to_string());
650        }
651        let warnings =
652            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
653        crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
654            .map_err(crate::rule::LintError::InvalidInput)
655    }
656
657    fn as_any(&self) -> &dyn std::any::Any {
658        self
659    }
660
661    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
662    where
663        Self: Sized,
664    {
665        let rule_config: MD027Config = load_rule_config(config);
666        Box::new(MD027MultipleSpacesBlockquote::with_config(rule_config))
667    }
668
669    crate::impl_rule_config_sections!(MD027Config);
670
671    /// Check if this rule should be skipped
672    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
673        ctx.content.is_empty() || !ctx.likely_has_blockquotes()
674    }
675}
676
677impl MD027MultipleSpacesBlockquote {
678    /// For each line, whether the run of blockquote lines directly above it holds a list
679    /// item or a line inside a list block. This helps identify list continuation lines
680    /// even when list block detection doesn't catch all continuation lines. A line that
681    /// is not in a blockquote ends the run.
682    fn previous_blockquote_lines_had_list(ctx: &crate::lint_context::LintContext) -> Vec<bool> {
683        let mut had_list = Vec::with_capacity(ctx.lines.len());
684        let mut run_has_list = false;
685        for (idx, line) in ctx.lines.iter().enumerate() {
686            had_list.push(run_has_list);
687            if line.blockquote.is_none() {
688                run_has_list = false;
689            } else if line.list_item.is_some() || ctx.is_in_list_block(idx + 1) {
690                run_has_list = true;
691            }
692        }
693        had_list
694    }
695
696    /// Detect malformed blockquote attempts where user intent is clear
697    fn detect_malformed_blockquote_attempts(&self, line: &str) -> Vec<(usize, usize, String, String)> {
698        let mut results = Vec::new();
699        // Every malformed pattern starts with Unicode whitespace followed by
700        // `>`. Ordinary prose cannot match any of them.
701        if !line.trim_start().starts_with('>') {
702            return results;
703        }
704
705        for (pattern, issue_type) in MALFORMED_BLOCKQUOTE_PATTERNS.iter() {
706            if let Some(cap) = pattern.captures(line) {
707                let match_obj = cap.get(0).unwrap();
708                let start = match_obj.start();
709                let len = match_obj.len();
710
711                // Extract potential blockquote components
712                if let Some((fixed_line, description)) = self.extract_blockquote_fix_from_match(&cap, issue_type, line)
713                {
714                    // Only proceed if this looks like a genuine blockquote attempt
715                    if self.looks_like_blockquote_attempt(line, &fixed_line) {
716                        results.push((start, len, fixed_line, description));
717                    }
718                }
719            }
720        }
721
722        results
723    }
724
725    /// Extract the proper blockquote format from a malformed match
726    fn extract_blockquote_fix_from_match(
727        &self,
728        cap: &regex::Captures,
729        issue_type: &str,
730        _original_line: &str,
731    ) -> Option<(String, String)> {
732        match issue_type {
733            "missing spaces in nested blockquote" => {
734                // >>text -> > > text
735                let indent = cap.get(1).map_or("", |m| m.as_str());
736                let content = cap.get(2).map_or("", |m| m.as_str());
737                Some((
738                    format!("{}> > {}", indent, content.trim()),
739                    "Missing spaces in nested blockquote".to_string(),
740                ))
741            }
742            "missing spaces in deeply nested blockquote" => {
743                // >>>text -> > > > text
744                let indent = cap.get(1).map_or("", |m| m.as_str());
745                let content = cap.get(2).map_or("", |m| m.as_str());
746                Some((
747                    format!("{}> > > {}", indent, content.trim()),
748                    "Missing spaces in deeply nested blockquote".to_string(),
749                ))
750            }
751            "extra blockquote marker" => {
752                // > >text -> > text
753                let indent = cap.get(1).map_or("", |m| m.as_str());
754                let content = cap.get(2).map_or("", |m| m.as_str());
755                Some((
756                    format!("{}> {}", indent, content.trim()),
757                    "Extra blockquote marker".to_string(),
758                ))
759            }
760            "indented blockquote missing space" => {
761                // (spaces)>text -> (spaces)> text
762                let indent = cap.get(1).map_or("", |m| m.as_str());
763                let content = cap.get(2).map_or("", |m| m.as_str());
764                Some((
765                    format!("{}> {}", indent, content.trim()),
766                    "Indented blockquote missing space".to_string(),
767                ))
768            }
769            _ => None,
770        }
771    }
772
773    /// Check if the pattern looks like a genuine blockquote attempt
774    fn looks_like_blockquote_attempt(&self, original: &str, fixed: &str) -> bool {
775        // Basic heuristics to avoid false positives
776
777        // 1. Content should not be too short (avoid flagging things like ">>>" alone)
778        let trimmed_original = original.trim();
779        if trimmed_original.len() < 5 {
780            // More restrictive
781            return false;
782        }
783
784        // 2. Should contain some text content after the markers
785        let content_after_markers = trimmed_original.trim_start_matches('>').trim_start_matches(' ');
786        if content_after_markers.is_empty() || content_after_markers.len() < 3 {
787            // More restrictive
788            return false;
789        }
790
791        // 3. Content should contain some alphabetic characters (not just symbols)
792        if !content_after_markers.chars().any(char::is_alphabetic) {
793            return false;
794        }
795
796        // 4. Fixed version should actually be a valid blockquote
797        // Check if it starts with optional whitespace followed by >
798        if !BLOCKQUOTE_PATTERN.is_match(fixed) {
799            return false;
800        }
801
802        // 5. Avoid flagging things that might be code or special syntax
803        if content_after_markers.starts_with('#') // Headers
804            || content_after_markers.starts_with('[') // Links
805            || content_after_markers.starts_with('`') // Code
806            || content_after_markers.starts_with("http") // URLs
807            || content_after_markers.starts_with("www.") // URLs
808            || content_after_markers.starts_with("ftp")
809        // URLs
810        {
811            return false;
812        }
813
814        // 6. Content should look like prose, not code or markup
815        let word_count = content_after_markers.split_whitespace().count();
816        if word_count < 3 {
817            // Should be at least 3 words to look like prose
818            return false;
819        }
820
821        true
822    }
823}
824
825#[cfg(test)]
826mod tests {
827    use super::*;
828    use crate::lint_context::LintContext;
829
830    #[test]
831    fn test_valid_blockquote() {
832        let rule = MD027MultipleSpacesBlockquote::default();
833        let content = "> This is a blockquote\n> > Nested quote";
834        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
835        let result = rule.check(&ctx).unwrap();
836        assert!(result.is_empty(), "Valid blockquotes should not be flagged");
837    }
838
839    #[test]
840    fn test_multiple_spaces_after_marker() {
841        let rule = MD027MultipleSpacesBlockquote::default();
842        let content = ">  This has two spaces\n>   This has three spaces";
843        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
844        let result = rule.check(&ctx).unwrap();
845        assert_eq!(result.len(), 2);
846        assert_eq!(result[0].line, 1);
847        assert_eq!(result[0].column, 3); // Points to the extra space (after > and first space)
848        assert_eq!(result[0].message, "Multiple spaces after quote marker (>)");
849        assert_eq!(result[1].line, 2);
850        assert_eq!(result[1].column, 3);
851    }
852
853    #[test]
854    fn test_nested_multiple_spaces() {
855        let rule = MD027MultipleSpacesBlockquote::default();
856        // LintContext sees these as single-level blockquotes because of the space between markers
857        let content = ">  Two spaces after marker\n>>  Two spaces in nested blockquote";
858        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
859        let result = rule.check(&ctx).unwrap();
860        assert_eq!(result.len(), 2);
861        assert!(result[0].message.contains("Multiple spaces"));
862        assert!(result[1].message.contains("Multiple spaces"));
863    }
864
865    #[test]
866    fn test_malformed_nested_quote() {
867        let rule = MD027MultipleSpacesBlockquote::default();
868        // LintContext sees >>text as a valid nested blockquote with no space after marker
869        // MD027 doesn't flag this as malformed, only as missing space after marker
870        let content = ">>This is a nested blockquote without space after markers";
871        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
872        let result = rule.check(&ctx).unwrap();
873        // This should not be flagged at all since >>text is valid CommonMark
874        assert_eq!(result.len(), 0);
875    }
876
877    #[test]
878    fn test_malformed_deeply_nested() {
879        let rule = MD027MultipleSpacesBlockquote::default();
880        // LintContext sees >>>text as a valid triple-nested blockquote
881        let content = ">>>This is deeply nested without spaces after markers";
882        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
883        let result = rule.check(&ctx).unwrap();
884        // This should not be flagged - >>>text is valid CommonMark
885        assert_eq!(result.len(), 0);
886    }
887
888    #[test]
889    fn test_extra_quote_marker() {
890        let rule = MD027MultipleSpacesBlockquote::default();
891        // "> >text" is parsed as single-level blockquote with ">text" as content
892        // This is valid CommonMark and not detected as malformed
893        let content = "> >This looks like nested but is actually single level with >This as content";
894        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
895        let result = rule.check(&ctx).unwrap();
896        assert_eq!(result.len(), 0);
897    }
898
899    #[test]
900    fn test_indented_missing_space() {
901        let rule = MD027MultipleSpacesBlockquote::default();
902        // 4+ spaces makes this a code block, not a blockquote
903        let content = "   >This has 3 spaces indent and no space after marker";
904        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
905        let result = rule.check(&ctx).unwrap();
906        // LintContext sees this as a blockquote with no space after marker
907        // MD027 doesn't flag this as malformed
908        assert_eq!(result.len(), 0);
909    }
910
911    #[test]
912    fn test_fix_multiple_spaces() {
913        let rule = MD027MultipleSpacesBlockquote::default();
914        let content = ">  Two spaces\n>   Three spaces";
915        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
916        let fixed = rule.fix(&ctx).unwrap();
917        assert_eq!(fixed, "> Two spaces\n> Three spaces");
918    }
919
920    #[test]
921    fn test_fix_malformed_quotes() {
922        let rule = MD027MultipleSpacesBlockquote::default();
923        // These are valid nested blockquotes, not malformed
924        let content = ">>Nested without spaces\n>>>Deeply nested without spaces";
925        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
926        let fixed = rule.fix(&ctx).unwrap();
927        // No fix needed - these are valid
928        assert_eq!(fixed, content);
929    }
930
931    #[test]
932    fn test_fix_extra_marker() {
933        let rule = MD027MultipleSpacesBlockquote::default();
934        // This is valid - single blockquote with >Extra as content
935        let content = "> >Extra marker here";
936        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
937        let fixed = rule.fix(&ctx).unwrap();
938        // No fix needed
939        assert_eq!(fixed, content);
940    }
941
942    #[test]
943    fn test_code_block_ignored() {
944        let rule = MD027MultipleSpacesBlockquote::default();
945        let content = "```\n>  This is in a code block\n```";
946        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
947        let result = rule.check(&ctx).unwrap();
948        assert!(result.is_empty(), "Code blocks should be ignored");
949    }
950
951    #[test]
952    fn test_short_content_not_flagged() {
953        let rule = MD027MultipleSpacesBlockquote::default();
954        let content = ">>>\n>>";
955        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
956        let result = rule.check(&ctx).unwrap();
957        assert!(result.is_empty(), "Very short content should not be flagged");
958    }
959
960    #[test]
961    fn test_non_prose_not_flagged() {
962        let rule = MD027MultipleSpacesBlockquote::default();
963        let content = ">>#header\n>>[link]\n>>`code`\n>>http://example.com";
964        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
965        let result = rule.check(&ctx).unwrap();
966        assert!(result.is_empty(), "Non-prose content should not be flagged");
967    }
968
969    #[test]
970    fn test_preserve_trailing_newline() {
971        let rule = MD027MultipleSpacesBlockquote::default();
972        let content = ">  Two spaces\n";
973        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
974        let fixed = rule.fix(&ctx).unwrap();
975        assert_eq!(fixed, "> Two spaces\n");
976
977        let content_no_newline = ">  Two spaces";
978        let ctx2 = LintContext::new(content_no_newline, crate::config::MarkdownFlavor::Standard, None);
979        let fixed2 = rule.fix(&ctx2).unwrap();
980        assert_eq!(fixed2, "> Two spaces");
981    }
982
983    #[test]
984    fn test_mixed_issues() {
985        let rule = MD027MultipleSpacesBlockquote::default();
986        let content = ">  Multiple spaces here\n>>Normal nested quote\n> Normal quote";
987        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
988        let result = rule.check(&ctx).unwrap();
989        assert_eq!(result.len(), 1, "Should only flag the multiple spaces");
990        assert_eq!(result[0].line, 1);
991    }
992
993    #[test]
994    fn test_looks_like_blockquote_attempt() {
995        let rule = MD027MultipleSpacesBlockquote::default();
996
997        // Should return true for genuine attempts
998        assert!(rule.looks_like_blockquote_attempt(
999            ">>This is a real blockquote attempt with text",
1000            "> > This is a real blockquote attempt with text"
1001        ));
1002
1003        // Should return false for too short
1004        assert!(!rule.looks_like_blockquote_attempt(">>>", "> > >"));
1005
1006        // Should return false for no alphabetic content
1007        assert!(!rule.looks_like_blockquote_attempt(">>123", "> > 123"));
1008
1009        // Should return false for code-like content
1010        assert!(!rule.looks_like_blockquote_attempt(">>#header", "> > #header"));
1011    }
1012
1013    #[test]
1014    fn test_extract_blockquote_fix() {
1015        let rule = MD027MultipleSpacesBlockquote::default();
1016        let regex = Regex::new(r"^(\s*)>>([^\s>].*|$)").unwrap();
1017        let cap = regex.captures(">>content").unwrap();
1018
1019        let result = rule.extract_blockquote_fix_from_match(&cap, "missing spaces in nested blockquote", ">>content");
1020        assert!(result.is_some());
1021        let (fixed, desc) = result.unwrap();
1022        assert_eq!(fixed, "> > content");
1023        assert!(desc.contains("Missing spaces"));
1024    }
1025
1026    #[test]
1027    fn test_empty_blockquote() {
1028        let rule = MD027MultipleSpacesBlockquote::default();
1029        let content = ">\n>  \n> content";
1030        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1031        let result = rule.check(&ctx).unwrap();
1032        // Empty blockquotes with multiple spaces should still be flagged
1033        assert_eq!(result.len(), 1);
1034        assert_eq!(result[0].line, 2);
1035    }
1036
1037    #[test]
1038    fn test_fix_preserves_indentation() {
1039        let rule = MD027MultipleSpacesBlockquote::default();
1040        let content = "  >  Indented with multiple spaces";
1041        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1042        let fixed = rule.fix(&ctx).unwrap();
1043        assert_eq!(fixed, "  > Indented with multiple spaces");
1044    }
1045
1046    #[test]
1047    fn test_tabs_after_marker_not_flagged() {
1048        // MD027 only flags multiple SPACES, not tabs
1049        // Tabs after blockquote markers are handled by MD010 (no-hard-tabs)
1050        // This matches markdownlint reference behavior
1051        let rule = MD027MultipleSpacesBlockquote::default();
1052
1053        // Tab after marker - NOT flagged by MD027 (that's MD010's job)
1054        let content = ">\tTab after marker";
1055        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1056        let result = rule.check(&ctx).unwrap();
1057        assert_eq!(result.len(), 0, "Single tab should not be flagged by MD027");
1058
1059        // Two tabs after marker - NOT flagged by MD027
1060        let content2 = ">\t\tTwo tabs";
1061        let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1062        let result2 = rule.check(&ctx2).unwrap();
1063        assert_eq!(result2.len(), 0, "Tabs should not be flagged by MD027");
1064    }
1065
1066    #[test]
1067    fn test_mixed_spaces_and_tabs() {
1068        let rule = MD027MultipleSpacesBlockquote::default();
1069        // Space then tab - only flags if there are multiple spaces
1070        // The tab itself is MD010's domain
1071        let content = ">  Space Space";
1072        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1073        let result = rule.check(&ctx).unwrap();
1074        assert_eq!(result.len(), 1);
1075        assert_eq!(result[0].column, 3); // Points to the extra space
1076
1077        // Three spaces should be flagged
1078        let content2 = ">   Three spaces";
1079        let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1080        let result2 = rule.check(&ctx2).unwrap();
1081        assert_eq!(result2.len(), 1);
1082    }
1083
1084    #[test]
1085    fn test_fix_multiple_spaces_various() {
1086        let rule = MD027MultipleSpacesBlockquote::default();
1087        // Fix should remove extra spaces
1088        let content = ">   Three spaces";
1089        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1090        let fixed = rule.fix(&ctx).unwrap();
1091        assert_eq!(fixed, "> Three spaces");
1092
1093        // Fix multiple spaces
1094        let content2 = ">    Four spaces";
1095        let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1096        let fixed2 = rule.fix(&ctx2).unwrap();
1097        assert_eq!(fixed2, "> Four spaces");
1098    }
1099
1100    #[test]
1101    fn test_list_continuation_inside_blockquote_not_flagged() {
1102        // List continuation indentation inside blockquotes should NOT be flagged
1103        // This matches markdownlint-cli behavior
1104        let rule = MD027MultipleSpacesBlockquote::default();
1105
1106        // List with continuation inside blockquote
1107        let content = "> - Item starts here\n>   This continues the item\n> - Another item";
1108        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1109        let result = rule.check(&ctx).unwrap();
1110        assert!(
1111            result.is_empty(),
1112            "List continuation inside blockquote should not be flagged, got: {result:?}"
1113        );
1114
1115        // Multiple list items with continuations
1116        let content2 = "> * First item\n>   First item continuation\n>   Still continuing\n> * Second item";
1117        let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1118        let result2 = rule.check(&ctx2).unwrap();
1119        assert!(
1120            result2.is_empty(),
1121            "List continuations should not be flagged, got: {result2:?}"
1122        );
1123    }
1124
1125    #[test]
1126    fn test_list_continuation_fix_preserves_indentation() {
1127        // Ensure fix doesn't break list continuation indentation
1128        let rule = MD027MultipleSpacesBlockquote::default();
1129
1130        let content = "> - Item\n>   continuation";
1131        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1132        let fixed = rule.fix(&ctx).unwrap();
1133        // Should preserve the list continuation indentation
1134        assert_eq!(fixed, "> - Item\n>   continuation");
1135    }
1136
1137    #[test]
1138    fn test_non_list_multiple_spaces_still_flagged() {
1139        // Non-list lines with multiple spaces should still be flagged
1140        let rule = MD027MultipleSpacesBlockquote::default();
1141
1142        // Just extra spaces, not a list
1143        let content = ">  This has extra spaces";
1144        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1145        let result = rule.check(&ctx).unwrap();
1146        assert_eq!(result.len(), 1, "Non-list line should be flagged");
1147    }
1148
1149    // =========================================================================
1150    // list_items config option tests
1151    // =========================================================================
1152
1153    #[test]
1154    fn test_list_items_default_false_skips_list_lines() {
1155        // rumdl default: list_items=false → list lines in blockquotes are skipped
1156        let rule = MD027MultipleSpacesBlockquote::default();
1157        let content = "# Test\n\n>  - item one\n>  - item two\n";
1158        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1159        let result = rule.check(&ctx).unwrap();
1160        assert!(
1161            result.is_empty(),
1162            "Default (list_items=false) should skip list-item lines, got {result:?}"
1163        );
1164    }
1165
1166    #[test]
1167    fn test_list_items_true_flags_unordered_list_lines() {
1168        // markdownlint-style strict: list_items=true → flag list-item lines
1169        let rule = MD027MultipleSpacesBlockquote::with_config(MD027Config { list_items: true });
1170        let content = "# Test\n\n>  - item one\n>  - item two\n";
1171        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1172        let result = rule.check(&ctx).unwrap();
1173        assert_eq!(
1174            result.len(),
1175            2,
1176            "list_items=true should flag both list-item lines, got {result:?}"
1177        );
1178        assert_eq!(result[0].line, 3);
1179        assert_eq!(result[1].line, 4);
1180    }
1181
1182    #[test]
1183    fn test_list_items_true_flags_ordered_list_lines() {
1184        let rule = MD027MultipleSpacesBlockquote::with_config(MD027Config { list_items: true });
1185        let content = "# Test\n\n>  1. first\n>  2. second\n";
1186        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1187        let result = rule.check(&ctx).unwrap();
1188        assert_eq!(
1189            result.len(),
1190            2,
1191            "list_items=true should flag ordered list-item lines, got {result:?}"
1192        );
1193    }
1194
1195    #[test]
1196    fn test_list_items_true_flags_list_continuation() {
1197        // Continuation line inside a blockquoted list should also fire
1198        let rule = MD027MultipleSpacesBlockquote::with_config(MD027Config { list_items: true });
1199        let content = "# Test\n\n>  - first item\n>  more list-y text\n";
1200        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1201        let result = rule.check(&ctx).unwrap();
1202        assert_eq!(
1203            result.len(),
1204            2,
1205            "list_items=true should flag both list-item and continuation, got {result:?}"
1206        );
1207    }
1208
1209    /// Lines `list_items = true` reports, with the markdownlint result for each
1210    /// document as the expectation.
1211    #[test]
1212    fn test_list_items_true_measures_indent_from_the_enclosing_item() {
1213        let rule = MD027MultipleSpacesBlockquote::with_config(MD027Config { list_items: true });
1214        for (content, expected) in [
1215            // Indentation that nests or continues an item is list structure.
1216            ("> - a\n>   - b\n", vec![]),
1217            ("> - a\n>\n>   para in a\n", vec![]),
1218            ("> 1. a\n>    - b\n>      - c\n", vec![]),
1219            ("> - a\n>     - b\n", vec![]),
1220            ("> - a\n>   continued\n", vec![]),
1221            ("> - a\n>\n>    over-indented para\n", vec![]),
1222            (">> - a\n>>   - b\n", vec![]),
1223            ("> - a\n>   - b\n>     - c\n>   - d\n", vec![]),
1224            // Controls: spacing the item structure does not account for.
1225            ("> - a\n>  - b\n", vec![2]),
1226            ("> 1. a\n>  2. b\n", vec![2]),
1227            ("> - a\n>  * b\n", vec![]),
1228            ("> - a\n>  1. b\n", vec![]),
1229            ("> 1. a\n>  1) b\n", vec![]),
1230            ("> text\n>     - b\n", vec![2]),
1231            (">   1. x\n>     * x\n", vec![1, 2]),
1232            ("> - a\n>>   x\n", vec![2]),
1233            ("> - a\n> >   x\n", vec![2]),
1234            (">  - a\n>   - b\n", vec![1, 2]),
1235            ("> a\n>   b\n", vec![2]),
1236            ("> - a\n> text\n>   x\n", vec![]),
1237            ("> - a\n>\n> text\n>   x\n", vec![4]),
1238            ("> - a\n\n>   x\n", vec![3]),
1239            (">>  - a\n>>   - b\n", vec![1, 2]),
1240            // A block that is not paragraph text ends the list, so what follows
1241            // is not a lazy continuation of it.
1242            ("> - a\n> ---\n>   text\n", vec![3]),
1243            ("> - a\n> * * *\n>   text\n", vec![3]),
1244            ("> - a\n> ```\n> code\n> ```\n>   text\n", vec![5]),
1245            ("> - a\n> ~~~\n> code\n> ~~~\n>   text\n", vec![5]),
1246            ("> -\n> text\n>   text\n", vec![3]),
1247            ("> - # heading\n> text\n>   text\n", vec![3]),
1248            // Controls: a fence inside the item, and text lazily continuing it.
1249            ("> - a\n>   ```\n>   code\n>   ```\n>   text\n", vec![]),
1250            ("> - a\n>   ```\n>   code\n>   ```\n> text\n>   x\n", vec![6]),
1251            ("> - a\n> text\n>   more\n", vec![]),
1252            // A marker that cannot interrupt a paragraph is its text, and a blank
1253            // line ends an item that is still empty.
1254            ("> text\n> 2. not a list\n>    bad spacing\n", vec![3]),
1255            ("> text\n> -\n>   bad spacing\n", vec![3]),
1256            ("> -\n>\n>   text\n", vec![3]),
1257            ("> - a\n>> ```\n>> code\n>> ```\n>   bad\n", vec![5]),
1258            ("> - a\n>   ===\n> text\n>   x\n", vec![4]),
1259            ("> - a\n> ===\n>   x\n", vec![]),
1260            ("> - a\n>   --\n> text\n>   bad\n", vec![4]),
1261            ("> - a | b\n>   --- | ---\n> outside\n>   bad\n", vec![4]),
1262            ("> `a`\n> -\n>   bad spacing\n", vec![3]),
1263            ("> a | b\n> --- | --- | ---\n> -\n>   bad spacing\n", vec![4]),
1264            ("> #hashtag\n> -\n>   bad spacing\n", vec![3]),
1265            ("> <3 love\n> -\n>   bad spacing\n", vec![3]),
1266            ("> - <em>a</em>\n>   ===\n> text\n>   bad\n", vec![4]),
1267            ("> - -\n> outside\n>   bad\n", vec![3]),
1268            ("> - - # heading\n> outside\n>   bad\n", vec![3]),
1269            ("> - - a\n>     b\n>   c\n", vec![]),
1270            ("> - a\n>   b | c\n>   --- | ---\n> outside\n>   bad\n", vec![5]),
1271            ("> - <span>\n> outside\n>   bad\n", vec![3]),
1272            ("> p\n>     # fake\n> 2. text\n>    c\n", vec![2, 4]),
1273            ("> p\nlazy\n    # fake\n> 2. text\n>    c\n", vec![5]),
1274            ("> p\n>     ---\n> 2. text\n>    c\n", vec![2, 4]),
1275            ("> a | b\n>     --- | ---\n> 2. x\n>    c\n", vec![2, 4]),
1276            ("> p\n>     <div>\n> 2. x\n>    c\n", vec![2, 4]),
1277            ("> p\n>     ```\n> 2. x\n>    c\n", vec![2, 4]),
1278            ("> - a\n>> q\n>\n>   - b\n>  - c\n", vec![4, 5]),
1279            ("> - a\n>   > # q\n> text\n>   bad\n", vec![4]),
1280            ("> - a\n>   > q\n> text\n>   bad\n", vec![]),
1281            ("# T\n\n> - a\n>   > q\n>\n>   - b\n", vec![]),
1282            ("# T\n\n> -     code\n>   - b\n", vec![]),
1283            ("# T\n\n> 1.     code\n>    1. b\n>  - x\n", vec![]),
1284            ("> 1.     code\n>  2. x\n", vec![2]),
1285            ("> -     code\n>  - x\n", vec![2]),
1286            ("> -     code\n>   - b\n>  - x\n", vec![3]),
1287            ("> - a\n>   > q\n>\n>   - b\n>  - c\n", vec![5]),
1288            ("> - a\n>   > q\n>   > - n\n>\n>   - b\n", vec![]),
1289            ("> text\nlazy\n> 2. not a list\n>    bad\n", vec![4]),
1290            ("> text\n\n> 2. a\n>    ok\n", vec![]),
1291            ("> a | b\n> --- | :\n> -\n>   bad\n", vec![4]),
1292            ("> a | b\n> --- | ---\n> -\n>   bad\n", vec![]),
1293            ("> text\n> ``` foo`bar\n> -\n>   bad\n", vec![4]),
1294            ("> text\n> ~~~ foo`bar\n> -\n>   bad\n", vec![]),
1295            ("> text\n> - <span>\n>   inside\n>   bad\n", vec![]),
1296            ("> text\n> - <span>\n> outside\n>   bad\n", vec![4]),
1297            ("> text\n> <span>\n> -\n>   bad\n", vec![4]),
1298            ("> - <div>\n>   ===\n> text\n>   bad\n", vec![4]),
1299            ("> - a \\| b | c\n>   --- | ---\n> outside\n>   bad\n", vec![4]),
1300            ("> - a | b\n>   --- | ---\n>   c | d\n> outside\n>   bad\n", vec![5]),
1301            ("> - a | b\n> --- | ---\n> text\n>   x\n", vec![]),
1302            ("> - a\n>   -\n> text\n>   bad\n", vec![4]),
1303            ("> - a\n>   2. b\n>      c\n", vec![]),
1304            ("> text\n> 1. is a list\n>    c\n", vec![]),
1305            ("> -\n>   text\n", vec![]),
1306            // A fence outside the quote ends it, and a tab pads a marker to the next
1307            // multiple of four columns.
1308            ("> - a\n```\ncode\n```\n>   text\n", vec![5]),
1309            ("> 1.\ta\n>\n>    b\n", vec![3]),
1310            ("> 1.\ta\n>\n>        b\n", vec![]),
1311            ("> - a\n>\t- b\n", vec![]),
1312        ] {
1313            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1314            let lines: Vec<usize> = rule.check(&ctx).unwrap().iter().map(|w| w.line).collect();
1315            assert_eq!(lines, expected, "{content:?}");
1316        }
1317    }
1318
1319    #[test]
1320    fn test_list_items_default_skips_continuation() {
1321        // Continuation line inside a blockquoted list is skipped by default
1322        let rule = MD027MultipleSpacesBlockquote::default();
1323        let content = "# Test\n\n>  - first item\n>  more list-y text\n";
1324        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1325        let result = rule.check(&ctx).unwrap();
1326        assert!(
1327            result.is_empty(),
1328            "Default should skip both list-item and continuation, got {result:?}"
1329        );
1330    }
1331
1332    #[test]
1333    fn test_plain_blockquote_text_flagged_in_both_modes() {
1334        let content = "# Test\n\n>  Plain blockquote text with extra space.\n";
1335        for cfg in [MD027Config { list_items: false }, MD027Config { list_items: true }] {
1336            let rule = MD027MultipleSpacesBlockquote::with_config(cfg.clone());
1337            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1338            let result = rule.check(&ctx).unwrap();
1339            assert_eq!(
1340                result.len(),
1341                1,
1342                "Plain blockquote text with extra spaces should always be flagged (cfg={cfg:?}), got {result:?}"
1343            );
1344        }
1345    }
1346
1347    #[test]
1348    fn test_md027_config_kebab_case_parses() {
1349        let toml_str = r#"
1350            list-items = true
1351        "#;
1352        let config: MD027Config = toml::from_str(toml_str).unwrap();
1353        assert!(config.list_items);
1354    }
1355
1356    #[test]
1357    fn test_md027_config_snake_case_alias_parses() {
1358        let toml_str = r#"
1359            list_items = true
1360        "#;
1361        let config: MD027Config = toml::from_str(toml_str).unwrap();
1362        assert!(config.list_items);
1363    }
1364
1365    #[test]
1366    fn test_md027_config_default_is_false() {
1367        let cfg = MD027Config::default();
1368        assert!(!cfg.list_items, "rumdl default for list_items should be false");
1369    }
1370
1371    #[test]
1372    fn test_md027_html_comment() {
1373        let rule = MD027MultipleSpacesBlockquote::default();
1374        let content = "<!--\n>  comment\n-->";
1375        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1376        let result = rule.check(&ctx).unwrap();
1377        assert!(
1378            result.is_empty(),
1379            "MD027 should not flag blockquotes inside HTML comments: {result:?}"
1380        );
1381    }
1382
1383    #[test]
1384    fn test_lazy_line_indent_that_changes_the_parse_is_reported_but_not_fixed() {
1385        let rule = MD027MultipleSpacesBlockquote::default();
1386        // Four or more columns past the marker, the line continues the
1387        // paragraph. One space would make it a break, heading, list or fence.
1388        for content in [
1389            "> o\n>     ---\n",
1390            "> o\n>     ===\n",
1391            "> o\n>     # x\n",
1392            "> o\n>     -  x\n",
1393            "> o\n>     ```\n",
1394            "> > o\n> >     ---\n",
1395        ] {
1396            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1397            let warnings = rule.check(&ctx).unwrap();
1398            assert!(!warnings.is_empty(), "{content:?} must still be reported");
1399            assert!(
1400                warnings.iter().all(|w| w.fix.is_none()),
1401                "{content:?} must carry no fix: {warnings:?}"
1402            );
1403            assert_eq!(rule.fix(&ctx).unwrap(), content, "{content:?}");
1404        }
1405    }
1406
1407    #[test]
1408    fn test_lazy_line_indent_on_plain_prose_is_still_fixed() {
1409        let rule = MD027MultipleSpacesBlockquote::default();
1410        let content = "> o\n>     more text\n>     and more\n";
1411        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1412        assert_eq!(rule.fix(&ctx).unwrap(), "> o\n> more text\n> and more\n");
1413    }
1414}