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