Skip to main content

rumdl_lib/rules/
md032_blanks_around_lists.rs

1use crate::lint_context::LazyContLine;
2use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
3use crate::utils::blockquote::{content_after_blockquote, effective_indent_in_blockquote, parse_blockquote_prefix};
4use crate::utils::calculate_indentation_width_default;
5use crate::utils::list_fix_guard::{Allowed, drop_structure_changing_fixes, protect_literal_code};
6use crate::utils::pandoc;
7use crate::utils::range_utils::calculate_line_range;
8use regex::Regex;
9use std::sync::LazyLock;
10
11mod md032_config;
12pub(super) use md032_config::MD032Config;
13
14// Detects ordered list items starting with a number other than 1
15static ORDERED_LIST_NON_ONE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*([2-9]|\d{2,})\.\s").unwrap());
16
17/// Check if a line is a thematic break (horizontal rule)
18/// Per CommonMark: 0-3 spaces of indentation, then 3+ of same char (-, *, _), optionally with spaces between
19fn is_thematic_break(line: &str) -> bool {
20    // Per CommonMark, thematic breaks can have 0-3 spaces of indentation (< 4 columns)
21    if calculate_indentation_width_default(line) > 3 {
22        return false;
23    }
24
25    let trimmed = line.trim();
26    if trimmed.len() < 3 {
27        return false;
28    }
29
30    let chars: Vec<char> = trimmed.chars().collect();
31    let first_non_space = chars.iter().find(|&&c| c != ' ');
32
33    if let Some(&marker) = first_non_space {
34        if marker != '-' && marker != '*' && marker != '_' {
35            return false;
36        }
37        let marker_count = chars.iter().filter(|&&c| c == marker).count();
38        let other_count = chars.iter().filter(|&&c| c != marker && c != ' ').count();
39        marker_count >= 3 && other_count == 0
40    } else {
41        false
42    }
43}
44
45/// Rule MD032: Lists should be surrounded by blank lines
46///
47/// This rule enforces that lists are surrounded by blank lines, which improves document
48/// readability and ensures consistent rendering across different Markdown processors.
49///
50/// ## Purpose
51///
52/// - **Readability**: Blank lines create visual separation between lists and surrounding content
53/// - **Parsing**: Many Markdown parsers require blank lines around lists for proper rendering
54/// - **Consistency**: Ensures uniform document structure and appearance
55/// - **Compatibility**: Improves compatibility across different Markdown implementations
56///
57/// ## Examples
58///
59/// ### Correct
60///
61/// ```markdown
62/// This is a paragraph of text.
63///
64/// - Item 1
65/// - Item 2
66/// - Item 3
67///
68/// This is another paragraph.
69/// ```
70///
71/// ### Incorrect
72///
73/// ```markdown
74/// This is a paragraph of text.
75/// - Item 1
76/// - Item 2
77/// - Item 3
78/// This is another paragraph.
79/// ```
80///
81/// ## Behavior Details
82///
83/// This rule checks for the following:
84///
85/// - **List Start**: There should be a blank line before the first item in a list
86///   (unless the list is at the beginning of the document or after front matter)
87/// - **List End**: There should be a blank line after the last item in a list
88///   (unless the list is at the end of the document)
89/// - **Nested Lists**: Properly handles nested lists and list continuations
90/// - **List Types**: Works with ordered lists, unordered lists, and all valid list markers (-, *, +)
91///
92/// ## Special Cases
93///
94/// This rule handles several special cases:
95///
96/// - **Front Matter**: YAML front matter is detected and skipped
97/// - **Code Blocks**: Lists inside code blocks are ignored
98/// - **List Content**: Indented content belonging to list items is properly recognized as part of the list
99/// - **Document Boundaries**: Lists at the beginning or end of the document have adjusted requirements
100///
101/// ## Fix Behavior
102///
103/// When applying automatic fixes, this rule:
104/// - Adds a blank line before the first list item when needed
105/// - Adds a blank line after the last list item when needed
106/// - Preserves document structure and existing content
107///
108/// ## Performance Optimizations
109///
110/// The rule includes several optimizations:
111/// - Fast path checks before applying more expensive regex operations
112/// - Efficient list item detection
113/// - Pre-computation of code block lines to avoid redundant processing
114#[derive(Debug, Clone, Default)]
115pub struct MD032BlanksAroundLists {
116    config: MD032Config,
117}
118
119/// The lines covered by MD032's list blocks, answering whether a line lies
120/// in any of them by binary search. The blocks may overlap and need not be
121/// ordered: sorted by start, each keeps the furthest end reached so far, so a
122/// line is covered exactly when the last block starting at or before it
123/// reaches it.
124struct ListCoverage {
125    starts: Vec<usize>,
126    reach: Vec<usize>,
127}
128
129impl ListCoverage {
130    fn new(list_blocks: &[(usize, usize, String)]) -> Self {
131        let mut spans: Vec<(usize, usize)> = list_blocks.iter().map(|&(start, end, _)| (start, end)).collect();
132        spans.sort_unstable();
133        let mut furthest = 0;
134        let (starts, reach) = spans
135            .into_iter()
136            .map(|(start, end)| {
137                furthest = furthest.max(end);
138                (start, furthest)
139            })
140            .unzip();
141        Self { starts, reach }
142    }
143
144    fn contains(&self, line_num: usize) -> bool {
145        let before = self.starts.partition_point(|&start| start <= line_num);
146        before > 0 && self.reach[before - 1] >= line_num
147    }
148}
149
150impl MD032BlanksAroundLists {
151    pub fn from_config_struct(config: MD032Config) -> Self {
152        Self { config }
153    }
154}
155
156impl MD032BlanksAroundLists {
157    /// Check if a blank line should be required before a list based on the previous line context
158    fn should_require_blank_line_before(
159        ctx: &crate::lint_context::LintContext,
160        prev_line_num: usize,
161        current_line_num: usize,
162    ) -> bool {
163        // Always require blank lines after code blocks, front matter, etc.
164        if ctx
165            .line_info(prev_line_num)
166            .is_some_and(|info| info.in_code_block || info.in_front_matter)
167        {
168            return true;
169        }
170
171        // Always allow nested lists (lists indented within other list items)
172        if Self::is_nested_list(ctx, prev_line_num, current_line_num) {
173            return false;
174        }
175
176        // Default: require blank line (matching markdownlint's behavior)
177        true
178    }
179
180    /// Check if the current list is nested within another list item
181    fn is_nested_list(
182        ctx: &crate::lint_context::LintContext,
183        prev_line_num: usize,    // 1-indexed
184        current_line_num: usize, // 1-indexed
185    ) -> bool {
186        // Check if current line is indented (typical for nested lists)
187        if current_line_num > 0 && current_line_num - 1 < ctx.lines.len() {
188            let current_line = &ctx.lines[current_line_num - 1];
189            if current_line.indent >= 2 {
190                // Check if previous line is a list item or list content
191                if prev_line_num > 0 && prev_line_num - 1 < ctx.lines.len() {
192                    let prev_line = &ctx.lines[prev_line_num - 1];
193                    // Previous line is a list item or indented content
194                    if prev_line.list_item.is_some() || prev_line.indent >= 2 {
195                        return true;
196                    }
197                }
198            }
199        }
200        false
201    }
202
203    /// Check if a lazy continuation fix should be applied to a line.
204    /// Returns false for lines inside code blocks, front matter, or HTML comments.
205    fn should_apply_lazy_fix(ctx: &crate::lint_context::LintContext, line_num: usize) -> bool {
206        ctx.lines
207            .get(line_num.saturating_sub(1))
208            .is_some_and(|li| !li.in_code_block && !li.in_front_matter && !li.in_html_comment && !li.in_mdx_comment)
209    }
210
211    /// Whether a line is a Pandoc/Quarto fenced-div marker in a flavor where
212    /// those are structure rather than prose. MD032 looks through them when it
213    /// scans for the blank line around a list, and the same line must not be
214    /// read as prose that lazily continues the last item either: indenting a
215    /// closing `:::` into the item pulls the div's fence into the list.
216    fn is_transparent_div_marker(ctx: &crate::lint_context::LintContext, info: &crate::lint_context::LineInfo) -> bool {
217        if !ctx.flavor.is_pandoc_compatible() {
218            return false;
219        }
220        let trimmed = info.content(ctx.content).trim();
221        pandoc::is_div_open(trimmed) || pandoc::is_div_close(trimmed)
222    }
223
224    /// Whether a lazy continuation line reported by the parser is one MD032
225    /// should act on: inside a list block, and not a transparent div marker.
226    fn is_reportable_lazy_line(
227        ctx: &crate::lint_context::LintContext,
228        coverage: &ListCoverage,
229        line_num: usize,
230    ) -> bool {
231        if !coverage.contains(line_num) {
232            return false;
233        }
234        ctx.lines
235            .get(line_num.saturating_sub(1))
236            .is_some_and(|info| !Self::is_transparent_div_marker(ctx, info))
237    }
238
239    /// Calculate the fix for a lazy continuation line.
240    /// Returns the byte range to replace and the replacement string.
241    fn calculate_lazy_continuation_fix(
242        ctx: &crate::lint_context::LintContext,
243        line_num: usize,
244        lazy_info: &LazyContLine,
245    ) -> Option<Fix> {
246        let line_info = ctx.lines.get(line_num.saturating_sub(1))?;
247        let line_content = line_info.content(ctx.content);
248
249        if lazy_info.blockquote_level == 0 {
250            // Regular list (no blockquote): replace leading whitespace with proper indent
251            let start_byte = line_info.byte_offset;
252            let end_byte = start_byte + lazy_info.current_indent;
253            let replacement = " ".repeat(lazy_info.expected_indent);
254
255            Some(Fix::new(start_byte..end_byte, replacement))
256        } else {
257            // List inside blockquote: preserve blockquote prefix, fix indent after it
258            let after_bq = content_after_blockquote(line_content, lazy_info.blockquote_level);
259            let prefix_byte_len = line_content.len().saturating_sub(after_bq.len());
260            if prefix_byte_len == 0 {
261                return None;
262            }
263
264            let current_indent = after_bq.len() - after_bq.trim_start().len();
265            let start_byte = line_info.byte_offset + prefix_byte_len;
266            let end_byte = start_byte + current_indent;
267            let replacement = " ".repeat(lazy_info.expected_indent);
268
269            Some(Fix::new(start_byte..end_byte, replacement))
270        }
271    }
272
273    /// Apply a lazy continuation fix to a single line.
274    /// Replaces the current indentation with the expected indentation.
275    fn apply_lazy_fix_to_line(line: &str, lazy_info: &LazyContLine) -> String {
276        if lazy_info.blockquote_level == 0 {
277            // Regular list: strip current indent, add expected indent
278            let content = line.trim_start();
279            format!("{}{}", " ".repeat(lazy_info.expected_indent), content)
280        } else {
281            // Blockquote list: preserve blockquote prefix, fix indent after it
282            let after_bq = content_after_blockquote(line, lazy_info.blockquote_level);
283            let prefix_len = line.len().saturating_sub(after_bq.len());
284            if prefix_len == 0 {
285                return line.to_string();
286            }
287
288            let prefix = &line[..prefix_len];
289            let rest = after_bq.trim_start();
290            format!("{}{}{}", prefix, " ".repeat(lazy_info.expected_indent), rest)
291        }
292    }
293
294    /// Find the first non-transparent line before the given line (1-indexed).
295    /// Returns (line_num, is_blank) where:
296    /// - line_num is the 1-indexed line of actual content (0 if start of document)
297    /// - is_blank is true if that line is blank (meaning separation exists)
298    ///
299    /// Transparent elements (HTML comments, Quarto div markers) are skipped,
300    /// matching markdownlint-cli behavior.
301    fn find_preceding_content(ctx: &crate::lint_context::LintContext, before_line: usize) -> (usize, bool) {
302        for line_num in (1..before_line).rev() {
303            let idx = line_num - 1;
304            if let Some(info) = ctx.lines.get(idx) {
305                // A line contributing nothing but comments separates the blocks
306                // around it the way an empty one does (#866)
307                if is_blank_in_context(info.content(ctx.content)) {
308                    return (line_num, true);
309                }
310                // Skip the interior of a multi-line comment - it's transparent
311                if info.in_html_comment || info.in_mdx_comment {
312                    continue;
313                }
314                // Skip Pandoc/Quarto div markers in Pandoc-compatible flavor - they're transparent
315                if Self::is_transparent_div_marker(ctx, info) {
316                    continue;
317                }
318                return (line_num, is_blank_in_context(info.content(ctx.content)));
319            }
320        }
321        // Start of document = effectively blank-separated
322        (0, true)
323    }
324
325    /// Find the first non-transparent line after the given line (1-indexed).
326    /// Returns (line_num, is_blank) where:
327    /// - line_num is the 1-indexed line of actual content (0 if end of document)
328    /// - is_blank is true if that line is blank (meaning separation exists)
329    ///
330    /// Transparent elements (HTML comments, Quarto div markers) are skipped.
331    fn find_following_content(ctx: &crate::lint_context::LintContext, after_line: usize) -> (usize, bool) {
332        let num_lines = ctx.lines.len();
333        for line_num in (after_line + 1)..=num_lines {
334            let idx = line_num - 1;
335            if let Some(info) = ctx.lines.get(idx) {
336                // A line contributing nothing but comments separates the blocks
337                // around it the way an empty one does (#866)
338                if is_blank_in_context(info.content(ctx.content)) {
339                    return (line_num, true);
340                }
341                // Skip the interior of a multi-line comment - it's transparent
342                if info.in_html_comment || info.in_mdx_comment {
343                    continue;
344                }
345                // Skip Pandoc/Quarto div markers in Pandoc-compatible flavor - they're transparent
346                if Self::is_transparent_div_marker(ctx, info) {
347                    continue;
348                }
349                return (line_num, is_blank_in_context(info.content(ctx.content)));
350            }
351        }
352        // End of document = effectively blank-separated
353        (0, true)
354    }
355
356    /// Whether the list block's own last line contributes nothing but comments.
357    ///
358    /// A comment written directly below the final item is parsed into that item's
359    /// segment, so it lands inside the block rather than after it. The block then
360    /// ends on a line that already separates it from what follows, the same way an
361    /// empty line would (#866).
362    fn block_ends_in_comment_line(lines: &[&str], end_line: usize) -> bool {
363        lines.get(end_line - 1).is_some_and(|line| is_blank_in_context(line))
364    }
365
366    // Shared by check() and fix(): standalone code blocks need separation,
367    // while indented code belonging to the list must remain attached.
368    /// The blockquote prefix for a blank line inserted after a list.
369    ///
370    /// The list's last line gives the prefix, keeping its marker spacing. That
371    /// line can be lazy paragraph text quoted less deeply than the list, so the
372    /// following line, which sits at the list's depth, gives it then.
373    fn prefix_for_blank_after(
374        ctx: &crate::lint_context::LintContext,
375        end_line: usize,
376        following_line: usize,
377        list_bq_level: usize,
378    ) -> String {
379        let end_bq_level = ctx
380            .line_info(end_line)
381            .and_then(|info| info.blockquote.as_ref())
382            .map_or(0, |bq| bq.nesting_level);
383        let source = if end_bq_level == list_bq_level {
384            end_line
385        } else {
386            following_line
387        };
388        ctx.blockquote_prefix_for_blank_line(source - 1)
389    }
390
391    fn is_following_content_excluded(ctx: &crate::lint_context::LintContext, line_num: usize, prefix: &str) -> bool {
392        ctx.line_info(line_num).is_some_and(|info| {
393            info.in_front_matter
394                || (info.in_code_block
395                    && effective_indent_in_blockquote(
396                        info.content(ctx.content),
397                        prefix.chars().filter(|&c| c == '>').count(),
398                        info.indent,
399                    ) >= 2)
400        })
401    }
402
403    // Convert centralized list blocks to the format expected by perform_checks
404    fn convert_list_blocks(&self, ctx: &crate::lint_context::LintContext) -> Vec<(usize, usize, String)> {
405        let mut blocks: Vec<(usize, usize, String)> = Vec::new();
406
407        for block in &ctx.list_blocks {
408            // Skip list blocks inside footnote definitions
409            if ctx
410                .line_info(block.start_line)
411                .is_some_and(|info| info.in_footnote_definition)
412            {
413                continue;
414            }
415
416            // For MD032, we need to check if there are code blocks that should
417            // split the list into separate segments
418
419            // Simple approach: if there's a fenced code block between list items,
420            // split at that point
421            let mut segments: Vec<(usize, usize)> = Vec::new();
422            let mut current_start = block.start_line;
423            let mut prev_item_line = 0;
424
425            // Helper to get blockquote level (count of '>' chars) from a line
426            let get_blockquote_level = |line_num: usize| -> usize {
427                if line_num == 0 || line_num > ctx.lines.len() {
428                    return 0;
429                }
430                let line_content = ctx.lines[line_num - 1].content(ctx.content);
431                parse_blockquote_prefix(line_content).map_or(0, |bq| bq.nesting_level)
432            };
433
434            let mut prev_bq_level = 0;
435
436            for &item_line in &block.item_lines {
437                let current_bq_level = get_blockquote_level(item_line);
438
439                if prev_item_line > 0 {
440                    // Check if blockquote level changed between items
441                    let blockquote_level_changed = prev_bq_level != current_bq_level;
442
443                    // Check if there's a standalone code fence between prev_item_line and item_line
444                    // A code fence that's indented as part of a list item should NOT split the list
445                    let mut has_standalone_code_fence = false;
446
447                    // Calculate minimum indentation for list item content
448                    let min_indent_for_content = if block.is_ordered {
449                        // For ordered lists, content should be indented at least to align with text after marker
450                        // e.g., "1. " = 3 chars, so content should be indented 3+ spaces
451                        3 // Minimum for "1. "
452                    } else {
453                        // For unordered lists, content should be indented at least 2 spaces
454                        2 // For "- " or "* "
455                    };
456
457                    for check_line in (prev_item_line + 1)..item_line {
458                        if check_line - 1 < ctx.lines.len() {
459                            let line = &ctx.lines[check_line - 1];
460                            let line_content = line.content(ctx.content);
461                            if line.in_code_block
462                                && (line_content.trim().starts_with("```") || line_content.trim().starts_with("~~~"))
463                            {
464                                // Check if this code fence is indented as part of the list item
465                                // If it's indented enough to be part of the list item, it shouldn't split
466                                if line.indent < min_indent_for_content {
467                                    has_standalone_code_fence = true;
468                                    break;
469                                }
470                            }
471                        }
472                    }
473
474                    if has_standalone_code_fence || blockquote_level_changed {
475                        // End current segment before this item
476                        segments.push((current_start, prev_item_line));
477                        current_start = item_line;
478                    }
479                }
480                prev_item_line = item_line;
481                prev_bq_level = current_bq_level;
482            }
483
484            // Add the final segment
485            // For the last segment, end at the last list item (not the full block end)
486            if prev_item_line > 0 {
487                segments.push((current_start, prev_item_line));
488            }
489
490            // Convert segments to blocks
491            for (start, end) in &segments {
492                // Extend the end to include any continuation lines immediately after the last item
493                let mut actual_end = *end;
494
495                if *end < block.end_line {
496                    // Get the blockquote level for this block
497                    let block_bq_level = block.blockquote_prefix.chars().filter(|&c| c == '>').count();
498
499                    // For blockquote lists, use a simpler min_continuation_indent
500                    // (the content column without the blockquote prefix portion)
501                    let min_continuation_indent = if block_bq_level > 0 {
502                        // For lists in blockquotes, content should align with text after marker
503                        if block.is_ordered {
504                            block.max_marker_width
505                        } else {
506                            2 // "- " or "* "
507                        }
508                    } else {
509                        ctx.lines
510                            .get(*end - 1)
511                            .and_then(|line_info| line_info.list_item.as_ref())
512                            .map_or(2, |item| item.content_column)
513                    };
514
515                    for check_line in (*end + 1)..=block.end_line {
516                        if check_line - 1 < ctx.lines.len() {
517                            let line = &ctx.lines[check_line - 1];
518                            let line_content = line.content(ctx.content);
519                            // Stop at next list item or non-continuation content. Only a
520                            // heading ends the item: `#2, #3` and other no-space `#` lines
521                            // are paragraph text of the item.
522                            if block.item_lines.contains(&check_line) || line.heading.is_some() {
523                                break;
524                            }
525                            // Don't extend through code blocks
526                            if line.in_code_block {
527                                break;
528                            }
529
530                            // Calculate effective indent for blockquote lines
531                            let effective_indent =
532                                effective_indent_in_blockquote(line_content, block_bq_level, line.indent);
533
534                            // Include indented continuation if indent meets threshold
535                            if effective_indent >= min_continuation_indent {
536                                actual_end = check_line;
537                            }
538                            // Include lazy continuation lines for structural purposes
539                            // Per CommonMark, only paragraph text can be lazy continuation
540                            // Thematic breaks, code fences, etc. cannot be lazy continuations
541                            // Always include lazy lines in block range - the config controls whether to WARN
542                            else if !line.is_blank
543                                && line.heading.is_none()
544                                && !block.item_lines.contains(&check_line)
545                                && !is_thematic_break(line_content)
546                            {
547                                // This is a lazy continuation line - include it in the block range
548                                actual_end = check_line;
549                            } else if !line.is_blank {
550                                // Non-blank line that's not a continuation - stop here
551                                break;
552                            }
553                        }
554                    }
555                }
556
557                blocks.push((*start, actual_end, block.blockquote_prefix.clone()));
558            }
559        }
560
561        // Filter out lists entirely inside HTML comments
562        blocks.retain(|(start, end, _)| {
563            // Check if ALL lines of this block are inside HTML comments
564            let all_in_comment = (*start..=*end).all(|line_num| {
565                ctx.lines
566                    .get(line_num - 1)
567                    .is_some_and(|info| info.in_html_comment || info.in_mdx_comment)
568            });
569            !all_in_comment
570        });
571
572        blocks
573    }
574
575    fn perform_checks(
576        &self,
577        ctx: &crate::lint_context::LintContext,
578        lines: &[&str],
579        list_blocks: &[(usize, usize, String)],
580    ) -> (Vec<LintWarning>, usize) {
581        let mut warnings = Vec::new();
582        let num_lines = lines.len();
583        let coverage = ListCoverage::new(list_blocks);
584
585        // Check for ordered lists starting with non-1 that aren't recognized as lists
586        // These need blank lines before them to be parsed as lists by CommonMark
587        for (line_idx, line) in lines.iter().enumerate() {
588            let line_num = line_idx + 1;
589
590            // Skip if this line is already part of a recognized list
591            if coverage.contains(line_num) {
592                continue;
593            }
594
595            // Skip if in code block, front matter, or HTML comment
596            if ctx.line_info(line_num).is_some_and(|info| {
597                info.in_code_block
598                    || info.in_front_matter
599                    || info.in_html_comment
600                    || info.in_mdx_comment
601                    || info.in_html_block
602                    || info.in_jsx_block
603            }) {
604                continue;
605            }
606
607            // Check if this line starts with a number other than 1
608            if ORDERED_LIST_NON_ONE_RE.is_match(line) {
609                // Check if there's a blank line before this
610                if line_idx > 0 {
611                    let prev_line = lines[line_idx - 1];
612                    let prev_is_blank = is_blank_in_context(prev_line);
613                    let prev_line_info = ctx.line_info(line_idx);
614                    let prev_excluded = prev_line_info.is_some_and(|info| info.in_code_block || info.in_front_matter);
615
616                    // Inside a recognized MkDocs admonition or content tab body, the
617                    // list-item metadata for nested lists is stale (the flavor-blind
618                    // parse classifies the indented body as code), so real list items
619                    // land in this fallback. Exempt an item only when an ordered list
620                    // marker line at the same indent precedes it in the container run,
621                    // walking over more-indented lines (wrapped continuations, deeper
622                    // nesting) and stopping at any same-or-less indented non-marker
623                    // line: prose there means the item cannot start a new list, which
624                    // is exactly the ambiguity this fallback exists to flag. Checked
625                    // directly on in_admonition/in_content_tab rather than the
626                    // in_mkdocs_container() helper: that helper's third component,
627                    // in_mkdocs_html_markdown, is populated in every flavor
628                    // (markdown="1" HTML divs are tag-scoped, not indentation-scoped),
629                    // so using it would also change behavior for standard-flavor
630                    // documents.
631                    let prev_in_mkdocs_container =
632                        prev_line_info.is_some_and(|info| info.in_admonition || info.in_content_tab);
633                    let continues_stale_container_list = prev_in_mkdocs_container && {
634                        let item_indent = calculate_indentation_width_default(line);
635                        let mut found_marker = false;
636                        for j in (0..line_idx).rev() {
637                            let in_container = ctx
638                                .line_info(j + 1)
639                                .is_some_and(|info| info.in_admonition || info.in_content_tab);
640                            if !in_container {
641                                break;
642                            }
643                            let candidate = lines[j];
644                            if is_blank_in_context(candidate) {
645                                continue;
646                            }
647                            let candidate_indent = calculate_indentation_width_default(candidate);
648                            if crate::utils::regex_cache::ORDERED_LIST_MARKER_REGEX.is_match(candidate)
649                                && candidate_indent == item_indent
650                            {
651                                found_marker = true;
652                                break;
653                            }
654                            if candidate_indent <= item_indent {
655                                break;
656                            }
657                        }
658                        found_marker
659                    };
660
661                    // Check if previous line looks like a sentence continuation
662                    // If the previous line is non-blank text that doesn't end with a sentence
663                    // terminator, this is likely a paragraph continuation, not a list item
664                    // e.g., "...in Chapter\n19. For now..." is a broken sentence, not a list
665                    let prev_trimmed = prev_line.trim();
666                    let is_sentence_continuation = continues_stale_container_list
667                        || (!prev_is_blank
668                            && !prev_trimmed.is_empty()
669                            && !prev_trimmed.ends_with('.')
670                            && !prev_trimmed.ends_with('!')
671                            && !prev_trimmed.ends_with('?')
672                            && !prev_trimmed.ends_with(':')
673                            && !prev_trimmed.ends_with(';')
674                            && !prev_trimmed.ends_with('>')
675                            && !prev_trimmed.ends_with('-')
676                            && !prev_trimmed.ends_with('*'));
677
678                    if prev_is_blank || !is_sentence_continuation {
679                        if !prev_is_blank && !prev_excluded {
680                            // This ordered list item starting with non-1 needs a blank line before it
681                            let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line);
682
683                            let bq_prefix = ctx.blockquote_prefix_for_blank_line(line_idx);
684                            warnings.push(LintWarning {
685                                line: start_line,
686                                column: start_col,
687                                end_line,
688                                end_column: end_col,
689                                severity: Severity::Warning,
690                                rule_name: Some(self.name().to_string()),
691                                message: "Ordered list starting with non-1 should be preceded by blank line"
692                                    .to_string(),
693                                fix: Some(Fix::new(
694                                    ctx.line_column_byte_range_with_length(line_num, 1, 0),
695                                    format!("{bq_prefix}\n"),
696                                )),
697                            });
698                        }
699
700                        // Also check if a blank line is needed AFTER this ordered list item
701                        // This ensures single-pass idempotency
702                        if line_idx + 1 < num_lines {
703                            let next_line = lines[line_idx + 1];
704                            let next_is_blank = is_blank_in_context(next_line);
705                            let next_excluded = ctx.line_info(line_idx + 2).is_some_and(|info| info.in_front_matter);
706
707                            if !next_is_blank && !next_excluded && !next_line.trim().is_empty() {
708                                // Check if next line is a continuation of this ordered list
709                                // Only other ordered items or indented continuations count;
710                                // unordered list markers are a different list requiring separation
711                                let next_trimmed = next_line.trim_start();
712                                let next_is_ordered_content = ORDERED_LIST_NON_ONE_RE.is_match(next_line)
713                                    || next_line.starts_with("1. ")
714                                    || (next_line.len() > next_trimmed.len()
715                                        && !next_trimmed.starts_with("- ")
716                                        && !next_trimmed.starts_with("* ")
717                                        && !next_trimmed.starts_with("+ ")); // indented continuation (not a nested unordered list)
718
719                                if !next_is_ordered_content {
720                                    let (start_line, start_col, end_line, end_col) =
721                                        calculate_line_range(line_num, line);
722                                    let bq_prefix = ctx.blockquote_prefix_for_blank_line(line_idx);
723                                    warnings.push(LintWarning {
724                                        line: start_line,
725                                        column: start_col,
726                                        end_line,
727                                        end_column: end_col,
728                                        severity: Severity::Warning,
729                                        rule_name: Some(self.name().to_string()),
730                                        message: "List should be followed by blank line".to_string(),
731                                        fix: Some(Fix::new(
732                                            ctx.line_column_byte_range_with_length(line_num + 1, 1, 0),
733                                            format!("{bq_prefix}\n"),
734                                        )),
735                                    });
736                                }
737                            }
738                        }
739                    }
740                }
741            }
742        }
743
744        // The warnings above turn paragraph text into a list, which is their
745        // point; the ones below separate lists that already parse as lists.
746        let separating_from = warnings.len();
747
748        for &(start_line, end_line, ref prefix) in list_blocks {
749            let block_bq_level = prefix.chars().filter(|&c| c == '>').count();
750            // Skip lists that start inside HTML/MDX comments
751            if ctx
752                .line_info(start_line)
753                .is_some_and(|info| info.in_html_comment || info.in_mdx_comment)
754            {
755                continue;
756            }
757
758            if start_line > 1 {
759                // Look past HTML comments to find actual preceding content
760                let (content_line, has_blank_separation) = Self::find_preceding_content(ctx, start_line);
761
762                // If blank separation exists (through HTML comments), no warning needed
763                if !has_blank_separation && content_line > 0 {
764                    let prev_line_str = lines[content_line - 1];
765                    let is_prev_excluded = ctx
766                        .line_info(content_line)
767                        .is_some_and(|info| info.in_code_block || info.in_front_matter);
768                    let prev_bq_level = parse_blockquote_prefix(prev_line_str).map_or(0, |bq| bq.nesting_level);
769                    let prefixes_match = prev_bq_level == block_bq_level;
770
771                    // Only require blank lines for content in the same context (same blockquote level)
772                    // and when the context actually requires it
773                    let should_require = Self::should_require_blank_line_before(ctx, content_line, start_line);
774                    if !is_prev_excluded && prefixes_match && should_require {
775                        // Calculate precise character range for the entire list line that needs a blank line before it
776                        let (start_line, start_col, end_line, end_col) =
777                            calculate_line_range(start_line, lines[start_line - 1]);
778
779                        warnings.push(LintWarning {
780                            line: start_line,
781                            column: start_col,
782                            end_line,
783                            end_column: end_col,
784                            severity: Severity::Warning,
785                            rule_name: Some(self.name().to_string()),
786                            message: "List should be preceded by blank line".to_string(),
787                            fix: Some(Fix::new(
788                                ctx.line_column_byte_range_with_length(start_line, 1, 0),
789                                format!("{}\n", ctx.blockquote_prefix_for_blank_line(start_line - 1)),
790                            )),
791                        });
792                    }
793                }
794            }
795
796            if end_line < num_lines && !Self::block_ends_in_comment_line(lines, end_line) {
797                // Look past HTML comments to find actual following content
798                let (content_line, has_blank_separation) = Self::find_following_content(ctx, end_line);
799
800                // If blank separation exists (through HTML comments), no warning needed
801                if !has_blank_separation && content_line > 0 {
802                    let next_line_str = lines[content_line - 1];
803                    // Check if next line is excluded - front matter or indented code blocks within lists
804                    // We want blank lines before standalone code blocks, but not within list items
805                    let is_next_excluded = Self::is_following_content_excluded(ctx, content_line, prefix);
806                    let next_line_bq_level = parse_blockquote_prefix(next_line_str).map_or(0, |bq| bq.nesting_level);
807
808                    // Check blockquote levels to detect boundary transitions
809                    // If the list ends inside a blockquote but the following line exits the blockquote
810                    // (fewer > chars in prefix), no blank line is needed - the blockquote boundary
811                    // provides semantic separation
812                    let end_line_str = lines[end_line - 1];
813                    let end_line_bq_level = parse_blockquote_prefix(end_line_str).map_or(0, |bq| bq.nesting_level);
814                    let exits_blockquote = end_line_bq_level > 0 && next_line_bq_level < end_line_bq_level;
815
816                    let prefixes_match = next_line_bq_level == block_bq_level;
817
818                    // Only require blank lines for content in the same context (same blockquote level)
819                    // Skip if the following line exits a blockquote - boundary provides separation
820                    if !is_next_excluded && prefixes_match && !exits_blockquote {
821                        // Calculate precise character range for the last line of the list (not the line after)
822                        let (start_line_last, start_col_last, end_line_last, end_col_last) =
823                            calculate_line_range(end_line, lines[end_line - 1]);
824
825                        warnings.push(LintWarning {
826                            line: start_line_last,
827                            column: start_col_last,
828                            end_line: end_line_last,
829                            end_column: end_col_last,
830                            severity: Severity::Warning,
831                            rule_name: Some(self.name().to_string()),
832                            message: "List should be followed by blank line".to_string(),
833                            fix: Some(Fix::new(
834                                ctx.line_column_byte_range_with_length(end_line + 1, 1, 0),
835                                format!(
836                                    "{}\n",
837                                    Self::prefix_for_blank_after(ctx, end_line, content_line, block_bq_level)
838                                ),
839                            )),
840                        });
841                    }
842                }
843            }
844        }
845        (warnings, separating_from)
846    }
847}
848
849impl Rule for MD032BlanksAroundLists {
850    fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
851        (self.config == Default::default()).then_some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(32))
852    }
853
854    fn name(&self) -> &'static str {
855        "MD032"
856    }
857
858    fn description(&self) -> &'static str {
859        "Lists should be surrounded by blank lines"
860    }
861
862    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
863        if self.config == Default::default() {
864            return Ok(self.prepared(ctx).warnings.clone());
865        }
866        Ok(self.check_native(ctx))
867    }
868
869    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
870        if self.config == Default::default() {
871            let prepared = self.prepared(ctx);
872            return Ok(self.fix_with_inputs(ctx, &prepared.withheld, &prepared.blocks, &prepared.fallback));
873        }
874        Ok(self.fix_with_structure_impl(ctx, &self.withheld_fixes(ctx)))
875    }
876
877    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
878        // Skip if no list blocks exist (includes ordered and unordered lists)
879        // Note: list_blocks is pre-computed in LintContext, so this is already efficient
880        ctx.content.is_empty() || ctx.list_blocks.is_empty()
881    }
882
883    fn category(&self) -> RuleCategory {
884        RuleCategory::List
885    }
886
887    fn as_any(&self) -> &dyn std::any::Any {
888        self
889    }
890
891    crate::impl_rule_config_methods!(MD032Config);
892}
893
894impl MD032BlanksAroundLists {
895    fn check_native(&self, ctx: &crate::lint_context::LintContext) -> Vec<LintWarning> {
896        let (mut warnings, separating_from) = self.unguarded_warnings(ctx);
897        drop_structure_changing_fixes(ctx, &mut warnings[separating_from..], Allowed::Nothing);
898        protect_literal_code(ctx, &mut warnings);
899        warnings
900    }
901
902    fn prepared<'a>(
903        &self,
904        ctx: &'a crate::lint_context::LintContext,
905    ) -> &'a crate::lint_context::NativeListSpacingPlan {
906        // Only default instances enter this per-context plan. Custom configs
907        // use check_native/withheld_fixes and cannot read or populate it.
908        debug_assert!(self.config == Default::default());
909        ctx.native_list_spacing_plan(|| {
910            let blocks = self.convert_list_blocks(ctx);
911            let (mut warnings, separating_from) = self.unguarded_warnings_with_blocks(ctx, &blocks);
912            let fallback = warnings[..separating_from].to_vec();
913            let withheld = Self::guard_warnings(ctx, &mut warnings, separating_from);
914            crate::lint_context::NativeListSpacingPlan {
915                warnings,
916                withheld,
917                blocks,
918                fallback,
919            }
920        })
921    }
922
923    /// Every warning with its fix as computed, and the index from which the
924    /// warnings separate lists that already parse as lists.
925    fn unguarded_warnings(&self, ctx: &crate::lint_context::LintContext) -> (Vec<LintWarning>, usize) {
926        let list_blocks = self.convert_list_blocks(ctx);
927        self.unguarded_warnings_with_blocks(ctx, &list_blocks)
928    }
929
930    fn unguarded_warnings_with_blocks(
931        &self,
932        ctx: &crate::lint_context::LintContext,
933        list_blocks: &[(usize, usize, String)],
934    ) -> (Vec<LintWarning>, usize) {
935        let lines = ctx.raw_lines();
936        // Early return for empty content
937        if lines.is_empty() {
938            return (Vec::new(), 0);
939        }
940
941        if list_blocks.is_empty() {
942            return (Vec::new(), 0);
943        }
944
945        let (mut warnings, separating_from) = self.perform_checks(ctx, lines, list_blocks);
946
947        // When lazy continuation is not allowed, detect and warn about lazy continuation
948        // lines WITHIN list blocks (text that continues a list item but with less
949        // indentation than expected). Lazy continuation at the END of list blocks is
950        // already handled by the segment extension logic above.
951        if !self.config.allow_lazy_continuation {
952            let lazy_cont_lines = ctx.lazy_continuation_lines();
953            let coverage = ListCoverage::new(list_blocks);
954
955            for lazy_info in lazy_cont_lines.iter() {
956                let line_num = lazy_info.line_num;
957
958                // Only warn about lazy continuation lines that are WITHIN a list block
959                // (i.e., between list items). End-of-block lazy continuation is already
960                // handled by the existing "list should be followed by blank line" logic.
961                if !Self::is_reportable_lazy_line(ctx, &coverage, line_num) {
962                    continue;
963                }
964
965                // Get the expected indent for context in the warning message
966                let line_content = lines.get(line_num.saturating_sub(1)).unwrap_or(&"");
967                let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line_content);
968
969                // Calculate fix: add proper indentation to the lazy continuation line
970                let fix = if Self::should_apply_lazy_fix(ctx, line_num) {
971                    Self::calculate_lazy_continuation_fix(ctx, line_num, lazy_info)
972                } else {
973                    None
974                };
975
976                warnings.push(LintWarning {
977                    line: start_line,
978                    column: start_col,
979                    end_line,
980                    end_column: end_col,
981                    severity: Severity::Warning,
982                    rule_name: Some(self.name().to_string()),
983                    message: "Lazy continuation line should be properly indented or preceded by blank line".to_string(),
984                    fix,
985                });
986            }
987        }
988
989        (warnings, separating_from)
990    }
991
992    /// The fixes the guards withhold, as the line each one edits and
993    /// whether it inserts a blank line before that line (else it re-indents a
994    /// lazy continuation line). The fix path builds its own edits, so it reads
995    /// the guards' verdicts from the warnings, including literal warnings that
996    /// are removed altogether rather than retained with an unfixable edit.
997    fn withheld_fixes(&self, ctx: &crate::lint_context::LintContext) -> std::collections::HashSet<(usize, bool)> {
998        let (mut guarded, separating_from) = self.unguarded_warnings(ctx);
999        Self::guard_warnings(ctx, &mut guarded, separating_from)
1000    }
1001
1002    fn guard_warnings(
1003        ctx: &crate::lint_context::LintContext,
1004        guarded: &mut Vec<LintWarning>,
1005        separating_from: usize,
1006    ) -> std::collections::HashSet<(usize, bool)> {
1007        let fix_key = |fix: &Fix| {
1008            let line = ctx.offset_to_line_col(fix.range.start).0;
1009            (line, fix.range.is_empty() && fix.replacement.ends_with('\n'))
1010        };
1011        let mut withheld: std::collections::HashSet<_> = guarded
1012            .iter()
1013            .filter_map(|warning| warning.fix.as_ref())
1014            .map(fix_key)
1015            .collect();
1016        drop_structure_changing_fixes(ctx, &mut guarded[separating_from..], Allowed::Nothing);
1017        protect_literal_code(ctx, guarded);
1018        for fix in guarded.iter().filter_map(|warning| warning.fix.as_ref()) {
1019            withheld.remove(&fix_key(fix));
1020        }
1021        withheld
1022    }
1023
1024    /// Helper method for fixing implementation
1025    fn fix_with_structure_impl(
1026        &self,
1027        ctx: &crate::lint_context::LintContext,
1028        withheld: &std::collections::HashSet<(usize, bool)>,
1029    ) -> String {
1030        let list_blocks = self.convert_list_blocks(ctx);
1031        let (mut fallback, separating_from) = self.unguarded_warnings(ctx);
1032        fallback.truncate(separating_from);
1033        self.fix_with_inputs(ctx, withheld, &list_blocks, &fallback)
1034    }
1035
1036    fn fix_with_inputs(
1037        &self,
1038        ctx: &crate::lint_context::LintContext,
1039        withheld: &std::collections::HashSet<(usize, bool)>,
1040        list_blocks: &[(usize, usize, String)],
1041        fallback_warnings: &[LintWarning],
1042    ) -> String {
1043        let lines = ctx.raw_lines();
1044        let num_lines = lines.len();
1045        if num_lines == 0 {
1046            return String::new();
1047        }
1048
1049        if list_blocks.is_empty() {
1050            return ctx.content.to_string();
1051        }
1052
1053        // Phase 0: Collect lazy continuation line fixes (if not allowed)
1054        // Map of line_num -> LazyContLine for applying fixes
1055        let mut lazy_fixes: std::collections::BTreeMap<usize, LazyContLine> = std::collections::BTreeMap::new();
1056        if !self.config.allow_lazy_continuation {
1057            let lazy_cont_lines = ctx.lazy_continuation_lines();
1058            let coverage = ListCoverage::new(list_blocks);
1059            for lazy_info in lazy_cont_lines.iter() {
1060                let line_num = lazy_info.line_num;
1061                // Only fix lines within a list block
1062                if !Self::is_reportable_lazy_line(ctx, &coverage, line_num) {
1063                    continue;
1064                }
1065                // Only fix if not in code block, front matter, or HTML comment
1066                if !Self::should_apply_lazy_fix(ctx, line_num)
1067                    || ctx.inline_config().is_rule_disabled(self.name(), line_num)
1068                {
1069                    continue;
1070                }
1071                if !withheld.contains(&(line_num, false)) {
1072                    lazy_fixes.insert(line_num, lazy_info.clone());
1073                }
1074            }
1075        }
1076
1077        let mut insertions: std::collections::BTreeMap<usize, String> = std::collections::BTreeMap::new();
1078
1079        // Fallback items do not yet belong to a parsed list, so the block loop
1080        // below cannot see them. Reuse their advertised edits, with the same
1081        // literal guard verdicts and diagnostic-line suppression as check().
1082        for warning in fallback_warnings {
1083            if ctx.inline_config().is_rule_disabled(self.name(), warning.line) {
1084                continue;
1085            }
1086            if let Some(fix) = &warning.fix
1087                && fix.range.is_empty()
1088                && let Some(prefix) = fix.replacement.strip_suffix('\n')
1089            {
1090                let line_num = ctx.offset_to_line_col(fix.range.start).0;
1091                if !withheld.contains(&(line_num, true)) {
1092                    insertions.entry(line_num).or_insert_with(|| prefix.to_string());
1093                }
1094            }
1095        }
1096
1097        // Phase 1: Identify needed insertions
1098        for &(start_line, end_line, ref prefix) in list_blocks {
1099            let block_bq_level = prefix.chars().filter(|&c| c == '>').count();
1100            // Skip lists that start inside HTML/MDX comments
1101            if ctx
1102                .line_info(start_line)
1103                .is_some_and(|info| info.in_html_comment || info.in_mdx_comment)
1104            {
1105                continue;
1106            }
1107
1108            // Check before block
1109            if start_line > 1 && !ctx.inline_config().is_rule_disabled(self.name(), start_line) {
1110                // Look past HTML comments to find actual preceding content
1111                let (content_line, has_blank_separation) = Self::find_preceding_content(ctx, start_line);
1112
1113                // If blank separation exists (through HTML comments), no fix needed
1114                if !has_blank_separation && content_line > 0 {
1115                    let prev_line_str = lines[content_line - 1];
1116                    let is_prev_excluded = ctx
1117                        .line_info(content_line)
1118                        .is_some_and(|info| info.in_code_block || info.in_front_matter);
1119                    let prev_bq_level = parse_blockquote_prefix(prev_line_str).map_or(0, |bq| bq.nesting_level);
1120
1121                    let should_require = Self::should_require_blank_line_before(ctx, content_line, start_line);
1122                    // Compare depth so compact and spaced markers share the same context.
1123                    if !is_prev_excluded && prev_bq_level == block_bq_level && should_require {
1124                        // Use centralized helper for consistent blockquote prefix (no trailing space)
1125                        let bq_prefix = ctx.blockquote_prefix_for_blank_line(start_line - 1);
1126                        if !withheld.contains(&(start_line, true)) {
1127                            insertions.insert(start_line, bq_prefix);
1128                        }
1129                    }
1130                }
1131            }
1132
1133            // Check after block
1134            if end_line < num_lines
1135                && !ctx.inline_config().is_rule_disabled(self.name(), end_line)
1136                && !Self::block_ends_in_comment_line(lines, end_line)
1137            {
1138                // Look past HTML comments to find actual following content
1139                let (content_line, has_blank_separation) = Self::find_following_content(ctx, end_line);
1140
1141                // If blank separation exists (through HTML comments), no fix needed
1142                if !has_blank_separation && content_line > 0 {
1143                    let next_line_str = lines[content_line - 1];
1144                    // Match check(): standalone code blocks need separation, but
1145                    // indented code inside a list item must stay attached to it.
1146                    let is_next_excluded = Self::is_following_content_excluded(ctx, content_line, prefix);
1147                    let next_line_bq_level = parse_blockquote_prefix(next_line_str).map_or(0, |bq| bq.nesting_level);
1148
1149                    // Check blockquote levels to detect boundary transitions
1150                    let end_line_str = lines[end_line - 1];
1151                    let end_line_bq_level = parse_blockquote_prefix(end_line_str).map_or(0, |bq| bq.nesting_level);
1152                    let exits_blockquote = end_line_bq_level > 0 && next_line_bq_level < end_line_bq_level;
1153
1154                    // Compare depth so compact and spaced markers share the same context.
1155                    // Skip if exiting a blockquote - boundary provides separation
1156                    if !is_next_excluded && next_line_bq_level == block_bq_level && !exits_blockquote {
1157                        // Use centralized helper for consistent blockquote prefix (no trailing space)
1158                        let bq_prefix = Self::prefix_for_blank_after(ctx, end_line, content_line, block_bq_level);
1159                        if !withheld.contains(&(end_line + 1, true)) {
1160                            insertions.insert(end_line + 1, bq_prefix);
1161                        }
1162                    }
1163                }
1164            }
1165        }
1166
1167        if insertions.is_empty() && lazy_fixes.is_empty() {
1168            return ctx.content.to_string();
1169        }
1170
1171        // Phase 2: Reconstruct with insertions and lazy fixes
1172        let mut result_lines: Vec<String> = Vec::with_capacity(num_lines + insertions.len());
1173        for (i, line) in lines.iter().enumerate() {
1174            let current_line_num = i + 1;
1175            if let Some(prefix_to_insert) = insertions.get(&current_line_num)
1176                && (result_lines.is_empty() || result_lines.last().unwrap() != prefix_to_insert)
1177            {
1178                result_lines.push(prefix_to_insert.clone());
1179            }
1180
1181            // Disabled lines were excluded when collecting fixes.
1182            if let Some(lazy_info) = lazy_fixes.get(&current_line_num) {
1183                let fixed_line = Self::apply_lazy_fix_to_line(line, lazy_info);
1184                result_lines.push(fixed_line);
1185            } else {
1186                result_lines.push(line.to_string());
1187            }
1188        }
1189
1190        // Preserve the final newline if the original content had one
1191        let line_ending = crate::utils::detect_line_ending(ctx.content);
1192        let mut result = result_lines.join(line_ending);
1193        if ctx.content.ends_with('\n') {
1194            result.push_str(line_ending);
1195        }
1196        result
1197    }
1198}
1199
1200// Checks if a line is blank, considering compact and spaced blockquote markers.
1201// A line contributing nothing but HTML comments counts as blank too (#866).
1202fn is_blank_in_context(line: &str) -> bool {
1203    parse_blockquote_prefix(line)
1204        .map_or(line, |bq| bq.content)
1205        .trim()
1206        .is_empty()
1207        || crate::utils::blank_lines::is_blank_or_comment_only(line)
1208}
1209
1210#[cfg(test)]
1211mod tests {
1212    use super::*;
1213    use crate::lint_context::LintContext;
1214    use crate::rule::Rule;
1215
1216    #[test]
1217    fn prepared_native_list_spacing_preserves_diagnostics_fixes_and_configuration() {
1218        use crate::config::MarkdownFlavor::*;
1219        let flavors = [
1220            Standard,
1221            MkDocs,
1222            MDX,
1223            Pandoc,
1224            Quarto,
1225            Obsidian,
1226            Kramdown,
1227            AzureDevOps,
1228            MyST,
1229            Hugo,
1230            MDG,
1231            GhAw,
1232        ];
1233        let bodies = [
1234            "text\n- first\n- second\nfollowing text",
1235            "- first\nlazy paragraph\n- second\nafter",
1236            "- first\n\n```rust\n- literal\n```\n- second",
1237            "- first\n  ```rust\n  - literal\n  ```\n- second",
1238            "---\ntitle: List\n---\ntext\n- first\nnext",
1239            "<!-- rumdl-disable MD032 -->\ntext\n- first\nnext",
1240            "text\n- first\n<!-- rumdl-disable-next-line MD032 -->\n- second\nnext",
1241            "<!--\n- literal\n-->\ntext\n- first\nnext",
1242            "text\n2. fallback\n3. next",
1243            "text\n- first | cell\n| --- | --- |\n- second",
1244            "É 🦀 text\n- `literal`\nlazy text\n- next",
1245            "",
1246        ];
1247        for flavor in flavors {
1248            for prefix in ["", "> ", "> > "] {
1249                for body in bodies {
1250                    let source = body.lines().fold(String::new(), |mut source, line| {
1251                        source.push_str(prefix);
1252                        source.push_str(line);
1253                        source.push('\n');
1254                        source
1255                    });
1256                    for source in [source.clone(), source.replace('\n', "\r\n")] {
1257                        for fix_first in [false, true] {
1258                            let ctx = LintContext::new(&source, flavor, None);
1259                            // Both configurations share this context deliberately: the
1260                            // default plan must never supply a nondefault rule's answer.
1261                            for allow_lazy_continuation in [true, false, true] {
1262                                let rule = MD032BlanksAroundLists::from_config_struct(MD032Config {
1263                                    allow_lazy_continuation,
1264                                });
1265                                let expected_warnings = rule.check_native(&ctx);
1266                                let expected_fix = rule.fix_with_structure_impl(&ctx, &rule.withheld_fixes(&ctx));
1267                                if fix_first {
1268                                    assert_eq!(rule.fix(&ctx).unwrap(), expected_fix, "{flavor:?} {source:?}");
1269                                }
1270                                assert_eq!(rule.check(&ctx).unwrap(), expected_warnings, "{flavor:?} {source:?}");
1271                                assert_eq!(rule.fix(&ctx).unwrap(), expected_fix, "{flavor:?} {source:?}");
1272                                assert_eq!(rule.check(&ctx).unwrap(), expected_warnings);
1273                            }
1274                            // A rewrite gets a new context, so none of the old
1275                            // offsets or suppressed edits can survive the revision.
1276                            let rule = MD032BlanksAroundLists::default();
1277                            let fixed = rule.fix(&ctx).unwrap();
1278                            let next = LintContext::new(&fixed, flavor, None);
1279                            assert_eq!(rule.check(&next).unwrap(), rule.check_native(&next));
1280                            assert_eq!(
1281                                rule.fix(&next).unwrap(),
1282                                rule.fix_with_structure_impl(&next, &rule.withheld_fixes(&next))
1283                            );
1284                        }
1285                    }
1286                }
1287            }
1288        }
1289    }
1290
1291    #[test]
1292    fn test_list_coverage_matches_every_block_whatever_their_order() {
1293        // Out of order, overlapping, one nested inside another and ending
1294        // before it, one a single line, with gaps between them.
1295        let blocks: Vec<(usize, usize, String)> = [(20, 22), (3, 10), (5, 6), (9, 14), (30, 30), (17, 17)]
1296            .into_iter()
1297            .map(|(start, end)| (start, end, String::new()))
1298            .collect();
1299        let coverage = ListCoverage::new(&blocks);
1300        for line in 0..=35 {
1301            let expected = blocks.iter().any(|&(start, end, _)| start <= line && line <= end);
1302            assert_eq!(coverage.contains(line), expected, "line {line}");
1303        }
1304        assert!(!ListCoverage::new(&[]).contains(1));
1305    }
1306
1307    fn lint(content: &str) -> Vec<LintWarning> {
1308        let rule = MD032BlanksAroundLists::default();
1309        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1310        rule.check(&ctx).expect("Lint check failed")
1311    }
1312
1313    fn fix(content: &str) -> String {
1314        let rule = MD032BlanksAroundLists::default();
1315        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1316        rule.fix(&ctx).expect("Lint fix failed")
1317    }
1318
1319    #[test]
1320    fn test_spaced_nested_blockquotes_list_separation() {
1321        for (list_prefix, surrounding_prefix) in [
1322            ("> >", "> >"),
1323            (">  >", ">  >"),
1324            ("> > >", "> > >"),
1325            ("> >", ">>"),
1326            (">>", "> >"),
1327        ] {
1328            let content = format!(
1329                "{surrounding_prefix} Introduction\n{list_prefix} - item\n{surrounding_prefix} ~~~\n{surrounding_prefix} code\n{surrounding_prefix} ~~~\n"
1330            );
1331            let expected = format!(
1332                "{surrounding_prefix} Introduction\n{list_prefix}\n{list_prefix} - item\n{list_prefix}\n{surrounding_prefix} ~~~\n{surrounding_prefix} code\n{surrounding_prefix} ~~~\n"
1333            );
1334            let warnings = lint(&content);
1335            assert_eq!(warnings.len(), 2, "{content:?}: {warnings:?}");
1336            assert!(warnings.iter().all(|warning| warning.line == 2));
1337            let mut edited = content.clone();
1338            for warning in warnings.iter().rev() {
1339                let edit = warning.fix.as_ref().expect("missing diagnostic fix");
1340                edited.replace_range(edit.range.clone(), &edit.replacement);
1341            }
1342            assert_eq!(edited, expected, "Diagnostic fixes must preserve marker spacing");
1343            assert_eq!(fix(&content), expected);
1344            assert!(lint(&expected).is_empty(), "{expected:?}: {:?}", lint(&expected));
1345            assert_eq!(fix(&expected), expected, "Fix must be idempotent");
1346        }
1347    }
1348
1349    #[test]
1350    fn test_spaced_nested_blockquotes_preserve_list_code_and_exits() {
1351        for content in [
1352            "> > - item\n> >   ```\n> >   code\n> >   ```\n",
1353            "> > 1. item\n> >    ~~~\n> >    code\n> >    ~~~\n",
1354            "> > - item\n> ~~~\n> code\n> ~~~\n",
1355            "> > - item\n~~~\ncode\n~~~\n",
1356            "> > - item\n>> - next item\n",
1357        ] {
1358            assert!(lint(content).is_empty(), "{content:?}: {:?}", lint(content));
1359            assert_eq!(fix(content), content);
1360        }
1361    }
1362
1363    #[test]
1364    fn test_fix_after_a_list_split_by_a_fence_keeps_its_continuation_lines() {
1365        // A fence at the margin ends the list, and the lines before it, indented
1366        // or lazy, still belong to the last item's paragraph.
1367        for (content, expected) in [
1368            (
1369                "1. a\n2. b\n   c\n```\nx\n```\n3. d\n",
1370                "1. a\n2. b\n   c\n\n```\nx\n```\n3. d\n",
1371            ),
1372            (
1373                "* a\n```\n```\n* b\n](x)\n\ntext\n",
1374                "* a\n\n```\n```\n* b\n](x)\n\ntext\n",
1375            ),
1376        ] {
1377            assert_eq!(fix(content), expected, "{content:?}");
1378        }
1379        let warnings = lint("1. a\n2. b\n   c\n```\nx\n```\n3. d\n");
1380        assert_eq!(warnings.iter().map(|w| w.line).collect::<Vec<_>>(), vec![3]);
1381    }
1382
1383    #[test]
1384    fn test_backtick_line_short_of_the_content_column_is_lazy_text() {
1385        // Four columns of indent outside the item make the backticks paragraph
1386        // text rather than a fence, so the list runs on through them.
1387        let content = "   1.  a\n    ```\n    code\n";
1388        assert!(lint(content).is_empty(), "{:?}", lint(content));
1389        assert_eq!(fix(content), content);
1390    }
1391
1392    #[test]
1393    fn test_blank_after_a_quoted_list_ending_in_lazy_text_stays_in_the_quote() {
1394        let content = "> 1. a\nlazy\n> - b\n";
1395        let warnings = lint(content);
1396        assert_eq!(warnings.len(), 1, "{warnings:?}");
1397        assert_eq!(fix(content), "> 1. a\nlazy\n>\n> - b\n");
1398    }
1399
1400    #[test]
1401    fn test_fix_never_changes_how_the_lists_parse() {
1402        // Items indented between two content columns, or quoted apart from
1403        // the list before them, are read as one list by the parser and as
1404        // several by the list model. A blank line between them would make the
1405        // list loose or move items into another list, so those fixes are
1406        // withheld while the fixes that only separate lists stay.
1407        for (content, expected) in [
1408            ("1.   item 41\n   +  item 51\n+ item 52\n", None),
1409            (" *   item 17\n   10.  item 21\n1. item 22\n", None),
1410            ("-  item 6\n>         ```\n   10. item 23\n 1.  item 24\n", None),
1411            (
1412                "   10. item 43\n>   +  item 47\n>  continuation text\n>      1. item 50\n",
1413                Some("   10. item 43\n>\n>   +  item 47\n>  continuation text\n>      1. item 50\n"),
1414            ),
1415            (
1416                "   1.  item 63\n>    +  item 65\n> +   item 66\n",
1417                Some("   1.  item 63\n>\n>    +  item 65\n> +   item 66\n"),
1418            ),
1419        ] {
1420            let expected = expected.unwrap_or(content);
1421            assert_eq!(fix(content), expected, "{content:?}");
1422            let fixable = lint(content).iter().filter(|w| w.fix.is_some()).count();
1423            assert_eq!(fixable, usize::from(expected != content), "{content:?}");
1424        }
1425    }
1426
1427    #[test]
1428    fn test_fix_separates_list_from_standalone_code_fence() {
1429        for (content, expected) in [
1430            (
1431                "# Test\n\n>   - List item 1\n>   - List item 2\n> ```\n> code\n> ```\n",
1432                "# Test\n\n>   - List item 1\n>   - List item 2\n>\n> ```\n> code\n> ```\n",
1433            ),
1434            ("- item\n```rust\ncode\n```\n", "- item\n\n```rust\ncode\n```\n"),
1435            ("1. item\n~~~\ncode\n~~~", "1. item\n\n~~~\ncode\n~~~"),
1436            (
1437                ">> - item\n>> ~~~\n>> code\n>> ~~~\n",
1438                ">> - item\n>>\n>> ~~~\n>> code\n>> ~~~\n",
1439            ),
1440        ] {
1441            let warnings = lint(content);
1442            assert_eq!(warnings.len(), 1, "{content:?}: {warnings:?}");
1443            assert_eq!(warnings[0].message, "List should be followed by blank line");
1444            let edit = warnings[0].fix.as_ref().expect("missing warning fix");
1445            let mut edited = content.to_string();
1446            edited.replace_range(edit.range.clone(), &edit.replacement);
1447            assert_eq!(edited, expected, "Diagnostic and document fixes must agree");
1448            assert_eq!(fix(content), expected, "{content:?}");
1449            assert!(lint(expected).is_empty(), "{expected:?}");
1450            assert_eq!(fix(expected), expected, "Fix must be idempotent");
1451        }
1452    }
1453
1454    #[test]
1455    fn test_fix_preserves_code_fence_inside_list_item() {
1456        for content in [
1457            "- item\n  ```\n  code\n  ```\n",
1458            "1. item\n   ~~~\n   code\n   ~~~\n",
1459            "> - item\n>   ```\n>   code\n>   ```\n",
1460        ] {
1461            assert!(lint(content).is_empty(), "{content:?}: {:?}", lint(content));
1462            assert_eq!(fix(content), content, "A nested fence must stay inside its list item");
1463        }
1464    }
1465
1466    #[test]
1467    fn test_fix_does_not_split_item_before_different_list_type() {
1468        // The continuation line belongs to the bullet item. Only one blank line
1469        // is needed, between the two lists; inserting one after the marker line
1470        // would split the item into a list plus a stray paragraph.
1471        let content = "- alpha beta\n  aligned\n1. ordered item\n   cont\n";
1472        assert_eq!(fix(content), "- alpha beta\n  aligned\n\n1. ordered item\n   cont\n");
1473
1474        // check() anchors the boundary on the first list's last line and the
1475        // second list's first line, exactly as it does when neither item wraps.
1476        let warnings = lint(content);
1477        assert_eq!(warnings.len(), 2);
1478        assert_eq!(warnings[0].line, 2);
1479        assert_eq!(warnings[1].line, 3);
1480    }
1481
1482    #[test]
1483    fn test_fix_does_not_split_blockquoted_item_before_different_list_type() {
1484        let content = "> - alpha beta\n>   aligned\n> 1. ordered item\n";
1485        assert_eq!(fix(content), "> - alpha beta\n>   aligned\n>\n> 1. ordered item\n");
1486    }
1487
1488    #[test]
1489    fn test_fix_keeps_lazy_continuation_with_its_item() {
1490        // Per CommonMark the lazy line continues the item's paragraph, so the
1491        // blank belongs after it, before the new list. Splitting it off would
1492        // promote it to a standalone paragraph and change the rendering.
1493        let content = "- alpha beta\nlazy\n1. ordered item\n";
1494        assert_eq!(fix(content), "- alpha beta\nlazy\n\n1. ordered item\n");
1495
1496        let warnings = lint(content);
1497        assert_eq!(warnings.len(), 2);
1498        assert_eq!(warnings[0].line, 2);
1499        assert_eq!(warnings[1].line, 3);
1500    }
1501
1502    #[test]
1503    fn test_fix_keeps_blockquoted_lazy_continuation_with_its_item() {
1504        let content = "> - alpha beta\n> lazy\n> 1. ordered item\n";
1505        assert_eq!(fix(content), "> - alpha beta\n> lazy\n>\n> 1. ordered item\n");
1506    }
1507
1508    #[test]
1509    fn test_fix_indents_lazy_continuation_when_not_allowed() {
1510        // With allow_lazy_continuation = false the lazy line is first indented
1511        // into the item (Phase 0), then the blank separates the two lists.
1512        let rule = MD032BlanksAroundLists::from_config_struct(MD032Config {
1513            allow_lazy_continuation: false,
1514        });
1515        let content = "- alpha beta\nlazy\n1. ordered item\n";
1516        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1517        let fixed = rule.fix(&ctx).expect("Lint fix failed");
1518        assert_eq!(fixed, "- alpha beta\n  lazy\n\n1. ordered item\n");
1519    }
1520
1521    #[test]
1522    fn test_lazy_continuation_does_not_indent_template_values() {
1523        let rule = MD032BlanksAroundLists::from_config_struct(MD032Config {
1524            allow_lazy_continuation: false,
1525        });
1526        for (flavor, code) in [
1527            (
1528                crate::config::MarkdownFlavor::Standard,
1529                "{% set unused=\"first\nLiteral\nlast\" %}{{ unused|length }}",
1530            ),
1531            (
1532                crate::config::MarkdownFlavor::Hugo,
1533                "{{< note title=`first\nLiteral\nlast` >}}",
1534            ),
1535        ] {
1536            let source = format!("- Visible {code}\n- Sibling\n\nOther:\n- Item\n");
1537            let expected = format!("- Visible {code}\n- Sibling\n\nOther:\n\n- Item\n");
1538            let ctx = LintContext::new(&source, flavor, None);
1539            assert_eq!(rule.check(&ctx).unwrap().len(), 1, "{source}");
1540            assert_eq!(rule.fix(&ctx).unwrap(), expected, "{source}");
1541        }
1542    }
1543
1544    #[test]
1545    fn test_div_closer_after_list_is_not_a_lazy_continuation_in_quarto() {
1546        // On a Pandoc-compatible flavor a `:::` line is the div's fence, not
1547        // prose continuing the last item, so `allow_lazy_continuation = false`
1548        // must neither report it nor indent it into the item.
1549        let rule = MD032BlanksAroundLists::from_config_struct(MD032Config {
1550            allow_lazy_continuation: false,
1551        });
1552        let content = "::: callout-note\n- List item 1\n- List item 2\n:::\n";
1553        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1554        let warnings = rule.check(&ctx).expect("Lint check failed");
1555        assert!(warnings.is_empty(), "Expected no warnings, got: {warnings:?}");
1556        let fixed = rule.fix(&ctx).expect("Lint fix failed");
1557        assert_eq!(fixed, content);
1558    }
1559
1560    #[test]
1561    fn test_prose_after_list_in_quarto_div_is_still_a_lazy_continuation() {
1562        // Positive control for the div-marker exemption: real prose under the
1563        // item inside a div is still indented when lazy continuation is off,
1564        // and the closing fence that follows it stays where it is.
1565        let rule = MD032BlanksAroundLists::from_config_struct(MD032Config {
1566            allow_lazy_continuation: false,
1567        });
1568        let content = "::: callout-note\n- List item 1\nlazy\n:::\n";
1569        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1570        let warnings = rule.check(&ctx).expect("Lint check failed");
1571        assert_eq!(
1572            warnings.len(),
1573            1,
1574            "Expected one lazy-continuation warning, got: {warnings:?}"
1575        );
1576        assert_eq!(warnings[0].line, 3);
1577        let fixed = rule.fix(&ctx).expect("Lint fix failed");
1578        assert_eq!(fixed, "::: callout-note\n- List item 1\n  lazy\n:::\n");
1579    }
1580
1581    #[test]
1582    fn test_div_closer_after_list_is_a_lazy_continuation_in_standard() {
1583        // Outside the Pandoc-compatible flavors `:::` is ordinary text, so it
1584        // lazily continues the item exactly as CommonMark reads it. Indented
1585        // into the item it would start a definition list description, so the
1586        // warning comes without a fix.
1587        let rule = MD032BlanksAroundLists::from_config_struct(MD032Config {
1588            allow_lazy_continuation: false,
1589        });
1590        let content = "Intro\n\n- List item 1\n- List item 2\n:::\n";
1591        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1592        let warnings = rule.check(&ctx).unwrap();
1593        assert_eq!(warnings.len(), 1, "{warnings:?}");
1594        assert_eq!(warnings[0].line, 5);
1595        assert!(warnings[0].fix.is_none(), "{warnings:?}");
1596        assert_eq!(rule.fix(&ctx).unwrap(), content);
1597
1598        let content = "Intro\n\n- List item 1\n- List item 2\nlazy\n";
1599        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1600        assert_eq!(
1601            rule.fix(&ctx).unwrap(),
1602            "Intro\n\n- List item 1\n- List item 2\n  lazy\n"
1603        );
1604    }
1605
1606    // Test that warnings include Fix objects
1607    fn check_warnings_have_fixes(content: &str) {
1608        let warnings = lint(content);
1609        for warning in &warnings {
1610            assert!(warning.fix.is_some(), "Warning should have fix: {warning:?}");
1611        }
1612    }
1613
1614    #[test]
1615    fn test_list_at_start() {
1616        // Per markdownlint-cli: trailing text without blank line is treated as lazy continuation
1617        // so NO warning is expected here
1618        let content = "- Item 1\n- Item 2\nText";
1619        let warnings = lint(content);
1620        assert_eq!(
1621            warnings.len(),
1622            0,
1623            "Trailing text is lazy continuation per CommonMark - no warning expected"
1624        );
1625    }
1626
1627    #[test]
1628    fn test_list_at_end() {
1629        let content = "Text\n- Item 1\n- Item 2";
1630        let warnings = lint(content);
1631        assert_eq!(
1632            warnings.len(),
1633            1,
1634            "Expected 1 warning for list at end without preceding blank line"
1635        );
1636        assert_eq!(
1637            warnings[0].line, 2,
1638            "Warning should be on the first line of the list (line 2)"
1639        );
1640        assert!(warnings[0].message.contains("preceded by blank line"));
1641
1642        // Test that warning has fix
1643        check_warnings_have_fixes(content);
1644
1645        let fixed_content = fix(content);
1646        assert_eq!(fixed_content, "Text\n\n- Item 1\n- Item 2");
1647
1648        // Verify fix resolves the issue
1649        let warnings_after_fix = lint(&fixed_content);
1650        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1651    }
1652
1653    #[test]
1654    fn test_list_in_middle() {
1655        // Per markdownlint-cli: only preceding blank line is required
1656        // Trailing text is treated as lazy continuation
1657        let content = "Text 1\n- Item 1\n- Item 2\nText 2";
1658        let warnings = lint(content);
1659        assert_eq!(
1660            warnings.len(),
1661            1,
1662            "Expected 1 warning for list needing preceding blank line (trailing text is lazy continuation)"
1663        );
1664        assert_eq!(warnings[0].line, 2, "Warning on line 2 (start)");
1665        assert!(warnings[0].message.contains("preceded by blank line"));
1666
1667        // Test that warnings have fixes
1668        check_warnings_have_fixes(content);
1669
1670        let fixed_content = fix(content);
1671        assert_eq!(fixed_content, "Text 1\n\n- Item 1\n- Item 2\nText 2");
1672
1673        // Verify fix resolves the issue
1674        let warnings_after_fix = lint(&fixed_content);
1675        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1676    }
1677
1678    #[test]
1679    fn test_correct_spacing() {
1680        let content = "Text 1\n\n- Item 1\n- Item 2\n\nText 2";
1681        let warnings = lint(content);
1682        assert_eq!(warnings.len(), 0, "Expected no warnings for correctly spaced list");
1683
1684        let fixed_content = fix(content);
1685        assert_eq!(fixed_content, content, "Fix should not change correctly spaced content");
1686    }
1687
1688    #[test]
1689    fn test_list_with_content() {
1690        // Per markdownlint-cli: only preceding blank line warning
1691        // Trailing text is lazy continuation
1692        let content = "Text\n* Item 1\n  Content\n* Item 2\n  More content\nText";
1693        let warnings = lint(content);
1694        assert_eq!(
1695            warnings.len(),
1696            1,
1697            "Expected 1 warning for list needing preceding blank line. Got: {warnings:?}"
1698        );
1699        assert_eq!(warnings[0].line, 2, "Warning should be on line 2 (start)");
1700        assert!(warnings[0].message.contains("preceded by blank line"));
1701
1702        // Test that warnings have fixes
1703        check_warnings_have_fixes(content);
1704
1705        let fixed_content = fix(content);
1706        let expected_fixed = "Text\n\n* Item 1\n  Content\n* Item 2\n  More content\nText";
1707        assert_eq!(
1708            fixed_content, expected_fixed,
1709            "Fix did not produce the expected output. Got:\n{fixed_content}"
1710        );
1711
1712        // Verify fix resolves the issue
1713        let warnings_after_fix = lint(&fixed_content);
1714        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1715    }
1716
1717    #[test]
1718    fn test_nested_list() {
1719        // Per markdownlint-cli: only preceding blank line warning
1720        let content = "Text\n- Item 1\n  - Nested 1\n- Item 2\nText";
1721        let warnings = lint(content);
1722        assert_eq!(
1723            warnings.len(),
1724            1,
1725            "Nested list block needs preceding blank only. Got: {warnings:?}"
1726        );
1727        assert_eq!(warnings[0].line, 2);
1728        assert!(warnings[0].message.contains("preceded by blank line"));
1729
1730        // Test that warnings have fixes
1731        check_warnings_have_fixes(content);
1732
1733        let fixed_content = fix(content);
1734        assert_eq!(fixed_content, "Text\n\n- Item 1\n  - Nested 1\n- Item 2\nText");
1735
1736        // Verify fix resolves the issue
1737        let warnings_after_fix = lint(&fixed_content);
1738        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1739    }
1740
1741    #[test]
1742    fn test_list_with_internal_blanks() {
1743        // Per markdownlint-cli: only preceding blank line warning
1744        let content = "Text\n* Item 1\n\n  More Item 1 Content\n* Item 2\nText";
1745        let warnings = lint(content);
1746        assert_eq!(
1747            warnings.len(),
1748            1,
1749            "List with internal blanks needs preceding blank only. Got: {warnings:?}"
1750        );
1751        assert_eq!(warnings[0].line, 2);
1752        assert!(warnings[0].message.contains("preceded by blank line"));
1753
1754        // Test that warnings have fixes
1755        check_warnings_have_fixes(content);
1756
1757        let fixed_content = fix(content);
1758        assert_eq!(
1759            fixed_content,
1760            "Text\n\n* Item 1\n\n  More Item 1 Content\n* Item 2\nText"
1761        );
1762
1763        // Verify fix resolves the issue
1764        let warnings_after_fix = lint(&fixed_content);
1765        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1766    }
1767
1768    #[test]
1769    fn test_ignore_code_blocks() {
1770        let content = "```\n- Not a list item\n```\nText";
1771        let warnings = lint(content);
1772        assert_eq!(warnings.len(), 0);
1773        let fixed_content = fix(content);
1774        assert_eq!(fixed_content, content);
1775    }
1776
1777    #[test]
1778    fn test_ignore_front_matter() {
1779        // Per markdownlint-cli: NO warnings - front matter is followed by list, trailing text is lazy continuation
1780        let content = "---\ntitle: Test\n---\n- List Item\nText";
1781        let warnings = lint(content);
1782        assert_eq!(
1783            warnings.len(),
1784            0,
1785            "Front matter test should have no MD032 warnings. Got: {warnings:?}"
1786        );
1787
1788        // No fixes needed since no warnings
1789        let fixed_content = fix(content);
1790        assert_eq!(fixed_content, content, "No changes when no warnings");
1791    }
1792
1793    #[test]
1794    fn test_multiple_lists() {
1795        // Our implementation treats "Text 2" and "Text 3" as lazy continuation within a single merged list block
1796        // (since both - and * are unordered markers and there's no structural separator)
1797        // markdownlint-cli sees them as separate lists with 3 warnings, but our behavior differs.
1798        // The key requirement is that the fix resolves all warnings.
1799        let content = "Text\n- List 1 Item 1\n- List 1 Item 2\nText 2\n* List 2 Item 1\nText 3";
1800        let warnings = lint(content);
1801        // At minimum we should warn about missing preceding blank for line 2
1802        assert!(
1803            !warnings.is_empty(),
1804            "Should have at least one warning for missing blank line. Got: {warnings:?}"
1805        );
1806
1807        // Test that warnings have fixes
1808        check_warnings_have_fixes(content);
1809
1810        let fixed_content = fix(content);
1811        // The fix should add blank lines before lists that need them
1812        let warnings_after_fix = lint(&fixed_content);
1813        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1814    }
1815
1816    #[test]
1817    fn test_adjacent_lists() {
1818        let content = "- List 1\n\n* List 2";
1819        let warnings = lint(content);
1820        assert_eq!(warnings.len(), 0);
1821        let fixed_content = fix(content);
1822        assert_eq!(fixed_content, content);
1823    }
1824
1825    #[test]
1826    fn test_list_in_blockquote() {
1827        // Per markdownlint-cli: 1 warning (preceding only, trailing is lazy continuation)
1828        let content = "> Quote line 1\n> - List item 1\n> - List item 2\n> Quote line 2";
1829        let warnings = lint(content);
1830        assert_eq!(
1831            warnings.len(),
1832            1,
1833            "Expected 1 warning for blockquoted list needing preceding blank. Got: {warnings:?}"
1834        );
1835        assert_eq!(warnings[0].line, 2);
1836
1837        // Test that warnings have fixes
1838        check_warnings_have_fixes(content);
1839
1840        let fixed_content = fix(content);
1841        // Fix should add blank line before list only (no trailing space per markdownlint-cli)
1842        assert_eq!(
1843            fixed_content, "> Quote line 1\n>\n> - List item 1\n> - List item 2\n> Quote line 2",
1844            "Fix for blockquoted list failed. Got:\n{fixed_content}"
1845        );
1846
1847        // Verify fix resolves the issue
1848        let warnings_after_fix = lint(&fixed_content);
1849        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1850    }
1851
1852    #[test]
1853    fn test_ordered_list() {
1854        // Per markdownlint-cli: 1 warning (preceding only)
1855        let content = "Text\n1. Item 1\n2. Item 2\nText";
1856        let warnings = lint(content);
1857        assert_eq!(warnings.len(), 1);
1858
1859        // Test that warnings have fixes
1860        check_warnings_have_fixes(content);
1861
1862        let fixed_content = fix(content);
1863        assert_eq!(fixed_content, "Text\n\n1. Item 1\n2. Item 2\nText");
1864
1865        // Verify fix resolves the issue
1866        let warnings_after_fix = lint(&fixed_content);
1867        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1868    }
1869
1870    #[test]
1871    fn test_no_double_blank_fix() {
1872        // Per markdownlint-cli: trailing text is lazy continuation, so NO warning needed
1873        let content = "Text\n\n- Item 1\n- Item 2\nText"; // Has preceding blank, trailing is lazy
1874        let warnings = lint(content);
1875        assert_eq!(
1876            warnings.len(),
1877            0,
1878            "Should have no warnings - properly preceded, trailing is lazy"
1879        );
1880
1881        let fixed_content = fix(content);
1882        assert_eq!(
1883            fixed_content, content,
1884            "No fix needed when no warnings. Got:\n{fixed_content}"
1885        );
1886
1887        let content2 = "Text\n- Item 1\n- Item 2\n\nText"; // Missing blank before
1888        let warnings2 = lint(content2);
1889        assert_eq!(warnings2.len(), 1);
1890        if !warnings2.is_empty() {
1891            assert_eq!(
1892                warnings2[0].line, 2,
1893                "Warning line for missing blank before should be the first line of the block"
1894            );
1895        }
1896
1897        // Test that warnings have fixes
1898        check_warnings_have_fixes(content2);
1899
1900        let fixed_content2 = fix(content2);
1901        assert_eq!(
1902            fixed_content2, "Text\n\n- Item 1\n- Item 2\n\nText",
1903            "Fix added extra blank before. Got:\n{fixed_content2}"
1904        );
1905    }
1906
1907    #[test]
1908    fn test_empty_input() {
1909        let content = "";
1910        let warnings = lint(content);
1911        assert_eq!(warnings.len(), 0);
1912        let fixed_content = fix(content);
1913        assert_eq!(fixed_content, "");
1914    }
1915
1916    #[test]
1917    fn test_only_list() {
1918        let content = "- Item 1\n- Item 2";
1919        let warnings = lint(content);
1920        assert_eq!(warnings.len(), 0);
1921        let fixed_content = fix(content);
1922        assert_eq!(fixed_content, content);
1923    }
1924
1925    // === COMPREHENSIVE FIX TESTS ===
1926
1927    #[test]
1928    fn test_fix_complex_nested_blockquote() {
1929        // Per markdownlint-cli: 1 warning (preceding only)
1930        let content = "> Text before\n> - Item 1\n>   - Nested item\n> - Item 2\n> Text after";
1931        let warnings = lint(content);
1932        assert_eq!(
1933            warnings.len(),
1934            1,
1935            "Should warn for missing preceding blank only. Got: {warnings:?}"
1936        );
1937
1938        // Test that warnings have fixes
1939        check_warnings_have_fixes(content);
1940
1941        let fixed_content = fix(content);
1942        // Per markdownlint-cli, blank lines in blockquotes have no trailing space
1943        let expected = "> Text before\n>\n> - Item 1\n>   - Nested item\n> - Item 2\n> Text after";
1944        assert_eq!(fixed_content, expected, "Fix should preserve blockquote structure");
1945
1946        let warnings_after_fix = lint(&fixed_content);
1947        assert_eq!(warnings_after_fix.len(), 0, "Fix should eliminate all warnings");
1948    }
1949
1950    #[test]
1951    fn test_fix_mixed_list_markers() {
1952        // Per markdownlint-cli: mixed markers may be treated as separate lists
1953        // The exact behavior depends on implementation details
1954        let content = "Text\n- Item 1\n* Item 2\n+ Item 3\nText";
1955        let warnings = lint(content);
1956        // At minimum, there should be a warning for the first list needing preceding blank
1957        assert!(
1958            !warnings.is_empty(),
1959            "Should have at least 1 warning for mixed marker list. Got: {warnings:?}"
1960        );
1961
1962        // Test that warnings have fixes
1963        check_warnings_have_fixes(content);
1964
1965        let fixed_content = fix(content);
1966        // The fix should add at least a blank line before the first list
1967        assert!(
1968            fixed_content.contains("Text\n\n-"),
1969            "Fix should add blank line before first list item"
1970        );
1971
1972        // Verify fix resolves the issue
1973        let warnings_after_fix = lint(&fixed_content);
1974        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1975    }
1976
1977    #[test]
1978    fn test_fix_ordered_list_with_different_numbers() {
1979        // Per markdownlint-cli: 1 warning (preceding only)
1980        let content = "Text\n1. First\n3. Third\n2. Second\nText";
1981        let warnings = lint(content);
1982        assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1983
1984        // Test that warnings have fixes
1985        check_warnings_have_fixes(content);
1986
1987        let fixed_content = fix(content);
1988        let expected = "Text\n\n1. First\n3. Third\n2. Second\nText";
1989        assert_eq!(
1990            fixed_content, expected,
1991            "Fix should handle ordered lists with non-sequential numbers"
1992        );
1993
1994        // Verify fix resolves the issue
1995        let warnings_after_fix = lint(&fixed_content);
1996        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1997    }
1998
1999    #[test]
2000    fn test_fix_list_with_code_blocks_inside() {
2001        // Per markdownlint-cli: 1 warning (preceding only)
2002        let content = "Text\n- Item 1\n  ```\n  code\n  ```\n- Item 2\nText";
2003        let warnings = lint(content);
2004        assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
2005
2006        // Test that warnings have fixes
2007        check_warnings_have_fixes(content);
2008
2009        let fixed_content = fix(content);
2010        let expected = "Text\n\n- Item 1\n  ```\n  code\n  ```\n- Item 2\nText";
2011        assert_eq!(
2012            fixed_content, expected,
2013            "Fix should handle lists with internal code blocks"
2014        );
2015
2016        // Verify fix resolves the issue
2017        let warnings_after_fix = lint(&fixed_content);
2018        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
2019    }
2020
2021    #[test]
2022    fn test_fix_deeply_nested_lists() {
2023        // Per markdownlint-cli: 1 warning (preceding only)
2024        let content = "Text\n- Level 1\n  - Level 2\n    - Level 3\n      - Level 4\n- Back to Level 1\nText";
2025        let warnings = lint(content);
2026        assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
2027
2028        // Test that warnings have fixes
2029        check_warnings_have_fixes(content);
2030
2031        let fixed_content = fix(content);
2032        let expected = "Text\n\n- Level 1\n  - Level 2\n    - Level 3\n      - Level 4\n- Back to Level 1\nText";
2033        assert_eq!(fixed_content, expected, "Fix should handle deeply nested lists");
2034
2035        // Verify fix resolves the issue
2036        let warnings_after_fix = lint(&fixed_content);
2037        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
2038    }
2039
2040    #[test]
2041    fn test_fix_list_with_multiline_items() {
2042        // Per markdownlint-cli: trailing "Text" at indent=0 is lazy continuation
2043        // Only the preceding blank line is required
2044        let content = "Text\n- Item 1\n  continues here\n  and here\n- Item 2\n  also continues\nText";
2045        let warnings = lint(content);
2046        assert_eq!(
2047            warnings.len(),
2048            1,
2049            "Should only warn for missing blank before list (trailing text is lazy continuation)"
2050        );
2051
2052        // Test that warnings have fixes
2053        check_warnings_have_fixes(content);
2054
2055        let fixed_content = fix(content);
2056        let expected = "Text\n\n- Item 1\n  continues here\n  and here\n- Item 2\n  also continues\nText";
2057        assert_eq!(fixed_content, expected, "Fix should add blank before list only");
2058
2059        // Verify fix resolves the issue
2060        let warnings_after_fix = lint(&fixed_content);
2061        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
2062    }
2063
2064    #[test]
2065    fn test_fix_list_at_document_boundaries() {
2066        // List at very start
2067        let content1 = "- Item 1\n- Item 2";
2068        let warnings1 = lint(content1);
2069        assert_eq!(
2070            warnings1.len(),
2071            0,
2072            "List at document start should not need blank before"
2073        );
2074        let fixed1 = fix(content1);
2075        assert_eq!(fixed1, content1, "No fix needed for list at start");
2076
2077        // List at very end
2078        let content2 = "Text\n- Item 1\n- Item 2";
2079        let warnings2 = lint(content2);
2080        assert_eq!(warnings2.len(), 1, "List at document end should need blank before");
2081        check_warnings_have_fixes(content2);
2082        let fixed2 = fix(content2);
2083        assert_eq!(
2084            fixed2, "Text\n\n- Item 1\n- Item 2",
2085            "Should add blank before list at end"
2086        );
2087    }
2088
2089    #[test]
2090    fn test_fix_preserves_existing_blank_lines() {
2091        let content = "Text\n\n\n- Item 1\n- Item 2\n\n\nText";
2092        let warnings = lint(content);
2093        assert_eq!(warnings.len(), 0, "Multiple blank lines should be preserved");
2094        let fixed_content = fix(content);
2095        assert_eq!(fixed_content, content, "Fix should not modify already correct content");
2096    }
2097
2098    #[test]
2099    fn test_fix_handles_tabs_and_spaces() {
2100        // Tab at line start = 4 spaces = indented code (not a list item per CommonMark)
2101        // Only the space-indented line is a real list item
2102        let content = "Text\n\t- Item with tab\n  - Item with spaces\nText";
2103        let warnings = lint(content);
2104        // Per markdownlint-cli: only line 3 (space-indented) is a list needing blanks
2105        assert!(!warnings.is_empty(), "Should warn for missing blank before list");
2106
2107        // Test that warnings have fixes
2108        check_warnings_have_fixes(content);
2109
2110        let fixed_content = fix(content);
2111        // Add blank before the actual list item (line 3), not the tab-indented code (line 2)
2112        // Trailing text is lazy continuation, so no blank after
2113        let expected = "Text\n\t- Item with tab\n\n  - Item with spaces\nText";
2114        assert_eq!(fixed_content, expected, "Fix should add blank before list item");
2115
2116        // Verify fix resolves the issue
2117        let warnings_after_fix = lint(&fixed_content);
2118        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
2119    }
2120
2121    #[test]
2122    fn test_fix_warning_objects_have_correct_ranges() {
2123        // Per markdownlint-cli: trailing text is lazy continuation, only 1 warning
2124        let content = "Text\n- Item 1\n- Item 2\nText";
2125        let warnings = lint(content);
2126        assert_eq!(warnings.len(), 1, "Only preceding blank warning expected");
2127
2128        // Check that each warning has a fix with a valid range
2129        for warning in &warnings {
2130            assert!(warning.fix.is_some(), "Warning should have fix");
2131            let fix = warning.fix.as_ref().unwrap();
2132            assert!(fix.range.start <= fix.range.end, "Fix range should be valid");
2133            assert!(
2134                !fix.replacement.is_empty() || fix.range.start == fix.range.end,
2135                "Fix should have replacement or be insertion"
2136            );
2137        }
2138    }
2139
2140    #[test]
2141    fn test_fix_idempotent() {
2142        // Per markdownlint-cli: trailing text is lazy continuation
2143        let content = "Text\n- Item 1\n- Item 2\nText";
2144
2145        // Apply fix once - only adds blank before (trailing text is lazy continuation)
2146        let fixed_once = fix(content);
2147        assert_eq!(fixed_once, "Text\n\n- Item 1\n- Item 2\nText");
2148
2149        // Apply fix again - should be unchanged
2150        let fixed_twice = fix(&fixed_once);
2151        assert_eq!(fixed_twice, fixed_once, "Fix should be idempotent");
2152
2153        // No warnings after fix
2154        let warnings_after_fix = lint(&fixed_once);
2155        assert_eq!(warnings_after_fix.len(), 0, "No warnings should remain after fix");
2156    }
2157
2158    #[test]
2159    fn test_fix_preserves_crlf_and_matches_diagnostic_edits() {
2160        let rule = MD032BlanksAroundLists::default();
2161        for (content, expected) in [
2162            ("Text\r\n- item\r\n", "Text\r\n\r\n- item\r\n"),
2163            (
2164                "> > - item\r\n> > ~~~\r\n> > code\r\n> > ~~~",
2165                "> > - item\r\n> >\r\n> > ~~~\r\n> > code\r\n> > ~~~",
2166            ),
2167            ("Text\r\n\r\n- item\r\n", "Text\r\n\r\n- item\r\n"),
2168            ("Text\r\n\n- item\r\n", "Text\r\n\n- item\r\n"),
2169        ] {
2170            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2171            let warnings = rule.check(&ctx).unwrap();
2172            let edited = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
2173            assert_eq!(edited, expected);
2174            assert_eq!(rule.fix(&ctx).unwrap(), expected);
2175            let fixed_ctx = LintContext::new(expected, crate::config::MarkdownFlavor::Standard, None);
2176            assert!(rule.check(&fixed_ctx).unwrap().is_empty());
2177            assert_eq!(rule.fix(&fixed_ctx).unwrap(), expected);
2178        }
2179    }
2180
2181    #[test]
2182    fn test_fix_respects_inline_config_at_each_list_boundary() {
2183        use crate::utils::fix_utils::{apply_warning_fixes, filter_warnings_by_inline_config};
2184
2185        let rule = MD032BlanksAroundLists::default();
2186        // Comments can belong to a list's final segment. Suppression must follow
2187        // the diagnostic line at each boundary, even if the list starts elsewhere.
2188        for (content, expected, warning_lines) in [
2189            (
2190                "Text\n<!-- rumdl-disable-next-line MD032 -->\n- item\ntail <!-- comment -->\n# Heading\n",
2191                "Text\n<!-- rumdl-disable-next-line MD032 -->\n- item\ntail <!-- comment -->\n\n# Heading\n",
2192                vec![4],
2193            ),
2194            (
2195                "Text\n- item\n<!-- rumdl-disable-next-line MD032 -->\n<!-- comment -->\n# Heading\n",
2196                "Text\n\n- item\n<!-- rumdl-disable-next-line MD032 -->\n<!-- comment -->\n# Heading\n",
2197                vec![2],
2198            ),
2199            (
2200                "Text\n- item\n<!-- rumdl-disable MD032 -->\n<!-- comment -->\n# Heading\n<!-- rumdl-enable MD032 -->\nText\n- enabled\n",
2201                "Text\n\n- item\n<!-- rumdl-disable MD032 -->\n<!-- comment -->\n# Heading\n<!-- rumdl-enable MD032 -->\nText\n\n- enabled\n",
2202                vec![2, 8],
2203            ),
2204            (
2205                "Text\n- item\n<!-- rumdl-disable -->\n<!-- comment -->\n# Heading\n<!-- rumdl-enable -->\nText\n- enabled\n",
2206                "Text\n\n- item\n<!-- rumdl-disable -->\n<!-- comment -->\n# Heading\n<!-- rumdl-enable -->\nText\n\n- enabled\n",
2207                vec![2, 8],
2208            ),
2209            (
2210                // The disable names another rule, so MD032 still reports the
2211                // boundary it owns. The line it is written on separates the
2212                // paragraph from the list, so only the heading below is missing
2213                // its blank line.
2214                "Text\n<!-- rumdl-disable MD013 -->\n- item\n# Heading\n",
2215                "Text\n<!-- rumdl-disable MD013 -->\n- item\n\n# Heading\n",
2216                vec![3],
2217            ),
2218        ] {
2219            for ending in ["\n", "\r\n"] {
2220                for final_newline in [true, false] {
2221                    let content = if final_newline {
2222                        content
2223                    } else {
2224                        content.trim_end_matches('\n')
2225                    };
2226                    let expected = if final_newline {
2227                        expected
2228                    } else {
2229                        expected.trim_end_matches('\n')
2230                    };
2231                    let content = content.replace('\n', ending);
2232                    let expected = expected.replace('\n', ending);
2233                    let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
2234                    let warnings =
2235                        filter_warnings_by_inline_config(rule.check(&ctx).unwrap(), ctx.inline_config(), rule.name());
2236                    assert_eq!(warnings.iter().map(|w| w.line).collect::<Vec<_>>(), warning_lines);
2237                    assert_eq!(apply_warning_fixes(&content, &warnings).unwrap(), expected);
2238                    assert_eq!(rule.fix(&ctx).unwrap(), expected);
2239                    let fixed_ctx = LintContext::new(&expected, crate::config::MarkdownFlavor::Standard, None);
2240                    assert!(
2241                        filter_warnings_by_inline_config(
2242                            rule.check(&fixed_ctx).unwrap(),
2243                            fixed_ctx.inline_config(),
2244                            rule.name()
2245                        )
2246                        .is_empty()
2247                    );
2248                    assert_eq!(rule.fix(&fixed_ctx).unwrap(), expected);
2249                }
2250            }
2251        }
2252    }
2253
2254    #[test]
2255    fn test_disabled_lazy_fix_preserves_mixed_line_endings() {
2256        use crate::utils::fix_utils::{apply_warning_fixes, filter_warnings_by_inline_config};
2257
2258        let rule = MD032BlanksAroundLists::from_config_struct(MD032Config {
2259            allow_lazy_continuation: false,
2260        });
2261        let content = "<!-- rumdl-disable MD032 -->\r\n\r\n- item\ncontinuation\r\n- next\r\n";
2262        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2263        let raw = rule.check(&ctx).unwrap();
2264        assert_eq!(raw.len(), 1);
2265        assert!(raw[0].fix.is_some());
2266        let warnings = filter_warnings_by_inline_config(raw, ctx.inline_config(), rule.name());
2267        assert!(warnings.is_empty());
2268        assert_eq!(apply_warning_fixes(content, &warnings).unwrap(), content);
2269        assert_eq!(rule.fix(&ctx).unwrap(), content);
2270    }
2271
2272    #[test]
2273    fn test_fix_with_normalized_line_endings() {
2274        // In production, content is normalized to LF at I/O boundary
2275        // Unit tests should use LF input to reflect actual runtime behavior
2276        // Per markdownlint-cli: trailing text is lazy continuation, only 1 warning
2277        let content = "Text\n- Item 1\n- Item 2\nText";
2278        let warnings = lint(content);
2279        assert_eq!(warnings.len(), 1, "Should detect missing blank before list");
2280
2281        // Test that warnings have fixes
2282        check_warnings_have_fixes(content);
2283
2284        let fixed_content = fix(content);
2285        // Only adds blank before (trailing text is lazy continuation)
2286        let expected = "Text\n\n- Item 1\n- Item 2\nText";
2287        assert_eq!(fixed_content, expected, "Fix should work with normalized LF content");
2288    }
2289
2290    #[test]
2291    fn test_fix_preserves_final_newline() {
2292        // Per markdownlint-cli: trailing text is lazy continuation
2293        // Test with final newline
2294        let content_with_newline = "Text\n- Item 1\n- Item 2\nText\n";
2295        let fixed_with_newline = fix(content_with_newline);
2296        assert!(
2297            fixed_with_newline.ends_with('\n'),
2298            "Fix should preserve final newline when present"
2299        );
2300        // Only adds blank before (trailing text is lazy continuation)
2301        assert_eq!(fixed_with_newline, "Text\n\n- Item 1\n- Item 2\nText\n");
2302
2303        // Test without final newline
2304        let content_without_newline = "Text\n- Item 1\n- Item 2\nText";
2305        let fixed_without_newline = fix(content_without_newline);
2306        assert!(
2307            !fixed_without_newline.ends_with('\n'),
2308            "Fix should not add final newline when not present"
2309        );
2310        // Only adds blank before (trailing text is lazy continuation)
2311        assert_eq!(fixed_without_newline, "Text\n\n- Item 1\n- Item 2\nText");
2312    }
2313
2314    #[test]
2315    fn test_fix_multiline_list_items_no_indent() {
2316        let content = "## Configuration\n\nThis rule has the following configuration options:\n\n- `option1`: Description that continues\non the next line without indentation.\n- `option2`: Another description that also continues\non the next line.\n\n## Next Section";
2317
2318        let warnings = lint(content);
2319        // Should only warn about missing blank lines around the entire list, not between items
2320        assert_eq!(
2321            warnings.len(),
2322            0,
2323            "Should not warn for properly formatted list with multi-line items. Got: {warnings:?}"
2324        );
2325
2326        let fixed_content = fix(content);
2327        // Should not change the content since it's already correct
2328        assert_eq!(
2329            fixed_content, content,
2330            "Should not modify correctly formatted multi-line list items"
2331        );
2332    }
2333
2334    #[test]
2335    fn test_nested_list_with_lazy_continuation() {
2336        // Issue #188: Nested list following a lazy continuation line should not require blank lines
2337        // This matches markdownlint-cli behavior which does NOT warn on this pattern
2338        //
2339        // The key element is line 6 (`!=`), ternary...) which is a lazy continuation of line 5.
2340        // Line 6 contains `||` inside code spans, which should NOT be detected as a table separator.
2341        let content = r#"# Test
2342
2343- **Token Dispatch (Phase 3.2)**: COMPLETE. Extracts tokens from both:
2344  1. Switch/case dispatcher statements (original Phase 3.2)
2345  2. Inline conditionals - if/else, bitwise checks (`&`, `|`), comparison (`==`,
2346`!=`), ternary operators (`?:`), macros (`ISTOK`, `ISUNSET`), compound conditions (`&&`, `||`) (Phase 3.2.1)
2347     - 30 explicit tokens extracted, 23 dispatcher rules with embedded token
2348       references"#;
2349
2350        let warnings = lint(content);
2351        // No MD032 warnings should be generated - this is a valid nested list structure
2352        // with lazy continuation (line 6 has no indent but continues line 5)
2353        let md032_warnings: Vec<_> = warnings
2354            .iter()
2355            .filter(|w| w.rule_name.as_deref() == Some("MD032"))
2356            .collect();
2357        assert_eq!(
2358            md032_warnings.len(),
2359            0,
2360            "Should not warn for nested list with lazy continuation. Got: {md032_warnings:?}"
2361        );
2362    }
2363
2364    #[test]
2365    fn test_pipes_in_code_spans_not_detected_as_table() {
2366        // Pipes inside code spans should NOT break lists
2367        let content = r#"# Test
2368
2369- Item with `a | b` inline code
2370  - Nested item should work
2371
2372"#;
2373
2374        let warnings = lint(content);
2375        let md032_warnings: Vec<_> = warnings
2376            .iter()
2377            .filter(|w| w.rule_name.as_deref() == Some("MD032"))
2378            .collect();
2379        assert_eq!(
2380            md032_warnings.len(),
2381            0,
2382            "Pipes in code spans should not break lists. Got: {md032_warnings:?}"
2383        );
2384    }
2385
2386    #[test]
2387    fn test_multiple_code_spans_with_pipes() {
2388        // Multiple code spans with pipes should not break lists
2389        let content = r#"# Test
2390
2391- Item with `a | b` and `c || d` operators
2392  - Nested item should work
2393
2394"#;
2395
2396        let warnings = lint(content);
2397        let md032_warnings: Vec<_> = warnings
2398            .iter()
2399            .filter(|w| w.rule_name.as_deref() == Some("MD032"))
2400            .collect();
2401        assert_eq!(
2402            md032_warnings.len(),
2403            0,
2404            "Multiple code spans with pipes should not break lists. Got: {md032_warnings:?}"
2405        );
2406    }
2407
2408    #[test]
2409    fn test_actual_table_breaks_list() {
2410        // An actual table between list items SHOULD break the list
2411        let content = r#"# Test
2412
2413- Item before table
2414
2415| Col1 | Col2 |
2416|------|------|
2417| A    | B    |
2418
2419- Item after table
2420
2421"#;
2422
2423        let warnings = lint(content);
2424        // There should be NO MD032 warnings because both lists are properly surrounded by blank lines
2425        let md032_warnings: Vec<_> = warnings
2426            .iter()
2427            .filter(|w| w.rule_name.as_deref() == Some("MD032"))
2428            .collect();
2429        assert_eq!(
2430            md032_warnings.len(),
2431            0,
2432            "Both lists should be properly separated by blank lines. Got: {md032_warnings:?}"
2433        );
2434    }
2435
2436    #[test]
2437    fn test_thematic_break_not_lazy_continuation() {
2438        // Thematic breaks (HRs) cannot be lazy continuation per CommonMark
2439        // List followed by HR without blank line should warn
2440        let content = r#"- Item 1
2441- Item 2
2442***
2443
2444More text.
2445"#;
2446
2447        let warnings = lint(content);
2448        let md032_warnings: Vec<_> = warnings
2449            .iter()
2450            .filter(|w| w.rule_name.as_deref() == Some("MD032"))
2451            .collect();
2452        assert_eq!(
2453            md032_warnings.len(),
2454            1,
2455            "Should warn for list not followed by blank line before thematic break. Got: {md032_warnings:?}"
2456        );
2457        assert!(
2458            md032_warnings[0].message.contains("followed by blank line"),
2459            "Warning should be about missing blank after list"
2460        );
2461    }
2462
2463    #[test]
2464    fn test_thematic_break_with_blank_line() {
2465        // List followed by blank line then HR should NOT warn
2466        let content = r#"- Item 1
2467- Item 2
2468
2469***
2470
2471More text.
2472"#;
2473
2474        let warnings = lint(content);
2475        let md032_warnings: Vec<_> = warnings
2476            .iter()
2477            .filter(|w| w.rule_name.as_deref() == Some("MD032"))
2478            .collect();
2479        assert_eq!(
2480            md032_warnings.len(),
2481            0,
2482            "Should not warn when list is properly followed by blank line. Got: {md032_warnings:?}"
2483        );
2484    }
2485
2486    #[test]
2487    fn test_various_thematic_break_styles() {
2488        // Test different HR styles are all recognized
2489        // Note: Spaced styles like "- - -" and "* * *" are excluded because they start
2490        // with list markers ("- " or "* ") which get parsed as list items by the
2491        // upstream CommonMark parser. That's a separate parsing issue.
2492        for hr in ["---", "***", "___"] {
2493            let content = format!(
2494                r#"- Item 1
2495- Item 2
2496{hr}
2497
2498More text.
2499"#
2500            );
2501
2502            let warnings = lint(&content);
2503            let md032_warnings: Vec<_> = warnings
2504                .iter()
2505                .filter(|w| w.rule_name.as_deref() == Some("MD032"))
2506                .collect();
2507            assert_eq!(
2508                md032_warnings.len(),
2509                1,
2510                "Should warn for HR style '{hr}' without blank line. Got: {md032_warnings:?}"
2511            );
2512        }
2513    }
2514
2515    // === LAZY CONTINUATION TESTS ===
2516
2517    fn lint_with_config(content: &str, config: MD032Config) -> Vec<LintWarning> {
2518        let rule = MD032BlanksAroundLists::from_config_struct(config);
2519        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2520        rule.check(&ctx).expect("Lint check failed")
2521    }
2522
2523    fn fix_with_config(content: &str, config: MD032Config) -> String {
2524        let rule = MD032BlanksAroundLists::from_config_struct(config);
2525        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2526        rule.fix(&ctx).expect("Lint fix failed")
2527    }
2528
2529    #[test]
2530    fn test_lazy_continuation_allowed_by_default() {
2531        // Default behavior: lazy continuation is allowed, no warning
2532        let content = "# Heading\n\n1. List\nSome text.";
2533        let warnings = lint(content);
2534        assert_eq!(
2535            warnings.len(),
2536            0,
2537            "Default behavior should allow lazy continuation. Got: {warnings:?}"
2538        );
2539    }
2540
2541    #[test]
2542    fn test_lazy_continuation_disallowed() {
2543        // With allow_lazy_continuation = false, should warn about lazy continuation
2544        let content = "# Heading\n\n1. List\nSome text.";
2545        let config = MD032Config {
2546            allow_lazy_continuation: false,
2547        };
2548        let warnings = lint_with_config(content, config);
2549        assert_eq!(
2550            warnings.len(),
2551            1,
2552            "Should warn when lazy continuation is disallowed. Got: {warnings:?}"
2553        );
2554        assert!(
2555            warnings[0].message.contains("Lazy continuation"),
2556            "Warning message should mention lazy continuation"
2557        );
2558        assert_eq!(warnings[0].line, 4, "Warning should be on the lazy line");
2559    }
2560
2561    #[test]
2562    fn test_lazy_continuation_fix() {
2563        // With allow_lazy_continuation = false, fix should add proper indentation
2564        let content = "# Heading\n\n1. List\nSome text.";
2565        let config = MD032Config {
2566            allow_lazy_continuation: false,
2567        };
2568        let fixed = fix_with_config(content, config.clone());
2569        // Fix adds proper indentation (3 spaces for "1. " marker width)
2570        assert_eq!(
2571            fixed, "# Heading\n\n1. List\n   Some text.",
2572            "Fix should add proper indentation to lazy continuation"
2573        );
2574
2575        // Verify no warnings after fix
2576        let warnings_after = lint_with_config(&fixed, config);
2577        assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
2578    }
2579
2580    #[test]
2581    fn test_lazy_continuation_multiple_lines() {
2582        // Multiple lazy continuation lines - each gets its own warning
2583        let content = "- Item 1\nLine 2\nLine 3";
2584        let config = MD032Config {
2585            allow_lazy_continuation: false,
2586        };
2587        let warnings = lint_with_config(content, config.clone());
2588        // Both Line 2 and Line 3 are lazy continuation lines
2589        assert_eq!(
2590            warnings.len(),
2591            2,
2592            "Should warn for each lazy continuation line. Got: {warnings:?}"
2593        );
2594
2595        let fixed = fix_with_config(content, config.clone());
2596        // Fix adds proper indentation (2 spaces for "- " marker)
2597        assert_eq!(
2598            fixed, "- Item 1\n  Line 2\n  Line 3",
2599            "Fix should add proper indentation to lazy continuation lines"
2600        );
2601
2602        // Verify no warnings after fix
2603        let warnings_after = lint_with_config(&fixed, config);
2604        assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
2605    }
2606
2607    #[test]
2608    fn test_lazy_continuation_with_indented_content() {
2609        // Indented content is valid continuation, not lazy continuation
2610        let content = "- Item 1\n  Indented content\nLazy text";
2611        let config = MD032Config {
2612            allow_lazy_continuation: false,
2613        };
2614        let warnings = lint_with_config(content, config);
2615        assert_eq!(
2616            warnings.len(),
2617            1,
2618            "Should warn for lazy text after indented content. Got: {warnings:?}"
2619        );
2620    }
2621
2622    #[test]
2623    fn test_lazy_continuation_properly_separated() {
2624        // With proper blank line, no warning even with strict config
2625        let content = "- Item 1\n\nSome text.";
2626        let config = MD032Config {
2627            allow_lazy_continuation: false,
2628        };
2629        let warnings = lint_with_config(content, config);
2630        assert_eq!(
2631            warnings.len(),
2632            0,
2633            "Should not warn when list is properly followed by blank line. Got: {warnings:?}"
2634        );
2635    }
2636
2637    // ==================== Comprehensive edge case tests ====================
2638
2639    #[test]
2640    fn test_lazy_continuation_ordered_list_parenthesis_marker() {
2641        // Ordered list with parenthesis marker (1) instead of period
2642        let content = "1) First item\nLazy continuation";
2643        let config = MD032Config {
2644            allow_lazy_continuation: false,
2645        };
2646        let warnings = lint_with_config(content, config.clone());
2647        assert_eq!(
2648            warnings.len(),
2649            1,
2650            "Should warn for lazy continuation with parenthesis marker"
2651        );
2652
2653        let fixed = fix_with_config(content, config);
2654        // Fix adds proper indentation (3 spaces for "1) " marker)
2655        assert_eq!(fixed, "1) First item\n   Lazy continuation");
2656    }
2657
2658    #[test]
2659    fn test_lazy_continuation_followed_by_another_list() {
2660        // Lazy continuation text followed by another list item
2661        // In CommonMark, "Some text" becomes part of Item 1's lazy continuation,
2662        // and "- Item 2" starts a new list item within the same list.
2663        // With allow_lazy_continuation = false, we warn about lazy continuation
2664        // even within valid list structure (issue #295).
2665        let content = "- Item 1\nSome text\n- Item 2";
2666        let config = MD032Config {
2667            allow_lazy_continuation: false,
2668        };
2669        let warnings = lint_with_config(content, config);
2670        // Should warn about lazy continuation on line 2
2671        assert_eq!(
2672            warnings.len(),
2673            1,
2674            "Should warn about lazy continuation within list. Got: {warnings:?}"
2675        );
2676        assert!(
2677            warnings[0].message.contains("Lazy continuation"),
2678            "Warning should be about lazy continuation"
2679        );
2680        assert_eq!(warnings[0].line, 2, "Warning should be on line 2");
2681    }
2682
2683    #[test]
2684    fn test_lazy_continuation_multiple_in_document() {
2685        // Loose list (blank line between items) with lazy continuation
2686        // In CommonMark, this is a single loose list, not two separate lists.
2687        // "Lazy 1" is lazy continuation of Item 1
2688        // "Lazy 2" is lazy continuation of Item 2
2689        let content = "- Item 1\nLazy 1\n\n- Item 2\nLazy 2";
2690        let config = MD032Config {
2691            allow_lazy_continuation: false,
2692        };
2693        let warnings = lint_with_config(content, config.clone());
2694        // Expect 2 warnings for both lazy continuation lines
2695        assert_eq!(
2696            warnings.len(),
2697            2,
2698            "Should warn for both lazy continuations. Got: {warnings:?}"
2699        );
2700
2701        let fixed = fix_with_config(content, config.clone());
2702        // Auto-fix should add proper indentation to both lazy continuation lines
2703        assert!(
2704            fixed.contains("  Lazy 1"),
2705            "Fixed content should have indented 'Lazy 1'. Got: {fixed:?}"
2706        );
2707        assert!(
2708            fixed.contains("  Lazy 2"),
2709            "Fixed content should have indented 'Lazy 2'. Got: {fixed:?}"
2710        );
2711
2712        let warnings_after = lint_with_config(&fixed, config);
2713        // No warnings after fix: both lazy lines are properly indented
2714        assert_eq!(
2715            warnings_after.len(),
2716            0,
2717            "All warnings should be fixed after auto-fix. Got: {warnings_after:?}"
2718        );
2719    }
2720
2721    #[test]
2722    fn test_lazy_continuation_end_of_document_no_newline() {
2723        // Lazy continuation at end of document without trailing newline
2724        let content = "- Item\nNo trailing newline";
2725        let config = MD032Config {
2726            allow_lazy_continuation: false,
2727        };
2728        let warnings = lint_with_config(content, config.clone());
2729        assert_eq!(warnings.len(), 1, "Should warn even at end of document");
2730
2731        let fixed = fix_with_config(content, config);
2732        // Fix adds proper indentation (2 spaces for "- " marker)
2733        assert_eq!(fixed, "- Item\n  No trailing newline");
2734    }
2735
2736    #[test]
2737    fn test_lazy_continuation_thematic_break_still_needs_blank() {
2738        // Thematic break after list without blank line still triggers MD032
2739        // The thematic break ends the list, but MD032 requires blank line separation
2740        let content = "- Item 1\n---";
2741        let config = MD032Config {
2742            allow_lazy_continuation: false,
2743        };
2744        let warnings = lint_with_config(content, config.clone());
2745        // Should warn because list needs blank line before thematic break
2746        assert_eq!(
2747            warnings.len(),
2748            1,
2749            "List should need blank line before thematic break. Got: {warnings:?}"
2750        );
2751
2752        // Verify fix adds blank line
2753        let fixed = fix_with_config(content, config);
2754        assert_eq!(fixed, "- Item 1\n\n---");
2755    }
2756
2757    #[test]
2758    fn test_lazy_continuation_heading_not_flagged() {
2759        // Heading after list should NOT be flagged as lazy continuation
2760        // (headings end lists per CommonMark)
2761        let content = "- Item 1\n# Heading";
2762        let config = MD032Config {
2763            allow_lazy_continuation: false,
2764        };
2765        let warnings = lint_with_config(content, config);
2766        // The warning should be about missing blank line, not lazy continuation
2767        // But headings interrupt lists, so the list ends at Item 1
2768        assert!(
2769            warnings.iter().all(|w| !w.message.contains("lazy")),
2770            "Heading should not trigger lazy continuation warning"
2771        );
2772    }
2773
2774    #[test]
2775    fn test_lazy_continuation_mixed_list_types() {
2776        // Mixed ordered and unordered with lazy continuation
2777        let content = "- Unordered\n1. Ordered\nLazy text";
2778        let config = MD032Config {
2779            allow_lazy_continuation: false,
2780        };
2781        let warnings = lint_with_config(content, config.clone());
2782        assert!(!warnings.is_empty(), "Should warn about structure issues");
2783    }
2784
2785    #[test]
2786    fn test_lazy_continuation_deep_nesting() {
2787        // Deep nested list with lazy continuation at end
2788        let content = "- Level 1\n  - Level 2\n    - Level 3\nLazy at root";
2789        let config = MD032Config {
2790            allow_lazy_continuation: false,
2791        };
2792        let warnings = lint_with_config(content, config.clone());
2793        assert!(
2794            !warnings.is_empty(),
2795            "Should warn about lazy continuation after nested list"
2796        );
2797
2798        let fixed = fix_with_config(content, config.clone());
2799        let warnings_after = lint_with_config(&fixed, config);
2800        assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
2801    }
2802
2803    #[test]
2804    fn test_lazy_continuation_with_emphasis_in_text() {
2805        // Lazy continuation containing emphasis markers
2806        let content = "- Item\n*emphasized* continuation";
2807        let config = MD032Config {
2808            allow_lazy_continuation: false,
2809        };
2810        let warnings = lint_with_config(content, config.clone());
2811        assert_eq!(warnings.len(), 1, "Should warn even with emphasis in continuation");
2812
2813        let fixed = fix_with_config(content, config);
2814        // Fix adds proper indentation (2 spaces for "- " marker)
2815        assert_eq!(fixed, "- Item\n  *emphasized* continuation");
2816    }
2817
2818    #[test]
2819    fn test_lazy_continuation_with_code_span() {
2820        // Lazy continuation containing code span
2821        let content = "- Item\n`code` continuation";
2822        let config = MD032Config {
2823            allow_lazy_continuation: false,
2824        };
2825        let warnings = lint_with_config(content, config.clone());
2826        assert_eq!(warnings.len(), 1, "Should warn even with code span in continuation");
2827
2828        let fixed = fix_with_config(content, config);
2829        // Fix adds proper indentation (2 spaces for "- " marker)
2830        assert_eq!(fixed, "- Item\n  `code` continuation");
2831    }
2832
2833    // =========================================================================
2834    // Issue #295: Lazy continuation after nested sublists
2835    // These tests verify detection of lazy continuation at outer indent level
2836    // after nested sublists, followed by another list item.
2837    // =========================================================================
2838
2839    #[test]
2840    fn test_issue295_case1_nested_bullets_then_continuation_then_item() {
2841        // Outer numbered item with nested bullets, lazy continuation, then next item
2842        // The lazy continuation "A new Chat..." appears at column 1, not indented
2843        let content = r#"1. Create a new Chat conversation:
2844   - On the sidebar, select **New Chat**.
2845   - In the box, type `/new`.
2846   A new Chat conversation replaces the previous one.
28471. Under the Chat text box, turn off the toggle."#;
2848        let config = MD032Config {
2849            allow_lazy_continuation: false,
2850        };
2851        let warnings = lint_with_config(content, config);
2852        // Should warn about line 4 "A new Chat..." which is lazy continuation
2853        let lazy_warnings: Vec<_> = warnings
2854            .iter()
2855            .filter(|w| w.message.contains("Lazy continuation"))
2856            .collect();
2857        assert!(
2858            !lazy_warnings.is_empty(),
2859            "Should detect lazy continuation after nested bullets. Got: {warnings:?}"
2860        );
2861        assert!(
2862            lazy_warnings.iter().any(|w| w.line == 4),
2863            "Should warn on line 4. Got: {lazy_warnings:?}"
2864        );
2865    }
2866
2867    #[test]
2868    fn test_issue295_case3_code_span_starts_lazy_continuation() {
2869        // Code span at the START of lazy continuation after nested bullets
2870        // This is tricky because pulldown-cmark emits Code event, not Text
2871        let content = r#"- `field`: Is the specific key:
2872  - `password`: Accesses the password.
2873  - `api_key`: Accesses the api_key.
2874  `token`: Specifies which ID token to use.
2875- `version_id`: Is the unique identifier."#;
2876        let config = MD032Config {
2877            allow_lazy_continuation: false,
2878        };
2879        let warnings = lint_with_config(content, config);
2880        // Should warn about line 4 "`token`:..." which starts with code span
2881        let lazy_warnings: Vec<_> = warnings
2882            .iter()
2883            .filter(|w| w.message.contains("Lazy continuation"))
2884            .collect();
2885        assert!(
2886            !lazy_warnings.is_empty(),
2887            "Should detect lazy continuation starting with code span. Got: {warnings:?}"
2888        );
2889        assert!(
2890            lazy_warnings.iter().any(|w| w.line == 4),
2891            "Should warn on line 4 (code span start). Got: {lazy_warnings:?}"
2892        );
2893    }
2894
2895    #[test]
2896    fn test_issue295_case4_deep_nesting_with_continuation_then_item() {
2897        // Multiple nesting levels, lazy continuation, then next outer item
2898        let content = r#"- Check out the branch, and test locally.
2899  - If the MR requires significant modifications:
2900    - **Skip local testing** and review instead.
2901    - **Request verification** from the author.
2902    - **Identify the minimal change** needed.
2903  Your testing might result in opportunities.
2904- If you don't understand, _say so_."#;
2905        let config = MD032Config {
2906            allow_lazy_continuation: false,
2907        };
2908        let warnings = lint_with_config(content, config);
2909        // Should warn about line 6 "Your testing..." which is lazy continuation
2910        let lazy_warnings: Vec<_> = warnings
2911            .iter()
2912            .filter(|w| w.message.contains("Lazy continuation"))
2913            .collect();
2914        assert!(
2915            !lazy_warnings.is_empty(),
2916            "Should detect lazy continuation after deep nesting. Got: {warnings:?}"
2917        );
2918        assert!(
2919            lazy_warnings.iter().any(|w| w.line == 6),
2920            "Should warn on line 6. Got: {lazy_warnings:?}"
2921        );
2922    }
2923
2924    #[test]
2925    fn test_issue295_ordered_list_nested_bullets_continuation() {
2926        // Ordered list with nested bullets, continuation at outer level, then next item
2927        // This is the exact pattern from debug_test6.md
2928        let content = r#"# Test
2929
29301. First item.
2931   - Nested A.
2932   - Nested B.
2933   Continuation at outer level.
29341. Second item."#;
2935        let config = MD032Config {
2936            allow_lazy_continuation: false,
2937        };
2938        let warnings = lint_with_config(content, config);
2939        // Should warn about line 6 "Continuation at outer level."
2940        let lazy_warnings: Vec<_> = warnings
2941            .iter()
2942            .filter(|w| w.message.contains("Lazy continuation"))
2943            .collect();
2944        assert!(
2945            !lazy_warnings.is_empty(),
2946            "Should detect lazy continuation at outer level after nested. Got: {warnings:?}"
2947        );
2948        // Line 6 = "   Continuation at outer level." (3 spaces indent, but needs 4 for proper continuation)
2949        assert!(
2950            lazy_warnings.iter().any(|w| w.line == 6),
2951            "Should warn on line 6. Got: {lazy_warnings:?}"
2952        );
2953    }
2954
2955    #[test]
2956    fn test_issue295_multiple_lazy_lines_after_nested() {
2957        // Multiple lazy continuation lines after nested sublist
2958        let content = r#"1. The device client receives a response.
2959   - Those defined by OAuth Framework.
2960   - Those specific to device authorization.
2961   Those error responses are described below.
2962   For more information on each response,
2963   see the documentation.
29641. Next step in the process."#;
2965        let config = MD032Config {
2966            allow_lazy_continuation: false,
2967        };
2968        let warnings = lint_with_config(content, config);
2969        // Should warn about lines 4, 5, 6 (all lazy continuation)
2970        let lazy_warnings: Vec<_> = warnings
2971            .iter()
2972            .filter(|w| w.message.contains("Lazy continuation"))
2973            .collect();
2974        assert!(
2975            lazy_warnings.len() >= 3,
2976            "Should detect multiple lazy continuation lines. Got {} warnings: {lazy_warnings:?}",
2977            lazy_warnings.len()
2978        );
2979    }
2980
2981    #[test]
2982    fn test_issue295_properly_indented_not_lazy() {
2983        // Properly indented continuation after nested sublist should NOT warn
2984        let content = r#"1. First item.
2985   - Nested A.
2986   - Nested B.
2987
2988   Properly indented continuation.
29891. Second item."#;
2990        let config = MD032Config {
2991            allow_lazy_continuation: false,
2992        };
2993        let warnings = lint_with_config(content, config);
2994        // With blank line before, this is a new paragraph, not lazy continuation
2995        let lazy_warnings: Vec<_> = warnings
2996            .iter()
2997            .filter(|w| w.message.contains("Lazy continuation"))
2998            .collect();
2999        assert_eq!(
3000            lazy_warnings.len(),
3001            0,
3002            "Should NOT warn when blank line separates continuation. Got: {lazy_warnings:?}"
3003        );
3004    }
3005
3006    // =========================================================================
3007    // HTML Comment Transparency Tests
3008    // HTML comments should be "transparent" for blank line checking,
3009    // matching markdownlint-cli behavior.
3010    // =========================================================================
3011
3012    #[test]
3013    fn test_html_comment_before_list_with_preceding_blank() {
3014        // Blank line before HTML comment = list is properly separated
3015        // markdownlint-cli does NOT warn here
3016        let content = "Some text.\n\n<!-- comment -->\n- List item";
3017        let warnings = lint(content);
3018        assert_eq!(
3019            warnings.len(),
3020            0,
3021            "Should not warn when blank line exists before HTML comment. Got: {warnings:?}"
3022        );
3023    }
3024
3025    #[test]
3026    fn test_html_comment_after_list_with_following_blank() {
3027        // Blank line after HTML comment = list is properly separated
3028        let content = "- List item\n<!-- comment -->\n\nSome text.";
3029        let warnings = lint(content);
3030        assert_eq!(
3031            warnings.len(),
3032            0,
3033            "Should not warn when blank line exists after HTML comment. Got: {warnings:?}"
3034        );
3035    }
3036
3037    #[test]
3038    fn test_list_inside_html_comment_ignored() {
3039        // Lists entirely inside HTML comments should not be analyzed
3040        let content = "<!--\n1. First\n2. Second\n3. Third\n-->";
3041        let warnings = lint(content);
3042        assert_eq!(
3043            warnings.len(),
3044            0,
3045            "Should not analyze lists inside HTML comments. Got: {warnings:?}"
3046        );
3047    }
3048
3049    #[test]
3050    fn test_multiline_html_comment_before_list() {
3051        // Multi-line HTML comment should be transparent
3052        let content = "Text\n\n<!--\nThis is a\nmulti-line\ncomment\n-->\n- Item";
3053        let warnings = lint(content);
3054        assert_eq!(
3055            warnings.len(),
3056            0,
3057            "Multi-line HTML comment should be transparent. Got: {warnings:?}"
3058        );
3059    }
3060
3061    #[test]
3062    fn test_a_comment_line_separates_the_paragraph_from_the_list() {
3063        // A line holding nothing but a comment is the separation the rule asks for,
3064        // so there is nothing to report and nothing to insert (#866)
3065        let content = "Some text.\n<!-- comment -->\n- List item";
3066        let warnings = lint(content);
3067        assert_eq!(
3068            warnings.len(),
3069            0,
3070            "A comment-only line separates the blocks around it. Got: {warnings:?}"
3071        );
3072    }
3073
3074    #[test]
3075    fn test_a_line_carrying_text_beside_a_comment_still_warns() {
3076        // Control for the test above: the comment is not the whole line, so the
3077        // paragraph runs straight into the list and the blank line is missing
3078        let content = "Some text. <!-- comment -->\n- List item";
3079        let warnings = lint(content);
3080        assert_eq!(
3081            warnings.len(),
3082            1,
3083            "A paragraph with a trailing comment is still a paragraph. Got: {warnings:?}"
3084        );
3085        assert!(
3086            warnings[0].message.contains("preceded by blank line"),
3087            "Should be 'preceded by blank line' warning"
3088        );
3089    }
3090
3091    #[test]
3092    fn test_no_blank_after_html_comment_no_warn_lazy_continuation() {
3093        // Text immediately after list (through HTML comment) is lazy continuation
3094        // markdownlint-cli does NOT warn here - the text becomes part of the list
3095        let content = "- List item\n<!-- comment -->\nSome text.";
3096        let warnings = lint(content);
3097        assert_eq!(
3098            warnings.len(),
3099            0,
3100            "Should not warn - text after comment becomes lazy continuation. Got: {warnings:?}"
3101        );
3102    }
3103
3104    #[test]
3105    fn test_list_followed_by_heading_through_comment_should_warn() {
3106        // Heading cannot be lazy continuation, so this SHOULD warn
3107        let content = "- List item\n<!-- comment -->\n# Heading";
3108        let warnings = lint(content);
3109        // Headings after lists through HTML comments should be handled gracefully
3110        // The blank line check should look past the comment
3111        assert!(
3112            warnings.len() <= 1,
3113            "Should handle heading after comment gracefully. Got: {warnings:?}"
3114        );
3115    }
3116
3117    #[test]
3118    fn test_html_comment_between_list_and_text_both_directions() {
3119        // Blank line on both sides through HTML comment
3120        let content = "Text before.\n\n<!-- comment -->\n- Item 1\n- Item 2\n<!-- another -->\n\nText after.";
3121        let warnings = lint(content);
3122        assert_eq!(
3123            warnings.len(),
3124            0,
3125            "Should not warn with proper separation through comments. Got: {warnings:?}"
3126        );
3127    }
3128
3129    #[test]
3130    fn test_html_comment_fix_does_not_insert_unnecessary_blank() {
3131        // Fix should not add blank line when separation already exists through comment
3132        let content = "Text.\n\n<!-- comment -->\n- Item";
3133        let fixed = fix(content);
3134        assert_eq!(fixed, content, "Fix should not modify already-correct content");
3135    }
3136
3137    #[test]
3138    fn test_html_comment_fix_adds_blank_when_needed() {
3139        // A comment-only line is already the separation, so the fix leaves it alone.
3140        // A comment sharing its line with prose is not, and there the blank goes in.
3141        let separated = "Text.\n<!-- comment -->\n- Item";
3142        assert_eq!(
3143            fix(separated),
3144            separated,
3145            "A comment-only line needs no blank line inserted around it"
3146        );
3147
3148        let content = "Text. <!-- comment -->\n- Item";
3149        let fixed = fix(content);
3150        assert!(
3151            fixed.contains("Text. <!-- comment -->\n\n- Item"),
3152            "Fix should add blank line before list. Got: {fixed}"
3153        );
3154    }
3155
3156    #[test]
3157    fn test_ordered_list_inside_html_comment() {
3158        // Ordered list with non-1 start inside comment should not warn
3159        let content = "<!--\n3. Starting at 3\n4. Next item\n-->";
3160        let warnings = lint(content);
3161        assert_eq!(
3162            warnings.len(),
3163            0,
3164            "Should not warn about ordered list inside HTML comment. Got: {warnings:?}"
3165        );
3166    }
3167
3168    // =========================================================================
3169    // Blockquote Boundary Transition Tests
3170    // When a list inside a blockquote ends and the next line exits the blockquote,
3171    // no blank line is needed - the blockquote boundary provides semantic separation.
3172    // =========================================================================
3173
3174    #[test]
3175    fn test_blockquote_list_exit_no_warning() {
3176        // Blockquote list followed by outer content - no blank line needed
3177        let content = "- outer item\n  > - blockquote list 1\n  > - blockquote list 2\n- next outer item";
3178        let warnings = lint(content);
3179        assert_eq!(
3180            warnings.len(),
3181            0,
3182            "Should not warn when exiting blockquote. Got: {warnings:?}"
3183        );
3184    }
3185
3186    #[test]
3187    fn test_nested_blockquote_list_exit() {
3188        // Nested blockquote list - exiting should not require blank line
3189        let content = "- outer\n  - nested\n    > - bq list 1\n    > - bq list 2\n  - back to nested\n- outer again";
3190        let warnings = lint(content);
3191        assert_eq!(
3192            warnings.len(),
3193            0,
3194            "Should not warn when exiting nested blockquote list. Got: {warnings:?}"
3195        );
3196    }
3197
3198    #[test]
3199    fn test_blockquote_same_level_no_warning() {
3200        // List INSIDE blockquote followed by text INSIDE same blockquote
3201        // markdownlint-cli does NOT warn for this case - lazy continuation applies
3202        let content = "> - item 1\n> - item 2\n> Text after";
3203        let warnings = lint(content);
3204        assert_eq!(
3205            warnings.len(),
3206            0,
3207            "Should not warn - text is lazy continuation in blockquote. Got: {warnings:?}"
3208        );
3209    }
3210
3211    #[test]
3212    fn test_blockquote_list_with_special_chars() {
3213        // Content with special chars like <> should not affect blockquote detection
3214        let content = "- Item with <>&\n  > - blockquote item\n- Back to outer";
3215        let warnings = lint(content);
3216        assert_eq!(
3217            warnings.len(),
3218            0,
3219            "Special chars in content should not affect blockquote detection. Got: {warnings:?}"
3220        );
3221    }
3222
3223    #[test]
3224    fn test_lazy_continuation_whitespace_only_line() {
3225        // Per CommonMark/pulldown-cmark, whitespace-only line IS a blank line separator
3226        // The list ends at the whitespace-only line, text starts a new paragraph
3227        let content = "- Item\n   \nText after whitespace-only line";
3228        let config = MD032Config {
3229            allow_lazy_continuation: false,
3230        };
3231        let warnings = lint_with_config(content, config);
3232        // Whitespace-only line counts as blank line separator - no lazy continuation
3233        assert_eq!(
3234            warnings.len(),
3235            0,
3236            "Whitespace-only line IS a separator in CommonMark. Got: {warnings:?}"
3237        );
3238    }
3239
3240    #[test]
3241    fn test_lazy_continuation_blockquote_context() {
3242        // List inside blockquote with lazy continuation
3243        let content = "> - Item\n> Lazy in quote";
3244        let config = MD032Config {
3245            allow_lazy_continuation: false,
3246        };
3247        let warnings = lint_with_config(content, config);
3248        // Inside blockquote, lazy continuation may behave differently
3249        // This tests that we handle blockquote context
3250        assert!(warnings.len() <= 1, "Should handle blockquote context gracefully");
3251    }
3252
3253    #[test]
3254    fn test_lazy_continuation_fix_preserves_content() {
3255        // Ensure fix doesn't modify the actual content
3256        let content = "- Item with special chars: <>&\nContinuation with: \"quotes\"";
3257        let config = MD032Config {
3258            allow_lazy_continuation: false,
3259        };
3260        let fixed = fix_with_config(content, config);
3261        assert!(fixed.contains("<>&"), "Should preserve special chars");
3262        assert!(fixed.contains("\"quotes\""), "Should preserve quotes");
3263        // Fix adds proper indentation (2 spaces for "- " marker)
3264        assert_eq!(fixed, "- Item with special chars: <>&\n  Continuation with: \"quotes\"");
3265    }
3266
3267    #[test]
3268    fn test_lazy_continuation_fix_idempotent() {
3269        // Running fix twice should produce same result
3270        let content = "- Item\nLazy";
3271        let config = MD032Config {
3272            allow_lazy_continuation: false,
3273        };
3274        let fixed_once = fix_with_config(content, config.clone());
3275        let fixed_twice = fix_with_config(&fixed_once, config);
3276        assert_eq!(fixed_once, fixed_twice, "Fix should be idempotent");
3277    }
3278
3279    #[test]
3280    fn test_lazy_continuation_config_default_allows() {
3281        // Verify default config allows lazy continuation
3282        let content = "- Item\nLazy text that continues";
3283        let default_config = MD032Config::default();
3284        assert!(
3285            default_config.allow_lazy_continuation,
3286            "Default should allow lazy continuation"
3287        );
3288        let warnings = lint_with_config(content, default_config);
3289        assert_eq!(warnings.len(), 0, "Default config should not warn on lazy continuation");
3290    }
3291
3292    #[test]
3293    fn test_lazy_continuation_after_multi_line_item() {
3294        // List item with proper indented continuation, then lazy text
3295        let content = "- Item line 1\n  Item line 2 (indented)\nLazy (not indented)";
3296        let config = MD032Config {
3297            allow_lazy_continuation: false,
3298        };
3299        let warnings = lint_with_config(content, config.clone());
3300        assert_eq!(
3301            warnings.len(),
3302            1,
3303            "Should warn only for the lazy line, not the indented line"
3304        );
3305    }
3306
3307    // Issue #260: Lists inside blockquotes should not produce false positives
3308    #[test]
3309    fn test_blockquote_list_with_continuation_and_nested() {
3310        // This is the exact case from issue #260
3311        // markdownlint-cli reports NO warnings for this
3312        let content = "> - item 1\n>   continuation\n>   - nested\n> - item 2";
3313        let warnings = lint(content);
3314        assert_eq!(
3315            warnings.len(),
3316            0,
3317            "Blockquoted list with continuation and nested items should have no warnings. Got: {warnings:?}"
3318        );
3319    }
3320
3321    #[test]
3322    fn test_blockquote_list_simple() {
3323        // Simple blockquoted list
3324        let content = "> - item 1\n> - item 2";
3325        let warnings = lint(content);
3326        assert_eq!(warnings.len(), 0, "Simple blockquoted list should have no warnings");
3327    }
3328
3329    #[test]
3330    fn test_blockquote_list_with_continuation_only() {
3331        // Blockquoted list with continuation line (no nesting)
3332        let content = "> - item 1\n>   continuation\n> - item 2";
3333        let warnings = lint(content);
3334        assert_eq!(
3335            warnings.len(),
3336            0,
3337            "Blockquoted list with continuation should have no warnings"
3338        );
3339    }
3340
3341    #[test]
3342    fn test_blockquote_list_with_lazy_continuation() {
3343        // Blockquoted list with lazy continuation (no extra indent after >)
3344        let content = "> - item 1\n> lazy continuation\n> - item 2";
3345        let warnings = lint(content);
3346        assert_eq!(
3347            warnings.len(),
3348            0,
3349            "Blockquoted list with lazy continuation should have no warnings"
3350        );
3351    }
3352
3353    #[test]
3354    fn test_nested_blockquote_list() {
3355        // List inside nested blockquote (>> prefix)
3356        let content = ">> - item 1\n>>   continuation\n>>   - nested\n>> - item 2";
3357        let warnings = lint(content);
3358        assert_eq!(warnings.len(), 0, "Nested blockquote list should have no warnings");
3359    }
3360
3361    #[test]
3362    fn test_blockquote_list_needs_preceding_blank() {
3363        // Blockquote list preceded by non-blank content SHOULD warn
3364        let content = "> Text before\n> - item 1\n> - item 2";
3365        let warnings = lint(content);
3366        assert_eq!(
3367            warnings.len(),
3368            1,
3369            "Should warn for missing blank before blockquoted list"
3370        );
3371    }
3372
3373    #[test]
3374    fn test_blockquote_list_properly_separated() {
3375        // Blockquote list with proper blank lines - no warnings
3376        let content = "> Text before\n>\n> - item 1\n> - item 2\n>\n> Text after";
3377        let warnings = lint(content);
3378        assert_eq!(
3379            warnings.len(),
3380            0,
3381            "Properly separated blockquoted list should have no warnings"
3382        );
3383    }
3384
3385    #[test]
3386    fn test_blockquote_ordered_list() {
3387        // Ordered list in blockquote with continuation
3388        let content = "> 1. item 1\n>    continuation\n> 2. item 2";
3389        let warnings = lint(content);
3390        assert_eq!(warnings.len(), 0, "Ordered list in blockquote should have no warnings");
3391    }
3392
3393    #[test]
3394    fn test_blockquote_list_with_empty_blockquote_line() {
3395        // Empty blockquote line (just ">") between items - still same list
3396        let content = "> - item 1\n>\n> - item 2";
3397        let warnings = lint(content);
3398        assert_eq!(warnings.len(), 0, "Empty blockquote line should not break list");
3399    }
3400
3401    /// Issue #268: Multi-paragraph list items in blockquotes should not trigger false positives
3402    #[test]
3403    fn test_blockquote_list_multi_paragraph_items() {
3404        // List item with blank line + continuation paragraph + next item
3405        // This is a common pattern for multi-paragraph list items in blockquotes
3406        let content = "# Test\n\n> Some intro text\n> \n> * List item 1\n> \n>   Continuation\n> * List item 2\n";
3407        let warnings = lint(content);
3408        assert_eq!(
3409            warnings.len(),
3410            0,
3411            "Multi-paragraph list items in blockquotes should have no warnings. Got: {warnings:?}"
3412        );
3413    }
3414
3415    /// Issue #268: Ordered lists with multi-paragraph items in blockquotes
3416    #[test]
3417    fn test_blockquote_ordered_list_multi_paragraph_items() {
3418        let content = "> 1. First item\n> \n>    Continuation of first\n> 2. Second item\n";
3419        let warnings = lint(content);
3420        assert_eq!(
3421            warnings.len(),
3422            0,
3423            "Ordered multi-paragraph list items in blockquotes should have no warnings. Got: {warnings:?}"
3424        );
3425    }
3426
3427    /// Issue #268: Multiple continuation paragraphs in blockquote list
3428    #[test]
3429    fn test_blockquote_list_multiple_continuations() {
3430        let content = "> - Item 1\n> \n>   First continuation\n> \n>   Second continuation\n> - Item 2\n";
3431        let warnings = lint(content);
3432        assert_eq!(
3433            warnings.len(),
3434            0,
3435            "Multiple continuation paragraphs should not break blockquote list. Got: {warnings:?}"
3436        );
3437    }
3438
3439    /// Issue #268: Nested blockquote (>>) with multi-paragraph list items
3440    #[test]
3441    fn test_nested_blockquote_multi_paragraph_list() {
3442        let content = ">> - Item 1\n>> \n>>   Continuation\n>> - Item 2\n";
3443        let warnings = lint(content);
3444        assert_eq!(
3445            warnings.len(),
3446            0,
3447            "Nested blockquote multi-paragraph list should have no warnings. Got: {warnings:?}"
3448        );
3449    }
3450
3451    /// Issue #268: Triple-nested blockquote (>>>) with multi-paragraph list items
3452    #[test]
3453    fn test_triple_nested_blockquote_multi_paragraph_list() {
3454        let content = ">>> - Item 1\n>>> \n>>>   Continuation\n>>> - Item 2\n";
3455        let warnings = lint(content);
3456        assert_eq!(
3457            warnings.len(),
3458            0,
3459            "Triple-nested blockquote multi-paragraph list should have no warnings. Got: {warnings:?}"
3460        );
3461    }
3462
3463    /// Issue #268: Last item in blockquote list has continuation (edge case)
3464    #[test]
3465    fn test_blockquote_list_last_item_continuation() {
3466        let content = "> - Item 1\n> - Item 2\n> \n>   Continuation of item 2\n";
3467        let warnings = lint(content);
3468        assert_eq!(
3469            warnings.len(),
3470            0,
3471            "Last item with continuation should have no warnings. Got: {warnings:?}"
3472        );
3473    }
3474
3475    /// Issue #268: First item only has continuation in blockquote list
3476    #[test]
3477    fn test_blockquote_list_first_item_only_continuation() {
3478        let content = "> - Item 1\n> \n>   Continuation of item 1\n";
3479        let warnings = lint(content);
3480        assert_eq!(
3481            warnings.len(),
3482            0,
3483            "Single item with continuation should have no warnings. Got: {warnings:?}"
3484        );
3485    }
3486
3487    /// Blockquote level change SHOULD still be detected as list break
3488    /// Note: markdownlint flags BOTH lines in this case - line 1 for missing preceding blank,
3489    /// and line 2 for missing preceding blank (level change)
3490    #[test]
3491    fn test_blockquote_level_change_breaks_list() {
3492        // Going from > to >> should break the list - markdownlint flags both lines
3493        let content = "> - Item in single blockquote\n>> - Item in nested blockquote\n";
3494        let warnings = lint(content);
3495        // markdownlint reports: line 1 (list at start), line 2 (level change)
3496        // For now, accept 0 or more warnings since this is a complex edge case
3497        // The main fix (multi-paragraph items) is more important than this edge case
3498        assert!(
3499            warnings.len() <= 2,
3500            "Blockquote level change warnings should be reasonable. Got: {warnings:?}"
3501        );
3502    }
3503
3504    /// Exiting blockquote SHOULD still be detected as needing blank line
3505    #[test]
3506    fn test_exit_blockquote_needs_blank_before_list() {
3507        // Text after blockquote, then list without blank
3508        let content = "> Blockquote text\n\n- List outside blockquote\n";
3509        let warnings = lint(content);
3510        assert_eq!(
3511            warnings.len(),
3512            0,
3513            "List after blank line outside blockquote should be fine. Got: {warnings:?}"
3514        );
3515
3516        // Without blank line after blockquote - markdownlint flags this
3517        // But rumdl may not flag it due to complexity of detecting "text immediately before list"
3518        // This is an acceptable deviation for now
3519        let content2 = "> Blockquote text\n- List outside blockquote\n";
3520        let warnings2 = lint(content2);
3521        // Accept 0 or 1 - main fix is more important than this edge case
3522        assert!(
3523            warnings2.len() <= 1,
3524            "List after blockquote warnings should be reasonable. Got: {warnings2:?}"
3525        );
3526    }
3527
3528    /// Issue #268: Test all unordered list markers (-, *, +) with multi-paragraph items
3529    #[test]
3530    fn test_blockquote_multi_paragraph_all_unordered_markers() {
3531        // Dash marker
3532        let content_dash = "> - Item 1\n> \n>   Continuation\n> - Item 2\n";
3533        let warnings = lint(content_dash);
3534        assert_eq!(warnings.len(), 0, "Dash marker should work. Got: {warnings:?}");
3535
3536        // Asterisk marker
3537        let content_asterisk = "> * Item 1\n> \n>   Continuation\n> * Item 2\n";
3538        let warnings = lint(content_asterisk);
3539        assert_eq!(warnings.len(), 0, "Asterisk marker should work. Got: {warnings:?}");
3540
3541        // Plus marker
3542        let content_plus = "> + Item 1\n> \n>   Continuation\n> + Item 2\n";
3543        let warnings = lint(content_plus);
3544        assert_eq!(warnings.len(), 0, "Plus marker should work. Got: {warnings:?}");
3545    }
3546
3547    /// Issue #268: Parenthesis-style ordered list markers (1))
3548    #[test]
3549    fn test_blockquote_multi_paragraph_parenthesis_marker() {
3550        let content = "> 1) Item 1\n> \n>    Continuation\n> 2) Item 2\n";
3551        let warnings = lint(content);
3552        assert_eq!(
3553            warnings.len(),
3554            0,
3555            "Parenthesis ordered markers should work. Got: {warnings:?}"
3556        );
3557    }
3558
3559    /// Issue #268: Multi-digit ordered list numbers have wider markers
3560    #[test]
3561    fn test_blockquote_multi_paragraph_multi_digit_numbers() {
3562        // "10. " is 4 chars, so continuation needs 4 spaces
3563        let content = "> 10. Item 10\n> \n>     Continuation of item 10\n> 11. Item 11\n";
3564        let warnings = lint(content);
3565        assert_eq!(
3566            warnings.len(),
3567            0,
3568            "Multi-digit ordered list should work. Got: {warnings:?}"
3569        );
3570    }
3571
3572    /// Issue #268: Continuation with emphasis and other inline formatting
3573    #[test]
3574    fn test_blockquote_multi_paragraph_with_formatting() {
3575        let content = "> - Item with **bold**\n> \n>   Continuation with *emphasis* and `code`\n> - Item 2\n";
3576        let warnings = lint(content);
3577        assert_eq!(
3578            warnings.len(),
3579            0,
3580            "Continuation with inline formatting should work. Got: {warnings:?}"
3581        );
3582    }
3583
3584    /// Issue #268: Multiple items each with their own continuation paragraph
3585    #[test]
3586    fn test_blockquote_multi_paragraph_all_items_have_continuation() {
3587        let content = "> - Item 1\n> \n>   Continuation 1\n> - Item 2\n> \n>   Continuation 2\n> - Item 3\n> \n>   Continuation 3\n";
3588        let warnings = lint(content);
3589        assert_eq!(
3590            warnings.len(),
3591            0,
3592            "All items with continuations should work. Got: {warnings:?}"
3593        );
3594    }
3595
3596    /// Issue #268: Continuation starting with lowercase (tests uppercase heuristic doesn't break this)
3597    #[test]
3598    fn test_blockquote_multi_paragraph_lowercase_continuation() {
3599        let content = "> - Item 1\n> \n>   and this continues the item\n> - Item 2\n";
3600        let warnings = lint(content);
3601        assert_eq!(
3602            warnings.len(),
3603            0,
3604            "Lowercase continuation should work. Got: {warnings:?}"
3605        );
3606    }
3607
3608    /// Issue #268: Continuation starting with uppercase (tests uppercase heuristic is bypassed with proper indent)
3609    #[test]
3610    fn test_blockquote_multi_paragraph_uppercase_continuation() {
3611        let content = "> - Item 1\n> \n>   This continues the item with uppercase\n> - Item 2\n";
3612        let warnings = lint(content);
3613        assert_eq!(
3614            warnings.len(),
3615            0,
3616            "Uppercase continuation with proper indent should work. Got: {warnings:?}"
3617        );
3618    }
3619
3620    /// Issue #268: Mixed ordered and unordered shouldn't affect multi-paragraph handling
3621    #[test]
3622    fn test_blockquote_separate_ordered_unordered_multi_paragraph() {
3623        // Two separate lists in same blockquote
3624        let content = "> - Unordered item\n> \n>   Continuation\n> \n> 1. Ordered item\n> \n>    Continuation\n";
3625        let warnings = lint(content);
3626        // May have warning for missing blank between lists, but not for the continuations
3627        assert!(
3628            warnings.len() <= 1,
3629            "Separate lists with continuations should be reasonable. Got: {warnings:?}"
3630        );
3631    }
3632
3633    /// Issue #268: Blockquote with bare > line (no space) as blank
3634    #[test]
3635    fn test_blockquote_multi_paragraph_bare_marker_blank() {
3636        // Using ">" alone instead of "> " for blank line
3637        let content = "> - Item 1\n>\n>   Continuation\n> - Item 2\n";
3638        let warnings = lint(content);
3639        assert_eq!(warnings.len(), 0, "Bare > as blank line should work. Got: {warnings:?}");
3640    }
3641
3642    #[test]
3643    fn test_blockquote_list_varying_spaces_after_marker() {
3644        // Different spacing after > (1 space vs 3 spaces) but same blockquote level
3645        let content = "> - item 1\n>   continuation with more indent\n> - item 2";
3646        let warnings = lint(content);
3647        assert_eq!(warnings.len(), 0, "Varying spaces after > should not break list");
3648    }
3649
3650    #[test]
3651    fn test_deeply_nested_blockquote_list() {
3652        // Triple-nested blockquote with list
3653        let content = ">>> - item 1\n>>>   continuation\n>>> - item 2";
3654        let warnings = lint(content);
3655        assert_eq!(
3656            warnings.len(),
3657            0,
3658            "Deeply nested blockquote list should have no warnings"
3659        );
3660    }
3661
3662    #[test]
3663    fn test_blockquote_level_change_in_list() {
3664        // Blockquote level changes mid-list - this breaks the list
3665        let content = "> - item 1\n>> - deeper item\n> - item 2";
3666        // Each segment is a separate list context due to blockquote level change
3667        // markdownlint-cli reports 4 warnings for this case
3668        let warnings = lint(content);
3669        assert!(
3670            !warnings.is_empty(),
3671            "Blockquote level change should break list and trigger warnings"
3672        );
3673    }
3674
3675    #[test]
3676    fn test_blockquote_list_with_code_span() {
3677        // List item with inline code in blockquote
3678        let content = "> - item with `code`\n>   continuation\n> - item 2";
3679        let warnings = lint(content);
3680        assert_eq!(
3681            warnings.len(),
3682            0,
3683            "Blockquote list with code span should have no warnings"
3684        );
3685    }
3686
3687    #[test]
3688    fn test_code_span_html_comment_delimiters_no_false_positive() {
3689        // Issue #679: `<!--` and `-->` inside inline code spans on different lines
3690        // must not be parsed as a single multi-line HTML comment. When they were,
3691        // the blank lines between them were treated as "inside a comment"
3692        // (transparent), so MD032 saw the list as lacking surrounding blanks and
3693        // emitted false "list should be followed/preceded by blank line" warnings.
3694        let content = "Text before list.\n\n1. A list item with `<!--` in a code span\n\n### Heading After\n\n1. Another item with `-->` in it\n";
3695        let warnings = lint(content);
3696        assert_eq!(
3697            warnings.len(),
3698            0,
3699            "code-span HTML comment delimiters must not cause MD032 false positives, got: {warnings:?}"
3700        );
3701    }
3702
3703    #[test]
3704    fn test_code_span_html_comment_delimiters_fix_is_idempotent() {
3705        // Issue #679: the false positives above drove a non-converging `--fix`
3706        // loop - each pass inserted a blank line, shifting bytes so the spurious
3707        // comment range re-matched and the rule "found" the missing blank again.
3708        // The correct fix is a no-op because the content is already well-formed.
3709        let content = "Text before list.\n\n1. A list item with `<!--` in a code span\n\n### Heading After\n\n1. Another item with `-->` in it\n";
3710        let fixed = fix(content);
3711        assert_eq!(
3712            fixed, content,
3713            "MD032 fix must be a no-op for content whose only `<!--`/`-->` are inside code spans"
3714        );
3715    }
3716
3717    #[test]
3718    fn test_blockquote_list_at_document_end() {
3719        // List at end of document (no trailing content)
3720        let content = "> Some text\n>\n> - item 1\n> - item 2";
3721        let warnings = lint(content);
3722        assert_eq!(
3723            warnings.len(),
3724            0,
3725            "Blockquote list at document end should have no warnings"
3726        );
3727    }
3728
3729    #[test]
3730    fn test_fix_preserves_blockquote_prefix_before_list() {
3731        // Issue #268: Fix should insert blockquote-prefixed blank lines inside blockquotes
3732        let content = "> Text before
3733> - Item 1
3734> - Item 2";
3735        let fixed = fix(content);
3736
3737        // The blank line inserted before the list should have the blockquote prefix (no trailing space per markdownlint-cli)
3738        let expected = "> Text before
3739>
3740> - Item 1
3741> - Item 2";
3742        assert_eq!(
3743            fixed, expected,
3744            "Fix should insert '>' blank line, not plain blank line"
3745        );
3746    }
3747
3748    #[test]
3749    fn test_fix_preserves_triple_nested_blockquote_prefix_for_list() {
3750        // Triple-nested blockquotes should preserve full prefix
3751        // Per markdownlint-cli, only preceding blank line is required
3752        let content = ">>> Triple nested
3753>>> - Item 1
3754>>> - Item 2
3755>>> More text";
3756        let fixed = fix(content);
3757
3758        // Should insert ">>>" blank line before list only
3759        let expected = ">>> Triple nested
3760>>>
3761>>> - Item 1
3762>>> - Item 2
3763>>> More text";
3764        assert_eq!(
3765            fixed, expected,
3766            "Fix should preserve triple-nested blockquote prefix '>>>'"
3767        );
3768    }
3769
3770    // ==================== Quarto Flavor Tests ====================
3771
3772    fn lint_quarto(content: &str) -> Vec<LintWarning> {
3773        let rule = MD032BlanksAroundLists::default();
3774        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
3775        rule.check(&ctx).unwrap()
3776    }
3777
3778    #[test]
3779    fn test_quarto_list_after_div_open() {
3780        // List immediately after Quarto div opening: div marker is transparent
3781        let content = "Content\n\n::: {.callout-note}\n- Item 1\n- Item 2\n:::\n";
3782        let warnings = lint_quarto(content);
3783        // The blank line before div opening should count as separation
3784        assert!(
3785            warnings.is_empty(),
3786            "Quarto div marker should be transparent before list: {warnings:?}"
3787        );
3788    }
3789
3790    #[test]
3791    fn test_quarto_list_before_div_close() {
3792        // List immediately before Quarto div closing: div close is at end, transparent
3793        let content = "::: {.callout-note}\n\n- Item 1\n- Item 2\n:::\n";
3794        let warnings = lint_quarto(content);
3795        // The div closing marker is at end, should be transparent
3796        assert!(
3797            warnings.is_empty(),
3798            "Quarto div marker should be transparent after list: {warnings:?}"
3799        );
3800    }
3801
3802    #[test]
3803    fn test_quarto_list_needs_blank_without_div() {
3804        // List still needs blank line without div providing separation
3805        let content = "Content\n::: {.callout-note}\n- Item 1\n- Item 2\n:::\n";
3806        let warnings = lint_quarto(content);
3807        // No blank between "Content" and div opening (which is transparent)
3808        // so list appears right after "Content" - needs blank
3809        assert!(
3810            !warnings.is_empty(),
3811            "Should still require blank when not present: {warnings:?}"
3812        );
3813    }
3814
3815    #[test]
3816    fn test_quarto_list_in_callout_with_content() {
3817        // List inside callout with proper blank lines
3818        let content = "::: {.callout-note}\nNote introduction:\n\n- Item 1\n- Item 2\n\nMore note content.\n:::\n";
3819        let warnings = lint_quarto(content);
3820        assert!(
3821            warnings.is_empty(),
3822            "List with proper blanks inside callout should pass: {warnings:?}"
3823        );
3824    }
3825
3826    #[test]
3827    fn test_quarto_div_markers_not_transparent_in_standard_flavor() {
3828        // In standard flavor, ::: is regular text
3829        let content = "Content\n\n:::\n- Item 1\n- Item 2\n:::\n";
3830        let warnings = lint(content); // Uses standard flavor
3831        // In standard, ::: is just text, so list follows ::: without blank
3832        assert!(
3833            !warnings.is_empty(),
3834            "Standard flavor should not treat ::: as transparent: {warnings:?}"
3835        );
3836    }
3837
3838    #[test]
3839    fn test_quarto_nested_divs_with_list() {
3840        // Nested Quarto divs with list inside
3841        let content = "::: {.outer}\n::: {.inner}\n\n- Item 1\n- Item 2\n\n:::\n:::\n";
3842        let warnings = lint_quarto(content);
3843        assert!(warnings.is_empty(), "Nested divs with list should work: {warnings:?}");
3844    }
3845
3846    #[test]
3847    fn test_issue512_complex_nested_list_with_continuation() {
3848        // Three-level nested list with continuation paragraphs at parent indent levels.
3849        // The continuation paragraphs are part of the same list, so no MD032 warning expected.
3850        let content = "\
3851- First level of indentation.
3852  - Second level of indentation.
3853    - Third level of indentation.
3854    - Third level of indentation.
3855
3856    Second level list continuation.
3857
3858  First level list continuation.
3859- First level of indentation.
3860";
3861        let warnings = lint(content);
3862        assert!(
3863            warnings.is_empty(),
3864            "Nested list with parent-level continuation should produce no warnings. Got: {warnings:?}"
3865        );
3866    }
3867
3868    #[test]
3869    fn test_issue512_continuation_at_root_level() {
3870        // Nested list where continuation returns to indent 0 (lazy continuation).
3871        // The unindented "Root level lazy continuation." breaks the list, so the next
3872        // list item needs a blank line before it. markdownlint-cli also warns here.
3873        let content = "\
3874- First level.
3875  - Second level.
3876
3877  First level continuation.
3878
3879Root level lazy continuation.
3880- Another first level item.
3881";
3882        let warnings = lint(content);
3883        assert_eq!(
3884            warnings.len(),
3885            1,
3886            "Should warn on line 7 (new list after break). Got: {warnings:?}"
3887        );
3888        assert_eq!(warnings[0].line, 7);
3889    }
3890
3891    #[test]
3892    fn test_issue512_three_level_nesting_continuation_at_each_level() {
3893        // Each nesting level has a continuation paragraph
3894        let content = "\
3895- Level 1 item.
3896  - Level 2 item.
3897    - Level 3 item.
3898
3899    Level 3 continuation.
3900
3901  Level 2 continuation.
3902
3903  Level 1 continuation (indented under marker).
3904- Another level 1 item.
3905";
3906        let warnings = lint(content);
3907        assert!(
3908            warnings.is_empty(),
3909            "Continuation at each nesting level should produce no warnings. Got: {warnings:?}"
3910        );
3911    }
3912
3913    #[test]
3914    fn test_pandoc_list_after_div_open() {
3915        // List immediately after a Pandoc div opening should not require a blank line,
3916        // mirroring the Quarto behavior tested in test_quarto_list_after_div_open.
3917        let rule = MD032BlanksAroundLists::default();
3918        let content = "Content\n\n::: {.callout-note}\n- Item 1\n- Item 2\n:::\n";
3919        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
3920        let warnings = rule.check(&ctx).unwrap();
3921        assert!(
3922            warnings.is_empty(),
3923            "MD032 should treat Pandoc div marker as transparent before list: {warnings:?}"
3924        );
3925    }
3926
3927    #[test]
3928    fn test_md032_html_comment() {
3929        let rule = MD032BlanksAroundLists::default();
3930        let content = "text\n<!--\n- Item 1\n- Item 2\n-->\ntext";
3931        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3932        let warnings = rule.check(&ctx).unwrap();
3933        assert!(
3934            warnings.is_empty(),
3935            "MD032 should not require blank lines around lists inside HTML comments: {warnings:?}"
3936        );
3937    }
3938
3939    #[test]
3940    fn test_mkdocs_admonition_nested_ordered_list_not_flagged() {
3941        // A properly indented ordered list nested inside a MkDocs
3942        // admonition must not be flagged just because the preceding item's
3943        // text happens to end in sentence-terminating punctuation. The
3944        // fallback "non-1 ordered item not in any known list block" heuristic
3945        // exists for ambiguous prose, not for recognized container content.
3946        let rule = MD032BlanksAroundLists::default();
3947        let content = "1. no error here\n\n!!! example\n\n    1. no error here.\n    2. error here because previous line ends with a \".\"\n    3. no error here\n";
3948        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
3949        let warnings = rule.check(&ctx).unwrap();
3950        assert!(
3951            warnings.is_empty(),
3952            "admonition-nested ordered list should not be flagged: {warnings:?}"
3953        );
3954    }
3955
3956    #[test]
3957    fn test_mkdocs_admonition_nested_ordered_list_cascade_not_flagged() {
3958        // When item 2 also ends in a period, the false positive previously
3959        // cascaded to item 3 as well (its preceding line then also looked
3960        // like a finished sentence). Both items must stay unflagged.
3961        let rule = MD032BlanksAroundLists::default();
3962        let content = "1. no error here\n\n!!! example\n\n    1. no error here.\n    2. error here because previous line ends with a period.\n    3. no error here\n";
3963        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
3964        let warnings = rule.check(&ctx).unwrap();
3965        assert!(
3966            warnings.is_empty(),
3967            "cascading admonition-nested ordered list should not be flagged: {warnings:?}"
3968        );
3969    }
3970
3971    #[test]
3972    fn test_mkdocs_content_tab_nested_ordered_list_not_flagged() {
3973        // The same false positive occurs inside a MkDocs content tab body.
3974        let rule = MD032BlanksAroundLists::default();
3975        let content = "1. no error here\n\n=== \"Tab A\"\n\n    1. no error here.\n    2. error here because previous line ends with a \".\"\n    3. no error here\n";
3976        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
3977        let warnings = rule.check(&ctx).unwrap();
3978        assert!(
3979            warnings.is_empty(),
3980            "content-tab-nested ordered list should not be flagged: {warnings:?}"
3981        );
3982    }
3983
3984    #[test]
3985    fn test_mkdocs_admonition_prose_then_non1_item_still_flagged() {
3986        // Prose followed by a non-1 ordered item inside an admonition has the
3987        // same missing-blank ambiguity as outside one: the item cannot
3988        // interrupt the paragraph, so the fallback warning must survive. Only
3989        // stale nested lists (an ordered marker line above at the same
3990        // indent) are exempt.
3991        let rule = MD032BlanksAroundLists::default();
3992        let content = "1. no error here\n\n!!! example\n\n    Intro.\n    2. item\n";
3993        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
3994        let warnings = rule.check(&ctx).unwrap();
3995        assert_eq!(
3996            warnings.len(),
3997            1,
3998            "prose then non-1 item inside an admonition must stay flagged: {warnings:?}"
3999        );
4000    }
4001
4002    #[test]
4003    fn test_mkdocs_admonition_prose_after_list_item_breaks_continuation() {
4004        // A same-indent prose line between a list item and a non-1 item is a
4005        // lazy continuation of the earlier item, so the non-1 item cannot
4006        // start a new item there; the warning must survive even though an
4007        // ordered marker exists further up.
4008        let rule = MD032BlanksAroundLists::default();
4009        let content = "1. no error here\n\n!!! example\n\n    1. one.\n    Intro prose.\n    2. two\n";
4010        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
4011        let warnings = rule.check(&ctx).unwrap();
4012        assert_eq!(
4013            warnings.len(),
4014            1,
4015            "prose at item indent breaks the list continuation, item must stay flagged: {warnings:?}"
4016        );
4017    }
4018
4019    #[test]
4020    fn test_mkdocs_admonition_wrapped_item_continuation_not_flagged() {
4021        // A non-1 item whose predecessor is the wrapped continuation line of
4022        // an earlier item belongs to the same stale nested list: the deeper
4023        // indented continuation must not break the walk back to the item
4024        // marker at the same indent.
4025        let rule = MD032BlanksAroundLists::default();
4026        let content = "1. no error here\n\n!!! example\n\n    1. item one that wraps\n       onto a second line.\n    2. item two\n";
4027        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
4028        let warnings = rule.check(&ctx).unwrap();
4029        assert!(
4030            warnings.is_empty(),
4031            "wrapped continuation of a nested list item must not be flagged: {warnings:?}"
4032        );
4033    }
4034
4035    #[test]
4036    fn test_mkdocs_ambiguous_prose_non1_ordered_item_still_flagged() {
4037        // Control: outside any recognized container, a non-1 ordered item
4038        // directly following a line that reads as a finished sentence is
4039        // genuinely ambiguous - CommonMark won't parse it as a new list
4040        // item without a blank line, so the fallback heuristic must keep
4041        // warning here under MkDocs flavor exactly as it does elsewhere.
4042        let rule = MD032BlanksAroundLists::default();
4043        let content = "1. no error here\n\nno error here.\n2. error here because previous line ends with a period.\n";
4044        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
4045        let warnings = rule.check(&ctx).unwrap();
4046        assert_eq!(
4047            warnings.len(),
4048            1,
4049            "ambiguous non-1 ordered item outside any container should still be flagged: {warnings:?}"
4050        );
4051        assert_eq!(warnings[0].line, 4);
4052        assert!(warnings[0].message.contains("non-1"));
4053    }
4054
4055    #[test]
4056    fn test_mkdocs_admonition_nested_list_without_trailing_punctuation_not_flagged() {
4057        // Sanity control: with item 1 not ending in sentence punctuation, the
4058        // fallback heuristic already treated item 2 as a sentence continuation
4059        // (no warning) even before the fix. This proves the regression tests
4060        // above are not vacuously passing - the guard is exercised only when
4061        // the preceding text ends in sentence-terminating punctuation.
4062        let rule = MD032BlanksAroundLists::default();
4063        let content = "1. no error here\n\n!!! example\n\n    1. no error here\n    2. error here because previous line ends with a \".\"\n    3. no error here\n";
4064        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
4065        let warnings = rule.check(&ctx).unwrap();
4066        assert!(
4067            warnings.is_empty(),
4068            "admonition-nested ordered list without trailing punctuation should not be flagged: {warnings:?}"
4069        );
4070    }
4071
4072    #[test]
4073    fn test_standard_flavor_admonition_indented_list_unchanged() {
4074        // Control: under standard flavor there is no MkDocs admonition
4075        // concept, so the 4-space-indented block after `!!! example` is
4076        // genuine indented code, already excluded by the code-block guard.
4077        // This must stay unflagged, unaffected by the MkDocs-only fix.
4078        let rule = MD032BlanksAroundLists::default();
4079        let content = "1. no error here\n\n!!! example\n\n    1. no error here.\n    2. error here because previous line ends with a \".\"\n    3. no error here\n";
4080        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4081        let warnings = rule.check(&ctx).unwrap();
4082        assert!(
4083            warnings.is_empty(),
4084            "indented code block under standard flavor should not be flagged: {warnings:?}"
4085        );
4086    }
4087
4088    #[test]
4089    fn test_mkdocs_html_markdown_div_nested_ordered_list_still_flagged() {
4090        // A markdown="1" HTML div is tag-scoped, not indentation-scoped, and
4091        // is detected in every flavor (see in_mkdocs_html_markdown). The
4092        // guard for this fix deliberately checks only in_admonition and
4093        // in_content_tab, so behavior for this div case is unchanged by it.
4094        let rule = MD032BlanksAroundLists::default();
4095        let content = "1. no error here\n\n<div markdown=\"1\">\n\n    1. no error here.\n    2. error here because previous line ends with a \".\"\n    3. no error here\n\n</div>\n";
4096        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
4097        let warnings = rule.check(&ctx).unwrap();
4098        assert_eq!(
4099            warnings.len(),
4100            1,
4101            "markdown=\"1\" div nested ordered list behavior must stay unchanged: {warnings:?}"
4102        );
4103        assert_eq!(warnings[0].line, 6);
4104    }
4105
4106    #[test]
4107    fn test_pseudo_list_marker_after_list() {
4108        let content = indoc::indoc! {"
4109            -   Item 1
4110                Item 1 content.
4111
4112            The unsigned-integer types may be written `uN`, with `N` a positive multiple of
4113            8. Unsigned integer types wrap around on overflow; we strongly advise that they
4114            are not used except when those semantics are desired.
4115        "};
4116        let warnings = lint(content);
4117        assert!(
4118            warnings.is_empty(),
4119            "Expected no warnings for pseudo-list marker after list, but got: {warnings:?}"
4120        );
4121    }
4122
4123    #[test]
4124    fn test_pseudo_list_marker_without_preceding_list() {
4125        let content = indoc::indoc! {"
4126            The unsigned-integer types may be written `uN`, with `N` a positive multiple of
4127            8. Unsigned integer types wrap around on overflow; we strongly advise that they
4128            are not used except when those semantics are desired.
4129        "};
4130        let warnings = lint(content);
4131        assert!(
4132            warnings.is_empty(),
4133            "Expected no warnings for pseudo-list marker without preceding list, but got: {warnings:?}"
4134        );
4135    }
4136
4137    #[test]
4138    fn test_no_space_hash_continuation_line_stays_in_its_item() {
4139        // `#2, #3` is recorded as an invalid ATX heading for MD018's benefit but is
4140        // paragraph text to CommonMark, so it neither ends the list block nor earns a
4141        // blank line above it. The only blank line owed here is the one before the
4142        // task list that follows the heading.
4143        let content = indoc::indoc! {"
4144            5. **`M.md`** - the deltas (esp. items #1,
4145               #2, #3, #5, #8).
4146
4147            ---
4148
4149            ## Plan
4150
4151            ### Phase 0
4152            - [ ] task one
4153                  wrapped
4154        "};
4155        let warnings = lint(content);
4156        assert_eq!(
4157            warnings.len(),
4158            1,
4159            "only the task list is missing a blank line, got: {warnings:?}"
4160        );
4161        assert_eq!(warnings[0].line, 9);
4162        assert_eq!(warnings[0].message, "List should be preceded by blank line");
4163
4164        let expected = indoc::indoc! {"
4165            5. **`M.md`** - the deltas (esp. items #1,
4166               #2, #3, #5, #8).
4167
4168            ---
4169
4170            ## Plan
4171
4172            ### Phase 0
4173
4174            - [ ] task one
4175                  wrapped
4176        "};
4177        assert_eq!(fix(content), expected);
4178    }
4179
4180    #[test]
4181    fn test_fix_keeps_tight_continuation_attached_while_fixing_elsewhere() {
4182        // The two-space continuation under `3. item` sits below the content column,
4183        // so it is a lazy continuation of the item and the list block includes it.
4184        // The fix runs whenever the rule has any warning in the document, here the
4185        // heading-hugging list at the end, and must not insert between the item and
4186        // its continuation, which would detach the continuation from the item.
4187        let content = indoc::indoc! {"
4188            1. first
4189
4190            3. item
4191              continuation
4192
4193              1. nested
4194              2. nested
4195
4196            ## Heading
4197            - task
4198        "};
4199        let warnings = lint(content);
4200        assert_eq!(
4201            warnings.iter().map(|w| w.line).collect::<Vec<_>>(),
4202            vec![10],
4203            "only the list after the heading is missing a blank line, got: {warnings:?}"
4204        );
4205
4206        let expected = indoc::indoc! {"
4207            1. first
4208
4209            3. item
4210              continuation
4211
4212              1. nested
4213              2. nested
4214
4215            ## Heading
4216
4217            - task
4218        "};
4219        assert_eq!(fix(content), expected);
4220    }
4221
4222    #[test]
4223    fn test_no_space_hash_lazy_continuation_stays_in_its_item() {
4224        // A lazy continuation line that begins with `#` followed by a digit is
4225        // paragraph text to CommonMark (no space after the `#`), so it belongs to
4226        // the item above it and the list keeps running through it.
4227        let content = indoc::indoc! {"
4228            - item (esp. #1,
4229            #2, #3).
4230            - next item
4231
4232            ## Heading
4233            - task
4234        "};
4235        let warnings = lint(content);
4236        assert_eq!(
4237            warnings.iter().map(|w| w.line).collect::<Vec<_>>(),
4238            vec![6],
4239            "only the list after the heading is missing a blank line, got: {warnings:?}"
4240        );
4241
4242        let expected = indoc::indoc! {"
4243            - item (esp. #1,
4244            #2, #3).
4245            - next item
4246
4247            ## Heading
4248
4249            - task
4250        "};
4251        assert_eq!(fix(content), expected);
4252    }
4253
4254    #[test]
4255    fn test_under_indented_continuation_lines_stay_in_their_item() {
4256        // Paragraph text indented short of the content column is a lazy
4257        // continuation per CommonMark, however short the indent, in and out of a
4258        // blockquote. Neither the check nor the fix separates it from its item.
4259        for content in [
4260            "1. Helps to avoid situations\n  changes that the team might not accept\n  changes are in a direction.\n",
4261            "> 1. Helps to avoid situations\n>   changes that the team might not accept\n>   changes are in a direction.\n",
4262            "- Item\n lazy continuation\n- another item\n",
4263            "> - Item\n>  lazy continuation\n> - another item\n",
4264        ] {
4265            let warnings = lint(content);
4266            assert!(warnings.is_empty(), "{content:?}: got {warnings:?}");
4267            assert_eq!(fix(content), content, "{content:?}");
4268        }
4269    }
4270
4271    #[test]
4272    fn test_under_indented_continuation_lines_are_lazy_when_lazy_is_disallowed() {
4273        // With allow_lazy_continuation = false the same lines are reported as
4274        // lazy continuations, not as a list missing a blank line around it.
4275        let config = MD032Config {
4276            allow_lazy_continuation: false,
4277        };
4278        for (content, lazy_lines) in [
4279            ("- Item\n lazy continuation\n- another item\n", vec![2]),
4280            ("> - Item\n>  lazy continuation\n> - another item\n", vec![2]),
4281            ("> 1. Item\n>   changes that\n>   changes are\n> 2. next\n", vec![2, 3]),
4282        ] {
4283            let warnings = lint_with_config(content, config.clone());
4284            assert!(
4285                warnings.iter().all(|w| w.message.contains("Lazy continuation")),
4286                "{content:?}: got {warnings:?}"
4287            );
4288            assert_eq!(
4289                warnings.iter().map(|w| w.line).collect::<Vec<_>>(),
4290                lazy_lines,
4291                "{content:?}: got {warnings:?}"
4292            );
4293        }
4294    }
4295
4296    #[test]
4297    fn test_structural_line_at_short_indent_ends_the_list() {
4298        // A thematic break or heading cannot be a lazy continuation, so at an
4299        // indent short of the content column it ends the list and the list is
4300        // missing its trailing blank line, in and out of a blockquote.
4301        for (content, expected) in [
4302            ("1. item\n  ---\n", "1. item\n\n  ---\n"),
4303            ("1. item\n  ## Heading\n", "1. item\n\n  ## Heading\n"),
4304            ("> 1. item\n>   ---\n", "> 1. item\n>\n>   ---\n"),
4305            ("> 1. item\n> ---\n", "> 1. item\n>\n> ---\n"),
4306        ] {
4307            let warnings = lint(content);
4308            assert_eq!(
4309                warnings
4310                    .iter()
4311                    .map(|w| (w.line, w.message.as_str()))
4312                    .collect::<Vec<_>>(),
4313                vec![(1, "List should be followed by blank line")],
4314                "{content:?}: got {warnings:?}"
4315            );
4316            assert_eq!(fix(content), expected, "{content:?}");
4317        }
4318    }
4319
4320    #[test]
4321    fn test_html_block_at_short_indent_ends_the_list() {
4322        // A block-level tag interrupts a paragraph, so at an indent short of
4323        // the content column it is not a lazy continuation: it ends the list,
4324        // and a list that follows its closing tag is a new list. Both lists
4325        // are missing a blank line next to the HTML block, whether the tag
4326        // sits at the margin or one column in, and inside a blockquote as much
4327        // as at the root.
4328        for (content, expected) in [
4329            (
4330                "- item\n<script>\nx\n</script>\n- next\n",
4331                "- item\n\n<script>\nx\n</script>\n\n- next\n",
4332            ),
4333            (
4334                "- item\n <script>\n x\n </script>\n- next\n",
4335                "- item\n\n <script>\n x\n </script>\n\n- next\n",
4336            ),
4337            (
4338                "- item\n <pre>\n x\n </pre>\n- next\n",
4339                "- item\n\n <pre>\n x\n </pre>\n\n- next\n",
4340            ),
4341            (
4342                "> - item\n> <script>\n> x\n> </script>\n> - next\n",
4343                "> - item\n>\n> <script>\n> x\n> </script>\n>\n> - next\n",
4344            ),
4345            (
4346                "> - item\n> <pre>\n> x\n> </pre>\n> - next\n",
4347                "> - item\n>\n> <pre>\n> x\n> </pre>\n>\n> - next\n",
4348            ),
4349        ] {
4350            let warnings = lint(content);
4351            assert_eq!(
4352                warnings
4353                    .iter()
4354                    .map(|w| (w.line, w.message.as_str()))
4355                    .collect::<Vec<_>>(),
4356                vec![
4357                    (1, "List should be followed by blank line"),
4358                    (5, "List should be preceded by blank line"),
4359                ],
4360                "{content:?}: got {warnings:?}"
4361            );
4362            assert_eq!(fix(content), expected, "{content:?}");
4363        }
4364    }
4365
4366    #[test]
4367    fn test_html_looking_text_at_short_indent_is_a_lazy_continuation() {
4368        // An HTML block opens only within three columns of indent, and only
4369        // when the tag name ends the way CommonMark requires. A tag indented
4370        // four columns short of a wide item's content column, or a bracket
4371        // that merely begins with a block element's name, is paragraph text
4372        // that lazily continues the item, at the root and inside a blockquote:
4373        // one list, nothing to report, nothing to rewrite. Indent is measured
4374        // in columns, so a tab reaches the fourth column however few bytes
4375        // precede it (a tab right after `>` gives the marker its optional
4376        // space and indents with the rest), and a tag that reaches an outer
4377        // item's content column is that item's own HTML block, however short
4378        // of a nested item it falls.
4379        for content in [
4380            "100. item\n    <div>\n101. next\n",
4381            "> 100. item\n>     <div>\n> 101. next\n",
4382            "100. item\n    <div>\ntext\n101. next\n",
4383            "- item\n<div.class>\n- next\n",
4384            "> - item\n> <div.class>\n> - next\n",
4385            "100. item\n\t<div>\n101. next\n",
4386            "- item\n  \t<div>\n- next\n",
4387            "> - item\n> \t<div>\n> - next\n",
4388            "> - item\n>\t<div>\n> - next\n",
4389            "> 100. item\n>   \t<div>\n> 101. next\n",
4390            "- outer\n  - inner\n   <script>\n   x\n   </script>\n- next\n",
4391            "- outer\n  - inner\n  <script>\n  x\n  </script>\n- next\n",
4392            "> - outer\n>   - inner\n>   <div>\n>   x\n>   </div>\n> - next\n",
4393        ] {
4394            let warnings = lint(content);
4395            assert!(warnings.is_empty(), "{content:?}: got {warnings:?}");
4396            assert_eq!(fix(content), content, "{content:?}");
4397        }
4398
4399        // Control: the same tag one column closer to the margin, or short of
4400        // every item's content column, opens a block and ends the list after
4401        // its last item.
4402        for (content, last_item_line) in [
4403            ("100. item\n   <div>\n101. next\n", 1),
4404            ("> 100. item\n>    <div>\n> 101. next\n", 1),
4405            ("- item\n <div>\n- next\n", 1),
4406            ("> 1. item\n> \t<div>\n> 2. next\n", 1),
4407            ("> 1. item\n>\t<div>\n> 2. next\n", 1),
4408            ("1. outer\n   1. inner\n  <div>\n2. next\n", 2),
4409        ] {
4410            let warnings = lint(content);
4411            assert_eq!(
4412                warnings
4413                    .iter()
4414                    .map(|w| (w.line, w.message.as_str()))
4415                    .collect::<Vec<_>>(),
4416                vec![(last_item_line, "List should be followed by blank line")],
4417                "{content:?}: got {warnings:?}"
4418            );
4419        }
4420    }
4421
4422    #[test]
4423    fn test_tab_indented_nested_list_stays_inside_its_item() {
4424        // A tab before a nested marker reaches the fourth column, past the
4425        // parent item's content column, so the nested list belongs to the
4426        // item however few bytes precede the marker, at the root and inside a
4427        // blockquote: one list, nothing to report, nothing to rewrite.
4428        // Every row nests a list of the OTHER type, since a same-type child
4429        // was grouped with its parent before markers were measured in
4430        // columns; the last row is that same-type shape, kept as the control
4431        // that the column measure did not disturb it.
4432        for content in [
4433            "* item text\n\t1. nested\n\t   more\n",
4434            "* item text\n\tcontinuation\n\t1. nested\n",
4435            "1. item text\n\t- nested\n",
4436            "> * item text\n>\t1. nested\n",
4437            "> * item text\n> \t1. nested\n",
4438            "* item text\n\tcontinuation\n\t- nested\n",
4439        ] {
4440            let warnings = lint(content);
4441            assert!(warnings.is_empty(), "{content:?}: got {warnings:?}");
4442            assert_eq!(fix(content), content, "{content:?}");
4443        }
4444
4445        // Control: a marker one space in falls short of the content column,
4446        // so it starts a new list of another type against the first.
4447        for content in ["* item text\n 1. nested\n", "> * item text\n>  1. nested\n"] {
4448            let warnings = lint(content);
4449            assert_eq!(
4450                warnings
4451                    .iter()
4452                    .map(|w| (w.line, w.message.as_str()))
4453                    .collect::<Vec<_>>(),
4454                vec![
4455                    (1, "List should be followed by blank line"),
4456                    (2, "List should be preceded by blank line"),
4457                ],
4458                "{content:?}: got {warnings:?}"
4459            );
4460        }
4461    }
4462
4463    #[test]
4464    fn test_list_marker_inside_an_unclosed_html_block_is_html() {
4465        // A block opened by a tag such as `<div>` runs to the next blank line,
4466        // so a marker line before that blank is the block's content and not a
4467        // list: the list before the block still needs its blank line, the
4468        // marker line needs nothing, at the root and inside a blockquote.
4469        for (content, expected) in [
4470            (
4471                "- item\n<div>\nx\n</div>\n- next\n",
4472                "- item\n\n<div>\nx\n</div>\n- next\n",
4473            ),
4474            (
4475                "> - item\n> <div>\n> x\n> </div>\n> - next\n",
4476                "> - item\n>\n> <div>\n> x\n> </div>\n> - next\n",
4477            ),
4478        ] {
4479            let warnings = lint(content);
4480            assert_eq!(
4481                warnings
4482                    .iter()
4483                    .map(|w| (w.line, w.message.as_str()))
4484                    .collect::<Vec<_>>(),
4485                vec![(1, "List should be followed by blank line")],
4486                "{content:?}: got {warnings:?}"
4487            );
4488            assert_eq!(fix(content), expected, "{content:?}");
4489        }
4490    }
4491
4492    #[test]
4493    fn test_html_block_at_content_column_is_item_content() {
4494        // The same tags indented to the content column are the item's own
4495        // content, so the list runs through them and nothing is reported.
4496        for content in [
4497            "- item\n  <script>\n  x\n  </script>\n- next\n",
4498            "- item\n  <div>\n  x\n  </div>\n- next\n",
4499            "1. item\n   <pre>\n   x\n   </pre>\n2. next\n",
4500            "> - item\n>   <div>\n>   x\n>   </div>\n> - next\n",
4501        ] {
4502            assert!(lint(content).is_empty(), "{content:?}: got {:?}", lint(content));
4503            assert_eq!(fix(content), content, "{content:?}");
4504        }
4505    }
4506    #[test]
4507    fn test_ordered_fallback_fix_applies_advertised_insertions() {
4508        use crate::utils::fix_utils::apply_warning_fixes;
4509        let rule = MD032BlanksAroundLists::default();
4510        for body in [
4511            "First.\n3. Item\nLast.\n",
4512            "First.\n3. Item\n4. Second\nLast.\n",
4513            "First.\n3. Item.\n4. Second.\nLast.\n",
4514            "First.\n3. Item\n# Heading\n",
4515            "First.\n3. Item\nLast.\n\nSecond.\n8. Another\nTail.\n",
4516        ] {
4517            for ending in ["\n", "\r\n"] {
4518                for trailing in [true, false] {
4519                    let source = format!("- Real\n\n{body}");
4520                    let source = if trailing {
4521                        source
4522                    } else {
4523                        source.trim_end_matches('\n').to_string()
4524                    };
4525                    let source = source.replace('\n', ending);
4526                    let ctx = LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
4527                    let warnings = rule.check(&ctx).unwrap();
4528                    assert!(!warnings.is_empty());
4529                    let expected = apply_warning_fixes(&source, &warnings).unwrap();
4530                    assert_ne!(expected, source);
4531                    assert_eq!(rule.fix(&ctx).unwrap(), expected, "{source}");
4532                    let fixed_ctx = LintContext::new(&expected, crate::config::MarkdownFlavor::Standard, None);
4533                    assert!(rule.check(&fixed_ctx).unwrap().is_empty());
4534                    assert_eq!(rule.fix(&fixed_ctx).unwrap(), expected);
4535                }
4536            }
4537        }
4538    }
4539
4540    #[test]
4541    fn test_ordered_fallback_fix_keeps_literal_and_disabled_insertions_withheld() {
4542        use crate::utils::fix_utils::{apply_warning_fixes, filter_warnings_by_inline_config};
4543        let rule = MD032BlanksAroundLists::default();
4544        for (flavor, source) in [
4545            (
4546                crate::config::MarkdownFlavor::Standard,
4547                "{% set unused=\"First.\n3. Literal\nLast.\" %}{{ unused|length }}\n\n- Real\n",
4548            ),
4549            (
4550                crate::config::MarkdownFlavor::MDX,
4551                "export const text = `First.\n3. Literal\nLast.`\n\n- Real\n",
4552            ),
4553            (
4554                crate::config::MarkdownFlavor::Standard,
4555                "- Real\n\nFirst.\n<!-- rumdl-disable-next-line MD032 -->\n3. Item\nLast.\n",
4556            ),
4557            (
4558                crate::config::MarkdownFlavor::Standard,
4559                "- Real\n\nFirst.\n3. Item <!-- rumdl-disable-line MD032 -->\nLast.\n",
4560            ),
4561            (crate::config::MarkdownFlavor::Standard, "First.\n3. Item\nLast.\n"),
4562        ] {
4563            for ending in ["\n", "\r\n"] {
4564                let source = source.replace('\n', ending);
4565                let ctx = LintContext::new(&source, flavor, None);
4566                let warnings =
4567                    filter_warnings_by_inline_config(rule.check(&ctx).unwrap(), ctx.inline_config(), rule.name());
4568                assert!(warnings.is_empty(), "{source}: {warnings:?}");
4569                assert_eq!(apply_warning_fixes(&source, &warnings).unwrap(), source);
4570                assert_eq!(rule.fix(&ctx).unwrap(), source, "{source}");
4571            }
4572        }
4573    }
4574}
4575
4576#[cfg(test)]
4577mod configured_plan_regressions {
4578    use super::*;
4579
4580    #[test]
4581    fn cached_default_plan_never_crosses_rule_configuration() {
4582        for source in [
4583            "- item\nunindented continuation\n",
4584            "before\n\n- item\n\n3. third\n",
4585            "<!-- rumdl-configure-file {\"MD032\": {\"allow-lazy-continuation\": false}} -->\n- item\ntail\n",
4586        ] {
4587            for custom_first in [false, true] {
4588                let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
4589                let default = MD032BlanksAroundLists::default();
4590                let custom = MD032BlanksAroundLists {
4591                    config: MD032Config {
4592                        allow_lazy_continuation: false,
4593                    },
4594                };
4595                let rules = if custom_first {
4596                    [&custom, &default, &custom, &default]
4597                } else {
4598                    [&default, &custom, &default, &custom]
4599                };
4600                for rule in rules {
4601                    assert_eq!(rule.check(&ctx).unwrap(), rule.check_native(&ctx));
4602                    assert_eq!(
4603                        rule.fix(&ctx).unwrap(),
4604                        rule.fix_with_structure_impl(&ctx, &rule.withheld_fixes(&ctx))
4605                    );
4606                }
4607            }
4608        }
4609    }
4610}