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