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