Skip to main content

rumdl_lib/rules/
md032_blanks_around_lists.rs

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