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 pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
6use regex::Regex;
7use std::collections::HashSet;
8use std::sync::LazyLock;
9
10mod md032_config;
11pub use md032_config::MD032Config;
12
13// Detects ordered list items starting with a number other than 1
14static ORDERED_LIST_NON_ONE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*([2-9]|\d{2,})\.\s").unwrap());
15
16/// Check if a line is a thematic break (horizontal rule)
17/// Per CommonMark: 0-3 spaces of indentation, then 3+ of same char (-, *, _), optionally with spaces between
18fn is_thematic_break(line: &str) -> bool {
19    // Per CommonMark, thematic breaks can have 0-3 spaces of indentation (< 4 columns)
20    if ElementCache::calculate_indentation_width_default(line) > 3 {
21        return false;
22    }
23
24    let trimmed = line.trim();
25    if trimmed.len() < 3 {
26        return false;
27    }
28
29    let chars: Vec<char> = trimmed.chars().collect();
30    let first_non_space = chars.iter().find(|&&c| c != ' ');
31
32    if let Some(&marker) = first_non_space {
33        if marker != '-' && marker != '*' && marker != '_' {
34            return false;
35        }
36        let marker_count = chars.iter().filter(|&&c| c == marker).count();
37        let other_count = chars.iter().filter(|&&c| c != marker && c != ' ').count();
38        marker_count >= 3 && other_count == 0
39    } else {
40        false
41    }
42}
43
44/// Rule MD032: Lists should be surrounded by blank lines
45///
46/// This rule enforces that lists are surrounded by blank lines, which improves document
47/// readability and ensures consistent rendering across different Markdown processors.
48///
49/// ## Purpose
50///
51/// - **Readability**: Blank lines create visual separation between lists and surrounding content
52/// - **Parsing**: Many Markdown parsers require blank lines around lists for proper rendering
53/// - **Consistency**: Ensures uniform document structure and appearance
54/// - **Compatibility**: Improves compatibility across different Markdown implementations
55///
56/// ## Examples
57///
58/// ### Correct
59///
60/// ```markdown
61/// This is a paragraph of text.
62///
63/// - Item 1
64/// - Item 2
65/// - Item 3
66///
67/// This is another paragraph.
68/// ```
69///
70/// ### Incorrect
71///
72/// ```markdown
73/// This is a paragraph of text.
74/// - Item 1
75/// - Item 2
76/// - Item 3
77/// This is another paragraph.
78/// ```
79///
80/// ## Behavior Details
81///
82/// This rule checks for the following:
83///
84/// - **List Start**: There should be a blank line before the first item in a list
85///   (unless the list is at the beginning of the document or after front matter)
86/// - **List End**: There should be a blank line after the last item in a list
87///   (unless the list is at the end of the document)
88/// - **Nested Lists**: Properly handles nested lists and list continuations
89/// - **List Types**: Works with ordered lists, unordered lists, and all valid list markers (-, *, +)
90///
91/// ## Special Cases
92///
93/// This rule handles several special cases:
94///
95/// - **Front Matter**: YAML front matter is detected and skipped
96/// - **Code Blocks**: Lists inside code blocks are ignored
97/// - **List Content**: Indented content belonging to list items is properly recognized as part of the list
98/// - **Document Boundaries**: Lists at the beginning or end of the document have adjusted requirements
99///
100/// ## Fix Behavior
101///
102/// When applying automatic fixes, this rule:
103/// - Adds a blank line before the first list item when needed
104/// - Adds a blank line after the last list item when needed
105/// - Preserves document structure and existing content
106///
107/// ## Performance Optimizations
108///
109/// The rule includes several optimizations:
110/// - Fast path checks before applying more expensive regex operations
111/// - Efficient list item detection
112/// - Pre-computation of code block lines to avoid redundant processing
113#[derive(Debug, Clone, Default)]
114pub struct MD032BlanksAroundLists {
115    config: MD032Config,
116}
117
118impl MD032BlanksAroundLists {
119    pub fn from_config_struct(config: MD032Config) -> Self {
120        Self { config }
121    }
122}
123
124impl MD032BlanksAroundLists {
125    /// Check if a blank line should be required before a list based on the previous line context
126    fn should_require_blank_line_before(
127        ctx: &crate::lint_context::LintContext,
128        prev_line_num: usize,
129        current_line_num: usize,
130    ) -> bool {
131        // Always require blank lines after code blocks, front matter, etc.
132        if ctx
133            .line_info(prev_line_num)
134            .is_some_and(|info| info.in_code_block || info.in_front_matter)
135        {
136            return true;
137        }
138
139        // Always allow nested lists (lists indented within other list items)
140        if Self::is_nested_list(ctx, prev_line_num, current_line_num) {
141            return false;
142        }
143
144        // Default: require blank line (matching markdownlint's behavior)
145        true
146    }
147
148    /// Check if the current list is nested within another list item
149    fn is_nested_list(
150        ctx: &crate::lint_context::LintContext,
151        prev_line_num: usize,    // 1-indexed
152        current_line_num: usize, // 1-indexed
153    ) -> bool {
154        // Check if current line is indented (typical for nested lists)
155        if current_line_num > 0 && current_line_num - 1 < ctx.lines.len() {
156            let current_line = &ctx.lines[current_line_num - 1];
157            if current_line.indent >= 2 {
158                // Check if previous line is a list item or list content
159                if prev_line_num > 0 && prev_line_num - 1 < ctx.lines.len() {
160                    let prev_line = &ctx.lines[prev_line_num - 1];
161                    // Previous line is a list item or indented content
162                    if prev_line.list_item.is_some() || prev_line.indent >= 2 {
163                        return true;
164                    }
165                }
166            }
167        }
168        false
169    }
170
171    /// Detect lazy continuation lines within list items using pulldown-cmark's SoftBreak events.
172    ///
173    /// Lazy continuation occurs when text continues a list item paragraph but with less
174    /// indentation than expected. pulldown-cmark identifies this via SoftBreak followed by Text
175    /// within a list Item, where the Text starts at a column less than the item's content column.
176    fn detect_lazy_continuation_lines(ctx: &crate::lint_context::LintContext) -> HashSet<usize> {
177        let mut lazy_lines = HashSet::new();
178        let parser = Parser::new_ext(ctx.content, Options::all());
179
180        // Stack of (item_start_byte, expected_content_column) for nested items
181        let mut item_stack: Vec<usize> = vec![];
182        let mut after_soft_break = false;
183
184        for (event, range) in parser.into_offset_iter() {
185            match event {
186                Event::Start(Tag::Item) => {
187                    // Get the expected content column from pre-parsed LineInfo
188                    let line_num = Self::byte_to_line(&ctx.line_offsets, range.start);
189                    let content_col = ctx
190                        .lines
191                        .get(line_num.saturating_sub(1))
192                        .and_then(|li| li.list_item.as_ref())
193                        .map(|item| item.content_column)
194                        .unwrap_or(0);
195                    item_stack.push(content_col);
196                    after_soft_break = false;
197                }
198                Event::End(TagEnd::Item) => {
199                    item_stack.pop();
200                    after_soft_break = false;
201                }
202                Event::SoftBreak if !item_stack.is_empty() => {
203                    after_soft_break = true;
204                }
205                // Handle both Text and Code events after SoftBreak
206                // (lazy continuation can start with code spans like `token`)
207                Event::Text(_) | Event::Code(_) if after_soft_break => {
208                    if let Some(&expected_col) = item_stack.last() {
209                        let line_num = Self::byte_to_line(&ctx.line_offsets, range.start);
210                        let actual_indent = ctx
211                            .lines
212                            .get(line_num.saturating_sub(1))
213                            .map(|li| li.indent)
214                            .unwrap_or(0);
215
216                        // If the text starts at a column less than expected, it's lazy continuation
217                        if actual_indent < expected_col {
218                            lazy_lines.insert(line_num);
219                        }
220                    }
221                    after_soft_break = false;
222                }
223                _ => {
224                    after_soft_break = false;
225                }
226            }
227        }
228
229        lazy_lines
230    }
231
232    /// Convert a byte offset to a 1-indexed line number
233    fn byte_to_line(line_offsets: &[usize], byte_offset: usize) -> usize {
234        match line_offsets.binary_search(&byte_offset) {
235            Ok(idx) => idx + 1,
236            Err(idx) => idx.max(1),
237        }
238    }
239
240    /// Find the first non-HTML-comment line before the given line (1-indexed).
241    /// Returns (line_num, is_blank) where:
242    /// - line_num is the 1-indexed line of actual content (0 if start of document)
243    /// - is_blank is true if that line is blank (meaning separation exists)
244    ///
245    /// This enables HTML comments to be "transparent" for blank line checking,
246    /// matching markdownlint-cli behavior.
247    fn find_preceding_content(ctx: &crate::lint_context::LintContext, before_line: usize) -> (usize, bool) {
248        for line_num in (1..before_line).rev() {
249            let idx = line_num - 1;
250            if let Some(info) = ctx.lines.get(idx) {
251                // Skip HTML comment lines - they're transparent
252                if info.in_html_comment {
253                    continue;
254                }
255                return (line_num, info.is_blank);
256            }
257        }
258        // Start of document = effectively blank-separated
259        (0, true)
260    }
261
262    /// Find the first non-HTML-comment line after the given line (1-indexed).
263    /// Returns (line_num, is_blank) where:
264    /// - line_num is the 1-indexed line of actual content (0 if end of document)
265    /// - is_blank is true if that line is blank (meaning separation exists)
266    fn find_following_content(ctx: &crate::lint_context::LintContext, after_line: usize) -> (usize, bool) {
267        let num_lines = ctx.lines.len();
268        for line_num in (after_line + 1)..=num_lines {
269            let idx = line_num - 1;
270            if let Some(info) = ctx.lines.get(idx) {
271                // Skip HTML comment lines - they're transparent
272                if info.in_html_comment {
273                    continue;
274                }
275                return (line_num, info.is_blank);
276            }
277        }
278        // End of document = effectively blank-separated
279        (0, true)
280    }
281
282    // Convert centralized list blocks to the format expected by perform_checks
283    fn convert_list_blocks(&self, ctx: &crate::lint_context::LintContext) -> Vec<(usize, usize, String)> {
284        let mut blocks: Vec<(usize, usize, String)> = Vec::new();
285
286        for block in &ctx.list_blocks {
287            // For MD032, we need to check if there are code blocks that should
288            // split the list into separate segments
289
290            // Simple approach: if there's a fenced code block between list items,
291            // split at that point
292            let mut segments: Vec<(usize, usize)> = Vec::new();
293            let mut current_start = block.start_line;
294            let mut prev_item_line = 0;
295
296            // Helper to get blockquote level (count of '>' chars) from a line
297            let get_blockquote_level = |line_num: usize| -> usize {
298                if line_num == 0 || line_num > ctx.lines.len() {
299                    return 0;
300                }
301                let line_content = ctx.lines[line_num - 1].content(ctx.content);
302                BLOCKQUOTE_PREFIX_RE
303                    .find(line_content)
304                    .map(|m| m.as_str().chars().filter(|&c| c == '>').count())
305                    .unwrap_or(0)
306            };
307
308            let mut prev_bq_level = 0;
309
310            for &item_line in &block.item_lines {
311                let current_bq_level = get_blockquote_level(item_line);
312
313                if prev_item_line > 0 {
314                    // Check if blockquote level changed between items
315                    let blockquote_level_changed = prev_bq_level != current_bq_level;
316
317                    // Check if there's a standalone code fence between prev_item_line and item_line
318                    // A code fence that's indented as part of a list item should NOT split the list
319                    let mut has_standalone_code_fence = false;
320
321                    // Calculate minimum indentation for list item content
322                    let min_indent_for_content = if block.is_ordered {
323                        // For ordered lists, content should be indented at least to align with text after marker
324                        // e.g., "1. " = 3 chars, so content should be indented 3+ spaces
325                        3 // Minimum for "1. "
326                    } else {
327                        // For unordered lists, content should be indented at least 2 spaces
328                        2 // For "- " or "* "
329                    };
330
331                    for check_line in (prev_item_line + 1)..item_line {
332                        if check_line - 1 < ctx.lines.len() {
333                            let line = &ctx.lines[check_line - 1];
334                            let line_content = line.content(ctx.content);
335                            if line.in_code_block
336                                && (line_content.trim().starts_with("```") || line_content.trim().starts_with("~~~"))
337                            {
338                                // Check if this code fence is indented as part of the list item
339                                // If it's indented enough to be part of the list item, it shouldn't split
340                                if line.indent < min_indent_for_content {
341                                    has_standalone_code_fence = true;
342                                    break;
343                                }
344                            }
345                        }
346                    }
347
348                    if has_standalone_code_fence || blockquote_level_changed {
349                        // End current segment before this item
350                        segments.push((current_start, prev_item_line));
351                        current_start = item_line;
352                    }
353                }
354                prev_item_line = item_line;
355                prev_bq_level = current_bq_level;
356            }
357
358            // Add the final segment
359            // For the last segment, end at the last list item (not the full block end)
360            if prev_item_line > 0 {
361                segments.push((current_start, prev_item_line));
362            }
363
364            // Check if this list block was split by code fences
365            let has_code_fence_splits = segments.len() > 1 && {
366                // Check if any segments were created due to code fences
367                let mut found_fence = false;
368                for i in 0..segments.len() - 1 {
369                    let seg_end = segments[i].1;
370                    let next_start = segments[i + 1].0;
371                    // Check if there's a code fence between these segments
372                    for check_line in (seg_end + 1)..next_start {
373                        if check_line - 1 < ctx.lines.len() {
374                            let line = &ctx.lines[check_line - 1];
375                            let line_content = line.content(ctx.content);
376                            if line.in_code_block
377                                && (line_content.trim().starts_with("```") || line_content.trim().starts_with("~~~"))
378                            {
379                                found_fence = true;
380                                break;
381                            }
382                        }
383                    }
384                    if found_fence {
385                        break;
386                    }
387                }
388                found_fence
389            };
390
391            // Convert segments to blocks
392            for (start, end) in segments.iter() {
393                // Extend the end to include any continuation lines immediately after the last item
394                let mut actual_end = *end;
395
396                // If this list was split by code fences, don't extend any segments
397                // They should remain as individual list items for MD032 purposes
398                if !has_code_fence_splits && *end < block.end_line {
399                    // Get the minimum indent required for proper continuation
400                    // This is the content column of the last list item in the segment
401                    let min_continuation_indent = ctx
402                        .lines
403                        .get(*end - 1)
404                        .and_then(|line_info| line_info.list_item.as_ref())
405                        .map(|item| item.content_column)
406                        .unwrap_or(2);
407
408                    for check_line in (*end + 1)..=block.end_line {
409                        if check_line - 1 < ctx.lines.len() {
410                            let line = &ctx.lines[check_line - 1];
411                            let line_content = line.content(ctx.content);
412                            // Stop at next list item or non-continuation content
413                            if block.item_lines.contains(&check_line) || line.heading.is_some() {
414                                break;
415                            }
416                            // Don't extend through code blocks
417                            if line.in_code_block {
418                                break;
419                            }
420                            // Include indented continuation if indent meets threshold
421                            if line.indent >= min_continuation_indent {
422                                actual_end = check_line;
423                            }
424                            // Include lazy continuation lines (multiple consecutive lines without indent)
425                            // Per CommonMark, only paragraph text can be lazy continuation
426                            // Thematic breaks, code fences, etc. cannot be lazy continuations
427                            // Only include lazy continuation if allowed by config
428                            else if self.config.allow_lazy_continuation
429                                && !line.is_blank
430                                && line.heading.is_none()
431                                && !block.item_lines.contains(&check_line)
432                                && !is_thematic_break(line_content)
433                            {
434                                // This is a lazy continuation line - check if we're still in the same paragraph
435                                // Allow multiple consecutive lazy continuation lines
436                                actual_end = check_line;
437                            } else if !line.is_blank {
438                                // Non-blank line that's not a continuation - stop here
439                                break;
440                            }
441                        }
442                    }
443                }
444
445                blocks.push((*start, actual_end, block.blockquote_prefix.clone()));
446            }
447        }
448
449        // Filter out lists entirely inside HTML comments
450        blocks.retain(|(start, end, _)| {
451            // Check if ALL lines of this block are inside HTML comments
452            let all_in_comment =
453                (*start..=*end).all(|line_num| ctx.lines.get(line_num - 1).is_some_and(|info| info.in_html_comment));
454            !all_in_comment
455        });
456
457        blocks
458    }
459
460    fn perform_checks(
461        &self,
462        ctx: &crate::lint_context::LintContext,
463        lines: &[&str],
464        list_blocks: &[(usize, usize, String)],
465        line_index: &LineIndex,
466    ) -> LintResult {
467        let mut warnings = Vec::new();
468        let num_lines = lines.len();
469
470        // Check for ordered lists starting with non-1 that aren't recognized as lists
471        // These need blank lines before them to be parsed as lists by CommonMark
472        for (line_idx, line) in lines.iter().enumerate() {
473            let line_num = line_idx + 1;
474
475            // Skip if this line is already part of a recognized list
476            let is_in_list = list_blocks
477                .iter()
478                .any(|(start, end, _)| line_num >= *start && line_num <= *end);
479            if is_in_list {
480                continue;
481            }
482
483            // Skip if in code block, front matter, or HTML comment
484            if ctx
485                .line_info(line_num)
486                .is_some_and(|info| info.in_code_block || info.in_front_matter || info.in_html_comment)
487            {
488                continue;
489            }
490
491            // Check if this line starts with a number other than 1
492            if ORDERED_LIST_NON_ONE_RE.is_match(line) {
493                // Check if there's a blank line before this
494                if line_idx > 0 {
495                    let prev_line = lines[line_idx - 1];
496                    let prev_is_blank = is_blank_in_context(prev_line);
497                    let prev_excluded = ctx
498                        .line_info(line_idx)
499                        .is_some_and(|info| info.in_code_block || info.in_front_matter);
500
501                    // Check if previous line looks like a sentence continuation
502                    // If the previous line is non-blank text that doesn't end with a sentence
503                    // terminator, this is likely a paragraph continuation, not a list item
504                    // e.g., "...in Chapter\n19. For now..." is a broken sentence, not a list
505                    let prev_trimmed = prev_line.trim();
506                    let is_sentence_continuation = !prev_is_blank
507                        && !prev_trimmed.is_empty()
508                        && !prev_trimmed.ends_with('.')
509                        && !prev_trimmed.ends_with('!')
510                        && !prev_trimmed.ends_with('?')
511                        && !prev_trimmed.ends_with(':')
512                        && !prev_trimmed.ends_with(';')
513                        && !prev_trimmed.ends_with('>')
514                        && !prev_trimmed.ends_with('-')
515                        && !prev_trimmed.ends_with('*');
516
517                    if !prev_is_blank && !prev_excluded && !is_sentence_continuation {
518                        // This ordered list item starting with non-1 needs a blank line before it
519                        let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line);
520
521                        let bq_prefix = ctx.blockquote_prefix_for_blank_line(line_idx);
522                        warnings.push(LintWarning {
523                            line: start_line,
524                            column: start_col,
525                            end_line,
526                            end_column: end_col,
527                            severity: Severity::Warning,
528                            rule_name: Some(self.name().to_string()),
529                            message: "Ordered list starting with non-1 should be preceded by blank line".to_string(),
530                            fix: Some(Fix {
531                                range: line_index.line_col_to_byte_range_with_length(line_num, 1, 0),
532                                replacement: format!("{bq_prefix}\n"),
533                            }),
534                        });
535                    }
536                }
537            }
538        }
539
540        for &(start_line, end_line, ref prefix) in list_blocks {
541            // Skip lists that start inside HTML comments
542            if ctx.line_info(start_line).is_some_and(|info| info.in_html_comment) {
543                continue;
544            }
545
546            if start_line > 1 {
547                // Look past HTML comments to find actual preceding content
548                let (content_line, has_blank_separation) = Self::find_preceding_content(ctx, start_line);
549
550                // If blank separation exists (through HTML comments), no warning needed
551                if !has_blank_separation && content_line > 0 {
552                    let prev_line_str = lines[content_line - 1];
553                    let is_prev_excluded = ctx
554                        .line_info(content_line)
555                        .is_some_and(|info| info.in_code_block || info.in_front_matter);
556                    let prev_prefix = BLOCKQUOTE_PREFIX_RE
557                        .find(prev_line_str)
558                        .map_or(String::new(), |m| m.as_str().to_string());
559                    let prefixes_match = prev_prefix.trim() == prefix.trim();
560
561                    // Only require blank lines for content in the same context (same blockquote level)
562                    // and when the context actually requires it
563                    let should_require = Self::should_require_blank_line_before(ctx, content_line, start_line);
564                    if !is_prev_excluded && prefixes_match && should_require {
565                        // Calculate precise character range for the entire list line that needs a blank line before it
566                        let (start_line, start_col, end_line, end_col) =
567                            calculate_line_range(start_line, lines[start_line - 1]);
568
569                        warnings.push(LintWarning {
570                            line: start_line,
571                            column: start_col,
572                            end_line,
573                            end_column: end_col,
574                            severity: Severity::Warning,
575                            rule_name: Some(self.name().to_string()),
576                            message: "List should be preceded by blank line".to_string(),
577                            fix: Some(Fix {
578                                range: line_index.line_col_to_byte_range_with_length(start_line, 1, 0),
579                                replacement: format!("{prefix}\n"),
580                            }),
581                        });
582                    }
583                }
584            }
585
586            if end_line < num_lines {
587                // Look past HTML comments to find actual following content
588                let (content_line, has_blank_separation) = Self::find_following_content(ctx, end_line);
589
590                // If blank separation exists (through HTML comments), no warning needed
591                if !has_blank_separation && content_line > 0 {
592                    let next_line_str = lines[content_line - 1];
593                    // Check if next line is excluded - front matter or indented code blocks within lists
594                    // We want blank lines before standalone code blocks, but not within list items
595                    let is_next_excluded = ctx.line_info(content_line).is_some_and(|info| info.in_front_matter)
596                        || (content_line <= ctx.lines.len()
597                            && ctx.lines[content_line - 1].in_code_block
598                            && ctx.lines[content_line - 1].indent >= 2);
599                    let next_prefix = BLOCKQUOTE_PREFIX_RE
600                        .find(next_line_str)
601                        .map_or(String::new(), |m| m.as_str().to_string());
602
603                    // Check blockquote levels to detect boundary transitions
604                    // If the list ends inside a blockquote but the following line exits the blockquote
605                    // (fewer > chars in prefix), no blank line is needed - the blockquote boundary
606                    // provides semantic separation
607                    let end_line_str = lines[end_line - 1];
608                    let end_line_prefix = BLOCKQUOTE_PREFIX_RE
609                        .find(end_line_str)
610                        .map_or(String::new(), |m| m.as_str().to_string());
611                    let end_line_bq_level = end_line_prefix.chars().filter(|&c| c == '>').count();
612                    let next_line_bq_level = next_prefix.chars().filter(|&c| c == '>').count();
613                    let exits_blockquote = end_line_bq_level > 0 && next_line_bq_level < end_line_bq_level;
614
615                    let prefixes_match = next_prefix.trim() == prefix.trim();
616
617                    // Only require blank lines for content in the same context (same blockquote level)
618                    // Skip if the following line exits a blockquote - boundary provides separation
619                    if !is_next_excluded && prefixes_match && !exits_blockquote {
620                        // Calculate precise character range for the last line of the list (not the line after)
621                        let (start_line_last, start_col_last, end_line_last, end_col_last) =
622                            calculate_line_range(end_line, lines[end_line - 1]);
623
624                        warnings.push(LintWarning {
625                            line: start_line_last,
626                            column: start_col_last,
627                            end_line: end_line_last,
628                            end_column: end_col_last,
629                            severity: Severity::Warning,
630                            rule_name: Some(self.name().to_string()),
631                            message: "List should be followed by blank line".to_string(),
632                            fix: Some(Fix {
633                                range: line_index.line_col_to_byte_range_with_length(end_line + 1, 1, 0),
634                                replacement: format!("{prefix}\n"),
635                            }),
636                        });
637                    }
638                }
639            }
640        }
641        Ok(warnings)
642    }
643}
644
645impl Rule for MD032BlanksAroundLists {
646    fn name(&self) -> &'static str {
647        "MD032"
648    }
649
650    fn description(&self) -> &'static str {
651        "Lists should be surrounded by blank lines"
652    }
653
654    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
655        let content = ctx.content;
656        let lines: Vec<&str> = content.lines().collect();
657        let line_index = &ctx.line_index;
658
659        // Early return for empty content
660        if lines.is_empty() {
661            return Ok(Vec::new());
662        }
663
664        let list_blocks = self.convert_list_blocks(ctx);
665
666        if list_blocks.is_empty() {
667            return Ok(Vec::new());
668        }
669
670        let mut warnings = self.perform_checks(ctx, &lines, &list_blocks, line_index)?;
671
672        // When lazy continuation is not allowed, detect and warn about lazy continuation
673        // lines WITHIN list blocks (text that continues a list item but with less
674        // indentation than expected). Lazy continuation at the END of list blocks is
675        // already handled by the segment extension logic above.
676        if !self.config.allow_lazy_continuation {
677            let lazy_lines = Self::detect_lazy_continuation_lines(ctx);
678
679            for line_num in lazy_lines {
680                // Only warn about lazy continuation lines that are WITHIN a list block
681                // (i.e., between list items). End-of-block lazy continuation is already
682                // handled by the existing "list should be followed by blank line" logic.
683                let is_within_block = list_blocks
684                    .iter()
685                    .any(|(start, end, _)| line_num >= *start && line_num <= *end);
686
687                if !is_within_block {
688                    continue;
689                }
690
691                // Get the expected indent for context in the warning message
692                let line_content = lines.get(line_num.saturating_sub(1)).unwrap_or(&"");
693                let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line_content);
694
695                // No automatic fix for within-list lazy continuation - adding a blank line
696                // would change document semantics (making it a separate paragraph instead
697                // of part of the list item). Let the user decide how to handle it.
698                warnings.push(LintWarning {
699                    line: start_line,
700                    column: start_col,
701                    end_line,
702                    end_column: end_col,
703                    severity: Severity::Warning,
704                    rule_name: Some(self.name().to_string()),
705                    message: "Lazy continuation line should be properly indented or preceded by blank line".to_string(),
706                    fix: None,
707                });
708            }
709        }
710
711        Ok(warnings)
712    }
713
714    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
715        self.fix_with_structure_impl(ctx)
716    }
717
718    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
719        // Fast path: check if document likely has lists
720        if ctx.content.is_empty() || !ctx.likely_has_lists() {
721            return true;
722        }
723        // Verify list blocks actually exist
724        ctx.list_blocks.is_empty()
725    }
726
727    fn category(&self) -> RuleCategory {
728        RuleCategory::List
729    }
730
731    fn as_any(&self) -> &dyn std::any::Any {
732        self
733    }
734
735    fn default_config_section(&self) -> Option<(String, toml::Value)> {
736        use crate::rule_config_serde::RuleConfig;
737        let default_config = MD032Config::default();
738        let json_value = serde_json::to_value(&default_config).ok()?;
739        let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
740
741        if let toml::Value::Table(table) = toml_value {
742            if !table.is_empty() {
743                Some((MD032Config::RULE_NAME.to_string(), toml::Value::Table(table)))
744            } else {
745                None
746            }
747        } else {
748            None
749        }
750    }
751
752    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
753    where
754        Self: Sized,
755    {
756        let rule_config = crate::rule_config_serde::load_rule_config::<MD032Config>(config);
757        Box::new(MD032BlanksAroundLists::from_config_struct(rule_config))
758    }
759}
760
761impl MD032BlanksAroundLists {
762    /// Helper method for fixing implementation
763    fn fix_with_structure_impl(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
764        let lines: Vec<&str> = ctx.content.lines().collect();
765        let num_lines = lines.len();
766        if num_lines == 0 {
767            return Ok(String::new());
768        }
769
770        let list_blocks = self.convert_list_blocks(ctx);
771        if list_blocks.is_empty() {
772            return Ok(ctx.content.to_string());
773        }
774
775        let mut insertions: std::collections::BTreeMap<usize, String> = std::collections::BTreeMap::new();
776
777        // Phase 1: Identify needed insertions
778        for &(start_line, end_line, ref prefix) in &list_blocks {
779            // Skip lists that start inside HTML comments
780            if ctx.line_info(start_line).is_some_and(|info| info.in_html_comment) {
781                continue;
782            }
783
784            // Check before block
785            if start_line > 1 {
786                // Look past HTML comments to find actual preceding content
787                let (content_line, has_blank_separation) = Self::find_preceding_content(ctx, start_line);
788
789                // If blank separation exists (through HTML comments), no fix needed
790                if !has_blank_separation && content_line > 0 {
791                    let prev_line_str = lines[content_line - 1];
792                    let is_prev_excluded = ctx
793                        .line_info(content_line)
794                        .is_some_and(|info| info.in_code_block || info.in_front_matter);
795                    let prev_prefix = BLOCKQUOTE_PREFIX_RE
796                        .find(prev_line_str)
797                        .map_or(String::new(), |m| m.as_str().to_string());
798
799                    let should_require = Self::should_require_blank_line_before(ctx, content_line, start_line);
800                    // Compare trimmed prefixes to handle varying whitespace after > markers
801                    if !is_prev_excluded && prev_prefix.trim() == prefix.trim() && should_require {
802                        // Use centralized helper for consistent blockquote prefix (no trailing space)
803                        let bq_prefix = ctx.blockquote_prefix_for_blank_line(start_line - 1);
804                        insertions.insert(start_line, bq_prefix);
805                    }
806                }
807            }
808
809            // Check after block
810            if end_line < num_lines {
811                // Look past HTML comments to find actual following content
812                let (content_line, has_blank_separation) = Self::find_following_content(ctx, end_line);
813
814                // If blank separation exists (through HTML comments), no fix needed
815                if !has_blank_separation && content_line > 0 {
816                    let next_line_str = lines[content_line - 1];
817                    // Check if next line is excluded - in code block, front matter, or starts an indented code block
818                    let is_next_excluded = ctx
819                        .line_info(content_line)
820                        .is_some_and(|info| info.in_code_block || info.in_front_matter)
821                        || (content_line <= ctx.lines.len()
822                            && ctx.lines[content_line - 1].in_code_block
823                            && ctx.lines[content_line - 1].indent >= 2
824                            && (ctx.lines[content_line - 1]
825                                .content(ctx.content)
826                                .trim()
827                                .starts_with("```")
828                                || ctx.lines[content_line - 1]
829                                    .content(ctx.content)
830                                    .trim()
831                                    .starts_with("~~~")));
832                    let next_prefix = BLOCKQUOTE_PREFIX_RE
833                        .find(next_line_str)
834                        .map_or(String::new(), |m| m.as_str().to_string());
835
836                    // Check blockquote levels to detect boundary transitions
837                    let end_line_str = lines[end_line - 1];
838                    let end_line_prefix = BLOCKQUOTE_PREFIX_RE
839                        .find(end_line_str)
840                        .map_or(String::new(), |m| m.as_str().to_string());
841                    let end_line_bq_level = end_line_prefix.chars().filter(|&c| c == '>').count();
842                    let next_line_bq_level = next_prefix.chars().filter(|&c| c == '>').count();
843                    let exits_blockquote = end_line_bq_level > 0 && next_line_bq_level < end_line_bq_level;
844
845                    // Compare trimmed prefixes to handle varying whitespace after > markers
846                    // Skip if exiting a blockquote - boundary provides separation
847                    if !is_next_excluded && next_prefix.trim() == prefix.trim() && !exits_blockquote {
848                        // Use centralized helper for consistent blockquote prefix (no trailing space)
849                        let bq_prefix = ctx.blockquote_prefix_for_blank_line(end_line - 1);
850                        insertions.insert(end_line + 1, bq_prefix);
851                    }
852                }
853            }
854        }
855
856        // Phase 2: Reconstruct with insertions
857        let mut result_lines: Vec<String> = Vec::with_capacity(num_lines + insertions.len());
858        for (i, line) in lines.iter().enumerate() {
859            let current_line_num = i + 1;
860            if let Some(prefix_to_insert) = insertions.get(&current_line_num)
861                && (result_lines.is_empty() || result_lines.last().unwrap() != prefix_to_insert)
862            {
863                result_lines.push(prefix_to_insert.clone());
864            }
865            result_lines.push(line.to_string());
866        }
867
868        // Preserve the final newline if the original content had one
869        let mut result = result_lines.join("\n");
870        if ctx.content.ends_with('\n') {
871            result.push('\n');
872        }
873        Ok(result)
874    }
875}
876
877// Checks if a line is blank, considering blockquote context
878fn is_blank_in_context(line: &str) -> bool {
879    // A line is blank if it's empty or contains only whitespace,
880    // potentially after removing blockquote markers.
881    if let Some(m) = BLOCKQUOTE_PREFIX_RE.find(line) {
882        // If a blockquote prefix is found, check if the content *after* the prefix is blank.
883        line[m.end()..].trim().is_empty()
884    } else {
885        // No blockquote prefix, check the whole line for blankness.
886        line.trim().is_empty()
887    }
888}
889
890#[cfg(test)]
891mod tests {
892    use super::*;
893    use crate::lint_context::LintContext;
894    use crate::rule::Rule;
895
896    fn lint(content: &str) -> Vec<LintWarning> {
897        let rule = MD032BlanksAroundLists::default();
898        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
899        rule.check(&ctx).expect("Lint check failed")
900    }
901
902    fn fix(content: &str) -> String {
903        let rule = MD032BlanksAroundLists::default();
904        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
905        rule.fix(&ctx).expect("Lint fix failed")
906    }
907
908    // Test that warnings include Fix objects
909    fn check_warnings_have_fixes(content: &str) {
910        let warnings = lint(content);
911        for warning in &warnings {
912            assert!(warning.fix.is_some(), "Warning should have fix: {warning:?}");
913        }
914    }
915
916    #[test]
917    fn test_list_at_start() {
918        // Per markdownlint-cli: trailing text without blank line is treated as lazy continuation
919        // so NO warning is expected here
920        let content = "- Item 1\n- Item 2\nText";
921        let warnings = lint(content);
922        assert_eq!(
923            warnings.len(),
924            0,
925            "Trailing text is lazy continuation per CommonMark - no warning expected"
926        );
927    }
928
929    #[test]
930    fn test_list_at_end() {
931        let content = "Text\n- Item 1\n- Item 2";
932        let warnings = lint(content);
933        assert_eq!(
934            warnings.len(),
935            1,
936            "Expected 1 warning for list at end without preceding blank line"
937        );
938        assert_eq!(
939            warnings[0].line, 2,
940            "Warning should be on the first line of the list (line 2)"
941        );
942        assert!(warnings[0].message.contains("preceded by blank line"));
943
944        // Test that warning has fix
945        check_warnings_have_fixes(content);
946
947        let fixed_content = fix(content);
948        assert_eq!(fixed_content, "Text\n\n- Item 1\n- Item 2");
949
950        // Verify fix resolves the issue
951        let warnings_after_fix = lint(&fixed_content);
952        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
953    }
954
955    #[test]
956    fn test_list_in_middle() {
957        // Per markdownlint-cli: only preceding blank line is required
958        // Trailing text is treated as lazy continuation
959        let content = "Text 1\n- Item 1\n- Item 2\nText 2";
960        let warnings = lint(content);
961        assert_eq!(
962            warnings.len(),
963            1,
964            "Expected 1 warning for list needing preceding blank line (trailing text is lazy continuation)"
965        );
966        assert_eq!(warnings[0].line, 2, "Warning on line 2 (start)");
967        assert!(warnings[0].message.contains("preceded by blank line"));
968
969        // Test that warnings have fixes
970        check_warnings_have_fixes(content);
971
972        let fixed_content = fix(content);
973        assert_eq!(fixed_content, "Text 1\n\n- Item 1\n- Item 2\nText 2");
974
975        // Verify fix resolves the issue
976        let warnings_after_fix = lint(&fixed_content);
977        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
978    }
979
980    #[test]
981    fn test_correct_spacing() {
982        let content = "Text 1\n\n- Item 1\n- Item 2\n\nText 2";
983        let warnings = lint(content);
984        assert_eq!(warnings.len(), 0, "Expected no warnings for correctly spaced list");
985
986        let fixed_content = fix(content);
987        assert_eq!(fixed_content, content, "Fix should not change correctly spaced content");
988    }
989
990    #[test]
991    fn test_list_with_content() {
992        // Per markdownlint-cli: only preceding blank line warning
993        // Trailing text is lazy continuation
994        let content = "Text\n* Item 1\n  Content\n* Item 2\n  More content\nText";
995        let warnings = lint(content);
996        assert_eq!(
997            warnings.len(),
998            1,
999            "Expected 1 warning for list needing preceding blank line. Got: {warnings:?}"
1000        );
1001        assert_eq!(warnings[0].line, 2, "Warning should be on line 2 (start)");
1002        assert!(warnings[0].message.contains("preceded by blank line"));
1003
1004        // Test that warnings have fixes
1005        check_warnings_have_fixes(content);
1006
1007        let fixed_content = fix(content);
1008        let expected_fixed = "Text\n\n* Item 1\n  Content\n* Item 2\n  More content\nText";
1009        assert_eq!(
1010            fixed_content, expected_fixed,
1011            "Fix did not produce the expected output. Got:\n{fixed_content}"
1012        );
1013
1014        // Verify fix resolves the issue
1015        let warnings_after_fix = lint(&fixed_content);
1016        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1017    }
1018
1019    #[test]
1020    fn test_nested_list() {
1021        // Per markdownlint-cli: only preceding blank line warning
1022        let content = "Text\n- Item 1\n  - Nested 1\n- Item 2\nText";
1023        let warnings = lint(content);
1024        assert_eq!(
1025            warnings.len(),
1026            1,
1027            "Nested list block needs preceding blank only. Got: {warnings:?}"
1028        );
1029        assert_eq!(warnings[0].line, 2);
1030        assert!(warnings[0].message.contains("preceded by blank line"));
1031
1032        // Test that warnings have fixes
1033        check_warnings_have_fixes(content);
1034
1035        let fixed_content = fix(content);
1036        assert_eq!(fixed_content, "Text\n\n- Item 1\n  - Nested 1\n- Item 2\nText");
1037
1038        // Verify fix resolves the issue
1039        let warnings_after_fix = lint(&fixed_content);
1040        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1041    }
1042
1043    #[test]
1044    fn test_list_with_internal_blanks() {
1045        // Per markdownlint-cli: only preceding blank line warning
1046        let content = "Text\n* Item 1\n\n  More Item 1 Content\n* Item 2\nText";
1047        let warnings = lint(content);
1048        assert_eq!(
1049            warnings.len(),
1050            1,
1051            "List with internal blanks needs preceding blank only. Got: {warnings:?}"
1052        );
1053        assert_eq!(warnings[0].line, 2);
1054        assert!(warnings[0].message.contains("preceded by blank line"));
1055
1056        // Test that warnings have fixes
1057        check_warnings_have_fixes(content);
1058
1059        let fixed_content = fix(content);
1060        assert_eq!(
1061            fixed_content,
1062            "Text\n\n* Item 1\n\n  More Item 1 Content\n* Item 2\nText"
1063        );
1064
1065        // Verify fix resolves the issue
1066        let warnings_after_fix = lint(&fixed_content);
1067        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1068    }
1069
1070    #[test]
1071    fn test_ignore_code_blocks() {
1072        let content = "```\n- Not a list item\n```\nText";
1073        let warnings = lint(content);
1074        assert_eq!(warnings.len(), 0);
1075        let fixed_content = fix(content);
1076        assert_eq!(fixed_content, content);
1077    }
1078
1079    #[test]
1080    fn test_ignore_front_matter() {
1081        // Per markdownlint-cli: NO warnings - front matter is followed by list, trailing text is lazy continuation
1082        let content = "---\ntitle: Test\n---\n- List Item\nText";
1083        let warnings = lint(content);
1084        assert_eq!(
1085            warnings.len(),
1086            0,
1087            "Front matter test should have no MD032 warnings. Got: {warnings:?}"
1088        );
1089
1090        // No fixes needed since no warnings
1091        let fixed_content = fix(content);
1092        assert_eq!(fixed_content, content, "No changes when no warnings");
1093    }
1094
1095    #[test]
1096    fn test_multiple_lists() {
1097        // Our implementation treats "Text 2" and "Text 3" as lazy continuation within a single merged list block
1098        // (since both - and * are unordered markers and there's no structural separator)
1099        // markdownlint-cli sees them as separate lists with 3 warnings, but our behavior differs.
1100        // The key requirement is that the fix resolves all warnings.
1101        let content = "Text\n- List 1 Item 1\n- List 1 Item 2\nText 2\n* List 2 Item 1\nText 3";
1102        let warnings = lint(content);
1103        // At minimum we should warn about missing preceding blank for line 2
1104        assert!(
1105            !warnings.is_empty(),
1106            "Should have at least one warning for missing blank line. Got: {warnings:?}"
1107        );
1108
1109        // Test that warnings have fixes
1110        check_warnings_have_fixes(content);
1111
1112        let fixed_content = fix(content);
1113        // The fix should add blank lines before lists that need them
1114        let warnings_after_fix = lint(&fixed_content);
1115        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1116    }
1117
1118    #[test]
1119    fn test_adjacent_lists() {
1120        let content = "- List 1\n\n* List 2";
1121        let warnings = lint(content);
1122        assert_eq!(warnings.len(), 0);
1123        let fixed_content = fix(content);
1124        assert_eq!(fixed_content, content);
1125    }
1126
1127    #[test]
1128    fn test_list_in_blockquote() {
1129        // Per markdownlint-cli: 1 warning (preceding only, trailing is lazy continuation)
1130        let content = "> Quote line 1\n> - List item 1\n> - List item 2\n> Quote line 2";
1131        let warnings = lint(content);
1132        assert_eq!(
1133            warnings.len(),
1134            1,
1135            "Expected 1 warning for blockquoted list needing preceding blank. Got: {warnings:?}"
1136        );
1137        assert_eq!(warnings[0].line, 2);
1138
1139        // Test that warnings have fixes
1140        check_warnings_have_fixes(content);
1141
1142        let fixed_content = fix(content);
1143        // Fix should add blank line before list only (no trailing space per markdownlint-cli)
1144        assert_eq!(
1145            fixed_content, "> Quote line 1\n>\n> - List item 1\n> - List item 2\n> Quote line 2",
1146            "Fix for blockquoted list failed. Got:\n{fixed_content}"
1147        );
1148
1149        // Verify fix resolves the issue
1150        let warnings_after_fix = lint(&fixed_content);
1151        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1152    }
1153
1154    #[test]
1155    fn test_ordered_list() {
1156        // Per markdownlint-cli: 1 warning (preceding only)
1157        let content = "Text\n1. Item 1\n2. Item 2\nText";
1158        let warnings = lint(content);
1159        assert_eq!(warnings.len(), 1);
1160
1161        // Test that warnings have fixes
1162        check_warnings_have_fixes(content);
1163
1164        let fixed_content = fix(content);
1165        assert_eq!(fixed_content, "Text\n\n1. Item 1\n2. Item 2\nText");
1166
1167        // Verify fix resolves the issue
1168        let warnings_after_fix = lint(&fixed_content);
1169        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1170    }
1171
1172    #[test]
1173    fn test_no_double_blank_fix() {
1174        // Per markdownlint-cli: trailing text is lazy continuation, so NO warning needed
1175        let content = "Text\n\n- Item 1\n- Item 2\nText"; // Has preceding blank, trailing is lazy
1176        let warnings = lint(content);
1177        assert_eq!(
1178            warnings.len(),
1179            0,
1180            "Should have no warnings - properly preceded, trailing is lazy"
1181        );
1182
1183        let fixed_content = fix(content);
1184        assert_eq!(
1185            fixed_content, content,
1186            "No fix needed when no warnings. Got:\n{fixed_content}"
1187        );
1188
1189        let content2 = "Text\n- Item 1\n- Item 2\n\nText"; // Missing blank before
1190        let warnings2 = lint(content2);
1191        assert_eq!(warnings2.len(), 1);
1192        if !warnings2.is_empty() {
1193            assert_eq!(
1194                warnings2[0].line, 2,
1195                "Warning line for missing blank before should be the first line of the block"
1196            );
1197        }
1198
1199        // Test that warnings have fixes
1200        check_warnings_have_fixes(content2);
1201
1202        let fixed_content2 = fix(content2);
1203        assert_eq!(
1204            fixed_content2, "Text\n\n- Item 1\n- Item 2\n\nText",
1205            "Fix added extra blank before. Got:\n{fixed_content2}"
1206        );
1207    }
1208
1209    #[test]
1210    fn test_empty_input() {
1211        let content = "";
1212        let warnings = lint(content);
1213        assert_eq!(warnings.len(), 0);
1214        let fixed_content = fix(content);
1215        assert_eq!(fixed_content, "");
1216    }
1217
1218    #[test]
1219    fn test_only_list() {
1220        let content = "- Item 1\n- Item 2";
1221        let warnings = lint(content);
1222        assert_eq!(warnings.len(), 0);
1223        let fixed_content = fix(content);
1224        assert_eq!(fixed_content, content);
1225    }
1226
1227    // === COMPREHENSIVE FIX TESTS ===
1228
1229    #[test]
1230    fn test_fix_complex_nested_blockquote() {
1231        // Per markdownlint-cli: 1 warning (preceding only)
1232        let content = "> Text before\n> - Item 1\n>   - Nested item\n> - Item 2\n> Text after";
1233        let warnings = lint(content);
1234        assert_eq!(
1235            warnings.len(),
1236            1,
1237            "Should warn for missing preceding blank only. Got: {warnings:?}"
1238        );
1239
1240        // Test that warnings have fixes
1241        check_warnings_have_fixes(content);
1242
1243        let fixed_content = fix(content);
1244        // Per markdownlint-cli, blank lines in blockquotes have no trailing space
1245        let expected = "> Text before\n>\n> - Item 1\n>   - Nested item\n> - Item 2\n> Text after";
1246        assert_eq!(fixed_content, expected, "Fix should preserve blockquote structure");
1247
1248        let warnings_after_fix = lint(&fixed_content);
1249        assert_eq!(warnings_after_fix.len(), 0, "Fix should eliminate all warnings");
1250    }
1251
1252    #[test]
1253    fn test_fix_mixed_list_markers() {
1254        // Per markdownlint-cli: mixed markers may be treated as separate lists
1255        // The exact behavior depends on implementation details
1256        let content = "Text\n- Item 1\n* Item 2\n+ Item 3\nText";
1257        let warnings = lint(content);
1258        // At minimum, there should be a warning for the first list needing preceding blank
1259        assert!(
1260            !warnings.is_empty(),
1261            "Should have at least 1 warning for mixed marker list. Got: {warnings:?}"
1262        );
1263
1264        // Test that warnings have fixes
1265        check_warnings_have_fixes(content);
1266
1267        let fixed_content = fix(content);
1268        // The fix should add at least a blank line before the first list
1269        assert!(
1270            fixed_content.contains("Text\n\n-"),
1271            "Fix should add blank line before first list item"
1272        );
1273
1274        // Verify fix resolves the issue
1275        let warnings_after_fix = lint(&fixed_content);
1276        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1277    }
1278
1279    #[test]
1280    fn test_fix_ordered_list_with_different_numbers() {
1281        // Per markdownlint-cli: 1 warning (preceding only)
1282        let content = "Text\n1. First\n3. Third\n2. Second\nText";
1283        let warnings = lint(content);
1284        assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1285
1286        // Test that warnings have fixes
1287        check_warnings_have_fixes(content);
1288
1289        let fixed_content = fix(content);
1290        let expected = "Text\n\n1. First\n3. Third\n2. Second\nText";
1291        assert_eq!(
1292            fixed_content, expected,
1293            "Fix should handle ordered lists with non-sequential numbers"
1294        );
1295
1296        // Verify fix resolves the issue
1297        let warnings_after_fix = lint(&fixed_content);
1298        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1299    }
1300
1301    #[test]
1302    fn test_fix_list_with_code_blocks_inside() {
1303        // Per markdownlint-cli: 1 warning (preceding only)
1304        let content = "Text\n- Item 1\n  ```\n  code\n  ```\n- Item 2\nText";
1305        let warnings = lint(content);
1306        assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1307
1308        // Test that warnings have fixes
1309        check_warnings_have_fixes(content);
1310
1311        let fixed_content = fix(content);
1312        let expected = "Text\n\n- Item 1\n  ```\n  code\n  ```\n- Item 2\nText";
1313        assert_eq!(
1314            fixed_content, expected,
1315            "Fix should handle lists with internal code blocks"
1316        );
1317
1318        // Verify fix resolves the issue
1319        let warnings_after_fix = lint(&fixed_content);
1320        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1321    }
1322
1323    #[test]
1324    fn test_fix_deeply_nested_lists() {
1325        // Per markdownlint-cli: 1 warning (preceding only)
1326        let content = "Text\n- Level 1\n  - Level 2\n    - Level 3\n      - Level 4\n- Back to Level 1\nText";
1327        let warnings = lint(content);
1328        assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1329
1330        // Test that warnings have fixes
1331        check_warnings_have_fixes(content);
1332
1333        let fixed_content = fix(content);
1334        let expected = "Text\n\n- Level 1\n  - Level 2\n    - Level 3\n      - Level 4\n- Back to Level 1\nText";
1335        assert_eq!(fixed_content, expected, "Fix should handle deeply nested lists");
1336
1337        // Verify fix resolves the issue
1338        let warnings_after_fix = lint(&fixed_content);
1339        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1340    }
1341
1342    #[test]
1343    fn test_fix_list_with_multiline_items() {
1344        // Per markdownlint-cli: trailing "Text" at indent=0 is lazy continuation
1345        // Only the preceding blank line is required
1346        let content = "Text\n- Item 1\n  continues here\n  and here\n- Item 2\n  also continues\nText";
1347        let warnings = lint(content);
1348        assert_eq!(
1349            warnings.len(),
1350            1,
1351            "Should only warn for missing blank before list (trailing text is lazy continuation)"
1352        );
1353
1354        // Test that warnings have fixes
1355        check_warnings_have_fixes(content);
1356
1357        let fixed_content = fix(content);
1358        let expected = "Text\n\n- Item 1\n  continues here\n  and here\n- Item 2\n  also continues\nText";
1359        assert_eq!(fixed_content, expected, "Fix should add blank before list only");
1360
1361        // Verify fix resolves the issue
1362        let warnings_after_fix = lint(&fixed_content);
1363        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1364    }
1365
1366    #[test]
1367    fn test_fix_list_at_document_boundaries() {
1368        // List at very start
1369        let content1 = "- Item 1\n- Item 2";
1370        let warnings1 = lint(content1);
1371        assert_eq!(
1372            warnings1.len(),
1373            0,
1374            "List at document start should not need blank before"
1375        );
1376        let fixed1 = fix(content1);
1377        assert_eq!(fixed1, content1, "No fix needed for list at start");
1378
1379        // List at very end
1380        let content2 = "Text\n- Item 1\n- Item 2";
1381        let warnings2 = lint(content2);
1382        assert_eq!(warnings2.len(), 1, "List at document end should need blank before");
1383        check_warnings_have_fixes(content2);
1384        let fixed2 = fix(content2);
1385        assert_eq!(
1386            fixed2, "Text\n\n- Item 1\n- Item 2",
1387            "Should add blank before list at end"
1388        );
1389    }
1390
1391    #[test]
1392    fn test_fix_preserves_existing_blank_lines() {
1393        let content = "Text\n\n\n- Item 1\n- Item 2\n\n\nText";
1394        let warnings = lint(content);
1395        assert_eq!(warnings.len(), 0, "Multiple blank lines should be preserved");
1396        let fixed_content = fix(content);
1397        assert_eq!(fixed_content, content, "Fix should not modify already correct content");
1398    }
1399
1400    #[test]
1401    fn test_fix_handles_tabs_and_spaces() {
1402        // Tab at line start = 4 spaces = indented code (not a list item per CommonMark)
1403        // Only the space-indented line is a real list item
1404        let content = "Text\n\t- Item with tab\n  - Item with spaces\nText";
1405        let warnings = lint(content);
1406        // Per markdownlint-cli: only line 3 (space-indented) is a list needing blanks
1407        assert!(!warnings.is_empty(), "Should warn for missing blank before list");
1408
1409        // Test that warnings have fixes
1410        check_warnings_have_fixes(content);
1411
1412        let fixed_content = fix(content);
1413        // Add blank before the actual list item (line 3), not the tab-indented code (line 2)
1414        // Trailing text is lazy continuation, so no blank after
1415        let expected = "Text\n\t- Item with tab\n\n  - Item with spaces\nText";
1416        assert_eq!(fixed_content, expected, "Fix should add blank before list item");
1417
1418        // Verify fix resolves the issue
1419        let warnings_after_fix = lint(&fixed_content);
1420        assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1421    }
1422
1423    #[test]
1424    fn test_fix_warning_objects_have_correct_ranges() {
1425        // Per markdownlint-cli: trailing text is lazy continuation, only 1 warning
1426        let content = "Text\n- Item 1\n- Item 2\nText";
1427        let warnings = lint(content);
1428        assert_eq!(warnings.len(), 1, "Only preceding blank warning expected");
1429
1430        // Check that each warning has a fix with a valid range
1431        for warning in &warnings {
1432            assert!(warning.fix.is_some(), "Warning should have fix");
1433            let fix = warning.fix.as_ref().unwrap();
1434            assert!(fix.range.start <= fix.range.end, "Fix range should be valid");
1435            assert!(
1436                !fix.replacement.is_empty() || fix.range.start == fix.range.end,
1437                "Fix should have replacement or be insertion"
1438            );
1439        }
1440    }
1441
1442    #[test]
1443    fn test_fix_idempotent() {
1444        // Per markdownlint-cli: trailing text is lazy continuation
1445        let content = "Text\n- Item 1\n- Item 2\nText";
1446
1447        // Apply fix once - only adds blank before (trailing text is lazy continuation)
1448        let fixed_once = fix(content);
1449        assert_eq!(fixed_once, "Text\n\n- Item 1\n- Item 2\nText");
1450
1451        // Apply fix again - should be unchanged
1452        let fixed_twice = fix(&fixed_once);
1453        assert_eq!(fixed_twice, fixed_once, "Fix should be idempotent");
1454
1455        // No warnings after fix
1456        let warnings_after_fix = lint(&fixed_once);
1457        assert_eq!(warnings_after_fix.len(), 0, "No warnings should remain after fix");
1458    }
1459
1460    #[test]
1461    fn test_fix_with_normalized_line_endings() {
1462        // In production, content is normalized to LF at I/O boundary
1463        // Unit tests should use LF input to reflect actual runtime behavior
1464        // Per markdownlint-cli: trailing text is lazy continuation, only 1 warning
1465        let content = "Text\n- Item 1\n- Item 2\nText";
1466        let warnings = lint(content);
1467        assert_eq!(warnings.len(), 1, "Should detect missing blank before list");
1468
1469        // Test that warnings have fixes
1470        check_warnings_have_fixes(content);
1471
1472        let fixed_content = fix(content);
1473        // Only adds blank before (trailing text is lazy continuation)
1474        let expected = "Text\n\n- Item 1\n- Item 2\nText";
1475        assert_eq!(fixed_content, expected, "Fix should work with normalized LF content");
1476    }
1477
1478    #[test]
1479    fn test_fix_preserves_final_newline() {
1480        // Per markdownlint-cli: trailing text is lazy continuation
1481        // Test with final newline
1482        let content_with_newline = "Text\n- Item 1\n- Item 2\nText\n";
1483        let fixed_with_newline = fix(content_with_newline);
1484        assert!(
1485            fixed_with_newline.ends_with('\n'),
1486            "Fix should preserve final newline when present"
1487        );
1488        // Only adds blank before (trailing text is lazy continuation)
1489        assert_eq!(fixed_with_newline, "Text\n\n- Item 1\n- Item 2\nText\n");
1490
1491        // Test without final newline
1492        let content_without_newline = "Text\n- Item 1\n- Item 2\nText";
1493        let fixed_without_newline = fix(content_without_newline);
1494        assert!(
1495            !fixed_without_newline.ends_with('\n'),
1496            "Fix should not add final newline when not present"
1497        );
1498        // Only adds blank before (trailing text is lazy continuation)
1499        assert_eq!(fixed_without_newline, "Text\n\n- Item 1\n- Item 2\nText");
1500    }
1501
1502    #[test]
1503    fn test_fix_multiline_list_items_no_indent() {
1504        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";
1505
1506        let warnings = lint(content);
1507        // Should only warn about missing blank lines around the entire list, not between items
1508        assert_eq!(
1509            warnings.len(),
1510            0,
1511            "Should not warn for properly formatted list with multi-line items. Got: {warnings:?}"
1512        );
1513
1514        let fixed_content = fix(content);
1515        // Should not change the content since it's already correct
1516        assert_eq!(
1517            fixed_content, content,
1518            "Should not modify correctly formatted multi-line list items"
1519        );
1520    }
1521
1522    #[test]
1523    fn test_nested_list_with_lazy_continuation() {
1524        // Issue #188: Nested list following a lazy continuation line should not require blank lines
1525        // This matches markdownlint-cli behavior which does NOT warn on this pattern
1526        //
1527        // The key element is line 6 (`!=`), ternary...) which is a lazy continuation of line 5.
1528        // Line 6 contains `||` inside code spans, which should NOT be detected as a table separator.
1529        let content = r#"# Test
1530
1531- **Token Dispatch (Phase 3.2)**: COMPLETE. Extracts tokens from both:
1532  1. Switch/case dispatcher statements (original Phase 3.2)
1533  2. Inline conditionals - if/else, bitwise checks (`&`, `|`), comparison (`==`,
1534`!=`), ternary operators (`?:`), macros (`ISTOK`, `ISUNSET`), compound conditions (`&&`, `||`) (Phase 3.2.1)
1535     - 30 explicit tokens extracted, 23 dispatcher rules with embedded token
1536       references"#;
1537
1538        let warnings = lint(content);
1539        // No MD032 warnings should be generated - this is a valid nested list structure
1540        // with lazy continuation (line 6 has no indent but continues line 5)
1541        let md032_warnings: Vec<_> = warnings
1542            .iter()
1543            .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1544            .collect();
1545        assert_eq!(
1546            md032_warnings.len(),
1547            0,
1548            "Should not warn for nested list with lazy continuation. Got: {md032_warnings:?}"
1549        );
1550    }
1551
1552    #[test]
1553    fn test_pipes_in_code_spans_not_detected_as_table() {
1554        // Pipes inside code spans should NOT break lists
1555        let content = r#"# Test
1556
1557- Item with `a | b` inline code
1558  - Nested item should work
1559
1560"#;
1561
1562        let warnings = lint(content);
1563        let md032_warnings: Vec<_> = warnings
1564            .iter()
1565            .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1566            .collect();
1567        assert_eq!(
1568            md032_warnings.len(),
1569            0,
1570            "Pipes in code spans should not break lists. Got: {md032_warnings:?}"
1571        );
1572    }
1573
1574    #[test]
1575    fn test_multiple_code_spans_with_pipes() {
1576        // Multiple code spans with pipes should not break lists
1577        let content = r#"# Test
1578
1579- Item with `a | b` and `c || d` operators
1580  - Nested item should work
1581
1582"#;
1583
1584        let warnings = lint(content);
1585        let md032_warnings: Vec<_> = warnings
1586            .iter()
1587            .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1588            .collect();
1589        assert_eq!(
1590            md032_warnings.len(),
1591            0,
1592            "Multiple code spans with pipes should not break lists. Got: {md032_warnings:?}"
1593        );
1594    }
1595
1596    #[test]
1597    fn test_actual_table_breaks_list() {
1598        // An actual table between list items SHOULD break the list
1599        let content = r#"# Test
1600
1601- Item before table
1602
1603| Col1 | Col2 |
1604|------|------|
1605| A    | B    |
1606
1607- Item after table
1608
1609"#;
1610
1611        let warnings = lint(content);
1612        // There should be NO MD032 warnings because both lists are properly surrounded by blank lines
1613        let md032_warnings: Vec<_> = warnings
1614            .iter()
1615            .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1616            .collect();
1617        assert_eq!(
1618            md032_warnings.len(),
1619            0,
1620            "Both lists should be properly separated by blank lines. Got: {md032_warnings:?}"
1621        );
1622    }
1623
1624    #[test]
1625    fn test_thematic_break_not_lazy_continuation() {
1626        // Thematic breaks (HRs) cannot be lazy continuation per CommonMark
1627        // List followed by HR without blank line should warn
1628        let content = r#"- Item 1
1629- Item 2
1630***
1631
1632More text.
1633"#;
1634
1635        let warnings = lint(content);
1636        let md032_warnings: Vec<_> = warnings
1637            .iter()
1638            .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1639            .collect();
1640        assert_eq!(
1641            md032_warnings.len(),
1642            1,
1643            "Should warn for list not followed by blank line before thematic break. Got: {md032_warnings:?}"
1644        );
1645        assert!(
1646            md032_warnings[0].message.contains("followed by blank line"),
1647            "Warning should be about missing blank after list"
1648        );
1649    }
1650
1651    #[test]
1652    fn test_thematic_break_with_blank_line() {
1653        // List followed by blank line then HR should NOT warn
1654        let content = r#"- Item 1
1655- Item 2
1656
1657***
1658
1659More text.
1660"#;
1661
1662        let warnings = lint(content);
1663        let md032_warnings: Vec<_> = warnings
1664            .iter()
1665            .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1666            .collect();
1667        assert_eq!(
1668            md032_warnings.len(),
1669            0,
1670            "Should not warn when list is properly followed by blank line. Got: {md032_warnings:?}"
1671        );
1672    }
1673
1674    #[test]
1675    fn test_various_thematic_break_styles() {
1676        // Test different HR styles are all recognized
1677        // Note: Spaced styles like "- - -" and "* * *" are excluded because they start
1678        // with list markers ("- " or "* ") which get parsed as list items by the
1679        // upstream CommonMark parser. That's a separate parsing issue.
1680        for hr in ["---", "***", "___"] {
1681            let content = format!(
1682                r#"- Item 1
1683- Item 2
1684{hr}
1685
1686More text.
1687"#
1688            );
1689
1690            let warnings = lint(&content);
1691            let md032_warnings: Vec<_> = warnings
1692                .iter()
1693                .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1694                .collect();
1695            assert_eq!(
1696                md032_warnings.len(),
1697                1,
1698                "Should warn for HR style '{hr}' without blank line. Got: {md032_warnings:?}"
1699            );
1700        }
1701    }
1702
1703    // === LAZY CONTINUATION TESTS ===
1704
1705    fn lint_with_config(content: &str, config: MD032Config) -> Vec<LintWarning> {
1706        let rule = MD032BlanksAroundLists::from_config_struct(config);
1707        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1708        rule.check(&ctx).expect("Lint check failed")
1709    }
1710
1711    fn fix_with_config(content: &str, config: MD032Config) -> String {
1712        let rule = MD032BlanksAroundLists::from_config_struct(config);
1713        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1714        rule.fix(&ctx).expect("Lint fix failed")
1715    }
1716
1717    #[test]
1718    fn test_lazy_continuation_allowed_by_default() {
1719        // Default behavior: lazy continuation is allowed, no warning
1720        let content = "# Heading\n\n1. List\nSome text.";
1721        let warnings = lint(content);
1722        assert_eq!(
1723            warnings.len(),
1724            0,
1725            "Default behavior should allow lazy continuation. Got: {warnings:?}"
1726        );
1727    }
1728
1729    #[test]
1730    fn test_lazy_continuation_disallowed() {
1731        // With allow_lazy_continuation = false, should warn
1732        let content = "# Heading\n\n1. List\nSome text.";
1733        let config = MD032Config {
1734            allow_lazy_continuation: false,
1735        };
1736        let warnings = lint_with_config(content, config);
1737        assert_eq!(
1738            warnings.len(),
1739            1,
1740            "Should warn when lazy continuation is disallowed. Got: {warnings:?}"
1741        );
1742        assert!(
1743            warnings[0].message.contains("followed by blank line"),
1744            "Warning message should mention blank line"
1745        );
1746    }
1747
1748    #[test]
1749    fn test_lazy_continuation_fix() {
1750        // With allow_lazy_continuation = false, fix should insert blank line
1751        let content = "# Heading\n\n1. List\nSome text.";
1752        let config = MD032Config {
1753            allow_lazy_continuation: false,
1754        };
1755        let fixed = fix_with_config(content, config.clone());
1756        assert_eq!(
1757            fixed, "# Heading\n\n1. List\n\nSome text.",
1758            "Fix should insert blank line before lazy continuation"
1759        );
1760
1761        // Verify no warnings after fix
1762        let warnings_after = lint_with_config(&fixed, config);
1763        assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
1764    }
1765
1766    #[test]
1767    fn test_lazy_continuation_multiple_lines() {
1768        // Multiple lazy continuation lines
1769        let content = "- Item 1\nLine 2\nLine 3";
1770        let config = MD032Config {
1771            allow_lazy_continuation: false,
1772        };
1773        let warnings = lint_with_config(content, config.clone());
1774        assert_eq!(
1775            warnings.len(),
1776            1,
1777            "Should warn for lazy continuation. Got: {warnings:?}"
1778        );
1779
1780        let fixed = fix_with_config(content, config.clone());
1781        assert_eq!(
1782            fixed, "- Item 1\n\nLine 2\nLine 3",
1783            "Fix should insert blank line after list"
1784        );
1785
1786        // Verify no warnings after fix
1787        let warnings_after = lint_with_config(&fixed, config);
1788        assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
1789    }
1790
1791    #[test]
1792    fn test_lazy_continuation_with_indented_content() {
1793        // Indented content is valid continuation, not lazy continuation
1794        let content = "- Item 1\n  Indented content\nLazy text";
1795        let config = MD032Config {
1796            allow_lazy_continuation: false,
1797        };
1798        let warnings = lint_with_config(content, config);
1799        assert_eq!(
1800            warnings.len(),
1801            1,
1802            "Should warn for lazy text after indented content. Got: {warnings:?}"
1803        );
1804    }
1805
1806    #[test]
1807    fn test_lazy_continuation_properly_separated() {
1808        // With proper blank line, no warning even with strict config
1809        let content = "- Item 1\n\nSome text.";
1810        let config = MD032Config {
1811            allow_lazy_continuation: false,
1812        };
1813        let warnings = lint_with_config(content, config);
1814        assert_eq!(
1815            warnings.len(),
1816            0,
1817            "Should not warn when list is properly followed by blank line. Got: {warnings:?}"
1818        );
1819    }
1820
1821    // ==================== Comprehensive edge case tests ====================
1822
1823    #[test]
1824    fn test_lazy_continuation_ordered_list_parenthesis_marker() {
1825        // Ordered list with parenthesis marker (1) instead of period
1826        let content = "1) First item\nLazy continuation";
1827        let config = MD032Config {
1828            allow_lazy_continuation: false,
1829        };
1830        let warnings = lint_with_config(content, config.clone());
1831        assert_eq!(
1832            warnings.len(),
1833            1,
1834            "Should warn for lazy continuation with parenthesis marker"
1835        );
1836
1837        let fixed = fix_with_config(content, config);
1838        assert_eq!(fixed, "1) First item\n\nLazy continuation");
1839    }
1840
1841    #[test]
1842    fn test_lazy_continuation_followed_by_another_list() {
1843        // Lazy continuation text followed by another list item
1844        // In CommonMark, "Some text" becomes part of Item 1's lazy continuation,
1845        // and "- Item 2" starts a new list item within the same list.
1846        // With allow_lazy_continuation = false, we warn about lazy continuation
1847        // even within valid list structure (issue #295).
1848        let content = "- Item 1\nSome text\n- Item 2";
1849        let config = MD032Config {
1850            allow_lazy_continuation: false,
1851        };
1852        let warnings = lint_with_config(content, config);
1853        // Should warn about lazy continuation on line 2
1854        assert_eq!(
1855            warnings.len(),
1856            1,
1857            "Should warn about lazy continuation within list. Got: {warnings:?}"
1858        );
1859        assert!(
1860            warnings[0].message.contains("Lazy continuation"),
1861            "Warning should be about lazy continuation"
1862        );
1863        assert_eq!(warnings[0].line, 2, "Warning should be on line 2");
1864    }
1865
1866    #[test]
1867    fn test_lazy_continuation_multiple_in_document() {
1868        // Loose list (blank line between items) with lazy continuation
1869        // In CommonMark, this is a single loose list, not two separate lists.
1870        // "Lazy 1" is lazy continuation of Item 1
1871        // "Lazy 2" is lazy continuation of Item 2, and list ends without blank line
1872        let content = "- Item 1\nLazy 1\n\n- Item 2\nLazy 2";
1873        let config = MD032Config {
1874            allow_lazy_continuation: false,
1875        };
1876        let warnings = lint_with_config(content, config.clone());
1877        // Expect 2 warnings:
1878        // 1. Line 2: lazy continuation within list
1879        // 2. Line 4: list not followed by blank (second item ends at EOF)
1880        assert_eq!(
1881            warnings.len(),
1882            2,
1883            "Should warn for both lazy continuations and list end. Got: {warnings:?}"
1884        );
1885
1886        let fixed = fix_with_config(content, config.clone());
1887        let warnings_after = lint_with_config(&fixed, config);
1888        // Within-list lazy continuation has no auto-fix (would change document semantics),
1889        // so 1 warning remains after fixing the end-of-list issue
1890        assert_eq!(
1891            warnings_after.len(),
1892            1,
1893            "Within-list lazy continuation warning should remain (no auto-fix)"
1894        );
1895    }
1896
1897    #[test]
1898    fn test_lazy_continuation_end_of_document_no_newline() {
1899        // Lazy continuation at end of document without trailing newline
1900        let content = "- Item\nNo trailing newline";
1901        let config = MD032Config {
1902            allow_lazy_continuation: false,
1903        };
1904        let warnings = lint_with_config(content, config.clone());
1905        assert_eq!(warnings.len(), 1, "Should warn even at end of document");
1906
1907        let fixed = fix_with_config(content, config);
1908        assert_eq!(fixed, "- Item\n\nNo trailing newline");
1909    }
1910
1911    #[test]
1912    fn test_lazy_continuation_thematic_break_still_needs_blank() {
1913        // Thematic break after list without blank line still triggers MD032
1914        // The thematic break ends the list, but MD032 requires blank line separation
1915        let content = "- Item 1\n---";
1916        let config = MD032Config {
1917            allow_lazy_continuation: false,
1918        };
1919        let warnings = lint_with_config(content, config.clone());
1920        // Should warn because list needs blank line before thematic break
1921        assert_eq!(
1922            warnings.len(),
1923            1,
1924            "List should need blank line before thematic break. Got: {warnings:?}"
1925        );
1926
1927        // Verify fix adds blank line
1928        let fixed = fix_with_config(content, config);
1929        assert_eq!(fixed, "- Item 1\n\n---");
1930    }
1931
1932    #[test]
1933    fn test_lazy_continuation_heading_not_flagged() {
1934        // Heading after list should NOT be flagged as lazy continuation
1935        // (headings end lists per CommonMark)
1936        let content = "- Item 1\n# Heading";
1937        let config = MD032Config {
1938            allow_lazy_continuation: false,
1939        };
1940        let warnings = lint_with_config(content, config);
1941        // The warning should be about missing blank line, not lazy continuation
1942        // But headings interrupt lists, so the list ends at Item 1
1943        assert!(
1944            warnings.iter().all(|w| !w.message.contains("lazy")),
1945            "Heading should not trigger lazy continuation warning"
1946        );
1947    }
1948
1949    #[test]
1950    fn test_lazy_continuation_mixed_list_types() {
1951        // Mixed ordered and unordered with lazy continuation
1952        let content = "- Unordered\n1. Ordered\nLazy text";
1953        let config = MD032Config {
1954            allow_lazy_continuation: false,
1955        };
1956        let warnings = lint_with_config(content, config.clone());
1957        assert!(!warnings.is_empty(), "Should warn about structure issues");
1958    }
1959
1960    #[test]
1961    fn test_lazy_continuation_deep_nesting() {
1962        // Deep nested list with lazy continuation at end
1963        let content = "- Level 1\n  - Level 2\n    - Level 3\nLazy at root";
1964        let config = MD032Config {
1965            allow_lazy_continuation: false,
1966        };
1967        let warnings = lint_with_config(content, config.clone());
1968        assert!(
1969            !warnings.is_empty(),
1970            "Should warn about lazy continuation after nested list"
1971        );
1972
1973        let fixed = fix_with_config(content, config.clone());
1974        let warnings_after = lint_with_config(&fixed, config);
1975        assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
1976    }
1977
1978    #[test]
1979    fn test_lazy_continuation_with_emphasis_in_text() {
1980        // Lazy continuation containing emphasis markers
1981        let content = "- Item\n*emphasized* continuation";
1982        let config = MD032Config {
1983            allow_lazy_continuation: false,
1984        };
1985        let warnings = lint_with_config(content, config.clone());
1986        assert_eq!(warnings.len(), 1, "Should warn even with emphasis in continuation");
1987
1988        let fixed = fix_with_config(content, config);
1989        assert_eq!(fixed, "- Item\n\n*emphasized* continuation");
1990    }
1991
1992    #[test]
1993    fn test_lazy_continuation_with_code_span() {
1994        // Lazy continuation containing code span
1995        let content = "- Item\n`code` continuation";
1996        let config = MD032Config {
1997            allow_lazy_continuation: false,
1998        };
1999        let warnings = lint_with_config(content, config.clone());
2000        assert_eq!(warnings.len(), 1, "Should warn even with code span in continuation");
2001
2002        let fixed = fix_with_config(content, config);
2003        assert_eq!(fixed, "- Item\n\n`code` continuation");
2004    }
2005
2006    // =========================================================================
2007    // Issue #295: Lazy continuation after nested sublists
2008    // These tests verify detection of lazy continuation at outer indent level
2009    // after nested sublists, followed by another list item.
2010    // =========================================================================
2011
2012    #[test]
2013    fn test_issue295_case1_nested_bullets_then_continuation_then_item() {
2014        // Outer numbered item with nested bullets, lazy continuation, then next item
2015        // The lazy continuation "A new Chat..." appears at column 1, not indented
2016        let content = r#"1. Create a new Chat conversation:
2017   - On the sidebar, select **New Chat**.
2018   - In the box, type `/new`.
2019   A new Chat conversation replaces the previous one.
20201. Under the Chat text box, turn off the toggle."#;
2021        let config = MD032Config {
2022            allow_lazy_continuation: false,
2023        };
2024        let warnings = lint_with_config(content, config);
2025        // Should warn about line 4 "A new Chat..." which is lazy continuation
2026        let lazy_warnings: Vec<_> = warnings
2027            .iter()
2028            .filter(|w| w.message.contains("Lazy continuation"))
2029            .collect();
2030        assert!(
2031            !lazy_warnings.is_empty(),
2032            "Should detect lazy continuation after nested bullets. Got: {warnings:?}"
2033        );
2034        assert!(
2035            lazy_warnings.iter().any(|w| w.line == 4),
2036            "Should warn on line 4. Got: {lazy_warnings:?}"
2037        );
2038    }
2039
2040    #[test]
2041    fn test_issue295_case3_code_span_starts_lazy_continuation() {
2042        // Code span at the START of lazy continuation after nested bullets
2043        // This is tricky because pulldown-cmark emits Code event, not Text
2044        let content = r#"- `field`: Is the specific key:
2045  - `password`: Accesses the password.
2046  - `api_key`: Accesses the api_key.
2047  `token`: Specifies which ID token to use.
2048- `version_id`: Is the unique identifier."#;
2049        let config = MD032Config {
2050            allow_lazy_continuation: false,
2051        };
2052        let warnings = lint_with_config(content, config);
2053        // Should warn about line 4 "`token`:..." which starts with code span
2054        let lazy_warnings: Vec<_> = warnings
2055            .iter()
2056            .filter(|w| w.message.contains("Lazy continuation"))
2057            .collect();
2058        assert!(
2059            !lazy_warnings.is_empty(),
2060            "Should detect lazy continuation starting with code span. Got: {warnings:?}"
2061        );
2062        assert!(
2063            lazy_warnings.iter().any(|w| w.line == 4),
2064            "Should warn on line 4 (code span start). Got: {lazy_warnings:?}"
2065        );
2066    }
2067
2068    #[test]
2069    fn test_issue295_case4_deep_nesting_with_continuation_then_item() {
2070        // Multiple nesting levels, lazy continuation, then next outer item
2071        let content = r#"- Check out the branch, and test locally.
2072  - If the MR requires significant modifications:
2073    - **Skip local testing** and review instead.
2074    - **Request verification** from the author.
2075    - **Identify the minimal change** needed.
2076  Your testing might result in opportunities.
2077- If you don't understand, _say so_."#;
2078        let config = MD032Config {
2079            allow_lazy_continuation: false,
2080        };
2081        let warnings = lint_with_config(content, config);
2082        // Should warn about line 6 "Your testing..." which is lazy continuation
2083        let lazy_warnings: Vec<_> = warnings
2084            .iter()
2085            .filter(|w| w.message.contains("Lazy continuation"))
2086            .collect();
2087        assert!(
2088            !lazy_warnings.is_empty(),
2089            "Should detect lazy continuation after deep nesting. Got: {warnings:?}"
2090        );
2091        assert!(
2092            lazy_warnings.iter().any(|w| w.line == 6),
2093            "Should warn on line 6. Got: {lazy_warnings:?}"
2094        );
2095    }
2096
2097    #[test]
2098    fn test_issue295_ordered_list_nested_bullets_continuation() {
2099        // Ordered list with nested bullets, continuation at outer level, then next item
2100        // This is the exact pattern from debug_test6.md
2101        let content = r#"# Test
2102
21031. First item.
2104   - Nested A.
2105   - Nested B.
2106   Continuation at outer level.
21071. Second item."#;
2108        let config = MD032Config {
2109            allow_lazy_continuation: false,
2110        };
2111        let warnings = lint_with_config(content, config);
2112        // Should warn about line 6 "Continuation at outer level."
2113        let lazy_warnings: Vec<_> = warnings
2114            .iter()
2115            .filter(|w| w.message.contains("Lazy continuation"))
2116            .collect();
2117        assert!(
2118            !lazy_warnings.is_empty(),
2119            "Should detect lazy continuation at outer level after nested. Got: {warnings:?}"
2120        );
2121        // Line 6 = "   Continuation at outer level." (3 spaces indent, but needs 4 for proper continuation)
2122        assert!(
2123            lazy_warnings.iter().any(|w| w.line == 6),
2124            "Should warn on line 6. Got: {lazy_warnings:?}"
2125        );
2126    }
2127
2128    #[test]
2129    fn test_issue295_multiple_lazy_lines_after_nested() {
2130        // Multiple lazy continuation lines after nested sublist
2131        let content = r#"1. The device client receives a response.
2132   - Those defined by OAuth Framework.
2133   - Those specific to device authorization.
2134   Those error responses are described below.
2135   For more information on each response,
2136   see the documentation.
21371. Next step in the process."#;
2138        let config = MD032Config {
2139            allow_lazy_continuation: false,
2140        };
2141        let warnings = lint_with_config(content, config);
2142        // Should warn about lines 4, 5, 6 (all lazy continuation)
2143        let lazy_warnings: Vec<_> = warnings
2144            .iter()
2145            .filter(|w| w.message.contains("Lazy continuation"))
2146            .collect();
2147        assert!(
2148            lazy_warnings.len() >= 3,
2149            "Should detect multiple lazy continuation lines. Got {} warnings: {lazy_warnings:?}",
2150            lazy_warnings.len()
2151        );
2152    }
2153
2154    #[test]
2155    fn test_issue295_properly_indented_not_lazy() {
2156        // Properly indented continuation after nested sublist should NOT warn
2157        let content = r#"1. First item.
2158   - Nested A.
2159   - Nested B.
2160
2161   Properly indented continuation.
21621. Second item."#;
2163        let config = MD032Config {
2164            allow_lazy_continuation: false,
2165        };
2166        let warnings = lint_with_config(content, config);
2167        // With blank line before, this is a new paragraph, not lazy continuation
2168        let lazy_warnings: Vec<_> = warnings
2169            .iter()
2170            .filter(|w| w.message.contains("Lazy continuation"))
2171            .collect();
2172        assert_eq!(
2173            lazy_warnings.len(),
2174            0,
2175            "Should NOT warn when blank line separates continuation. Got: {lazy_warnings:?}"
2176        );
2177    }
2178
2179    // =========================================================================
2180    // HTML Comment Transparency Tests
2181    // HTML comments should be "transparent" for blank line checking,
2182    // matching markdownlint-cli behavior.
2183    // =========================================================================
2184
2185    #[test]
2186    fn test_html_comment_before_list_with_preceding_blank() {
2187        // Blank line before HTML comment = list is properly separated
2188        // markdownlint-cli does NOT warn here
2189        let content = "Some text.\n\n<!-- comment -->\n- List item";
2190        let warnings = lint(content);
2191        assert_eq!(
2192            warnings.len(),
2193            0,
2194            "Should not warn when blank line exists before HTML comment. Got: {warnings:?}"
2195        );
2196    }
2197
2198    #[test]
2199    fn test_html_comment_after_list_with_following_blank() {
2200        // Blank line after HTML comment = list is properly separated
2201        let content = "- List item\n<!-- comment -->\n\nSome text.";
2202        let warnings = lint(content);
2203        assert_eq!(
2204            warnings.len(),
2205            0,
2206            "Should not warn when blank line exists after HTML comment. Got: {warnings:?}"
2207        );
2208    }
2209
2210    #[test]
2211    fn test_list_inside_html_comment_ignored() {
2212        // Lists entirely inside HTML comments should not be analyzed
2213        let content = "<!--\n1. First\n2. Second\n3. Third\n-->";
2214        let warnings = lint(content);
2215        assert_eq!(
2216            warnings.len(),
2217            0,
2218            "Should not analyze lists inside HTML comments. Got: {warnings:?}"
2219        );
2220    }
2221
2222    #[test]
2223    fn test_multiline_html_comment_before_list() {
2224        // Multi-line HTML comment should be transparent
2225        let content = "Text\n\n<!--\nThis is a\nmulti-line\ncomment\n-->\n- Item";
2226        let warnings = lint(content);
2227        assert_eq!(
2228            warnings.len(),
2229            0,
2230            "Multi-line HTML comment should be transparent. Got: {warnings:?}"
2231        );
2232    }
2233
2234    #[test]
2235    fn test_no_blank_before_html_comment_still_warns() {
2236        // No blank line anywhere = should still warn
2237        let content = "Some text.\n<!-- comment -->\n- List item";
2238        let warnings = lint(content);
2239        assert_eq!(
2240            warnings.len(),
2241            1,
2242            "Should warn when no blank line exists (even with HTML comment). Got: {warnings:?}"
2243        );
2244        assert!(
2245            warnings[0].message.contains("preceded by blank line"),
2246            "Should be 'preceded by blank line' warning"
2247        );
2248    }
2249
2250    #[test]
2251    fn test_no_blank_after_html_comment_no_warn_lazy_continuation() {
2252        // Text immediately after list (through HTML comment) is lazy continuation
2253        // markdownlint-cli does NOT warn here - the text becomes part of the list
2254        let content = "- List item\n<!-- comment -->\nSome text.";
2255        let warnings = lint(content);
2256        assert_eq!(
2257            warnings.len(),
2258            0,
2259            "Should not warn - text after comment becomes lazy continuation. Got: {warnings:?}"
2260        );
2261    }
2262
2263    #[test]
2264    fn test_list_followed_by_heading_through_comment_should_warn() {
2265        // Heading cannot be lazy continuation, so this SHOULD warn
2266        let content = "- List item\n<!-- comment -->\n# Heading";
2267        let warnings = lint(content);
2268        // Headings after lists through HTML comments should be handled gracefully
2269        // The blank line check should look past the comment
2270        assert!(
2271            warnings.len() <= 1,
2272            "Should handle heading after comment gracefully. Got: {warnings:?}"
2273        );
2274    }
2275
2276    #[test]
2277    fn test_html_comment_between_list_and_text_both_directions() {
2278        // Blank line on both sides through HTML comment
2279        let content = "Text before.\n\n<!-- comment -->\n- Item 1\n- Item 2\n<!-- another -->\n\nText after.";
2280        let warnings = lint(content);
2281        assert_eq!(
2282            warnings.len(),
2283            0,
2284            "Should not warn with proper separation through comments. Got: {warnings:?}"
2285        );
2286    }
2287
2288    #[test]
2289    fn test_html_comment_fix_does_not_insert_unnecessary_blank() {
2290        // Fix should not add blank line when separation already exists through comment
2291        let content = "Text.\n\n<!-- comment -->\n- Item";
2292        let fixed = fix(content);
2293        assert_eq!(fixed, content, "Fix should not modify already-correct content");
2294    }
2295
2296    #[test]
2297    fn test_html_comment_fix_adds_blank_when_needed() {
2298        // Fix should add blank line when no separation exists
2299        // The blank line is added immediately before the list (after the comment)
2300        let content = "Text.\n<!-- comment -->\n- Item";
2301        let fixed = fix(content);
2302        assert!(
2303            fixed.contains("<!-- comment -->\n\n- Item"),
2304            "Fix should add blank line before list. Got: {fixed}"
2305        );
2306    }
2307
2308    #[test]
2309    fn test_ordered_list_inside_html_comment() {
2310        // Ordered list with non-1 start inside comment should not warn
2311        let content = "<!--\n3. Starting at 3\n4. Next item\n-->";
2312        let warnings = lint(content);
2313        assert_eq!(
2314            warnings.len(),
2315            0,
2316            "Should not warn about ordered list inside HTML comment. Got: {warnings:?}"
2317        );
2318    }
2319
2320    // =========================================================================
2321    // Blockquote Boundary Transition Tests
2322    // When a list inside a blockquote ends and the next line exits the blockquote,
2323    // no blank line is needed - the blockquote boundary provides semantic separation.
2324    // =========================================================================
2325
2326    #[test]
2327    fn test_blockquote_list_exit_no_warning() {
2328        // Blockquote list followed by outer content - no blank line needed
2329        let content = "- outer item\n  > - blockquote list 1\n  > - blockquote list 2\n- next outer item";
2330        let warnings = lint(content);
2331        assert_eq!(
2332            warnings.len(),
2333            0,
2334            "Should not warn when exiting blockquote. Got: {warnings:?}"
2335        );
2336    }
2337
2338    #[test]
2339    fn test_nested_blockquote_list_exit() {
2340        // Nested blockquote list - exiting should not require blank line
2341        let content = "- outer\n  - nested\n    > - bq list 1\n    > - bq list 2\n  - back to nested\n- outer again";
2342        let warnings = lint(content);
2343        assert_eq!(
2344            warnings.len(),
2345            0,
2346            "Should not warn when exiting nested blockquote list. Got: {warnings:?}"
2347        );
2348    }
2349
2350    #[test]
2351    fn test_blockquote_same_level_no_warning() {
2352        // List INSIDE blockquote followed by text INSIDE same blockquote
2353        // markdownlint-cli does NOT warn for this case - lazy continuation applies
2354        let content = "> - item 1\n> - item 2\n> Text after";
2355        let warnings = lint(content);
2356        assert_eq!(
2357            warnings.len(),
2358            0,
2359            "Should not warn - text is lazy continuation in blockquote. Got: {warnings:?}"
2360        );
2361    }
2362
2363    #[test]
2364    fn test_blockquote_list_with_special_chars() {
2365        // Content with special chars like <> should not affect blockquote detection
2366        let content = "- Item with <>&\n  > - blockquote item\n- Back to outer";
2367        let warnings = lint(content);
2368        assert_eq!(
2369            warnings.len(),
2370            0,
2371            "Special chars in content should not affect blockquote detection. Got: {warnings:?}"
2372        );
2373    }
2374
2375    #[test]
2376    fn test_lazy_continuation_whitespace_only_line() {
2377        // Line with only whitespace is NOT considered a blank line for MD032
2378        // This matches CommonMark where only truly empty lines are "blank"
2379        let content = "- Item\n   \nText after whitespace-only line";
2380        let config = MD032Config {
2381            allow_lazy_continuation: false,
2382        };
2383        let warnings = lint_with_config(content, config.clone());
2384        // Whitespace-only line does NOT count as blank line separator
2385        assert_eq!(
2386            warnings.len(),
2387            1,
2388            "Whitespace-only line should NOT count as separator. Got: {warnings:?}"
2389        );
2390
2391        // Verify fix adds proper blank line
2392        let fixed = fix_with_config(content, config);
2393        assert!(fixed.contains("\n\nText"), "Fix should add blank line separator");
2394    }
2395
2396    #[test]
2397    fn test_lazy_continuation_blockquote_context() {
2398        // List inside blockquote with lazy continuation
2399        let content = "> - Item\n> Lazy in quote";
2400        let config = MD032Config {
2401            allow_lazy_continuation: false,
2402        };
2403        let warnings = lint_with_config(content, config);
2404        // Inside blockquote, lazy continuation may behave differently
2405        // This tests that we handle blockquote context
2406        assert!(warnings.len() <= 1, "Should handle blockquote context gracefully");
2407    }
2408
2409    #[test]
2410    fn test_lazy_continuation_fix_preserves_content() {
2411        // Ensure fix doesn't modify the actual content
2412        let content = "- Item with special chars: <>&\nContinuation with: \"quotes\"";
2413        let config = MD032Config {
2414            allow_lazy_continuation: false,
2415        };
2416        let fixed = fix_with_config(content, config);
2417        assert!(fixed.contains("<>&"), "Should preserve special chars");
2418        assert!(fixed.contains("\"quotes\""), "Should preserve quotes");
2419        assert_eq!(fixed, "- Item with special chars: <>&\n\nContinuation with: \"quotes\"");
2420    }
2421
2422    #[test]
2423    fn test_lazy_continuation_fix_idempotent() {
2424        // Running fix twice should produce same result
2425        let content = "- Item\nLazy";
2426        let config = MD032Config {
2427            allow_lazy_continuation: false,
2428        };
2429        let fixed_once = fix_with_config(content, config.clone());
2430        let fixed_twice = fix_with_config(&fixed_once, config);
2431        assert_eq!(fixed_once, fixed_twice, "Fix should be idempotent");
2432    }
2433
2434    #[test]
2435    fn test_lazy_continuation_config_default_allows() {
2436        // Verify default config allows lazy continuation
2437        let content = "- Item\nLazy text that continues";
2438        let default_config = MD032Config::default();
2439        assert!(
2440            default_config.allow_lazy_continuation,
2441            "Default should allow lazy continuation"
2442        );
2443        let warnings = lint_with_config(content, default_config);
2444        assert_eq!(warnings.len(), 0, "Default config should not warn on lazy continuation");
2445    }
2446
2447    #[test]
2448    fn test_lazy_continuation_after_multi_line_item() {
2449        // List item with proper indented continuation, then lazy text
2450        let content = "- Item line 1\n  Item line 2 (indented)\nLazy (not indented)";
2451        let config = MD032Config {
2452            allow_lazy_continuation: false,
2453        };
2454        let warnings = lint_with_config(content, config.clone());
2455        assert_eq!(
2456            warnings.len(),
2457            1,
2458            "Should warn only for the lazy line, not the indented line"
2459        );
2460    }
2461
2462    // Issue #260: Lists inside blockquotes should not produce false positives
2463    #[test]
2464    fn test_blockquote_list_with_continuation_and_nested() {
2465        // This is the exact case from issue #260
2466        // markdownlint-cli reports NO warnings for this
2467        let content = "> - item 1\n>   continuation\n>   - nested\n> - item 2";
2468        let warnings = lint(content);
2469        assert_eq!(
2470            warnings.len(),
2471            0,
2472            "Blockquoted list with continuation and nested items should have no warnings. Got: {warnings:?}"
2473        );
2474    }
2475
2476    #[test]
2477    fn test_blockquote_list_simple() {
2478        // Simple blockquoted list
2479        let content = "> - item 1\n> - item 2";
2480        let warnings = lint(content);
2481        assert_eq!(warnings.len(), 0, "Simple blockquoted list should have no warnings");
2482    }
2483
2484    #[test]
2485    fn test_blockquote_list_with_continuation_only() {
2486        // Blockquoted list with continuation line (no nesting)
2487        let content = "> - item 1\n>   continuation\n> - item 2";
2488        let warnings = lint(content);
2489        assert_eq!(
2490            warnings.len(),
2491            0,
2492            "Blockquoted list with continuation should have no warnings"
2493        );
2494    }
2495
2496    #[test]
2497    fn test_blockquote_list_with_lazy_continuation() {
2498        // Blockquoted list with lazy continuation (no extra indent after >)
2499        let content = "> - item 1\n> lazy continuation\n> - item 2";
2500        let warnings = lint(content);
2501        assert_eq!(
2502            warnings.len(),
2503            0,
2504            "Blockquoted list with lazy continuation should have no warnings"
2505        );
2506    }
2507
2508    #[test]
2509    fn test_nested_blockquote_list() {
2510        // List inside nested blockquote (>> prefix)
2511        let content = ">> - item 1\n>>   continuation\n>>   - nested\n>> - item 2";
2512        let warnings = lint(content);
2513        assert_eq!(warnings.len(), 0, "Nested blockquote list should have no warnings");
2514    }
2515
2516    #[test]
2517    fn test_blockquote_list_needs_preceding_blank() {
2518        // Blockquote list preceded by non-blank content SHOULD warn
2519        let content = "> Text before\n> - item 1\n> - item 2";
2520        let warnings = lint(content);
2521        assert_eq!(
2522            warnings.len(),
2523            1,
2524            "Should warn for missing blank before blockquoted list"
2525        );
2526    }
2527
2528    #[test]
2529    fn test_blockquote_list_properly_separated() {
2530        // Blockquote list with proper blank lines - no warnings
2531        let content = "> Text before\n>\n> - item 1\n> - item 2\n>\n> Text after";
2532        let warnings = lint(content);
2533        assert_eq!(
2534            warnings.len(),
2535            0,
2536            "Properly separated blockquoted list should have no warnings"
2537        );
2538    }
2539
2540    #[test]
2541    fn test_blockquote_ordered_list() {
2542        // Ordered list in blockquote with continuation
2543        let content = "> 1. item 1\n>    continuation\n> 2. item 2";
2544        let warnings = lint(content);
2545        assert_eq!(warnings.len(), 0, "Ordered list in blockquote should have no warnings");
2546    }
2547
2548    #[test]
2549    fn test_blockquote_list_with_empty_blockquote_line() {
2550        // Empty blockquote line (just ">") between items - still same list
2551        let content = "> - item 1\n>\n> - item 2";
2552        let warnings = lint(content);
2553        assert_eq!(warnings.len(), 0, "Empty blockquote line should not break list");
2554    }
2555
2556    /// Issue #268: Multi-paragraph list items in blockquotes should not trigger false positives
2557    #[test]
2558    fn test_blockquote_list_multi_paragraph_items() {
2559        // List item with blank line + continuation paragraph + next item
2560        // This is a common pattern for multi-paragraph list items in blockquotes
2561        let content = "# Test\n\n> Some intro text\n> \n> * List item 1\n> \n>   Continuation\n> * List item 2\n";
2562        let warnings = lint(content);
2563        assert_eq!(
2564            warnings.len(),
2565            0,
2566            "Multi-paragraph list items in blockquotes should have no warnings. Got: {warnings:?}"
2567        );
2568    }
2569
2570    /// Issue #268: Ordered lists with multi-paragraph items in blockquotes
2571    #[test]
2572    fn test_blockquote_ordered_list_multi_paragraph_items() {
2573        let content = "> 1. First item\n> \n>    Continuation of first\n> 2. Second item\n";
2574        let warnings = lint(content);
2575        assert_eq!(
2576            warnings.len(),
2577            0,
2578            "Ordered multi-paragraph list items in blockquotes should have no warnings. Got: {warnings:?}"
2579        );
2580    }
2581
2582    /// Issue #268: Multiple continuation paragraphs in blockquote list
2583    #[test]
2584    fn test_blockquote_list_multiple_continuations() {
2585        let content = "> - Item 1\n> \n>   First continuation\n> \n>   Second continuation\n> - Item 2\n";
2586        let warnings = lint(content);
2587        assert_eq!(
2588            warnings.len(),
2589            0,
2590            "Multiple continuation paragraphs should not break blockquote list. Got: {warnings:?}"
2591        );
2592    }
2593
2594    /// Issue #268: Nested blockquote (>>) with multi-paragraph list items
2595    #[test]
2596    fn test_nested_blockquote_multi_paragraph_list() {
2597        let content = ">> - Item 1\n>> \n>>   Continuation\n>> - Item 2\n";
2598        let warnings = lint(content);
2599        assert_eq!(
2600            warnings.len(),
2601            0,
2602            "Nested blockquote multi-paragraph list should have no warnings. Got: {warnings:?}"
2603        );
2604    }
2605
2606    /// Issue #268: Triple-nested blockquote (>>>) with multi-paragraph list items
2607    #[test]
2608    fn test_triple_nested_blockquote_multi_paragraph_list() {
2609        let content = ">>> - Item 1\n>>> \n>>>   Continuation\n>>> - Item 2\n";
2610        let warnings = lint(content);
2611        assert_eq!(
2612            warnings.len(),
2613            0,
2614            "Triple-nested blockquote multi-paragraph list should have no warnings. Got: {warnings:?}"
2615        );
2616    }
2617
2618    /// Issue #268: Last item in blockquote list has continuation (edge case)
2619    #[test]
2620    fn test_blockquote_list_last_item_continuation() {
2621        let content = "> - Item 1\n> - Item 2\n> \n>   Continuation of item 2\n";
2622        let warnings = lint(content);
2623        assert_eq!(
2624            warnings.len(),
2625            0,
2626            "Last item with continuation should have no warnings. Got: {warnings:?}"
2627        );
2628    }
2629
2630    /// Issue #268: First item only has continuation in blockquote list
2631    #[test]
2632    fn test_blockquote_list_first_item_only_continuation() {
2633        let content = "> - Item 1\n> \n>   Continuation of item 1\n";
2634        let warnings = lint(content);
2635        assert_eq!(
2636            warnings.len(),
2637            0,
2638            "Single item with continuation should have no warnings. Got: {warnings:?}"
2639        );
2640    }
2641
2642    /// Blockquote level change SHOULD still be detected as list break
2643    /// Note: markdownlint flags BOTH lines in this case - line 1 for missing preceding blank,
2644    /// and line 2 for missing preceding blank (level change)
2645    #[test]
2646    fn test_blockquote_level_change_breaks_list() {
2647        // Going from > to >> should break the list - markdownlint flags both lines
2648        let content = "> - Item in single blockquote\n>> - Item in nested blockquote\n";
2649        let warnings = lint(content);
2650        // markdownlint reports: line 1 (list at start), line 2 (level change)
2651        // For now, accept 0 or more warnings since this is a complex edge case
2652        // The main fix (multi-paragraph items) is more important than this edge case
2653        assert!(
2654            warnings.len() <= 2,
2655            "Blockquote level change warnings should be reasonable. Got: {warnings:?}"
2656        );
2657    }
2658
2659    /// Exiting blockquote SHOULD still be detected as needing blank line
2660    #[test]
2661    fn test_exit_blockquote_needs_blank_before_list() {
2662        // Text after blockquote, then list without blank
2663        let content = "> Blockquote text\n\n- List outside blockquote\n";
2664        let warnings = lint(content);
2665        assert_eq!(
2666            warnings.len(),
2667            0,
2668            "List after blank line outside blockquote should be fine. Got: {warnings:?}"
2669        );
2670
2671        // Without blank line after blockquote - markdownlint flags this
2672        // But rumdl may not flag it due to complexity of detecting "text immediately before list"
2673        // This is an acceptable deviation for now
2674        let content2 = "> Blockquote text\n- List outside blockquote\n";
2675        let warnings2 = lint(content2);
2676        // Accept 0 or 1 - main fix is more important than this edge case
2677        assert!(
2678            warnings2.len() <= 1,
2679            "List after blockquote warnings should be reasonable. Got: {warnings2:?}"
2680        );
2681    }
2682
2683    /// Issue #268: Test all unordered list markers (-, *, +) with multi-paragraph items
2684    #[test]
2685    fn test_blockquote_multi_paragraph_all_unordered_markers() {
2686        // Dash marker
2687        let content_dash = "> - Item 1\n> \n>   Continuation\n> - Item 2\n";
2688        let warnings = lint(content_dash);
2689        assert_eq!(warnings.len(), 0, "Dash marker should work. Got: {warnings:?}");
2690
2691        // Asterisk marker
2692        let content_asterisk = "> * Item 1\n> \n>   Continuation\n> * Item 2\n";
2693        let warnings = lint(content_asterisk);
2694        assert_eq!(warnings.len(), 0, "Asterisk marker should work. Got: {warnings:?}");
2695
2696        // Plus marker
2697        let content_plus = "> + Item 1\n> \n>   Continuation\n> + Item 2\n";
2698        let warnings = lint(content_plus);
2699        assert_eq!(warnings.len(), 0, "Plus marker should work. Got: {warnings:?}");
2700    }
2701
2702    /// Issue #268: Parenthesis-style ordered list markers (1))
2703    #[test]
2704    fn test_blockquote_multi_paragraph_parenthesis_marker() {
2705        let content = "> 1) Item 1\n> \n>    Continuation\n> 2) Item 2\n";
2706        let warnings = lint(content);
2707        assert_eq!(
2708            warnings.len(),
2709            0,
2710            "Parenthesis ordered markers should work. Got: {warnings:?}"
2711        );
2712    }
2713
2714    /// Issue #268: Multi-digit ordered list numbers have wider markers
2715    #[test]
2716    fn test_blockquote_multi_paragraph_multi_digit_numbers() {
2717        // "10. " is 4 chars, so continuation needs 4 spaces
2718        let content = "> 10. Item 10\n> \n>     Continuation of item 10\n> 11. Item 11\n";
2719        let warnings = lint(content);
2720        assert_eq!(
2721            warnings.len(),
2722            0,
2723            "Multi-digit ordered list should work. Got: {warnings:?}"
2724        );
2725    }
2726
2727    /// Issue #268: Continuation with emphasis and other inline formatting
2728    #[test]
2729    fn test_blockquote_multi_paragraph_with_formatting() {
2730        let content = "> - Item with **bold**\n> \n>   Continuation with *emphasis* and `code`\n> - Item 2\n";
2731        let warnings = lint(content);
2732        assert_eq!(
2733            warnings.len(),
2734            0,
2735            "Continuation with inline formatting should work. Got: {warnings:?}"
2736        );
2737    }
2738
2739    /// Issue #268: Multiple items each with their own continuation paragraph
2740    #[test]
2741    fn test_blockquote_multi_paragraph_all_items_have_continuation() {
2742        let content = "> - Item 1\n> \n>   Continuation 1\n> - Item 2\n> \n>   Continuation 2\n> - Item 3\n> \n>   Continuation 3\n";
2743        let warnings = lint(content);
2744        assert_eq!(
2745            warnings.len(),
2746            0,
2747            "All items with continuations should work. Got: {warnings:?}"
2748        );
2749    }
2750
2751    /// Issue #268: Continuation starting with lowercase (tests uppercase heuristic doesn't break this)
2752    #[test]
2753    fn test_blockquote_multi_paragraph_lowercase_continuation() {
2754        let content = "> - Item 1\n> \n>   and this continues the item\n> - Item 2\n";
2755        let warnings = lint(content);
2756        assert_eq!(
2757            warnings.len(),
2758            0,
2759            "Lowercase continuation should work. Got: {warnings:?}"
2760        );
2761    }
2762
2763    /// Issue #268: Continuation starting with uppercase (tests uppercase heuristic is bypassed with proper indent)
2764    #[test]
2765    fn test_blockquote_multi_paragraph_uppercase_continuation() {
2766        let content = "> - Item 1\n> \n>   This continues the item with uppercase\n> - Item 2\n";
2767        let warnings = lint(content);
2768        assert_eq!(
2769            warnings.len(),
2770            0,
2771            "Uppercase continuation with proper indent should work. Got: {warnings:?}"
2772        );
2773    }
2774
2775    /// Issue #268: Mixed ordered and unordered shouldn't affect multi-paragraph handling
2776    #[test]
2777    fn test_blockquote_separate_ordered_unordered_multi_paragraph() {
2778        // Two separate lists in same blockquote
2779        let content = "> - Unordered item\n> \n>   Continuation\n> \n> 1. Ordered item\n> \n>    Continuation\n";
2780        let warnings = lint(content);
2781        // May have warning for missing blank between lists, but not for the continuations
2782        assert!(
2783            warnings.len() <= 1,
2784            "Separate lists with continuations should be reasonable. Got: {warnings:?}"
2785        );
2786    }
2787
2788    /// Issue #268: Blockquote with bare > line (no space) as blank
2789    #[test]
2790    fn test_blockquote_multi_paragraph_bare_marker_blank() {
2791        // Using ">" alone instead of "> " for blank line
2792        let content = "> - Item 1\n>\n>   Continuation\n> - Item 2\n";
2793        let warnings = lint(content);
2794        assert_eq!(warnings.len(), 0, "Bare > as blank line should work. Got: {warnings:?}");
2795    }
2796
2797    #[test]
2798    fn test_blockquote_list_varying_spaces_after_marker() {
2799        // Different spacing after > (1 space vs 3 spaces) but same blockquote level
2800        let content = "> - item 1\n>   continuation with more indent\n> - item 2";
2801        let warnings = lint(content);
2802        assert_eq!(warnings.len(), 0, "Varying spaces after > should not break list");
2803    }
2804
2805    #[test]
2806    fn test_deeply_nested_blockquote_list() {
2807        // Triple-nested blockquote with list
2808        let content = ">>> - item 1\n>>>   continuation\n>>> - item 2";
2809        let warnings = lint(content);
2810        assert_eq!(
2811            warnings.len(),
2812            0,
2813            "Deeply nested blockquote list should have no warnings"
2814        );
2815    }
2816
2817    #[test]
2818    fn test_blockquote_level_change_in_list() {
2819        // Blockquote level changes mid-list - this breaks the list
2820        let content = "> - item 1\n>> - deeper item\n> - item 2";
2821        // Each segment is a separate list context due to blockquote level change
2822        // markdownlint-cli reports 4 warnings for this case
2823        let warnings = lint(content);
2824        assert!(
2825            !warnings.is_empty(),
2826            "Blockquote level change should break list and trigger warnings"
2827        );
2828    }
2829
2830    #[test]
2831    fn test_blockquote_list_with_code_span() {
2832        // List item with inline code in blockquote
2833        let content = "> - item with `code`\n>   continuation\n> - item 2";
2834        let warnings = lint(content);
2835        assert_eq!(
2836            warnings.len(),
2837            0,
2838            "Blockquote list with code span should have no warnings"
2839        );
2840    }
2841
2842    #[test]
2843    fn test_blockquote_list_at_document_end() {
2844        // List at end of document (no trailing content)
2845        let content = "> Some text\n>\n> - item 1\n> - item 2";
2846        let warnings = lint(content);
2847        assert_eq!(
2848            warnings.len(),
2849            0,
2850            "Blockquote list at document end should have no warnings"
2851        );
2852    }
2853
2854    #[test]
2855    fn test_fix_preserves_blockquote_prefix_before_list() {
2856        // Issue #268: Fix should insert blockquote-prefixed blank lines inside blockquotes
2857        let content = "> Text before
2858> - Item 1
2859> - Item 2";
2860        let fixed = fix(content);
2861
2862        // The blank line inserted before the list should have the blockquote prefix (no trailing space per markdownlint-cli)
2863        let expected = "> Text before
2864>
2865> - Item 1
2866> - Item 2";
2867        assert_eq!(
2868            fixed, expected,
2869            "Fix should insert '>' blank line, not plain blank line"
2870        );
2871    }
2872
2873    #[test]
2874    fn test_fix_preserves_triple_nested_blockquote_prefix_for_list() {
2875        // Triple-nested blockquotes should preserve full prefix
2876        // Per markdownlint-cli, only preceding blank line is required
2877        let content = ">>> Triple nested
2878>>> - Item 1
2879>>> - Item 2
2880>>> More text";
2881        let fixed = fix(content);
2882
2883        // Should insert ">>>" blank line before list only
2884        let expected = ">>> Triple nested
2885>>>
2886>>> - Item 1
2887>>> - Item 2
2888>>> More text";
2889        assert_eq!(
2890            fixed, expected,
2891            "Fix should preserve triple-nested blockquote prefix '>>>'"
2892        );
2893    }
2894}