Skip to main content

rumdl_lib/rules/
md032_blanks_around_lists.rs

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