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        ctx.content.is_empty() || ctx.list_blocks.is_empty()
470    }
471
472    fn category(&self) -> RuleCategory {
473        RuleCategory::List
474    }
475
476    fn as_any(&self) -> &dyn std::any::Any {
477        self
478    }
479
480    fn default_config_section(&self) -> Option<(String, toml::Value)> {
481        let mut map = toml::map::Map::new();
482        map.insert(
483            "allow_after_headings".to_string(),
484            toml::Value::Boolean(self.allow_after_headings),
485        );
486        map.insert(
487            "allow_after_colons".to_string(),
488            toml::Value::Boolean(self.allow_after_colons),
489        );
490        Some((self.name().to_string(), toml::Value::Table(map)))
491    }
492
493    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
494    where
495        Self: Sized,
496    {
497        let allow_after_headings =
498            crate::config::get_rule_config_value::<bool>(config, "MD032", "allow_after_headings").unwrap_or(false); // Match markdownlint's stricter behavior
499
500        let allow_after_colons =
501            crate::config::get_rule_config_value::<bool>(config, "MD032", "allow_after_colons").unwrap_or(false); // Match markdownlint's stricter behavior
502
503        Box::new(MD032BlanksAroundLists {
504            allow_after_headings,
505            allow_after_colons,
506        })
507    }
508}
509
510impl MD032BlanksAroundLists {
511    /// Helper method for fixing implementation
512    fn fix_with_structure_impl(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
513        let lines: Vec<&str> = ctx.content.lines().collect();
514        let num_lines = lines.len();
515        if num_lines == 0 {
516            return Ok(String::new());
517        }
518
519        let list_blocks = self.convert_list_blocks(ctx);
520        if list_blocks.is_empty() {
521            return Ok(ctx.content.to_string());
522        }
523
524        let mut insertions: std::collections::BTreeMap<usize, String> = std::collections::BTreeMap::new();
525
526        // Phase 1: Identify needed insertions
527        for &(start_line, end_line, ref prefix) in &list_blocks {
528            // Check before block
529            if start_line > 1 {
530                let prev_line_actual_idx_0 = start_line - 2;
531                let prev_line_actual_idx_1 = start_line - 1;
532                let is_prev_excluded =
533                    ctx.is_in_code_block(prev_line_actual_idx_1) || ctx.is_in_front_matter(prev_line_actual_idx_1);
534                let prev_prefix = BLOCKQUOTE_PREFIX_RE
535                    .find(lines[prev_line_actual_idx_0])
536                    .map_or(String::new(), |m| m.as_str().to_string());
537
538                let should_require = self.should_require_blank_line_before(
539                    lines[prev_line_actual_idx_0],
540                    ctx,
541                    prev_line_actual_idx_1,
542                    start_line,
543                );
544                if !is_prev_excluded
545                    && !is_blank_in_context(lines[prev_line_actual_idx_0])
546                    && prev_prefix == *prefix
547                    && should_require
548                {
549                    insertions.insert(start_line, prefix.clone());
550                }
551            }
552
553            // Check after block
554            if end_line < num_lines {
555                let after_block_line_idx_0 = end_line;
556                let after_block_line_idx_1 = end_line + 1;
557                let line_after_block_content_str = lines[after_block_line_idx_0];
558                // Check if next line is excluded - in code block, front matter, or starts an indented code block
559                // Only exclude code fence lines if they're indented (part of list content)
560                let is_line_after_excluded = ctx.is_in_code_block(after_block_line_idx_1)
561                    || ctx.is_in_front_matter(after_block_line_idx_1)
562                    || (after_block_line_idx_0 < ctx.lines.len()
563                        && ctx.lines[after_block_line_idx_0].in_code_block
564                        && ctx.lines[after_block_line_idx_0].indent >= 2
565                        && (ctx.lines[after_block_line_idx_0].content.trim().starts_with("```")
566                            || ctx.lines[after_block_line_idx_0].content.trim().starts_with("~~~")));
567                let after_prefix = BLOCKQUOTE_PREFIX_RE
568                    .find(line_after_block_content_str)
569                    .map_or(String::new(), |m| m.as_str().to_string());
570
571                if !is_line_after_excluded
572                    && !is_blank_in_context(line_after_block_content_str)
573                    && after_prefix == *prefix
574                {
575                    insertions.insert(after_block_line_idx_1, prefix.clone());
576                }
577            }
578        }
579
580        // Phase 2: Reconstruct with insertions
581        let mut result_lines: Vec<String> = Vec::with_capacity(num_lines + insertions.len());
582        for (i, line) in lines.iter().enumerate() {
583            let current_line_num = i + 1;
584            if let Some(prefix_to_insert) = insertions.get(&current_line_num)
585                && (result_lines.is_empty() || result_lines.last().unwrap() != prefix_to_insert)
586            {
587                result_lines.push(prefix_to_insert.clone());
588            }
589            result_lines.push(line.to_string());
590        }
591
592        // Preserve the final newline if the original content had one
593        let mut result = result_lines.join("\n");
594        if ctx.content.ends_with('\n') {
595            result.push('\n');
596        }
597        Ok(result)
598    }
599}
600
601// Checks if a line is blank, considering blockquote context
602fn is_blank_in_context(line: &str) -> bool {
603    // A line is blank if it's empty or contains only whitespace,
604    // potentially after removing blockquote markers.
605    if let Some(m) = BLOCKQUOTE_PREFIX_RE.find(line) {
606        // If a blockquote prefix is found, check if the content *after* the prefix is blank.
607        line[m.end()..].trim().is_empty()
608    } else {
609        // No blockquote prefix, check the whole line for blankness.
610        line.trim().is_empty()
611    }
612}
613
614#[cfg(test)]
615mod tests {
616    use super::*;
617    use crate::lint_context::LintContext;
618    use crate::rule::Rule;
619
620    fn lint(content: &str) -> Vec<LintWarning> {
621        let rule = MD032BlanksAroundLists::default();
622        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
623        rule.check(&ctx).expect("Lint check failed")
624    }
625
626    fn fix(content: &str) -> String {
627        let rule = MD032BlanksAroundLists::default();
628        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
629        rule.fix(&ctx).expect("Lint fix failed")
630    }
631
632    // Test that warnings include Fix objects
633    fn check_warnings_have_fixes(content: &str) {
634        let warnings = lint(content);
635        for warning in &warnings {
636            assert!(warning.fix.is_some(), "Warning should have fix: {warning:?}");
637        }
638    }
639
640    #[test]
641    fn test_list_at_start() {
642        let content = "- Item 1\n- Item 2\nText";
643        let warnings = lint(content);
644        assert_eq!(
645            warnings.len(),
646            1,
647            "Expected 1 warning for list at start without trailing blank line"
648        );
649        assert_eq!(
650            warnings[0].line, 2,
651            "Warning should be on the last line of the list (line 2)"
652        );
653        assert!(warnings[0].message.contains("followed by blank line"));
654
655        // Test that warning has fix
656        check_warnings_have_fixes(content);
657
658        let fixed_content = fix(content);
659        assert_eq!(fixed_content, "- Item 1\n- Item 2\n\nText");
660
661        // Verify fix resolves the issue
662        let warnings_after_fix = lint(&fixed_content);
663        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
664    }
665
666    #[test]
667    fn test_list_at_end() {
668        let content = "Text\n- Item 1\n- Item 2";
669        let warnings = lint(content);
670        assert_eq!(
671            warnings.len(),
672            1,
673            "Expected 1 warning for list at end without preceding blank line"
674        );
675        assert_eq!(
676            warnings[0].line, 2,
677            "Warning should be on the first line of the list (line 2)"
678        );
679        assert!(warnings[0].message.contains("preceded by blank line"));
680
681        // Test that warning has fix
682        check_warnings_have_fixes(content);
683
684        let fixed_content = fix(content);
685        assert_eq!(fixed_content, "Text\n\n- Item 1\n- Item 2");
686
687        // Verify fix resolves the issue
688        let warnings_after_fix = lint(&fixed_content);
689        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
690    }
691
692    #[test]
693    fn test_list_in_middle() {
694        let content = "Text 1\n- Item 1\n- Item 2\nText 2";
695        let warnings = lint(content);
696        assert_eq!(
697            warnings.len(),
698            2,
699            "Expected 2 warnings for list in middle without surrounding blank lines"
700        );
701        assert_eq!(warnings[0].line, 2, "First warning on line 2 (start)");
702        assert!(warnings[0].message.contains("preceded by blank line"));
703        assert_eq!(warnings[1].line, 3, "Second warning on line 3 (end)");
704        assert!(warnings[1].message.contains("followed by blank line"));
705
706        // Test that warnings have fixes
707        check_warnings_have_fixes(content);
708
709        let fixed_content = fix(content);
710        assert_eq!(fixed_content, "Text 1\n\n- Item 1\n- Item 2\n\nText 2");
711
712        // Verify fix resolves the issue
713        let warnings_after_fix = lint(&fixed_content);
714        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
715    }
716
717    #[test]
718    fn test_correct_spacing() {
719        let content = "Text 1\n\n- Item 1\n- Item 2\n\nText 2";
720        let warnings = lint(content);
721        assert_eq!(warnings.len(), 0, "Expected no warnings for correctly spaced list");
722
723        let fixed_content = fix(content);
724        assert_eq!(fixed_content, content, "Fix should not change correctly spaced content");
725    }
726
727    #[test]
728    fn test_list_with_content() {
729        let content = "Text\n* Item 1\n  Content\n* Item 2\n  More content\nText";
730        let warnings = lint(content);
731        assert_eq!(
732            warnings.len(),
733            2,
734            "Expected 2 warnings for list block (lines 2-5) missing surrounding blanks. Got: {warnings:?}"
735        );
736        if warnings.len() == 2 {
737            assert_eq!(warnings[0].line, 2, "Warning 1 should be on line 2 (start)");
738            assert!(warnings[0].message.contains("preceded by blank line"));
739            assert_eq!(warnings[1].line, 5, "Warning 2 should be on line 5 (end)");
740            assert!(warnings[1].message.contains("followed by blank line"));
741        }
742
743        // Test that warnings have fixes
744        check_warnings_have_fixes(content);
745
746        let fixed_content = fix(content);
747        let expected_fixed = "Text\n\n* Item 1\n  Content\n* Item 2\n  More content\n\nText";
748        assert_eq!(
749            fixed_content, expected_fixed,
750            "Fix did not produce the expected output. Got:\n{fixed_content}"
751        );
752
753        // Verify fix resolves the issue
754        let warnings_after_fix = lint(&fixed_content);
755        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
756    }
757
758    #[test]
759    fn test_nested_list() {
760        let content = "Text\n- Item 1\n  - Nested 1\n- Item 2\nText";
761        let warnings = lint(content);
762        assert_eq!(warnings.len(), 2, "Nested list block warnings. Got: {warnings:?}"); // Needs blank before line 2, after line 4
763        if warnings.len() == 2 {
764            assert_eq!(warnings[0].line, 2);
765            assert_eq!(warnings[1].line, 4);
766        }
767
768        // Test that warnings have fixes
769        check_warnings_have_fixes(content);
770
771        let fixed_content = fix(content);
772        assert_eq!(fixed_content, "Text\n\n- Item 1\n  - Nested 1\n- Item 2\n\nText");
773
774        // Verify fix resolves the issue
775        let warnings_after_fix = lint(&fixed_content);
776        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
777    }
778
779    #[test]
780    fn test_list_with_internal_blanks() {
781        let content = "Text\n* Item 1\n\n  More Item 1 Content\n* Item 2\nText";
782        let warnings = lint(content);
783        assert_eq!(
784            warnings.len(),
785            2,
786            "List with internal blanks warnings. Got: {warnings:?}"
787        );
788        if warnings.len() == 2 {
789            assert_eq!(warnings[0].line, 2);
790            assert_eq!(warnings[1].line, 5); // End of block is line 5
791        }
792
793        // Test that warnings have fixes
794        check_warnings_have_fixes(content);
795
796        let fixed_content = fix(content);
797        assert_eq!(
798            fixed_content,
799            "Text\n\n* Item 1\n\n  More Item 1 Content\n* Item 2\n\nText"
800        );
801
802        // Verify fix resolves the issue
803        let warnings_after_fix = lint(&fixed_content);
804        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
805    }
806
807    #[test]
808    fn test_ignore_code_blocks() {
809        let content = "```\n- Not a list item\n```\nText";
810        let warnings = lint(content);
811        assert_eq!(warnings.len(), 0);
812        let fixed_content = fix(content);
813        assert_eq!(fixed_content, content);
814    }
815
816    #[test]
817    fn test_ignore_front_matter() {
818        let content = "---\ntitle: Test\n---\n- List Item\nText";
819        let warnings = lint(content);
820        assert_eq!(warnings.len(), 1, "Front matter test warnings. Got: {warnings:?}");
821        if !warnings.is_empty() {
822            assert_eq!(warnings[0].line, 4); // Warning on last line of list
823            assert!(warnings[0].message.contains("followed by blank line"));
824        }
825
826        // Test that warnings have fixes
827        check_warnings_have_fixes(content);
828
829        let fixed_content = fix(content);
830        assert_eq!(fixed_content, "---\ntitle: Test\n---\n- List Item\n\nText");
831
832        // Verify fix resolves the issue
833        let warnings_after_fix = lint(&fixed_content);
834        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
835    }
836
837    #[test]
838    fn test_multiple_lists() {
839        let content = "Text\n- List 1 Item 1\n- List 1 Item 2\nText 2\n* List 2 Item 1\nText 3";
840        let warnings = lint(content);
841        assert_eq!(warnings.len(), 4, "Multiple lists warnings. Got: {warnings:?}");
842
843        // Test that warnings have fixes
844        check_warnings_have_fixes(content);
845
846        let fixed_content = fix(content);
847        assert_eq!(
848            fixed_content,
849            "Text\n\n- List 1 Item 1\n- List 1 Item 2\n\nText 2\n\n* List 2 Item 1\n\nText 3"
850        );
851
852        // Verify fix resolves the issue
853        let warnings_after_fix = lint(&fixed_content);
854        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
855    }
856
857    #[test]
858    fn test_adjacent_lists() {
859        let content = "- List 1\n\n* List 2";
860        let warnings = lint(content);
861        assert_eq!(warnings.len(), 0);
862        let fixed_content = fix(content);
863        assert_eq!(fixed_content, content);
864    }
865
866    #[test]
867    fn test_list_in_blockquote() {
868        let content = "> Quote line 1\n> - List item 1\n> - List item 2\n> Quote line 2";
869        let warnings = lint(content);
870        assert_eq!(
871            warnings.len(),
872            2,
873            "Expected 2 warnings for blockquoted list. Got: {warnings:?}"
874        );
875        if warnings.len() == 2 {
876            assert_eq!(warnings[0].line, 2);
877            assert_eq!(warnings[1].line, 3);
878        }
879
880        // Test that warnings have fixes
881        check_warnings_have_fixes(content);
882
883        let fixed_content = fix(content);
884        // Check expected output preserves the space after >
885        assert_eq!(
886            fixed_content, "> Quote line 1\n> \n> - List item 1\n> - List item 2\n> \n> Quote line 2",
887            "Fix for blockquoted list failed. Got:\n{fixed_content}"
888        );
889
890        // Verify fix resolves the issue
891        let warnings_after_fix = lint(&fixed_content);
892        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
893    }
894
895    #[test]
896    fn test_ordered_list() {
897        let content = "Text\n1. Item 1\n2. Item 2\nText";
898        let warnings = lint(content);
899        assert_eq!(warnings.len(), 2);
900
901        // Test that warnings have fixes
902        check_warnings_have_fixes(content);
903
904        let fixed_content = fix(content);
905        assert_eq!(fixed_content, "Text\n\n1. Item 1\n2. Item 2\n\nText");
906
907        // Verify fix resolves the issue
908        let warnings_after_fix = lint(&fixed_content);
909        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
910    }
911
912    #[test]
913    fn test_no_double_blank_fix() {
914        let content = "Text\n\n- Item 1\n- Item 2\nText"; // Missing blank after
915        let warnings = lint(content);
916        assert_eq!(warnings.len(), 1);
917        if !warnings.is_empty() {
918            assert_eq!(
919                warnings[0].line, 4,
920                "Warning line for missing blank after should be the last line of the block"
921            );
922        }
923
924        // Test that warnings have fixes
925        check_warnings_have_fixes(content);
926
927        let fixed_content = fix(content);
928        assert_eq!(
929            fixed_content, "Text\n\n- Item 1\n- Item 2\n\nText",
930            "Fix added extra blank after. Got:\n{fixed_content}"
931        );
932
933        let content2 = "Text\n- Item 1\n- Item 2\n\nText"; // Missing blank before
934        let warnings2 = lint(content2);
935        assert_eq!(warnings2.len(), 1);
936        if !warnings2.is_empty() {
937            assert_eq!(
938                warnings2[0].line, 2,
939                "Warning line for missing blank before should be the first line of the block"
940            );
941        }
942
943        // Test that warnings have fixes
944        check_warnings_have_fixes(content2);
945
946        let fixed_content2 = fix(content2);
947        assert_eq!(
948            fixed_content2, "Text\n\n- Item 1\n- Item 2\n\nText",
949            "Fix added extra blank before. Got:\n{fixed_content2}"
950        );
951    }
952
953    #[test]
954    fn test_empty_input() {
955        let content = "";
956        let warnings = lint(content);
957        assert_eq!(warnings.len(), 0);
958        let fixed_content = fix(content);
959        assert_eq!(fixed_content, "");
960    }
961
962    #[test]
963    fn test_only_list() {
964        let content = "- Item 1\n- Item 2";
965        let warnings = lint(content);
966        assert_eq!(warnings.len(), 0);
967        let fixed_content = fix(content);
968        assert_eq!(fixed_content, content);
969    }
970
971    // === COMPREHENSIVE FIX TESTS ===
972
973    #[test]
974    fn test_fix_complex_nested_blockquote() {
975        let content = "> Text before\n> - Item 1\n>   - Nested item\n> - Item 2\n> Text after";
976        let warnings = lint(content);
977        // With stricter behavior matching markdownlint, we get 2 warnings:
978        // Line 2: list should be preceded by blank line
979        // Line 4: list should be followed by blank line
980        assert_eq!(
981            warnings.len(),
982            2,
983            "Should warn for missing blanks around the entire list block"
984        );
985
986        // Test that warnings have fixes
987        check_warnings_have_fixes(content);
988
989        let fixed_content = fix(content);
990        let expected = "> Text before\n> \n> - Item 1\n>   - Nested item\n> - Item 2\n> \n> Text after";
991        assert_eq!(fixed_content, expected, "Fix should preserve blockquote structure");
992
993        // With the stricter behavior, the fix now properly eliminates all warnings
994        let warnings_after_fix = lint(&fixed_content);
995        assert_eq!(warnings_after_fix.len(), 0, "Fix should eliminate all warnings");
996    }
997
998    #[test]
999    fn test_fix_mixed_list_markers() {
1000        let content = "Text\n- Item 1\n* Item 2\n+ Item 3\nText";
1001        let warnings = lint(content);
1002        assert_eq!(
1003            warnings.len(),
1004            2,
1005            "Should warn for missing blanks around mixed marker list"
1006        );
1007
1008        // Test that warnings have fixes
1009        check_warnings_have_fixes(content);
1010
1011        let fixed_content = fix(content);
1012        let expected = "Text\n\n- Item 1\n* Item 2\n+ Item 3\n\nText";
1013        assert_eq!(fixed_content, expected, "Fix should handle mixed list markers");
1014
1015        // Verify fix resolves the issue
1016        let warnings_after_fix = lint(&fixed_content);
1017        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1018    }
1019
1020    #[test]
1021    fn test_fix_ordered_list_with_different_numbers() {
1022        let content = "Text\n1. First\n3. Third\n2. Second\nText";
1023        let warnings = lint(content);
1024        assert_eq!(warnings.len(), 2, "Should warn for missing blanks around ordered list");
1025
1026        // Test that warnings have fixes
1027        check_warnings_have_fixes(content);
1028
1029        let fixed_content = fix(content);
1030        let expected = "Text\n\n1. First\n3. Third\n2. Second\n\nText";
1031        assert_eq!(
1032            fixed_content, expected,
1033            "Fix should handle ordered lists with non-sequential numbers"
1034        );
1035
1036        // Verify fix resolves the issue
1037        let warnings_after_fix = lint(&fixed_content);
1038        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1039    }
1040
1041    #[test]
1042    fn test_fix_list_with_code_blocks_inside() {
1043        let content = "Text\n- Item 1\n  ```\n  code\n  ```\n- Item 2\nText";
1044        let warnings = lint(content);
1045        // MD032 detects missing blanks around the list
1046        // Line 2: list should be preceded by blank line
1047        // Line 6: list should be followed by blank line
1048        assert_eq!(warnings.len(), 2, "Should warn for missing blanks around list");
1049
1050        // Test that warnings have fixes
1051        check_warnings_have_fixes(content);
1052
1053        let fixed_content = fix(content);
1054        let expected = "Text\n\n- Item 1\n  ```\n  code\n  ```\n- Item 2\n\nText";
1055        assert_eq!(
1056            fixed_content, expected,
1057            "Fix should handle lists with internal code blocks"
1058        );
1059
1060        // Verify fix resolves the issue
1061        let warnings_after_fix = lint(&fixed_content);
1062        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1063    }
1064
1065    #[test]
1066    fn test_fix_deeply_nested_lists() {
1067        let content = "Text\n- Level 1\n  - Level 2\n    - Level 3\n      - Level 4\n- Back to Level 1\nText";
1068        let warnings = lint(content);
1069        assert_eq!(
1070            warnings.len(),
1071            2,
1072            "Should warn for missing blanks around deeply nested list"
1073        );
1074
1075        // Test that warnings have fixes
1076        check_warnings_have_fixes(content);
1077
1078        let fixed_content = fix(content);
1079        let expected = "Text\n\n- Level 1\n  - Level 2\n    - Level 3\n      - Level 4\n- Back to Level 1\n\nText";
1080        assert_eq!(fixed_content, expected, "Fix should handle deeply nested lists");
1081
1082        // Verify fix resolves the issue
1083        let warnings_after_fix = lint(&fixed_content);
1084        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1085    }
1086
1087    #[test]
1088    fn test_fix_list_with_multiline_items() {
1089        let content = "Text\n- Item 1\n  continues here\n  and here\n- Item 2\n  also continues\nText";
1090        let warnings = lint(content);
1091        assert_eq!(
1092            warnings.len(),
1093            2,
1094            "Should warn for missing blanks around multiline list"
1095        );
1096
1097        // Test that warnings have fixes
1098        check_warnings_have_fixes(content);
1099
1100        let fixed_content = fix(content);
1101        let expected = "Text\n\n- Item 1\n  continues here\n  and here\n- Item 2\n  also continues\n\nText";
1102        assert_eq!(fixed_content, expected, "Fix should handle multiline list items");
1103
1104        // Verify fix resolves the issue
1105        let warnings_after_fix = lint(&fixed_content);
1106        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1107    }
1108
1109    #[test]
1110    fn test_fix_list_at_document_boundaries() {
1111        // List at very start
1112        let content1 = "- Item 1\n- Item 2";
1113        let warnings1 = lint(content1);
1114        assert_eq!(
1115            warnings1.len(),
1116            0,
1117            "List at document start should not need blank before"
1118        );
1119        let fixed1 = fix(content1);
1120        assert_eq!(fixed1, content1, "No fix needed for list at start");
1121
1122        // List at very end
1123        let content2 = "Text\n- Item 1\n- Item 2";
1124        let warnings2 = lint(content2);
1125        assert_eq!(warnings2.len(), 1, "List at document end should need blank before");
1126        check_warnings_have_fixes(content2);
1127        let fixed2 = fix(content2);
1128        assert_eq!(
1129            fixed2, "Text\n\n- Item 1\n- Item 2",
1130            "Should add blank before list at end"
1131        );
1132    }
1133
1134    #[test]
1135    fn test_fix_preserves_existing_blank_lines() {
1136        let content = "Text\n\n\n- Item 1\n- Item 2\n\n\nText";
1137        let warnings = lint(content);
1138        assert_eq!(warnings.len(), 0, "Multiple blank lines should be preserved");
1139        let fixed_content = fix(content);
1140        assert_eq!(fixed_content, content, "Fix should not modify already correct content");
1141    }
1142
1143    #[test]
1144    fn test_fix_handles_tabs_and_spaces() {
1145        let content = "Text\n\t- Item with tab\n  - Item with spaces\nText";
1146        let warnings = lint(content);
1147        assert_eq!(warnings.len(), 2, "Should warn regardless of indentation type");
1148
1149        // Test that warnings have fixes
1150        check_warnings_have_fixes(content);
1151
1152        let fixed_content = fix(content);
1153        let expected = "Text\n\n\t- Item with tab\n  - Item with spaces\n\nText";
1154        assert_eq!(fixed_content, expected, "Fix should preserve original indentation");
1155
1156        // Verify fix resolves the issue
1157        let warnings_after_fix = lint(&fixed_content);
1158        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1159    }
1160
1161    #[test]
1162    fn test_fix_warning_objects_have_correct_ranges() {
1163        let content = "Text\n- Item 1\n- Item 2\nText";
1164        let warnings = lint(content);
1165        assert_eq!(warnings.len(), 2);
1166
1167        // Check that each warning has a fix with a valid range
1168        for warning in &warnings {
1169            assert!(warning.fix.is_some(), "Warning should have fix");
1170            let fix = warning.fix.as_ref().unwrap();
1171            assert!(fix.range.start <= fix.range.end, "Fix range should be valid");
1172            assert!(
1173                !fix.replacement.is_empty() || fix.range.start == fix.range.end,
1174                "Fix should have replacement or be insertion"
1175            );
1176        }
1177    }
1178
1179    #[test]
1180    fn test_fix_idempotent() {
1181        let content = "Text\n- Item 1\n- Item 2\nText";
1182
1183        // Apply fix once
1184        let fixed_once = fix(content);
1185        assert_eq!(fixed_once, "Text\n\n- Item 1\n- Item 2\n\nText");
1186
1187        // Apply fix again - should be unchanged
1188        let fixed_twice = fix(&fixed_once);
1189        assert_eq!(fixed_twice, fixed_once, "Fix should be idempotent");
1190
1191        // No warnings after fix
1192        let warnings_after_fix = lint(&fixed_once);
1193        assert_eq!(warnings_after_fix.len(), 0, "No warnings should remain after fix");
1194    }
1195
1196    #[test]
1197    fn test_fix_with_windows_line_endings() {
1198        let content = "Text\r\n- Item 1\r\n- Item 2\r\nText";
1199        let warnings = lint(content);
1200        assert_eq!(warnings.len(), 2, "Should detect issues with Windows line endings");
1201
1202        // Test that warnings have fixes
1203        check_warnings_have_fixes(content);
1204
1205        let fixed_content = fix(content);
1206        // Note: Our fix uses \n, which is standard for Rust string processing
1207        let expected = "Text\n\n- Item 1\n- Item 2\n\nText";
1208        assert_eq!(fixed_content, expected, "Fix should handle Windows line endings");
1209    }
1210
1211    #[test]
1212    fn test_fix_preserves_final_newline() {
1213        // Test with final newline
1214        let content_with_newline = "Text\n- Item 1\n- Item 2\nText\n";
1215        let fixed_with_newline = fix(content_with_newline);
1216        assert!(
1217            fixed_with_newline.ends_with('\n'),
1218            "Fix should preserve final newline when present"
1219        );
1220        assert_eq!(fixed_with_newline, "Text\n\n- Item 1\n- Item 2\n\nText\n");
1221
1222        // Test without final newline
1223        let content_without_newline = "Text\n- Item 1\n- Item 2\nText";
1224        let fixed_without_newline = fix(content_without_newline);
1225        assert!(
1226            !fixed_without_newline.ends_with('\n'),
1227            "Fix should not add final newline when not present"
1228        );
1229        assert_eq!(fixed_without_newline, "Text\n\n- Item 1\n- Item 2\n\nText");
1230    }
1231
1232    #[test]
1233    fn test_fix_multiline_list_items_no_indent() {
1234        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";
1235
1236        let warnings = lint(content);
1237        // Should only warn about missing blank lines around the entire list, not between items
1238        assert_eq!(
1239            warnings.len(),
1240            0,
1241            "Should not warn for properly formatted list with multi-line items. Got: {warnings:?}"
1242        );
1243
1244        let fixed_content = fix(content);
1245        // Should not change the content since it's already correct
1246        assert_eq!(
1247            fixed_content, content,
1248            "Should not modify correctly formatted multi-line list items"
1249        );
1250    }
1251}