Skip to main content

rumdl_lib/rules/
md032_blanks_around_lists.rs

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