Skip to main content

rumdl_lib/rules/
md032_blanks_around_lists.rs

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