Skip to main content

rumdl_lib/rules/
md032_blanks_around_lists.rs

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