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