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