rumdl_lib/rules/
md032_blanks_around_lists.rs

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