Skip to main content

rumdl_lib/rules/
md032_blanks_around_lists.rs

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