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