rumdl_lib/rules/
md032_blanks_around_lists.rs

1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2use crate::utils::range_utils::{LineIndex, calculate_line_range};
3use crate::utils::regex_cache::BLOCKQUOTE_PREFIX_RE;
4use regex::Regex;
5use std::sync::LazyLock;
6// Detects ordered list items starting with a number other than 1
7static ORDERED_LIST_NON_ONE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*([2-9]|\d{2,})\.\s").unwrap());
8
9/// Check if a line is a thematic break (horizontal rule)
10/// Per CommonMark: 0-3 spaces, then 3+ of same char (-, *, _), optionally with spaces between
11fn is_thematic_break(line: &str) -> bool {
12    let leading_spaces = line.len() - line.trim_start_matches(' ').len();
13    if leading_spaces > 3 || line.starts_with('\t') {
14        return false;
15    }
16
17    let trimmed = line.trim();
18    if trimmed.len() < 3 {
19        return false;
20    }
21
22    let chars: Vec<char> = trimmed.chars().collect();
23    let first_non_space = chars.iter().find(|&&c| c != ' ');
24
25    if let Some(&marker) = first_non_space {
26        if marker != '-' && marker != '*' && marker != '_' {
27            return false;
28        }
29        let marker_count = chars.iter().filter(|&&c| c == marker).count();
30        let other_count = chars.iter().filter(|&&c| c != marker && c != ' ').count();
31        marker_count >= 3 && other_count == 0
32    } else {
33        false
34    }
35}
36
37/// Rule MD032: Lists should be surrounded by blank lines
38///
39/// This rule enforces that lists are surrounded by blank lines, which improves document
40/// readability and ensures consistent rendering across different Markdown processors.
41///
42/// ## Purpose
43///
44/// - **Readability**: Blank lines create visual separation between lists and surrounding content
45/// - **Parsing**: Many Markdown parsers require blank lines around lists for proper rendering
46/// - **Consistency**: Ensures uniform document structure and appearance
47/// - **Compatibility**: Improves compatibility across different Markdown implementations
48///
49/// ## Examples
50///
51/// ### Correct
52///
53/// ```markdown
54/// This is a paragraph of text.
55///
56/// - Item 1
57/// - Item 2
58/// - Item 3
59///
60/// This is another paragraph.
61/// ```
62///
63/// ### Incorrect
64///
65/// ```markdown
66/// This is a paragraph of text.
67/// - Item 1
68/// - Item 2
69/// - Item 3
70/// This is another paragraph.
71/// ```
72///
73/// ## Behavior Details
74///
75/// This rule checks for the following:
76///
77/// - **List Start**: There should be a blank line before the first item in a list
78///   (unless the list is at the beginning of the document or after front matter)
79/// - **List End**: There should be a blank line after the last item in a list
80///   (unless the list is at the end of the document)
81/// - **Nested Lists**: Properly handles nested lists and list continuations
82/// - **List Types**: Works with ordered lists, unordered lists, and all valid list markers (-, *, +)
83///
84/// ## Special Cases
85///
86/// This rule handles several special cases:
87///
88/// - **Front Matter**: YAML front matter is detected and skipped
89/// - **Code Blocks**: Lists inside code blocks are ignored
90/// - **List Content**: Indented content belonging to list items is properly recognized as part of the list
91/// - **Document Boundaries**: Lists at the beginning or end of the document have adjusted requirements
92///
93/// ## Fix Behavior
94///
95/// When applying automatic fixes, this rule:
96/// - Adds a blank line before the first list item when needed
97/// - Adds a blank line after the last list item when needed
98/// - Preserves document structure and existing content
99///
100/// ## Performance Optimizations
101///
102/// The rule includes several optimizations:
103/// - Fast path checks before applying more expensive regex operations
104/// - Efficient list item detection
105/// - Pre-computation of code block lines to avoid redundant processing
106#[derive(Debug, Clone, Default)]
107pub struct MD032BlanksAroundLists;
108
109impl MD032BlanksAroundLists {
110    /// Check if a blank line should be required before a list based on the previous line context
111    fn should_require_blank_line_before(
112        ctx: &crate::lint_context::LintContext,
113        prev_line_num: usize,
114        current_line_num: usize,
115    ) -> bool {
116        // Always require blank lines after code blocks, front matter, etc.
117        if ctx
118            .line_info(prev_line_num)
119            .is_some_and(|info| info.in_code_block || info.in_front_matter)
120        {
121            return true;
122        }
123
124        // Always allow nested lists (lists indented within other list items)
125        if Self::is_nested_list(ctx, prev_line_num, current_line_num) {
126            return false;
127        }
128
129        // Default: require blank line (matching markdownlint's behavior)
130        true
131    }
132
133    /// Check if the current list is nested within another list item
134    fn is_nested_list(
135        ctx: &crate::lint_context::LintContext,
136        prev_line_num: usize,    // 1-indexed
137        current_line_num: usize, // 1-indexed
138    ) -> bool {
139        // Check if current line is indented (typical for nested lists)
140        if current_line_num > 0 && current_line_num - 1 < ctx.lines.len() {
141            let current_line = &ctx.lines[current_line_num - 1];
142            if current_line.indent >= 2 {
143                // Check if previous line is a list item or list content
144                if prev_line_num > 0 && prev_line_num - 1 < ctx.lines.len() {
145                    let prev_line = &ctx.lines[prev_line_num - 1];
146                    // Previous line is a list item or indented content
147                    if prev_line.list_item.is_some() || prev_line.indent >= 2 {
148                        return true;
149                    }
150                }
151            }
152        }
153        false
154    }
155
156    // Convert centralized list blocks to the format expected by perform_checks
157    fn convert_list_blocks(&self, ctx: &crate::lint_context::LintContext) -> Vec<(usize, usize, String)> {
158        let mut blocks: Vec<(usize, usize, String)> = Vec::new();
159
160        for block in &ctx.list_blocks {
161            // For MD032, we need to check if there are code blocks that should
162            // split the list into separate segments
163
164            // Simple approach: if there's a fenced code block between list items,
165            // split at that point
166            let mut segments: Vec<(usize, usize)> = Vec::new();
167            let mut current_start = block.start_line;
168            let mut prev_item_line = 0;
169
170            for &item_line in &block.item_lines {
171                if prev_item_line > 0 {
172                    // Check if there's a standalone code fence between prev_item_line and item_line
173                    // A code fence that's indented as part of a list item should NOT split the list
174                    let mut has_standalone_code_fence = false;
175
176                    // Calculate minimum indentation for list item content
177                    let min_indent_for_content = if block.is_ordered {
178                        // For ordered lists, content should be indented at least to align with text after marker
179                        // e.g., "1. " = 3 chars, so content should be indented 3+ spaces
180                        3 // Minimum for "1. "
181                    } else {
182                        // For unordered lists, content should be indented at least 2 spaces
183                        2 // For "- " or "* "
184                    };
185
186                    for check_line in (prev_item_line + 1)..item_line {
187                        if check_line - 1 < ctx.lines.len() {
188                            let line = &ctx.lines[check_line - 1];
189                            let line_content = line.content(ctx.content);
190                            if line.in_code_block
191                                && (line_content.trim().starts_with("```") || line_content.trim().starts_with("~~~"))
192                            {
193                                // Check if this code fence is indented as part of the list item
194                                // If it's indented enough to be part of the list item, it shouldn't split
195                                if line.indent < min_indent_for_content {
196                                    has_standalone_code_fence = true;
197                                    break;
198                                }
199                            }
200                        }
201                    }
202
203                    if has_standalone_code_fence {
204                        // End current segment before this item
205                        segments.push((current_start, prev_item_line));
206                        current_start = item_line;
207                    }
208                }
209                prev_item_line = item_line;
210            }
211
212            // Add the final segment
213            // For the last segment, end at the last list item (not the full block end)
214            if prev_item_line > 0 {
215                segments.push((current_start, prev_item_line));
216            }
217
218            // Check if this list block was split by code fences
219            let has_code_fence_splits = segments.len() > 1 && {
220                // Check if any segments were created due to code fences
221                let mut found_fence = false;
222                for i in 0..segments.len() - 1 {
223                    let seg_end = segments[i].1;
224                    let next_start = segments[i + 1].0;
225                    // Check if there's a code fence between these segments
226                    for check_line in (seg_end + 1)..next_start {
227                        if check_line - 1 < ctx.lines.len() {
228                            let line = &ctx.lines[check_line - 1];
229                            let line_content = line.content(ctx.content);
230                            if line.in_code_block
231                                && (line_content.trim().starts_with("```") || line_content.trim().starts_with("~~~"))
232                            {
233                                found_fence = true;
234                                break;
235                            }
236                        }
237                    }
238                    if found_fence {
239                        break;
240                    }
241                }
242                found_fence
243            };
244
245            // Convert segments to blocks
246            for (start, end) in segments.iter() {
247                // Extend the end to include any continuation lines immediately after the last item
248                let mut actual_end = *end;
249
250                // If this list was split by code fences, don't extend any segments
251                // They should remain as individual list items for MD032 purposes
252                if !has_code_fence_splits && *end < block.end_line {
253                    for check_line in (*end + 1)..=block.end_line {
254                        if check_line - 1 < ctx.lines.len() {
255                            let line = &ctx.lines[check_line - 1];
256                            let line_content = line.content(ctx.content);
257                            // Stop at next list item or non-continuation content
258                            if block.item_lines.contains(&check_line) || line.heading.is_some() {
259                                break;
260                            }
261                            // Don't extend through code blocks
262                            if line.in_code_block {
263                                break;
264                            }
265                            // Include indented continuation
266                            if line.indent >= 2 {
267                                actual_end = check_line;
268                            }
269                            // Include lazy continuation lines (multiple consecutive lines without indent)
270                            // Per CommonMark, only paragraph text can be lazy continuation
271                            // Thematic breaks, code fences, etc. cannot be lazy continuations
272                            else if !line.is_blank
273                                && line.heading.is_none()
274                                && !block.item_lines.contains(&check_line)
275                                && !is_thematic_break(line_content)
276                            {
277                                // This is a lazy continuation line - check if we're still in the same paragraph
278                                // Allow multiple consecutive lazy continuation lines
279                                actual_end = check_line;
280                            } else if !line.is_blank {
281                                // Non-blank line that's not a continuation - stop here
282                                break;
283                            }
284                        }
285                    }
286                }
287
288                blocks.push((*start, actual_end, block.blockquote_prefix.clone()));
289            }
290        }
291
292        blocks
293    }
294
295    fn perform_checks(
296        &self,
297        ctx: &crate::lint_context::LintContext,
298        lines: &[&str],
299        list_blocks: &[(usize, usize, String)],
300        line_index: &LineIndex,
301    ) -> LintResult {
302        let mut warnings = Vec::new();
303        let num_lines = lines.len();
304
305        // Check for ordered lists starting with non-1 that aren't recognized as lists
306        // These need blank lines before them to be parsed as lists by CommonMark
307        for (line_idx, line) in lines.iter().enumerate() {
308            let line_num = line_idx + 1;
309
310            // Skip if this line is already part of a recognized list
311            let is_in_list = list_blocks
312                .iter()
313                .any(|(start, end, _)| line_num >= *start && line_num <= *end);
314            if is_in_list {
315                continue;
316            }
317
318            // Skip if in code block or front matter
319            if ctx
320                .line_info(line_num)
321                .is_some_and(|info| info.in_code_block || info.in_front_matter)
322            {
323                continue;
324            }
325
326            // Check if this line starts with a number other than 1
327            if ORDERED_LIST_NON_ONE_RE.is_match(line) {
328                // Check if there's a blank line before this
329                if line_idx > 0 {
330                    let prev_line = lines[line_idx - 1];
331                    let prev_is_blank = is_blank_in_context(prev_line);
332                    let prev_excluded = ctx
333                        .line_info(line_idx)
334                        .is_some_and(|info| info.in_code_block || info.in_front_matter);
335
336                    if !prev_is_blank && !prev_excluded {
337                        // This ordered list item starting with non-1 needs a blank line before it
338                        let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line);
339
340                        warnings.push(LintWarning {
341                            line: start_line,
342                            column: start_col,
343                            end_line,
344                            end_column: end_col,
345                            severity: Severity::Warning,
346                            rule_name: Some(self.name().to_string()),
347                            message: "Ordered list starting with non-1 should be preceded by blank line".to_string(),
348                            fix: Some(Fix {
349                                range: line_index.line_col_to_byte_range_with_length(line_num, 1, 0),
350                                replacement: "\n".to_string(),
351                            }),
352                        });
353                    }
354                }
355            }
356        }
357
358        for &(start_line, end_line, ref prefix) in list_blocks {
359            if start_line > 1 {
360                let prev_line_actual_idx_0 = start_line - 2;
361                let prev_line_actual_idx_1 = start_line - 1;
362                let prev_line_str = lines[prev_line_actual_idx_0];
363                let is_prev_excluded = ctx
364                    .line_info(prev_line_actual_idx_1)
365                    .is_some_and(|info| info.in_code_block || info.in_front_matter);
366                let prev_prefix = BLOCKQUOTE_PREFIX_RE
367                    .find(prev_line_str)
368                    .map_or(String::new(), |m| m.as_str().to_string());
369                let prev_is_blank = is_blank_in_context(prev_line_str);
370                let prefixes_match = prev_prefix.trim() == prefix.trim();
371
372                // Only require blank lines for content in the same context (same blockquote level)
373                // and when the context actually requires it
374                let should_require = Self::should_require_blank_line_before(ctx, prev_line_actual_idx_1, start_line);
375                if !is_prev_excluded && !prev_is_blank && prefixes_match && should_require {
376                    // Calculate precise character range for the entire list line that needs a blank line before it
377                    let (start_line, start_col, end_line, end_col) =
378                        calculate_line_range(start_line, lines[start_line - 1]);
379
380                    warnings.push(LintWarning {
381                        line: start_line,
382                        column: start_col,
383                        end_line,
384                        end_column: end_col,
385                        severity: Severity::Warning,
386                        rule_name: Some(self.name().to_string()),
387                        message: "List should be preceded by blank line".to_string(),
388                        fix: Some(Fix {
389                            range: line_index.line_col_to_byte_range_with_length(start_line, 1, 0),
390                            replacement: format!("{prefix}\n"),
391                        }),
392                    });
393                }
394            }
395
396            if end_line < num_lines {
397                let next_line_idx_0 = end_line;
398                let next_line_idx_1 = end_line + 1;
399                let next_line_str = lines[next_line_idx_0];
400                // Check if next line is excluded - front matter or indented code blocks within lists
401                // We want blank lines before standalone code blocks, but not within list items
402                let is_next_excluded = ctx.line_info(next_line_idx_1).is_some_and(|info| info.in_front_matter)
403                    || (next_line_idx_0 < ctx.lines.len()
404                        && ctx.lines[next_line_idx_0].in_code_block
405                        && ctx.lines[next_line_idx_0].indent >= 2);
406                let next_prefix = BLOCKQUOTE_PREFIX_RE
407                    .find(next_line_str)
408                    .map_or(String::new(), |m| m.as_str().to_string());
409                let next_is_blank = is_blank_in_context(next_line_str);
410                let prefixes_match = next_prefix.trim() == prefix.trim();
411
412                // Only require blank lines for content in the same context (same blockquote level)
413                if !is_next_excluded && !next_is_blank && prefixes_match {
414                    // Calculate precise character range for the last line of the list (not the line after)
415                    let (start_line_last, start_col_last, end_line_last, end_col_last) =
416                        calculate_line_range(end_line, lines[end_line - 1]);
417
418                    warnings.push(LintWarning {
419                        line: start_line_last,
420                        column: start_col_last,
421                        end_line: end_line_last,
422                        end_column: end_col_last,
423                        severity: Severity::Warning,
424                        rule_name: Some(self.name().to_string()),
425                        message: "List should be followed by blank line".to_string(),
426                        fix: Some(Fix {
427                            range: line_index.line_col_to_byte_range_with_length(end_line + 1, 1, 0),
428                            replacement: format!("{prefix}\n"),
429                        }),
430                    });
431                }
432            }
433        }
434        Ok(warnings)
435    }
436}
437
438impl Rule for MD032BlanksAroundLists {
439    fn name(&self) -> &'static str {
440        "MD032"
441    }
442
443    fn description(&self) -> &'static str {
444        "Lists should be surrounded by blank lines"
445    }
446
447    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
448        let content = ctx.content;
449        let lines: Vec<&str> = content.lines().collect();
450        let line_index = &ctx.line_index;
451
452        // Early return for empty content
453        if lines.is_empty() {
454            return Ok(Vec::new());
455        }
456
457        let list_blocks = self.convert_list_blocks(ctx);
458
459        if list_blocks.is_empty() {
460            return Ok(Vec::new());
461        }
462
463        self.perform_checks(ctx, &lines, &list_blocks, line_index)
464    }
465
466    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
467        self.fix_with_structure_impl(ctx)
468    }
469
470    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
471        // Fast path: check if document likely has lists
472        if ctx.content.is_empty() || !ctx.likely_has_lists() {
473            return true;
474        }
475        // Verify list blocks actually exist
476        ctx.list_blocks.is_empty()
477    }
478
479    fn category(&self) -> RuleCategory {
480        RuleCategory::List
481    }
482
483    fn as_any(&self) -> &dyn std::any::Any {
484        self
485    }
486
487    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
488    where
489        Self: Sized,
490    {
491        Box::new(MD032BlanksAroundLists)
492    }
493}
494
495impl MD032BlanksAroundLists {
496    /// Helper method for fixing implementation
497    fn fix_with_structure_impl(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
498        let lines: Vec<&str> = ctx.content.lines().collect();
499        let num_lines = lines.len();
500        if num_lines == 0 {
501            return Ok(String::new());
502        }
503
504        let list_blocks = self.convert_list_blocks(ctx);
505        if list_blocks.is_empty() {
506            return Ok(ctx.content.to_string());
507        }
508
509        let mut insertions: std::collections::BTreeMap<usize, String> = std::collections::BTreeMap::new();
510
511        // Phase 1: Identify needed insertions
512        for &(start_line, end_line, ref prefix) in &list_blocks {
513            // Check before block
514            if start_line > 1 {
515                let prev_line_actual_idx_0 = start_line - 2;
516                let prev_line_actual_idx_1 = start_line - 1;
517                let is_prev_excluded = ctx
518                    .line_info(prev_line_actual_idx_1)
519                    .is_some_and(|info| info.in_code_block || info.in_front_matter);
520                let prev_prefix = BLOCKQUOTE_PREFIX_RE
521                    .find(lines[prev_line_actual_idx_0])
522                    .map_or(String::new(), |m| m.as_str().to_string());
523
524                let should_require = Self::should_require_blank_line_before(ctx, prev_line_actual_idx_1, start_line);
525                if !is_prev_excluded
526                    && !is_blank_in_context(lines[prev_line_actual_idx_0])
527                    && prev_prefix == *prefix
528                    && should_require
529                {
530                    insertions.insert(start_line, prefix.clone());
531                }
532            }
533
534            // Check after block
535            if end_line < num_lines {
536                let after_block_line_idx_0 = end_line;
537                let after_block_line_idx_1 = end_line + 1;
538                let line_after_block_content_str = lines[after_block_line_idx_0];
539                // Check if next line is excluded - in code block, front matter, or starts an indented code block
540                // Only exclude code fence lines if they're indented (part of list content)
541                let is_line_after_excluded = ctx
542                    .line_info(after_block_line_idx_1)
543                    .is_some_and(|info| info.in_code_block || info.in_front_matter)
544                    || (after_block_line_idx_0 < ctx.lines.len()
545                        && ctx.lines[after_block_line_idx_0].in_code_block
546                        && ctx.lines[after_block_line_idx_0].indent >= 2
547                        && (ctx.lines[after_block_line_idx_0]
548                            .content(ctx.content)
549                            .trim()
550                            .starts_with("```")
551                            || ctx.lines[after_block_line_idx_0]
552                                .content(ctx.content)
553                                .trim()
554                                .starts_with("~~~")));
555                let after_prefix = BLOCKQUOTE_PREFIX_RE
556                    .find(line_after_block_content_str)
557                    .map_or(String::new(), |m| m.as_str().to_string());
558
559                if !is_line_after_excluded
560                    && !is_blank_in_context(line_after_block_content_str)
561                    && after_prefix == *prefix
562                {
563                    insertions.insert(after_block_line_idx_1, prefix.clone());
564                }
565            }
566        }
567
568        // Phase 2: Reconstruct with insertions
569        let mut result_lines: Vec<String> = Vec::with_capacity(num_lines + insertions.len());
570        for (i, line) in lines.iter().enumerate() {
571            let current_line_num = i + 1;
572            if let Some(prefix_to_insert) = insertions.get(&current_line_num)
573                && (result_lines.is_empty() || result_lines.last().unwrap() != prefix_to_insert)
574            {
575                result_lines.push(prefix_to_insert.clone());
576            }
577            result_lines.push(line.to_string());
578        }
579
580        // Preserve the final newline if the original content had one
581        let mut result = result_lines.join("\n");
582        if ctx.content.ends_with('\n') {
583            result.push('\n');
584        }
585        Ok(result)
586    }
587}
588
589// Checks if a line is blank, considering blockquote context
590fn is_blank_in_context(line: &str) -> bool {
591    // A line is blank if it's empty or contains only whitespace,
592    // potentially after removing blockquote markers.
593    if let Some(m) = BLOCKQUOTE_PREFIX_RE.find(line) {
594        // If a blockquote prefix is found, check if the content *after* the prefix is blank.
595        line[m.end()..].trim().is_empty()
596    } else {
597        // No blockquote prefix, check the whole line for blankness.
598        line.trim().is_empty()
599    }
600}
601
602#[cfg(test)]
603mod tests {
604    use super::*;
605    use crate::lint_context::LintContext;
606    use crate::rule::Rule;
607
608    fn lint(content: &str) -> Vec<LintWarning> {
609        let rule = MD032BlanksAroundLists;
610        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
611        rule.check(&ctx).expect("Lint check failed")
612    }
613
614    fn fix(content: &str) -> String {
615        let rule = MD032BlanksAroundLists;
616        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
617        rule.fix(&ctx).expect("Lint fix failed")
618    }
619
620    // Test that warnings include Fix objects
621    fn check_warnings_have_fixes(content: &str) {
622        let warnings = lint(content);
623        for warning in &warnings {
624            assert!(warning.fix.is_some(), "Warning should have fix: {warning:?}");
625        }
626    }
627
628    #[test]
629    fn test_list_at_start() {
630        // Per markdownlint-cli: trailing text without blank line is treated as lazy continuation
631        // so NO warning is expected here
632        let content = "- Item 1\n- Item 2\nText";
633        let warnings = lint(content);
634        assert_eq!(
635            warnings.len(),
636            0,
637            "Trailing text is lazy continuation per CommonMark - no warning expected"
638        );
639    }
640
641    #[test]
642    fn test_list_at_end() {
643        let content = "Text\n- Item 1\n- Item 2";
644        let warnings = lint(content);
645        assert_eq!(
646            warnings.len(),
647            1,
648            "Expected 1 warning for list at end without preceding blank line"
649        );
650        assert_eq!(
651            warnings[0].line, 2,
652            "Warning should be on the first line of the list (line 2)"
653        );
654        assert!(warnings[0].message.contains("preceded by blank line"));
655
656        // Test that warning has fix
657        check_warnings_have_fixes(content);
658
659        let fixed_content = fix(content);
660        assert_eq!(fixed_content, "Text\n\n- Item 1\n- Item 2");
661
662        // Verify fix resolves the issue
663        let warnings_after_fix = lint(&fixed_content);
664        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
665    }
666
667    #[test]
668    fn test_list_in_middle() {
669        // Per markdownlint-cli: only preceding blank line is required
670        // Trailing text is treated as lazy continuation
671        let content = "Text 1\n- Item 1\n- Item 2\nText 2";
672        let warnings = lint(content);
673        assert_eq!(
674            warnings.len(),
675            1,
676            "Expected 1 warning for list needing preceding blank line (trailing text is lazy continuation)"
677        );
678        assert_eq!(warnings[0].line, 2, "Warning on line 2 (start)");
679        assert!(warnings[0].message.contains("preceded by blank line"));
680
681        // Test that warnings have fixes
682        check_warnings_have_fixes(content);
683
684        let fixed_content = fix(content);
685        assert_eq!(fixed_content, "Text 1\n\n- Item 1\n- Item 2\nText 2");
686
687        // Verify fix resolves the issue
688        let warnings_after_fix = lint(&fixed_content);
689        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
690    }
691
692    #[test]
693    fn test_correct_spacing() {
694        let content = "Text 1\n\n- Item 1\n- Item 2\n\nText 2";
695        let warnings = lint(content);
696        assert_eq!(warnings.len(), 0, "Expected no warnings for correctly spaced list");
697
698        let fixed_content = fix(content);
699        assert_eq!(fixed_content, content, "Fix should not change correctly spaced content");
700    }
701
702    #[test]
703    fn test_list_with_content() {
704        // Per markdownlint-cli: only preceding blank line warning
705        // Trailing text is lazy continuation
706        let content = "Text\n* Item 1\n  Content\n* Item 2\n  More content\nText";
707        let warnings = lint(content);
708        assert_eq!(
709            warnings.len(),
710            1,
711            "Expected 1 warning for list needing preceding blank line. Got: {warnings:?}"
712        );
713        assert_eq!(warnings[0].line, 2, "Warning should be on line 2 (start)");
714        assert!(warnings[0].message.contains("preceded by blank line"));
715
716        // Test that warnings have fixes
717        check_warnings_have_fixes(content);
718
719        let fixed_content = fix(content);
720        let expected_fixed = "Text\n\n* Item 1\n  Content\n* Item 2\n  More content\nText";
721        assert_eq!(
722            fixed_content, expected_fixed,
723            "Fix did not produce the expected output. Got:\n{fixed_content}"
724        );
725
726        // Verify fix resolves the issue
727        let warnings_after_fix = lint(&fixed_content);
728        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
729    }
730
731    #[test]
732    fn test_nested_list() {
733        // Per markdownlint-cli: only preceding blank line warning
734        let content = "Text\n- Item 1\n  - Nested 1\n- Item 2\nText";
735        let warnings = lint(content);
736        assert_eq!(
737            warnings.len(),
738            1,
739            "Nested list block needs preceding blank only. Got: {warnings:?}"
740        );
741        assert_eq!(warnings[0].line, 2);
742        assert!(warnings[0].message.contains("preceded by blank line"));
743
744        // Test that warnings have fixes
745        check_warnings_have_fixes(content);
746
747        let fixed_content = fix(content);
748        assert_eq!(fixed_content, "Text\n\n- Item 1\n  - Nested 1\n- Item 2\nText");
749
750        // Verify fix resolves the issue
751        let warnings_after_fix = lint(&fixed_content);
752        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
753    }
754
755    #[test]
756    fn test_list_with_internal_blanks() {
757        // Per markdownlint-cli: only preceding blank line warning
758        let content = "Text\n* Item 1\n\n  More Item 1 Content\n* Item 2\nText";
759        let warnings = lint(content);
760        assert_eq!(
761            warnings.len(),
762            1,
763            "List with internal blanks needs preceding blank only. Got: {warnings:?}"
764        );
765        assert_eq!(warnings[0].line, 2);
766        assert!(warnings[0].message.contains("preceded by blank line"));
767
768        // Test that warnings have fixes
769        check_warnings_have_fixes(content);
770
771        let fixed_content = fix(content);
772        assert_eq!(
773            fixed_content,
774            "Text\n\n* Item 1\n\n  More Item 1 Content\n* Item 2\nText"
775        );
776
777        // Verify fix resolves the issue
778        let warnings_after_fix = lint(&fixed_content);
779        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
780    }
781
782    #[test]
783    fn test_ignore_code_blocks() {
784        let content = "```\n- Not a list item\n```\nText";
785        let warnings = lint(content);
786        assert_eq!(warnings.len(), 0);
787        let fixed_content = fix(content);
788        assert_eq!(fixed_content, content);
789    }
790
791    #[test]
792    fn test_ignore_front_matter() {
793        // Per markdownlint-cli: NO warnings - front matter is followed by list, trailing text is lazy continuation
794        let content = "---\ntitle: Test\n---\n- List Item\nText";
795        let warnings = lint(content);
796        assert_eq!(
797            warnings.len(),
798            0,
799            "Front matter test should have no MD032 warnings. Got: {warnings:?}"
800        );
801
802        // No fixes needed since no warnings
803        let fixed_content = fix(content);
804        assert_eq!(fixed_content, content, "No changes when no warnings");
805    }
806
807    #[test]
808    fn test_multiple_lists() {
809        // Our implementation treats "Text 2" and "Text 3" as lazy continuation within a single merged list block
810        // (since both - and * are unordered markers and there's no structural separator)
811        // markdownlint-cli sees them as separate lists with 3 warnings, but our behavior differs.
812        // The key requirement is that the fix resolves all warnings.
813        let content = "Text\n- List 1 Item 1\n- List 1 Item 2\nText 2\n* List 2 Item 1\nText 3";
814        let warnings = lint(content);
815        // At minimum we should warn about missing preceding blank for line 2
816        assert!(
817            !warnings.is_empty(),
818            "Should have at least one warning for missing blank line. Got: {warnings:?}"
819        );
820
821        // Test that warnings have fixes
822        check_warnings_have_fixes(content);
823
824        let fixed_content = fix(content);
825        // The fix should add blank lines before lists that need them
826        let warnings_after_fix = lint(&fixed_content);
827        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
828    }
829
830    #[test]
831    fn test_adjacent_lists() {
832        let content = "- List 1\n\n* List 2";
833        let warnings = lint(content);
834        assert_eq!(warnings.len(), 0);
835        let fixed_content = fix(content);
836        assert_eq!(fixed_content, content);
837    }
838
839    #[test]
840    fn test_list_in_blockquote() {
841        // Per markdownlint-cli: 1 warning (preceding only, trailing is lazy continuation)
842        let content = "> Quote line 1\n> - List item 1\n> - List item 2\n> Quote line 2";
843        let warnings = lint(content);
844        assert_eq!(
845            warnings.len(),
846            1,
847            "Expected 1 warning for blockquoted list needing preceding blank. Got: {warnings:?}"
848        );
849        assert_eq!(warnings[0].line, 2);
850
851        // Test that warnings have fixes
852        check_warnings_have_fixes(content);
853
854        let fixed_content = fix(content);
855        // Fix should add blank line before list only
856        assert_eq!(
857            fixed_content, "> Quote line 1\n> \n> - List item 1\n> - List item 2\n> Quote line 2",
858            "Fix for blockquoted list failed. Got:\n{fixed_content}"
859        );
860
861        // Verify fix resolves the issue
862        let warnings_after_fix = lint(&fixed_content);
863        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
864    }
865
866    #[test]
867    fn test_ordered_list() {
868        // Per markdownlint-cli: 1 warning (preceding only)
869        let content = "Text\n1. Item 1\n2. Item 2\nText";
870        let warnings = lint(content);
871        assert_eq!(warnings.len(), 1);
872
873        // Test that warnings have fixes
874        check_warnings_have_fixes(content);
875
876        let fixed_content = fix(content);
877        assert_eq!(fixed_content, "Text\n\n1. Item 1\n2. Item 2\nText");
878
879        // Verify fix resolves the issue
880        let warnings_after_fix = lint(&fixed_content);
881        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
882    }
883
884    #[test]
885    fn test_no_double_blank_fix() {
886        // Per markdownlint-cli: trailing text is lazy continuation, so NO warning needed
887        let content = "Text\n\n- Item 1\n- Item 2\nText"; // Has preceding blank, trailing is lazy
888        let warnings = lint(content);
889        assert_eq!(
890            warnings.len(),
891            0,
892            "Should have no warnings - properly preceded, trailing is lazy"
893        );
894
895        let fixed_content = fix(content);
896        assert_eq!(
897            fixed_content, content,
898            "No fix needed when no warnings. Got:\n{fixed_content}"
899        );
900
901        let content2 = "Text\n- Item 1\n- Item 2\n\nText"; // Missing blank before
902        let warnings2 = lint(content2);
903        assert_eq!(warnings2.len(), 1);
904        if !warnings2.is_empty() {
905            assert_eq!(
906                warnings2[0].line, 2,
907                "Warning line for missing blank before should be the first line of the block"
908            );
909        }
910
911        // Test that warnings have fixes
912        check_warnings_have_fixes(content2);
913
914        let fixed_content2 = fix(content2);
915        assert_eq!(
916            fixed_content2, "Text\n\n- Item 1\n- Item 2\n\nText",
917            "Fix added extra blank before. Got:\n{fixed_content2}"
918        );
919    }
920
921    #[test]
922    fn test_empty_input() {
923        let content = "";
924        let warnings = lint(content);
925        assert_eq!(warnings.len(), 0);
926        let fixed_content = fix(content);
927        assert_eq!(fixed_content, "");
928    }
929
930    #[test]
931    fn test_only_list() {
932        let content = "- Item 1\n- Item 2";
933        let warnings = lint(content);
934        assert_eq!(warnings.len(), 0);
935        let fixed_content = fix(content);
936        assert_eq!(fixed_content, content);
937    }
938
939    // === COMPREHENSIVE FIX TESTS ===
940
941    #[test]
942    fn test_fix_complex_nested_blockquote() {
943        // Per markdownlint-cli: 1 warning (preceding only)
944        let content = "> Text before\n> - Item 1\n>   - Nested item\n> - Item 2\n> Text after";
945        let warnings = lint(content);
946        assert_eq!(
947            warnings.len(),
948            1,
949            "Should warn for missing preceding blank only. Got: {warnings:?}"
950        );
951
952        // Test that warnings have fixes
953        check_warnings_have_fixes(content);
954
955        let fixed_content = fix(content);
956        let expected = "> Text before\n> \n> - Item 1\n>   - Nested item\n> - Item 2\n> Text after";
957        assert_eq!(fixed_content, expected, "Fix should preserve blockquote structure");
958
959        let warnings_after_fix = lint(&fixed_content);
960        assert_eq!(warnings_after_fix.len(), 0, "Fix should eliminate all warnings");
961    }
962
963    #[test]
964    fn test_fix_mixed_list_markers() {
965        // Per markdownlint-cli: mixed markers may be treated as separate lists
966        // The exact behavior depends on implementation details
967        let content = "Text\n- Item 1\n* Item 2\n+ Item 3\nText";
968        let warnings = lint(content);
969        // At minimum, there should be a warning for the first list needing preceding blank
970        assert!(
971            !warnings.is_empty(),
972            "Should have at least 1 warning for mixed marker list. Got: {warnings:?}"
973        );
974
975        // Test that warnings have fixes
976        check_warnings_have_fixes(content);
977
978        let fixed_content = fix(content);
979        // The fix should add at least a blank line before the first list
980        assert!(
981            fixed_content.contains("Text\n\n-"),
982            "Fix should add blank line before first list item"
983        );
984
985        // Verify fix resolves the issue
986        let warnings_after_fix = lint(&fixed_content);
987        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
988    }
989
990    #[test]
991    fn test_fix_ordered_list_with_different_numbers() {
992        // Per markdownlint-cli: 1 warning (preceding only)
993        let content = "Text\n1. First\n3. Third\n2. Second\nText";
994        let warnings = lint(content);
995        assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
996
997        // Test that warnings have fixes
998        check_warnings_have_fixes(content);
999
1000        let fixed_content = fix(content);
1001        let expected = "Text\n\n1. First\n3. Third\n2. Second\nText";
1002        assert_eq!(
1003            fixed_content, expected,
1004            "Fix should handle ordered lists with non-sequential numbers"
1005        );
1006
1007        // Verify fix resolves the issue
1008        let warnings_after_fix = lint(&fixed_content);
1009        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1010    }
1011
1012    #[test]
1013    fn test_fix_list_with_code_blocks_inside() {
1014        // Per markdownlint-cli: 1 warning (preceding only)
1015        let content = "Text\n- Item 1\n  ```\n  code\n  ```\n- Item 2\nText";
1016        let warnings = lint(content);
1017        assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1018
1019        // Test that warnings have fixes
1020        check_warnings_have_fixes(content);
1021
1022        let fixed_content = fix(content);
1023        let expected = "Text\n\n- Item 1\n  ```\n  code\n  ```\n- Item 2\nText";
1024        assert_eq!(
1025            fixed_content, expected,
1026            "Fix should handle lists with internal code blocks"
1027        );
1028
1029        // Verify fix resolves the issue
1030        let warnings_after_fix = lint(&fixed_content);
1031        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1032    }
1033
1034    #[test]
1035    fn test_fix_deeply_nested_lists() {
1036        // Per markdownlint-cli: 1 warning (preceding only)
1037        let content = "Text\n- Level 1\n  - Level 2\n    - Level 3\n      - Level 4\n- Back to Level 1\nText";
1038        let warnings = lint(content);
1039        assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1040
1041        // Test that warnings have fixes
1042        check_warnings_have_fixes(content);
1043
1044        let fixed_content = fix(content);
1045        let expected = "Text\n\n- Level 1\n  - Level 2\n    - Level 3\n      - Level 4\n- Back to Level 1\nText";
1046        assert_eq!(fixed_content, expected, "Fix should handle deeply nested lists");
1047
1048        // Verify fix resolves the issue
1049        let warnings_after_fix = lint(&fixed_content);
1050        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1051    }
1052
1053    #[test]
1054    fn test_fix_list_with_multiline_items() {
1055        // Per markdownlint-cli: trailing "Text" at indent=0 is lazy continuation
1056        // Only the preceding blank line is required
1057        let content = "Text\n- Item 1\n  continues here\n  and here\n- Item 2\n  also continues\nText";
1058        let warnings = lint(content);
1059        assert_eq!(
1060            warnings.len(),
1061            1,
1062            "Should only warn for missing blank before list (trailing text is lazy continuation)"
1063        );
1064
1065        // Test that warnings have fixes
1066        check_warnings_have_fixes(content);
1067
1068        let fixed_content = fix(content);
1069        let expected = "Text\n\n- Item 1\n  continues here\n  and here\n- Item 2\n  also continues\nText";
1070        assert_eq!(fixed_content, expected, "Fix should add blank before list only");
1071
1072        // Verify fix resolves the issue
1073        let warnings_after_fix = lint(&fixed_content);
1074        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1075    }
1076
1077    #[test]
1078    fn test_fix_list_at_document_boundaries() {
1079        // List at very start
1080        let content1 = "- Item 1\n- Item 2";
1081        let warnings1 = lint(content1);
1082        assert_eq!(
1083            warnings1.len(),
1084            0,
1085            "List at document start should not need blank before"
1086        );
1087        let fixed1 = fix(content1);
1088        assert_eq!(fixed1, content1, "No fix needed for list at start");
1089
1090        // List at very end
1091        let content2 = "Text\n- Item 1\n- Item 2";
1092        let warnings2 = lint(content2);
1093        assert_eq!(warnings2.len(), 1, "List at document end should need blank before");
1094        check_warnings_have_fixes(content2);
1095        let fixed2 = fix(content2);
1096        assert_eq!(
1097            fixed2, "Text\n\n- Item 1\n- Item 2",
1098            "Should add blank before list at end"
1099        );
1100    }
1101
1102    #[test]
1103    fn test_fix_preserves_existing_blank_lines() {
1104        let content = "Text\n\n\n- Item 1\n- Item 2\n\n\nText";
1105        let warnings = lint(content);
1106        assert_eq!(warnings.len(), 0, "Multiple blank lines should be preserved");
1107        let fixed_content = fix(content);
1108        assert_eq!(fixed_content, content, "Fix should not modify already correct content");
1109    }
1110
1111    #[test]
1112    fn test_fix_handles_tabs_and_spaces() {
1113        // Per markdownlint-cli: trailing text is lazy continuation, only preceding blank needed
1114        let content = "Text\n\t- Item with tab\n  - Item with spaces\nText";
1115        let warnings = lint(content);
1116        // The tab-indented item and space-indented item may be seen as separate lists
1117        // Per markdownlint-cli behavior, we expect at least 1 warning
1118        assert!(!warnings.is_empty(), "Should warn for missing blank before list");
1119
1120        // Test that warnings have fixes
1121        check_warnings_have_fixes(content);
1122
1123        let fixed_content = fix(content);
1124        // Only add blank before, not after (trailing text is lazy continuation)
1125        let expected = "Text\n\n\t- Item with tab\n  - Item with spaces\nText";
1126        assert_eq!(fixed_content, expected, "Fix should preserve original indentation");
1127
1128        // Verify fix resolves the issue
1129        let warnings_after_fix = lint(&fixed_content);
1130        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1131    }
1132
1133    #[test]
1134    fn test_fix_warning_objects_have_correct_ranges() {
1135        // Per markdownlint-cli: trailing text is lazy continuation, only 1 warning
1136        let content = "Text\n- Item 1\n- Item 2\nText";
1137        let warnings = lint(content);
1138        assert_eq!(warnings.len(), 1, "Only preceding blank warning expected");
1139
1140        // Check that each warning has a fix with a valid range
1141        for warning in &warnings {
1142            assert!(warning.fix.is_some(), "Warning should have fix");
1143            let fix = warning.fix.as_ref().unwrap();
1144            assert!(fix.range.start <= fix.range.end, "Fix range should be valid");
1145            assert!(
1146                !fix.replacement.is_empty() || fix.range.start == fix.range.end,
1147                "Fix should have replacement or be insertion"
1148            );
1149        }
1150    }
1151
1152    #[test]
1153    fn test_fix_idempotent() {
1154        // Per markdownlint-cli: trailing text is lazy continuation
1155        let content = "Text\n- Item 1\n- Item 2\nText";
1156
1157        // Apply fix once - only adds blank before (trailing text is lazy continuation)
1158        let fixed_once = fix(content);
1159        assert_eq!(fixed_once, "Text\n\n- Item 1\n- Item 2\nText");
1160
1161        // Apply fix again - should be unchanged
1162        let fixed_twice = fix(&fixed_once);
1163        assert_eq!(fixed_twice, fixed_once, "Fix should be idempotent");
1164
1165        // No warnings after fix
1166        let warnings_after_fix = lint(&fixed_once);
1167        assert_eq!(warnings_after_fix.len(), 0, "No warnings should remain after fix");
1168    }
1169
1170    #[test]
1171    fn test_fix_with_normalized_line_endings() {
1172        // In production, content is normalized to LF at I/O boundary
1173        // Unit tests should use LF input to reflect actual runtime behavior
1174        // Per markdownlint-cli: trailing text is lazy continuation, only 1 warning
1175        let content = "Text\n- Item 1\n- Item 2\nText";
1176        let warnings = lint(content);
1177        assert_eq!(warnings.len(), 1, "Should detect missing blank before list");
1178
1179        // Test that warnings have fixes
1180        check_warnings_have_fixes(content);
1181
1182        let fixed_content = fix(content);
1183        // Only adds blank before (trailing text is lazy continuation)
1184        let expected = "Text\n\n- Item 1\n- Item 2\nText";
1185        assert_eq!(fixed_content, expected, "Fix should work with normalized LF content");
1186    }
1187
1188    #[test]
1189    fn test_fix_preserves_final_newline() {
1190        // Per markdownlint-cli: trailing text is lazy continuation
1191        // Test with final newline
1192        let content_with_newline = "Text\n- Item 1\n- Item 2\nText\n";
1193        let fixed_with_newline = fix(content_with_newline);
1194        assert!(
1195            fixed_with_newline.ends_with('\n'),
1196            "Fix should preserve final newline when present"
1197        );
1198        // Only adds blank before (trailing text is lazy continuation)
1199        assert_eq!(fixed_with_newline, "Text\n\n- Item 1\n- Item 2\nText\n");
1200
1201        // Test without final newline
1202        let content_without_newline = "Text\n- Item 1\n- Item 2\nText";
1203        let fixed_without_newline = fix(content_without_newline);
1204        assert!(
1205            !fixed_without_newline.ends_with('\n'),
1206            "Fix should not add final newline when not present"
1207        );
1208        // Only adds blank before (trailing text is lazy continuation)
1209        assert_eq!(fixed_without_newline, "Text\n\n- Item 1\n- Item 2\nText");
1210    }
1211
1212    #[test]
1213    fn test_fix_multiline_list_items_no_indent() {
1214        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";
1215
1216        let warnings = lint(content);
1217        // Should only warn about missing blank lines around the entire list, not between items
1218        assert_eq!(
1219            warnings.len(),
1220            0,
1221            "Should not warn for properly formatted list with multi-line items. Got: {warnings:?}"
1222        );
1223
1224        let fixed_content = fix(content);
1225        // Should not change the content since it's already correct
1226        assert_eq!(
1227            fixed_content, content,
1228            "Should not modify correctly formatted multi-line list items"
1229        );
1230    }
1231
1232    #[test]
1233    fn test_nested_list_with_lazy_continuation() {
1234        // Issue #188: Nested list following a lazy continuation line should not require blank lines
1235        // This matches markdownlint-cli behavior which does NOT warn on this pattern
1236        //
1237        // The key element is line 6 (`!=`), ternary...) which is a lazy continuation of line 5.
1238        // Line 6 contains `||` inside code spans, which should NOT be detected as a table separator.
1239        let content = r#"# Test
1240
1241- **Token Dispatch (Phase 3.2)**: COMPLETE. Extracts tokens from both:
1242  1. Switch/case dispatcher statements (original Phase 3.2)
1243  2. Inline conditionals - if/else, bitwise checks (`&`, `|`), comparison (`==`,
1244`!=`), ternary operators (`?:`), macros (`ISTOK`, `ISUNSET`), compound conditions (`&&`, `||`) (Phase 3.2.1)
1245     - 30 explicit tokens extracted, 23 dispatcher rules with embedded token
1246       references"#;
1247
1248        let warnings = lint(content);
1249        // No MD032 warnings should be generated - this is a valid nested list structure
1250        // with lazy continuation (line 6 has no indent but continues line 5)
1251        let md032_warnings: Vec<_> = warnings
1252            .iter()
1253            .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1254            .collect();
1255        assert_eq!(
1256            md032_warnings.len(),
1257            0,
1258            "Should not warn for nested list with lazy continuation. Got: {md032_warnings:?}"
1259        );
1260    }
1261
1262    #[test]
1263    fn test_pipes_in_code_spans_not_detected_as_table() {
1264        // Pipes inside code spans should NOT break lists
1265        let content = r#"# Test
1266
1267- Item with `a | b` inline code
1268  - Nested item should work
1269
1270"#;
1271
1272        let warnings = lint(content);
1273        let md032_warnings: Vec<_> = warnings
1274            .iter()
1275            .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1276            .collect();
1277        assert_eq!(
1278            md032_warnings.len(),
1279            0,
1280            "Pipes in code spans should not break lists. Got: {md032_warnings:?}"
1281        );
1282    }
1283
1284    #[test]
1285    fn test_multiple_code_spans_with_pipes() {
1286        // Multiple code spans with pipes should not break lists
1287        let content = r#"# Test
1288
1289- Item with `a | b` and `c || d` operators
1290  - Nested item should work
1291
1292"#;
1293
1294        let warnings = lint(content);
1295        let md032_warnings: Vec<_> = warnings
1296            .iter()
1297            .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1298            .collect();
1299        assert_eq!(
1300            md032_warnings.len(),
1301            0,
1302            "Multiple code spans with pipes should not break lists. Got: {md032_warnings:?}"
1303        );
1304    }
1305
1306    #[test]
1307    fn test_actual_table_breaks_list() {
1308        // An actual table between list items SHOULD break the list
1309        let content = r#"# Test
1310
1311- Item before table
1312
1313| Col1 | Col2 |
1314|------|------|
1315| A    | B    |
1316
1317- Item after table
1318
1319"#;
1320
1321        let warnings = lint(content);
1322        // There should be NO MD032 warnings because both lists are properly surrounded by blank lines
1323        let md032_warnings: Vec<_> = warnings
1324            .iter()
1325            .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1326            .collect();
1327        assert_eq!(
1328            md032_warnings.len(),
1329            0,
1330            "Both lists should be properly separated by blank lines. Got: {md032_warnings:?}"
1331        );
1332    }
1333
1334    #[test]
1335    fn test_thematic_break_not_lazy_continuation() {
1336        // Thematic breaks (HRs) cannot be lazy continuation per CommonMark
1337        // List followed by HR without blank line should warn
1338        let content = r#"- Item 1
1339- Item 2
1340***
1341
1342More text.
1343"#;
1344
1345        let warnings = lint(content);
1346        let md032_warnings: Vec<_> = warnings
1347            .iter()
1348            .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1349            .collect();
1350        assert_eq!(
1351            md032_warnings.len(),
1352            1,
1353            "Should warn for list not followed by blank line before thematic break. Got: {md032_warnings:?}"
1354        );
1355        assert!(
1356            md032_warnings[0].message.contains("followed by blank line"),
1357            "Warning should be about missing blank after list"
1358        );
1359    }
1360
1361    #[test]
1362    fn test_thematic_break_with_blank_line() {
1363        // List followed by blank line then HR should NOT warn
1364        let content = r#"- Item 1
1365- Item 2
1366
1367***
1368
1369More text.
1370"#;
1371
1372        let warnings = lint(content);
1373        let md032_warnings: Vec<_> = warnings
1374            .iter()
1375            .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1376            .collect();
1377        assert_eq!(
1378            md032_warnings.len(),
1379            0,
1380            "Should not warn when list is properly followed by blank line. Got: {md032_warnings:?}"
1381        );
1382    }
1383
1384    #[test]
1385    fn test_various_thematic_break_styles() {
1386        // Test different HR styles are all recognized
1387        // Note: Spaced styles like "- - -" and "* * *" are excluded because they start
1388        // with list markers ("- " or "* ") which get parsed as list items by the
1389        // upstream CommonMark parser. That's a separate parsing issue.
1390        for hr in ["---", "***", "___"] {
1391            let content = format!(
1392                r#"- Item 1
1393- Item 2
1394{hr}
1395
1396More text.
1397"#
1398            );
1399
1400            let warnings = lint(&content);
1401            let md032_warnings: Vec<_> = warnings
1402                .iter()
1403                .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1404                .collect();
1405            assert_eq!(
1406                md032_warnings.len(),
1407                1,
1408                "Should warn for HR style '{hr}' without blank line. Got: {md032_warnings:?}"
1409            );
1410        }
1411    }
1412}