rumdl_lib/rules/
md032_blanks_around_lists.rs

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