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