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};
4use crate::utils::calculate_indentation_width_default;
5use crate::utils::pandoc;
6use crate::utils::range_utils::{LineIndex, calculate_line_range};
7use crate::utils::regex_cache::BLOCKQUOTE_PREFIX_RE;
8use regex::Regex;
9use std::sync::LazyLock;
10
11mod md032_config;
12pub(super) use md032_config::MD032Config;
13
14// Detects ordered list items starting with a number other than 1
15static ORDERED_LIST_NON_ONE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*([2-9]|\d{2,})\.\s").unwrap());
16
17/// Check if a line is a thematic break (horizontal rule)
18/// Per CommonMark: 0-3 spaces of indentation, then 3+ of same char (-, *, _), optionally with spaces between
19fn is_thematic_break(line: &str) -> bool {
20    // Per CommonMark, thematic breaks can have 0-3 spaces of indentation (< 4 columns)
21    if calculate_indentation_width_default(line) > 3 {
22        return false;
23    }
24
25    let trimmed = line.trim();
26    if trimmed.len() < 3 {
27        return false;
28    }
29
30    let chars: Vec<char> = trimmed.chars().collect();
31    let first_non_space = chars.iter().find(|&&c| c != ' ');
32
33    if let Some(&marker) = first_non_space {
34        if marker != '-' && marker != '*' && marker != '_' {
35            return false;
36        }
37        let marker_count = chars.iter().filter(|&&c| c == marker).count();
38        let other_count = chars.iter().filter(|&&c| c != marker && c != ' ').count();
39        marker_count >= 3 && other_count == 0
40    } else {
41        false
42    }
43}
44
45/// Rule MD032: Lists should be surrounded by blank lines
46///
47/// This rule enforces that lists are surrounded by blank lines, which improves document
48/// readability and ensures consistent rendering across different Markdown processors.
49///
50/// ## Purpose
51///
52/// - **Readability**: Blank lines create visual separation between lists and surrounding content
53/// - **Parsing**: Many Markdown parsers require blank lines around lists for proper rendering
54/// - **Consistency**: Ensures uniform document structure and appearance
55/// - **Compatibility**: Improves compatibility across different Markdown implementations
56///
57/// ## Examples
58///
59/// ### Correct
60///
61/// ```markdown
62/// This is a paragraph of text.
63///
64/// - Item 1
65/// - Item 2
66/// - Item 3
67///
68/// This is another paragraph.
69/// ```
70///
71/// ### Incorrect
72///
73/// ```markdown
74/// This is a paragraph of text.
75/// - Item 1
76/// - Item 2
77/// - Item 3
78/// This is another paragraph.
79/// ```
80///
81/// ## Behavior Details
82///
83/// This rule checks for the following:
84///
85/// - **List Start**: There should be a blank line before the first item in a list
86///   (unless the list is at the beginning of the document or after front matter)
87/// - **List End**: There should be a blank line after the last item in a list
88///   (unless the list is at the end of the document)
89/// - **Nested Lists**: Properly handles nested lists and list continuations
90/// - **List Types**: Works with ordered lists, unordered lists, and all valid list markers (-, *, +)
91///
92/// ## Special Cases
93///
94/// This rule handles several special cases:
95///
96/// - **Front Matter**: YAML front matter is detected and skipped
97/// - **Code Blocks**: Lists inside code blocks are ignored
98/// - **List Content**: Indented content belonging to list items is properly recognized as part of the list
99/// - **Document Boundaries**: Lists at the beginning or end of the document have adjusted requirements
100///
101/// ## Fix Behavior
102///
103/// When applying automatic fixes, this rule:
104/// - Adds a blank line before the first list item when needed
105/// - Adds a blank line after the last list item when needed
106/// - Preserves document structure and existing content
107///
108/// ## Performance Optimizations
109///
110/// The rule includes several optimizations:
111/// - Fast path checks before applying more expensive regex operations
112/// - Efficient list item detection
113/// - Pre-computation of code block lines to avoid redundant processing
114#[derive(Debug, Clone, Default)]
115pub struct MD032BlanksAroundLists {
116    config: MD032Config,
117}
118
119impl MD032BlanksAroundLists {
120    pub fn from_config_struct(config: MD032Config) -> Self {
121        Self { config }
122    }
123}
124
125impl MD032BlanksAroundLists {
126    /// Check if a blank line should be required before a list based on the previous line context
127    fn should_require_blank_line_before(
128        ctx: &crate::lint_context::LintContext,
129        prev_line_num: usize,
130        current_line_num: usize,
131    ) -> bool {
132        // Always require blank lines after code blocks, front matter, etc.
133        if ctx
134            .line_info(prev_line_num)
135            .is_some_and(|info| info.in_code_block || info.in_front_matter)
136        {
137            return true;
138        }
139
140        // Always allow nested lists (lists indented within other list items)
141        if Self::is_nested_list(ctx, prev_line_num, current_line_num) {
142            return false;
143        }
144
145        // Default: require blank line (matching markdownlint's behavior)
146        true
147    }
148
149    /// Check if the current list is nested within another list item
150    fn is_nested_list(
151        ctx: &crate::lint_context::LintContext,
152        prev_line_num: usize,    // 1-indexed
153        current_line_num: usize, // 1-indexed
154    ) -> bool {
155        // Check if current line is indented (typical for nested lists)
156        if current_line_num > 0 && current_line_num - 1 < ctx.lines.len() {
157            let current_line = &ctx.lines[current_line_num - 1];
158            if current_line.indent >= 2 {
159                // Check if previous line is a list item or list content
160                if prev_line_num > 0 && prev_line_num - 1 < ctx.lines.len() {
161                    let prev_line = &ctx.lines[prev_line_num - 1];
162                    // Previous line is a list item or indented content
163                    if prev_line.list_item.is_some() || prev_line.indent >= 2 {
164                        return true;
165                    }
166                }
167            }
168        }
169        false
170    }
171
172    /// Check if a lazy continuation fix should be applied to a line.
173    /// Returns false for lines inside code blocks, front matter, or HTML comments.
174    fn should_apply_lazy_fix(ctx: &crate::lint_context::LintContext, line_num: usize) -> bool {
175        ctx.lines
176            .get(line_num.saturating_sub(1))
177            .is_some_and(|li| !li.in_code_block && !li.in_front_matter && !li.in_html_comment && !li.in_mdx_comment)
178    }
179
180    /// Calculate the fix for a lazy continuation line.
181    /// Returns the byte range to replace and the replacement string.
182    fn calculate_lazy_continuation_fix(
183        ctx: &crate::lint_context::LintContext,
184        line_num: usize,
185        lazy_info: &LazyContLine,
186    ) -> Option<Fix> {
187        let line_info = ctx.lines.get(line_num.saturating_sub(1))?;
188        let line_content = line_info.content(ctx.content);
189
190        if lazy_info.blockquote_level == 0 {
191            // Regular list (no blockquote): replace leading whitespace with proper indent
192            let start_byte = line_info.byte_offset;
193            let end_byte = start_byte + lazy_info.current_indent;
194            let replacement = " ".repeat(lazy_info.expected_indent);
195
196            Some(Fix::new(start_byte..end_byte, replacement))
197        } else {
198            // List inside blockquote: preserve blockquote prefix, fix indent after it
199            let after_bq = content_after_blockquote(line_content, lazy_info.blockquote_level);
200            let prefix_byte_len = line_content.len().saturating_sub(after_bq.len());
201            if prefix_byte_len == 0 {
202                return None;
203            }
204
205            let current_indent = after_bq.len() - after_bq.trim_start().len();
206            let start_byte = line_info.byte_offset + prefix_byte_len;
207            let end_byte = start_byte + current_indent;
208            let replacement = " ".repeat(lazy_info.expected_indent);
209
210            Some(Fix::new(start_byte..end_byte, replacement))
211        }
212    }
213
214    /// Apply a lazy continuation fix to a single line.
215    /// Replaces the current indentation with the expected indentation.
216    fn apply_lazy_fix_to_line(line: &str, lazy_info: &LazyContLine) -> String {
217        if lazy_info.blockquote_level == 0 {
218            // Regular list: strip current indent, add expected indent
219            let content = line.trim_start();
220            format!("{}{}", " ".repeat(lazy_info.expected_indent), content)
221        } else {
222            // Blockquote list: preserve blockquote prefix, fix indent after it
223            let after_bq = content_after_blockquote(line, lazy_info.blockquote_level);
224            let prefix_len = line.len().saturating_sub(after_bq.len());
225            if prefix_len == 0 {
226                return line.to_string();
227            }
228
229            let prefix = &line[..prefix_len];
230            let rest = after_bq.trim_start();
231            format!("{}{}{}", prefix, " ".repeat(lazy_info.expected_indent), rest)
232        }
233    }
234
235    /// Find the first non-transparent line before the given line (1-indexed).
236    /// Returns (line_num, is_blank) where:
237    /// - line_num is the 1-indexed line of actual content (0 if start of document)
238    /// - is_blank is true if that line is blank (meaning separation exists)
239    ///
240    /// Transparent elements (HTML comments, Quarto div markers) are skipped,
241    /// matching markdownlint-cli behavior.
242    fn find_preceding_content(ctx: &crate::lint_context::LintContext, before_line: usize) -> (usize, bool) {
243        let is_pandoc = ctx.flavor.is_pandoc_compatible();
244        for line_num in (1..before_line).rev() {
245            let idx = line_num - 1;
246            if let Some(info) = ctx.lines.get(idx) {
247                // Skip HTML/MDX comment lines - they're transparent
248                if info.in_html_comment || info.in_mdx_comment {
249                    continue;
250                }
251                // Skip Pandoc/Quarto div markers in Pandoc-compatible flavor - they're transparent
252                if is_pandoc {
253                    let trimmed = info.content(ctx.content).trim();
254                    if pandoc::is_div_open(trimmed) || pandoc::is_div_close(trimmed) {
255                        continue;
256                    }
257                }
258                return (line_num, info.is_blank);
259            }
260        }
261        // Start of document = effectively blank-separated
262        (0, true)
263    }
264
265    /// Find the first non-transparent line after 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 end 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    fn find_following_content(ctx: &crate::lint_context::LintContext, after_line: usize) -> (usize, bool) {
272        let is_pandoc = ctx.flavor.is_pandoc_compatible();
273        let num_lines = ctx.lines.len();
274        for line_num in (after_line + 1)..=num_lines {
275            let idx = line_num - 1;
276            if let Some(info) = ctx.lines.get(idx) {
277                // Skip HTML/MDX comment lines - they're transparent
278                if info.in_html_comment || info.in_mdx_comment {
279                    continue;
280                }
281                // Skip Pandoc/Quarto div markers in Pandoc-compatible flavor - they're transparent
282                if is_pandoc {
283                    let trimmed = info.content(ctx.content).trim();
284                    if pandoc::is_div_open(trimmed) || pandoc::is_div_close(trimmed) {
285                        continue;
286                    }
287                }
288                return (line_num, info.is_blank);
289            }
290        }
291        // End of document = effectively blank-separated
292        (0, true)
293    }
294
295    // Convert centralized list blocks to the format expected by perform_checks
296    fn convert_list_blocks(&self, ctx: &crate::lint_context::LintContext) -> Vec<(usize, usize, String)> {
297        let mut blocks: Vec<(usize, usize, String)> = Vec::new();
298
299        for block in &ctx.list_blocks {
300            // Skip list blocks inside footnote definitions
301            if ctx
302                .line_info(block.start_line)
303                .is_some_and(|info| info.in_footnote_definition)
304            {
305                continue;
306            }
307
308            // For MD032, we need to check if there are code blocks that should
309            // split the list into separate segments
310
311            // Simple approach: if there's a fenced code block between list items,
312            // split at that point
313            let mut segments: Vec<(usize, usize)> = Vec::new();
314            let mut current_start = block.start_line;
315            let mut prev_item_line = 0;
316
317            // Helper to get blockquote level (count of '>' chars) from a line
318            let get_blockquote_level = |line_num: usize| -> usize {
319                if line_num == 0 || line_num > ctx.lines.len() {
320                    return 0;
321                }
322                let line_content = ctx.lines[line_num - 1].content(ctx.content);
323                BLOCKQUOTE_PREFIX_RE
324                    .find(line_content)
325                    .map_or(0, |m| m.as_str().chars().filter(|&c| c == '>').count())
326            };
327
328            let mut prev_bq_level = 0;
329
330            for &item_line in &block.item_lines {
331                let current_bq_level = get_blockquote_level(item_line);
332
333                if prev_item_line > 0 {
334                    // Check if blockquote level changed between items
335                    let blockquote_level_changed = prev_bq_level != current_bq_level;
336
337                    // Check if there's a standalone code fence between prev_item_line and item_line
338                    // A code fence that's indented as part of a list item should NOT split the list
339                    let mut has_standalone_code_fence = false;
340
341                    // Calculate minimum indentation for list item content
342                    let min_indent_for_content = if block.is_ordered {
343                        // For ordered lists, content should be indented at least to align with text after marker
344                        // e.g., "1. " = 3 chars, so content should be indented 3+ spaces
345                        3 // Minimum for "1. "
346                    } else {
347                        // For unordered lists, content should be indented at least 2 spaces
348                        2 // For "- " or "* "
349                    };
350
351                    for check_line in (prev_item_line + 1)..item_line {
352                        if check_line - 1 < ctx.lines.len() {
353                            let line = &ctx.lines[check_line - 1];
354                            let line_content = line.content(ctx.content);
355                            if line.in_code_block
356                                && (line_content.trim().starts_with("```") || line_content.trim().starts_with("~~~"))
357                            {
358                                // Check if this code fence is indented as part of the list item
359                                // If it's indented enough to be part of the list item, it shouldn't split
360                                if line.indent < min_indent_for_content {
361                                    has_standalone_code_fence = true;
362                                    break;
363                                }
364                            }
365                        }
366                    }
367
368                    if has_standalone_code_fence || blockquote_level_changed {
369                        // End current segment before this item
370                        segments.push((current_start, prev_item_line));
371                        current_start = item_line;
372                    }
373                }
374                prev_item_line = item_line;
375                prev_bq_level = current_bq_level;
376            }
377
378            // Add the final segment
379            // For the last segment, end at the last list item (not the full block end)
380            if prev_item_line > 0 {
381                segments.push((current_start, prev_item_line));
382            }
383
384            // Check if this list block was split by code fences
385            let has_code_fence_splits = segments.len() > 1 && {
386                // Check if any segments were created due to code fences
387                let mut found_fence = false;
388                for i in 0..segments.len() - 1 {
389                    let seg_end = segments[i].1;
390                    let next_start = segments[i + 1].0;
391                    // Check if there's a code fence between these segments
392                    for check_line in (seg_end + 1)..next_start {
393                        if check_line - 1 < ctx.lines.len() {
394                            let line = &ctx.lines[check_line - 1];
395                            let line_content = line.content(ctx.content);
396                            if line.in_code_block
397                                && (line_content.trim().starts_with("```") || line_content.trim().starts_with("~~~"))
398                            {
399                                found_fence = true;
400                                break;
401                            }
402                        }
403                    }
404                    if found_fence {
405                        break;
406                    }
407                }
408                found_fence
409            };
410
411            // Convert segments to blocks
412            for (start, end) in &segments {
413                // Extend the end to include any continuation lines immediately after the last item
414                let mut actual_end = *end;
415
416                // If this list was split by code fences, don't extend any segments
417                // They should remain as individual list items for MD032 purposes
418                if !has_code_fence_splits && *end < block.end_line {
419                    // Get the blockquote level for this block
420                    let block_bq_level = block.blockquote_prefix.chars().filter(|&c| c == '>').count();
421
422                    // For blockquote lists, use a simpler min_continuation_indent
423                    // (the content column without the blockquote prefix portion)
424                    let min_continuation_indent = if block_bq_level > 0 {
425                        // For lists in blockquotes, content should align with text after marker
426                        if block.is_ordered {
427                            block.max_marker_width
428                        } else {
429                            2 // "- " or "* "
430                        }
431                    } else {
432                        ctx.lines
433                            .get(*end - 1)
434                            .and_then(|line_info| line_info.list_item.as_ref())
435                            .map_or(2, |item| item.content_column)
436                    };
437
438                    for check_line in (*end + 1)..=block.end_line {
439                        if check_line - 1 < ctx.lines.len() {
440                            let line = &ctx.lines[check_line - 1];
441                            let line_content = line.content(ctx.content);
442                            // Stop at next list item or non-continuation content
443                            if block.item_lines.contains(&check_line) || line.heading.is_some() {
444                                break;
445                            }
446                            // Don't extend through code blocks
447                            if line.in_code_block {
448                                break;
449                            }
450
451                            // Calculate effective indent for blockquote lines
452                            let effective_indent =
453                                effective_indent_in_blockquote(line_content, block_bq_level, line.indent);
454
455                            // Include indented continuation if indent meets threshold
456                            if effective_indent >= min_continuation_indent {
457                                actual_end = check_line;
458                            }
459                            // Include lazy continuation lines for structural purposes
460                            // Per CommonMark, only paragraph text can be lazy continuation
461                            // Thematic breaks, code fences, etc. cannot be lazy continuations
462                            // Always include lazy lines in block range - the config controls whether to WARN
463                            else if !line.is_blank
464                                && line.heading.is_none()
465                                && !block.item_lines.contains(&check_line)
466                                && !is_thematic_break(line_content)
467                            {
468                                // This is a lazy continuation line - include it in the block range
469                                actual_end = check_line;
470                            } else if !line.is_blank {
471                                // Non-blank line that's not a continuation - stop here
472                                break;
473                            }
474                        }
475                    }
476                }
477
478                blocks.push((*start, actual_end, block.blockquote_prefix.clone()));
479            }
480        }
481
482        // Filter out lists entirely inside HTML comments
483        blocks.retain(|(start, end, _)| {
484            // Check if ALL lines of this block are inside HTML comments
485            let all_in_comment = (*start..=*end).all(|line_num| {
486                ctx.lines
487                    .get(line_num - 1)
488                    .is_some_and(|info| info.in_html_comment || info.in_mdx_comment)
489            });
490            !all_in_comment
491        });
492
493        blocks
494    }
495
496    fn perform_checks(
497        &self,
498        ctx: &crate::lint_context::LintContext,
499        lines: &[&str],
500        list_blocks: &[(usize, usize, String)],
501        line_index: &LineIndex,
502    ) -> Vec<LintWarning> {
503        let mut warnings = Vec::new();
504        let num_lines = lines.len();
505
506        // Check for ordered lists starting with non-1 that aren't recognized as lists
507        // These need blank lines before them to be parsed as lists by CommonMark
508        for (line_idx, line) in lines.iter().enumerate() {
509            let line_num = line_idx + 1;
510
511            // Skip if this line is already part of a recognized list
512            let is_in_list = list_blocks
513                .iter()
514                .any(|(start, end, _)| line_num >= *start && line_num <= *end);
515            if is_in_list {
516                continue;
517            }
518
519            // Skip if in code block, front matter, or HTML comment
520            if ctx.line_info(line_num).is_some_and(|info| {
521                info.in_code_block
522                    || info.in_front_matter
523                    || info.in_html_comment
524                    || info.in_mdx_comment
525                    || info.in_html_block
526                    || info.in_jsx_block
527            }) {
528                continue;
529            }
530
531            // Check if this line starts with a number other than 1
532            if ORDERED_LIST_NON_ONE_RE.is_match(line) {
533                // Check if there's a blank line before this
534                if line_idx > 0 {
535                    let prev_line = lines[line_idx - 1];
536                    let prev_is_blank = is_blank_in_context(prev_line);
537                    let prev_excluded = ctx
538                        .line_info(line_idx)
539                        .is_some_and(|info| info.in_code_block || info.in_front_matter);
540
541                    // Check if previous line looks like a sentence continuation
542                    // If the previous line is non-blank text that doesn't end with a sentence
543                    // terminator, this is likely a paragraph continuation, not a list item
544                    // e.g., "...in Chapter\n19. For now..." is a broken sentence, not a list
545                    let prev_trimmed = prev_line.trim();
546                    let is_sentence_continuation = !prev_is_blank
547                        && !prev_trimmed.is_empty()
548                        && !prev_trimmed.ends_with('.')
549                        && !prev_trimmed.ends_with('!')
550                        && !prev_trimmed.ends_with('?')
551                        && !prev_trimmed.ends_with(':')
552                        && !prev_trimmed.ends_with(';')
553                        && !prev_trimmed.ends_with('>')
554                        && !prev_trimmed.ends_with('-')
555                        && !prev_trimmed.ends_with('*');
556
557                    if !prev_is_blank && !prev_excluded && !is_sentence_continuation {
558                        // This ordered list item starting with non-1 needs a blank line before it
559                        let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line);
560
561                        let bq_prefix = ctx.blockquote_prefix_for_blank_line(line_idx);
562                        warnings.push(LintWarning {
563                            line: start_line,
564                            column: start_col,
565                            end_line,
566                            end_column: end_col,
567                            severity: Severity::Warning,
568                            rule_name: Some(self.name().to_string()),
569                            message: "Ordered list starting with non-1 should be preceded by blank line".to_string(),
570                            fix: Some(Fix::new(
571                                line_index.line_col_to_byte_range_with_length(line_num, 1, 0),
572                                format!("{bq_prefix}\n"),
573                            )),
574                        });
575                    }
576
577                    // Also check if a blank line is needed AFTER this ordered list item
578                    // This ensures single-pass idempotency
579                    if line_idx + 1 < num_lines {
580                        let next_line = lines[line_idx + 1];
581                        let next_is_blank = is_blank_in_context(next_line);
582                        let next_excluded = ctx.line_info(line_idx + 2).is_some_and(|info| info.in_front_matter);
583
584                        if !next_is_blank && !next_excluded && !next_line.trim().is_empty() {
585                            // Check if next line is a continuation of this ordered list
586                            // Only other ordered items or indented continuations count;
587                            // unordered list markers are a different list requiring separation
588                            let next_trimmed = next_line.trim_start();
589                            let next_is_ordered_content = ORDERED_LIST_NON_ONE_RE.is_match(next_line)
590                                || next_line.starts_with("1. ")
591                                || (next_line.len() > next_trimmed.len()
592                                    && !next_trimmed.starts_with("- ")
593                                    && !next_trimmed.starts_with("* ")
594                                    && !next_trimmed.starts_with("+ ")); // indented continuation (not a nested unordered list)
595
596                            if !next_is_ordered_content {
597                                let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line);
598                                let bq_prefix = ctx.blockquote_prefix_for_blank_line(line_idx);
599                                warnings.push(LintWarning {
600                                    line: start_line,
601                                    column: start_col,
602                                    end_line,
603                                    end_column: end_col,
604                                    severity: Severity::Warning,
605                                    rule_name: Some(self.name().to_string()),
606                                    message: "List should be followed by blank line".to_string(),
607                                    fix: Some(Fix::new(
608                                        line_index.line_col_to_byte_range_with_length(line_num + 1, 1, 0),
609                                        format!("{bq_prefix}\n"),
610                                    )),
611                                });
612                            }
613                        }
614                    }
615                }
616            }
617        }
618
619        for &(start_line, end_line, ref prefix) in list_blocks {
620            // Skip lists that start inside HTML/MDX comments
621            if ctx
622                .line_info(start_line)
623                .is_some_and(|info| info.in_html_comment || info.in_mdx_comment)
624            {
625                continue;
626            }
627
628            if start_line > 1 {
629                // Look past HTML comments to find actual preceding content
630                let (content_line, has_blank_separation) = Self::find_preceding_content(ctx, start_line);
631
632                // If blank separation exists (through HTML comments), no warning needed
633                if !has_blank_separation && content_line > 0 {
634                    let prev_line_str = lines[content_line - 1];
635                    let is_prev_excluded = ctx
636                        .line_info(content_line)
637                        .is_some_and(|info| info.in_code_block || info.in_front_matter);
638                    let prev_prefix = BLOCKQUOTE_PREFIX_RE.find(prev_line_str).map_or("", |m| m.as_str());
639                    let prefixes_match = prev_prefix.trim() == prefix.trim();
640
641                    // Only require blank lines for content in the same context (same blockquote level)
642                    // and when the context actually requires it
643                    let should_require = Self::should_require_blank_line_before(ctx, content_line, start_line);
644                    if !is_prev_excluded && prefixes_match && should_require {
645                        // Calculate precise character range for the entire list line that needs a blank line before it
646                        let (start_line, start_col, end_line, end_col) =
647                            calculate_line_range(start_line, lines[start_line - 1]);
648
649                        warnings.push(LintWarning {
650                            line: start_line,
651                            column: start_col,
652                            end_line,
653                            end_column: end_col,
654                            severity: Severity::Warning,
655                            rule_name: Some(self.name().to_string()),
656                            message: "List should be preceded by blank line".to_string(),
657                            fix: Some(Fix::new(
658                                line_index.line_col_to_byte_range_with_length(start_line, 1, 0),
659                                format!("{prefix}\n"),
660                            )),
661                        });
662                    }
663                }
664            }
665
666            if end_line < num_lines {
667                // Look past HTML comments to find actual following content
668                let (content_line, has_blank_separation) = Self::find_following_content(ctx, end_line);
669
670                // If blank separation exists (through HTML comments), no warning needed
671                if !has_blank_separation && content_line > 0 {
672                    let next_line_str = lines[content_line - 1];
673                    // Check if next line is excluded - front matter or indented code blocks within lists
674                    // We want blank lines before standalone code blocks, but not within list items
675                    let is_next_excluded = ctx.line_info(content_line).is_some_and(|info| info.in_front_matter)
676                        || (content_line <= ctx.lines.len()
677                            && ctx.lines[content_line - 1].in_code_block
678                            && ctx.lines[content_line - 1].indent >= 2);
679                    let next_prefix = BLOCKQUOTE_PREFIX_RE.find(next_line_str).map_or("", |m| m.as_str());
680
681                    // Check blockquote levels to detect boundary transitions
682                    // If the list ends inside a blockquote but the following line exits the blockquote
683                    // (fewer > chars in prefix), no blank line is needed - the blockquote boundary
684                    // provides semantic separation
685                    let end_line_str = lines[end_line - 1];
686                    let end_line_prefix = BLOCKQUOTE_PREFIX_RE.find(end_line_str).map_or("", |m| m.as_str());
687                    let end_line_bq_level = end_line_prefix.chars().filter(|&c| c == '>').count();
688                    let next_line_bq_level = next_prefix.chars().filter(|&c| c == '>').count();
689                    let exits_blockquote = end_line_bq_level > 0 && next_line_bq_level < end_line_bq_level;
690
691                    let prefixes_match = next_prefix.trim() == prefix.trim();
692
693                    // Do not warn when the immediately following line is a tight continuation
694                    // of the last list item. A tight continuation is any non-blank,
695                    // non-list-item line indented strictly past the last item's marker column.
696                    // Inserting a blank there would structurally separate the continuation
697                    // from its parent item.
698                    let is_tight_continuation_of_last_item = ctx
699                        .lines
700                        .get(end_line - 1)
701                        .and_then(|last_li| last_li.list_item.as_ref())
702                        .is_some_and(|last_item| {
703                            let marker_col = last_item.marker_column;
704                            ctx.lines.get(content_line - 1).is_some_and(|next_li| {
705                                !next_li.is_blank && next_li.list_item.is_none() && next_li.indent > marker_col
706                            })
707                        });
708
709                    // Only require blank lines for content in the same context (same blockquote level)
710                    // Skip if the following line exits a blockquote - boundary provides separation
711                    if !is_next_excluded && prefixes_match && !exits_blockquote && !is_tight_continuation_of_last_item {
712                        // Calculate precise character range for the last line of the list (not the line after)
713                        let (start_line_last, start_col_last, end_line_last, end_col_last) =
714                            calculate_line_range(end_line, lines[end_line - 1]);
715
716                        warnings.push(LintWarning {
717                            line: start_line_last,
718                            column: start_col_last,
719                            end_line: end_line_last,
720                            end_column: end_col_last,
721                            severity: Severity::Warning,
722                            rule_name: Some(self.name().to_string()),
723                            message: "List should be followed by blank line".to_string(),
724                            fix: Some(Fix::new(
725                                line_index.line_col_to_byte_range_with_length(end_line + 1, 1, 0),
726                                format!("{prefix}\n"),
727                            )),
728                        });
729                    }
730                }
731            }
732        }
733        warnings
734    }
735}
736
737impl Rule for MD032BlanksAroundLists {
738    fn name(&self) -> &'static str {
739        "MD032"
740    }
741
742    fn description(&self) -> &'static str {
743        "Lists should be surrounded by blank lines"
744    }
745
746    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
747        let lines = ctx.raw_lines();
748        let line_index = &ctx.line_index;
749
750        // Early return for empty content
751        if lines.is_empty() {
752            return Ok(Vec::new());
753        }
754
755        let list_blocks = self.convert_list_blocks(ctx);
756
757        if list_blocks.is_empty() {
758            return Ok(Vec::new());
759        }
760
761        let mut warnings = self.perform_checks(ctx, lines, &list_blocks, line_index);
762
763        // When lazy continuation is not allowed, detect and warn about lazy continuation
764        // lines WITHIN list blocks (text that continues a list item but with less
765        // indentation than expected). Lazy continuation at the END of list blocks is
766        // already handled by the segment extension logic above.
767        if !self.config.allow_lazy_continuation {
768            let lazy_cont_lines = ctx.lazy_continuation_lines();
769
770            for lazy_info in lazy_cont_lines.iter() {
771                let line_num = lazy_info.line_num;
772
773                // Only warn about lazy continuation lines that are WITHIN a list block
774                // (i.e., between list items). End-of-block lazy continuation is already
775                // handled by the existing "list should be followed by blank line" logic.
776                let is_within_block = list_blocks
777                    .iter()
778                    .any(|(start, end, _)| line_num >= *start && line_num <= *end);
779
780                if !is_within_block {
781                    continue;
782                }
783
784                // Get the expected indent for context in the warning message
785                let line_content = lines.get(line_num.saturating_sub(1)).unwrap_or(&"");
786                let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line_content);
787
788                // Calculate fix: add proper indentation to the lazy continuation line
789                let fix = if Self::should_apply_lazy_fix(ctx, line_num) {
790                    Self::calculate_lazy_continuation_fix(ctx, line_num, lazy_info)
791                } else {
792                    None
793                };
794
795                warnings.push(LintWarning {
796                    line: start_line,
797                    column: start_col,
798                    end_line,
799                    end_column: end_col,
800                    severity: Severity::Warning,
801                    rule_name: Some(self.name().to_string()),
802                    message: "Lazy continuation line should be properly indented or preceded by blank line".to_string(),
803                    fix,
804                });
805            }
806        }
807
808        Ok(warnings)
809    }
810
811    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
812        Ok(self.fix_with_structure_impl(ctx))
813    }
814
815    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
816        // Skip if no list blocks exist (includes ordered and unordered lists)
817        // Note: list_blocks is pre-computed in LintContext, so this is already efficient
818        ctx.content.is_empty() || ctx.list_blocks.is_empty()
819    }
820
821    fn category(&self) -> RuleCategory {
822        RuleCategory::List
823    }
824
825    fn as_any(&self) -> &dyn std::any::Any {
826        self
827    }
828
829    fn default_config_section(&self) -> Option<(String, toml::Value)> {
830        use crate::rule_config_serde::RuleConfig;
831        let default_config = MD032Config::default();
832        let json_value = serde_json::to_value(&default_config).ok()?;
833        let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
834
835        if let toml::Value::Table(table) = toml_value {
836            if !table.is_empty() {
837                Some((MD032Config::RULE_NAME.to_string(), toml::Value::Table(table)))
838            } else {
839                None
840            }
841        } else {
842            None
843        }
844    }
845
846    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
847    where
848        Self: Sized,
849    {
850        let rule_config = crate::rule_config_serde::load_rule_config::<MD032Config>(config);
851        Box::new(MD032BlanksAroundLists::from_config_struct(rule_config))
852    }
853}
854
855impl MD032BlanksAroundLists {
856    /// Helper method for fixing implementation
857    fn fix_with_structure_impl(&self, ctx: &crate::lint_context::LintContext) -> String {
858        let lines = ctx.raw_lines();
859        let num_lines = lines.len();
860        if num_lines == 0 {
861            return String::new();
862        }
863
864        let list_blocks = self.convert_list_blocks(ctx);
865        if list_blocks.is_empty() {
866            return ctx.content.to_string();
867        }
868
869        // Phase 0: Collect lazy continuation line fixes (if not allowed)
870        // Map of line_num -> LazyContLine for applying fixes
871        let mut lazy_fixes: std::collections::BTreeMap<usize, LazyContLine> = std::collections::BTreeMap::new();
872        if !self.config.allow_lazy_continuation {
873            let lazy_cont_lines = ctx.lazy_continuation_lines();
874            for lazy_info in lazy_cont_lines.iter() {
875                let line_num = lazy_info.line_num;
876                // Only fix lines within a list block
877                let is_within_block = list_blocks
878                    .iter()
879                    .any(|(start, end, _)| line_num >= *start && line_num <= *end);
880                if !is_within_block {
881                    continue;
882                }
883                // Only fix if not in code block, front matter, or HTML comment
884                if !Self::should_apply_lazy_fix(ctx, line_num) {
885                    continue;
886                }
887                lazy_fixes.insert(line_num, lazy_info.clone());
888            }
889        }
890
891        let mut insertions: std::collections::BTreeMap<usize, String> = std::collections::BTreeMap::new();
892
893        // Phase 1: Identify needed insertions
894        for &(start_line, end_line, ref prefix) in &list_blocks {
895            // Skip lists where this rule is disabled by inline config
896            if ctx.inline_config().is_rule_disabled("MD032", start_line) {
897                continue;
898            }
899
900            // Skip lists that start inside HTML/MDX comments
901            if ctx
902                .line_info(start_line)
903                .is_some_and(|info| info.in_html_comment || info.in_mdx_comment)
904            {
905                continue;
906            }
907
908            // Check before block
909            if start_line > 1 {
910                // Look past HTML comments to find actual preceding content
911                let (content_line, has_blank_separation) = Self::find_preceding_content(ctx, start_line);
912
913                // If blank separation exists (through HTML comments), no fix needed
914                if !has_blank_separation && content_line > 0 {
915                    let prev_line_str = lines[content_line - 1];
916                    let is_prev_excluded = ctx
917                        .line_info(content_line)
918                        .is_some_and(|info| info.in_code_block || info.in_front_matter);
919                    let prev_prefix = BLOCKQUOTE_PREFIX_RE.find(prev_line_str).map_or("", |m| m.as_str());
920
921                    let should_require = Self::should_require_blank_line_before(ctx, content_line, start_line);
922                    // Compare trimmed prefixes to handle varying whitespace after > markers
923                    if !is_prev_excluded && prev_prefix.trim() == prefix.trim() && should_require {
924                        // Use centralized helper for consistent blockquote prefix (no trailing space)
925                        let bq_prefix = ctx.blockquote_prefix_for_blank_line(start_line - 1);
926                        insertions.insert(start_line, bq_prefix);
927                    }
928                }
929            }
930
931            // Check after block
932            if end_line < num_lines {
933                // Look past HTML comments to find actual following content
934                let (content_line, has_blank_separation) = Self::find_following_content(ctx, end_line);
935
936                // If blank separation exists (through HTML comments), no fix needed
937                if !has_blank_separation && content_line > 0 {
938                    let next_line_str = lines[content_line - 1];
939                    // Check if next line is excluded - in code block, front matter, or starts an indented code block
940                    let is_next_excluded = ctx
941                        .line_info(content_line)
942                        .is_some_and(|info| info.in_code_block || info.in_front_matter)
943                        || (content_line <= ctx.lines.len()
944                            && ctx.lines[content_line - 1].in_code_block
945                            && ctx.lines[content_line - 1].indent >= 2
946                            && (ctx.lines[content_line - 1]
947                                .content(ctx.content)
948                                .trim()
949                                .starts_with("```")
950                                || ctx.lines[content_line - 1]
951                                    .content(ctx.content)
952                                    .trim()
953                                    .starts_with("~~~")));
954                    let next_prefix = BLOCKQUOTE_PREFIX_RE.find(next_line_str).map_or("", |m| m.as_str());
955
956                    // Check blockquote levels to detect boundary transitions
957                    let end_line_str = lines[end_line - 1];
958                    let end_line_prefix = BLOCKQUOTE_PREFIX_RE.find(end_line_str).map_or("", |m| m.as_str());
959                    let end_line_bq_level = end_line_prefix.chars().filter(|&c| c == '>').count();
960                    let next_line_bq_level = next_prefix.chars().filter(|&c| c == '>').count();
961                    let exits_blockquote = end_line_bq_level > 0 && next_line_bq_level < end_line_bq_level;
962
963                    // Compare trimmed prefixes to handle varying whitespace after > markers
964                    // Skip if exiting a blockquote - boundary provides separation
965                    if !is_next_excluded && next_prefix.trim() == prefix.trim() && !exits_blockquote {
966                        // Use centralized helper for consistent blockquote prefix (no trailing space)
967                        let bq_prefix = ctx.blockquote_prefix_for_blank_line(end_line - 1);
968                        insertions.insert(end_line + 1, bq_prefix);
969                    }
970                }
971            }
972        }
973
974        // Phase 2: Reconstruct with insertions and lazy fixes
975        let mut result_lines: Vec<String> = Vec::with_capacity(num_lines + insertions.len());
976        for (i, line) in lines.iter().enumerate() {
977            let current_line_num = i + 1;
978            if let Some(prefix_to_insert) = insertions.get(&current_line_num)
979                && (result_lines.is_empty() || result_lines.last().unwrap() != prefix_to_insert)
980            {
981                result_lines.push(prefix_to_insert.clone());
982            }
983
984            // Apply lazy continuation fix if needed (skip if rule is disabled for this line)
985            if let Some(lazy_info) = lazy_fixes.get(&current_line_num)
986                && !ctx.inline_config().is_rule_disabled("MD032", current_line_num)
987            {
988                let fixed_line = Self::apply_lazy_fix_to_line(line, lazy_info);
989                result_lines.push(fixed_line);
990            } else {
991                result_lines.push(line.to_string());
992            }
993        }
994
995        // Preserve the final newline if the original content had one
996        let mut result = result_lines.join("\n");
997        if ctx.content.ends_with('\n') {
998            result.push('\n');
999        }
1000        result
1001    }
1002}
1003
1004// Checks if a line is blank, considering blockquote context
1005fn is_blank_in_context(line: &str) -> bool {
1006    // A line is blank if it's empty or contains only whitespace,
1007    // potentially after removing blockquote markers.
1008    if let Some(m) = BLOCKQUOTE_PREFIX_RE.find(line) {
1009        // If a blockquote prefix is found, check if the content *after* the prefix is blank.
1010        line[m.end()..].trim().is_empty()
1011    } else {
1012        // No blockquote prefix, check the whole line for blankness.
1013        line.trim().is_empty()
1014    }
1015}
1016
1017#[cfg(test)]
1018mod tests {
1019    use super::*;
1020    use crate::lint_context::LintContext;
1021    use crate::rule::Rule;
1022
1023    fn lint(content: &str) -> Vec<LintWarning> {
1024        let rule = MD032BlanksAroundLists::default();
1025        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1026        rule.check(&ctx).expect("Lint check failed")
1027    }
1028
1029    fn fix(content: &str) -> String {
1030        let rule = MD032BlanksAroundLists::default();
1031        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1032        rule.fix(&ctx).expect("Lint fix failed")
1033    }
1034
1035    // Test that warnings include Fix objects
1036    fn check_warnings_have_fixes(content: &str) {
1037        let warnings = lint(content);
1038        for warning in &warnings {
1039            assert!(warning.fix.is_some(), "Warning should have fix: {warning:?}");
1040        }
1041    }
1042
1043    #[test]
1044    fn test_list_at_start() {
1045        // Per markdownlint-cli: trailing text without blank line is treated as lazy continuation
1046        // so NO warning is expected here
1047        let content = "- Item 1\n- Item 2\nText";
1048        let warnings = lint(content);
1049        assert_eq!(
1050            warnings.len(),
1051            0,
1052            "Trailing text is lazy continuation per CommonMark - no warning expected"
1053        );
1054    }
1055
1056    #[test]
1057    fn test_list_at_end() {
1058        let content = "Text\n- Item 1\n- Item 2";
1059        let warnings = lint(content);
1060        assert_eq!(
1061            warnings.len(),
1062            1,
1063            "Expected 1 warning for list at end without preceding blank line"
1064        );
1065        assert_eq!(
1066            warnings[0].line, 2,
1067            "Warning should be on the first line of the list (line 2)"
1068        );
1069        assert!(warnings[0].message.contains("preceded by blank line"));
1070
1071        // Test that warning has fix
1072        check_warnings_have_fixes(content);
1073
1074        let fixed_content = fix(content);
1075        assert_eq!(fixed_content, "Text\n\n- Item 1\n- Item 2");
1076
1077        // Verify fix resolves the issue
1078        let warnings_after_fix = lint(&fixed_content);
1079        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1080    }
1081
1082    #[test]
1083    fn test_list_in_middle() {
1084        // Per markdownlint-cli: only preceding blank line is required
1085        // Trailing text is treated as lazy continuation
1086        let content = "Text 1\n- Item 1\n- Item 2\nText 2";
1087        let warnings = lint(content);
1088        assert_eq!(
1089            warnings.len(),
1090            1,
1091            "Expected 1 warning for list needing preceding blank line (trailing text is lazy continuation)"
1092        );
1093        assert_eq!(warnings[0].line, 2, "Warning on line 2 (start)");
1094        assert!(warnings[0].message.contains("preceded by blank line"));
1095
1096        // Test that warnings have fixes
1097        check_warnings_have_fixes(content);
1098
1099        let fixed_content = fix(content);
1100        assert_eq!(fixed_content, "Text 1\n\n- Item 1\n- Item 2\nText 2");
1101
1102        // Verify fix resolves the issue
1103        let warnings_after_fix = lint(&fixed_content);
1104        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1105    }
1106
1107    #[test]
1108    fn test_correct_spacing() {
1109        let content = "Text 1\n\n- Item 1\n- Item 2\n\nText 2";
1110        let warnings = lint(content);
1111        assert_eq!(warnings.len(), 0, "Expected no warnings for correctly spaced list");
1112
1113        let fixed_content = fix(content);
1114        assert_eq!(fixed_content, content, "Fix should not change correctly spaced content");
1115    }
1116
1117    #[test]
1118    fn test_list_with_content() {
1119        // Per markdownlint-cli: only preceding blank line warning
1120        // Trailing text is lazy continuation
1121        let content = "Text\n* Item 1\n  Content\n* Item 2\n  More content\nText";
1122        let warnings = lint(content);
1123        assert_eq!(
1124            warnings.len(),
1125            1,
1126            "Expected 1 warning for list needing preceding blank line. Got: {warnings:?}"
1127        );
1128        assert_eq!(warnings[0].line, 2, "Warning should be on line 2 (start)");
1129        assert!(warnings[0].message.contains("preceded by blank line"));
1130
1131        // Test that warnings have fixes
1132        check_warnings_have_fixes(content);
1133
1134        let fixed_content = fix(content);
1135        let expected_fixed = "Text\n\n* Item 1\n  Content\n* Item 2\n  More content\nText";
1136        assert_eq!(
1137            fixed_content, expected_fixed,
1138            "Fix did not produce the expected output. Got:\n{fixed_content}"
1139        );
1140
1141        // Verify fix resolves the issue
1142        let warnings_after_fix = lint(&fixed_content);
1143        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1144    }
1145
1146    #[test]
1147    fn test_nested_list() {
1148        // Per markdownlint-cli: only preceding blank line warning
1149        let content = "Text\n- Item 1\n  - Nested 1\n- Item 2\nText";
1150        let warnings = lint(content);
1151        assert_eq!(
1152            warnings.len(),
1153            1,
1154            "Nested list block needs preceding blank only. Got: {warnings:?}"
1155        );
1156        assert_eq!(warnings[0].line, 2);
1157        assert!(warnings[0].message.contains("preceded by blank line"));
1158
1159        // Test that warnings have fixes
1160        check_warnings_have_fixes(content);
1161
1162        let fixed_content = fix(content);
1163        assert_eq!(fixed_content, "Text\n\n- Item 1\n  - Nested 1\n- Item 2\nText");
1164
1165        // Verify fix resolves the issue
1166        let warnings_after_fix = lint(&fixed_content);
1167        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1168    }
1169
1170    #[test]
1171    fn test_list_with_internal_blanks() {
1172        // Per markdownlint-cli: only preceding blank line warning
1173        let content = "Text\n* Item 1\n\n  More Item 1 Content\n* Item 2\nText";
1174        let warnings = lint(content);
1175        assert_eq!(
1176            warnings.len(),
1177            1,
1178            "List with internal blanks needs preceding blank only. Got: {warnings:?}"
1179        );
1180        assert_eq!(warnings[0].line, 2);
1181        assert!(warnings[0].message.contains("preceded by blank line"));
1182
1183        // Test that warnings have fixes
1184        check_warnings_have_fixes(content);
1185
1186        let fixed_content = fix(content);
1187        assert_eq!(
1188            fixed_content,
1189            "Text\n\n* Item 1\n\n  More Item 1 Content\n* Item 2\nText"
1190        );
1191
1192        // Verify fix resolves the issue
1193        let warnings_after_fix = lint(&fixed_content);
1194        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1195    }
1196
1197    #[test]
1198    fn test_ignore_code_blocks() {
1199        let content = "```\n- Not a list item\n```\nText";
1200        let warnings = lint(content);
1201        assert_eq!(warnings.len(), 0);
1202        let fixed_content = fix(content);
1203        assert_eq!(fixed_content, content);
1204    }
1205
1206    #[test]
1207    fn test_ignore_front_matter() {
1208        // Per markdownlint-cli: NO warnings - front matter is followed by list, trailing text is lazy continuation
1209        let content = "---\ntitle: Test\n---\n- List Item\nText";
1210        let warnings = lint(content);
1211        assert_eq!(
1212            warnings.len(),
1213            0,
1214            "Front matter test should have no MD032 warnings. Got: {warnings:?}"
1215        );
1216
1217        // No fixes needed since no warnings
1218        let fixed_content = fix(content);
1219        assert_eq!(fixed_content, content, "No changes when no warnings");
1220    }
1221
1222    #[test]
1223    fn test_multiple_lists() {
1224        // Our implementation treats "Text 2" and "Text 3" as lazy continuation within a single merged list block
1225        // (since both - and * are unordered markers and there's no structural separator)
1226        // markdownlint-cli sees them as separate lists with 3 warnings, but our behavior differs.
1227        // The key requirement is that the fix resolves all warnings.
1228        let content = "Text\n- List 1 Item 1\n- List 1 Item 2\nText 2\n* List 2 Item 1\nText 3";
1229        let warnings = lint(content);
1230        // At minimum we should warn about missing preceding blank for line 2
1231        assert!(
1232            !warnings.is_empty(),
1233            "Should have at least one warning for missing blank line. Got: {warnings:?}"
1234        );
1235
1236        // Test that warnings have fixes
1237        check_warnings_have_fixes(content);
1238
1239        let fixed_content = fix(content);
1240        // The fix should add blank lines before lists that need them
1241        let warnings_after_fix = lint(&fixed_content);
1242        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1243    }
1244
1245    #[test]
1246    fn test_adjacent_lists() {
1247        let content = "- List 1\n\n* List 2";
1248        let warnings = lint(content);
1249        assert_eq!(warnings.len(), 0);
1250        let fixed_content = fix(content);
1251        assert_eq!(fixed_content, content);
1252    }
1253
1254    #[test]
1255    fn test_list_in_blockquote() {
1256        // Per markdownlint-cli: 1 warning (preceding only, trailing is lazy continuation)
1257        let content = "> Quote line 1\n> - List item 1\n> - List item 2\n> Quote line 2";
1258        let warnings = lint(content);
1259        assert_eq!(
1260            warnings.len(),
1261            1,
1262            "Expected 1 warning for blockquoted list needing preceding blank. Got: {warnings:?}"
1263        );
1264        assert_eq!(warnings[0].line, 2);
1265
1266        // Test that warnings have fixes
1267        check_warnings_have_fixes(content);
1268
1269        let fixed_content = fix(content);
1270        // Fix should add blank line before list only (no trailing space per markdownlint-cli)
1271        assert_eq!(
1272            fixed_content, "> Quote line 1\n>\n> - List item 1\n> - List item 2\n> Quote line 2",
1273            "Fix for blockquoted list failed. Got:\n{fixed_content}"
1274        );
1275
1276        // Verify fix resolves the issue
1277        let warnings_after_fix = lint(&fixed_content);
1278        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1279    }
1280
1281    #[test]
1282    fn test_ordered_list() {
1283        // Per markdownlint-cli: 1 warning (preceding only)
1284        let content = "Text\n1. Item 1\n2. Item 2\nText";
1285        let warnings = lint(content);
1286        assert_eq!(warnings.len(), 1);
1287
1288        // Test that warnings have fixes
1289        check_warnings_have_fixes(content);
1290
1291        let fixed_content = fix(content);
1292        assert_eq!(fixed_content, "Text\n\n1. Item 1\n2. Item 2\nText");
1293
1294        // Verify fix resolves the issue
1295        let warnings_after_fix = lint(&fixed_content);
1296        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1297    }
1298
1299    #[test]
1300    fn test_no_double_blank_fix() {
1301        // Per markdownlint-cli: trailing text is lazy continuation, so NO warning needed
1302        let content = "Text\n\n- Item 1\n- Item 2\nText"; // Has preceding blank, trailing is lazy
1303        let warnings = lint(content);
1304        assert_eq!(
1305            warnings.len(),
1306            0,
1307            "Should have no warnings - properly preceded, trailing is lazy"
1308        );
1309
1310        let fixed_content = fix(content);
1311        assert_eq!(
1312            fixed_content, content,
1313            "No fix needed when no warnings. Got:\n{fixed_content}"
1314        );
1315
1316        let content2 = "Text\n- Item 1\n- Item 2\n\nText"; // Missing blank before
1317        let warnings2 = lint(content2);
1318        assert_eq!(warnings2.len(), 1);
1319        if !warnings2.is_empty() {
1320            assert_eq!(
1321                warnings2[0].line, 2,
1322                "Warning line for missing blank before should be the first line of the block"
1323            );
1324        }
1325
1326        // Test that warnings have fixes
1327        check_warnings_have_fixes(content2);
1328
1329        let fixed_content2 = fix(content2);
1330        assert_eq!(
1331            fixed_content2, "Text\n\n- Item 1\n- Item 2\n\nText",
1332            "Fix added extra blank before. Got:\n{fixed_content2}"
1333        );
1334    }
1335
1336    #[test]
1337    fn test_empty_input() {
1338        let content = "";
1339        let warnings = lint(content);
1340        assert_eq!(warnings.len(), 0);
1341        let fixed_content = fix(content);
1342        assert_eq!(fixed_content, "");
1343    }
1344
1345    #[test]
1346    fn test_only_list() {
1347        let content = "- Item 1\n- Item 2";
1348        let warnings = lint(content);
1349        assert_eq!(warnings.len(), 0);
1350        let fixed_content = fix(content);
1351        assert_eq!(fixed_content, content);
1352    }
1353
1354    // === COMPREHENSIVE FIX TESTS ===
1355
1356    #[test]
1357    fn test_fix_complex_nested_blockquote() {
1358        // Per markdownlint-cli: 1 warning (preceding only)
1359        let content = "> Text before\n> - Item 1\n>   - Nested item\n> - Item 2\n> Text after";
1360        let warnings = lint(content);
1361        assert_eq!(
1362            warnings.len(),
1363            1,
1364            "Should warn for missing preceding blank only. Got: {warnings:?}"
1365        );
1366
1367        // Test that warnings have fixes
1368        check_warnings_have_fixes(content);
1369
1370        let fixed_content = fix(content);
1371        // Per markdownlint-cli, blank lines in blockquotes have no trailing space
1372        let expected = "> Text before\n>\n> - Item 1\n>   - Nested item\n> - Item 2\n> Text after";
1373        assert_eq!(fixed_content, expected, "Fix should preserve blockquote structure");
1374
1375        let warnings_after_fix = lint(&fixed_content);
1376        assert_eq!(warnings_after_fix.len(), 0, "Fix should eliminate all warnings");
1377    }
1378
1379    #[test]
1380    fn test_fix_mixed_list_markers() {
1381        // Per markdownlint-cli: mixed markers may be treated as separate lists
1382        // The exact behavior depends on implementation details
1383        let content = "Text\n- Item 1\n* Item 2\n+ Item 3\nText";
1384        let warnings = lint(content);
1385        // At minimum, there should be a warning for the first list needing preceding blank
1386        assert!(
1387            !warnings.is_empty(),
1388            "Should have at least 1 warning for mixed marker list. Got: {warnings:?}"
1389        );
1390
1391        // Test that warnings have fixes
1392        check_warnings_have_fixes(content);
1393
1394        let fixed_content = fix(content);
1395        // The fix should add at least a blank line before the first list
1396        assert!(
1397            fixed_content.contains("Text\n\n-"),
1398            "Fix should add blank line before first list item"
1399        );
1400
1401        // Verify fix resolves the issue
1402        let warnings_after_fix = lint(&fixed_content);
1403        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1404    }
1405
1406    #[test]
1407    fn test_fix_ordered_list_with_different_numbers() {
1408        // Per markdownlint-cli: 1 warning (preceding only)
1409        let content = "Text\n1. First\n3. Third\n2. Second\nText";
1410        let warnings = lint(content);
1411        assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1412
1413        // Test that warnings have fixes
1414        check_warnings_have_fixes(content);
1415
1416        let fixed_content = fix(content);
1417        let expected = "Text\n\n1. First\n3. Third\n2. Second\nText";
1418        assert_eq!(
1419            fixed_content, expected,
1420            "Fix should handle ordered lists with non-sequential numbers"
1421        );
1422
1423        // Verify fix resolves the issue
1424        let warnings_after_fix = lint(&fixed_content);
1425        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1426    }
1427
1428    #[test]
1429    fn test_fix_list_with_code_blocks_inside() {
1430        // Per markdownlint-cli: 1 warning (preceding only)
1431        let content = "Text\n- Item 1\n  ```\n  code\n  ```\n- Item 2\nText";
1432        let warnings = lint(content);
1433        assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1434
1435        // Test that warnings have fixes
1436        check_warnings_have_fixes(content);
1437
1438        let fixed_content = fix(content);
1439        let expected = "Text\n\n- Item 1\n  ```\n  code\n  ```\n- Item 2\nText";
1440        assert_eq!(
1441            fixed_content, expected,
1442            "Fix should handle lists with internal code blocks"
1443        );
1444
1445        // Verify fix resolves the issue
1446        let warnings_after_fix = lint(&fixed_content);
1447        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1448    }
1449
1450    #[test]
1451    fn test_fix_deeply_nested_lists() {
1452        // Per markdownlint-cli: 1 warning (preceding only)
1453        let content = "Text\n- Level 1\n  - Level 2\n    - Level 3\n      - Level 4\n- Back to Level 1\nText";
1454        let warnings = lint(content);
1455        assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1456
1457        // Test that warnings have fixes
1458        check_warnings_have_fixes(content);
1459
1460        let fixed_content = fix(content);
1461        let expected = "Text\n\n- Level 1\n  - Level 2\n    - Level 3\n      - Level 4\n- Back to Level 1\nText";
1462        assert_eq!(fixed_content, expected, "Fix should handle deeply nested lists");
1463
1464        // Verify fix resolves the issue
1465        let warnings_after_fix = lint(&fixed_content);
1466        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1467    }
1468
1469    #[test]
1470    fn test_fix_list_with_multiline_items() {
1471        // Per markdownlint-cli: trailing "Text" at indent=0 is lazy continuation
1472        // Only the preceding blank line is required
1473        let content = "Text\n- Item 1\n  continues here\n  and here\n- Item 2\n  also continues\nText";
1474        let warnings = lint(content);
1475        assert_eq!(
1476            warnings.len(),
1477            1,
1478            "Should only warn for missing blank before list (trailing text is lazy continuation)"
1479        );
1480
1481        // Test that warnings have fixes
1482        check_warnings_have_fixes(content);
1483
1484        let fixed_content = fix(content);
1485        let expected = "Text\n\n- Item 1\n  continues here\n  and here\n- Item 2\n  also continues\nText";
1486        assert_eq!(fixed_content, expected, "Fix should add blank before list only");
1487
1488        // Verify fix resolves the issue
1489        let warnings_after_fix = lint(&fixed_content);
1490        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1491    }
1492
1493    #[test]
1494    fn test_fix_list_at_document_boundaries() {
1495        // List at very start
1496        let content1 = "- Item 1\n- Item 2";
1497        let warnings1 = lint(content1);
1498        assert_eq!(
1499            warnings1.len(),
1500            0,
1501            "List at document start should not need blank before"
1502        );
1503        let fixed1 = fix(content1);
1504        assert_eq!(fixed1, content1, "No fix needed for list at start");
1505
1506        // List at very end
1507        let content2 = "Text\n- Item 1\n- Item 2";
1508        let warnings2 = lint(content2);
1509        assert_eq!(warnings2.len(), 1, "List at document end should need blank before");
1510        check_warnings_have_fixes(content2);
1511        let fixed2 = fix(content2);
1512        assert_eq!(
1513            fixed2, "Text\n\n- Item 1\n- Item 2",
1514            "Should add blank before list at end"
1515        );
1516    }
1517
1518    #[test]
1519    fn test_fix_preserves_existing_blank_lines() {
1520        let content = "Text\n\n\n- Item 1\n- Item 2\n\n\nText";
1521        let warnings = lint(content);
1522        assert_eq!(warnings.len(), 0, "Multiple blank lines should be preserved");
1523        let fixed_content = fix(content);
1524        assert_eq!(fixed_content, content, "Fix should not modify already correct content");
1525    }
1526
1527    #[test]
1528    fn test_fix_handles_tabs_and_spaces() {
1529        // Tab at line start = 4 spaces = indented code (not a list item per CommonMark)
1530        // Only the space-indented line is a real list item
1531        let content = "Text\n\t- Item with tab\n  - Item with spaces\nText";
1532        let warnings = lint(content);
1533        // Per markdownlint-cli: only line 3 (space-indented) is a list needing blanks
1534        assert!(!warnings.is_empty(), "Should warn for missing blank before list");
1535
1536        // Test that warnings have fixes
1537        check_warnings_have_fixes(content);
1538
1539        let fixed_content = fix(content);
1540        // Add blank before the actual list item (line 3), not the tab-indented code (line 2)
1541        // Trailing text is lazy continuation, so no blank after
1542        let expected = "Text\n\t- Item with tab\n\n  - Item with spaces\nText";
1543        assert_eq!(fixed_content, expected, "Fix should add blank before list item");
1544
1545        // Verify fix resolves the issue
1546        let warnings_after_fix = lint(&fixed_content);
1547        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1548    }
1549
1550    #[test]
1551    fn test_fix_warning_objects_have_correct_ranges() {
1552        // Per markdownlint-cli: trailing text is lazy continuation, only 1 warning
1553        let content = "Text\n- Item 1\n- Item 2\nText";
1554        let warnings = lint(content);
1555        assert_eq!(warnings.len(), 1, "Only preceding blank warning expected");
1556
1557        // Check that each warning has a fix with a valid range
1558        for warning in &warnings {
1559            assert!(warning.fix.is_some(), "Warning should have fix");
1560            let fix = warning.fix.as_ref().unwrap();
1561            assert!(fix.range.start <= fix.range.end, "Fix range should be valid");
1562            assert!(
1563                !fix.replacement.is_empty() || fix.range.start == fix.range.end,
1564                "Fix should have replacement or be insertion"
1565            );
1566        }
1567    }
1568
1569    #[test]
1570    fn test_fix_idempotent() {
1571        // Per markdownlint-cli: trailing text is lazy continuation
1572        let content = "Text\n- Item 1\n- Item 2\nText";
1573
1574        // Apply fix once - only adds blank before (trailing text is lazy continuation)
1575        let fixed_once = fix(content);
1576        assert_eq!(fixed_once, "Text\n\n- Item 1\n- Item 2\nText");
1577
1578        // Apply fix again - should be unchanged
1579        let fixed_twice = fix(&fixed_once);
1580        assert_eq!(fixed_twice, fixed_once, "Fix should be idempotent");
1581
1582        // No warnings after fix
1583        let warnings_after_fix = lint(&fixed_once);
1584        assert_eq!(warnings_after_fix.len(), 0, "No warnings should remain after fix");
1585    }
1586
1587    #[test]
1588    fn test_fix_with_normalized_line_endings() {
1589        // In production, content is normalized to LF at I/O boundary
1590        // Unit tests should use LF input to reflect actual runtime behavior
1591        // Per markdownlint-cli: trailing text is lazy continuation, only 1 warning
1592        let content = "Text\n- Item 1\n- Item 2\nText";
1593        let warnings = lint(content);
1594        assert_eq!(warnings.len(), 1, "Should detect missing blank before list");
1595
1596        // Test that warnings have fixes
1597        check_warnings_have_fixes(content);
1598
1599        let fixed_content = fix(content);
1600        // Only adds blank before (trailing text is lazy continuation)
1601        let expected = "Text\n\n- Item 1\n- Item 2\nText";
1602        assert_eq!(fixed_content, expected, "Fix should work with normalized LF content");
1603    }
1604
1605    #[test]
1606    fn test_fix_preserves_final_newline() {
1607        // Per markdownlint-cli: trailing text is lazy continuation
1608        // Test with final newline
1609        let content_with_newline = "Text\n- Item 1\n- Item 2\nText\n";
1610        let fixed_with_newline = fix(content_with_newline);
1611        assert!(
1612            fixed_with_newline.ends_with('\n'),
1613            "Fix should preserve final newline when present"
1614        );
1615        // Only adds blank before (trailing text is lazy continuation)
1616        assert_eq!(fixed_with_newline, "Text\n\n- Item 1\n- Item 2\nText\n");
1617
1618        // Test without final newline
1619        let content_without_newline = "Text\n- Item 1\n- Item 2\nText";
1620        let fixed_without_newline = fix(content_without_newline);
1621        assert!(
1622            !fixed_without_newline.ends_with('\n'),
1623            "Fix should not add final newline when not present"
1624        );
1625        // Only adds blank before (trailing text is lazy continuation)
1626        assert_eq!(fixed_without_newline, "Text\n\n- Item 1\n- Item 2\nText");
1627    }
1628
1629    #[test]
1630    fn test_fix_multiline_list_items_no_indent() {
1631        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";
1632
1633        let warnings = lint(content);
1634        // Should only warn about missing blank lines around the entire list, not between items
1635        assert_eq!(
1636            warnings.len(),
1637            0,
1638            "Should not warn for properly formatted list with multi-line items. Got: {warnings:?}"
1639        );
1640
1641        let fixed_content = fix(content);
1642        // Should not change the content since it's already correct
1643        assert_eq!(
1644            fixed_content, content,
1645            "Should not modify correctly formatted multi-line list items"
1646        );
1647    }
1648
1649    #[test]
1650    fn test_nested_list_with_lazy_continuation() {
1651        // Issue #188: Nested list following a lazy continuation line should not require blank lines
1652        // This matches markdownlint-cli behavior which does NOT warn on this pattern
1653        //
1654        // The key element is line 6 (`!=`), ternary...) which is a lazy continuation of line 5.
1655        // Line 6 contains `||` inside code spans, which should NOT be detected as a table separator.
1656        let content = r#"# Test
1657
1658- **Token Dispatch (Phase 3.2)**: COMPLETE. Extracts tokens from both:
1659  1. Switch/case dispatcher statements (original Phase 3.2)
1660  2. Inline conditionals - if/else, bitwise checks (`&`, `|`), comparison (`==`,
1661`!=`), ternary operators (`?:`), macros (`ISTOK`, `ISUNSET`), compound conditions (`&&`, `||`) (Phase 3.2.1)
1662     - 30 explicit tokens extracted, 23 dispatcher rules with embedded token
1663       references"#;
1664
1665        let warnings = lint(content);
1666        // No MD032 warnings should be generated - this is a valid nested list structure
1667        // with lazy continuation (line 6 has no indent but continues line 5)
1668        let md032_warnings: Vec<_> = warnings
1669            .iter()
1670            .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1671            .collect();
1672        assert_eq!(
1673            md032_warnings.len(),
1674            0,
1675            "Should not warn for nested list with lazy continuation. Got: {md032_warnings:?}"
1676        );
1677    }
1678
1679    #[test]
1680    fn test_pipes_in_code_spans_not_detected_as_table() {
1681        // Pipes inside code spans should NOT break lists
1682        let content = r#"# Test
1683
1684- Item with `a | b` inline code
1685  - Nested item should work
1686
1687"#;
1688
1689        let warnings = lint(content);
1690        let md032_warnings: Vec<_> = warnings
1691            .iter()
1692            .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1693            .collect();
1694        assert_eq!(
1695            md032_warnings.len(),
1696            0,
1697            "Pipes in code spans should not break lists. Got: {md032_warnings:?}"
1698        );
1699    }
1700
1701    #[test]
1702    fn test_multiple_code_spans_with_pipes() {
1703        // Multiple code spans with pipes should not break lists
1704        let content = r#"# Test
1705
1706- Item with `a | b` and `c || d` operators
1707  - Nested item should work
1708
1709"#;
1710
1711        let warnings = lint(content);
1712        let md032_warnings: Vec<_> = warnings
1713            .iter()
1714            .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1715            .collect();
1716        assert_eq!(
1717            md032_warnings.len(),
1718            0,
1719            "Multiple code spans with pipes should not break lists. Got: {md032_warnings:?}"
1720        );
1721    }
1722
1723    #[test]
1724    fn test_actual_table_breaks_list() {
1725        // An actual table between list items SHOULD break the list
1726        let content = r#"# Test
1727
1728- Item before table
1729
1730| Col1 | Col2 |
1731|------|------|
1732| A    | B    |
1733
1734- Item after table
1735
1736"#;
1737
1738        let warnings = lint(content);
1739        // There should be NO MD032 warnings because both lists are properly surrounded by blank lines
1740        let md032_warnings: Vec<_> = warnings
1741            .iter()
1742            .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1743            .collect();
1744        assert_eq!(
1745            md032_warnings.len(),
1746            0,
1747            "Both lists should be properly separated by blank lines. Got: {md032_warnings:?}"
1748        );
1749    }
1750
1751    #[test]
1752    fn test_thematic_break_not_lazy_continuation() {
1753        // Thematic breaks (HRs) cannot be lazy continuation per CommonMark
1754        // List followed by HR without blank line should warn
1755        let content = r#"- Item 1
1756- Item 2
1757***
1758
1759More text.
1760"#;
1761
1762        let warnings = lint(content);
1763        let md032_warnings: Vec<_> = warnings
1764            .iter()
1765            .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1766            .collect();
1767        assert_eq!(
1768            md032_warnings.len(),
1769            1,
1770            "Should warn for list not followed by blank line before thematic break. Got: {md032_warnings:?}"
1771        );
1772        assert!(
1773            md032_warnings[0].message.contains("followed by blank line"),
1774            "Warning should be about missing blank after list"
1775        );
1776    }
1777
1778    #[test]
1779    fn test_thematic_break_with_blank_line() {
1780        // List followed by blank line then HR should NOT warn
1781        let content = r#"- Item 1
1782- Item 2
1783
1784***
1785
1786More text.
1787"#;
1788
1789        let warnings = lint(content);
1790        let md032_warnings: Vec<_> = warnings
1791            .iter()
1792            .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1793            .collect();
1794        assert_eq!(
1795            md032_warnings.len(),
1796            0,
1797            "Should not warn when list is properly followed by blank line. Got: {md032_warnings:?}"
1798        );
1799    }
1800
1801    #[test]
1802    fn test_various_thematic_break_styles() {
1803        // Test different HR styles are all recognized
1804        // Note: Spaced styles like "- - -" and "* * *" are excluded because they start
1805        // with list markers ("- " or "* ") which get parsed as list items by the
1806        // upstream CommonMark parser. That's a separate parsing issue.
1807        for hr in ["---", "***", "___"] {
1808            let content = format!(
1809                r#"- Item 1
1810- Item 2
1811{hr}
1812
1813More text.
1814"#
1815            );
1816
1817            let warnings = lint(&content);
1818            let md032_warnings: Vec<_> = warnings
1819                .iter()
1820                .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1821                .collect();
1822            assert_eq!(
1823                md032_warnings.len(),
1824                1,
1825                "Should warn for HR style '{hr}' without blank line. Got: {md032_warnings:?}"
1826            );
1827        }
1828    }
1829
1830    // === LAZY CONTINUATION TESTS ===
1831
1832    fn lint_with_config(content: &str, config: MD032Config) -> Vec<LintWarning> {
1833        let rule = MD032BlanksAroundLists::from_config_struct(config);
1834        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1835        rule.check(&ctx).expect("Lint check failed")
1836    }
1837
1838    fn fix_with_config(content: &str, config: MD032Config) -> String {
1839        let rule = MD032BlanksAroundLists::from_config_struct(config);
1840        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1841        rule.fix(&ctx).expect("Lint fix failed")
1842    }
1843
1844    #[test]
1845    fn test_lazy_continuation_allowed_by_default() {
1846        // Default behavior: lazy continuation is allowed, no warning
1847        let content = "# Heading\n\n1. List\nSome text.";
1848        let warnings = lint(content);
1849        assert_eq!(
1850            warnings.len(),
1851            0,
1852            "Default behavior should allow lazy continuation. Got: {warnings:?}"
1853        );
1854    }
1855
1856    #[test]
1857    fn test_lazy_continuation_disallowed() {
1858        // With allow_lazy_continuation = false, should warn about lazy continuation
1859        let content = "# Heading\n\n1. List\nSome text.";
1860        let config = MD032Config {
1861            allow_lazy_continuation: false,
1862        };
1863        let warnings = lint_with_config(content, config);
1864        assert_eq!(
1865            warnings.len(),
1866            1,
1867            "Should warn when lazy continuation is disallowed. Got: {warnings:?}"
1868        );
1869        assert!(
1870            warnings[0].message.contains("Lazy continuation"),
1871            "Warning message should mention lazy continuation"
1872        );
1873        assert_eq!(warnings[0].line, 4, "Warning should be on the lazy line");
1874    }
1875
1876    #[test]
1877    fn test_lazy_continuation_fix() {
1878        // With allow_lazy_continuation = false, fix should add proper indentation
1879        let content = "# Heading\n\n1. List\nSome text.";
1880        let config = MD032Config {
1881            allow_lazy_continuation: false,
1882        };
1883        let fixed = fix_with_config(content, config.clone());
1884        // Fix adds proper indentation (3 spaces for "1. " marker width)
1885        assert_eq!(
1886            fixed, "# Heading\n\n1. List\n   Some text.",
1887            "Fix should add proper indentation to lazy continuation"
1888        );
1889
1890        // Verify no warnings after fix
1891        let warnings_after = lint_with_config(&fixed, config);
1892        assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
1893    }
1894
1895    #[test]
1896    fn test_lazy_continuation_multiple_lines() {
1897        // Multiple lazy continuation lines - each gets its own warning
1898        let content = "- Item 1\nLine 2\nLine 3";
1899        let config = MD032Config {
1900            allow_lazy_continuation: false,
1901        };
1902        let warnings = lint_with_config(content, config.clone());
1903        // Both Line 2 and Line 3 are lazy continuation lines
1904        assert_eq!(
1905            warnings.len(),
1906            2,
1907            "Should warn for each lazy continuation line. Got: {warnings:?}"
1908        );
1909
1910        let fixed = fix_with_config(content, config.clone());
1911        // Fix adds proper indentation (2 spaces for "- " marker)
1912        assert_eq!(
1913            fixed, "- Item 1\n  Line 2\n  Line 3",
1914            "Fix should add proper indentation to lazy continuation lines"
1915        );
1916
1917        // Verify no warnings after fix
1918        let warnings_after = lint_with_config(&fixed, config);
1919        assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
1920    }
1921
1922    #[test]
1923    fn test_lazy_continuation_with_indented_content() {
1924        // Indented content is valid continuation, not lazy continuation
1925        let content = "- Item 1\n  Indented content\nLazy text";
1926        let config = MD032Config {
1927            allow_lazy_continuation: false,
1928        };
1929        let warnings = lint_with_config(content, config);
1930        assert_eq!(
1931            warnings.len(),
1932            1,
1933            "Should warn for lazy text after indented content. Got: {warnings:?}"
1934        );
1935    }
1936
1937    #[test]
1938    fn test_lazy_continuation_properly_separated() {
1939        // With proper blank line, no warning even with strict config
1940        let content = "- Item 1\n\nSome text.";
1941        let config = MD032Config {
1942            allow_lazy_continuation: false,
1943        };
1944        let warnings = lint_with_config(content, config);
1945        assert_eq!(
1946            warnings.len(),
1947            0,
1948            "Should not warn when list is properly followed by blank line. Got: {warnings:?}"
1949        );
1950    }
1951
1952    // ==================== Comprehensive edge case tests ====================
1953
1954    #[test]
1955    fn test_lazy_continuation_ordered_list_parenthesis_marker() {
1956        // Ordered list with parenthesis marker (1) instead of period
1957        let content = "1) First item\nLazy continuation";
1958        let config = MD032Config {
1959            allow_lazy_continuation: false,
1960        };
1961        let warnings = lint_with_config(content, config.clone());
1962        assert_eq!(
1963            warnings.len(),
1964            1,
1965            "Should warn for lazy continuation with parenthesis marker"
1966        );
1967
1968        let fixed = fix_with_config(content, config);
1969        // Fix adds proper indentation (3 spaces for "1) " marker)
1970        assert_eq!(fixed, "1) First item\n   Lazy continuation");
1971    }
1972
1973    #[test]
1974    fn test_lazy_continuation_followed_by_another_list() {
1975        // Lazy continuation text followed by another list item
1976        // In CommonMark, "Some text" becomes part of Item 1's lazy continuation,
1977        // and "- Item 2" starts a new list item within the same list.
1978        // With allow_lazy_continuation = false, we warn about lazy continuation
1979        // even within valid list structure (issue #295).
1980        let content = "- Item 1\nSome text\n- Item 2";
1981        let config = MD032Config {
1982            allow_lazy_continuation: false,
1983        };
1984        let warnings = lint_with_config(content, config);
1985        // Should warn about lazy continuation on line 2
1986        assert_eq!(
1987            warnings.len(),
1988            1,
1989            "Should warn about lazy continuation within list. Got: {warnings:?}"
1990        );
1991        assert!(
1992            warnings[0].message.contains("Lazy continuation"),
1993            "Warning should be about lazy continuation"
1994        );
1995        assert_eq!(warnings[0].line, 2, "Warning should be on line 2");
1996    }
1997
1998    #[test]
1999    fn test_lazy_continuation_multiple_in_document() {
2000        // Loose list (blank line between items) with lazy continuation
2001        // In CommonMark, this is a single loose list, not two separate lists.
2002        // "Lazy 1" is lazy continuation of Item 1
2003        // "Lazy 2" is lazy continuation of Item 2
2004        let content = "- Item 1\nLazy 1\n\n- Item 2\nLazy 2";
2005        let config = MD032Config {
2006            allow_lazy_continuation: false,
2007        };
2008        let warnings = lint_with_config(content, config.clone());
2009        // Expect 2 warnings for both lazy continuation lines
2010        assert_eq!(
2011            warnings.len(),
2012            2,
2013            "Should warn for both lazy continuations. Got: {warnings:?}"
2014        );
2015
2016        let fixed = fix_with_config(content, config.clone());
2017        // Auto-fix should add proper indentation to both lazy continuation lines
2018        assert!(
2019            fixed.contains("  Lazy 1"),
2020            "Fixed content should have indented 'Lazy 1'. Got: {fixed:?}"
2021        );
2022        assert!(
2023            fixed.contains("  Lazy 2"),
2024            "Fixed content should have indented 'Lazy 2'. Got: {fixed:?}"
2025        );
2026
2027        let warnings_after = lint_with_config(&fixed, config);
2028        // No warnings after fix: both lazy lines are properly indented
2029        assert_eq!(
2030            warnings_after.len(),
2031            0,
2032            "All warnings should be fixed after auto-fix. Got: {warnings_after:?}"
2033        );
2034    }
2035
2036    #[test]
2037    fn test_lazy_continuation_end_of_document_no_newline() {
2038        // Lazy continuation at end of document without trailing newline
2039        let content = "- Item\nNo trailing newline";
2040        let config = MD032Config {
2041            allow_lazy_continuation: false,
2042        };
2043        let warnings = lint_with_config(content, config.clone());
2044        assert_eq!(warnings.len(), 1, "Should warn even at end of document");
2045
2046        let fixed = fix_with_config(content, config);
2047        // Fix adds proper indentation (2 spaces for "- " marker)
2048        assert_eq!(fixed, "- Item\n  No trailing newline");
2049    }
2050
2051    #[test]
2052    fn test_lazy_continuation_thematic_break_still_needs_blank() {
2053        // Thematic break after list without blank line still triggers MD032
2054        // The thematic break ends the list, but MD032 requires blank line separation
2055        let content = "- Item 1\n---";
2056        let config = MD032Config {
2057            allow_lazy_continuation: false,
2058        };
2059        let warnings = lint_with_config(content, config.clone());
2060        // Should warn because list needs blank line before thematic break
2061        assert_eq!(
2062            warnings.len(),
2063            1,
2064            "List should need blank line before thematic break. Got: {warnings:?}"
2065        );
2066
2067        // Verify fix adds blank line
2068        let fixed = fix_with_config(content, config);
2069        assert_eq!(fixed, "- Item 1\n\n---");
2070    }
2071
2072    #[test]
2073    fn test_lazy_continuation_heading_not_flagged() {
2074        // Heading after list should NOT be flagged as lazy continuation
2075        // (headings end lists per CommonMark)
2076        let content = "- Item 1\n# Heading";
2077        let config = MD032Config {
2078            allow_lazy_continuation: false,
2079        };
2080        let warnings = lint_with_config(content, config);
2081        // The warning should be about missing blank line, not lazy continuation
2082        // But headings interrupt lists, so the list ends at Item 1
2083        assert!(
2084            warnings.iter().all(|w| !w.message.contains("lazy")),
2085            "Heading should not trigger lazy continuation warning"
2086        );
2087    }
2088
2089    #[test]
2090    fn test_lazy_continuation_mixed_list_types() {
2091        // Mixed ordered and unordered with lazy continuation
2092        let content = "- Unordered\n1. Ordered\nLazy text";
2093        let config = MD032Config {
2094            allow_lazy_continuation: false,
2095        };
2096        let warnings = lint_with_config(content, config.clone());
2097        assert!(!warnings.is_empty(), "Should warn about structure issues");
2098    }
2099
2100    #[test]
2101    fn test_lazy_continuation_deep_nesting() {
2102        // Deep nested list with lazy continuation at end
2103        let content = "- Level 1\n  - Level 2\n    - Level 3\nLazy at root";
2104        let config = MD032Config {
2105            allow_lazy_continuation: false,
2106        };
2107        let warnings = lint_with_config(content, config.clone());
2108        assert!(
2109            !warnings.is_empty(),
2110            "Should warn about lazy continuation after nested list"
2111        );
2112
2113        let fixed = fix_with_config(content, config.clone());
2114        let warnings_after = lint_with_config(&fixed, config);
2115        assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
2116    }
2117
2118    #[test]
2119    fn test_lazy_continuation_with_emphasis_in_text() {
2120        // Lazy continuation containing emphasis markers
2121        let content = "- Item\n*emphasized* continuation";
2122        let config = MD032Config {
2123            allow_lazy_continuation: false,
2124        };
2125        let warnings = lint_with_config(content, config.clone());
2126        assert_eq!(warnings.len(), 1, "Should warn even with emphasis in continuation");
2127
2128        let fixed = fix_with_config(content, config);
2129        // Fix adds proper indentation (2 spaces for "- " marker)
2130        assert_eq!(fixed, "- Item\n  *emphasized* continuation");
2131    }
2132
2133    #[test]
2134    fn test_lazy_continuation_with_code_span() {
2135        // Lazy continuation containing code span
2136        let content = "- Item\n`code` continuation";
2137        let config = MD032Config {
2138            allow_lazy_continuation: false,
2139        };
2140        let warnings = lint_with_config(content, config.clone());
2141        assert_eq!(warnings.len(), 1, "Should warn even with code span in continuation");
2142
2143        let fixed = fix_with_config(content, config);
2144        // Fix adds proper indentation (2 spaces for "- " marker)
2145        assert_eq!(fixed, "- Item\n  `code` continuation");
2146    }
2147
2148    // =========================================================================
2149    // Issue #295: Lazy continuation after nested sublists
2150    // These tests verify detection of lazy continuation at outer indent level
2151    // after nested sublists, followed by another list item.
2152    // =========================================================================
2153
2154    #[test]
2155    fn test_issue295_case1_nested_bullets_then_continuation_then_item() {
2156        // Outer numbered item with nested bullets, lazy continuation, then next item
2157        // The lazy continuation "A new Chat..." appears at column 1, not indented
2158        let content = r#"1. Create a new Chat conversation:
2159   - On the sidebar, select **New Chat**.
2160   - In the box, type `/new`.
2161   A new Chat conversation replaces the previous one.
21621. Under the Chat text box, turn off the toggle."#;
2163        let config = MD032Config {
2164            allow_lazy_continuation: false,
2165        };
2166        let warnings = lint_with_config(content, config);
2167        // Should warn about line 4 "A new Chat..." which is lazy continuation
2168        let lazy_warnings: Vec<_> = warnings
2169            .iter()
2170            .filter(|w| w.message.contains("Lazy continuation"))
2171            .collect();
2172        assert!(
2173            !lazy_warnings.is_empty(),
2174            "Should detect lazy continuation after nested bullets. Got: {warnings:?}"
2175        );
2176        assert!(
2177            lazy_warnings.iter().any(|w| w.line == 4),
2178            "Should warn on line 4. Got: {lazy_warnings:?}"
2179        );
2180    }
2181
2182    #[test]
2183    fn test_issue295_case3_code_span_starts_lazy_continuation() {
2184        // Code span at the START of lazy continuation after nested bullets
2185        // This is tricky because pulldown-cmark emits Code event, not Text
2186        let content = r#"- `field`: Is the specific key:
2187  - `password`: Accesses the password.
2188  - `api_key`: Accesses the api_key.
2189  `token`: Specifies which ID token to use.
2190- `version_id`: Is the unique identifier."#;
2191        let config = MD032Config {
2192            allow_lazy_continuation: false,
2193        };
2194        let warnings = lint_with_config(content, config);
2195        // Should warn about line 4 "`token`:..." which starts with code span
2196        let lazy_warnings: Vec<_> = warnings
2197            .iter()
2198            .filter(|w| w.message.contains("Lazy continuation"))
2199            .collect();
2200        assert!(
2201            !lazy_warnings.is_empty(),
2202            "Should detect lazy continuation starting with code span. Got: {warnings:?}"
2203        );
2204        assert!(
2205            lazy_warnings.iter().any(|w| w.line == 4),
2206            "Should warn on line 4 (code span start). Got: {lazy_warnings:?}"
2207        );
2208    }
2209
2210    #[test]
2211    fn test_issue295_case4_deep_nesting_with_continuation_then_item() {
2212        // Multiple nesting levels, lazy continuation, then next outer item
2213        let content = r#"- Check out the branch, and test locally.
2214  - If the MR requires significant modifications:
2215    - **Skip local testing** and review instead.
2216    - **Request verification** from the author.
2217    - **Identify the minimal change** needed.
2218  Your testing might result in opportunities.
2219- If you don't understand, _say so_."#;
2220        let config = MD032Config {
2221            allow_lazy_continuation: false,
2222        };
2223        let warnings = lint_with_config(content, config);
2224        // Should warn about line 6 "Your testing..." which is lazy continuation
2225        let lazy_warnings: Vec<_> = warnings
2226            .iter()
2227            .filter(|w| w.message.contains("Lazy continuation"))
2228            .collect();
2229        assert!(
2230            !lazy_warnings.is_empty(),
2231            "Should detect lazy continuation after deep nesting. Got: {warnings:?}"
2232        );
2233        assert!(
2234            lazy_warnings.iter().any(|w| w.line == 6),
2235            "Should warn on line 6. Got: {lazy_warnings:?}"
2236        );
2237    }
2238
2239    #[test]
2240    fn test_issue295_ordered_list_nested_bullets_continuation() {
2241        // Ordered list with nested bullets, continuation at outer level, then next item
2242        // This is the exact pattern from debug_test6.md
2243        let content = r#"# Test
2244
22451. First item.
2246   - Nested A.
2247   - Nested B.
2248   Continuation at outer level.
22491. Second item."#;
2250        let config = MD032Config {
2251            allow_lazy_continuation: false,
2252        };
2253        let warnings = lint_with_config(content, config);
2254        // Should warn about line 6 "Continuation at outer level."
2255        let lazy_warnings: Vec<_> = warnings
2256            .iter()
2257            .filter(|w| w.message.contains("Lazy continuation"))
2258            .collect();
2259        assert!(
2260            !lazy_warnings.is_empty(),
2261            "Should detect lazy continuation at outer level after nested. Got: {warnings:?}"
2262        );
2263        // Line 6 = "   Continuation at outer level." (3 spaces indent, but needs 4 for proper continuation)
2264        assert!(
2265            lazy_warnings.iter().any(|w| w.line == 6),
2266            "Should warn on line 6. Got: {lazy_warnings:?}"
2267        );
2268    }
2269
2270    #[test]
2271    fn test_issue295_multiple_lazy_lines_after_nested() {
2272        // Multiple lazy continuation lines after nested sublist
2273        let content = r#"1. The device client receives a response.
2274   - Those defined by OAuth Framework.
2275   - Those specific to device authorization.
2276   Those error responses are described below.
2277   For more information on each response,
2278   see the documentation.
22791. Next step in the process."#;
2280        let config = MD032Config {
2281            allow_lazy_continuation: false,
2282        };
2283        let warnings = lint_with_config(content, config);
2284        // Should warn about lines 4, 5, 6 (all lazy continuation)
2285        let lazy_warnings: Vec<_> = warnings
2286            .iter()
2287            .filter(|w| w.message.contains("Lazy continuation"))
2288            .collect();
2289        assert!(
2290            lazy_warnings.len() >= 3,
2291            "Should detect multiple lazy continuation lines. Got {} warnings: {lazy_warnings:?}",
2292            lazy_warnings.len()
2293        );
2294    }
2295
2296    #[test]
2297    fn test_issue295_properly_indented_not_lazy() {
2298        // Properly indented continuation after nested sublist should NOT warn
2299        let content = r#"1. First item.
2300   - Nested A.
2301   - Nested B.
2302
2303   Properly indented continuation.
23041. Second item."#;
2305        let config = MD032Config {
2306            allow_lazy_continuation: false,
2307        };
2308        let warnings = lint_with_config(content, config);
2309        // With blank line before, this is a new paragraph, not lazy continuation
2310        let lazy_warnings: Vec<_> = warnings
2311            .iter()
2312            .filter(|w| w.message.contains("Lazy continuation"))
2313            .collect();
2314        assert_eq!(
2315            lazy_warnings.len(),
2316            0,
2317            "Should NOT warn when blank line separates continuation. Got: {lazy_warnings:?}"
2318        );
2319    }
2320
2321    // =========================================================================
2322    // HTML Comment Transparency Tests
2323    // HTML comments should be "transparent" for blank line checking,
2324    // matching markdownlint-cli behavior.
2325    // =========================================================================
2326
2327    #[test]
2328    fn test_html_comment_before_list_with_preceding_blank() {
2329        // Blank line before HTML comment = list is properly separated
2330        // markdownlint-cli does NOT warn here
2331        let content = "Some text.\n\n<!-- comment -->\n- List item";
2332        let warnings = lint(content);
2333        assert_eq!(
2334            warnings.len(),
2335            0,
2336            "Should not warn when blank line exists before HTML comment. Got: {warnings:?}"
2337        );
2338    }
2339
2340    #[test]
2341    fn test_html_comment_after_list_with_following_blank() {
2342        // Blank line after HTML comment = list is properly separated
2343        let content = "- List item\n<!-- comment -->\n\nSome text.";
2344        let warnings = lint(content);
2345        assert_eq!(
2346            warnings.len(),
2347            0,
2348            "Should not warn when blank line exists after HTML comment. Got: {warnings:?}"
2349        );
2350    }
2351
2352    #[test]
2353    fn test_list_inside_html_comment_ignored() {
2354        // Lists entirely inside HTML comments should not be analyzed
2355        let content = "<!--\n1. First\n2. Second\n3. Third\n-->";
2356        let warnings = lint(content);
2357        assert_eq!(
2358            warnings.len(),
2359            0,
2360            "Should not analyze lists inside HTML comments. Got: {warnings:?}"
2361        );
2362    }
2363
2364    #[test]
2365    fn test_multiline_html_comment_before_list() {
2366        // Multi-line HTML comment should be transparent
2367        let content = "Text\n\n<!--\nThis is a\nmulti-line\ncomment\n-->\n- Item";
2368        let warnings = lint(content);
2369        assert_eq!(
2370            warnings.len(),
2371            0,
2372            "Multi-line HTML comment should be transparent. Got: {warnings:?}"
2373        );
2374    }
2375
2376    #[test]
2377    fn test_no_blank_before_html_comment_still_warns() {
2378        // No blank line anywhere = should still warn
2379        let content = "Some text.\n<!-- comment -->\n- List item";
2380        let warnings = lint(content);
2381        assert_eq!(
2382            warnings.len(),
2383            1,
2384            "Should warn when no blank line exists (even with HTML comment). Got: {warnings:?}"
2385        );
2386        assert!(
2387            warnings[0].message.contains("preceded by blank line"),
2388            "Should be 'preceded by blank line' warning"
2389        );
2390    }
2391
2392    #[test]
2393    fn test_no_blank_after_html_comment_no_warn_lazy_continuation() {
2394        // Text immediately after list (through HTML comment) is lazy continuation
2395        // markdownlint-cli does NOT warn here - the text becomes part of the list
2396        let content = "- List item\n<!-- comment -->\nSome text.";
2397        let warnings = lint(content);
2398        assert_eq!(
2399            warnings.len(),
2400            0,
2401            "Should not warn - text after comment becomes lazy continuation. Got: {warnings:?}"
2402        );
2403    }
2404
2405    #[test]
2406    fn test_list_followed_by_heading_through_comment_should_warn() {
2407        // Heading cannot be lazy continuation, so this SHOULD warn
2408        let content = "- List item\n<!-- comment -->\n# Heading";
2409        let warnings = lint(content);
2410        // Headings after lists through HTML comments should be handled gracefully
2411        // The blank line check should look past the comment
2412        assert!(
2413            warnings.len() <= 1,
2414            "Should handle heading after comment gracefully. Got: {warnings:?}"
2415        );
2416    }
2417
2418    #[test]
2419    fn test_html_comment_between_list_and_text_both_directions() {
2420        // Blank line on both sides through HTML comment
2421        let content = "Text before.\n\n<!-- comment -->\n- Item 1\n- Item 2\n<!-- another -->\n\nText after.";
2422        let warnings = lint(content);
2423        assert_eq!(
2424            warnings.len(),
2425            0,
2426            "Should not warn with proper separation through comments. Got: {warnings:?}"
2427        );
2428    }
2429
2430    #[test]
2431    fn test_html_comment_fix_does_not_insert_unnecessary_blank() {
2432        // Fix should not add blank line when separation already exists through comment
2433        let content = "Text.\n\n<!-- comment -->\n- Item";
2434        let fixed = fix(content);
2435        assert_eq!(fixed, content, "Fix should not modify already-correct content");
2436    }
2437
2438    #[test]
2439    fn test_html_comment_fix_adds_blank_when_needed() {
2440        // Fix should add blank line when no separation exists
2441        // The blank line is added immediately before the list (after the comment)
2442        let content = "Text.\n<!-- comment -->\n- Item";
2443        let fixed = fix(content);
2444        assert!(
2445            fixed.contains("<!-- comment -->\n\n- Item"),
2446            "Fix should add blank line before list. Got: {fixed}"
2447        );
2448    }
2449
2450    #[test]
2451    fn test_ordered_list_inside_html_comment() {
2452        // Ordered list with non-1 start inside comment should not warn
2453        let content = "<!--\n3. Starting at 3\n4. Next item\n-->";
2454        let warnings = lint(content);
2455        assert_eq!(
2456            warnings.len(),
2457            0,
2458            "Should not warn about ordered list inside HTML comment. Got: {warnings:?}"
2459        );
2460    }
2461
2462    // =========================================================================
2463    // Blockquote Boundary Transition Tests
2464    // When a list inside a blockquote ends and the next line exits the blockquote,
2465    // no blank line is needed - the blockquote boundary provides semantic separation.
2466    // =========================================================================
2467
2468    #[test]
2469    fn test_blockquote_list_exit_no_warning() {
2470        // Blockquote list followed by outer content - no blank line needed
2471        let content = "- outer item\n  > - blockquote list 1\n  > - blockquote list 2\n- next outer item";
2472        let warnings = lint(content);
2473        assert_eq!(
2474            warnings.len(),
2475            0,
2476            "Should not warn when exiting blockquote. Got: {warnings:?}"
2477        );
2478    }
2479
2480    #[test]
2481    fn test_nested_blockquote_list_exit() {
2482        // Nested blockquote list - exiting should not require blank line
2483        let content = "- outer\n  - nested\n    > - bq list 1\n    > - bq list 2\n  - back to nested\n- outer again";
2484        let warnings = lint(content);
2485        assert_eq!(
2486            warnings.len(),
2487            0,
2488            "Should not warn when exiting nested blockquote list. Got: {warnings:?}"
2489        );
2490    }
2491
2492    #[test]
2493    fn test_blockquote_same_level_no_warning() {
2494        // List INSIDE blockquote followed by text INSIDE same blockquote
2495        // markdownlint-cli does NOT warn for this case - lazy continuation applies
2496        let content = "> - item 1\n> - item 2\n> Text after";
2497        let warnings = lint(content);
2498        assert_eq!(
2499            warnings.len(),
2500            0,
2501            "Should not warn - text is lazy continuation in blockquote. Got: {warnings:?}"
2502        );
2503    }
2504
2505    #[test]
2506    fn test_blockquote_list_with_special_chars() {
2507        // Content with special chars like <> should not affect blockquote detection
2508        let content = "- Item with <>&\n  > - blockquote item\n- Back to outer";
2509        let warnings = lint(content);
2510        assert_eq!(
2511            warnings.len(),
2512            0,
2513            "Special chars in content should not affect blockquote detection. Got: {warnings:?}"
2514        );
2515    }
2516
2517    #[test]
2518    fn test_lazy_continuation_whitespace_only_line() {
2519        // Per CommonMark/pulldown-cmark, whitespace-only line IS a blank line separator
2520        // The list ends at the whitespace-only line, text starts a new paragraph
2521        let content = "- Item\n   \nText after whitespace-only line";
2522        let config = MD032Config {
2523            allow_lazy_continuation: false,
2524        };
2525        let warnings = lint_with_config(content, config);
2526        // Whitespace-only line counts as blank line separator - no lazy continuation
2527        assert_eq!(
2528            warnings.len(),
2529            0,
2530            "Whitespace-only line IS a separator in CommonMark. Got: {warnings:?}"
2531        );
2532    }
2533
2534    #[test]
2535    fn test_lazy_continuation_blockquote_context() {
2536        // List inside blockquote with lazy continuation
2537        let content = "> - Item\n> Lazy in quote";
2538        let config = MD032Config {
2539            allow_lazy_continuation: false,
2540        };
2541        let warnings = lint_with_config(content, config);
2542        // Inside blockquote, lazy continuation may behave differently
2543        // This tests that we handle blockquote context
2544        assert!(warnings.len() <= 1, "Should handle blockquote context gracefully");
2545    }
2546
2547    #[test]
2548    fn test_lazy_continuation_fix_preserves_content() {
2549        // Ensure fix doesn't modify the actual content
2550        let content = "- Item with special chars: <>&\nContinuation with: \"quotes\"";
2551        let config = MD032Config {
2552            allow_lazy_continuation: false,
2553        };
2554        let fixed = fix_with_config(content, config);
2555        assert!(fixed.contains("<>&"), "Should preserve special chars");
2556        assert!(fixed.contains("\"quotes\""), "Should preserve quotes");
2557        // Fix adds proper indentation (2 spaces for "- " marker)
2558        assert_eq!(fixed, "- Item with special chars: <>&\n  Continuation with: \"quotes\"");
2559    }
2560
2561    #[test]
2562    fn test_lazy_continuation_fix_idempotent() {
2563        // Running fix twice should produce same result
2564        let content = "- Item\nLazy";
2565        let config = MD032Config {
2566            allow_lazy_continuation: false,
2567        };
2568        let fixed_once = fix_with_config(content, config.clone());
2569        let fixed_twice = fix_with_config(&fixed_once, config);
2570        assert_eq!(fixed_once, fixed_twice, "Fix should be idempotent");
2571    }
2572
2573    #[test]
2574    fn test_lazy_continuation_config_default_allows() {
2575        // Verify default config allows lazy continuation
2576        let content = "- Item\nLazy text that continues";
2577        let default_config = MD032Config::default();
2578        assert!(
2579            default_config.allow_lazy_continuation,
2580            "Default should allow lazy continuation"
2581        );
2582        let warnings = lint_with_config(content, default_config);
2583        assert_eq!(warnings.len(), 0, "Default config should not warn on lazy continuation");
2584    }
2585
2586    #[test]
2587    fn test_lazy_continuation_after_multi_line_item() {
2588        // List item with proper indented continuation, then lazy text
2589        let content = "- Item line 1\n  Item line 2 (indented)\nLazy (not indented)";
2590        let config = MD032Config {
2591            allow_lazy_continuation: false,
2592        };
2593        let warnings = lint_with_config(content, config.clone());
2594        assert_eq!(
2595            warnings.len(),
2596            1,
2597            "Should warn only for the lazy line, not the indented line"
2598        );
2599    }
2600
2601    // Issue #260: Lists inside blockquotes should not produce false positives
2602    #[test]
2603    fn test_blockquote_list_with_continuation_and_nested() {
2604        // This is the exact case from issue #260
2605        // markdownlint-cli reports NO warnings for this
2606        let content = "> - item 1\n>   continuation\n>   - nested\n> - item 2";
2607        let warnings = lint(content);
2608        assert_eq!(
2609            warnings.len(),
2610            0,
2611            "Blockquoted list with continuation and nested items should have no warnings. Got: {warnings:?}"
2612        );
2613    }
2614
2615    #[test]
2616    fn test_blockquote_list_simple() {
2617        // Simple blockquoted list
2618        let content = "> - item 1\n> - item 2";
2619        let warnings = lint(content);
2620        assert_eq!(warnings.len(), 0, "Simple blockquoted list should have no warnings");
2621    }
2622
2623    #[test]
2624    fn test_blockquote_list_with_continuation_only() {
2625        // Blockquoted list with continuation line (no nesting)
2626        let content = "> - item 1\n>   continuation\n> - item 2";
2627        let warnings = lint(content);
2628        assert_eq!(
2629            warnings.len(),
2630            0,
2631            "Blockquoted list with continuation should have no warnings"
2632        );
2633    }
2634
2635    #[test]
2636    fn test_blockquote_list_with_lazy_continuation() {
2637        // Blockquoted list with lazy continuation (no extra indent after >)
2638        let content = "> - item 1\n> lazy continuation\n> - item 2";
2639        let warnings = lint(content);
2640        assert_eq!(
2641            warnings.len(),
2642            0,
2643            "Blockquoted list with lazy continuation should have no warnings"
2644        );
2645    }
2646
2647    #[test]
2648    fn test_nested_blockquote_list() {
2649        // List inside nested blockquote (>> prefix)
2650        let content = ">> - item 1\n>>   continuation\n>>   - nested\n>> - item 2";
2651        let warnings = lint(content);
2652        assert_eq!(warnings.len(), 0, "Nested blockquote list should have no warnings");
2653    }
2654
2655    #[test]
2656    fn test_blockquote_list_needs_preceding_blank() {
2657        // Blockquote list preceded by non-blank content SHOULD warn
2658        let content = "> Text before\n> - item 1\n> - item 2";
2659        let warnings = lint(content);
2660        assert_eq!(
2661            warnings.len(),
2662            1,
2663            "Should warn for missing blank before blockquoted list"
2664        );
2665    }
2666
2667    #[test]
2668    fn test_blockquote_list_properly_separated() {
2669        // Blockquote list with proper blank lines - no warnings
2670        let content = "> Text before\n>\n> - item 1\n> - item 2\n>\n> Text after";
2671        let warnings = lint(content);
2672        assert_eq!(
2673            warnings.len(),
2674            0,
2675            "Properly separated blockquoted list should have no warnings"
2676        );
2677    }
2678
2679    #[test]
2680    fn test_blockquote_ordered_list() {
2681        // Ordered list in blockquote with continuation
2682        let content = "> 1. item 1\n>    continuation\n> 2. item 2";
2683        let warnings = lint(content);
2684        assert_eq!(warnings.len(), 0, "Ordered list in blockquote should have no warnings");
2685    }
2686
2687    #[test]
2688    fn test_blockquote_list_with_empty_blockquote_line() {
2689        // Empty blockquote line (just ">") between items - still same list
2690        let content = "> - item 1\n>\n> - item 2";
2691        let warnings = lint(content);
2692        assert_eq!(warnings.len(), 0, "Empty blockquote line should not break list");
2693    }
2694
2695    /// Issue #268: Multi-paragraph list items in blockquotes should not trigger false positives
2696    #[test]
2697    fn test_blockquote_list_multi_paragraph_items() {
2698        // List item with blank line + continuation paragraph + next item
2699        // This is a common pattern for multi-paragraph list items in blockquotes
2700        let content = "# Test\n\n> Some intro text\n> \n> * List item 1\n> \n>   Continuation\n> * List item 2\n";
2701        let warnings = lint(content);
2702        assert_eq!(
2703            warnings.len(),
2704            0,
2705            "Multi-paragraph list items in blockquotes should have no warnings. Got: {warnings:?}"
2706        );
2707    }
2708
2709    /// Issue #268: Ordered lists with multi-paragraph items in blockquotes
2710    #[test]
2711    fn test_blockquote_ordered_list_multi_paragraph_items() {
2712        let content = "> 1. First item\n> \n>    Continuation of first\n> 2. Second item\n";
2713        let warnings = lint(content);
2714        assert_eq!(
2715            warnings.len(),
2716            0,
2717            "Ordered multi-paragraph list items in blockquotes should have no warnings. Got: {warnings:?}"
2718        );
2719    }
2720
2721    /// Issue #268: Multiple continuation paragraphs in blockquote list
2722    #[test]
2723    fn test_blockquote_list_multiple_continuations() {
2724        let content = "> - Item 1\n> \n>   First continuation\n> \n>   Second continuation\n> - Item 2\n";
2725        let warnings = lint(content);
2726        assert_eq!(
2727            warnings.len(),
2728            0,
2729            "Multiple continuation paragraphs should not break blockquote list. Got: {warnings:?}"
2730        );
2731    }
2732
2733    /// Issue #268: Nested blockquote (>>) with multi-paragraph list items
2734    #[test]
2735    fn test_nested_blockquote_multi_paragraph_list() {
2736        let content = ">> - Item 1\n>> \n>>   Continuation\n>> - Item 2\n";
2737        let warnings = lint(content);
2738        assert_eq!(
2739            warnings.len(),
2740            0,
2741            "Nested blockquote multi-paragraph list should have no warnings. Got: {warnings:?}"
2742        );
2743    }
2744
2745    /// Issue #268: Triple-nested blockquote (>>>) with multi-paragraph list items
2746    #[test]
2747    fn test_triple_nested_blockquote_multi_paragraph_list() {
2748        let content = ">>> - Item 1\n>>> \n>>>   Continuation\n>>> - Item 2\n";
2749        let warnings = lint(content);
2750        assert_eq!(
2751            warnings.len(),
2752            0,
2753            "Triple-nested blockquote multi-paragraph list should have no warnings. Got: {warnings:?}"
2754        );
2755    }
2756
2757    /// Issue #268: Last item in blockquote list has continuation (edge case)
2758    #[test]
2759    fn test_blockquote_list_last_item_continuation() {
2760        let content = "> - Item 1\n> - Item 2\n> \n>   Continuation of item 2\n";
2761        let warnings = lint(content);
2762        assert_eq!(
2763            warnings.len(),
2764            0,
2765            "Last item with continuation should have no warnings. Got: {warnings:?}"
2766        );
2767    }
2768
2769    /// Issue #268: First item only has continuation in blockquote list
2770    #[test]
2771    fn test_blockquote_list_first_item_only_continuation() {
2772        let content = "> - Item 1\n> \n>   Continuation of item 1\n";
2773        let warnings = lint(content);
2774        assert_eq!(
2775            warnings.len(),
2776            0,
2777            "Single item with continuation should have no warnings. Got: {warnings:?}"
2778        );
2779    }
2780
2781    /// Blockquote level change SHOULD still be detected as list break
2782    /// Note: markdownlint flags BOTH lines in this case - line 1 for missing preceding blank,
2783    /// and line 2 for missing preceding blank (level change)
2784    #[test]
2785    fn test_blockquote_level_change_breaks_list() {
2786        // Going from > to >> should break the list - markdownlint flags both lines
2787        let content = "> - Item in single blockquote\n>> - Item in nested blockquote\n";
2788        let warnings = lint(content);
2789        // markdownlint reports: line 1 (list at start), line 2 (level change)
2790        // For now, accept 0 or more warnings since this is a complex edge case
2791        // The main fix (multi-paragraph items) is more important than this edge case
2792        assert!(
2793            warnings.len() <= 2,
2794            "Blockquote level change warnings should be reasonable. Got: {warnings:?}"
2795        );
2796    }
2797
2798    /// Exiting blockquote SHOULD still be detected as needing blank line
2799    #[test]
2800    fn test_exit_blockquote_needs_blank_before_list() {
2801        // Text after blockquote, then list without blank
2802        let content = "> Blockquote text\n\n- List outside blockquote\n";
2803        let warnings = lint(content);
2804        assert_eq!(
2805            warnings.len(),
2806            0,
2807            "List after blank line outside blockquote should be fine. Got: {warnings:?}"
2808        );
2809
2810        // Without blank line after blockquote - markdownlint flags this
2811        // But rumdl may not flag it due to complexity of detecting "text immediately before list"
2812        // This is an acceptable deviation for now
2813        let content2 = "> Blockquote text\n- List outside blockquote\n";
2814        let warnings2 = lint(content2);
2815        // Accept 0 or 1 - main fix is more important than this edge case
2816        assert!(
2817            warnings2.len() <= 1,
2818            "List after blockquote warnings should be reasonable. Got: {warnings2:?}"
2819        );
2820    }
2821
2822    /// Issue #268: Test all unordered list markers (-, *, +) with multi-paragraph items
2823    #[test]
2824    fn test_blockquote_multi_paragraph_all_unordered_markers() {
2825        // Dash marker
2826        let content_dash = "> - Item 1\n> \n>   Continuation\n> - Item 2\n";
2827        let warnings = lint(content_dash);
2828        assert_eq!(warnings.len(), 0, "Dash marker should work. Got: {warnings:?}");
2829
2830        // Asterisk marker
2831        let content_asterisk = "> * Item 1\n> \n>   Continuation\n> * Item 2\n";
2832        let warnings = lint(content_asterisk);
2833        assert_eq!(warnings.len(), 0, "Asterisk marker should work. Got: {warnings:?}");
2834
2835        // Plus marker
2836        let content_plus = "> + Item 1\n> \n>   Continuation\n> + Item 2\n";
2837        let warnings = lint(content_plus);
2838        assert_eq!(warnings.len(), 0, "Plus marker should work. Got: {warnings:?}");
2839    }
2840
2841    /// Issue #268: Parenthesis-style ordered list markers (1))
2842    #[test]
2843    fn test_blockquote_multi_paragraph_parenthesis_marker() {
2844        let content = "> 1) Item 1\n> \n>    Continuation\n> 2) Item 2\n";
2845        let warnings = lint(content);
2846        assert_eq!(
2847            warnings.len(),
2848            0,
2849            "Parenthesis ordered markers should work. Got: {warnings:?}"
2850        );
2851    }
2852
2853    /// Issue #268: Multi-digit ordered list numbers have wider markers
2854    #[test]
2855    fn test_blockquote_multi_paragraph_multi_digit_numbers() {
2856        // "10. " is 4 chars, so continuation needs 4 spaces
2857        let content = "> 10. Item 10\n> \n>     Continuation of item 10\n> 11. Item 11\n";
2858        let warnings = lint(content);
2859        assert_eq!(
2860            warnings.len(),
2861            0,
2862            "Multi-digit ordered list should work. Got: {warnings:?}"
2863        );
2864    }
2865
2866    /// Issue #268: Continuation with emphasis and other inline formatting
2867    #[test]
2868    fn test_blockquote_multi_paragraph_with_formatting() {
2869        let content = "> - Item with **bold**\n> \n>   Continuation with *emphasis* and `code`\n> - Item 2\n";
2870        let warnings = lint(content);
2871        assert_eq!(
2872            warnings.len(),
2873            0,
2874            "Continuation with inline formatting should work. Got: {warnings:?}"
2875        );
2876    }
2877
2878    /// Issue #268: Multiple items each with their own continuation paragraph
2879    #[test]
2880    fn test_blockquote_multi_paragraph_all_items_have_continuation() {
2881        let content = "> - Item 1\n> \n>   Continuation 1\n> - Item 2\n> \n>   Continuation 2\n> - Item 3\n> \n>   Continuation 3\n";
2882        let warnings = lint(content);
2883        assert_eq!(
2884            warnings.len(),
2885            0,
2886            "All items with continuations should work. Got: {warnings:?}"
2887        );
2888    }
2889
2890    /// Issue #268: Continuation starting with lowercase (tests uppercase heuristic doesn't break this)
2891    #[test]
2892    fn test_blockquote_multi_paragraph_lowercase_continuation() {
2893        let content = "> - Item 1\n> \n>   and this continues the item\n> - Item 2\n";
2894        let warnings = lint(content);
2895        assert_eq!(
2896            warnings.len(),
2897            0,
2898            "Lowercase continuation should work. Got: {warnings:?}"
2899        );
2900    }
2901
2902    /// Issue #268: Continuation starting with uppercase (tests uppercase heuristic is bypassed with proper indent)
2903    #[test]
2904    fn test_blockquote_multi_paragraph_uppercase_continuation() {
2905        let content = "> - Item 1\n> \n>   This continues the item with uppercase\n> - Item 2\n";
2906        let warnings = lint(content);
2907        assert_eq!(
2908            warnings.len(),
2909            0,
2910            "Uppercase continuation with proper indent should work. Got: {warnings:?}"
2911        );
2912    }
2913
2914    /// Issue #268: Mixed ordered and unordered shouldn't affect multi-paragraph handling
2915    #[test]
2916    fn test_blockquote_separate_ordered_unordered_multi_paragraph() {
2917        // Two separate lists in same blockquote
2918        let content = "> - Unordered item\n> \n>   Continuation\n> \n> 1. Ordered item\n> \n>    Continuation\n";
2919        let warnings = lint(content);
2920        // May have warning for missing blank between lists, but not for the continuations
2921        assert!(
2922            warnings.len() <= 1,
2923            "Separate lists with continuations should be reasonable. Got: {warnings:?}"
2924        );
2925    }
2926
2927    /// Issue #268: Blockquote with bare > line (no space) as blank
2928    #[test]
2929    fn test_blockquote_multi_paragraph_bare_marker_blank() {
2930        // Using ">" alone instead of "> " for blank line
2931        let content = "> - Item 1\n>\n>   Continuation\n> - Item 2\n";
2932        let warnings = lint(content);
2933        assert_eq!(warnings.len(), 0, "Bare > as blank line should work. Got: {warnings:?}");
2934    }
2935
2936    #[test]
2937    fn test_blockquote_list_varying_spaces_after_marker() {
2938        // Different spacing after > (1 space vs 3 spaces) but same blockquote level
2939        let content = "> - item 1\n>   continuation with more indent\n> - item 2";
2940        let warnings = lint(content);
2941        assert_eq!(warnings.len(), 0, "Varying spaces after > should not break list");
2942    }
2943
2944    #[test]
2945    fn test_deeply_nested_blockquote_list() {
2946        // Triple-nested blockquote with list
2947        let content = ">>> - item 1\n>>>   continuation\n>>> - item 2";
2948        let warnings = lint(content);
2949        assert_eq!(
2950            warnings.len(),
2951            0,
2952            "Deeply nested blockquote list should have no warnings"
2953        );
2954    }
2955
2956    #[test]
2957    fn test_blockquote_level_change_in_list() {
2958        // Blockquote level changes mid-list - this breaks the list
2959        let content = "> - item 1\n>> - deeper item\n> - item 2";
2960        // Each segment is a separate list context due to blockquote level change
2961        // markdownlint-cli reports 4 warnings for this case
2962        let warnings = lint(content);
2963        assert!(
2964            !warnings.is_empty(),
2965            "Blockquote level change should break list and trigger warnings"
2966        );
2967    }
2968
2969    #[test]
2970    fn test_blockquote_list_with_code_span() {
2971        // List item with inline code in blockquote
2972        let content = "> - item with `code`\n>   continuation\n> - item 2";
2973        let warnings = lint(content);
2974        assert_eq!(
2975            warnings.len(),
2976            0,
2977            "Blockquote list with code span should have no warnings"
2978        );
2979    }
2980
2981    #[test]
2982    fn test_blockquote_list_at_document_end() {
2983        // List at end of document (no trailing content)
2984        let content = "> Some text\n>\n> - item 1\n> - item 2";
2985        let warnings = lint(content);
2986        assert_eq!(
2987            warnings.len(),
2988            0,
2989            "Blockquote list at document end should have no warnings"
2990        );
2991    }
2992
2993    #[test]
2994    fn test_fix_preserves_blockquote_prefix_before_list() {
2995        // Issue #268: Fix should insert blockquote-prefixed blank lines inside blockquotes
2996        let content = "> Text before
2997> - Item 1
2998> - Item 2";
2999        let fixed = fix(content);
3000
3001        // The blank line inserted before the list should have the blockquote prefix (no trailing space per markdownlint-cli)
3002        let expected = "> Text before
3003>
3004> - Item 1
3005> - Item 2";
3006        assert_eq!(
3007            fixed, expected,
3008            "Fix should insert '>' blank line, not plain blank line"
3009        );
3010    }
3011
3012    #[test]
3013    fn test_fix_preserves_triple_nested_blockquote_prefix_for_list() {
3014        // Triple-nested blockquotes should preserve full prefix
3015        // Per markdownlint-cli, only preceding blank line is required
3016        let content = ">>> Triple nested
3017>>> - Item 1
3018>>> - Item 2
3019>>> More text";
3020        let fixed = fix(content);
3021
3022        // Should insert ">>>" blank line before list only
3023        let expected = ">>> Triple nested
3024>>>
3025>>> - Item 1
3026>>> - Item 2
3027>>> More text";
3028        assert_eq!(
3029            fixed, expected,
3030            "Fix should preserve triple-nested blockquote prefix '>>>'"
3031        );
3032    }
3033
3034    // ==================== Quarto Flavor Tests ====================
3035
3036    fn lint_quarto(content: &str) -> Vec<LintWarning> {
3037        let rule = MD032BlanksAroundLists::default();
3038        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
3039        rule.check(&ctx).unwrap()
3040    }
3041
3042    #[test]
3043    fn test_quarto_list_after_div_open() {
3044        // List immediately after Quarto div opening: div marker is transparent
3045        let content = "Content\n\n::: {.callout-note}\n- Item 1\n- Item 2\n:::\n";
3046        let warnings = lint_quarto(content);
3047        // The blank line before div opening should count as separation
3048        assert!(
3049            warnings.is_empty(),
3050            "Quarto div marker should be transparent before list: {warnings:?}"
3051        );
3052    }
3053
3054    #[test]
3055    fn test_quarto_list_before_div_close() {
3056        // List immediately before Quarto div closing: div close is at end, transparent
3057        let content = "::: {.callout-note}\n\n- Item 1\n- Item 2\n:::\n";
3058        let warnings = lint_quarto(content);
3059        // The div closing marker is at end, should be transparent
3060        assert!(
3061            warnings.is_empty(),
3062            "Quarto div marker should be transparent after list: {warnings:?}"
3063        );
3064    }
3065
3066    #[test]
3067    fn test_quarto_list_needs_blank_without_div() {
3068        // List still needs blank line without div providing separation
3069        let content = "Content\n::: {.callout-note}\n- Item 1\n- Item 2\n:::\n";
3070        let warnings = lint_quarto(content);
3071        // No blank between "Content" and div opening (which is transparent)
3072        // so list appears right after "Content" - needs blank
3073        assert!(
3074            !warnings.is_empty(),
3075            "Should still require blank when not present: {warnings:?}"
3076        );
3077    }
3078
3079    #[test]
3080    fn test_quarto_list_in_callout_with_content() {
3081        // List inside callout with proper blank lines
3082        let content = "::: {.callout-note}\nNote introduction:\n\n- Item 1\n- Item 2\n\nMore note content.\n:::\n";
3083        let warnings = lint_quarto(content);
3084        assert!(
3085            warnings.is_empty(),
3086            "List with proper blanks inside callout should pass: {warnings:?}"
3087        );
3088    }
3089
3090    #[test]
3091    fn test_quarto_div_markers_not_transparent_in_standard_flavor() {
3092        // In standard flavor, ::: is regular text
3093        let content = "Content\n\n:::\n- Item 1\n- Item 2\n:::\n";
3094        let warnings = lint(content); // Uses standard flavor
3095        // In standard, ::: is just text, so list follows ::: without blank
3096        assert!(
3097            !warnings.is_empty(),
3098            "Standard flavor should not treat ::: as transparent: {warnings:?}"
3099        );
3100    }
3101
3102    #[test]
3103    fn test_quarto_nested_divs_with_list() {
3104        // Nested Quarto divs with list inside
3105        let content = "::: {.outer}\n::: {.inner}\n\n- Item 1\n- Item 2\n\n:::\n:::\n";
3106        let warnings = lint_quarto(content);
3107        assert!(warnings.is_empty(), "Nested divs with list should work: {warnings:?}");
3108    }
3109
3110    #[test]
3111    fn test_issue512_complex_nested_list_with_continuation() {
3112        // Three-level nested list with continuation paragraphs at parent indent levels.
3113        // The continuation paragraphs are part of the same list, so no MD032 warning expected.
3114        let content = "\
3115- First level of indentation.
3116  - Second level of indentation.
3117    - Third level of indentation.
3118    - Third level of indentation.
3119
3120    Second level list continuation.
3121
3122  First level list continuation.
3123- First level of indentation.
3124";
3125        let warnings = lint(content);
3126        assert!(
3127            warnings.is_empty(),
3128            "Nested list with parent-level continuation should produce no warnings. Got: {warnings:?}"
3129        );
3130    }
3131
3132    #[test]
3133    fn test_issue512_continuation_at_root_level() {
3134        // Nested list where continuation returns to indent 0 (lazy continuation).
3135        // The unindented "Root level lazy continuation." breaks the list, so the next
3136        // list item needs a blank line before it. markdownlint-cli also warns here.
3137        let content = "\
3138- First level.
3139  - Second level.
3140
3141  First level continuation.
3142
3143Root level lazy continuation.
3144- Another first level item.
3145";
3146        let warnings = lint(content);
3147        assert_eq!(
3148            warnings.len(),
3149            1,
3150            "Should warn on line 7 (new list after break). Got: {warnings:?}"
3151        );
3152        assert_eq!(warnings[0].line, 7);
3153    }
3154
3155    #[test]
3156    fn test_issue512_three_level_nesting_continuation_at_each_level() {
3157        // Each nesting level has a continuation paragraph
3158        let content = "\
3159- Level 1 item.
3160  - Level 2 item.
3161    - Level 3 item.
3162
3163    Level 3 continuation.
3164
3165  Level 2 continuation.
3166
3167  Level 1 continuation (indented under marker).
3168- Another level 1 item.
3169";
3170        let warnings = lint(content);
3171        assert!(
3172            warnings.is_empty(),
3173            "Continuation at each nesting level should produce no warnings. Got: {warnings:?}"
3174        );
3175    }
3176
3177    #[test]
3178    fn test_pandoc_list_after_div_open() {
3179        // List immediately after a Pandoc div opening should not require a blank line,
3180        // mirroring the Quarto behavior tested in test_quarto_list_after_div_open.
3181        let rule = MD032BlanksAroundLists::default();
3182        let content = "Content\n\n::: {.callout-note}\n- Item 1\n- Item 2\n:::\n";
3183        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
3184        let warnings = rule.check(&ctx).unwrap();
3185        assert!(
3186            warnings.is_empty(),
3187            "MD032 should treat Pandoc div marker as transparent before list: {warnings:?}"
3188        );
3189    }
3190}