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