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};
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 name(&self) -> &'static str {
851        "MD032"
852    }
853
854    fn description(&self) -> &'static str {
855        "Lists should be surrounded by blank lines"
856    }
857
858    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
859        let (mut warnings, separating_from) = self.unguarded_warnings(ctx);
860        drop_structure_changing_fixes(ctx, &mut warnings[separating_from..], Allowed::Nothing);
861        Ok(warnings)
862    }
863
864    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
865        Ok(self.fix_with_structure_impl(ctx, &self.withheld_fixes(ctx)))
866    }
867
868    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
869        // Skip if no list blocks exist (includes ordered and unordered lists)
870        // Note: list_blocks is pre-computed in LintContext, so this is already efficient
871        ctx.content.is_empty() || ctx.list_blocks.is_empty()
872    }
873
874    fn category(&self) -> RuleCategory {
875        RuleCategory::List
876    }
877
878    fn as_any(&self) -> &dyn std::any::Any {
879        self
880    }
881
882    crate::impl_rule_config_methods!(MD032Config);
883}
884
885impl MD032BlanksAroundLists {
886    /// Every warning with its fix as computed, and the index from which the
887    /// warnings separate lists that already parse as lists.
888    fn unguarded_warnings(&self, ctx: &crate::lint_context::LintContext) -> (Vec<LintWarning>, usize) {
889        let lines = ctx.raw_lines();
890        // Early return for empty content
891        if lines.is_empty() {
892            return (Vec::new(), 0);
893        }
894
895        let list_blocks = self.convert_list_blocks(ctx);
896
897        if list_blocks.is_empty() {
898            return (Vec::new(), 0);
899        }
900
901        let (mut warnings, separating_from) = self.perform_checks(ctx, lines, &list_blocks);
902
903        // When lazy continuation is not allowed, detect and warn about lazy continuation
904        // lines WITHIN list blocks (text that continues a list item but with less
905        // indentation than expected). Lazy continuation at the END of list blocks is
906        // already handled by the segment extension logic above.
907        if !self.config.allow_lazy_continuation {
908            let lazy_cont_lines = ctx.lazy_continuation_lines();
909            let coverage = ListCoverage::new(&list_blocks);
910
911            for lazy_info in lazy_cont_lines.iter() {
912                let line_num = lazy_info.line_num;
913
914                // Only warn about lazy continuation lines that are WITHIN a list block
915                // (i.e., between list items). End-of-block lazy continuation is already
916                // handled by the existing "list should be followed by blank line" logic.
917                if !Self::is_reportable_lazy_line(ctx, &coverage, line_num) {
918                    continue;
919                }
920
921                // Get the expected indent for context in the warning message
922                let line_content = lines.get(line_num.saturating_sub(1)).unwrap_or(&"");
923                let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line_content);
924
925                // Calculate fix: add proper indentation to the lazy continuation line
926                let fix = if Self::should_apply_lazy_fix(ctx, line_num) {
927                    Self::calculate_lazy_continuation_fix(ctx, line_num, lazy_info)
928                } else {
929                    None
930                };
931
932                warnings.push(LintWarning {
933                    line: start_line,
934                    column: start_col,
935                    end_line,
936                    end_column: end_col,
937                    severity: Severity::Warning,
938                    rule_name: Some(self.name().to_string()),
939                    message: "Lazy continuation line should be properly indented or preceded by blank line".to_string(),
940                    fix,
941                });
942            }
943        }
944
945        (warnings, separating_from)
946    }
947
948    /// The fixes the structure guard withholds, as the line each one edits and
949    /// whether it inserts a blank line before that line (else it re-indents a
950    /// lazy continuation line). The fix path builds its own edits, so it reads
951    /// the guard's verdict from the warnings.
952    fn withheld_fixes(&self, ctx: &crate::lint_context::LintContext) -> std::collections::HashSet<(usize, bool)> {
953        let (unguarded, separating_from) = self.unguarded_warnings(ctx);
954        let mut guarded = unguarded[separating_from..].to_vec();
955        drop_structure_changing_fixes(ctx, &mut guarded, Allowed::Nothing);
956        unguarded[separating_from..]
957            .iter()
958            .zip(&guarded)
959            .filter(|(_, after)| after.fix.is_none())
960            .filter_map(|(before, _)| before.fix.as_ref())
961            .map(|fix| {
962                let line = ctx.offset_to_line_col(fix.range.start).0;
963                (line, fix.range.is_empty() && fix.replacement.ends_with('\n'))
964            })
965            .collect()
966    }
967
968    /// Helper method for fixing implementation
969    fn fix_with_structure_impl(
970        &self,
971        ctx: &crate::lint_context::LintContext,
972        withheld: &std::collections::HashSet<(usize, bool)>,
973    ) -> String {
974        let lines = ctx.raw_lines();
975        let num_lines = lines.len();
976        if num_lines == 0 {
977            return String::new();
978        }
979
980        let list_blocks = self.convert_list_blocks(ctx);
981        if list_blocks.is_empty() {
982            return ctx.content.to_string();
983        }
984
985        // Phase 0: Collect lazy continuation line fixes (if not allowed)
986        // Map of line_num -> LazyContLine for applying fixes
987        let mut lazy_fixes: std::collections::BTreeMap<usize, LazyContLine> = std::collections::BTreeMap::new();
988        if !self.config.allow_lazy_continuation {
989            let lazy_cont_lines = ctx.lazy_continuation_lines();
990            let coverage = ListCoverage::new(&list_blocks);
991            for lazy_info in lazy_cont_lines.iter() {
992                let line_num = lazy_info.line_num;
993                // Only fix lines within a list block
994                if !Self::is_reportable_lazy_line(ctx, &coverage, line_num) {
995                    continue;
996                }
997                // Only fix if not in code block, front matter, or HTML comment
998                if !Self::should_apply_lazy_fix(ctx, line_num)
999                    || ctx.inline_config().is_rule_disabled(self.name(), line_num)
1000                {
1001                    continue;
1002                }
1003                if !withheld.contains(&(line_num, false)) {
1004                    lazy_fixes.insert(line_num, lazy_info.clone());
1005                }
1006            }
1007        }
1008
1009        let mut insertions: std::collections::BTreeMap<usize, String> = std::collections::BTreeMap::new();
1010
1011        // Phase 1: Identify needed insertions
1012        for &(start_line, end_line, ref prefix) in &list_blocks {
1013            let block_bq_level = prefix.chars().filter(|&c| c == '>').count();
1014            // Skip lists that start inside HTML/MDX comments
1015            if ctx
1016                .line_info(start_line)
1017                .is_some_and(|info| info.in_html_comment || info.in_mdx_comment)
1018            {
1019                continue;
1020            }
1021
1022            // Check before block
1023            if start_line > 1 && !ctx.inline_config().is_rule_disabled(self.name(), start_line) {
1024                // Look past HTML comments to find actual preceding content
1025                let (content_line, has_blank_separation) = Self::find_preceding_content(ctx, start_line);
1026
1027                // If blank separation exists (through HTML comments), no fix needed
1028                if !has_blank_separation && content_line > 0 {
1029                    let prev_line_str = lines[content_line - 1];
1030                    let is_prev_excluded = ctx
1031                        .line_info(content_line)
1032                        .is_some_and(|info| info.in_code_block || info.in_front_matter);
1033                    let prev_bq_level = parse_blockquote_prefix(prev_line_str).map_or(0, |bq| bq.nesting_level);
1034
1035                    let should_require = Self::should_require_blank_line_before(ctx, content_line, start_line);
1036                    // Compare depth so compact and spaced markers share the same context.
1037                    if !is_prev_excluded && prev_bq_level == block_bq_level && should_require {
1038                        // Use centralized helper for consistent blockquote prefix (no trailing space)
1039                        let bq_prefix = ctx.blockquote_prefix_for_blank_line(start_line - 1);
1040                        if !withheld.contains(&(start_line, true)) {
1041                            insertions.insert(start_line, bq_prefix);
1042                        }
1043                    }
1044                }
1045            }
1046
1047            // Check after block
1048            if end_line < num_lines
1049                && !ctx.inline_config().is_rule_disabled(self.name(), end_line)
1050                && !Self::block_ends_in_comment_line(lines, end_line)
1051            {
1052                // Look past HTML comments to find actual following content
1053                let (content_line, has_blank_separation) = Self::find_following_content(ctx, end_line);
1054
1055                // If blank separation exists (through HTML comments), no fix needed
1056                if !has_blank_separation && content_line > 0 {
1057                    let next_line_str = lines[content_line - 1];
1058                    // Match check(): standalone code blocks need separation, but
1059                    // indented code inside a list item must stay attached to it.
1060                    let is_next_excluded = Self::is_following_content_excluded(ctx, content_line, prefix);
1061                    let next_line_bq_level = parse_blockquote_prefix(next_line_str).map_or(0, |bq| bq.nesting_level);
1062
1063                    // Check blockquote levels to detect boundary transitions
1064                    let end_line_str = lines[end_line - 1];
1065                    let end_line_bq_level = parse_blockquote_prefix(end_line_str).map_or(0, |bq| bq.nesting_level);
1066                    let exits_blockquote = end_line_bq_level > 0 && next_line_bq_level < end_line_bq_level;
1067
1068                    // Compare depth so compact and spaced markers share the same context.
1069                    // Skip if exiting a blockquote - boundary provides separation
1070                    if !is_next_excluded && next_line_bq_level == block_bq_level && !exits_blockquote {
1071                        // Use centralized helper for consistent blockquote prefix (no trailing space)
1072                        let bq_prefix = Self::prefix_for_blank_after(ctx, end_line, content_line, block_bq_level);
1073                        if !withheld.contains(&(end_line + 1, true)) {
1074                            insertions.insert(end_line + 1, bq_prefix);
1075                        }
1076                    }
1077                }
1078            }
1079        }
1080
1081        if insertions.is_empty() && lazy_fixes.is_empty() {
1082            return ctx.content.to_string();
1083        }
1084
1085        // Phase 2: Reconstruct with insertions and lazy fixes
1086        let mut result_lines: Vec<String> = Vec::with_capacity(num_lines + insertions.len());
1087        for (i, line) in lines.iter().enumerate() {
1088            let current_line_num = i + 1;
1089            if let Some(prefix_to_insert) = insertions.get(&current_line_num)
1090                && (result_lines.is_empty() || result_lines.last().unwrap() != prefix_to_insert)
1091            {
1092                result_lines.push(prefix_to_insert.clone());
1093            }
1094
1095            // Disabled lines were excluded when collecting fixes.
1096            if let Some(lazy_info) = lazy_fixes.get(&current_line_num) {
1097                let fixed_line = Self::apply_lazy_fix_to_line(line, lazy_info);
1098                result_lines.push(fixed_line);
1099            } else {
1100                result_lines.push(line.to_string());
1101            }
1102        }
1103
1104        // Preserve the final newline if the original content had one
1105        let line_ending = crate::utils::detect_line_ending(ctx.content);
1106        let mut result = result_lines.join(line_ending);
1107        if ctx.content.ends_with('\n') {
1108            result.push_str(line_ending);
1109        }
1110        result
1111    }
1112}
1113
1114// Checks if a line is blank, considering compact and spaced blockquote markers.
1115// A line contributing nothing but HTML comments counts as blank too (#866).
1116fn is_blank_in_context(line: &str) -> bool {
1117    parse_blockquote_prefix(line)
1118        .map_or(line, |bq| bq.content)
1119        .trim()
1120        .is_empty()
1121        || crate::utils::blank_lines::is_blank_or_comment_only(line)
1122}
1123
1124#[cfg(test)]
1125mod tests {
1126    use super::*;
1127    use crate::lint_context::LintContext;
1128    use crate::rule::Rule;
1129
1130    #[test]
1131    fn test_list_coverage_matches_every_block_whatever_their_order() {
1132        // Out of order, overlapping, one nested inside another and ending
1133        // before it, one a single line, with gaps between them.
1134        let blocks: Vec<(usize, usize, String)> = [(20, 22), (3, 10), (5, 6), (9, 14), (30, 30), (17, 17)]
1135            .into_iter()
1136            .map(|(start, end)| (start, end, String::new()))
1137            .collect();
1138        let coverage = ListCoverage::new(&blocks);
1139        for line in 0..=35 {
1140            let expected = blocks.iter().any(|&(start, end, _)| start <= line && line <= end);
1141            assert_eq!(coverage.contains(line), expected, "line {line}");
1142        }
1143        assert!(!ListCoverage::new(&[]).contains(1));
1144    }
1145
1146    fn lint(content: &str) -> Vec<LintWarning> {
1147        let rule = MD032BlanksAroundLists::default();
1148        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1149        rule.check(&ctx).expect("Lint check failed")
1150    }
1151
1152    fn fix(content: &str) -> String {
1153        let rule = MD032BlanksAroundLists::default();
1154        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1155        rule.fix(&ctx).expect("Lint fix failed")
1156    }
1157
1158    #[test]
1159    fn test_spaced_nested_blockquotes_list_separation() {
1160        for (list_prefix, surrounding_prefix) in [
1161            ("> >", "> >"),
1162            (">  >", ">  >"),
1163            ("> > >", "> > >"),
1164            ("> >", ">>"),
1165            (">>", "> >"),
1166        ] {
1167            let content = format!(
1168                "{surrounding_prefix} Introduction\n{list_prefix} - item\n{surrounding_prefix} ~~~\n{surrounding_prefix} code\n{surrounding_prefix} ~~~\n"
1169            );
1170            let expected = format!(
1171                "{surrounding_prefix} Introduction\n{list_prefix}\n{list_prefix} - item\n{list_prefix}\n{surrounding_prefix} ~~~\n{surrounding_prefix} code\n{surrounding_prefix} ~~~\n"
1172            );
1173            let warnings = lint(&content);
1174            assert_eq!(warnings.len(), 2, "{content:?}: {warnings:?}");
1175            assert!(warnings.iter().all(|warning| warning.line == 2));
1176            let mut edited = content.clone();
1177            for warning in warnings.iter().rev() {
1178                let edit = warning.fix.as_ref().expect("missing diagnostic fix");
1179                edited.replace_range(edit.range.clone(), &edit.replacement);
1180            }
1181            assert_eq!(edited, expected, "Diagnostic fixes must preserve marker spacing");
1182            assert_eq!(fix(&content), expected);
1183            assert!(lint(&expected).is_empty(), "{expected:?}: {:?}", lint(&expected));
1184            assert_eq!(fix(&expected), expected, "Fix must be idempotent");
1185        }
1186    }
1187
1188    #[test]
1189    fn test_spaced_nested_blockquotes_preserve_list_code_and_exits() {
1190        for content in [
1191            "> > - item\n> >   ```\n> >   code\n> >   ```\n",
1192            "> > 1. item\n> >    ~~~\n> >    code\n> >    ~~~\n",
1193            "> > - item\n> ~~~\n> code\n> ~~~\n",
1194            "> > - item\n~~~\ncode\n~~~\n",
1195            "> > - item\n>> - next item\n",
1196        ] {
1197            assert!(lint(content).is_empty(), "{content:?}: {:?}", lint(content));
1198            assert_eq!(fix(content), content);
1199        }
1200    }
1201
1202    #[test]
1203    fn test_fix_after_a_list_split_by_a_fence_keeps_its_continuation_lines() {
1204        // A fence at the margin ends the list, and the lines before it, indented
1205        // or lazy, still belong to the last item's paragraph.
1206        for (content, expected) in [
1207            (
1208                "1. a\n2. b\n   c\n```\nx\n```\n3. d\n",
1209                "1. a\n2. b\n   c\n\n```\nx\n```\n3. d\n",
1210            ),
1211            (
1212                "* a\n```\n```\n* b\n](x)\n\ntext\n",
1213                "* a\n\n```\n```\n* b\n](x)\n\ntext\n",
1214            ),
1215        ] {
1216            assert_eq!(fix(content), expected, "{content:?}");
1217        }
1218        let warnings = lint("1. a\n2. b\n   c\n```\nx\n```\n3. d\n");
1219        assert_eq!(warnings.iter().map(|w| w.line).collect::<Vec<_>>(), vec![3]);
1220    }
1221
1222    #[test]
1223    fn test_backtick_line_short_of_the_content_column_is_lazy_text() {
1224        // Four columns of indent outside the item make the backticks paragraph
1225        // text rather than a fence, so the list runs on through them.
1226        let content = "   1.  a\n    ```\n    code\n";
1227        assert!(lint(content).is_empty(), "{:?}", lint(content));
1228        assert_eq!(fix(content), content);
1229    }
1230
1231    #[test]
1232    fn test_blank_after_a_quoted_list_ending_in_lazy_text_stays_in_the_quote() {
1233        let content = "> 1. a\nlazy\n> - b\n";
1234        let warnings = lint(content);
1235        assert_eq!(warnings.len(), 1, "{warnings:?}");
1236        assert_eq!(fix(content), "> 1. a\nlazy\n>\n> - b\n");
1237    }
1238
1239    #[test]
1240    fn test_fix_never_changes_how_the_lists_parse() {
1241        // Items indented between two content columns, or quoted apart from
1242        // the list before them, are read as one list by the parser and as
1243        // several by the list model. A blank line between them would make the
1244        // list loose or move items into another list, so those fixes are
1245        // withheld while the fixes that only separate lists stay.
1246        for (content, expected) in [
1247            ("1.   item 41\n   +  item 51\n+ item 52\n", None),
1248            (" *   item 17\n   10.  item 21\n1. item 22\n", None),
1249            ("-  item 6\n>         ```\n   10. item 23\n 1.  item 24\n", None),
1250            (
1251                "   10. item 43\n>   +  item 47\n>  continuation text\n>      1. item 50\n",
1252                Some("   10. item 43\n>\n>   +  item 47\n>  continuation text\n>      1. item 50\n"),
1253            ),
1254            (
1255                "   1.  item 63\n>    +  item 65\n> +   item 66\n",
1256                Some("   1.  item 63\n>\n>    +  item 65\n> +   item 66\n"),
1257            ),
1258        ] {
1259            let expected = expected.unwrap_or(content);
1260            assert_eq!(fix(content), expected, "{content:?}");
1261            let fixable = lint(content).iter().filter(|w| w.fix.is_some()).count();
1262            assert_eq!(fixable, usize::from(expected != content), "{content:?}");
1263        }
1264    }
1265
1266    #[test]
1267    fn test_fix_separates_list_from_standalone_code_fence() {
1268        for (content, expected) in [
1269            (
1270                "# Test\n\n>   - List item 1\n>   - List item 2\n> ```\n> code\n> ```\n",
1271                "# Test\n\n>   - List item 1\n>   - List item 2\n>\n> ```\n> code\n> ```\n",
1272            ),
1273            ("- item\n```rust\ncode\n```\n", "- item\n\n```rust\ncode\n```\n"),
1274            ("1. item\n~~~\ncode\n~~~", "1. item\n\n~~~\ncode\n~~~"),
1275            (
1276                ">> - item\n>> ~~~\n>> code\n>> ~~~\n",
1277                ">> - item\n>>\n>> ~~~\n>> code\n>> ~~~\n",
1278            ),
1279        ] {
1280            let warnings = lint(content);
1281            assert_eq!(warnings.len(), 1, "{content:?}: {warnings:?}");
1282            assert_eq!(warnings[0].message, "List should be followed by blank line");
1283            let edit = warnings[0].fix.as_ref().expect("missing warning fix");
1284            let mut edited = content.to_string();
1285            edited.replace_range(edit.range.clone(), &edit.replacement);
1286            assert_eq!(edited, expected, "Diagnostic and document fixes must agree");
1287            assert_eq!(fix(content), expected, "{content:?}");
1288            assert!(lint(expected).is_empty(), "{expected:?}");
1289            assert_eq!(fix(expected), expected, "Fix must be idempotent");
1290        }
1291    }
1292
1293    #[test]
1294    fn test_fix_preserves_code_fence_inside_list_item() {
1295        for content in [
1296            "- item\n  ```\n  code\n  ```\n",
1297            "1. item\n   ~~~\n   code\n   ~~~\n",
1298            "> - item\n>   ```\n>   code\n>   ```\n",
1299        ] {
1300            assert!(lint(content).is_empty(), "{content:?}: {:?}", lint(content));
1301            assert_eq!(fix(content), content, "A nested fence must stay inside its list item");
1302        }
1303    }
1304
1305    #[test]
1306    fn test_fix_does_not_split_item_before_different_list_type() {
1307        // The continuation line belongs to the bullet item. Only one blank line
1308        // is needed, between the two lists; inserting one after the marker line
1309        // would split the item into a list plus a stray paragraph.
1310        let content = "- alpha beta\n  aligned\n1. ordered item\n   cont\n";
1311        assert_eq!(fix(content), "- alpha beta\n  aligned\n\n1. ordered item\n   cont\n");
1312
1313        // check() anchors the boundary on the first list's last line and the
1314        // second list's first line, exactly as it does when neither item wraps.
1315        let warnings = lint(content);
1316        assert_eq!(warnings.len(), 2);
1317        assert_eq!(warnings[0].line, 2);
1318        assert_eq!(warnings[1].line, 3);
1319    }
1320
1321    #[test]
1322    fn test_fix_does_not_split_blockquoted_item_before_different_list_type() {
1323        let content = "> - alpha beta\n>   aligned\n> 1. ordered item\n";
1324        assert_eq!(fix(content), "> - alpha beta\n>   aligned\n>\n> 1. ordered item\n");
1325    }
1326
1327    #[test]
1328    fn test_fix_keeps_lazy_continuation_with_its_item() {
1329        // Per CommonMark the lazy line continues the item's paragraph, so the
1330        // blank belongs after it, before the new list. Splitting it off would
1331        // promote it to a standalone paragraph and change the rendering.
1332        let content = "- alpha beta\nlazy\n1. ordered item\n";
1333        assert_eq!(fix(content), "- alpha beta\nlazy\n\n1. ordered item\n");
1334
1335        let warnings = lint(content);
1336        assert_eq!(warnings.len(), 2);
1337        assert_eq!(warnings[0].line, 2);
1338        assert_eq!(warnings[1].line, 3);
1339    }
1340
1341    #[test]
1342    fn test_fix_keeps_blockquoted_lazy_continuation_with_its_item() {
1343        let content = "> - alpha beta\n> lazy\n> 1. ordered item\n";
1344        assert_eq!(fix(content), "> - alpha beta\n> lazy\n>\n> 1. ordered item\n");
1345    }
1346
1347    #[test]
1348    fn test_fix_indents_lazy_continuation_when_not_allowed() {
1349        // With allow_lazy_continuation = false the lazy line is first indented
1350        // into the item (Phase 0), then the blank separates the two lists.
1351        let rule = MD032BlanksAroundLists::from_config_struct(MD032Config {
1352            allow_lazy_continuation: false,
1353        });
1354        let content = "- alpha beta\nlazy\n1. ordered item\n";
1355        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1356        let fixed = rule.fix(&ctx).expect("Lint fix failed");
1357        assert_eq!(fixed, "- alpha beta\n  lazy\n\n1. ordered item\n");
1358    }
1359
1360    #[test]
1361    fn test_div_closer_after_list_is_not_a_lazy_continuation_in_quarto() {
1362        // On a Pandoc-compatible flavor a `:::` line is the div's fence, not
1363        // prose continuing the last item, so `allow_lazy_continuation = false`
1364        // must neither report it nor indent it into the item.
1365        let rule = MD032BlanksAroundLists::from_config_struct(MD032Config {
1366            allow_lazy_continuation: false,
1367        });
1368        let content = "::: callout-note\n- List item 1\n- List item 2\n:::\n";
1369        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1370        let warnings = rule.check(&ctx).expect("Lint check failed");
1371        assert!(warnings.is_empty(), "Expected no warnings, got: {warnings:?}");
1372        let fixed = rule.fix(&ctx).expect("Lint fix failed");
1373        assert_eq!(fixed, content);
1374    }
1375
1376    #[test]
1377    fn test_prose_after_list_in_quarto_div_is_still_a_lazy_continuation() {
1378        // Positive control for the div-marker exemption: real prose under the
1379        // item inside a div is still indented when lazy continuation is off,
1380        // and the closing fence that follows it stays where it is.
1381        let rule = MD032BlanksAroundLists::from_config_struct(MD032Config {
1382            allow_lazy_continuation: false,
1383        });
1384        let content = "::: callout-note\n- List item 1\nlazy\n:::\n";
1385        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1386        let warnings = rule.check(&ctx).expect("Lint check failed");
1387        assert_eq!(
1388            warnings.len(),
1389            1,
1390            "Expected one lazy-continuation warning, got: {warnings:?}"
1391        );
1392        assert_eq!(warnings[0].line, 3);
1393        let fixed = rule.fix(&ctx).expect("Lint fix failed");
1394        assert_eq!(fixed, "::: callout-note\n- List item 1\n  lazy\n:::\n");
1395    }
1396
1397    #[test]
1398    fn test_div_closer_after_list_is_a_lazy_continuation_in_standard() {
1399        // Outside the Pandoc-compatible flavors `:::` is ordinary text, so it
1400        // lazily continues the item exactly as CommonMark reads it. Indented
1401        // into the item it would start a definition list description, so the
1402        // warning comes without a fix.
1403        let rule = MD032BlanksAroundLists::from_config_struct(MD032Config {
1404            allow_lazy_continuation: false,
1405        });
1406        let content = "Intro\n\n- List item 1\n- List item 2\n:::\n";
1407        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1408        let warnings = rule.check(&ctx).unwrap();
1409        assert_eq!(warnings.len(), 1, "{warnings:?}");
1410        assert_eq!(warnings[0].line, 5);
1411        assert!(warnings[0].fix.is_none(), "{warnings:?}");
1412        assert_eq!(rule.fix(&ctx).unwrap(), content);
1413
1414        let content = "Intro\n\n- List item 1\n- List item 2\nlazy\n";
1415        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1416        assert_eq!(
1417            rule.fix(&ctx).unwrap(),
1418            "Intro\n\n- List item 1\n- List item 2\n  lazy\n"
1419        );
1420    }
1421
1422    // Test that warnings include Fix objects
1423    fn check_warnings_have_fixes(content: &str) {
1424        let warnings = lint(content);
1425        for warning in &warnings {
1426            assert!(warning.fix.is_some(), "Warning should have fix: {warning:?}");
1427        }
1428    }
1429
1430    #[test]
1431    fn test_list_at_start() {
1432        // Per markdownlint-cli: trailing text without blank line is treated as lazy continuation
1433        // so NO warning is expected here
1434        let content = "- Item 1\n- Item 2\nText";
1435        let warnings = lint(content);
1436        assert_eq!(
1437            warnings.len(),
1438            0,
1439            "Trailing text is lazy continuation per CommonMark - no warning expected"
1440        );
1441    }
1442
1443    #[test]
1444    fn test_list_at_end() {
1445        let content = "Text\n- Item 1\n- Item 2";
1446        let warnings = lint(content);
1447        assert_eq!(
1448            warnings.len(),
1449            1,
1450            "Expected 1 warning for list at end without preceding blank line"
1451        );
1452        assert_eq!(
1453            warnings[0].line, 2,
1454            "Warning should be on the first line of the list (line 2)"
1455        );
1456        assert!(warnings[0].message.contains("preceded by blank line"));
1457
1458        // Test that warning has fix
1459        check_warnings_have_fixes(content);
1460
1461        let fixed_content = fix(content);
1462        assert_eq!(fixed_content, "Text\n\n- Item 1\n- Item 2");
1463
1464        // Verify fix resolves the issue
1465        let warnings_after_fix = lint(&fixed_content);
1466        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1467    }
1468
1469    #[test]
1470    fn test_list_in_middle() {
1471        // Per markdownlint-cli: only preceding blank line is required
1472        // Trailing text is treated as lazy continuation
1473        let content = "Text 1\n- Item 1\n- Item 2\nText 2";
1474        let warnings = lint(content);
1475        assert_eq!(
1476            warnings.len(),
1477            1,
1478            "Expected 1 warning for list needing preceding blank line (trailing text is lazy continuation)"
1479        );
1480        assert_eq!(warnings[0].line, 2, "Warning on line 2 (start)");
1481        assert!(warnings[0].message.contains("preceded by blank line"));
1482
1483        // Test that warnings have fixes
1484        check_warnings_have_fixes(content);
1485
1486        let fixed_content = fix(content);
1487        assert_eq!(fixed_content, "Text 1\n\n- Item 1\n- Item 2\nText 2");
1488
1489        // Verify fix resolves the issue
1490        let warnings_after_fix = lint(&fixed_content);
1491        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1492    }
1493
1494    #[test]
1495    fn test_correct_spacing() {
1496        let content = "Text 1\n\n- Item 1\n- Item 2\n\nText 2";
1497        let warnings = lint(content);
1498        assert_eq!(warnings.len(), 0, "Expected no warnings for correctly spaced list");
1499
1500        let fixed_content = fix(content);
1501        assert_eq!(fixed_content, content, "Fix should not change correctly spaced content");
1502    }
1503
1504    #[test]
1505    fn test_list_with_content() {
1506        // Per markdownlint-cli: only preceding blank line warning
1507        // Trailing text is lazy continuation
1508        let content = "Text\n* Item 1\n  Content\n* Item 2\n  More content\nText";
1509        let warnings = lint(content);
1510        assert_eq!(
1511            warnings.len(),
1512            1,
1513            "Expected 1 warning for list needing preceding blank line. Got: {warnings:?}"
1514        );
1515        assert_eq!(warnings[0].line, 2, "Warning should be on line 2 (start)");
1516        assert!(warnings[0].message.contains("preceded by blank line"));
1517
1518        // Test that warnings have fixes
1519        check_warnings_have_fixes(content);
1520
1521        let fixed_content = fix(content);
1522        let expected_fixed = "Text\n\n* Item 1\n  Content\n* Item 2\n  More content\nText";
1523        assert_eq!(
1524            fixed_content, expected_fixed,
1525            "Fix did not produce the expected output. Got:\n{fixed_content}"
1526        );
1527
1528        // Verify fix resolves the issue
1529        let warnings_after_fix = lint(&fixed_content);
1530        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1531    }
1532
1533    #[test]
1534    fn test_nested_list() {
1535        // Per markdownlint-cli: only preceding blank line warning
1536        let content = "Text\n- Item 1\n  - Nested 1\n- Item 2\nText";
1537        let warnings = lint(content);
1538        assert_eq!(
1539            warnings.len(),
1540            1,
1541            "Nested list block needs preceding blank only. Got: {warnings:?}"
1542        );
1543        assert_eq!(warnings[0].line, 2);
1544        assert!(warnings[0].message.contains("preceded by blank line"));
1545
1546        // Test that warnings have fixes
1547        check_warnings_have_fixes(content);
1548
1549        let fixed_content = fix(content);
1550        assert_eq!(fixed_content, "Text\n\n- Item 1\n  - Nested 1\n- Item 2\nText");
1551
1552        // Verify fix resolves the issue
1553        let warnings_after_fix = lint(&fixed_content);
1554        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1555    }
1556
1557    #[test]
1558    fn test_list_with_internal_blanks() {
1559        // Per markdownlint-cli: only preceding blank line warning
1560        let content = "Text\n* Item 1\n\n  More Item 1 Content\n* Item 2\nText";
1561        let warnings = lint(content);
1562        assert_eq!(
1563            warnings.len(),
1564            1,
1565            "List with internal blanks needs preceding blank only. Got: {warnings:?}"
1566        );
1567        assert_eq!(warnings[0].line, 2);
1568        assert!(warnings[0].message.contains("preceded by blank line"));
1569
1570        // Test that warnings have fixes
1571        check_warnings_have_fixes(content);
1572
1573        let fixed_content = fix(content);
1574        assert_eq!(
1575            fixed_content,
1576            "Text\n\n* Item 1\n\n  More Item 1 Content\n* Item 2\nText"
1577        );
1578
1579        // Verify fix resolves the issue
1580        let warnings_after_fix = lint(&fixed_content);
1581        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1582    }
1583
1584    #[test]
1585    fn test_ignore_code_blocks() {
1586        let content = "```\n- Not a list item\n```\nText";
1587        let warnings = lint(content);
1588        assert_eq!(warnings.len(), 0);
1589        let fixed_content = fix(content);
1590        assert_eq!(fixed_content, content);
1591    }
1592
1593    #[test]
1594    fn test_ignore_front_matter() {
1595        // Per markdownlint-cli: NO warnings - front matter is followed by list, trailing text is lazy continuation
1596        let content = "---\ntitle: Test\n---\n- List Item\nText";
1597        let warnings = lint(content);
1598        assert_eq!(
1599            warnings.len(),
1600            0,
1601            "Front matter test should have no MD032 warnings. Got: {warnings:?}"
1602        );
1603
1604        // No fixes needed since no warnings
1605        let fixed_content = fix(content);
1606        assert_eq!(fixed_content, content, "No changes when no warnings");
1607    }
1608
1609    #[test]
1610    fn test_multiple_lists() {
1611        // Our implementation treats "Text 2" and "Text 3" as lazy continuation within a single merged list block
1612        // (since both - and * are unordered markers and there's no structural separator)
1613        // markdownlint-cli sees them as separate lists with 3 warnings, but our behavior differs.
1614        // The key requirement is that the fix resolves all warnings.
1615        let content = "Text\n- List 1 Item 1\n- List 1 Item 2\nText 2\n* List 2 Item 1\nText 3";
1616        let warnings = lint(content);
1617        // At minimum we should warn about missing preceding blank for line 2
1618        assert!(
1619            !warnings.is_empty(),
1620            "Should have at least one warning for missing blank line. Got: {warnings:?}"
1621        );
1622
1623        // Test that warnings have fixes
1624        check_warnings_have_fixes(content);
1625
1626        let fixed_content = fix(content);
1627        // The fix should add blank lines before lists that need them
1628        let warnings_after_fix = lint(&fixed_content);
1629        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1630    }
1631
1632    #[test]
1633    fn test_adjacent_lists() {
1634        let content = "- List 1\n\n* List 2";
1635        let warnings = lint(content);
1636        assert_eq!(warnings.len(), 0);
1637        let fixed_content = fix(content);
1638        assert_eq!(fixed_content, content);
1639    }
1640
1641    #[test]
1642    fn test_list_in_blockquote() {
1643        // Per markdownlint-cli: 1 warning (preceding only, trailing is lazy continuation)
1644        let content = "> Quote line 1\n> - List item 1\n> - List item 2\n> Quote line 2";
1645        let warnings = lint(content);
1646        assert_eq!(
1647            warnings.len(),
1648            1,
1649            "Expected 1 warning for blockquoted list needing preceding blank. Got: {warnings:?}"
1650        );
1651        assert_eq!(warnings[0].line, 2);
1652
1653        // Test that warnings have fixes
1654        check_warnings_have_fixes(content);
1655
1656        let fixed_content = fix(content);
1657        // Fix should add blank line before list only (no trailing space per markdownlint-cli)
1658        assert_eq!(
1659            fixed_content, "> Quote line 1\n>\n> - List item 1\n> - List item 2\n> Quote line 2",
1660            "Fix for blockquoted list failed. Got:\n{fixed_content}"
1661        );
1662
1663        // Verify fix resolves the issue
1664        let warnings_after_fix = lint(&fixed_content);
1665        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1666    }
1667
1668    #[test]
1669    fn test_ordered_list() {
1670        // Per markdownlint-cli: 1 warning (preceding only)
1671        let content = "Text\n1. Item 1\n2. Item 2\nText";
1672        let warnings = lint(content);
1673        assert_eq!(warnings.len(), 1);
1674
1675        // Test that warnings have fixes
1676        check_warnings_have_fixes(content);
1677
1678        let fixed_content = fix(content);
1679        assert_eq!(fixed_content, "Text\n\n1. Item 1\n2. Item 2\nText");
1680
1681        // Verify fix resolves the issue
1682        let warnings_after_fix = lint(&fixed_content);
1683        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1684    }
1685
1686    #[test]
1687    fn test_no_double_blank_fix() {
1688        // Per markdownlint-cli: trailing text is lazy continuation, so NO warning needed
1689        let content = "Text\n\n- Item 1\n- Item 2\nText"; // Has preceding blank, trailing is lazy
1690        let warnings = lint(content);
1691        assert_eq!(
1692            warnings.len(),
1693            0,
1694            "Should have no warnings - properly preceded, trailing is lazy"
1695        );
1696
1697        let fixed_content = fix(content);
1698        assert_eq!(
1699            fixed_content, content,
1700            "No fix needed when no warnings. Got:\n{fixed_content}"
1701        );
1702
1703        let content2 = "Text\n- Item 1\n- Item 2\n\nText"; // Missing blank before
1704        let warnings2 = lint(content2);
1705        assert_eq!(warnings2.len(), 1);
1706        if !warnings2.is_empty() {
1707            assert_eq!(
1708                warnings2[0].line, 2,
1709                "Warning line for missing blank before should be the first line of the block"
1710            );
1711        }
1712
1713        // Test that warnings have fixes
1714        check_warnings_have_fixes(content2);
1715
1716        let fixed_content2 = fix(content2);
1717        assert_eq!(
1718            fixed_content2, "Text\n\n- Item 1\n- Item 2\n\nText",
1719            "Fix added extra blank before. Got:\n{fixed_content2}"
1720        );
1721    }
1722
1723    #[test]
1724    fn test_empty_input() {
1725        let content = "";
1726        let warnings = lint(content);
1727        assert_eq!(warnings.len(), 0);
1728        let fixed_content = fix(content);
1729        assert_eq!(fixed_content, "");
1730    }
1731
1732    #[test]
1733    fn test_only_list() {
1734        let content = "- Item 1\n- Item 2";
1735        let warnings = lint(content);
1736        assert_eq!(warnings.len(), 0);
1737        let fixed_content = fix(content);
1738        assert_eq!(fixed_content, content);
1739    }
1740
1741    // === COMPREHENSIVE FIX TESTS ===
1742
1743    #[test]
1744    fn test_fix_complex_nested_blockquote() {
1745        // Per markdownlint-cli: 1 warning (preceding only)
1746        let content = "> Text before\n> - Item 1\n>   - Nested item\n> - Item 2\n> Text after";
1747        let warnings = lint(content);
1748        assert_eq!(
1749            warnings.len(),
1750            1,
1751            "Should warn for missing preceding blank only. Got: {warnings:?}"
1752        );
1753
1754        // Test that warnings have fixes
1755        check_warnings_have_fixes(content);
1756
1757        let fixed_content = fix(content);
1758        // Per markdownlint-cli, blank lines in blockquotes have no trailing space
1759        let expected = "> Text before\n>\n> - Item 1\n>   - Nested item\n> - Item 2\n> Text after";
1760        assert_eq!(fixed_content, expected, "Fix should preserve blockquote structure");
1761
1762        let warnings_after_fix = lint(&fixed_content);
1763        assert_eq!(warnings_after_fix.len(), 0, "Fix should eliminate all warnings");
1764    }
1765
1766    #[test]
1767    fn test_fix_mixed_list_markers() {
1768        // Per markdownlint-cli: mixed markers may be treated as separate lists
1769        // The exact behavior depends on implementation details
1770        let content = "Text\n- Item 1\n* Item 2\n+ Item 3\nText";
1771        let warnings = lint(content);
1772        // At minimum, there should be a warning for the first list needing preceding blank
1773        assert!(
1774            !warnings.is_empty(),
1775            "Should have at least 1 warning for mixed marker list. Got: {warnings:?}"
1776        );
1777
1778        // Test that warnings have fixes
1779        check_warnings_have_fixes(content);
1780
1781        let fixed_content = fix(content);
1782        // The fix should add at least a blank line before the first list
1783        assert!(
1784            fixed_content.contains("Text\n\n-"),
1785            "Fix should add blank line before first list item"
1786        );
1787
1788        // Verify fix resolves the issue
1789        let warnings_after_fix = lint(&fixed_content);
1790        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1791    }
1792
1793    #[test]
1794    fn test_fix_ordered_list_with_different_numbers() {
1795        // Per markdownlint-cli: 1 warning (preceding only)
1796        let content = "Text\n1. First\n3. Third\n2. Second\nText";
1797        let warnings = lint(content);
1798        assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1799
1800        // Test that warnings have fixes
1801        check_warnings_have_fixes(content);
1802
1803        let fixed_content = fix(content);
1804        let expected = "Text\n\n1. First\n3. Third\n2. Second\nText";
1805        assert_eq!(
1806            fixed_content, expected,
1807            "Fix should handle ordered lists with non-sequential numbers"
1808        );
1809
1810        // Verify fix resolves the issue
1811        let warnings_after_fix = lint(&fixed_content);
1812        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1813    }
1814
1815    #[test]
1816    fn test_fix_list_with_code_blocks_inside() {
1817        // Per markdownlint-cli: 1 warning (preceding only)
1818        let content = "Text\n- Item 1\n  ```\n  code\n  ```\n- Item 2\nText";
1819        let warnings = lint(content);
1820        assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1821
1822        // Test that warnings have fixes
1823        check_warnings_have_fixes(content);
1824
1825        let fixed_content = fix(content);
1826        let expected = "Text\n\n- Item 1\n  ```\n  code\n  ```\n- Item 2\nText";
1827        assert_eq!(
1828            fixed_content, expected,
1829            "Fix should handle lists with internal code blocks"
1830        );
1831
1832        // Verify fix resolves the issue
1833        let warnings_after_fix = lint(&fixed_content);
1834        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1835    }
1836
1837    #[test]
1838    fn test_fix_deeply_nested_lists() {
1839        // Per markdownlint-cli: 1 warning (preceding only)
1840        let content = "Text\n- Level 1\n  - Level 2\n    - Level 3\n      - Level 4\n- Back to Level 1\nText";
1841        let warnings = lint(content);
1842        assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1843
1844        // Test that warnings have fixes
1845        check_warnings_have_fixes(content);
1846
1847        let fixed_content = fix(content);
1848        let expected = "Text\n\n- Level 1\n  - Level 2\n    - Level 3\n      - Level 4\n- Back to Level 1\nText";
1849        assert_eq!(fixed_content, expected, "Fix should handle deeply nested lists");
1850
1851        // Verify fix resolves the issue
1852        let warnings_after_fix = lint(&fixed_content);
1853        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1854    }
1855
1856    #[test]
1857    fn test_fix_list_with_multiline_items() {
1858        // Per markdownlint-cli: trailing "Text" at indent=0 is lazy continuation
1859        // Only the preceding blank line is required
1860        let content = "Text\n- Item 1\n  continues here\n  and here\n- Item 2\n  also continues\nText";
1861        let warnings = lint(content);
1862        assert_eq!(
1863            warnings.len(),
1864            1,
1865            "Should only warn for missing blank before list (trailing text is lazy continuation)"
1866        );
1867
1868        // Test that warnings have fixes
1869        check_warnings_have_fixes(content);
1870
1871        let fixed_content = fix(content);
1872        let expected = "Text\n\n- Item 1\n  continues here\n  and here\n- Item 2\n  also continues\nText";
1873        assert_eq!(fixed_content, expected, "Fix should add blank before list only");
1874
1875        // Verify fix resolves the issue
1876        let warnings_after_fix = lint(&fixed_content);
1877        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1878    }
1879
1880    #[test]
1881    fn test_fix_list_at_document_boundaries() {
1882        // List at very start
1883        let content1 = "- Item 1\n- Item 2";
1884        let warnings1 = lint(content1);
1885        assert_eq!(
1886            warnings1.len(),
1887            0,
1888            "List at document start should not need blank before"
1889        );
1890        let fixed1 = fix(content1);
1891        assert_eq!(fixed1, content1, "No fix needed for list at start");
1892
1893        // List at very end
1894        let content2 = "Text\n- Item 1\n- Item 2";
1895        let warnings2 = lint(content2);
1896        assert_eq!(warnings2.len(), 1, "List at document end should need blank before");
1897        check_warnings_have_fixes(content2);
1898        let fixed2 = fix(content2);
1899        assert_eq!(
1900            fixed2, "Text\n\n- Item 1\n- Item 2",
1901            "Should add blank before list at end"
1902        );
1903    }
1904
1905    #[test]
1906    fn test_fix_preserves_existing_blank_lines() {
1907        let content = "Text\n\n\n- Item 1\n- Item 2\n\n\nText";
1908        let warnings = lint(content);
1909        assert_eq!(warnings.len(), 0, "Multiple blank lines should be preserved");
1910        let fixed_content = fix(content);
1911        assert_eq!(fixed_content, content, "Fix should not modify already correct content");
1912    }
1913
1914    #[test]
1915    fn test_fix_handles_tabs_and_spaces() {
1916        // Tab at line start = 4 spaces = indented code (not a list item per CommonMark)
1917        // Only the space-indented line is a real list item
1918        let content = "Text\n\t- Item with tab\n  - Item with spaces\nText";
1919        let warnings = lint(content);
1920        // Per markdownlint-cli: only line 3 (space-indented) is a list needing blanks
1921        assert!(!warnings.is_empty(), "Should warn for missing blank before list");
1922
1923        // Test that warnings have fixes
1924        check_warnings_have_fixes(content);
1925
1926        let fixed_content = fix(content);
1927        // Add blank before the actual list item (line 3), not the tab-indented code (line 2)
1928        // Trailing text is lazy continuation, so no blank after
1929        let expected = "Text\n\t- Item with tab\n\n  - Item with spaces\nText";
1930        assert_eq!(fixed_content, expected, "Fix should add blank before list item");
1931
1932        // Verify fix resolves the issue
1933        let warnings_after_fix = lint(&fixed_content);
1934        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1935    }
1936
1937    #[test]
1938    fn test_fix_warning_objects_have_correct_ranges() {
1939        // Per markdownlint-cli: trailing text is lazy continuation, only 1 warning
1940        let content = "Text\n- Item 1\n- Item 2\nText";
1941        let warnings = lint(content);
1942        assert_eq!(warnings.len(), 1, "Only preceding blank warning expected");
1943
1944        // Check that each warning has a fix with a valid range
1945        for warning in &warnings {
1946            assert!(warning.fix.is_some(), "Warning should have fix");
1947            let fix = warning.fix.as_ref().unwrap();
1948            assert!(fix.range.start <= fix.range.end, "Fix range should be valid");
1949            assert!(
1950                !fix.replacement.is_empty() || fix.range.start == fix.range.end,
1951                "Fix should have replacement or be insertion"
1952            );
1953        }
1954    }
1955
1956    #[test]
1957    fn test_fix_idempotent() {
1958        // Per markdownlint-cli: trailing text is lazy continuation
1959        let content = "Text\n- Item 1\n- Item 2\nText";
1960
1961        // Apply fix once - only adds blank before (trailing text is lazy continuation)
1962        let fixed_once = fix(content);
1963        assert_eq!(fixed_once, "Text\n\n- Item 1\n- Item 2\nText");
1964
1965        // Apply fix again - should be unchanged
1966        let fixed_twice = fix(&fixed_once);
1967        assert_eq!(fixed_twice, fixed_once, "Fix should be idempotent");
1968
1969        // No warnings after fix
1970        let warnings_after_fix = lint(&fixed_once);
1971        assert_eq!(warnings_after_fix.len(), 0, "No warnings should remain after fix");
1972    }
1973
1974    #[test]
1975    fn test_fix_preserves_crlf_and_matches_diagnostic_edits() {
1976        let rule = MD032BlanksAroundLists::default();
1977        for (content, expected) in [
1978            ("Text\r\n- item\r\n", "Text\r\n\r\n- item\r\n"),
1979            (
1980                "> > - item\r\n> > ~~~\r\n> > code\r\n> > ~~~",
1981                "> > - item\r\n> >\r\n> > ~~~\r\n> > code\r\n> > ~~~",
1982            ),
1983            ("Text\r\n\r\n- item\r\n", "Text\r\n\r\n- item\r\n"),
1984            ("Text\r\n\n- item\r\n", "Text\r\n\n- item\r\n"),
1985        ] {
1986            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1987            let warnings = rule.check(&ctx).unwrap();
1988            let edited = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
1989            assert_eq!(edited, expected);
1990            assert_eq!(rule.fix(&ctx).unwrap(), expected);
1991            let fixed_ctx = LintContext::new(expected, crate::config::MarkdownFlavor::Standard, None);
1992            assert!(rule.check(&fixed_ctx).unwrap().is_empty());
1993            assert_eq!(rule.fix(&fixed_ctx).unwrap(), expected);
1994        }
1995    }
1996
1997    #[test]
1998    fn test_fix_respects_inline_config_at_each_list_boundary() {
1999        use crate::utils::fix_utils::{apply_warning_fixes, filter_warnings_by_inline_config};
2000
2001        let rule = MD032BlanksAroundLists::default();
2002        // Comments can belong to a list's final segment. Suppression must follow
2003        // the diagnostic line at each boundary, even if the list starts elsewhere.
2004        for (content, expected, warning_lines) in [
2005            (
2006                "Text\n<!-- rumdl-disable-next-line MD032 -->\n- item\ntail <!-- comment -->\n# Heading\n",
2007                "Text\n<!-- rumdl-disable-next-line MD032 -->\n- item\ntail <!-- comment -->\n\n# Heading\n",
2008                vec![4],
2009            ),
2010            (
2011                "Text\n- item\n<!-- rumdl-disable-next-line MD032 -->\n<!-- comment -->\n# Heading\n",
2012                "Text\n\n- item\n<!-- rumdl-disable-next-line MD032 -->\n<!-- comment -->\n# Heading\n",
2013                vec![2],
2014            ),
2015            (
2016                "Text\n- item\n<!-- rumdl-disable MD032 -->\n<!-- comment -->\n# Heading\n<!-- rumdl-enable MD032 -->\nText\n- enabled\n",
2017                "Text\n\n- item\n<!-- rumdl-disable MD032 -->\n<!-- comment -->\n# Heading\n<!-- rumdl-enable MD032 -->\nText\n\n- enabled\n",
2018                vec![2, 8],
2019            ),
2020            (
2021                "Text\n- item\n<!-- rumdl-disable -->\n<!-- comment -->\n# Heading\n<!-- rumdl-enable -->\nText\n- enabled\n",
2022                "Text\n\n- item\n<!-- rumdl-disable -->\n<!-- comment -->\n# Heading\n<!-- rumdl-enable -->\nText\n\n- enabled\n",
2023                vec![2, 8],
2024            ),
2025            (
2026                // The disable names another rule, so MD032 still reports the
2027                // boundary it owns. The line it is written on separates the
2028                // paragraph from the list, so only the heading below is missing
2029                // its blank line.
2030                "Text\n<!-- rumdl-disable MD013 -->\n- item\n# Heading\n",
2031                "Text\n<!-- rumdl-disable MD013 -->\n- item\n\n# Heading\n",
2032                vec![3],
2033            ),
2034        ] {
2035            for ending in ["\n", "\r\n"] {
2036                for final_newline in [true, false] {
2037                    let content = if final_newline {
2038                        content
2039                    } else {
2040                        content.trim_end_matches('\n')
2041                    };
2042                    let expected = if final_newline {
2043                        expected
2044                    } else {
2045                        expected.trim_end_matches('\n')
2046                    };
2047                    let content = content.replace('\n', ending);
2048                    let expected = expected.replace('\n', ending);
2049                    let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
2050                    let warnings =
2051                        filter_warnings_by_inline_config(rule.check(&ctx).unwrap(), ctx.inline_config(), rule.name());
2052                    assert_eq!(warnings.iter().map(|w| w.line).collect::<Vec<_>>(), warning_lines);
2053                    assert_eq!(apply_warning_fixes(&content, &warnings).unwrap(), expected);
2054                    assert_eq!(rule.fix(&ctx).unwrap(), expected);
2055                    let fixed_ctx = LintContext::new(&expected, crate::config::MarkdownFlavor::Standard, None);
2056                    assert!(
2057                        filter_warnings_by_inline_config(
2058                            rule.check(&fixed_ctx).unwrap(),
2059                            fixed_ctx.inline_config(),
2060                            rule.name()
2061                        )
2062                        .is_empty()
2063                    );
2064                    assert_eq!(rule.fix(&fixed_ctx).unwrap(), expected);
2065                }
2066            }
2067        }
2068    }
2069
2070    #[test]
2071    fn test_disabled_lazy_fix_preserves_mixed_line_endings() {
2072        use crate::utils::fix_utils::{apply_warning_fixes, filter_warnings_by_inline_config};
2073
2074        let rule = MD032BlanksAroundLists::from_config_struct(MD032Config {
2075            allow_lazy_continuation: false,
2076        });
2077        let content = "<!-- rumdl-disable MD032 -->\r\n\r\n- item\ncontinuation\r\n- next\r\n";
2078        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2079        let raw = rule.check(&ctx).unwrap();
2080        assert_eq!(raw.len(), 1);
2081        assert!(raw[0].fix.is_some());
2082        let warnings = filter_warnings_by_inline_config(raw, ctx.inline_config(), rule.name());
2083        assert!(warnings.is_empty());
2084        assert_eq!(apply_warning_fixes(content, &warnings).unwrap(), content);
2085        assert_eq!(rule.fix(&ctx).unwrap(), content);
2086    }
2087
2088    #[test]
2089    fn test_fix_with_normalized_line_endings() {
2090        // In production, content is normalized to LF at I/O boundary
2091        // Unit tests should use LF input to reflect actual runtime behavior
2092        // Per markdownlint-cli: trailing text is lazy continuation, only 1 warning
2093        let content = "Text\n- Item 1\n- Item 2\nText";
2094        let warnings = lint(content);
2095        assert_eq!(warnings.len(), 1, "Should detect missing blank before list");
2096
2097        // Test that warnings have fixes
2098        check_warnings_have_fixes(content);
2099
2100        let fixed_content = fix(content);
2101        // Only adds blank before (trailing text is lazy continuation)
2102        let expected = "Text\n\n- Item 1\n- Item 2\nText";
2103        assert_eq!(fixed_content, expected, "Fix should work with normalized LF content");
2104    }
2105
2106    #[test]
2107    fn test_fix_preserves_final_newline() {
2108        // Per markdownlint-cli: trailing text is lazy continuation
2109        // Test with final newline
2110        let content_with_newline = "Text\n- Item 1\n- Item 2\nText\n";
2111        let fixed_with_newline = fix(content_with_newline);
2112        assert!(
2113            fixed_with_newline.ends_with('\n'),
2114            "Fix should preserve final newline when present"
2115        );
2116        // Only adds blank before (trailing text is lazy continuation)
2117        assert_eq!(fixed_with_newline, "Text\n\n- Item 1\n- Item 2\nText\n");
2118
2119        // Test without final newline
2120        let content_without_newline = "Text\n- Item 1\n- Item 2\nText";
2121        let fixed_without_newline = fix(content_without_newline);
2122        assert!(
2123            !fixed_without_newline.ends_with('\n'),
2124            "Fix should not add final newline when not present"
2125        );
2126        // Only adds blank before (trailing text is lazy continuation)
2127        assert_eq!(fixed_without_newline, "Text\n\n- Item 1\n- Item 2\nText");
2128    }
2129
2130    #[test]
2131    fn test_fix_multiline_list_items_no_indent() {
2132        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";
2133
2134        let warnings = lint(content);
2135        // Should only warn about missing blank lines around the entire list, not between items
2136        assert_eq!(
2137            warnings.len(),
2138            0,
2139            "Should not warn for properly formatted list with multi-line items. Got: {warnings:?}"
2140        );
2141
2142        let fixed_content = fix(content);
2143        // Should not change the content since it's already correct
2144        assert_eq!(
2145            fixed_content, content,
2146            "Should not modify correctly formatted multi-line list items"
2147        );
2148    }
2149
2150    #[test]
2151    fn test_nested_list_with_lazy_continuation() {
2152        // Issue #188: Nested list following a lazy continuation line should not require blank lines
2153        // This matches markdownlint-cli behavior which does NOT warn on this pattern
2154        //
2155        // The key element is line 6 (`!=`), ternary...) which is a lazy continuation of line 5.
2156        // Line 6 contains `||` inside code spans, which should NOT be detected as a table separator.
2157        let content = r#"# Test
2158
2159- **Token Dispatch (Phase 3.2)**: COMPLETE. Extracts tokens from both:
2160  1. Switch/case dispatcher statements (original Phase 3.2)
2161  2. Inline conditionals - if/else, bitwise checks (`&`, `|`), comparison (`==`,
2162`!=`), ternary operators (`?:`), macros (`ISTOK`, `ISUNSET`), compound conditions (`&&`, `||`) (Phase 3.2.1)
2163     - 30 explicit tokens extracted, 23 dispatcher rules with embedded token
2164       references"#;
2165
2166        let warnings = lint(content);
2167        // No MD032 warnings should be generated - this is a valid nested list structure
2168        // with lazy continuation (line 6 has no indent but continues line 5)
2169        let md032_warnings: Vec<_> = warnings
2170            .iter()
2171            .filter(|w| w.rule_name.as_deref() == Some("MD032"))
2172            .collect();
2173        assert_eq!(
2174            md032_warnings.len(),
2175            0,
2176            "Should not warn for nested list with lazy continuation. Got: {md032_warnings:?}"
2177        );
2178    }
2179
2180    #[test]
2181    fn test_pipes_in_code_spans_not_detected_as_table() {
2182        // Pipes inside code spans should NOT break lists
2183        let content = r#"# Test
2184
2185- Item with `a | b` inline code
2186  - Nested item should work
2187
2188"#;
2189
2190        let warnings = lint(content);
2191        let md032_warnings: Vec<_> = warnings
2192            .iter()
2193            .filter(|w| w.rule_name.as_deref() == Some("MD032"))
2194            .collect();
2195        assert_eq!(
2196            md032_warnings.len(),
2197            0,
2198            "Pipes in code spans should not break lists. Got: {md032_warnings:?}"
2199        );
2200    }
2201
2202    #[test]
2203    fn test_multiple_code_spans_with_pipes() {
2204        // Multiple code spans with pipes should not break lists
2205        let content = r#"# Test
2206
2207- Item with `a | b` and `c || d` operators
2208  - Nested item should work
2209
2210"#;
2211
2212        let warnings = lint(content);
2213        let md032_warnings: Vec<_> = warnings
2214            .iter()
2215            .filter(|w| w.rule_name.as_deref() == Some("MD032"))
2216            .collect();
2217        assert_eq!(
2218            md032_warnings.len(),
2219            0,
2220            "Multiple code spans with pipes should not break lists. Got: {md032_warnings:?}"
2221        );
2222    }
2223
2224    #[test]
2225    fn test_actual_table_breaks_list() {
2226        // An actual table between list items SHOULD break the list
2227        let content = r#"# Test
2228
2229- Item before table
2230
2231| Col1 | Col2 |
2232|------|------|
2233| A    | B    |
2234
2235- Item after table
2236
2237"#;
2238
2239        let warnings = lint(content);
2240        // There should be NO MD032 warnings because both lists are properly surrounded by blank lines
2241        let md032_warnings: Vec<_> = warnings
2242            .iter()
2243            .filter(|w| w.rule_name.as_deref() == Some("MD032"))
2244            .collect();
2245        assert_eq!(
2246            md032_warnings.len(),
2247            0,
2248            "Both lists should be properly separated by blank lines. Got: {md032_warnings:?}"
2249        );
2250    }
2251
2252    #[test]
2253    fn test_thematic_break_not_lazy_continuation() {
2254        // Thematic breaks (HRs) cannot be lazy continuation per CommonMark
2255        // List followed by HR without blank line should warn
2256        let content = r#"- Item 1
2257- Item 2
2258***
2259
2260More text.
2261"#;
2262
2263        let warnings = lint(content);
2264        let md032_warnings: Vec<_> = warnings
2265            .iter()
2266            .filter(|w| w.rule_name.as_deref() == Some("MD032"))
2267            .collect();
2268        assert_eq!(
2269            md032_warnings.len(),
2270            1,
2271            "Should warn for list not followed by blank line before thematic break. Got: {md032_warnings:?}"
2272        );
2273        assert!(
2274            md032_warnings[0].message.contains("followed by blank line"),
2275            "Warning should be about missing blank after list"
2276        );
2277    }
2278
2279    #[test]
2280    fn test_thematic_break_with_blank_line() {
2281        // List followed by blank line then HR should NOT warn
2282        let content = r#"- Item 1
2283- Item 2
2284
2285***
2286
2287More text.
2288"#;
2289
2290        let warnings = lint(content);
2291        let md032_warnings: Vec<_> = warnings
2292            .iter()
2293            .filter(|w| w.rule_name.as_deref() == Some("MD032"))
2294            .collect();
2295        assert_eq!(
2296            md032_warnings.len(),
2297            0,
2298            "Should not warn when list is properly followed by blank line. Got: {md032_warnings:?}"
2299        );
2300    }
2301
2302    #[test]
2303    fn test_various_thematic_break_styles() {
2304        // Test different HR styles are all recognized
2305        // Note: Spaced styles like "- - -" and "* * *" are excluded because they start
2306        // with list markers ("- " or "* ") which get parsed as list items by the
2307        // upstream CommonMark parser. That's a separate parsing issue.
2308        for hr in ["---", "***", "___"] {
2309            let content = format!(
2310                r#"- Item 1
2311- Item 2
2312{hr}
2313
2314More text.
2315"#
2316            );
2317
2318            let warnings = lint(&content);
2319            let md032_warnings: Vec<_> = warnings
2320                .iter()
2321                .filter(|w| w.rule_name.as_deref() == Some("MD032"))
2322                .collect();
2323            assert_eq!(
2324                md032_warnings.len(),
2325                1,
2326                "Should warn for HR style '{hr}' without blank line. Got: {md032_warnings:?}"
2327            );
2328        }
2329    }
2330
2331    // === LAZY CONTINUATION TESTS ===
2332
2333    fn lint_with_config(content: &str, config: MD032Config) -> Vec<LintWarning> {
2334        let rule = MD032BlanksAroundLists::from_config_struct(config);
2335        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2336        rule.check(&ctx).expect("Lint check failed")
2337    }
2338
2339    fn fix_with_config(content: &str, config: MD032Config) -> String {
2340        let rule = MD032BlanksAroundLists::from_config_struct(config);
2341        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2342        rule.fix(&ctx).expect("Lint fix failed")
2343    }
2344
2345    #[test]
2346    fn test_lazy_continuation_allowed_by_default() {
2347        // Default behavior: lazy continuation is allowed, no warning
2348        let content = "# Heading\n\n1. List\nSome text.";
2349        let warnings = lint(content);
2350        assert_eq!(
2351            warnings.len(),
2352            0,
2353            "Default behavior should allow lazy continuation. Got: {warnings:?}"
2354        );
2355    }
2356
2357    #[test]
2358    fn test_lazy_continuation_disallowed() {
2359        // With allow_lazy_continuation = false, should warn about lazy continuation
2360        let content = "# Heading\n\n1. List\nSome text.";
2361        let config = MD032Config {
2362            allow_lazy_continuation: false,
2363        };
2364        let warnings = lint_with_config(content, config);
2365        assert_eq!(
2366            warnings.len(),
2367            1,
2368            "Should warn when lazy continuation is disallowed. Got: {warnings:?}"
2369        );
2370        assert!(
2371            warnings[0].message.contains("Lazy continuation"),
2372            "Warning message should mention lazy continuation"
2373        );
2374        assert_eq!(warnings[0].line, 4, "Warning should be on the lazy line");
2375    }
2376
2377    #[test]
2378    fn test_lazy_continuation_fix() {
2379        // With allow_lazy_continuation = false, fix should add proper indentation
2380        let content = "# Heading\n\n1. List\nSome text.";
2381        let config = MD032Config {
2382            allow_lazy_continuation: false,
2383        };
2384        let fixed = fix_with_config(content, config.clone());
2385        // Fix adds proper indentation (3 spaces for "1. " marker width)
2386        assert_eq!(
2387            fixed, "# Heading\n\n1. List\n   Some text.",
2388            "Fix should add proper indentation to lazy continuation"
2389        );
2390
2391        // Verify no warnings after fix
2392        let warnings_after = lint_with_config(&fixed, config);
2393        assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
2394    }
2395
2396    #[test]
2397    fn test_lazy_continuation_multiple_lines() {
2398        // Multiple lazy continuation lines - each gets its own warning
2399        let content = "- Item 1\nLine 2\nLine 3";
2400        let config = MD032Config {
2401            allow_lazy_continuation: false,
2402        };
2403        let warnings = lint_with_config(content, config.clone());
2404        // Both Line 2 and Line 3 are lazy continuation lines
2405        assert_eq!(
2406            warnings.len(),
2407            2,
2408            "Should warn for each lazy continuation line. Got: {warnings:?}"
2409        );
2410
2411        let fixed = fix_with_config(content, config.clone());
2412        // Fix adds proper indentation (2 spaces for "- " marker)
2413        assert_eq!(
2414            fixed, "- Item 1\n  Line 2\n  Line 3",
2415            "Fix should add proper indentation to lazy continuation lines"
2416        );
2417
2418        // Verify no warnings after fix
2419        let warnings_after = lint_with_config(&fixed, config);
2420        assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
2421    }
2422
2423    #[test]
2424    fn test_lazy_continuation_with_indented_content() {
2425        // Indented content is valid continuation, not lazy continuation
2426        let content = "- Item 1\n  Indented content\nLazy text";
2427        let config = MD032Config {
2428            allow_lazy_continuation: false,
2429        };
2430        let warnings = lint_with_config(content, config);
2431        assert_eq!(
2432            warnings.len(),
2433            1,
2434            "Should warn for lazy text after indented content. Got: {warnings:?}"
2435        );
2436    }
2437
2438    #[test]
2439    fn test_lazy_continuation_properly_separated() {
2440        // With proper blank line, no warning even with strict config
2441        let content = "- Item 1\n\nSome text.";
2442        let config = MD032Config {
2443            allow_lazy_continuation: false,
2444        };
2445        let warnings = lint_with_config(content, config);
2446        assert_eq!(
2447            warnings.len(),
2448            0,
2449            "Should not warn when list is properly followed by blank line. Got: {warnings:?}"
2450        );
2451    }
2452
2453    // ==================== Comprehensive edge case tests ====================
2454
2455    #[test]
2456    fn test_lazy_continuation_ordered_list_parenthesis_marker() {
2457        // Ordered list with parenthesis marker (1) instead of period
2458        let content = "1) First item\nLazy continuation";
2459        let config = MD032Config {
2460            allow_lazy_continuation: false,
2461        };
2462        let warnings = lint_with_config(content, config.clone());
2463        assert_eq!(
2464            warnings.len(),
2465            1,
2466            "Should warn for lazy continuation with parenthesis marker"
2467        );
2468
2469        let fixed = fix_with_config(content, config);
2470        // Fix adds proper indentation (3 spaces for "1) " marker)
2471        assert_eq!(fixed, "1) First item\n   Lazy continuation");
2472    }
2473
2474    #[test]
2475    fn test_lazy_continuation_followed_by_another_list() {
2476        // Lazy continuation text followed by another list item
2477        // In CommonMark, "Some text" becomes part of Item 1's lazy continuation,
2478        // and "- Item 2" starts a new list item within the same list.
2479        // With allow_lazy_continuation = false, we warn about lazy continuation
2480        // even within valid list structure (issue #295).
2481        let content = "- Item 1\nSome text\n- Item 2";
2482        let config = MD032Config {
2483            allow_lazy_continuation: false,
2484        };
2485        let warnings = lint_with_config(content, config);
2486        // Should warn about lazy continuation on line 2
2487        assert_eq!(
2488            warnings.len(),
2489            1,
2490            "Should warn about lazy continuation within list. Got: {warnings:?}"
2491        );
2492        assert!(
2493            warnings[0].message.contains("Lazy continuation"),
2494            "Warning should be about lazy continuation"
2495        );
2496        assert_eq!(warnings[0].line, 2, "Warning should be on line 2");
2497    }
2498
2499    #[test]
2500    fn test_lazy_continuation_multiple_in_document() {
2501        // Loose list (blank line between items) with lazy continuation
2502        // In CommonMark, this is a single loose list, not two separate lists.
2503        // "Lazy 1" is lazy continuation of Item 1
2504        // "Lazy 2" is lazy continuation of Item 2
2505        let content = "- Item 1\nLazy 1\n\n- Item 2\nLazy 2";
2506        let config = MD032Config {
2507            allow_lazy_continuation: false,
2508        };
2509        let warnings = lint_with_config(content, config.clone());
2510        // Expect 2 warnings for both lazy continuation lines
2511        assert_eq!(
2512            warnings.len(),
2513            2,
2514            "Should warn for both lazy continuations. Got: {warnings:?}"
2515        );
2516
2517        let fixed = fix_with_config(content, config.clone());
2518        // Auto-fix should add proper indentation to both lazy continuation lines
2519        assert!(
2520            fixed.contains("  Lazy 1"),
2521            "Fixed content should have indented 'Lazy 1'. Got: {fixed:?}"
2522        );
2523        assert!(
2524            fixed.contains("  Lazy 2"),
2525            "Fixed content should have indented 'Lazy 2'. Got: {fixed:?}"
2526        );
2527
2528        let warnings_after = lint_with_config(&fixed, config);
2529        // No warnings after fix: both lazy lines are properly indented
2530        assert_eq!(
2531            warnings_after.len(),
2532            0,
2533            "All warnings should be fixed after auto-fix. Got: {warnings_after:?}"
2534        );
2535    }
2536
2537    #[test]
2538    fn test_lazy_continuation_end_of_document_no_newline() {
2539        // Lazy continuation at end of document without trailing newline
2540        let content = "- Item\nNo trailing newline";
2541        let config = MD032Config {
2542            allow_lazy_continuation: false,
2543        };
2544        let warnings = lint_with_config(content, config.clone());
2545        assert_eq!(warnings.len(), 1, "Should warn even at end of document");
2546
2547        let fixed = fix_with_config(content, config);
2548        // Fix adds proper indentation (2 spaces for "- " marker)
2549        assert_eq!(fixed, "- Item\n  No trailing newline");
2550    }
2551
2552    #[test]
2553    fn test_lazy_continuation_thematic_break_still_needs_blank() {
2554        // Thematic break after list without blank line still triggers MD032
2555        // The thematic break ends the list, but MD032 requires blank line separation
2556        let content = "- Item 1\n---";
2557        let config = MD032Config {
2558            allow_lazy_continuation: false,
2559        };
2560        let warnings = lint_with_config(content, config.clone());
2561        // Should warn because list needs blank line before thematic break
2562        assert_eq!(
2563            warnings.len(),
2564            1,
2565            "List should need blank line before thematic break. Got: {warnings:?}"
2566        );
2567
2568        // Verify fix adds blank line
2569        let fixed = fix_with_config(content, config);
2570        assert_eq!(fixed, "- Item 1\n\n---");
2571    }
2572
2573    #[test]
2574    fn test_lazy_continuation_heading_not_flagged() {
2575        // Heading after list should NOT be flagged as lazy continuation
2576        // (headings end lists per CommonMark)
2577        let content = "- Item 1\n# Heading";
2578        let config = MD032Config {
2579            allow_lazy_continuation: false,
2580        };
2581        let warnings = lint_with_config(content, config);
2582        // The warning should be about missing blank line, not lazy continuation
2583        // But headings interrupt lists, so the list ends at Item 1
2584        assert!(
2585            warnings.iter().all(|w| !w.message.contains("lazy")),
2586            "Heading should not trigger lazy continuation warning"
2587        );
2588    }
2589
2590    #[test]
2591    fn test_lazy_continuation_mixed_list_types() {
2592        // Mixed ordered and unordered with lazy continuation
2593        let content = "- Unordered\n1. Ordered\nLazy text";
2594        let config = MD032Config {
2595            allow_lazy_continuation: false,
2596        };
2597        let warnings = lint_with_config(content, config.clone());
2598        assert!(!warnings.is_empty(), "Should warn about structure issues");
2599    }
2600
2601    #[test]
2602    fn test_lazy_continuation_deep_nesting() {
2603        // Deep nested list with lazy continuation at end
2604        let content = "- Level 1\n  - Level 2\n    - Level 3\nLazy at root";
2605        let config = MD032Config {
2606            allow_lazy_continuation: false,
2607        };
2608        let warnings = lint_with_config(content, config.clone());
2609        assert!(
2610            !warnings.is_empty(),
2611            "Should warn about lazy continuation after nested list"
2612        );
2613
2614        let fixed = fix_with_config(content, config.clone());
2615        let warnings_after = lint_with_config(&fixed, config);
2616        assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
2617    }
2618
2619    #[test]
2620    fn test_lazy_continuation_with_emphasis_in_text() {
2621        // Lazy continuation containing emphasis markers
2622        let content = "- Item\n*emphasized* continuation";
2623        let config = MD032Config {
2624            allow_lazy_continuation: false,
2625        };
2626        let warnings = lint_with_config(content, config.clone());
2627        assert_eq!(warnings.len(), 1, "Should warn even with emphasis in continuation");
2628
2629        let fixed = fix_with_config(content, config);
2630        // Fix adds proper indentation (2 spaces for "- " marker)
2631        assert_eq!(fixed, "- Item\n  *emphasized* continuation");
2632    }
2633
2634    #[test]
2635    fn test_lazy_continuation_with_code_span() {
2636        // Lazy continuation containing code span
2637        let content = "- Item\n`code` continuation";
2638        let config = MD032Config {
2639            allow_lazy_continuation: false,
2640        };
2641        let warnings = lint_with_config(content, config.clone());
2642        assert_eq!(warnings.len(), 1, "Should warn even with code span in continuation");
2643
2644        let fixed = fix_with_config(content, config);
2645        // Fix adds proper indentation (2 spaces for "- " marker)
2646        assert_eq!(fixed, "- Item\n  `code` continuation");
2647    }
2648
2649    // =========================================================================
2650    // Issue #295: Lazy continuation after nested sublists
2651    // These tests verify detection of lazy continuation at outer indent level
2652    // after nested sublists, followed by another list item.
2653    // =========================================================================
2654
2655    #[test]
2656    fn test_issue295_case1_nested_bullets_then_continuation_then_item() {
2657        // Outer numbered item with nested bullets, lazy continuation, then next item
2658        // The lazy continuation "A new Chat..." appears at column 1, not indented
2659        let content = r#"1. Create a new Chat conversation:
2660   - On the sidebar, select **New Chat**.
2661   - In the box, type `/new`.
2662   A new Chat conversation replaces the previous one.
26631. Under the Chat text box, turn off the toggle."#;
2664        let config = MD032Config {
2665            allow_lazy_continuation: false,
2666        };
2667        let warnings = lint_with_config(content, config);
2668        // Should warn about line 4 "A new Chat..." which is lazy continuation
2669        let lazy_warnings: Vec<_> = warnings
2670            .iter()
2671            .filter(|w| w.message.contains("Lazy continuation"))
2672            .collect();
2673        assert!(
2674            !lazy_warnings.is_empty(),
2675            "Should detect lazy continuation after nested bullets. Got: {warnings:?}"
2676        );
2677        assert!(
2678            lazy_warnings.iter().any(|w| w.line == 4),
2679            "Should warn on line 4. Got: {lazy_warnings:?}"
2680        );
2681    }
2682
2683    #[test]
2684    fn test_issue295_case3_code_span_starts_lazy_continuation() {
2685        // Code span at the START of lazy continuation after nested bullets
2686        // This is tricky because pulldown-cmark emits Code event, not Text
2687        let content = r#"- `field`: Is the specific key:
2688  - `password`: Accesses the password.
2689  - `api_key`: Accesses the api_key.
2690  `token`: Specifies which ID token to use.
2691- `version_id`: Is the unique identifier."#;
2692        let config = MD032Config {
2693            allow_lazy_continuation: false,
2694        };
2695        let warnings = lint_with_config(content, config);
2696        // Should warn about line 4 "`token`:..." which starts with code span
2697        let lazy_warnings: Vec<_> = warnings
2698            .iter()
2699            .filter(|w| w.message.contains("Lazy continuation"))
2700            .collect();
2701        assert!(
2702            !lazy_warnings.is_empty(),
2703            "Should detect lazy continuation starting with code span. Got: {warnings:?}"
2704        );
2705        assert!(
2706            lazy_warnings.iter().any(|w| w.line == 4),
2707            "Should warn on line 4 (code span start). Got: {lazy_warnings:?}"
2708        );
2709    }
2710
2711    #[test]
2712    fn test_issue295_case4_deep_nesting_with_continuation_then_item() {
2713        // Multiple nesting levels, lazy continuation, then next outer item
2714        let content = r#"- Check out the branch, and test locally.
2715  - If the MR requires significant modifications:
2716    - **Skip local testing** and review instead.
2717    - **Request verification** from the author.
2718    - **Identify the minimal change** needed.
2719  Your testing might result in opportunities.
2720- If you don't understand, _say so_."#;
2721        let config = MD032Config {
2722            allow_lazy_continuation: false,
2723        };
2724        let warnings = lint_with_config(content, config);
2725        // Should warn about line 6 "Your testing..." which is lazy continuation
2726        let lazy_warnings: Vec<_> = warnings
2727            .iter()
2728            .filter(|w| w.message.contains("Lazy continuation"))
2729            .collect();
2730        assert!(
2731            !lazy_warnings.is_empty(),
2732            "Should detect lazy continuation after deep nesting. Got: {warnings:?}"
2733        );
2734        assert!(
2735            lazy_warnings.iter().any(|w| w.line == 6),
2736            "Should warn on line 6. Got: {lazy_warnings:?}"
2737        );
2738    }
2739
2740    #[test]
2741    fn test_issue295_ordered_list_nested_bullets_continuation() {
2742        // Ordered list with nested bullets, continuation at outer level, then next item
2743        // This is the exact pattern from debug_test6.md
2744        let content = r#"# Test
2745
27461. First item.
2747   - Nested A.
2748   - Nested B.
2749   Continuation at outer level.
27501. Second item."#;
2751        let config = MD032Config {
2752            allow_lazy_continuation: false,
2753        };
2754        let warnings = lint_with_config(content, config);
2755        // Should warn about line 6 "Continuation at outer level."
2756        let lazy_warnings: Vec<_> = warnings
2757            .iter()
2758            .filter(|w| w.message.contains("Lazy continuation"))
2759            .collect();
2760        assert!(
2761            !lazy_warnings.is_empty(),
2762            "Should detect lazy continuation at outer level after nested. Got: {warnings:?}"
2763        );
2764        // Line 6 = "   Continuation at outer level." (3 spaces indent, but needs 4 for proper continuation)
2765        assert!(
2766            lazy_warnings.iter().any(|w| w.line == 6),
2767            "Should warn on line 6. Got: {lazy_warnings:?}"
2768        );
2769    }
2770
2771    #[test]
2772    fn test_issue295_multiple_lazy_lines_after_nested() {
2773        // Multiple lazy continuation lines after nested sublist
2774        let content = r#"1. The device client receives a response.
2775   - Those defined by OAuth Framework.
2776   - Those specific to device authorization.
2777   Those error responses are described below.
2778   For more information on each response,
2779   see the documentation.
27801. Next step in the process."#;
2781        let config = MD032Config {
2782            allow_lazy_continuation: false,
2783        };
2784        let warnings = lint_with_config(content, config);
2785        // Should warn about lines 4, 5, 6 (all lazy continuation)
2786        let lazy_warnings: Vec<_> = warnings
2787            .iter()
2788            .filter(|w| w.message.contains("Lazy continuation"))
2789            .collect();
2790        assert!(
2791            lazy_warnings.len() >= 3,
2792            "Should detect multiple lazy continuation lines. Got {} warnings: {lazy_warnings:?}",
2793            lazy_warnings.len()
2794        );
2795    }
2796
2797    #[test]
2798    fn test_issue295_properly_indented_not_lazy() {
2799        // Properly indented continuation after nested sublist should NOT warn
2800        let content = r#"1. First item.
2801   - Nested A.
2802   - Nested B.
2803
2804   Properly indented continuation.
28051. Second item."#;
2806        let config = MD032Config {
2807            allow_lazy_continuation: false,
2808        };
2809        let warnings = lint_with_config(content, config);
2810        // With blank line before, this is a new paragraph, not lazy continuation
2811        let lazy_warnings: Vec<_> = warnings
2812            .iter()
2813            .filter(|w| w.message.contains("Lazy continuation"))
2814            .collect();
2815        assert_eq!(
2816            lazy_warnings.len(),
2817            0,
2818            "Should NOT warn when blank line separates continuation. Got: {lazy_warnings:?}"
2819        );
2820    }
2821
2822    // =========================================================================
2823    // HTML Comment Transparency Tests
2824    // HTML comments should be "transparent" for blank line checking,
2825    // matching markdownlint-cli behavior.
2826    // =========================================================================
2827
2828    #[test]
2829    fn test_html_comment_before_list_with_preceding_blank() {
2830        // Blank line before HTML comment = list is properly separated
2831        // markdownlint-cli does NOT warn here
2832        let content = "Some text.\n\n<!-- comment -->\n- List item";
2833        let warnings = lint(content);
2834        assert_eq!(
2835            warnings.len(),
2836            0,
2837            "Should not warn when blank line exists before HTML comment. Got: {warnings:?}"
2838        );
2839    }
2840
2841    #[test]
2842    fn test_html_comment_after_list_with_following_blank() {
2843        // Blank line after HTML comment = list is properly separated
2844        let content = "- List item\n<!-- comment -->\n\nSome text.";
2845        let warnings = lint(content);
2846        assert_eq!(
2847            warnings.len(),
2848            0,
2849            "Should not warn when blank line exists after HTML comment. Got: {warnings:?}"
2850        );
2851    }
2852
2853    #[test]
2854    fn test_list_inside_html_comment_ignored() {
2855        // Lists entirely inside HTML comments should not be analyzed
2856        let content = "<!--\n1. First\n2. Second\n3. Third\n-->";
2857        let warnings = lint(content);
2858        assert_eq!(
2859            warnings.len(),
2860            0,
2861            "Should not analyze lists inside HTML comments. Got: {warnings:?}"
2862        );
2863    }
2864
2865    #[test]
2866    fn test_multiline_html_comment_before_list() {
2867        // Multi-line HTML comment should be transparent
2868        let content = "Text\n\n<!--\nThis is a\nmulti-line\ncomment\n-->\n- Item";
2869        let warnings = lint(content);
2870        assert_eq!(
2871            warnings.len(),
2872            0,
2873            "Multi-line HTML comment should be transparent. Got: {warnings:?}"
2874        );
2875    }
2876
2877    #[test]
2878    fn test_a_comment_line_separates_the_paragraph_from_the_list() {
2879        // A line holding nothing but a comment is the separation the rule asks for,
2880        // so there is nothing to report and nothing to insert (#866)
2881        let content = "Some text.\n<!-- comment -->\n- List item";
2882        let warnings = lint(content);
2883        assert_eq!(
2884            warnings.len(),
2885            0,
2886            "A comment-only line separates the blocks around it. Got: {warnings:?}"
2887        );
2888    }
2889
2890    #[test]
2891    fn test_a_line_carrying_text_beside_a_comment_still_warns() {
2892        // Control for the test above: the comment is not the whole line, so the
2893        // paragraph runs straight into the list and the blank line is missing
2894        let content = "Some text. <!-- comment -->\n- List item";
2895        let warnings = lint(content);
2896        assert_eq!(
2897            warnings.len(),
2898            1,
2899            "A paragraph with a trailing comment is still a paragraph. Got: {warnings:?}"
2900        );
2901        assert!(
2902            warnings[0].message.contains("preceded by blank line"),
2903            "Should be 'preceded by blank line' warning"
2904        );
2905    }
2906
2907    #[test]
2908    fn test_no_blank_after_html_comment_no_warn_lazy_continuation() {
2909        // Text immediately after list (through HTML comment) is lazy continuation
2910        // markdownlint-cli does NOT warn here - the text becomes part of the list
2911        let content = "- List item\n<!-- comment -->\nSome text.";
2912        let warnings = lint(content);
2913        assert_eq!(
2914            warnings.len(),
2915            0,
2916            "Should not warn - text after comment becomes lazy continuation. Got: {warnings:?}"
2917        );
2918    }
2919
2920    #[test]
2921    fn test_list_followed_by_heading_through_comment_should_warn() {
2922        // Heading cannot be lazy continuation, so this SHOULD warn
2923        let content = "- List item\n<!-- comment -->\n# Heading";
2924        let warnings = lint(content);
2925        // Headings after lists through HTML comments should be handled gracefully
2926        // The blank line check should look past the comment
2927        assert!(
2928            warnings.len() <= 1,
2929            "Should handle heading after comment gracefully. Got: {warnings:?}"
2930        );
2931    }
2932
2933    #[test]
2934    fn test_html_comment_between_list_and_text_both_directions() {
2935        // Blank line on both sides through HTML comment
2936        let content = "Text before.\n\n<!-- comment -->\n- Item 1\n- Item 2\n<!-- another -->\n\nText after.";
2937        let warnings = lint(content);
2938        assert_eq!(
2939            warnings.len(),
2940            0,
2941            "Should not warn with proper separation through comments. Got: {warnings:?}"
2942        );
2943    }
2944
2945    #[test]
2946    fn test_html_comment_fix_does_not_insert_unnecessary_blank() {
2947        // Fix should not add blank line when separation already exists through comment
2948        let content = "Text.\n\n<!-- comment -->\n- Item";
2949        let fixed = fix(content);
2950        assert_eq!(fixed, content, "Fix should not modify already-correct content");
2951    }
2952
2953    #[test]
2954    fn test_html_comment_fix_adds_blank_when_needed() {
2955        // A comment-only line is already the separation, so the fix leaves it alone.
2956        // A comment sharing its line with prose is not, and there the blank goes in.
2957        let separated = "Text.\n<!-- comment -->\n- Item";
2958        assert_eq!(
2959            fix(separated),
2960            separated,
2961            "A comment-only line needs no blank line inserted around it"
2962        );
2963
2964        let content = "Text. <!-- comment -->\n- Item";
2965        let fixed = fix(content);
2966        assert!(
2967            fixed.contains("Text. <!-- comment -->\n\n- Item"),
2968            "Fix should add blank line before list. Got: {fixed}"
2969        );
2970    }
2971
2972    #[test]
2973    fn test_ordered_list_inside_html_comment() {
2974        // Ordered list with non-1 start inside comment should not warn
2975        let content = "<!--\n3. Starting at 3\n4. Next item\n-->";
2976        let warnings = lint(content);
2977        assert_eq!(
2978            warnings.len(),
2979            0,
2980            "Should not warn about ordered list inside HTML comment. Got: {warnings:?}"
2981        );
2982    }
2983
2984    // =========================================================================
2985    // Blockquote Boundary Transition Tests
2986    // When a list inside a blockquote ends and the next line exits the blockquote,
2987    // no blank line is needed - the blockquote boundary provides semantic separation.
2988    // =========================================================================
2989
2990    #[test]
2991    fn test_blockquote_list_exit_no_warning() {
2992        // Blockquote list followed by outer content - no blank line needed
2993        let content = "- outer item\n  > - blockquote list 1\n  > - blockquote list 2\n- next outer item";
2994        let warnings = lint(content);
2995        assert_eq!(
2996            warnings.len(),
2997            0,
2998            "Should not warn when exiting blockquote. Got: {warnings:?}"
2999        );
3000    }
3001
3002    #[test]
3003    fn test_nested_blockquote_list_exit() {
3004        // Nested blockquote list - exiting should not require blank line
3005        let content = "- outer\n  - nested\n    > - bq list 1\n    > - bq list 2\n  - back to nested\n- outer again";
3006        let warnings = lint(content);
3007        assert_eq!(
3008            warnings.len(),
3009            0,
3010            "Should not warn when exiting nested blockquote list. Got: {warnings:?}"
3011        );
3012    }
3013
3014    #[test]
3015    fn test_blockquote_same_level_no_warning() {
3016        // List INSIDE blockquote followed by text INSIDE same blockquote
3017        // markdownlint-cli does NOT warn for this case - lazy continuation applies
3018        let content = "> - item 1\n> - item 2\n> Text after";
3019        let warnings = lint(content);
3020        assert_eq!(
3021            warnings.len(),
3022            0,
3023            "Should not warn - text is lazy continuation in blockquote. Got: {warnings:?}"
3024        );
3025    }
3026
3027    #[test]
3028    fn test_blockquote_list_with_special_chars() {
3029        // Content with special chars like <> should not affect blockquote detection
3030        let content = "- Item with <>&\n  > - blockquote item\n- Back to outer";
3031        let warnings = lint(content);
3032        assert_eq!(
3033            warnings.len(),
3034            0,
3035            "Special chars in content should not affect blockquote detection. Got: {warnings:?}"
3036        );
3037    }
3038
3039    #[test]
3040    fn test_lazy_continuation_whitespace_only_line() {
3041        // Per CommonMark/pulldown-cmark, whitespace-only line IS a blank line separator
3042        // The list ends at the whitespace-only line, text starts a new paragraph
3043        let content = "- Item\n   \nText after whitespace-only line";
3044        let config = MD032Config {
3045            allow_lazy_continuation: false,
3046        };
3047        let warnings = lint_with_config(content, config);
3048        // Whitespace-only line counts as blank line separator - no lazy continuation
3049        assert_eq!(
3050            warnings.len(),
3051            0,
3052            "Whitespace-only line IS a separator in CommonMark. Got: {warnings:?}"
3053        );
3054    }
3055
3056    #[test]
3057    fn test_lazy_continuation_blockquote_context() {
3058        // List inside blockquote with lazy continuation
3059        let content = "> - Item\n> Lazy in quote";
3060        let config = MD032Config {
3061            allow_lazy_continuation: false,
3062        };
3063        let warnings = lint_with_config(content, config);
3064        // Inside blockquote, lazy continuation may behave differently
3065        // This tests that we handle blockquote context
3066        assert!(warnings.len() <= 1, "Should handle blockquote context gracefully");
3067    }
3068
3069    #[test]
3070    fn test_lazy_continuation_fix_preserves_content() {
3071        // Ensure fix doesn't modify the actual content
3072        let content = "- Item with special chars: <>&\nContinuation with: \"quotes\"";
3073        let config = MD032Config {
3074            allow_lazy_continuation: false,
3075        };
3076        let fixed = fix_with_config(content, config);
3077        assert!(fixed.contains("<>&"), "Should preserve special chars");
3078        assert!(fixed.contains("\"quotes\""), "Should preserve quotes");
3079        // Fix adds proper indentation (2 spaces for "- " marker)
3080        assert_eq!(fixed, "- Item with special chars: <>&\n  Continuation with: \"quotes\"");
3081    }
3082
3083    #[test]
3084    fn test_lazy_continuation_fix_idempotent() {
3085        // Running fix twice should produce same result
3086        let content = "- Item\nLazy";
3087        let config = MD032Config {
3088            allow_lazy_continuation: false,
3089        };
3090        let fixed_once = fix_with_config(content, config.clone());
3091        let fixed_twice = fix_with_config(&fixed_once, config);
3092        assert_eq!(fixed_once, fixed_twice, "Fix should be idempotent");
3093    }
3094
3095    #[test]
3096    fn test_lazy_continuation_config_default_allows() {
3097        // Verify default config allows lazy continuation
3098        let content = "- Item\nLazy text that continues";
3099        let default_config = MD032Config::default();
3100        assert!(
3101            default_config.allow_lazy_continuation,
3102            "Default should allow lazy continuation"
3103        );
3104        let warnings = lint_with_config(content, default_config);
3105        assert_eq!(warnings.len(), 0, "Default config should not warn on lazy continuation");
3106    }
3107
3108    #[test]
3109    fn test_lazy_continuation_after_multi_line_item() {
3110        // List item with proper indented continuation, then lazy text
3111        let content = "- Item line 1\n  Item line 2 (indented)\nLazy (not indented)";
3112        let config = MD032Config {
3113            allow_lazy_continuation: false,
3114        };
3115        let warnings = lint_with_config(content, config.clone());
3116        assert_eq!(
3117            warnings.len(),
3118            1,
3119            "Should warn only for the lazy line, not the indented line"
3120        );
3121    }
3122
3123    // Issue #260: Lists inside blockquotes should not produce false positives
3124    #[test]
3125    fn test_blockquote_list_with_continuation_and_nested() {
3126        // This is the exact case from issue #260
3127        // markdownlint-cli reports NO warnings for this
3128        let content = "> - item 1\n>   continuation\n>   - nested\n> - item 2";
3129        let warnings = lint(content);
3130        assert_eq!(
3131            warnings.len(),
3132            0,
3133            "Blockquoted list with continuation and nested items should have no warnings. Got: {warnings:?}"
3134        );
3135    }
3136
3137    #[test]
3138    fn test_blockquote_list_simple() {
3139        // Simple blockquoted list
3140        let content = "> - item 1\n> - item 2";
3141        let warnings = lint(content);
3142        assert_eq!(warnings.len(), 0, "Simple blockquoted list should have no warnings");
3143    }
3144
3145    #[test]
3146    fn test_blockquote_list_with_continuation_only() {
3147        // Blockquoted list with continuation line (no nesting)
3148        let content = "> - item 1\n>   continuation\n> - item 2";
3149        let warnings = lint(content);
3150        assert_eq!(
3151            warnings.len(),
3152            0,
3153            "Blockquoted list with continuation should have no warnings"
3154        );
3155    }
3156
3157    #[test]
3158    fn test_blockquote_list_with_lazy_continuation() {
3159        // Blockquoted list with lazy continuation (no extra indent after >)
3160        let content = "> - item 1\n> lazy continuation\n> - item 2";
3161        let warnings = lint(content);
3162        assert_eq!(
3163            warnings.len(),
3164            0,
3165            "Blockquoted list with lazy continuation should have no warnings"
3166        );
3167    }
3168
3169    #[test]
3170    fn test_nested_blockquote_list() {
3171        // List inside nested blockquote (>> prefix)
3172        let content = ">> - item 1\n>>   continuation\n>>   - nested\n>> - item 2";
3173        let warnings = lint(content);
3174        assert_eq!(warnings.len(), 0, "Nested blockquote list should have no warnings");
3175    }
3176
3177    #[test]
3178    fn test_blockquote_list_needs_preceding_blank() {
3179        // Blockquote list preceded by non-blank content SHOULD warn
3180        let content = "> Text before\n> - item 1\n> - item 2";
3181        let warnings = lint(content);
3182        assert_eq!(
3183            warnings.len(),
3184            1,
3185            "Should warn for missing blank before blockquoted list"
3186        );
3187    }
3188
3189    #[test]
3190    fn test_blockquote_list_properly_separated() {
3191        // Blockquote list with proper blank lines - no warnings
3192        let content = "> Text before\n>\n> - item 1\n> - item 2\n>\n> Text after";
3193        let warnings = lint(content);
3194        assert_eq!(
3195            warnings.len(),
3196            0,
3197            "Properly separated blockquoted list should have no warnings"
3198        );
3199    }
3200
3201    #[test]
3202    fn test_blockquote_ordered_list() {
3203        // Ordered list in blockquote with continuation
3204        let content = "> 1. item 1\n>    continuation\n> 2. item 2";
3205        let warnings = lint(content);
3206        assert_eq!(warnings.len(), 0, "Ordered list in blockquote should have no warnings");
3207    }
3208
3209    #[test]
3210    fn test_blockquote_list_with_empty_blockquote_line() {
3211        // Empty blockquote line (just ">") between items - still same list
3212        let content = "> - item 1\n>\n> - item 2";
3213        let warnings = lint(content);
3214        assert_eq!(warnings.len(), 0, "Empty blockquote line should not break list");
3215    }
3216
3217    /// Issue #268: Multi-paragraph list items in blockquotes should not trigger false positives
3218    #[test]
3219    fn test_blockquote_list_multi_paragraph_items() {
3220        // List item with blank line + continuation paragraph + next item
3221        // This is a common pattern for multi-paragraph list items in blockquotes
3222        let content = "# Test\n\n> Some intro text\n> \n> * List item 1\n> \n>   Continuation\n> * List item 2\n";
3223        let warnings = lint(content);
3224        assert_eq!(
3225            warnings.len(),
3226            0,
3227            "Multi-paragraph list items in blockquotes should have no warnings. Got: {warnings:?}"
3228        );
3229    }
3230
3231    /// Issue #268: Ordered lists with multi-paragraph items in blockquotes
3232    #[test]
3233    fn test_blockquote_ordered_list_multi_paragraph_items() {
3234        let content = "> 1. First item\n> \n>    Continuation of first\n> 2. Second item\n";
3235        let warnings = lint(content);
3236        assert_eq!(
3237            warnings.len(),
3238            0,
3239            "Ordered multi-paragraph list items in blockquotes should have no warnings. Got: {warnings:?}"
3240        );
3241    }
3242
3243    /// Issue #268: Multiple continuation paragraphs in blockquote list
3244    #[test]
3245    fn test_blockquote_list_multiple_continuations() {
3246        let content = "> - Item 1\n> \n>   First continuation\n> \n>   Second continuation\n> - Item 2\n";
3247        let warnings = lint(content);
3248        assert_eq!(
3249            warnings.len(),
3250            0,
3251            "Multiple continuation paragraphs should not break blockquote list. Got: {warnings:?}"
3252        );
3253    }
3254
3255    /// Issue #268: Nested blockquote (>>) with multi-paragraph list items
3256    #[test]
3257    fn test_nested_blockquote_multi_paragraph_list() {
3258        let content = ">> - Item 1\n>> \n>>   Continuation\n>> - Item 2\n";
3259        let warnings = lint(content);
3260        assert_eq!(
3261            warnings.len(),
3262            0,
3263            "Nested blockquote multi-paragraph list should have no warnings. Got: {warnings:?}"
3264        );
3265    }
3266
3267    /// Issue #268: Triple-nested blockquote (>>>) with multi-paragraph list items
3268    #[test]
3269    fn test_triple_nested_blockquote_multi_paragraph_list() {
3270        let content = ">>> - Item 1\n>>> \n>>>   Continuation\n>>> - Item 2\n";
3271        let warnings = lint(content);
3272        assert_eq!(
3273            warnings.len(),
3274            0,
3275            "Triple-nested blockquote multi-paragraph list should have no warnings. Got: {warnings:?}"
3276        );
3277    }
3278
3279    /// Issue #268: Last item in blockquote list has continuation (edge case)
3280    #[test]
3281    fn test_blockquote_list_last_item_continuation() {
3282        let content = "> - Item 1\n> - Item 2\n> \n>   Continuation of item 2\n";
3283        let warnings = lint(content);
3284        assert_eq!(
3285            warnings.len(),
3286            0,
3287            "Last item with continuation should have no warnings. Got: {warnings:?}"
3288        );
3289    }
3290
3291    /// Issue #268: First item only has continuation in blockquote list
3292    #[test]
3293    fn test_blockquote_list_first_item_only_continuation() {
3294        let content = "> - Item 1\n> \n>   Continuation of item 1\n";
3295        let warnings = lint(content);
3296        assert_eq!(
3297            warnings.len(),
3298            0,
3299            "Single item with continuation should have no warnings. Got: {warnings:?}"
3300        );
3301    }
3302
3303    /// Blockquote level change SHOULD still be detected as list break
3304    /// Note: markdownlint flags BOTH lines in this case - line 1 for missing preceding blank,
3305    /// and line 2 for missing preceding blank (level change)
3306    #[test]
3307    fn test_blockquote_level_change_breaks_list() {
3308        // Going from > to >> should break the list - markdownlint flags both lines
3309        let content = "> - Item in single blockquote\n>> - Item in nested blockquote\n";
3310        let warnings = lint(content);
3311        // markdownlint reports: line 1 (list at start), line 2 (level change)
3312        // For now, accept 0 or more warnings since this is a complex edge case
3313        // The main fix (multi-paragraph items) is more important than this edge case
3314        assert!(
3315            warnings.len() <= 2,
3316            "Blockquote level change warnings should be reasonable. Got: {warnings:?}"
3317        );
3318    }
3319
3320    /// Exiting blockquote SHOULD still be detected as needing blank line
3321    #[test]
3322    fn test_exit_blockquote_needs_blank_before_list() {
3323        // Text after blockquote, then list without blank
3324        let content = "> Blockquote text\n\n- List outside blockquote\n";
3325        let warnings = lint(content);
3326        assert_eq!(
3327            warnings.len(),
3328            0,
3329            "List after blank line outside blockquote should be fine. Got: {warnings:?}"
3330        );
3331
3332        // Without blank line after blockquote - markdownlint flags this
3333        // But rumdl may not flag it due to complexity of detecting "text immediately before list"
3334        // This is an acceptable deviation for now
3335        let content2 = "> Blockquote text\n- List outside blockquote\n";
3336        let warnings2 = lint(content2);
3337        // Accept 0 or 1 - main fix is more important than this edge case
3338        assert!(
3339            warnings2.len() <= 1,
3340            "List after blockquote warnings should be reasonable. Got: {warnings2:?}"
3341        );
3342    }
3343
3344    /// Issue #268: Test all unordered list markers (-, *, +) with multi-paragraph items
3345    #[test]
3346    fn test_blockquote_multi_paragraph_all_unordered_markers() {
3347        // Dash marker
3348        let content_dash = "> - Item 1\n> \n>   Continuation\n> - Item 2\n";
3349        let warnings = lint(content_dash);
3350        assert_eq!(warnings.len(), 0, "Dash marker should work. Got: {warnings:?}");
3351
3352        // Asterisk marker
3353        let content_asterisk = "> * Item 1\n> \n>   Continuation\n> * Item 2\n";
3354        let warnings = lint(content_asterisk);
3355        assert_eq!(warnings.len(), 0, "Asterisk marker should work. Got: {warnings:?}");
3356
3357        // Plus marker
3358        let content_plus = "> + Item 1\n> \n>   Continuation\n> + Item 2\n";
3359        let warnings = lint(content_plus);
3360        assert_eq!(warnings.len(), 0, "Plus marker should work. Got: {warnings:?}");
3361    }
3362
3363    /// Issue #268: Parenthesis-style ordered list markers (1))
3364    #[test]
3365    fn test_blockquote_multi_paragraph_parenthesis_marker() {
3366        let content = "> 1) Item 1\n> \n>    Continuation\n> 2) Item 2\n";
3367        let warnings = lint(content);
3368        assert_eq!(
3369            warnings.len(),
3370            0,
3371            "Parenthesis ordered markers should work. Got: {warnings:?}"
3372        );
3373    }
3374
3375    /// Issue #268: Multi-digit ordered list numbers have wider markers
3376    #[test]
3377    fn test_blockquote_multi_paragraph_multi_digit_numbers() {
3378        // "10. " is 4 chars, so continuation needs 4 spaces
3379        let content = "> 10. Item 10\n> \n>     Continuation of item 10\n> 11. Item 11\n";
3380        let warnings = lint(content);
3381        assert_eq!(
3382            warnings.len(),
3383            0,
3384            "Multi-digit ordered list should work. Got: {warnings:?}"
3385        );
3386    }
3387
3388    /// Issue #268: Continuation with emphasis and other inline formatting
3389    #[test]
3390    fn test_blockquote_multi_paragraph_with_formatting() {
3391        let content = "> - Item with **bold**\n> \n>   Continuation with *emphasis* and `code`\n> - Item 2\n";
3392        let warnings = lint(content);
3393        assert_eq!(
3394            warnings.len(),
3395            0,
3396            "Continuation with inline formatting should work. Got: {warnings:?}"
3397        );
3398    }
3399
3400    /// Issue #268: Multiple items each with their own continuation paragraph
3401    #[test]
3402    fn test_blockquote_multi_paragraph_all_items_have_continuation() {
3403        let content = "> - Item 1\n> \n>   Continuation 1\n> - Item 2\n> \n>   Continuation 2\n> - Item 3\n> \n>   Continuation 3\n";
3404        let warnings = lint(content);
3405        assert_eq!(
3406            warnings.len(),
3407            0,
3408            "All items with continuations should work. Got: {warnings:?}"
3409        );
3410    }
3411
3412    /// Issue #268: Continuation starting with lowercase (tests uppercase heuristic doesn't break this)
3413    #[test]
3414    fn test_blockquote_multi_paragraph_lowercase_continuation() {
3415        let content = "> - Item 1\n> \n>   and this continues the item\n> - Item 2\n";
3416        let warnings = lint(content);
3417        assert_eq!(
3418            warnings.len(),
3419            0,
3420            "Lowercase continuation should work. Got: {warnings:?}"
3421        );
3422    }
3423
3424    /// Issue #268: Continuation starting with uppercase (tests uppercase heuristic is bypassed with proper indent)
3425    #[test]
3426    fn test_blockquote_multi_paragraph_uppercase_continuation() {
3427        let content = "> - Item 1\n> \n>   This continues the item with uppercase\n> - Item 2\n";
3428        let warnings = lint(content);
3429        assert_eq!(
3430            warnings.len(),
3431            0,
3432            "Uppercase continuation with proper indent should work. Got: {warnings:?}"
3433        );
3434    }
3435
3436    /// Issue #268: Mixed ordered and unordered shouldn't affect multi-paragraph handling
3437    #[test]
3438    fn test_blockquote_separate_ordered_unordered_multi_paragraph() {
3439        // Two separate lists in same blockquote
3440        let content = "> - Unordered item\n> \n>   Continuation\n> \n> 1. Ordered item\n> \n>    Continuation\n";
3441        let warnings = lint(content);
3442        // May have warning for missing blank between lists, but not for the continuations
3443        assert!(
3444            warnings.len() <= 1,
3445            "Separate lists with continuations should be reasonable. Got: {warnings:?}"
3446        );
3447    }
3448
3449    /// Issue #268: Blockquote with bare > line (no space) as blank
3450    #[test]
3451    fn test_blockquote_multi_paragraph_bare_marker_blank() {
3452        // Using ">" alone instead of "> " for blank line
3453        let content = "> - Item 1\n>\n>   Continuation\n> - Item 2\n";
3454        let warnings = lint(content);
3455        assert_eq!(warnings.len(), 0, "Bare > as blank line should work. Got: {warnings:?}");
3456    }
3457
3458    #[test]
3459    fn test_blockquote_list_varying_spaces_after_marker() {
3460        // Different spacing after > (1 space vs 3 spaces) but same blockquote level
3461        let content = "> - item 1\n>   continuation with more indent\n> - item 2";
3462        let warnings = lint(content);
3463        assert_eq!(warnings.len(), 0, "Varying spaces after > should not break list");
3464    }
3465
3466    #[test]
3467    fn test_deeply_nested_blockquote_list() {
3468        // Triple-nested blockquote with list
3469        let content = ">>> - item 1\n>>>   continuation\n>>> - item 2";
3470        let warnings = lint(content);
3471        assert_eq!(
3472            warnings.len(),
3473            0,
3474            "Deeply nested blockquote list should have no warnings"
3475        );
3476    }
3477
3478    #[test]
3479    fn test_blockquote_level_change_in_list() {
3480        // Blockquote level changes mid-list - this breaks the list
3481        let content = "> - item 1\n>> - deeper item\n> - item 2";
3482        // Each segment is a separate list context due to blockquote level change
3483        // markdownlint-cli reports 4 warnings for this case
3484        let warnings = lint(content);
3485        assert!(
3486            !warnings.is_empty(),
3487            "Blockquote level change should break list and trigger warnings"
3488        );
3489    }
3490
3491    #[test]
3492    fn test_blockquote_list_with_code_span() {
3493        // List item with inline code in blockquote
3494        let content = "> - item with `code`\n>   continuation\n> - item 2";
3495        let warnings = lint(content);
3496        assert_eq!(
3497            warnings.len(),
3498            0,
3499            "Blockquote list with code span should have no warnings"
3500        );
3501    }
3502
3503    #[test]
3504    fn test_code_span_html_comment_delimiters_no_false_positive() {
3505        // Issue #679: `<!--` and `-->` inside inline code spans on different lines
3506        // must not be parsed as a single multi-line HTML comment. When they were,
3507        // the blank lines between them were treated as "inside a comment"
3508        // (transparent), so MD032 saw the list as lacking surrounding blanks and
3509        // emitted false "list should be followed/preceded by blank line" warnings.
3510        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";
3511        let warnings = lint(content);
3512        assert_eq!(
3513            warnings.len(),
3514            0,
3515            "code-span HTML comment delimiters must not cause MD032 false positives, got: {warnings:?}"
3516        );
3517    }
3518
3519    #[test]
3520    fn test_code_span_html_comment_delimiters_fix_is_idempotent() {
3521        // Issue #679: the false positives above drove a non-converging `--fix`
3522        // loop - each pass inserted a blank line, shifting bytes so the spurious
3523        // comment range re-matched and the rule "found" the missing blank again.
3524        // The correct fix is a no-op because the content is already well-formed.
3525        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";
3526        let fixed = fix(content);
3527        assert_eq!(
3528            fixed, content,
3529            "MD032 fix must be a no-op for content whose only `<!--`/`-->` are inside code spans"
3530        );
3531    }
3532
3533    #[test]
3534    fn test_blockquote_list_at_document_end() {
3535        // List at end of document (no trailing content)
3536        let content = "> Some text\n>\n> - item 1\n> - item 2";
3537        let warnings = lint(content);
3538        assert_eq!(
3539            warnings.len(),
3540            0,
3541            "Blockquote list at document end should have no warnings"
3542        );
3543    }
3544
3545    #[test]
3546    fn test_fix_preserves_blockquote_prefix_before_list() {
3547        // Issue #268: Fix should insert blockquote-prefixed blank lines inside blockquotes
3548        let content = "> Text before
3549> - Item 1
3550> - Item 2";
3551        let fixed = fix(content);
3552
3553        // The blank line inserted before the list should have the blockquote prefix (no trailing space per markdownlint-cli)
3554        let expected = "> Text before
3555>
3556> - Item 1
3557> - Item 2";
3558        assert_eq!(
3559            fixed, expected,
3560            "Fix should insert '>' blank line, not plain blank line"
3561        );
3562    }
3563
3564    #[test]
3565    fn test_fix_preserves_triple_nested_blockquote_prefix_for_list() {
3566        // Triple-nested blockquotes should preserve full prefix
3567        // Per markdownlint-cli, only preceding blank line is required
3568        let content = ">>> Triple nested
3569>>> - Item 1
3570>>> - Item 2
3571>>> More text";
3572        let fixed = fix(content);
3573
3574        // Should insert ">>>" blank line before list only
3575        let expected = ">>> Triple nested
3576>>>
3577>>> - Item 1
3578>>> - Item 2
3579>>> More text";
3580        assert_eq!(
3581            fixed, expected,
3582            "Fix should preserve triple-nested blockquote prefix '>>>'"
3583        );
3584    }
3585
3586    // ==================== Quarto Flavor Tests ====================
3587
3588    fn lint_quarto(content: &str) -> Vec<LintWarning> {
3589        let rule = MD032BlanksAroundLists::default();
3590        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
3591        rule.check(&ctx).unwrap()
3592    }
3593
3594    #[test]
3595    fn test_quarto_list_after_div_open() {
3596        // List immediately after Quarto div opening: div marker is transparent
3597        let content = "Content\n\n::: {.callout-note}\n- Item 1\n- Item 2\n:::\n";
3598        let warnings = lint_quarto(content);
3599        // The blank line before div opening should count as separation
3600        assert!(
3601            warnings.is_empty(),
3602            "Quarto div marker should be transparent before list: {warnings:?}"
3603        );
3604    }
3605
3606    #[test]
3607    fn test_quarto_list_before_div_close() {
3608        // List immediately before Quarto div closing: div close is at end, transparent
3609        let content = "::: {.callout-note}\n\n- Item 1\n- Item 2\n:::\n";
3610        let warnings = lint_quarto(content);
3611        // The div closing marker is at end, should be transparent
3612        assert!(
3613            warnings.is_empty(),
3614            "Quarto div marker should be transparent after list: {warnings:?}"
3615        );
3616    }
3617
3618    #[test]
3619    fn test_quarto_list_needs_blank_without_div() {
3620        // List still needs blank line without div providing separation
3621        let content = "Content\n::: {.callout-note}\n- Item 1\n- Item 2\n:::\n";
3622        let warnings = lint_quarto(content);
3623        // No blank between "Content" and div opening (which is transparent)
3624        // so list appears right after "Content" - needs blank
3625        assert!(
3626            !warnings.is_empty(),
3627            "Should still require blank when not present: {warnings:?}"
3628        );
3629    }
3630
3631    #[test]
3632    fn test_quarto_list_in_callout_with_content() {
3633        // List inside callout with proper blank lines
3634        let content = "::: {.callout-note}\nNote introduction:\n\n- Item 1\n- Item 2\n\nMore note content.\n:::\n";
3635        let warnings = lint_quarto(content);
3636        assert!(
3637            warnings.is_empty(),
3638            "List with proper blanks inside callout should pass: {warnings:?}"
3639        );
3640    }
3641
3642    #[test]
3643    fn test_quarto_div_markers_not_transparent_in_standard_flavor() {
3644        // In standard flavor, ::: is regular text
3645        let content = "Content\n\n:::\n- Item 1\n- Item 2\n:::\n";
3646        let warnings = lint(content); // Uses standard flavor
3647        // In standard, ::: is just text, so list follows ::: without blank
3648        assert!(
3649            !warnings.is_empty(),
3650            "Standard flavor should not treat ::: as transparent: {warnings:?}"
3651        );
3652    }
3653
3654    #[test]
3655    fn test_quarto_nested_divs_with_list() {
3656        // Nested Quarto divs with list inside
3657        let content = "::: {.outer}\n::: {.inner}\n\n- Item 1\n- Item 2\n\n:::\n:::\n";
3658        let warnings = lint_quarto(content);
3659        assert!(warnings.is_empty(), "Nested divs with list should work: {warnings:?}");
3660    }
3661
3662    #[test]
3663    fn test_issue512_complex_nested_list_with_continuation() {
3664        // Three-level nested list with continuation paragraphs at parent indent levels.
3665        // The continuation paragraphs are part of the same list, so no MD032 warning expected.
3666        let content = "\
3667- First level of indentation.
3668  - Second level of indentation.
3669    - Third level of indentation.
3670    - Third level of indentation.
3671
3672    Second level list continuation.
3673
3674  First level list continuation.
3675- First level of indentation.
3676";
3677        let warnings = lint(content);
3678        assert!(
3679            warnings.is_empty(),
3680            "Nested list with parent-level continuation should produce no warnings. Got: {warnings:?}"
3681        );
3682    }
3683
3684    #[test]
3685    fn test_issue512_continuation_at_root_level() {
3686        // Nested list where continuation returns to indent 0 (lazy continuation).
3687        // The unindented "Root level lazy continuation." breaks the list, so the next
3688        // list item needs a blank line before it. markdownlint-cli also warns here.
3689        let content = "\
3690- First level.
3691  - Second level.
3692
3693  First level continuation.
3694
3695Root level lazy continuation.
3696- Another first level item.
3697";
3698        let warnings = lint(content);
3699        assert_eq!(
3700            warnings.len(),
3701            1,
3702            "Should warn on line 7 (new list after break). Got: {warnings:?}"
3703        );
3704        assert_eq!(warnings[0].line, 7);
3705    }
3706
3707    #[test]
3708    fn test_issue512_three_level_nesting_continuation_at_each_level() {
3709        // Each nesting level has a continuation paragraph
3710        let content = "\
3711- Level 1 item.
3712  - Level 2 item.
3713    - Level 3 item.
3714
3715    Level 3 continuation.
3716
3717  Level 2 continuation.
3718
3719  Level 1 continuation (indented under marker).
3720- Another level 1 item.
3721";
3722        let warnings = lint(content);
3723        assert!(
3724            warnings.is_empty(),
3725            "Continuation at each nesting level should produce no warnings. Got: {warnings:?}"
3726        );
3727    }
3728
3729    #[test]
3730    fn test_pandoc_list_after_div_open() {
3731        // List immediately after a Pandoc div opening should not require a blank line,
3732        // mirroring the Quarto behavior tested in test_quarto_list_after_div_open.
3733        let rule = MD032BlanksAroundLists::default();
3734        let content = "Content\n\n::: {.callout-note}\n- Item 1\n- Item 2\n:::\n";
3735        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
3736        let warnings = rule.check(&ctx).unwrap();
3737        assert!(
3738            warnings.is_empty(),
3739            "MD032 should treat Pandoc div marker as transparent before list: {warnings:?}"
3740        );
3741    }
3742
3743    #[test]
3744    fn test_md032_html_comment() {
3745        let rule = MD032BlanksAroundLists::default();
3746        let content = "text\n<!--\n- Item 1\n- Item 2\n-->\ntext";
3747        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3748        let warnings = rule.check(&ctx).unwrap();
3749        assert!(
3750            warnings.is_empty(),
3751            "MD032 should not require blank lines around lists inside HTML comments: {warnings:?}"
3752        );
3753    }
3754
3755    #[test]
3756    fn test_mkdocs_admonition_nested_ordered_list_not_flagged() {
3757        // A properly indented ordered list nested inside a MkDocs
3758        // admonition must not be flagged just because the preceding item's
3759        // text happens to end in sentence-terminating punctuation. The
3760        // fallback "non-1 ordered item not in any known list block" heuristic
3761        // exists for ambiguous prose, not for recognized container content.
3762        let rule = MD032BlanksAroundLists::default();
3763        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";
3764        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
3765        let warnings = rule.check(&ctx).unwrap();
3766        assert!(
3767            warnings.is_empty(),
3768            "admonition-nested ordered list should not be flagged: {warnings:?}"
3769        );
3770    }
3771
3772    #[test]
3773    fn test_mkdocs_admonition_nested_ordered_list_cascade_not_flagged() {
3774        // When item 2 also ends in a period, the false positive previously
3775        // cascaded to item 3 as well (its preceding line then also looked
3776        // like a finished sentence). Both items must stay unflagged.
3777        let rule = MD032BlanksAroundLists::default();
3778        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";
3779        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
3780        let warnings = rule.check(&ctx).unwrap();
3781        assert!(
3782            warnings.is_empty(),
3783            "cascading admonition-nested ordered list should not be flagged: {warnings:?}"
3784        );
3785    }
3786
3787    #[test]
3788    fn test_mkdocs_content_tab_nested_ordered_list_not_flagged() {
3789        // The same false positive occurs inside a MkDocs content tab body.
3790        let rule = MD032BlanksAroundLists::default();
3791        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";
3792        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
3793        let warnings = rule.check(&ctx).unwrap();
3794        assert!(
3795            warnings.is_empty(),
3796            "content-tab-nested ordered list should not be flagged: {warnings:?}"
3797        );
3798    }
3799
3800    #[test]
3801    fn test_mkdocs_admonition_prose_then_non1_item_still_flagged() {
3802        // Prose followed by a non-1 ordered item inside an admonition has the
3803        // same missing-blank ambiguity as outside one: the item cannot
3804        // interrupt the paragraph, so the fallback warning must survive. Only
3805        // stale nested lists (an ordered marker line above at the same
3806        // indent) are exempt.
3807        let rule = MD032BlanksAroundLists::default();
3808        let content = "1. no error here\n\n!!! example\n\n    Intro.\n    2. item\n";
3809        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
3810        let warnings = rule.check(&ctx).unwrap();
3811        assert_eq!(
3812            warnings.len(),
3813            1,
3814            "prose then non-1 item inside an admonition must stay flagged: {warnings:?}"
3815        );
3816    }
3817
3818    #[test]
3819    fn test_mkdocs_admonition_prose_after_list_item_breaks_continuation() {
3820        // A same-indent prose line between a list item and a non-1 item is a
3821        // lazy continuation of the earlier item, so the non-1 item cannot
3822        // start a new item there; the warning must survive even though an
3823        // ordered marker exists further up.
3824        let rule = MD032BlanksAroundLists::default();
3825        let content = "1. no error here\n\n!!! example\n\n    1. one.\n    Intro prose.\n    2. two\n";
3826        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
3827        let warnings = rule.check(&ctx).unwrap();
3828        assert_eq!(
3829            warnings.len(),
3830            1,
3831            "prose at item indent breaks the list continuation, item must stay flagged: {warnings:?}"
3832        );
3833    }
3834
3835    #[test]
3836    fn test_mkdocs_admonition_wrapped_item_continuation_not_flagged() {
3837        // A non-1 item whose predecessor is the wrapped continuation line of
3838        // an earlier item belongs to the same stale nested list: the deeper
3839        // indented continuation must not break the walk back to the item
3840        // marker at the same indent.
3841        let rule = MD032BlanksAroundLists::default();
3842        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";
3843        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
3844        let warnings = rule.check(&ctx).unwrap();
3845        assert!(
3846            warnings.is_empty(),
3847            "wrapped continuation of a nested list item must not be flagged: {warnings:?}"
3848        );
3849    }
3850
3851    #[test]
3852    fn test_mkdocs_ambiguous_prose_non1_ordered_item_still_flagged() {
3853        // Control: outside any recognized container, a non-1 ordered item
3854        // directly following a line that reads as a finished sentence is
3855        // genuinely ambiguous - CommonMark won't parse it as a new list
3856        // item without a blank line, so the fallback heuristic must keep
3857        // warning here under MkDocs flavor exactly as it does elsewhere.
3858        let rule = MD032BlanksAroundLists::default();
3859        let content = "1. no error here\n\nno error here.\n2. error here because previous line ends with a period.\n";
3860        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
3861        let warnings = rule.check(&ctx).unwrap();
3862        assert_eq!(
3863            warnings.len(),
3864            1,
3865            "ambiguous non-1 ordered item outside any container should still be flagged: {warnings:?}"
3866        );
3867        assert_eq!(warnings[0].line, 4);
3868        assert!(warnings[0].message.contains("non-1"));
3869    }
3870
3871    #[test]
3872    fn test_mkdocs_admonition_nested_list_without_trailing_punctuation_not_flagged() {
3873        // Sanity control: with item 1 not ending in sentence punctuation, the
3874        // fallback heuristic already treated item 2 as a sentence continuation
3875        // (no warning) even before the fix. This proves the regression tests
3876        // above are not vacuously passing - the guard is exercised only when
3877        // the preceding text ends in sentence-terminating punctuation.
3878        let rule = MD032BlanksAroundLists::default();
3879        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";
3880        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
3881        let warnings = rule.check(&ctx).unwrap();
3882        assert!(
3883            warnings.is_empty(),
3884            "admonition-nested ordered list without trailing punctuation should not be flagged: {warnings:?}"
3885        );
3886    }
3887
3888    #[test]
3889    fn test_standard_flavor_admonition_indented_list_unchanged() {
3890        // Control: under standard flavor there is no MkDocs admonition
3891        // concept, so the 4-space-indented block after `!!! example` is
3892        // genuine indented code, already excluded by the code-block guard.
3893        // This must stay unflagged, unaffected by the MkDocs-only fix.
3894        let rule = MD032BlanksAroundLists::default();
3895        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";
3896        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3897        let warnings = rule.check(&ctx).unwrap();
3898        assert!(
3899            warnings.is_empty(),
3900            "indented code block under standard flavor should not be flagged: {warnings:?}"
3901        );
3902    }
3903
3904    #[test]
3905    fn test_mkdocs_html_markdown_div_nested_ordered_list_still_flagged() {
3906        // A markdown="1" HTML div is tag-scoped, not indentation-scoped, and
3907        // is detected in every flavor (see in_mkdocs_html_markdown). The
3908        // guard for this fix deliberately checks only in_admonition and
3909        // in_content_tab, so behavior for this div case is unchanged by it.
3910        let rule = MD032BlanksAroundLists::default();
3911        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";
3912        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
3913        let warnings = rule.check(&ctx).unwrap();
3914        assert_eq!(
3915            warnings.len(),
3916            1,
3917            "markdown=\"1\" div nested ordered list behavior must stay unchanged: {warnings:?}"
3918        );
3919        assert_eq!(warnings[0].line, 6);
3920    }
3921
3922    #[test]
3923    fn test_pseudo_list_marker_after_list() {
3924        let content = indoc::indoc! {"
3925            -   Item 1
3926                Item 1 content.
3927
3928            The unsigned-integer types may be written `uN`, with `N` a positive multiple of
3929            8. Unsigned integer types wrap around on overflow; we strongly advise that they
3930            are not used except when those semantics are desired.
3931        "};
3932        let warnings = lint(content);
3933        assert!(
3934            warnings.is_empty(),
3935            "Expected no warnings for pseudo-list marker after list, but got: {warnings:?}"
3936        );
3937    }
3938
3939    #[test]
3940    fn test_pseudo_list_marker_without_preceding_list() {
3941        let content = indoc::indoc! {"
3942            The unsigned-integer types may be written `uN`, with `N` a positive multiple of
3943            8. Unsigned integer types wrap around on overflow; we strongly advise that they
3944            are not used except when those semantics are desired.
3945        "};
3946        let warnings = lint(content);
3947        assert!(
3948            warnings.is_empty(),
3949            "Expected no warnings for pseudo-list marker without preceding list, but got: {warnings:?}"
3950        );
3951    }
3952
3953    #[test]
3954    fn test_no_space_hash_continuation_line_stays_in_its_item() {
3955        // `#2, #3` is recorded as an invalid ATX heading for MD018's benefit but is
3956        // paragraph text to CommonMark, so it neither ends the list block nor earns a
3957        // blank line above it. The only blank line owed here is the one before the
3958        // task list that follows the heading.
3959        let content = indoc::indoc! {"
3960            5. **`M.md`** - the deltas (esp. items #1,
3961               #2, #3, #5, #8).
3962
3963            ---
3964
3965            ## Plan
3966
3967            ### Phase 0
3968            - [ ] task one
3969                  wrapped
3970        "};
3971        let warnings = lint(content);
3972        assert_eq!(
3973            warnings.len(),
3974            1,
3975            "only the task list is missing a blank line, got: {warnings:?}"
3976        );
3977        assert_eq!(warnings[0].line, 9);
3978        assert_eq!(warnings[0].message, "List should be preceded by blank line");
3979
3980        let expected = indoc::indoc! {"
3981            5. **`M.md`** - the deltas (esp. items #1,
3982               #2, #3, #5, #8).
3983
3984            ---
3985
3986            ## Plan
3987
3988            ### Phase 0
3989
3990            - [ ] task one
3991                  wrapped
3992        "};
3993        assert_eq!(fix(content), expected);
3994    }
3995
3996    #[test]
3997    fn test_fix_keeps_tight_continuation_attached_while_fixing_elsewhere() {
3998        // The two-space continuation under `3. item` sits below the content column,
3999        // so it is a lazy continuation of the item and the list block includes it.
4000        // The fix runs whenever the rule has any warning in the document, here the
4001        // heading-hugging list at the end, and must not insert between the item and
4002        // its continuation, which would detach the continuation from the item.
4003        let content = indoc::indoc! {"
4004            1. first
4005
4006            3. item
4007              continuation
4008
4009              1. nested
4010              2. nested
4011
4012            ## Heading
4013            - task
4014        "};
4015        let warnings = lint(content);
4016        assert_eq!(
4017            warnings.iter().map(|w| w.line).collect::<Vec<_>>(),
4018            vec![10],
4019            "only the list after the heading is missing a blank line, got: {warnings:?}"
4020        );
4021
4022        let expected = indoc::indoc! {"
4023            1. first
4024
4025            3. item
4026              continuation
4027
4028              1. nested
4029              2. nested
4030
4031            ## Heading
4032
4033            - task
4034        "};
4035        assert_eq!(fix(content), expected);
4036    }
4037
4038    #[test]
4039    fn test_no_space_hash_lazy_continuation_stays_in_its_item() {
4040        // A lazy continuation line that begins with `#` followed by a digit is
4041        // paragraph text to CommonMark (no space after the `#`), so it belongs to
4042        // the item above it and the list keeps running through it.
4043        let content = indoc::indoc! {"
4044            - item (esp. #1,
4045            #2, #3).
4046            - next item
4047
4048            ## Heading
4049            - task
4050        "};
4051        let warnings = lint(content);
4052        assert_eq!(
4053            warnings.iter().map(|w| w.line).collect::<Vec<_>>(),
4054            vec![6],
4055            "only the list after the heading is missing a blank line, got: {warnings:?}"
4056        );
4057
4058        let expected = indoc::indoc! {"
4059            - item (esp. #1,
4060            #2, #3).
4061            - next item
4062
4063            ## Heading
4064
4065            - task
4066        "};
4067        assert_eq!(fix(content), expected);
4068    }
4069
4070    #[test]
4071    fn test_under_indented_continuation_lines_stay_in_their_item() {
4072        // Paragraph text indented short of the content column is a lazy
4073        // continuation per CommonMark, however short the indent, in and out of a
4074        // blockquote. Neither the check nor the fix separates it from its item.
4075        for content in [
4076            "1. Helps to avoid situations\n  changes that the team might not accept\n  changes are in a direction.\n",
4077            "> 1. Helps to avoid situations\n>   changes that the team might not accept\n>   changes are in a direction.\n",
4078            "- Item\n lazy continuation\n- another item\n",
4079            "> - Item\n>  lazy continuation\n> - another item\n",
4080        ] {
4081            let warnings = lint(content);
4082            assert!(warnings.is_empty(), "{content:?}: got {warnings:?}");
4083            assert_eq!(fix(content), content, "{content:?}");
4084        }
4085    }
4086
4087    #[test]
4088    fn test_under_indented_continuation_lines_are_lazy_when_lazy_is_disallowed() {
4089        // With allow_lazy_continuation = false the same lines are reported as
4090        // lazy continuations, not as a list missing a blank line around it.
4091        let config = MD032Config {
4092            allow_lazy_continuation: false,
4093        };
4094        for (content, lazy_lines) in [
4095            ("- Item\n lazy continuation\n- another item\n", vec![2]),
4096            ("> - Item\n>  lazy continuation\n> - another item\n", vec![2]),
4097            ("> 1. Item\n>   changes that\n>   changes are\n> 2. next\n", vec![2, 3]),
4098        ] {
4099            let warnings = lint_with_config(content, config.clone());
4100            assert!(
4101                warnings.iter().all(|w| w.message.contains("Lazy continuation")),
4102                "{content:?}: got {warnings:?}"
4103            );
4104            assert_eq!(
4105                warnings.iter().map(|w| w.line).collect::<Vec<_>>(),
4106                lazy_lines,
4107                "{content:?}: got {warnings:?}"
4108            );
4109        }
4110    }
4111
4112    #[test]
4113    fn test_structural_line_at_short_indent_ends_the_list() {
4114        // A thematic break or heading cannot be a lazy continuation, so at an
4115        // indent short of the content column it ends the list and the list is
4116        // missing its trailing blank line, in and out of a blockquote.
4117        for (content, expected) in [
4118            ("1. item\n  ---\n", "1. item\n\n  ---\n"),
4119            ("1. item\n  ## Heading\n", "1. item\n\n  ## Heading\n"),
4120            ("> 1. item\n>   ---\n", "> 1. item\n>\n>   ---\n"),
4121            ("> 1. item\n> ---\n", "> 1. item\n>\n> ---\n"),
4122        ] {
4123            let warnings = lint(content);
4124            assert_eq!(
4125                warnings
4126                    .iter()
4127                    .map(|w| (w.line, w.message.as_str()))
4128                    .collect::<Vec<_>>(),
4129                vec![(1, "List should be followed by blank line")],
4130                "{content:?}: got {warnings:?}"
4131            );
4132            assert_eq!(fix(content), expected, "{content:?}");
4133        }
4134    }
4135
4136    #[test]
4137    fn test_html_block_at_short_indent_ends_the_list() {
4138        // A block-level tag interrupts a paragraph, so at an indent short of
4139        // the content column it is not a lazy continuation: it ends the list,
4140        // and a list that follows its closing tag is a new list. Both lists
4141        // are missing a blank line next to the HTML block, whether the tag
4142        // sits at the margin or one column in, and inside a blockquote as much
4143        // as at the root.
4144        for (content, expected) in [
4145            (
4146                "- item\n<script>\nx\n</script>\n- next\n",
4147                "- item\n\n<script>\nx\n</script>\n\n- next\n",
4148            ),
4149            (
4150                "- item\n <script>\n x\n </script>\n- next\n",
4151                "- item\n\n <script>\n x\n </script>\n\n- next\n",
4152            ),
4153            (
4154                "- item\n <pre>\n x\n </pre>\n- next\n",
4155                "- item\n\n <pre>\n x\n </pre>\n\n- next\n",
4156            ),
4157            (
4158                "> - item\n> <script>\n> x\n> </script>\n> - next\n",
4159                "> - item\n>\n> <script>\n> x\n> </script>\n>\n> - next\n",
4160            ),
4161            (
4162                "> - item\n> <pre>\n> x\n> </pre>\n> - next\n",
4163                "> - item\n>\n> <pre>\n> x\n> </pre>\n>\n> - next\n",
4164            ),
4165        ] {
4166            let warnings = lint(content);
4167            assert_eq!(
4168                warnings
4169                    .iter()
4170                    .map(|w| (w.line, w.message.as_str()))
4171                    .collect::<Vec<_>>(),
4172                vec![
4173                    (1, "List should be followed by blank line"),
4174                    (5, "List should be preceded by blank line"),
4175                ],
4176                "{content:?}: got {warnings:?}"
4177            );
4178            assert_eq!(fix(content), expected, "{content:?}");
4179        }
4180    }
4181
4182    #[test]
4183    fn test_html_looking_text_at_short_indent_is_a_lazy_continuation() {
4184        // An HTML block opens only within three columns of indent, and only
4185        // when the tag name ends the way CommonMark requires. A tag indented
4186        // four columns short of a wide item's content column, or a bracket
4187        // that merely begins with a block element's name, is paragraph text
4188        // that lazily continues the item, at the root and inside a blockquote:
4189        // one list, nothing to report, nothing to rewrite. Indent is measured
4190        // in columns, so a tab reaches the fourth column however few bytes
4191        // precede it (a tab right after `>` gives the marker its optional
4192        // space and indents with the rest), and a tag that reaches an outer
4193        // item's content column is that item's own HTML block, however short
4194        // of a nested item it falls.
4195        for content in [
4196            "100. item\n    <div>\n101. next\n",
4197            "> 100. item\n>     <div>\n> 101. next\n",
4198            "100. item\n    <div>\ntext\n101. next\n",
4199            "- item\n<div.class>\n- next\n",
4200            "> - item\n> <div.class>\n> - next\n",
4201            "100. item\n\t<div>\n101. next\n",
4202            "- item\n  \t<div>\n- next\n",
4203            "> - item\n> \t<div>\n> - next\n",
4204            "> - item\n>\t<div>\n> - next\n",
4205            "> 100. item\n>   \t<div>\n> 101. next\n",
4206            "- outer\n  - inner\n   <script>\n   x\n   </script>\n- next\n",
4207            "- outer\n  - inner\n  <script>\n  x\n  </script>\n- next\n",
4208            "> - outer\n>   - inner\n>   <div>\n>   x\n>   </div>\n> - next\n",
4209        ] {
4210            let warnings = lint(content);
4211            assert!(warnings.is_empty(), "{content:?}: got {warnings:?}");
4212            assert_eq!(fix(content), content, "{content:?}");
4213        }
4214
4215        // Control: the same tag one column closer to the margin, or short of
4216        // every item's content column, opens a block and ends the list after
4217        // its last item.
4218        for (content, last_item_line) in [
4219            ("100. item\n   <div>\n101. next\n", 1),
4220            ("> 100. item\n>    <div>\n> 101. next\n", 1),
4221            ("- item\n <div>\n- next\n", 1),
4222            ("> 1. item\n> \t<div>\n> 2. next\n", 1),
4223            ("> 1. item\n>\t<div>\n> 2. next\n", 1),
4224            ("1. outer\n   1. inner\n  <div>\n2. next\n", 2),
4225        ] {
4226            let warnings = lint(content);
4227            assert_eq!(
4228                warnings
4229                    .iter()
4230                    .map(|w| (w.line, w.message.as_str()))
4231                    .collect::<Vec<_>>(),
4232                vec![(last_item_line, "List should be followed by blank line")],
4233                "{content:?}: got {warnings:?}"
4234            );
4235        }
4236    }
4237
4238    #[test]
4239    fn test_tab_indented_nested_list_stays_inside_its_item() {
4240        // A tab before a nested marker reaches the fourth column, past the
4241        // parent item's content column, so the nested list belongs to the
4242        // item however few bytes precede the marker, at the root and inside a
4243        // blockquote: one list, nothing to report, nothing to rewrite.
4244        // Every row nests a list of the OTHER type, since a same-type child
4245        // was grouped with its parent before markers were measured in
4246        // columns; the last row is that same-type shape, kept as the control
4247        // that the column measure did not disturb it.
4248        for content in [
4249            "* item text\n\t1. nested\n\t   more\n",
4250            "* item text\n\tcontinuation\n\t1. nested\n",
4251            "1. item text\n\t- nested\n",
4252            "> * item text\n>\t1. nested\n",
4253            "> * item text\n> \t1. nested\n",
4254            "* item text\n\tcontinuation\n\t- nested\n",
4255        ] {
4256            let warnings = lint(content);
4257            assert!(warnings.is_empty(), "{content:?}: got {warnings:?}");
4258            assert_eq!(fix(content), content, "{content:?}");
4259        }
4260
4261        // Control: a marker one space in falls short of the content column,
4262        // so it starts a new list of another type against the first.
4263        for content in ["* item text\n 1. nested\n", "> * item text\n>  1. nested\n"] {
4264            let warnings = lint(content);
4265            assert_eq!(
4266                warnings
4267                    .iter()
4268                    .map(|w| (w.line, w.message.as_str()))
4269                    .collect::<Vec<_>>(),
4270                vec![
4271                    (1, "List should be followed by blank line"),
4272                    (2, "List should be preceded by blank line"),
4273                ],
4274                "{content:?}: got {warnings:?}"
4275            );
4276        }
4277    }
4278
4279    #[test]
4280    fn test_list_marker_inside_an_unclosed_html_block_is_html() {
4281        // A block opened by a tag such as `<div>` runs to the next blank line,
4282        // so a marker line before that blank is the block's content and not a
4283        // list: the list before the block still needs its blank line, the
4284        // marker line needs nothing, at the root and inside a blockquote.
4285        for (content, expected) in [
4286            (
4287                "- item\n<div>\nx\n</div>\n- next\n",
4288                "- item\n\n<div>\nx\n</div>\n- next\n",
4289            ),
4290            (
4291                "> - item\n> <div>\n> x\n> </div>\n> - next\n",
4292                "> - item\n>\n> <div>\n> x\n> </div>\n> - next\n",
4293            ),
4294        ] {
4295            let warnings = lint(content);
4296            assert_eq!(
4297                warnings
4298                    .iter()
4299                    .map(|w| (w.line, w.message.as_str()))
4300                    .collect::<Vec<_>>(),
4301                vec![(1, "List should be followed by blank line")],
4302                "{content:?}: got {warnings:?}"
4303            );
4304            assert_eq!(fix(content), expected, "{content:?}");
4305        }
4306    }
4307
4308    #[test]
4309    fn test_html_block_at_content_column_is_item_content() {
4310        // The same tags indented to the content column are the item's own
4311        // content, so the list runs through them and nothing is reported.
4312        for content in [
4313            "- item\n  <script>\n  x\n  </script>\n- next\n",
4314            "- item\n  <div>\n  x\n  </div>\n- next\n",
4315            "1. item\n   <pre>\n   x\n   </pre>\n2. next\n",
4316            "> - item\n>   <div>\n>   x\n>   </div>\n> - next\n",
4317        ] {
4318            assert!(lint(content).is_empty(), "{content:?}: got {:?}", lint(content));
4319            assert_eq!(fix(content), content, "{content:?}");
4320        }
4321    }
4322}