rumdl_lib/rules/
md032_blanks_around_lists.rs

1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2use crate::utils::element_cache::ElementCache;
3use crate::utils::range_utils::{LineIndex, calculate_line_range};
4use crate::utils::regex_cache::BLOCKQUOTE_PREFIX_RE;
5use regex::Regex;
6use std::sync::LazyLock;
7
8mod md032_config;
9pub use md032_config::MD032Config;
10
11// Detects ordered list items starting with a number other than 1
12static ORDERED_LIST_NON_ONE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*([2-9]|\d{2,})\.\s").unwrap());
13
14/// Check if a line is a thematic break (horizontal rule)
15/// Per CommonMark: 0-3 spaces of indentation, then 3+ of same char (-, *, _), optionally with spaces between
16fn is_thematic_break(line: &str) -> bool {
17    // Per CommonMark, thematic breaks can have 0-3 spaces of indentation (< 4 columns)
18    if ElementCache::calculate_indentation_width_default(line) > 3 {
19        return false;
20    }
21
22    let trimmed = line.trim();
23    if trimmed.len() < 3 {
24        return false;
25    }
26
27    let chars: Vec<char> = trimmed.chars().collect();
28    let first_non_space = chars.iter().find(|&&c| c != ' ');
29
30    if let Some(&marker) = first_non_space {
31        if marker != '-' && marker != '*' && marker != '_' {
32            return false;
33        }
34        let marker_count = chars.iter().filter(|&&c| c == marker).count();
35        let other_count = chars.iter().filter(|&&c| c != marker && c != ' ').count();
36        marker_count >= 3 && other_count == 0
37    } else {
38        false
39    }
40}
41
42/// Rule MD032: Lists should be surrounded by blank lines
43///
44/// This rule enforces that lists are surrounded by blank lines, which improves document
45/// readability and ensures consistent rendering across different Markdown processors.
46///
47/// ## Purpose
48///
49/// - **Readability**: Blank lines create visual separation between lists and surrounding content
50/// - **Parsing**: Many Markdown parsers require blank lines around lists for proper rendering
51/// - **Consistency**: Ensures uniform document structure and appearance
52/// - **Compatibility**: Improves compatibility across different Markdown implementations
53///
54/// ## Examples
55///
56/// ### Correct
57///
58/// ```markdown
59/// This is a paragraph of text.
60///
61/// - Item 1
62/// - Item 2
63/// - Item 3
64///
65/// This is another paragraph.
66/// ```
67///
68/// ### Incorrect
69///
70/// ```markdown
71/// This is a paragraph of text.
72/// - Item 1
73/// - Item 2
74/// - Item 3
75/// This is another paragraph.
76/// ```
77///
78/// ## Behavior Details
79///
80/// This rule checks for the following:
81///
82/// - **List Start**: There should be a blank line before the first item in a list
83///   (unless the list is at the beginning of the document or after front matter)
84/// - **List End**: There should be a blank line after the last item in a list
85///   (unless the list is at the end of the document)
86/// - **Nested Lists**: Properly handles nested lists and list continuations
87/// - **List Types**: Works with ordered lists, unordered lists, and all valid list markers (-, *, +)
88///
89/// ## Special Cases
90///
91/// This rule handles several special cases:
92///
93/// - **Front Matter**: YAML front matter is detected and skipped
94/// - **Code Blocks**: Lists inside code blocks are ignored
95/// - **List Content**: Indented content belonging to list items is properly recognized as part of the list
96/// - **Document Boundaries**: Lists at the beginning or end of the document have adjusted requirements
97///
98/// ## Fix Behavior
99///
100/// When applying automatic fixes, this rule:
101/// - Adds a blank line before the first list item when needed
102/// - Adds a blank line after the last list item when needed
103/// - Preserves document structure and existing content
104///
105/// ## Performance Optimizations
106///
107/// The rule includes several optimizations:
108/// - Fast path checks before applying more expensive regex operations
109/// - Efficient list item detection
110/// - Pre-computation of code block lines to avoid redundant processing
111#[derive(Debug, Clone, Default)]
112pub struct MD032BlanksAroundLists {
113    config: MD032Config,
114}
115
116impl MD032BlanksAroundLists {
117    pub fn from_config_struct(config: MD032Config) -> Self {
118        Self { config }
119    }
120}
121
122impl MD032BlanksAroundLists {
123    /// Check if a blank line should be required before a list based on the previous line context
124    fn should_require_blank_line_before(
125        ctx: &crate::lint_context::LintContext,
126        prev_line_num: usize,
127        current_line_num: usize,
128    ) -> bool {
129        // Always require blank lines after code blocks, front matter, etc.
130        if ctx
131            .line_info(prev_line_num)
132            .is_some_and(|info| info.in_code_block || info.in_front_matter)
133        {
134            return true;
135        }
136
137        // Always allow nested lists (lists indented within other list items)
138        if Self::is_nested_list(ctx, prev_line_num, current_line_num) {
139            return false;
140        }
141
142        // Default: require blank line (matching markdownlint's behavior)
143        true
144    }
145
146    /// Check if the current list is nested within another list item
147    fn is_nested_list(
148        ctx: &crate::lint_context::LintContext,
149        prev_line_num: usize,    // 1-indexed
150        current_line_num: usize, // 1-indexed
151    ) -> bool {
152        // Check if current line is indented (typical for nested lists)
153        if current_line_num > 0 && current_line_num - 1 < ctx.lines.len() {
154            let current_line = &ctx.lines[current_line_num - 1];
155            if current_line.indent >= 2 {
156                // Check if previous line is a list item or list content
157                if prev_line_num > 0 && prev_line_num - 1 < ctx.lines.len() {
158                    let prev_line = &ctx.lines[prev_line_num - 1];
159                    // Previous line is a list item or indented content
160                    if prev_line.list_item.is_some() || prev_line.indent >= 2 {
161                        return true;
162                    }
163                }
164            }
165        }
166        false
167    }
168
169    // Convert centralized list blocks to the format expected by perform_checks
170    fn convert_list_blocks(&self, ctx: &crate::lint_context::LintContext) -> Vec<(usize, usize, String)> {
171        let mut blocks: Vec<(usize, usize, String)> = Vec::new();
172
173        for block in &ctx.list_blocks {
174            // For MD032, we need to check if there are code blocks that should
175            // split the list into separate segments
176
177            // Simple approach: if there's a fenced code block between list items,
178            // split at that point
179            let mut segments: Vec<(usize, usize)> = Vec::new();
180            let mut current_start = block.start_line;
181            let mut prev_item_line = 0;
182
183            for &item_line in &block.item_lines {
184                if prev_item_line > 0 {
185                    // Check if there's a standalone code fence between prev_item_line and item_line
186                    // A code fence that's indented as part of a list item should NOT split the list
187                    let mut has_standalone_code_fence = false;
188
189                    // Calculate minimum indentation for list item content
190                    let min_indent_for_content = if block.is_ordered {
191                        // For ordered lists, content should be indented at least to align with text after marker
192                        // e.g., "1. " = 3 chars, so content should be indented 3+ spaces
193                        3 // Minimum for "1. "
194                    } else {
195                        // For unordered lists, content should be indented at least 2 spaces
196                        2 // For "- " or "* "
197                    };
198
199                    for check_line in (prev_item_line + 1)..item_line {
200                        if check_line - 1 < ctx.lines.len() {
201                            let line = &ctx.lines[check_line - 1];
202                            let line_content = line.content(ctx.content);
203                            if line.in_code_block
204                                && (line_content.trim().starts_with("```") || line_content.trim().starts_with("~~~"))
205                            {
206                                // Check if this code fence is indented as part of the list item
207                                // If it's indented enough to be part of the list item, it shouldn't split
208                                if line.indent < min_indent_for_content {
209                                    has_standalone_code_fence = true;
210                                    break;
211                                }
212                            }
213                        }
214                    }
215
216                    if has_standalone_code_fence {
217                        // End current segment before this item
218                        segments.push((current_start, prev_item_line));
219                        current_start = item_line;
220                    }
221                }
222                prev_item_line = item_line;
223            }
224
225            // Add the final segment
226            // For the last segment, end at the last list item (not the full block end)
227            if prev_item_line > 0 {
228                segments.push((current_start, prev_item_line));
229            }
230
231            // Check if this list block was split by code fences
232            let has_code_fence_splits = segments.len() > 1 && {
233                // Check if any segments were created due to code fences
234                let mut found_fence = false;
235                for i in 0..segments.len() - 1 {
236                    let seg_end = segments[i].1;
237                    let next_start = segments[i + 1].0;
238                    // Check if there's a code fence between these segments
239                    for check_line in (seg_end + 1)..next_start {
240                        if check_line - 1 < ctx.lines.len() {
241                            let line = &ctx.lines[check_line - 1];
242                            let line_content = line.content(ctx.content);
243                            if line.in_code_block
244                                && (line_content.trim().starts_with("```") || line_content.trim().starts_with("~~~"))
245                            {
246                                found_fence = true;
247                                break;
248                            }
249                        }
250                    }
251                    if found_fence {
252                        break;
253                    }
254                }
255                found_fence
256            };
257
258            // Convert segments to blocks
259            for (start, end) in segments.iter() {
260                // Extend the end to include any continuation lines immediately after the last item
261                let mut actual_end = *end;
262
263                // If this list was split by code fences, don't extend any segments
264                // They should remain as individual list items for MD032 purposes
265                if !has_code_fence_splits && *end < block.end_line {
266                    for check_line in (*end + 1)..=block.end_line {
267                        if check_line - 1 < ctx.lines.len() {
268                            let line = &ctx.lines[check_line - 1];
269                            let line_content = line.content(ctx.content);
270                            // Stop at next list item or non-continuation content
271                            if block.item_lines.contains(&check_line) || line.heading.is_some() {
272                                break;
273                            }
274                            // Don't extend through code blocks
275                            if line.in_code_block {
276                                break;
277                            }
278                            // Include indented continuation
279                            if line.indent >= 2 {
280                                actual_end = check_line;
281                            }
282                            // Include lazy continuation lines (multiple consecutive lines without indent)
283                            // Per CommonMark, only paragraph text can be lazy continuation
284                            // Thematic breaks, code fences, etc. cannot be lazy continuations
285                            // Only include lazy continuation if allowed by config
286                            else if self.config.allow_lazy_continuation
287                                && !line.is_blank
288                                && line.heading.is_none()
289                                && !block.item_lines.contains(&check_line)
290                                && !is_thematic_break(line_content)
291                            {
292                                // This is a lazy continuation line - check if we're still in the same paragraph
293                                // Allow multiple consecutive lazy continuation lines
294                                actual_end = check_line;
295                            } else if !line.is_blank {
296                                // Non-blank line that's not a continuation - stop here
297                                break;
298                            }
299                        }
300                    }
301                }
302
303                blocks.push((*start, actual_end, block.blockquote_prefix.clone()));
304            }
305        }
306
307        blocks
308    }
309
310    fn perform_checks(
311        &self,
312        ctx: &crate::lint_context::LintContext,
313        lines: &[&str],
314        list_blocks: &[(usize, usize, String)],
315        line_index: &LineIndex,
316    ) -> LintResult {
317        let mut warnings = Vec::new();
318        let num_lines = lines.len();
319
320        // Check for ordered lists starting with non-1 that aren't recognized as lists
321        // These need blank lines before them to be parsed as lists by CommonMark
322        for (line_idx, line) in lines.iter().enumerate() {
323            let line_num = line_idx + 1;
324
325            // Skip if this line is already part of a recognized list
326            let is_in_list = list_blocks
327                .iter()
328                .any(|(start, end, _)| line_num >= *start && line_num <= *end);
329            if is_in_list {
330                continue;
331            }
332
333            // Skip if in code block or front matter
334            if ctx
335                .line_info(line_num)
336                .is_some_and(|info| info.in_code_block || info.in_front_matter)
337            {
338                continue;
339            }
340
341            // Check if this line starts with a number other than 1
342            if ORDERED_LIST_NON_ONE_RE.is_match(line) {
343                // Check if there's a blank line before this
344                if line_idx > 0 {
345                    let prev_line = lines[line_idx - 1];
346                    let prev_is_blank = is_blank_in_context(prev_line);
347                    let prev_excluded = ctx
348                        .line_info(line_idx)
349                        .is_some_and(|info| info.in_code_block || info.in_front_matter);
350
351                    if !prev_is_blank && !prev_excluded {
352                        // This ordered list item starting with non-1 needs a blank line before it
353                        let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line);
354
355                        warnings.push(LintWarning {
356                            line: start_line,
357                            column: start_col,
358                            end_line,
359                            end_column: end_col,
360                            severity: Severity::Warning,
361                            rule_name: Some(self.name().to_string()),
362                            message: "Ordered list starting with non-1 should be preceded by blank line".to_string(),
363                            fix: Some(Fix {
364                                range: line_index.line_col_to_byte_range_with_length(line_num, 1, 0),
365                                replacement: "\n".to_string(),
366                            }),
367                        });
368                    }
369                }
370            }
371        }
372
373        for &(start_line, end_line, ref prefix) in list_blocks {
374            if start_line > 1 {
375                let prev_line_actual_idx_0 = start_line - 2;
376                let prev_line_actual_idx_1 = start_line - 1;
377                let prev_line_str = lines[prev_line_actual_idx_0];
378                let is_prev_excluded = ctx
379                    .line_info(prev_line_actual_idx_1)
380                    .is_some_and(|info| info.in_code_block || info.in_front_matter);
381                let prev_prefix = BLOCKQUOTE_PREFIX_RE
382                    .find(prev_line_str)
383                    .map_or(String::new(), |m| m.as_str().to_string());
384                let prev_is_blank = is_blank_in_context(prev_line_str);
385                let prefixes_match = prev_prefix.trim() == prefix.trim();
386
387                // Only require blank lines for content in the same context (same blockquote level)
388                // and when the context actually requires it
389                let should_require = Self::should_require_blank_line_before(ctx, prev_line_actual_idx_1, start_line);
390                if !is_prev_excluded && !prev_is_blank && prefixes_match && should_require {
391                    // Calculate precise character range for the entire list line that needs a blank line before it
392                    let (start_line, start_col, end_line, end_col) =
393                        calculate_line_range(start_line, lines[start_line - 1]);
394
395                    warnings.push(LintWarning {
396                        line: start_line,
397                        column: start_col,
398                        end_line,
399                        end_column: end_col,
400                        severity: Severity::Warning,
401                        rule_name: Some(self.name().to_string()),
402                        message: "List should be preceded by blank line".to_string(),
403                        fix: Some(Fix {
404                            range: line_index.line_col_to_byte_range_with_length(start_line, 1, 0),
405                            replacement: format!("{prefix}\n"),
406                        }),
407                    });
408                }
409            }
410
411            if end_line < num_lines {
412                let next_line_idx_0 = end_line;
413                let next_line_idx_1 = end_line + 1;
414                let next_line_str = lines[next_line_idx_0];
415                // Check if next line is excluded - front matter or indented code blocks within lists
416                // We want blank lines before standalone code blocks, but not within list items
417                let is_next_excluded = ctx.line_info(next_line_idx_1).is_some_and(|info| info.in_front_matter)
418                    || (next_line_idx_0 < ctx.lines.len()
419                        && ctx.lines[next_line_idx_0].in_code_block
420                        && ctx.lines[next_line_idx_0].indent >= 2);
421                let next_prefix = BLOCKQUOTE_PREFIX_RE
422                    .find(next_line_str)
423                    .map_or(String::new(), |m| m.as_str().to_string());
424                let next_is_blank = is_blank_in_context(next_line_str);
425                let prefixes_match = next_prefix.trim() == prefix.trim();
426
427                // Only require blank lines for content in the same context (same blockquote level)
428                if !is_next_excluded && !next_is_blank && prefixes_match {
429                    // Calculate precise character range for the last line of the list (not the line after)
430                    let (start_line_last, start_col_last, end_line_last, end_col_last) =
431                        calculate_line_range(end_line, lines[end_line - 1]);
432
433                    warnings.push(LintWarning {
434                        line: start_line_last,
435                        column: start_col_last,
436                        end_line: end_line_last,
437                        end_column: end_col_last,
438                        severity: Severity::Warning,
439                        rule_name: Some(self.name().to_string()),
440                        message: "List should be followed by blank line".to_string(),
441                        fix: Some(Fix {
442                            range: line_index.line_col_to_byte_range_with_length(end_line + 1, 1, 0),
443                            replacement: format!("{prefix}\n"),
444                        }),
445                    });
446                }
447            }
448        }
449        Ok(warnings)
450    }
451}
452
453impl Rule for MD032BlanksAroundLists {
454    fn name(&self) -> &'static str {
455        "MD032"
456    }
457
458    fn description(&self) -> &'static str {
459        "Lists should be surrounded by blank lines"
460    }
461
462    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
463        let content = ctx.content;
464        let lines: Vec<&str> = content.lines().collect();
465        let line_index = &ctx.line_index;
466
467        // Early return for empty content
468        if lines.is_empty() {
469            return Ok(Vec::new());
470        }
471
472        let list_blocks = self.convert_list_blocks(ctx);
473
474        if list_blocks.is_empty() {
475            return Ok(Vec::new());
476        }
477
478        self.perform_checks(ctx, &lines, &list_blocks, line_index)
479    }
480
481    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
482        self.fix_with_structure_impl(ctx)
483    }
484
485    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
486        // Fast path: check if document likely has lists
487        if ctx.content.is_empty() || !ctx.likely_has_lists() {
488            return true;
489        }
490        // Verify list blocks actually exist
491        ctx.list_blocks.is_empty()
492    }
493
494    fn category(&self) -> RuleCategory {
495        RuleCategory::List
496    }
497
498    fn as_any(&self) -> &dyn std::any::Any {
499        self
500    }
501
502    fn default_config_section(&self) -> Option<(String, toml::Value)> {
503        use crate::rule_config_serde::RuleConfig;
504        let default_config = MD032Config::default();
505        let json_value = serde_json::to_value(&default_config).ok()?;
506        let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
507
508        if let toml::Value::Table(table) = toml_value {
509            if !table.is_empty() {
510                Some((MD032Config::RULE_NAME.to_string(), toml::Value::Table(table)))
511            } else {
512                None
513            }
514        } else {
515            None
516        }
517    }
518
519    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
520    where
521        Self: Sized,
522    {
523        let rule_config = crate::rule_config_serde::load_rule_config::<MD032Config>(config);
524        Box::new(MD032BlanksAroundLists::from_config_struct(rule_config))
525    }
526}
527
528impl MD032BlanksAroundLists {
529    /// Helper method for fixing implementation
530    fn fix_with_structure_impl(&self, ctx: &crate::lint_context::LintContext) -> 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 = ctx
551                    .line_info(prev_line_actual_idx_1)
552                    .is_some_and(|info| info.in_code_block || info.in_front_matter);
553                let prev_prefix = BLOCKQUOTE_PREFIX_RE
554                    .find(lines[prev_line_actual_idx_0])
555                    .map_or(String::new(), |m| m.as_str().to_string());
556
557                let should_require = Self::should_require_blank_line_before(ctx, prev_line_actual_idx_1, start_line);
558                if !is_prev_excluded
559                    && !is_blank_in_context(lines[prev_line_actual_idx_0])
560                    && prev_prefix == *prefix
561                    && should_require
562                {
563                    insertions.insert(start_line, prefix.clone());
564                }
565            }
566
567            // Check after block
568            if end_line < num_lines {
569                let after_block_line_idx_0 = end_line;
570                let after_block_line_idx_1 = end_line + 1;
571                let line_after_block_content_str = lines[after_block_line_idx_0];
572                // Check if next line is excluded - in code block, front matter, or starts an indented code block
573                // Only exclude code fence lines if they're indented (part of list content)
574                let is_line_after_excluded = ctx
575                    .line_info(after_block_line_idx_1)
576                    .is_some_and(|info| info.in_code_block || info.in_front_matter)
577                    || (after_block_line_idx_0 < ctx.lines.len()
578                        && ctx.lines[after_block_line_idx_0].in_code_block
579                        && ctx.lines[after_block_line_idx_0].indent >= 2
580                        && (ctx.lines[after_block_line_idx_0]
581                            .content(ctx.content)
582                            .trim()
583                            .starts_with("```")
584                            || ctx.lines[after_block_line_idx_0]
585                                .content(ctx.content)
586                                .trim()
587                                .starts_with("~~~")));
588                let after_prefix = BLOCKQUOTE_PREFIX_RE
589                    .find(line_after_block_content_str)
590                    .map_or(String::new(), |m| m.as_str().to_string());
591
592                if !is_line_after_excluded
593                    && !is_blank_in_context(line_after_block_content_str)
594                    && after_prefix == *prefix
595                {
596                    insertions.insert(after_block_line_idx_1, prefix.clone());
597                }
598            }
599        }
600
601        // Phase 2: Reconstruct with insertions
602        let mut result_lines: Vec<String> = Vec::with_capacity(num_lines + insertions.len());
603        for (i, line) in lines.iter().enumerate() {
604            let current_line_num = i + 1;
605            if let Some(prefix_to_insert) = insertions.get(&current_line_num)
606                && (result_lines.is_empty() || result_lines.last().unwrap() != prefix_to_insert)
607            {
608                result_lines.push(prefix_to_insert.clone());
609            }
610            result_lines.push(line.to_string());
611        }
612
613        // Preserve the final newline if the original content had one
614        let mut result = result_lines.join("\n");
615        if ctx.content.ends_with('\n') {
616            result.push('\n');
617        }
618        Ok(result)
619    }
620}
621
622// Checks if a line is blank, considering blockquote context
623fn is_blank_in_context(line: &str) -> bool {
624    // A line is blank if it's empty or contains only whitespace,
625    // potentially after removing blockquote markers.
626    if let Some(m) = BLOCKQUOTE_PREFIX_RE.find(line) {
627        // If a blockquote prefix is found, check if the content *after* the prefix is blank.
628        line[m.end()..].trim().is_empty()
629    } else {
630        // No blockquote prefix, check the whole line for blankness.
631        line.trim().is_empty()
632    }
633}
634
635#[cfg(test)]
636mod tests {
637    use super::*;
638    use crate::lint_context::LintContext;
639    use crate::rule::Rule;
640
641    fn lint(content: &str) -> Vec<LintWarning> {
642        let rule = MD032BlanksAroundLists::default();
643        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
644        rule.check(&ctx).expect("Lint check failed")
645    }
646
647    fn fix(content: &str) -> String {
648        let rule = MD032BlanksAroundLists::default();
649        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
650        rule.fix(&ctx).expect("Lint fix failed")
651    }
652
653    // Test that warnings include Fix objects
654    fn check_warnings_have_fixes(content: &str) {
655        let warnings = lint(content);
656        for warning in &warnings {
657            assert!(warning.fix.is_some(), "Warning should have fix: {warning:?}");
658        }
659    }
660
661    #[test]
662    fn test_list_at_start() {
663        // Per markdownlint-cli: trailing text without blank line is treated as lazy continuation
664        // so NO warning is expected here
665        let content = "- Item 1\n- Item 2\nText";
666        let warnings = lint(content);
667        assert_eq!(
668            warnings.len(),
669            0,
670            "Trailing text is lazy continuation per CommonMark - no warning expected"
671        );
672    }
673
674    #[test]
675    fn test_list_at_end() {
676        let content = "Text\n- Item 1\n- Item 2";
677        let warnings = lint(content);
678        assert_eq!(
679            warnings.len(),
680            1,
681            "Expected 1 warning for list at end without preceding blank line"
682        );
683        assert_eq!(
684            warnings[0].line, 2,
685            "Warning should be on the first line of the list (line 2)"
686        );
687        assert!(warnings[0].message.contains("preceded by blank line"));
688
689        // Test that warning has fix
690        check_warnings_have_fixes(content);
691
692        let fixed_content = fix(content);
693        assert_eq!(fixed_content, "Text\n\n- Item 1\n- Item 2");
694
695        // Verify fix resolves the issue
696        let warnings_after_fix = lint(&fixed_content);
697        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
698    }
699
700    #[test]
701    fn test_list_in_middle() {
702        // Per markdownlint-cli: only preceding blank line is required
703        // Trailing text is treated as lazy continuation
704        let content = "Text 1\n- Item 1\n- Item 2\nText 2";
705        let warnings = lint(content);
706        assert_eq!(
707            warnings.len(),
708            1,
709            "Expected 1 warning for list needing preceding blank line (trailing text is lazy continuation)"
710        );
711        assert_eq!(warnings[0].line, 2, "Warning on line 2 (start)");
712        assert!(warnings[0].message.contains("preceded by blank line"));
713
714        // Test that warnings have fixes
715        check_warnings_have_fixes(content);
716
717        let fixed_content = fix(content);
718        assert_eq!(fixed_content, "Text 1\n\n- Item 1\n- Item 2\nText 2");
719
720        // Verify fix resolves the issue
721        let warnings_after_fix = lint(&fixed_content);
722        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
723    }
724
725    #[test]
726    fn test_correct_spacing() {
727        let content = "Text 1\n\n- Item 1\n- Item 2\n\nText 2";
728        let warnings = lint(content);
729        assert_eq!(warnings.len(), 0, "Expected no warnings for correctly spaced list");
730
731        let fixed_content = fix(content);
732        assert_eq!(fixed_content, content, "Fix should not change correctly spaced content");
733    }
734
735    #[test]
736    fn test_list_with_content() {
737        // Per markdownlint-cli: only preceding blank line warning
738        // Trailing text is lazy continuation
739        let content = "Text\n* Item 1\n  Content\n* Item 2\n  More content\nText";
740        let warnings = lint(content);
741        assert_eq!(
742            warnings.len(),
743            1,
744            "Expected 1 warning for list needing preceding blank line. Got: {warnings:?}"
745        );
746        assert_eq!(warnings[0].line, 2, "Warning should be on line 2 (start)");
747        assert!(warnings[0].message.contains("preceded by blank line"));
748
749        // Test that warnings have fixes
750        check_warnings_have_fixes(content);
751
752        let fixed_content = fix(content);
753        let expected_fixed = "Text\n\n* Item 1\n  Content\n* Item 2\n  More content\nText";
754        assert_eq!(
755            fixed_content, expected_fixed,
756            "Fix did not produce the expected output. Got:\n{fixed_content}"
757        );
758
759        // Verify fix resolves the issue
760        let warnings_after_fix = lint(&fixed_content);
761        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
762    }
763
764    #[test]
765    fn test_nested_list() {
766        // Per markdownlint-cli: only preceding blank line warning
767        let content = "Text\n- Item 1\n  - Nested 1\n- Item 2\nText";
768        let warnings = lint(content);
769        assert_eq!(
770            warnings.len(),
771            1,
772            "Nested list block needs preceding blank only. Got: {warnings:?}"
773        );
774        assert_eq!(warnings[0].line, 2);
775        assert!(warnings[0].message.contains("preceded by blank line"));
776
777        // Test that warnings have fixes
778        check_warnings_have_fixes(content);
779
780        let fixed_content = fix(content);
781        assert_eq!(fixed_content, "Text\n\n- Item 1\n  - Nested 1\n- Item 2\nText");
782
783        // Verify fix resolves the issue
784        let warnings_after_fix = lint(&fixed_content);
785        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
786    }
787
788    #[test]
789    fn test_list_with_internal_blanks() {
790        // Per markdownlint-cli: only preceding blank line warning
791        let content = "Text\n* Item 1\n\n  More Item 1 Content\n* Item 2\nText";
792        let warnings = lint(content);
793        assert_eq!(
794            warnings.len(),
795            1,
796            "List with internal blanks needs preceding blank only. Got: {warnings:?}"
797        );
798        assert_eq!(warnings[0].line, 2);
799        assert!(warnings[0].message.contains("preceded by blank line"));
800
801        // Test that warnings have fixes
802        check_warnings_have_fixes(content);
803
804        let fixed_content = fix(content);
805        assert_eq!(
806            fixed_content,
807            "Text\n\n* Item 1\n\n  More Item 1 Content\n* Item 2\nText"
808        );
809
810        // Verify fix resolves the issue
811        let warnings_after_fix = lint(&fixed_content);
812        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
813    }
814
815    #[test]
816    fn test_ignore_code_blocks() {
817        let content = "```\n- Not a list item\n```\nText";
818        let warnings = lint(content);
819        assert_eq!(warnings.len(), 0);
820        let fixed_content = fix(content);
821        assert_eq!(fixed_content, content);
822    }
823
824    #[test]
825    fn test_ignore_front_matter() {
826        // Per markdownlint-cli: NO warnings - front matter is followed by list, trailing text is lazy continuation
827        let content = "---\ntitle: Test\n---\n- List Item\nText";
828        let warnings = lint(content);
829        assert_eq!(
830            warnings.len(),
831            0,
832            "Front matter test should have no MD032 warnings. Got: {warnings:?}"
833        );
834
835        // No fixes needed since no warnings
836        let fixed_content = fix(content);
837        assert_eq!(fixed_content, content, "No changes when no warnings");
838    }
839
840    #[test]
841    fn test_multiple_lists() {
842        // Our implementation treats "Text 2" and "Text 3" as lazy continuation within a single merged list block
843        // (since both - and * are unordered markers and there's no structural separator)
844        // markdownlint-cli sees them as separate lists with 3 warnings, but our behavior differs.
845        // The key requirement is that the fix resolves all warnings.
846        let content = "Text\n- List 1 Item 1\n- List 1 Item 2\nText 2\n* List 2 Item 1\nText 3";
847        let warnings = lint(content);
848        // At minimum we should warn about missing preceding blank for line 2
849        assert!(
850            !warnings.is_empty(),
851            "Should have at least one warning for missing blank line. Got: {warnings:?}"
852        );
853
854        // Test that warnings have fixes
855        check_warnings_have_fixes(content);
856
857        let fixed_content = fix(content);
858        // The fix should add blank lines before lists that need them
859        let warnings_after_fix = lint(&fixed_content);
860        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
861    }
862
863    #[test]
864    fn test_adjacent_lists() {
865        let content = "- List 1\n\n* List 2";
866        let warnings = lint(content);
867        assert_eq!(warnings.len(), 0);
868        let fixed_content = fix(content);
869        assert_eq!(fixed_content, content);
870    }
871
872    #[test]
873    fn test_list_in_blockquote() {
874        // Per markdownlint-cli: 1 warning (preceding only, trailing is lazy continuation)
875        let content = "> Quote line 1\n> - List item 1\n> - List item 2\n> Quote line 2";
876        let warnings = lint(content);
877        assert_eq!(
878            warnings.len(),
879            1,
880            "Expected 1 warning for blockquoted list needing preceding blank. Got: {warnings:?}"
881        );
882        assert_eq!(warnings[0].line, 2);
883
884        // Test that warnings have fixes
885        check_warnings_have_fixes(content);
886
887        let fixed_content = fix(content);
888        // Fix should add blank line before list only
889        assert_eq!(
890            fixed_content, "> Quote line 1\n> \n> - List item 1\n> - List item 2\n> Quote line 2",
891            "Fix for blockquoted list failed. Got:\n{fixed_content}"
892        );
893
894        // Verify fix resolves the issue
895        let warnings_after_fix = lint(&fixed_content);
896        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
897    }
898
899    #[test]
900    fn test_ordered_list() {
901        // Per markdownlint-cli: 1 warning (preceding only)
902        let content = "Text\n1. Item 1\n2. Item 2\nText";
903        let warnings = lint(content);
904        assert_eq!(warnings.len(), 1);
905
906        // Test that warnings have fixes
907        check_warnings_have_fixes(content);
908
909        let fixed_content = fix(content);
910        assert_eq!(fixed_content, "Text\n\n1. Item 1\n2. Item 2\nText");
911
912        // Verify fix resolves the issue
913        let warnings_after_fix = lint(&fixed_content);
914        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
915    }
916
917    #[test]
918    fn test_no_double_blank_fix() {
919        // Per markdownlint-cli: trailing text is lazy continuation, so NO warning needed
920        let content = "Text\n\n- Item 1\n- Item 2\nText"; // Has preceding blank, trailing is lazy
921        let warnings = lint(content);
922        assert_eq!(
923            warnings.len(),
924            0,
925            "Should have no warnings - properly preceded, trailing is lazy"
926        );
927
928        let fixed_content = fix(content);
929        assert_eq!(
930            fixed_content, content,
931            "No fix needed when no warnings. Got:\n{fixed_content}"
932        );
933
934        let content2 = "Text\n- Item 1\n- Item 2\n\nText"; // Missing blank before
935        let warnings2 = lint(content2);
936        assert_eq!(warnings2.len(), 1);
937        if !warnings2.is_empty() {
938            assert_eq!(
939                warnings2[0].line, 2,
940                "Warning line for missing blank before should be the first line of the block"
941            );
942        }
943
944        // Test that warnings have fixes
945        check_warnings_have_fixes(content2);
946
947        let fixed_content2 = fix(content2);
948        assert_eq!(
949            fixed_content2, "Text\n\n- Item 1\n- Item 2\n\nText",
950            "Fix added extra blank before. Got:\n{fixed_content2}"
951        );
952    }
953
954    #[test]
955    fn test_empty_input() {
956        let content = "";
957        let warnings = lint(content);
958        assert_eq!(warnings.len(), 0);
959        let fixed_content = fix(content);
960        assert_eq!(fixed_content, "");
961    }
962
963    #[test]
964    fn test_only_list() {
965        let content = "- Item 1\n- Item 2";
966        let warnings = lint(content);
967        assert_eq!(warnings.len(), 0);
968        let fixed_content = fix(content);
969        assert_eq!(fixed_content, content);
970    }
971
972    // === COMPREHENSIVE FIX TESTS ===
973
974    #[test]
975    fn test_fix_complex_nested_blockquote() {
976        // Per markdownlint-cli: 1 warning (preceding only)
977        let content = "> Text before\n> - Item 1\n>   - Nested item\n> - Item 2\n> Text after";
978        let warnings = lint(content);
979        assert_eq!(
980            warnings.len(),
981            1,
982            "Should warn for missing preceding blank only. Got: {warnings:?}"
983        );
984
985        // Test that warnings have fixes
986        check_warnings_have_fixes(content);
987
988        let fixed_content = fix(content);
989        let expected = "> Text before\n> \n> - Item 1\n>   - Nested item\n> - Item 2\n> Text after";
990        assert_eq!(fixed_content, expected, "Fix should preserve blockquote structure");
991
992        let warnings_after_fix = lint(&fixed_content);
993        assert_eq!(warnings_after_fix.len(), 0, "Fix should eliminate all warnings");
994    }
995
996    #[test]
997    fn test_fix_mixed_list_markers() {
998        // Per markdownlint-cli: mixed markers may be treated as separate lists
999        // The exact behavior depends on implementation details
1000        let content = "Text\n- Item 1\n* Item 2\n+ Item 3\nText";
1001        let warnings = lint(content);
1002        // At minimum, there should be a warning for the first list needing preceding blank
1003        assert!(
1004            !warnings.is_empty(),
1005            "Should have at least 1 warning for mixed marker list. Got: {warnings:?}"
1006        );
1007
1008        // Test that warnings have fixes
1009        check_warnings_have_fixes(content);
1010
1011        let fixed_content = fix(content);
1012        // The fix should add at least a blank line before the first list
1013        assert!(
1014            fixed_content.contains("Text\n\n-"),
1015            "Fix should add blank line before first list item"
1016        );
1017
1018        // Verify fix resolves the issue
1019        let warnings_after_fix = lint(&fixed_content);
1020        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1021    }
1022
1023    #[test]
1024    fn test_fix_ordered_list_with_different_numbers() {
1025        // Per markdownlint-cli: 1 warning (preceding only)
1026        let content = "Text\n1. First\n3. Third\n2. Second\nText";
1027        let warnings = lint(content);
1028        assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1029
1030        // Test that warnings have fixes
1031        check_warnings_have_fixes(content);
1032
1033        let fixed_content = fix(content);
1034        let expected = "Text\n\n1. First\n3. Third\n2. Second\nText";
1035        assert_eq!(
1036            fixed_content, expected,
1037            "Fix should handle ordered lists with non-sequential numbers"
1038        );
1039
1040        // Verify fix resolves the issue
1041        let warnings_after_fix = lint(&fixed_content);
1042        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1043    }
1044
1045    #[test]
1046    fn test_fix_list_with_code_blocks_inside() {
1047        // Per markdownlint-cli: 1 warning (preceding only)
1048        let content = "Text\n- Item 1\n  ```\n  code\n  ```\n- Item 2\nText";
1049        let warnings = lint(content);
1050        assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1051
1052        // Test that warnings have fixes
1053        check_warnings_have_fixes(content);
1054
1055        let fixed_content = fix(content);
1056        let expected = "Text\n\n- Item 1\n  ```\n  code\n  ```\n- Item 2\nText";
1057        assert_eq!(
1058            fixed_content, expected,
1059            "Fix should handle lists with internal code blocks"
1060        );
1061
1062        // Verify fix resolves the issue
1063        let warnings_after_fix = lint(&fixed_content);
1064        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1065    }
1066
1067    #[test]
1068    fn test_fix_deeply_nested_lists() {
1069        // Per markdownlint-cli: 1 warning (preceding only)
1070        let content = "Text\n- Level 1\n  - Level 2\n    - Level 3\n      - Level 4\n- Back to Level 1\nText";
1071        let warnings = lint(content);
1072        assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1073
1074        // Test that warnings have fixes
1075        check_warnings_have_fixes(content);
1076
1077        let fixed_content = fix(content);
1078        let expected = "Text\n\n- Level 1\n  - Level 2\n    - Level 3\n      - Level 4\n- Back to Level 1\nText";
1079        assert_eq!(fixed_content, expected, "Fix should handle deeply nested lists");
1080
1081        // Verify fix resolves the issue
1082        let warnings_after_fix = lint(&fixed_content);
1083        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1084    }
1085
1086    #[test]
1087    fn test_fix_list_with_multiline_items() {
1088        // Per markdownlint-cli: trailing "Text" at indent=0 is lazy continuation
1089        // Only the preceding blank line is required
1090        let content = "Text\n- Item 1\n  continues here\n  and here\n- Item 2\n  also continues\nText";
1091        let warnings = lint(content);
1092        assert_eq!(
1093            warnings.len(),
1094            1,
1095            "Should only warn for missing blank before list (trailing text is lazy continuation)"
1096        );
1097
1098        // Test that warnings have fixes
1099        check_warnings_have_fixes(content);
1100
1101        let fixed_content = fix(content);
1102        let expected = "Text\n\n- Item 1\n  continues here\n  and here\n- Item 2\n  also continues\nText";
1103        assert_eq!(fixed_content, expected, "Fix should add blank before list only");
1104
1105        // Verify fix resolves the issue
1106        let warnings_after_fix = lint(&fixed_content);
1107        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1108    }
1109
1110    #[test]
1111    fn test_fix_list_at_document_boundaries() {
1112        // List at very start
1113        let content1 = "- Item 1\n- Item 2";
1114        let warnings1 = lint(content1);
1115        assert_eq!(
1116            warnings1.len(),
1117            0,
1118            "List at document start should not need blank before"
1119        );
1120        let fixed1 = fix(content1);
1121        assert_eq!(fixed1, content1, "No fix needed for list at start");
1122
1123        // List at very end
1124        let content2 = "Text\n- Item 1\n- Item 2";
1125        let warnings2 = lint(content2);
1126        assert_eq!(warnings2.len(), 1, "List at document end should need blank before");
1127        check_warnings_have_fixes(content2);
1128        let fixed2 = fix(content2);
1129        assert_eq!(
1130            fixed2, "Text\n\n- Item 1\n- Item 2",
1131            "Should add blank before list at end"
1132        );
1133    }
1134
1135    #[test]
1136    fn test_fix_preserves_existing_blank_lines() {
1137        let content = "Text\n\n\n- Item 1\n- Item 2\n\n\nText";
1138        let warnings = lint(content);
1139        assert_eq!(warnings.len(), 0, "Multiple blank lines should be preserved");
1140        let fixed_content = fix(content);
1141        assert_eq!(fixed_content, content, "Fix should not modify already correct content");
1142    }
1143
1144    #[test]
1145    fn test_fix_handles_tabs_and_spaces() {
1146        // Tab at line start = 4 spaces = indented code (not a list item per CommonMark)
1147        // Only the space-indented line is a real list item
1148        let content = "Text\n\t- Item with tab\n  - Item with spaces\nText";
1149        let warnings = lint(content);
1150        // Per markdownlint-cli: only line 3 (space-indented) is a list needing blanks
1151        assert!(!warnings.is_empty(), "Should warn for missing blank before list");
1152
1153        // Test that warnings have fixes
1154        check_warnings_have_fixes(content);
1155
1156        let fixed_content = fix(content);
1157        // Add blank before the actual list item (line 3), not the tab-indented code (line 2)
1158        // Trailing text is lazy continuation, so no blank after
1159        let expected = "Text\n\t- Item with tab\n\n  - Item with spaces\nText";
1160        assert_eq!(fixed_content, expected, "Fix should add blank before list item");
1161
1162        // Verify fix resolves the issue
1163        let warnings_after_fix = lint(&fixed_content);
1164        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1165    }
1166
1167    #[test]
1168    fn test_fix_warning_objects_have_correct_ranges() {
1169        // Per markdownlint-cli: trailing text is lazy continuation, only 1 warning
1170        let content = "Text\n- Item 1\n- Item 2\nText";
1171        let warnings = lint(content);
1172        assert_eq!(warnings.len(), 1, "Only preceding blank warning expected");
1173
1174        // Check that each warning has a fix with a valid range
1175        for warning in &warnings {
1176            assert!(warning.fix.is_some(), "Warning should have fix");
1177            let fix = warning.fix.as_ref().unwrap();
1178            assert!(fix.range.start <= fix.range.end, "Fix range should be valid");
1179            assert!(
1180                !fix.replacement.is_empty() || fix.range.start == fix.range.end,
1181                "Fix should have replacement or be insertion"
1182            );
1183        }
1184    }
1185
1186    #[test]
1187    fn test_fix_idempotent() {
1188        // Per markdownlint-cli: trailing text is lazy continuation
1189        let content = "Text\n- Item 1\n- Item 2\nText";
1190
1191        // Apply fix once - only adds blank before (trailing text is lazy continuation)
1192        let fixed_once = fix(content);
1193        assert_eq!(fixed_once, "Text\n\n- Item 1\n- Item 2\nText");
1194
1195        // Apply fix again - should be unchanged
1196        let fixed_twice = fix(&fixed_once);
1197        assert_eq!(fixed_twice, fixed_once, "Fix should be idempotent");
1198
1199        // No warnings after fix
1200        let warnings_after_fix = lint(&fixed_once);
1201        assert_eq!(warnings_after_fix.len(), 0, "No warnings should remain after fix");
1202    }
1203
1204    #[test]
1205    fn test_fix_with_normalized_line_endings() {
1206        // In production, content is normalized to LF at I/O boundary
1207        // Unit tests should use LF input to reflect actual runtime behavior
1208        // Per markdownlint-cli: trailing text is lazy continuation, only 1 warning
1209        let content = "Text\n- Item 1\n- Item 2\nText";
1210        let warnings = lint(content);
1211        assert_eq!(warnings.len(), 1, "Should detect missing blank before list");
1212
1213        // Test that warnings have fixes
1214        check_warnings_have_fixes(content);
1215
1216        let fixed_content = fix(content);
1217        // Only adds blank before (trailing text is lazy continuation)
1218        let expected = "Text\n\n- Item 1\n- Item 2\nText";
1219        assert_eq!(fixed_content, expected, "Fix should work with normalized LF content");
1220    }
1221
1222    #[test]
1223    fn test_fix_preserves_final_newline() {
1224        // Per markdownlint-cli: trailing text is lazy continuation
1225        // Test with final newline
1226        let content_with_newline = "Text\n- Item 1\n- Item 2\nText\n";
1227        let fixed_with_newline = fix(content_with_newline);
1228        assert!(
1229            fixed_with_newline.ends_with('\n'),
1230            "Fix should preserve final newline when present"
1231        );
1232        // Only adds blank before (trailing text is lazy continuation)
1233        assert_eq!(fixed_with_newline, "Text\n\n- Item 1\n- Item 2\nText\n");
1234
1235        // Test without final newline
1236        let content_without_newline = "Text\n- Item 1\n- Item 2\nText";
1237        let fixed_without_newline = fix(content_without_newline);
1238        assert!(
1239            !fixed_without_newline.ends_with('\n'),
1240            "Fix should not add final newline when not present"
1241        );
1242        // Only adds blank before (trailing text is lazy continuation)
1243        assert_eq!(fixed_without_newline, "Text\n\n- Item 1\n- Item 2\nText");
1244    }
1245
1246    #[test]
1247    fn test_fix_multiline_list_items_no_indent() {
1248        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";
1249
1250        let warnings = lint(content);
1251        // Should only warn about missing blank lines around the entire list, not between items
1252        assert_eq!(
1253            warnings.len(),
1254            0,
1255            "Should not warn for properly formatted list with multi-line items. Got: {warnings:?}"
1256        );
1257
1258        let fixed_content = fix(content);
1259        // Should not change the content since it's already correct
1260        assert_eq!(
1261            fixed_content, content,
1262            "Should not modify correctly formatted multi-line list items"
1263        );
1264    }
1265
1266    #[test]
1267    fn test_nested_list_with_lazy_continuation() {
1268        // Issue #188: Nested list following a lazy continuation line should not require blank lines
1269        // This matches markdownlint-cli behavior which does NOT warn on this pattern
1270        //
1271        // The key element is line 6 (`!=`), ternary...) which is a lazy continuation of line 5.
1272        // Line 6 contains `||` inside code spans, which should NOT be detected as a table separator.
1273        let content = r#"# Test
1274
1275- **Token Dispatch (Phase 3.2)**: COMPLETE. Extracts tokens from both:
1276  1. Switch/case dispatcher statements (original Phase 3.2)
1277  2. Inline conditionals - if/else, bitwise checks (`&`, `|`), comparison (`==`,
1278`!=`), ternary operators (`?:`), macros (`ISTOK`, `ISUNSET`), compound conditions (`&&`, `||`) (Phase 3.2.1)
1279     - 30 explicit tokens extracted, 23 dispatcher rules with embedded token
1280       references"#;
1281
1282        let warnings = lint(content);
1283        // No MD032 warnings should be generated - this is a valid nested list structure
1284        // with lazy continuation (line 6 has no indent but continues line 5)
1285        let md032_warnings: Vec<_> = warnings
1286            .iter()
1287            .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1288            .collect();
1289        assert_eq!(
1290            md032_warnings.len(),
1291            0,
1292            "Should not warn for nested list with lazy continuation. Got: {md032_warnings:?}"
1293        );
1294    }
1295
1296    #[test]
1297    fn test_pipes_in_code_spans_not_detected_as_table() {
1298        // Pipes inside code spans should NOT break lists
1299        let content = r#"# Test
1300
1301- Item with `a | b` inline code
1302  - Nested item should work
1303
1304"#;
1305
1306        let warnings = lint(content);
1307        let md032_warnings: Vec<_> = warnings
1308            .iter()
1309            .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1310            .collect();
1311        assert_eq!(
1312            md032_warnings.len(),
1313            0,
1314            "Pipes in code spans should not break lists. Got: {md032_warnings:?}"
1315        );
1316    }
1317
1318    #[test]
1319    fn test_multiple_code_spans_with_pipes() {
1320        // Multiple code spans with pipes should not break lists
1321        let content = r#"# Test
1322
1323- Item with `a | b` and `c || d` operators
1324  - Nested item should work
1325
1326"#;
1327
1328        let warnings = lint(content);
1329        let md032_warnings: Vec<_> = warnings
1330            .iter()
1331            .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1332            .collect();
1333        assert_eq!(
1334            md032_warnings.len(),
1335            0,
1336            "Multiple code spans with pipes should not break lists. Got: {md032_warnings:?}"
1337        );
1338    }
1339
1340    #[test]
1341    fn test_actual_table_breaks_list() {
1342        // An actual table between list items SHOULD break the list
1343        let content = r#"# Test
1344
1345- Item before table
1346
1347| Col1 | Col2 |
1348|------|------|
1349| A    | B    |
1350
1351- Item after table
1352
1353"#;
1354
1355        let warnings = lint(content);
1356        // There should be NO MD032 warnings because both lists are properly surrounded by blank lines
1357        let md032_warnings: Vec<_> = warnings
1358            .iter()
1359            .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1360            .collect();
1361        assert_eq!(
1362            md032_warnings.len(),
1363            0,
1364            "Both lists should be properly separated by blank lines. Got: {md032_warnings:?}"
1365        );
1366    }
1367
1368    #[test]
1369    fn test_thematic_break_not_lazy_continuation() {
1370        // Thematic breaks (HRs) cannot be lazy continuation per CommonMark
1371        // List followed by HR without blank line should warn
1372        let content = r#"- Item 1
1373- Item 2
1374***
1375
1376More text.
1377"#;
1378
1379        let warnings = lint(content);
1380        let md032_warnings: Vec<_> = warnings
1381            .iter()
1382            .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1383            .collect();
1384        assert_eq!(
1385            md032_warnings.len(),
1386            1,
1387            "Should warn for list not followed by blank line before thematic break. Got: {md032_warnings:?}"
1388        );
1389        assert!(
1390            md032_warnings[0].message.contains("followed by blank line"),
1391            "Warning should be about missing blank after list"
1392        );
1393    }
1394
1395    #[test]
1396    fn test_thematic_break_with_blank_line() {
1397        // List followed by blank line then HR should NOT warn
1398        let content = r#"- Item 1
1399- Item 2
1400
1401***
1402
1403More text.
1404"#;
1405
1406        let warnings = lint(content);
1407        let md032_warnings: Vec<_> = warnings
1408            .iter()
1409            .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1410            .collect();
1411        assert_eq!(
1412            md032_warnings.len(),
1413            0,
1414            "Should not warn when list is properly followed by blank line. Got: {md032_warnings:?}"
1415        );
1416    }
1417
1418    #[test]
1419    fn test_various_thematic_break_styles() {
1420        // Test different HR styles are all recognized
1421        // Note: Spaced styles like "- - -" and "* * *" are excluded because they start
1422        // with list markers ("- " or "* ") which get parsed as list items by the
1423        // upstream CommonMark parser. That's a separate parsing issue.
1424        for hr in ["---", "***", "___"] {
1425            let content = format!(
1426                r#"- Item 1
1427- Item 2
1428{hr}
1429
1430More text.
1431"#
1432            );
1433
1434            let warnings = lint(&content);
1435            let md032_warnings: Vec<_> = warnings
1436                .iter()
1437                .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1438                .collect();
1439            assert_eq!(
1440                md032_warnings.len(),
1441                1,
1442                "Should warn for HR style '{hr}' without blank line. Got: {md032_warnings:?}"
1443            );
1444        }
1445    }
1446
1447    // === LAZY CONTINUATION TESTS ===
1448
1449    fn lint_with_config(content: &str, config: MD032Config) -> Vec<LintWarning> {
1450        let rule = MD032BlanksAroundLists::from_config_struct(config);
1451        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1452        rule.check(&ctx).expect("Lint check failed")
1453    }
1454
1455    fn fix_with_config(content: &str, config: MD032Config) -> String {
1456        let rule = MD032BlanksAroundLists::from_config_struct(config);
1457        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1458        rule.fix(&ctx).expect("Lint fix failed")
1459    }
1460
1461    #[test]
1462    fn test_lazy_continuation_allowed_by_default() {
1463        // Default behavior: lazy continuation is allowed, no warning
1464        let content = "# Heading\n\n1. List\nSome text.";
1465        let warnings = lint(content);
1466        assert_eq!(
1467            warnings.len(),
1468            0,
1469            "Default behavior should allow lazy continuation. Got: {warnings:?}"
1470        );
1471    }
1472
1473    #[test]
1474    fn test_lazy_continuation_disallowed() {
1475        // With allow_lazy_continuation = false, should warn
1476        let content = "# Heading\n\n1. List\nSome text.";
1477        let config = MD032Config {
1478            allow_lazy_continuation: false,
1479        };
1480        let warnings = lint_with_config(content, config);
1481        assert_eq!(
1482            warnings.len(),
1483            1,
1484            "Should warn when lazy continuation is disallowed. Got: {warnings:?}"
1485        );
1486        assert!(
1487            warnings[0].message.contains("followed by blank line"),
1488            "Warning message should mention blank line"
1489        );
1490    }
1491
1492    #[test]
1493    fn test_lazy_continuation_fix() {
1494        // With allow_lazy_continuation = false, fix should insert blank line
1495        let content = "# Heading\n\n1. List\nSome text.";
1496        let config = MD032Config {
1497            allow_lazy_continuation: false,
1498        };
1499        let fixed = fix_with_config(content, config.clone());
1500        assert_eq!(
1501            fixed, "# Heading\n\n1. List\n\nSome text.",
1502            "Fix should insert blank line before lazy continuation"
1503        );
1504
1505        // Verify no warnings after fix
1506        let warnings_after = lint_with_config(&fixed, config);
1507        assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
1508    }
1509
1510    #[test]
1511    fn test_lazy_continuation_multiple_lines() {
1512        // Multiple lazy continuation lines
1513        let content = "- Item 1\nLine 2\nLine 3";
1514        let config = MD032Config {
1515            allow_lazy_continuation: false,
1516        };
1517        let warnings = lint_with_config(content, config.clone());
1518        assert_eq!(
1519            warnings.len(),
1520            1,
1521            "Should warn for lazy continuation. Got: {warnings:?}"
1522        );
1523
1524        let fixed = fix_with_config(content, config.clone());
1525        assert_eq!(
1526            fixed, "- Item 1\n\nLine 2\nLine 3",
1527            "Fix should insert blank line after list"
1528        );
1529
1530        // Verify no warnings after fix
1531        let warnings_after = lint_with_config(&fixed, config);
1532        assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
1533    }
1534
1535    #[test]
1536    fn test_lazy_continuation_with_indented_content() {
1537        // Indented content is valid continuation, not lazy continuation
1538        let content = "- Item 1\n  Indented content\nLazy text";
1539        let config = MD032Config {
1540            allow_lazy_continuation: false,
1541        };
1542        let warnings = lint_with_config(content, config);
1543        assert_eq!(
1544            warnings.len(),
1545            1,
1546            "Should warn for lazy text after indented content. Got: {warnings:?}"
1547        );
1548    }
1549
1550    #[test]
1551    fn test_lazy_continuation_properly_separated() {
1552        // With proper blank line, no warning even with strict config
1553        let content = "- Item 1\n\nSome text.";
1554        let config = MD032Config {
1555            allow_lazy_continuation: false,
1556        };
1557        let warnings = lint_with_config(content, config);
1558        assert_eq!(
1559            warnings.len(),
1560            0,
1561            "Should not warn when list is properly followed by blank line. Got: {warnings:?}"
1562        );
1563    }
1564
1565    // ==================== Expert-level edge case tests ====================
1566
1567    #[test]
1568    fn test_lazy_continuation_ordered_list_parenthesis_marker() {
1569        // Ordered list with parenthesis marker (1) instead of period
1570        let content = "1) First item\nLazy continuation";
1571        let config = MD032Config {
1572            allow_lazy_continuation: false,
1573        };
1574        let warnings = lint_with_config(content, config.clone());
1575        assert_eq!(
1576            warnings.len(),
1577            1,
1578            "Should warn for lazy continuation with parenthesis marker"
1579        );
1580
1581        let fixed = fix_with_config(content, config);
1582        assert_eq!(fixed, "1) First item\n\nLazy continuation");
1583    }
1584
1585    #[test]
1586    fn test_lazy_continuation_followed_by_another_list() {
1587        // Lazy continuation text followed by another list item
1588        // In CommonMark, "Some text" becomes part of Item 1's lazy continuation,
1589        // and "- Item 2" starts a new list item within the same list.
1590        // This is valid list structure, not a lazy continuation warning case.
1591        let content = "- Item 1\nSome text\n- Item 2";
1592        let config = MD032Config {
1593            allow_lazy_continuation: false,
1594        };
1595        let warnings = lint_with_config(content, config);
1596        // No MD032 warning because this is valid list structure
1597        // (all content is within the list block)
1598        assert_eq!(
1599            warnings.len(),
1600            0,
1601            "Valid list structure should not trigger lazy continuation warning"
1602        );
1603    }
1604
1605    #[test]
1606    fn test_lazy_continuation_multiple_in_document() {
1607        // Multiple lists with lazy continuation at end
1608        // First list: "- Item 1\nLazy 1" - lazy continuation is part of list
1609        // Blank line separates the lists
1610        // Second list: "- Item 2\nLazy 2" - lazy continuation followed by EOF
1611        // Only the second list triggers a warning (list not followed by blank)
1612        let content = "- Item 1\nLazy 1\n\n- Item 2\nLazy 2";
1613        let config = MD032Config {
1614            allow_lazy_continuation: false,
1615        };
1616        let warnings = lint_with_config(content, config.clone());
1617        assert_eq!(
1618            warnings.len(),
1619            1,
1620            "Should warn for second list (not followed by blank). Got: {warnings:?}"
1621        );
1622
1623        let fixed = fix_with_config(content, config.clone());
1624        let warnings_after = lint_with_config(&fixed, config);
1625        assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
1626    }
1627
1628    #[test]
1629    fn test_lazy_continuation_end_of_document_no_newline() {
1630        // Lazy continuation at end of document without trailing newline
1631        let content = "- Item\nNo trailing newline";
1632        let config = MD032Config {
1633            allow_lazy_continuation: false,
1634        };
1635        let warnings = lint_with_config(content, config.clone());
1636        assert_eq!(warnings.len(), 1, "Should warn even at end of document");
1637
1638        let fixed = fix_with_config(content, config);
1639        assert_eq!(fixed, "- Item\n\nNo trailing newline");
1640    }
1641
1642    #[test]
1643    fn test_lazy_continuation_thematic_break_still_needs_blank() {
1644        // Thematic break after list without blank line still triggers MD032
1645        // The thematic break ends the list, but MD032 requires blank line separation
1646        let content = "- Item 1\n---";
1647        let config = MD032Config {
1648            allow_lazy_continuation: false,
1649        };
1650        let warnings = lint_with_config(content, config.clone());
1651        // Should warn because list needs blank line before thematic break
1652        assert_eq!(
1653            warnings.len(),
1654            1,
1655            "List should need blank line before thematic break. Got: {warnings:?}"
1656        );
1657
1658        // Verify fix adds blank line
1659        let fixed = fix_with_config(content, config);
1660        assert_eq!(fixed, "- Item 1\n\n---");
1661    }
1662
1663    #[test]
1664    fn test_lazy_continuation_heading_not_flagged() {
1665        // Heading after list should NOT be flagged as lazy continuation
1666        // (headings end lists per CommonMark)
1667        let content = "- Item 1\n# Heading";
1668        let config = MD032Config {
1669            allow_lazy_continuation: false,
1670        };
1671        let warnings = lint_with_config(content, config);
1672        // The warning should be about missing blank line, not lazy continuation
1673        // But headings interrupt lists, so the list ends at Item 1
1674        assert!(
1675            warnings.iter().all(|w| !w.message.contains("lazy")),
1676            "Heading should not trigger lazy continuation warning"
1677        );
1678    }
1679
1680    #[test]
1681    fn test_lazy_continuation_mixed_list_types() {
1682        // Mixed ordered and unordered with lazy continuation
1683        let content = "- Unordered\n1. Ordered\nLazy text";
1684        let config = MD032Config {
1685            allow_lazy_continuation: false,
1686        };
1687        let warnings = lint_with_config(content, config.clone());
1688        assert!(!warnings.is_empty(), "Should warn about structure issues");
1689    }
1690
1691    #[test]
1692    fn test_lazy_continuation_deep_nesting() {
1693        // Deep nested list with lazy continuation at end
1694        let content = "- Level 1\n  - Level 2\n    - Level 3\nLazy at root";
1695        let config = MD032Config {
1696            allow_lazy_continuation: false,
1697        };
1698        let warnings = lint_with_config(content, config.clone());
1699        assert!(
1700            !warnings.is_empty(),
1701            "Should warn about lazy continuation after nested list"
1702        );
1703
1704        let fixed = fix_with_config(content, config.clone());
1705        let warnings_after = lint_with_config(&fixed, config);
1706        assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
1707    }
1708
1709    #[test]
1710    fn test_lazy_continuation_with_emphasis_in_text() {
1711        // Lazy continuation containing emphasis markers
1712        let content = "- Item\n*emphasized* continuation";
1713        let config = MD032Config {
1714            allow_lazy_continuation: false,
1715        };
1716        let warnings = lint_with_config(content, config.clone());
1717        assert_eq!(warnings.len(), 1, "Should warn even with emphasis in continuation");
1718
1719        let fixed = fix_with_config(content, config);
1720        assert_eq!(fixed, "- Item\n\n*emphasized* continuation");
1721    }
1722
1723    #[test]
1724    fn test_lazy_continuation_with_code_span() {
1725        // Lazy continuation containing code span
1726        let content = "- Item\n`code` continuation";
1727        let config = MD032Config {
1728            allow_lazy_continuation: false,
1729        };
1730        let warnings = lint_with_config(content, config.clone());
1731        assert_eq!(warnings.len(), 1, "Should warn even with code span in continuation");
1732
1733        let fixed = fix_with_config(content, config);
1734        assert_eq!(fixed, "- Item\n\n`code` continuation");
1735    }
1736
1737    #[test]
1738    fn test_lazy_continuation_whitespace_only_line() {
1739        // Line with only whitespace is NOT considered a blank line for MD032
1740        // This matches CommonMark where only truly empty lines are "blank"
1741        let content = "- Item\n   \nText after whitespace-only line";
1742        let config = MD032Config {
1743            allow_lazy_continuation: false,
1744        };
1745        let warnings = lint_with_config(content, config.clone());
1746        // Whitespace-only line does NOT count as blank line separator
1747        assert_eq!(
1748            warnings.len(),
1749            1,
1750            "Whitespace-only line should NOT count as separator. Got: {warnings:?}"
1751        );
1752
1753        // Verify fix adds proper blank line
1754        let fixed = fix_with_config(content, config);
1755        assert!(fixed.contains("\n\nText"), "Fix should add blank line separator");
1756    }
1757
1758    #[test]
1759    fn test_lazy_continuation_blockquote_context() {
1760        // List inside blockquote with lazy continuation
1761        let content = "> - Item\n> Lazy in quote";
1762        let config = MD032Config {
1763            allow_lazy_continuation: false,
1764        };
1765        let warnings = lint_with_config(content, config);
1766        // Inside blockquote, lazy continuation may behave differently
1767        // This tests that we handle blockquote context
1768        assert!(warnings.len() <= 1, "Should handle blockquote context gracefully");
1769    }
1770
1771    #[test]
1772    fn test_lazy_continuation_fix_preserves_content() {
1773        // Ensure fix doesn't modify the actual content
1774        let content = "- Item with special chars: <>&\nContinuation with: \"quotes\"";
1775        let config = MD032Config {
1776            allow_lazy_continuation: false,
1777        };
1778        let fixed = fix_with_config(content, config);
1779        assert!(fixed.contains("<>&"), "Should preserve special chars");
1780        assert!(fixed.contains("\"quotes\""), "Should preserve quotes");
1781        assert_eq!(fixed, "- Item with special chars: <>&\n\nContinuation with: \"quotes\"");
1782    }
1783
1784    #[test]
1785    fn test_lazy_continuation_fix_idempotent() {
1786        // Running fix twice should produce same result
1787        let content = "- Item\nLazy";
1788        let config = MD032Config {
1789            allow_lazy_continuation: false,
1790        };
1791        let fixed_once = fix_with_config(content, config.clone());
1792        let fixed_twice = fix_with_config(&fixed_once, config);
1793        assert_eq!(fixed_once, fixed_twice, "Fix should be idempotent");
1794    }
1795
1796    #[test]
1797    fn test_lazy_continuation_config_default_allows() {
1798        // Verify default config allows lazy continuation
1799        let content = "- Item\nLazy text that continues";
1800        let default_config = MD032Config::default();
1801        assert!(
1802            default_config.allow_lazy_continuation,
1803            "Default should allow lazy continuation"
1804        );
1805        let warnings = lint_with_config(content, default_config);
1806        assert_eq!(warnings.len(), 0, "Default config should not warn on lazy continuation");
1807    }
1808
1809    #[test]
1810    fn test_lazy_continuation_after_multi_line_item() {
1811        // List item with proper indented continuation, then lazy text
1812        let content = "- Item line 1\n  Item line 2 (indented)\nLazy (not indented)";
1813        let config = MD032Config {
1814            allow_lazy_continuation: false,
1815        };
1816        let warnings = lint_with_config(content, config.clone());
1817        assert_eq!(
1818            warnings.len(),
1819            1,
1820            "Should warn only for the lazy line, not the indented line"
1821        );
1822    }
1823
1824    // Issue #260: Lists inside blockquotes should not produce false positives
1825    #[test]
1826    fn test_blockquote_list_with_continuation_and_nested() {
1827        // This is the exact case from issue #260
1828        // markdownlint-cli reports NO warnings for this
1829        let content = "> - item 1\n>   continuation\n>   - nested\n> - item 2";
1830        let warnings = lint(content);
1831        assert_eq!(
1832            warnings.len(),
1833            0,
1834            "Blockquoted list with continuation and nested items should have no warnings. Got: {warnings:?}"
1835        );
1836    }
1837
1838    #[test]
1839    fn test_blockquote_list_simple() {
1840        // Simple blockquoted list
1841        let content = "> - item 1\n> - item 2";
1842        let warnings = lint(content);
1843        assert_eq!(warnings.len(), 0, "Simple blockquoted list should have no warnings");
1844    }
1845
1846    #[test]
1847    fn test_blockquote_list_with_continuation_only() {
1848        // Blockquoted list with continuation line (no nesting)
1849        let content = "> - item 1\n>   continuation\n> - item 2";
1850        let warnings = lint(content);
1851        assert_eq!(
1852            warnings.len(),
1853            0,
1854            "Blockquoted list with continuation should have no warnings"
1855        );
1856    }
1857
1858    #[test]
1859    fn test_blockquote_list_with_lazy_continuation() {
1860        // Blockquoted list with lazy continuation (no extra indent after >)
1861        let content = "> - item 1\n> lazy continuation\n> - item 2";
1862        let warnings = lint(content);
1863        assert_eq!(
1864            warnings.len(),
1865            0,
1866            "Blockquoted list with lazy continuation should have no warnings"
1867        );
1868    }
1869
1870    #[test]
1871    fn test_nested_blockquote_list() {
1872        // List inside nested blockquote (>> prefix)
1873        let content = ">> - item 1\n>>   continuation\n>>   - nested\n>> - item 2";
1874        let warnings = lint(content);
1875        assert_eq!(warnings.len(), 0, "Nested blockquote list should have no warnings");
1876    }
1877
1878    #[test]
1879    fn test_blockquote_list_needs_preceding_blank() {
1880        // Blockquote list preceded by non-blank content SHOULD warn
1881        let content = "> Text before\n> - item 1\n> - item 2";
1882        let warnings = lint(content);
1883        assert_eq!(
1884            warnings.len(),
1885            1,
1886            "Should warn for missing blank before blockquoted list"
1887        );
1888    }
1889
1890    #[test]
1891    fn test_blockquote_list_properly_separated() {
1892        // Blockquote list with proper blank lines - no warnings
1893        let content = "> Text before\n>\n> - item 1\n> - item 2\n>\n> Text after";
1894        let warnings = lint(content);
1895        assert_eq!(
1896            warnings.len(),
1897            0,
1898            "Properly separated blockquoted list should have no warnings"
1899        );
1900    }
1901
1902    #[test]
1903    fn test_blockquote_ordered_list() {
1904        // Ordered list in blockquote with continuation
1905        let content = "> 1. item 1\n>    continuation\n> 2. item 2";
1906        let warnings = lint(content);
1907        assert_eq!(warnings.len(), 0, "Ordered list in blockquote should have no warnings");
1908    }
1909
1910    #[test]
1911    fn test_blockquote_list_with_empty_blockquote_line() {
1912        // Empty blockquote line (just ">") between items - still same list
1913        let content = "> - item 1\n>\n> - item 2";
1914        let warnings = lint(content);
1915        assert_eq!(warnings.len(), 0, "Empty blockquote line should not break list");
1916    }
1917
1918    #[test]
1919    fn test_blockquote_list_varying_spaces_after_marker() {
1920        // Different spacing after > (1 space vs 3 spaces) but same blockquote level
1921        let content = "> - item 1\n>   continuation with more indent\n> - item 2";
1922        let warnings = lint(content);
1923        assert_eq!(warnings.len(), 0, "Varying spaces after > should not break list");
1924    }
1925
1926    #[test]
1927    fn test_deeply_nested_blockquote_list() {
1928        // Triple-nested blockquote with list
1929        let content = ">>> - item 1\n>>>   continuation\n>>> - item 2";
1930        let warnings = lint(content);
1931        assert_eq!(
1932            warnings.len(),
1933            0,
1934            "Deeply nested blockquote list should have no warnings"
1935        );
1936    }
1937
1938    #[test]
1939    #[ignore = "rumdl doesn't yet detect blockquote level changes between list items as list-breaking"]
1940    fn test_blockquote_level_change_in_list() {
1941        // Blockquote level changes mid-list - this SHOULD break the list
1942        // Verify we still detect when blockquote level actually changes
1943        // TODO: This is a separate enhancement from issue #260
1944        let content = "> - item 1\n>> - deeper item\n> - item 2";
1945        // Each segment is a separate list context due to blockquote level change
1946        // markdownlint-cli reports 4 warnings for this case
1947        let warnings = lint(content);
1948        assert!(
1949            !warnings.is_empty(),
1950            "Blockquote level change should break list and trigger warnings"
1951        );
1952    }
1953
1954    #[test]
1955    fn test_blockquote_list_with_code_span() {
1956        // List item with inline code in blockquote
1957        let content = "> - item with `code`\n>   continuation\n> - item 2";
1958        let warnings = lint(content);
1959        assert_eq!(
1960            warnings.len(),
1961            0,
1962            "Blockquote list with code span should have no warnings"
1963        );
1964    }
1965
1966    #[test]
1967    fn test_blockquote_list_at_document_end() {
1968        // List at end of document (no trailing content)
1969        let content = "> Some text\n>\n> - item 1\n> - item 2";
1970        let warnings = lint(content);
1971        assert_eq!(
1972            warnings.len(),
1973            0,
1974            "Blockquote list at document end should have no warnings"
1975        );
1976    }
1977}