rumdl_lib/rules/
md032_blanks_around_lists.rs

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