rumdl_lib/rules/
md032_blanks_around_lists.rs

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