rumdl_lib/rules/
md032_blanks_around_lists.rs

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