rumdl_lib/rules/
md032_blanks_around_lists.rs

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