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