Skip to main content

rumdl_lib/rules/
md032_blanks_around_lists.rs

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