rumdl_lib/rules/
md032_blanks_around_lists.rs

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