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