Skip to main content

rumdl_lib/rules/
md076_list_item_spacing.rs

1use crate::lint_context::LintContext;
2use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
3use crate::utils::skip_context::is_table_line;
4
5/// Rule MD076: Enforce consistent blank lines between list items
6///
7/// See [docs/md076.md](../../docs/md076.md) for full documentation and examples.
8///
9/// Enforces that the spacing between consecutive list items is consistent
10/// within each list: either all gaps have a blank line (loose) or none do (tight).
11///
12/// ## Configuration
13///
14/// ```toml
15/// [MD076]
16/// style = "consistent"  # "loose", "tight", or "consistent" (default)
17/// ```
18///
19/// - `"consistent"` — within each list, all gaps must use the same style (majority wins)
20/// - `"loose"` — blank line required between every pair of items
21/// - `"tight"` — no blank lines allowed between any items
22
23#[derive(Debug, Clone, PartialEq, Eq, Default)]
24pub enum ListItemSpacingStyle {
25    #[default]
26    Consistent,
27    Loose,
28    Tight,
29}
30
31#[derive(Debug, Clone, Default)]
32pub(super) struct MD076Config {
33    pub style: ListItemSpacingStyle,
34    /// When true, blank lines around continuation paragraphs within a list item
35    /// are permitted even in tight mode. This allows tight inter-item spacing
36    /// while using blank lines to visually separate continuation content.
37    pub allow_loose_continuation: bool,
38}
39
40#[derive(Debug, Clone, Default)]
41pub struct MD076ListItemSpacing {
42    config: MD076Config,
43}
44
45/// Classification of the spacing between two consecutive list items.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47enum GapKind {
48    /// No blank line between items.
49    Tight,
50    /// Blank line that is a genuine inter-item separator.
51    Loose,
52    /// Blank line required by another rule (MD031, MD058) around structural content.
53    /// Excluded from consistency analysis — neither loose nor tight.
54    Structural,
55    /// Blank line after continuation content within a list item.
56    /// Treated as `Structural` when `allow_loose_continuation` is enabled,
57    /// or as `Loose` when disabled (default).
58    ContinuationLoose,
59}
60
61/// Per-list analysis result shared by check() and fix().
62struct ListAnalysis {
63    /// 1-indexed line numbers of the list's items, in order.
64    items: Vec<usize>,
65    /// Classification of each inter-item gap.
66    gaps: Vec<GapKind>,
67    /// Whether loose gaps are violations (should have blank lines removed).
68    warn_loose_gaps: bool,
69    /// Whether tight gaps are violations (should have blank lines inserted).
70    warn_tight_gaps: bool,
71}
72
73impl MD076ListItemSpacing {
74    pub fn new(style: ListItemSpacingStyle) -> Self {
75        Self {
76            config: MD076Config {
77                style,
78                allow_loose_continuation: false,
79            },
80        }
81    }
82
83    pub fn with_allow_loose_continuation(mut self, allow: bool) -> Self {
84        self.config.allow_loose_continuation = allow;
85        self
86    }
87
88    /// Check whether a line is effectively blank, accounting for blockquote markers.
89    ///
90    /// A line like `>` or `> ` is considered blank in blockquote context even though
91    /// its raw content is non-empty.
92    fn is_effectively_blank(ctx: &LintContext, line_num: usize) -> bool {
93        if let Some(info) = ctx.line_info(line_num) {
94            let content = info.content(ctx.content);
95            if content.trim().is_empty() {
96                return true;
97            }
98            // In a blockquote, a line containing only markers (e.g., ">", "> ") is blank
99            if let Some(ref bq) = info.blockquote {
100                return bq.content.trim().is_empty();
101            }
102            false
103        } else {
104            false
105        }
106    }
107
108    /// Check whether a non-blank line is structural content (code block, table, HTML block,
109    /// or blockquote) whose trailing blank line is required by other rules (MD031, MD058).
110    fn is_structural_content(ctx: &LintContext, line_num: usize) -> bool {
111        if let Some(info) = ctx.line_info(line_num) {
112            // Inside a code block (includes the closing fence itself)
113            if info.in_code_block {
114                return true;
115            }
116            // Inside an HTML block
117            if info.in_html_block {
118                return true;
119            }
120            // Inside a blockquote
121            if info.blockquote.is_some() {
122                return true;
123            }
124            // A table row or separator
125            let content = info.content(ctx.content);
126            // Strip blockquote prefix and list continuation indent before checking table syntax
127            let effective = if let Some(ref bq) = info.blockquote {
128                bq.content.as_str()
129            } else {
130                content
131            };
132            if is_table_line(effective.trim_start()) {
133                return true;
134            }
135        }
136        false
137    }
138
139    /// Check whether a list item opens a fenced code block on its marker line.
140    ///
141    /// MD031 requires a blank line before that fence. MD076 must treat the same
142    /// blank as structural rather than removing it as an inter-item separator.
143    ///
144    /// The question goes to the parser, through the same `is_fenced` details MD031
145    /// itself reads, so the two rules cannot disagree about what a fence is. Line
146    /// text is not enough: a marker followed by five spaces and a fence is an
147    /// *indented* code block, which MD031 says nothing about, and the `in_code_block`
148    /// flag is true for indented blocks as well as fenced ones.
149    fn is_fenced_code_block_list_item(ctx: &LintContext, line_num: usize) -> bool {
150        let Some(info) = ctx.line_info(line_num) else {
151            return false;
152        };
153        if info.list_item.is_none() {
154            return false;
155        }
156
157        let line_range = info.byte_offset..info.byte_offset + info.byte_len;
158        ctx.code_block_details
159            .iter()
160            .any(|detail| detail.is_fenced && line_range.contains(&detail.start))
161    }
162
163    /// Check whether a non-blank line is continuation content within a list item
164    /// (indented prose that is not itself a list marker or structural content).
165    ///
166    /// `parent_content_col` is the content column of the parent list item marker
167    /// (e.g., 2 for `- item`, 3 for `1. item`). Continuation must be indented
168    /// to at least this column to belong to the parent item.
169    fn is_continuation_content(ctx: &LintContext, line_num: usize, parent_content_col: usize) -> bool {
170        let Some(info) = ctx.line_info(line_num) else {
171            return false;
172        };
173        // Lines with a list marker are items, not continuation
174        if info.list_item.is_some() {
175            return false;
176        }
177        // Structural content is handled separately by is_structural_content
178        if info.in_code_block
179            || info.in_html_block
180            || info.in_html_comment
181            || info.in_mdx_comment
182            || info.in_front_matter
183            || info.in_math_block
184            || info.blockquote.is_some()
185        {
186            return false;
187        }
188        let content = info.content(ctx.content);
189        if content.trim().is_empty() {
190            return false;
191        }
192        // Continuation must be indented to at least the parent item's content column
193        let indent = content.len() - content.trim_start().len();
194        indent >= parent_content_col
195    }
196
197    /// Classify the inter-item gap between two consecutive items.
198    ///
199    /// Returns `Tight` if there is no blank line, `Loose` if there is a genuine
200    /// inter-item separator blank, `Structural` if the only blank line is
201    /// required by another rule (MD031/MD058) after structural content, or
202    /// `ContinuationLoose` if the blank line follows continuation content
203    /// within a list item.
204    fn classify_gap(ctx: &LintContext, first: usize, next: usize) -> GapKind {
205        if next <= first + 1 {
206            return GapKind::Tight;
207        }
208        // The gap has a blank line only if the line immediately before the next item is blank.
209        if !Self::is_effectively_blank(ctx, next - 1) {
210            return GapKind::Tight;
211        }
212        // A fence opened directly on a list marker is still part of that list
213        // item. The blank before it belongs to MD031, not to MD076's spacing
214        // consistency policy, so removing it would create a fix loop.
215        if Self::is_fenced_code_block_list_item(ctx, next) {
216            return GapKind::Structural;
217        }
218        // Walk backwards past blank lines to find the last non-blank content line.
219        // If that line is structural content, the blank is required (not a separator).
220        let mut scan = next - 1;
221        while scan > first && Self::is_effectively_blank(ctx, scan) {
222            scan -= 1;
223        }
224        // `scan` is now the last non-blank line before the next item
225        if scan > first && Self::is_structural_content(ctx, scan) {
226            return GapKind::Structural;
227        }
228        // Check if the last non-blank line is continuation content.
229        // Use the first item's content column to verify proper indentation.
230        let parent_content_col = ctx
231            .line_info(first)
232            .and_then(|li| li.list_item.as_ref())
233            .map_or(2, |item| item.content_column);
234        if scan > first && Self::is_continuation_content(ctx, scan, parent_content_col) {
235            return GapKind::ContinuationLoose;
236        }
237        GapKind::Loose
238    }
239
240    /// Collect the 1-indexed line numbers of all inter-item blank lines in the gap.
241    ///
242    /// Walks backwards from the line before `next` collecting consecutive blank lines.
243    /// These are the actual separator lines between items, not blank lines within
244    /// multi-paragraph items. Structural blanks (after code blocks, tables, HTML blocks)
245    /// are excluded.
246    fn inter_item_blanks(ctx: &LintContext, first: usize, next: usize) -> Vec<usize> {
247        let mut blanks = Vec::new();
248        let mut line_num = next - 1;
249        while line_num > first && Self::is_effectively_blank(ctx, line_num) {
250            blanks.push(line_num);
251            line_num -= 1;
252        }
253        // If the last non-blank line is structural content, these blanks are structural
254        if line_num > first && Self::is_structural_content(ctx, line_num) {
255            return Vec::new();
256        }
257        blanks.reverse();
258        blanks
259    }
260
261    /// Analyze every list in the document: each block's items grouped into
262    /// the lists they form, one per run of items at one nesting level, so a
263    /// nested list is judged on its own spacing and never on its parent's.
264    fn analyze(&self, ctx: &LintContext) -> Vec<ListAnalysis> {
265        ctx.list_blocks
266            .iter()
267            .flat_map(|block| ctx.list_block_item_groups(block))
268            .filter_map(|items| {
269                Self::analyze_list(ctx, items, &self.config.style, self.config.allow_loose_continuation)
270            })
271            .collect()
272    }
273
274    /// Analyze one list, given the lines of its items in order, to determine
275    /// which gaps need fixing.
276    ///
277    /// Returns `None` if the list has fewer than 2 items or if no gaps violate
278    /// the configured style.
279    fn analyze_list(
280        ctx: &LintContext,
281        items: Vec<usize>,
282        style: &ListItemSpacingStyle,
283        allow_loose_continuation: bool,
284    ) -> Option<ListAnalysis> {
285        if items.len() < 2 {
286            return None;
287        }
288
289        // Classify each inter-item gap.
290        let gaps: Vec<GapKind> = items.windows(2).map(|w| Self::classify_gap(ctx, w[0], w[1])).collect();
291
292        // Structural gaps and (when allowed) continuation gaps are excluded
293        // from consistency analysis — they should not influence whether the
294        // list is considered loose or tight.
295        let loose_count = gaps
296            .iter()
297            .filter(|&&g| g == GapKind::Loose || (g == GapKind::ContinuationLoose && !allow_loose_continuation))
298            .count();
299        let tight_count = gaps.iter().filter(|&&g| g == GapKind::Tight).count();
300
301        let (warn_loose_gaps, warn_tight_gaps) = match style {
302            ListItemSpacingStyle::Loose => (false, true),
303            ListItemSpacingStyle::Tight => (true, false),
304            ListItemSpacingStyle::Consistent => {
305                if loose_count == 0 || tight_count == 0 {
306                    return None; // Already consistent (structural gaps excluded)
307                }
308                // Majority wins. On a tie, prefer tight (warn loose):
309                //   - tight is the dominant style in real-world Markdown;
310                //     loose is opt-in for multi-paragraph items,
311                //   - matches the minimal-whitespace convention used by
312                //     Prettier and most other Markdown formatters,
313                //   - removes a blank line rather than inserting one, which
314                //     is the lower-impact edit on a tied document.
315                if tight_count >= loose_count {
316                    (true, false)
317                } else {
318                    (false, true)
319                }
320            }
321        };
322
323        Some(ListAnalysis {
324            items,
325            gaps,
326            warn_loose_gaps,
327            warn_tight_gaps,
328        })
329    }
330}
331
332impl Rule for MD076ListItemSpacing {
333    fn name(&self) -> &'static str {
334        "MD076"
335    }
336
337    fn description(&self) -> &'static str {
338        "List item spacing should be consistent"
339    }
340
341    fn category(&self) -> RuleCategory {
342        RuleCategory::List
343    }
344
345    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
346        ctx.content.is_empty() || ctx.list_blocks.is_empty()
347    }
348
349    fn check(&self, ctx: &LintContext) -> LintResult {
350        if ctx.content.is_empty() {
351            return Ok(Vec::new());
352        }
353
354        let mut warnings = Vec::new();
355
356        let allow_cont = self.config.allow_loose_continuation;
357        // The edits are applied to the document as the rule saw it, which an
358        // editor hands over with its own line endings, so an inserted line
359        // ends the way the document's lines do.
360        let line_ending = crate::utils::line_ending::detect_line_ending(ctx.content);
361
362        for analysis in self.analyze(ctx) {
363            for (i, &gap) in analysis.gaps.iter().enumerate() {
364                let is_loose_violation = match gap {
365                    GapKind::Loose => analysis.warn_loose_gaps,
366                    GapKind::ContinuationLoose => !allow_cont && analysis.warn_loose_gaps,
367                    _ => false,
368                };
369
370                if is_loose_violation {
371                    let next_item = analysis.items[i + 1];
372                    let blanks = Self::inter_item_blanks(ctx, analysis.items[i], next_item);
373                    if let Some(&blank_line) = blanks.first() {
374                        let line_content = ctx.line_info(blank_line).map_or("", |li| li.content(ctx.content));
375                        // The edit removes the whole run of blank lines the
376                        // fix removes, so applying it alone closes the gap.
377                        let fix = ctx
378                            .line_start_byte(blank_line)
379                            .zip(ctx.line_start_byte(next_item))
380                            .map(|(start, end)| Fix::new(start..end, String::new()));
381                        warnings.push(LintWarning {
382                            rule_name: Some(self.name().to_string()),
383                            line: blank_line,
384                            column: 1,
385                            end_line: blank_line,
386                            end_column: line_content.chars().count() + 1,
387                            message: "Unexpected blank line between list items".to_string(),
388                            severity: Severity::Warning,
389                            fix,
390                        });
391                    }
392                } else if gap == GapKind::Tight && analysis.warn_tight_gaps {
393                    let next_item = analysis.items[i + 1];
394                    let line_content = ctx.line_info(next_item).map_or("", |li| li.content(ctx.content));
395                    // The blank line goes in front of the item, carrying the
396                    // item's blockquote prefix as the fix writes it.
397                    let fix = ctx.line_start_byte(next_item).map(|start| {
398                        let prefix = ctx.blockquote_prefix_for_blank_line(next_item - 1);
399                        Fix::new(start..start, format!("{prefix}{line_ending}"))
400                    });
401                    warnings.push(LintWarning {
402                        rule_name: Some(self.name().to_string()),
403                        line: next_item,
404                        column: 1,
405                        end_line: next_item,
406                        end_column: line_content.chars().count() + 1,
407                        message: "Missing blank line between list items".to_string(),
408                        severity: Severity::Warning,
409                        fix,
410                    });
411                }
412            }
413        }
414
415        Ok(warnings)
416    }
417
418    fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
419        if ctx.content.is_empty() {
420            return Ok(ctx.content.to_string());
421        }
422
423        // Collect all inter-item blank lines to remove and lines to insert before.
424        let mut insert_before: std::collections::HashSet<usize> = std::collections::HashSet::new();
425        let mut remove_lines: std::collections::HashSet<usize> = std::collections::HashSet::new();
426
427        let allow_cont = self.config.allow_loose_continuation;
428
429        for analysis in self.analyze(ctx) {
430            for (i, &gap) in analysis.gaps.iter().enumerate() {
431                let is_loose_violation = match gap {
432                    GapKind::Loose => analysis.warn_loose_gaps,
433                    GapKind::ContinuationLoose => !allow_cont && analysis.warn_loose_gaps,
434                    _ => false,
435                };
436
437                if is_loose_violation {
438                    for blank_line in Self::inter_item_blanks(ctx, analysis.items[i], analysis.items[i + 1]) {
439                        remove_lines.insert(blank_line);
440                    }
441                } else if gap == GapKind::Tight && analysis.warn_tight_gaps {
442                    insert_before.insert(analysis.items[i + 1]);
443                }
444            }
445        }
446
447        if insert_before.is_empty() && remove_lines.is_empty() {
448            return Ok(ctx.content.to_string());
449        }
450
451        let lines = ctx.raw_lines();
452        let mut result: Vec<String> = Vec::with_capacity(lines.len());
453
454        for (i, line) in lines.iter().enumerate() {
455            let line_num = i + 1;
456
457            // Skip modifications for lines where the rule is disabled via inline config
458            if ctx.is_rule_disabled(self.name(), line_num) {
459                result.push((*line).to_string());
460                continue;
461            }
462
463            if remove_lines.contains(&line_num) {
464                continue;
465            }
466
467            if insert_before.contains(&line_num) {
468                let bq_prefix = ctx.blockquote_prefix_for_blank_line(i);
469                result.push(bq_prefix);
470            }
471
472            result.push((*line).to_string());
473        }
474
475        let mut output = result.join("\n");
476        if ctx.content.ends_with('\n') {
477            output.push('\n');
478        }
479        Ok(output)
480    }
481
482    fn as_any(&self) -> &dyn std::any::Any {
483        self
484    }
485
486    fn default_config_section(&self) -> Option<(String, toml::Value)> {
487        let mut map = toml::map::Map::new();
488        let style_str = match self.config.style {
489            ListItemSpacingStyle::Consistent => "consistent",
490            ListItemSpacingStyle::Loose => "loose",
491            ListItemSpacingStyle::Tight => "tight",
492        };
493        map.insert("style".to_string(), toml::Value::String(style_str.to_string()));
494        map.insert(
495            "allow-loose-continuation".to_string(),
496            toml::Value::Boolean(self.config.allow_loose_continuation),
497        );
498        Some((self.name().to_string(), toml::Value::Table(map)))
499    }
500
501    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
502    where
503        Self: Sized,
504    {
505        let style = crate::config::get_rule_config_value::<String>(config, "MD076", "style")
506            .unwrap_or_else(|| "consistent".to_string());
507        let style = match style.as_str() {
508            "loose" => ListItemSpacingStyle::Loose,
509            "tight" => ListItemSpacingStyle::Tight,
510            _ => ListItemSpacingStyle::Consistent,
511        };
512        let allow_loose_continuation =
513            crate::config::get_rule_config_value::<bool>(config, "MD076", "allow-loose-continuation")
514                .or_else(|| crate::config::get_rule_config_value::<bool>(config, "MD076", "allow_loose_continuation"))
515                .unwrap_or(false);
516        Box::new(Self::new(style).with_allow_loose_continuation(allow_loose_continuation))
517    }
518}
519
520#[cfg(test)]
521mod tests {
522    use super::*;
523
524    fn check(content: &str, style: ListItemSpacingStyle) -> Vec<LintWarning> {
525        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
526        let rule = MD076ListItemSpacing::new(style);
527        rule.check(&ctx).unwrap()
528    }
529
530    fn check_with_continuation(
531        content: &str,
532        style: ListItemSpacingStyle,
533        allow_loose_continuation: bool,
534    ) -> Vec<LintWarning> {
535        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
536        let rule = MD076ListItemSpacing::new(style).with_allow_loose_continuation(allow_loose_continuation);
537        rule.check(&ctx).unwrap()
538    }
539
540    fn fix(content: &str, style: ListItemSpacingStyle) -> String {
541        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
542        let rule = MD076ListItemSpacing::new(style);
543        rule.fix(&ctx).unwrap()
544    }
545
546    fn fix_with_continuation(content: &str, style: ListItemSpacingStyle, allow_loose_continuation: bool) -> String {
547        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
548        let rule = MD076ListItemSpacing::new(style).with_allow_loose_continuation(allow_loose_continuation);
549        rule.fix(&ctx).unwrap()
550    }
551
552    // ── Basic style detection ──────────────────────────────────────────
553
554    #[test]
555    fn tight_list_tight_style_no_warnings() {
556        let content = "- Item 1\n- Item 2\n- Item 3\n";
557        assert!(check(content, ListItemSpacingStyle::Tight).is_empty());
558    }
559
560    #[test]
561    fn loose_list_loose_style_no_warnings() {
562        let content = "- Item 1\n\n- Item 2\n\n- Item 3\n";
563        assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
564    }
565
566    #[test]
567    fn tight_list_loose_style_warns() {
568        let content = "- Item 1\n- Item 2\n- Item 3\n";
569        let warnings = check(content, ListItemSpacingStyle::Loose);
570        assert_eq!(warnings.len(), 2);
571        assert!(warnings.iter().all(|w| w.message.contains("Missing")));
572    }
573
574    #[test]
575    fn loose_list_tight_style_warns() {
576        let content = "- Item 1\n\n- Item 2\n\n- Item 3\n";
577        let warnings = check(content, ListItemSpacingStyle::Tight);
578        assert_eq!(warnings.len(), 2);
579        assert!(warnings.iter().all(|w| w.message.contains("Unexpected")));
580    }
581
582    // ── Consistent mode ────────────────────────────────────────────────
583
584    #[test]
585    fn consistent_all_tight_no_warnings() {
586        let content = "- Item 1\n- Item 2\n- Item 3\n";
587        assert!(check(content, ListItemSpacingStyle::Consistent).is_empty());
588    }
589
590    #[test]
591    fn consistent_all_loose_no_warnings() {
592        let content = "- Item 1\n\n- Item 2\n\n- Item 3\n";
593        assert!(check(content, ListItemSpacingStyle::Consistent).is_empty());
594    }
595
596    #[test]
597    fn consistent_mixed_majority_loose_warns_tight() {
598        // 2 loose gaps, 1 tight gap → tight is minority → warn on tight
599        let content = "- Item 1\n\n- Item 2\n- Item 3\n\n- Item 4\n";
600        let warnings = check(content, ListItemSpacingStyle::Consistent);
601        assert_eq!(warnings.len(), 1);
602        assert!(warnings[0].message.contains("Missing"));
603    }
604
605    #[test]
606    fn consistent_mixed_majority_tight_warns_loose() {
607        // 1 loose gap, 2 tight gaps → loose is minority → warn on loose blank line
608        let content = "- Item 1\n\n- Item 2\n- Item 3\n- Item 4\n";
609        let warnings = check(content, ListItemSpacingStyle::Consistent);
610        assert_eq!(warnings.len(), 1);
611        assert!(warnings[0].message.contains("Unexpected"));
612    }
613
614    #[test]
615    fn consistent_tie_prefers_tight() {
616        // 1 loose + 1 tight gap → tied. Prefer tight: warn on the loose gap
617        // ("Unexpected blank line") so fmt removes the blank rather than
618        // inserting one. See `analyze_block` for the rationale.
619        let content = "- Item 1\n\n- Item 2\n- Item 3\n";
620        let warnings = check(content, ListItemSpacingStyle::Consistent);
621        assert_eq!(warnings.len(), 1);
622        assert!(warnings[0].message.contains("Unexpected"));
623    }
624
625    // ── Edge cases ─────────────────────────────────────────────────────
626
627    #[test]
628    fn single_item_list_no_warnings() {
629        let content = "- Only item\n";
630        assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
631        assert!(check(content, ListItemSpacingStyle::Tight).is_empty());
632        assert!(check(content, ListItemSpacingStyle::Consistent).is_empty());
633    }
634
635    #[test]
636    fn empty_content_no_warnings() {
637        assert!(check("", ListItemSpacingStyle::Consistent).is_empty());
638    }
639
640    #[test]
641    fn ordered_list_tight_gaps_loose_style_warns() {
642        let content = "1. First\n2. Second\n3. Third\n";
643        let warnings = check(content, ListItemSpacingStyle::Loose);
644        assert_eq!(warnings.len(), 2);
645    }
646
647    #[test]
648    fn task_list_works() {
649        let content = "- [x] Task 1\n- [ ] Task 2\n- [x] Task 3\n";
650        let warnings = check(content, ListItemSpacingStyle::Loose);
651        assert_eq!(warnings.len(), 2);
652        let fixed = fix(content, ListItemSpacingStyle::Loose);
653        assert_eq!(fixed, "- [x] Task 1\n\n- [ ] Task 2\n\n- [x] Task 3\n");
654    }
655
656    #[test]
657    fn no_trailing_newline() {
658        let content = "- Item 1\n- Item 2";
659        let warnings = check(content, ListItemSpacingStyle::Loose);
660        assert_eq!(warnings.len(), 1);
661        let fixed = fix(content, ListItemSpacingStyle::Loose);
662        assert_eq!(fixed, "- Item 1\n\n- Item 2");
663    }
664
665    #[test]
666    fn two_separate_lists() {
667        let content = "- A\n- B\n\nText\n\n1. One\n2. Two\n";
668        let warnings = check(content, ListItemSpacingStyle::Loose);
669        assert_eq!(warnings.len(), 2);
670        let fixed = fix(content, ListItemSpacingStyle::Loose);
671        assert_eq!(fixed, "- A\n\n- B\n\nText\n\n1. One\n\n2. Two\n");
672    }
673
674    #[test]
675    fn no_list_content() {
676        let content = "Just a paragraph.\n\nAnother paragraph.\n";
677        assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
678        assert!(check(content, ListItemSpacingStyle::Tight).is_empty());
679    }
680
681    // ── Multi-line and continuation items ──────────────────────────────
682
683    #[test]
684    fn continuation_lines_tight_detected() {
685        let content = "- Item 1\n  continuation\n- Item 2\n";
686        let warnings = check(content, ListItemSpacingStyle::Loose);
687        assert_eq!(warnings.len(), 1);
688        assert!(warnings[0].message.contains("Missing"));
689    }
690
691    #[test]
692    fn continuation_lines_loose_detected() {
693        let content = "- Item 1\n  continuation\n\n- Item 2\n";
694        assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
695        let warnings = check(content, ListItemSpacingStyle::Tight);
696        assert_eq!(warnings.len(), 1);
697        assert!(warnings[0].message.contains("Unexpected"));
698    }
699
700    #[test]
701    fn multi_paragraph_item_not_treated_as_inter_item_gap() {
702        // Blank line between paragraphs within Item 1 must NOT trigger a warning.
703        // Only the blank line immediately before Item 2 is an inter-item separator.
704        let content = "- Item 1\n\n  Second paragraph\n\n- Item 2\n";
705        // Both gaps are loose (blank before Item 2), so tight should warn once
706        let warnings = check(content, ListItemSpacingStyle::Tight);
707        assert_eq!(
708            warnings.len(),
709            1,
710            "Should warn only on the inter-item blank, not the intra-item blank"
711        );
712        // The fix should remove only the inter-item blank (line 4), preserving the
713        // multi-paragraph structure
714        let fixed = fix(content, ListItemSpacingStyle::Tight);
715        assert_eq!(fixed, "- Item 1\n\n  Second paragraph\n- Item 2\n");
716    }
717
718    #[test]
719    fn multi_paragraph_item_loose_style_no_warnings() {
720        // A loose list with multi-paragraph items is already loose — no warnings
721        let content = "- Item 1\n\n  Second paragraph\n\n- Item 2\n";
722        assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
723    }
724
725    // ── Blockquote lists ───────────────────────────────────────────────
726
727    #[test]
728    fn blockquote_tight_list_loose_style_warns() {
729        let content = "> - Item 1\n> - Item 2\n> - Item 3\n";
730        let warnings = check(content, ListItemSpacingStyle::Loose);
731        assert_eq!(warnings.len(), 2);
732    }
733
734    #[test]
735    fn blockquote_loose_list_detected() {
736        // A line with only `>` is effectively blank in blockquote context
737        let content = "> - Item 1\n>\n> - Item 2\n";
738        let warnings = check(content, ListItemSpacingStyle::Tight);
739        assert_eq!(warnings.len(), 1, "Blockquote-only line should be detected as blank");
740        assert!(warnings[0].message.contains("Unexpected"));
741    }
742
743    #[test]
744    fn blockquote_loose_list_no_warnings_when_loose() {
745        let content = "> - Item 1\n>\n> - Item 2\n";
746        assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
747    }
748
749    // ── Multiple blank lines ───────────────────────────────────────────
750
751    #[test]
752    fn multiple_blanks_all_removed() {
753        let content = "- Item 1\n\n\n- Item 2\n";
754        let fixed = fix(content, ListItemSpacingStyle::Tight);
755        assert_eq!(fixed, "- Item 1\n- Item 2\n");
756    }
757
758    #[test]
759    fn multiple_blanks_fix_is_idempotent() {
760        let content = "- Item 1\n\n\n\n- Item 2\n";
761        let fixed_once = fix(content, ListItemSpacingStyle::Tight);
762        let fixed_twice = fix(&fixed_once, ListItemSpacingStyle::Tight);
763        assert_eq!(fixed_once, fixed_twice);
764        assert_eq!(fixed_once, "- Item 1\n- Item 2\n");
765    }
766
767    // ── Fix correctness ────────────────────────────────────────────────
768
769    #[test]
770    fn fix_adds_blank_lines() {
771        let content = "- Item 1\n- Item 2\n- Item 3\n";
772        let fixed = fix(content, ListItemSpacingStyle::Loose);
773        assert_eq!(fixed, "- Item 1\n\n- Item 2\n\n- Item 3\n");
774    }
775
776    #[test]
777    fn fix_removes_blank_lines() {
778        let content = "- Item 1\n\n- Item 2\n\n- Item 3\n";
779        let fixed = fix(content, ListItemSpacingStyle::Tight);
780        assert_eq!(fixed, "- Item 1\n- Item 2\n- Item 3\n");
781    }
782
783    #[test]
784    fn fix_consistent_adds_blank() {
785        // 2 loose gaps, 1 tight gap → add blank before Item 3
786        let content = "- Item 1\n\n- Item 2\n- Item 3\n\n- Item 4\n";
787        let fixed = fix(content, ListItemSpacingStyle::Consistent);
788        assert_eq!(fixed, "- Item 1\n\n- Item 2\n\n- Item 3\n\n- Item 4\n");
789    }
790
791    #[test]
792    fn fix_idempotent_loose() {
793        let content = "- Item 1\n- Item 2\n";
794        let fixed_once = fix(content, ListItemSpacingStyle::Loose);
795        let fixed_twice = fix(&fixed_once, ListItemSpacingStyle::Loose);
796        assert_eq!(fixed_once, fixed_twice);
797    }
798
799    #[test]
800    fn fix_idempotent_tight() {
801        let content = "- Item 1\n\n- Item 2\n";
802        let fixed_once = fix(content, ListItemSpacingStyle::Tight);
803        let fixed_twice = fix(&fixed_once, ListItemSpacingStyle::Tight);
804        assert_eq!(fixed_once, fixed_twice);
805    }
806
807    // ── Nested lists ───────────────────────────────────────────────────
808
809    #[test]
810    fn nested_list_does_not_affect_parent() {
811        // Nested items should not trigger warnings for the parent list
812        let content = "- Item 1\n  - Nested A\n  - Nested B\n- Item 2\n";
813        let warnings = check(content, ListItemSpacingStyle::Tight);
814        assert!(
815            warnings.is_empty(),
816            "Nested items should not cause parent-level warnings"
817        );
818    }
819
820    #[test]
821    fn tab_nested_child_is_not_a_sibling() {
822        // A tab before the child's marker puts it at column 4, one level below
823        // the parent items, so the parent list is `parent` and `next` with no
824        // blank line between them and nothing to report. Measuring the child
825        // in bytes puts it at level 0, the parent's own, and reads the blank
826        // line as a loose gap between siblings.
827        let content = "* parent\n\n\t1. child\n* next\n";
828        let warnings = check(content, ListItemSpacingStyle::Consistent);
829        assert!(
830            warnings.is_empty(),
831            "a tab-nested child is not a sibling of the parent items: {warnings:?}"
832        );
833        assert_eq!(fix(content, ListItemSpacingStyle::Consistent), content);
834
835        // Positive control: the same shape with the child at the parent's
836        // level is a real spacing inconsistency.
837        let sibling = "* parent\n\n* child\n* next\n";
838        let warnings = check(sibling, ListItemSpacingStyle::Consistent);
839        assert_eq!(warnings.len(), 1, "{warnings:?}");
840        assert_eq!(warnings[0].line, 2);
841    }
842
843    #[test]
844    fn nested_list_is_analysed_at_its_own_level() {
845        // The nested list mixes a loose gap and a tight gap while the parent
846        // list is tight, so the inconsistency is the nested list's own: the
847        // tie resolves to tight and the blank line between its first two
848        // items is reported and removed. Space and tab indentation nest the
849        // same way.
850        for (label, content, fixed) in [
851            (
852                "spaces",
853                "- parent\n  - a\n\n  - b\n  - c\n- next\n",
854                "- parent\n  - a\n  - b\n  - c\n- next\n",
855            ),
856            (
857                "tab",
858                "* parent\n\t1. child A\n\n\t2. child B\n\t3. child C\n",
859                "* parent\n\t1. child A\n\t2. child B\n\t3. child C\n",
860            ),
861        ] {
862            let warnings = check(content, ListItemSpacingStyle::Consistent);
863            assert_eq!(warnings.len(), 1, "{label}: {warnings:?}");
864            assert_eq!(warnings[0].line, 3, "{label}: {warnings:?}");
865            assert_eq!(
866                warnings[0].message, "Unexpected blank line between list items",
867                "{label}"
868            );
869            assert_eq!(fix(content, ListItemSpacingStyle::Consistent), fixed, "{label}");
870        }
871
872        // Negative control: a nested list that is uniformly loose under a
873        // tight parent is consistent at both levels.
874        let content = "- parent\n  - a\n\n  - b\n- next\n";
875        let warnings = check(content, ListItemSpacingStyle::Consistent);
876        assert!(warnings.is_empty(), "{warnings:?}");
877        assert_eq!(fix(content, ListItemSpacingStyle::Consistent), content);
878    }
879
880    #[test]
881    fn nested_lists_under_different_parents_are_separate_lists() {
882        // `a1`/`a2` and `b1`/`b2` sit at the same nesting level but belong to
883        // different parent items, so each pair is judged on its own: the
884        // first is consistently tight, the second consistently loose, and a
885        // per-level view that ran them together would call the whole set
886        // inconsistent.
887        let content = "- a\n  - a1\n  - a2\n- b\n  - b1\n\n  - b2\n";
888        let warnings = check(content, ListItemSpacingStyle::Consistent);
889        assert!(warnings.is_empty(), "{warnings:?}");
890        assert_eq!(fix(content, ListItemSpacingStyle::Consistent), content);
891
892        // Positive control: the same two pairs under one parent are one list
893        // and its gaps do disagree.
894        let content = "- a\n  - a1\n  - a2\n  - b1\n\n  - b2\n";
895        let warnings = check(content, ListItemSpacingStyle::Consistent);
896        assert_eq!(warnings.len(), 1, "{warnings:?}");
897        assert_eq!(warnings[0].line, 5);
898    }
899
900    #[test]
901    fn every_warning_carries_the_edit_the_fix_applies() {
902        // A warning without an edit reads as unfixable to `check`, counts as
903        // not fixed after `fmt`, and offers no quick fix in an editor. Each
904        // MD076 warning carries its own edit, and applying the edits alone
905        // produces what the document-level fix produces: a removed gap loses
906        // its whole run of blank lines, an inserted blank line carries the
907        // item's blockquote prefix, and line endings are kept.
908        let cases = [
909            ("- a\n\n\n- b\n- c\n", ListItemSpacingStyle::Consistent),
910            ("- a\n- b\n\n- c\n", ListItemSpacingStyle::Loose),
911            ("> - a\n>\n> - b\n> - c\n", ListItemSpacingStyle::Consistent),
912            ("> - a\n> - b\n>\n> - c\n", ListItemSpacingStyle::Loose),
913            ("- p\n  - a\n\n  - b\n  - c\n", ListItemSpacingStyle::Consistent),
914        ];
915        for (content, style) in cases {
916            let warnings = check(content, style.clone());
917            assert!(!warnings.is_empty(), "{content:?}");
918            assert!(warnings.iter().all(|w| w.fix.is_some()), "{content:?}: {warnings:?}");
919            let applied = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
920            let fixed = fix(content, style);
921            assert_eq!(applied, fixed, "{content:?}");
922            assert_ne!(applied, content, "{content:?}");
923        }
924
925        // The edits are byte ranges into the document as written, and an
926        // inserted line ends the way the document's lines do, so a CRLF
927        // document keeps its line endings. The editor applies each edit as
928        // given, so the replacement itself is checked, not only the result of
929        // `apply_warning_fixes`, which restores the document's endings.
930        let content = "- a\r\n\r\n- b\r\n- c\r\n";
931        let warnings = check(content, ListItemSpacingStyle::Consistent);
932        assert_eq!(
933            crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap(),
934            "- a\r\n- b\r\n- c\r\n"
935        );
936        for (content, replacement) in [("- a\r\n- b\r\n\r\n- c\r\n", "\r\n"), ("> - a\r\n> - b\r\n", ">\r\n")] {
937            let warnings = check(content, ListItemSpacingStyle::Loose);
938            assert_eq!(warnings.len(), 1, "{content:?}: {warnings:?}");
939            let fix = warnings[0].fix.as_ref().expect("the warning carries its edit");
940            assert_eq!(fix.replacement, replacement, "{content:?}");
941            assert_eq!(fix.range.start, fix.range.end, "{content:?}: an insertion");
942        }
943        let content = "- a\r\n- b\r\n\r\n- c\r\n";
944        let warnings = check(content, ListItemSpacingStyle::Loose);
945        assert_eq!(
946            crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap(),
947            "- a\r\n\r\n- b\r\n\r\n- c\r\n"
948        );
949    }
950
951    #[test]
952    fn nested_lists_separated_by_parent_content_are_separate_lists() {
953        // A paragraph belonging to the parent item ends the nested list, so
954        // the tight pair before it and the loose pair after it are two lists,
955        // each consistent on its own; the gap around the paragraph is nobody's
956        // inter-item gap.
957        // The same holds for parent content that interrupts a paragraph
958        // without a blank line before it: an HTML comment, or a blockquote
959        // (a bare `>` opens one; under an unquoted list it is not a blank
960        // line, and inside a quoted list a deeper `>` is not one either).
961        for content in [
962            "- p\n  - a\n  - b\n\n  With:\n\n  - c\n\n  - d\n",
963            "- p\n  - a\n  - b\n  <!-- parent comment -->\n  - c\n\n  - d\n",
964            "- p\n  - a\n  - b\n  >\n  - c\n\n  - d\n",
965            "> - p\n>   - a\n>   - b\n>   >\n>   - c\n>\n>   - d\n",
966            "- p\n  - a\n    >\n  parent\n  - c\n\n  - d\n",
967            "- p\n  - a\n  - ```\n  more\n  - c\n\n  - d\n",
968            "- p\n  - a\n  - | h |\n    | --- |\n  more\n  - c\n\n  - d\n",
969        ] {
970            let warnings = check(content, ListItemSpacingStyle::Consistent);
971            assert!(warnings.is_empty(), "{content:?}: {warnings:?}");
972            assert_eq!(fix(content, ListItemSpacingStyle::Consistent), content, "{content:?}");
973        }
974
975        // Positive controls: without the paragraph the four items are one
976        // list whose gaps disagree, and so are they when the dedented line
977        // continues the paragraph an item's text opened (a backtick fence
978        // whose info string holds a backtick is text, not a fence).
979        for (content, line, message) in [
980            (
981                "- p\n  - a\n  - b\n\n  - c\n\n  - d\n",
982                3,
983                "Missing blank line between list items",
984            ),
985            (
986                "- p\n  - a\n  - ```lang`bad\n  more\n  - c\n\n  - d\n",
987                6,
988                "Unexpected blank line between list items",
989            ),
990        ] {
991            let warnings = check(content, ListItemSpacingStyle::Consistent);
992            assert_eq!(warnings.len(), 1, "{content:?}: {warnings:?}");
993            assert_eq!(warnings[0].line, line, "{content:?}");
994            assert_eq!(warnings[0].message, message, "{content:?}");
995        }
996    }
997
998    #[test]
999    fn lists_of_different_marker_types_are_separate_lists() {
1000        // A bullet list followed by an ordered list, or one bullet character
1001        // followed by another, is two lists at that level, so a tight pair
1002        // and a loose pair next to each other are each consistent and the
1003        // blank line between the second pair stays. Nested or not.
1004        for content in [
1005            "- parent\n  - bullet a\n  - bullet b\n  1. ordered a\n\n  2. ordered b\n- next\n",
1006            "- parent\n  - dash a\n  - dash b\n  * star a\n\n  * star b\n- next\n",
1007            "- parent\n  1. dot a\n  2. dot b\n  1) paren a\n\n  2) paren b\n- next\n",
1008            "- dash a\n- dash b\n* star a\n\n* star b\n",
1009        ] {
1010            let warnings = check(content, ListItemSpacingStyle::Consistent);
1011            assert!(warnings.is_empty(), "{content:?}: {warnings:?}");
1012            assert_eq!(fix(content, ListItemSpacingStyle::Consistent), content, "{content:?}");
1013        }
1014
1015        // Positive controls: with one marker type throughout, the four items
1016        // are one list whose gaps disagree, and the blank line goes.
1017        for (content, line) in [
1018            ("- parent\n  - a\n  - b\n  - c\n\n  - d\n- next\n", 5),
1019            ("- parent\n  1. a\n  2. b\n  3. c\n\n  4. d\n- next\n", 5),
1020            ("- a\n- b\n- c\n\n- d\n", 4),
1021        ] {
1022            let warnings = check(content, ListItemSpacingStyle::Consistent);
1023            assert_eq!(warnings.len(), 1, "{content:?}: {warnings:?}");
1024            assert_eq!(warnings[0].line, line, "{content:?}");
1025            assert_eq!(
1026                warnings[0].message, "Unexpected blank line between list items",
1027                "{content:?}"
1028            );
1029        }
1030    }
1031
1032    #[test]
1033    fn siblings_at_a_different_indent_are_not_the_nested_list() {
1034        // The siblings at column 2 sit left of the parent's content column, so
1035        // they continue the outer list, which is loose throughout; the child
1036        // list at column 3 is tight throughout. Neither is reported, and the
1037        // fix leaves the child list tight.
1038        let content = " - parent\n   - child a\n   - child b\n\n  - sibling a\n\n  - sibling b\n";
1039        let warnings = check(content, ListItemSpacingStyle::Consistent);
1040        assert!(warnings.is_empty(), "{warnings:?}");
1041        assert_eq!(fix(content, ListItemSpacingStyle::Consistent), content);
1042
1043        // Inside a blockquote the columns count from the quote's content, so
1044        // an indent before the `>` does not make the child list a sibling of
1045        // its parent: the loose child list is consistent on its own.
1046        let content = " > - parent\n>   - child a\n>\n>   - child b\n";
1047        let warnings = check(content, ListItemSpacingStyle::Consistent);
1048        assert!(warnings.is_empty(), "{warnings:?}");
1049        assert_eq!(fix(content, ListItemSpacingStyle::Consistent), content);
1050
1051        // Positive controls: an inconsistent gap in either list is reported
1052        // against that list, on the blank line that makes it loose.
1053        for (content, line, message) in [
1054            (
1055                " - parent\n   - child a\n   - child b\n\n  - sibling a\n  - sibling b\n",
1056                4,
1057                "Unexpected blank line between list items",
1058            ),
1059            (
1060                " - parent\n   - child a\n\n   - child b\n   - child c\n  - sibling\n",
1061                3,
1062                "Unexpected blank line between list items",
1063            ),
1064            (
1065                " > - parent\n>   - child a\n>\n>   - child b\n>   - child c\n",
1066                3,
1067                "Unexpected blank line between list items",
1068            ),
1069        ] {
1070            let warnings = check(content, ListItemSpacingStyle::Consistent);
1071            assert_eq!(warnings.len(), 1, "{content:?}: {warnings:?}");
1072            assert_eq!(warnings[0].line, line, "{content:?}");
1073            assert_eq!(warnings[0].message, message, "{content:?}");
1074        }
1075    }
1076
1077    #[test]
1078    fn lists_in_different_blockquotes_are_different_lists() {
1079        // A blockquote inside a list item holds its own list; a `>` left of
1080        // the item's content starts another blockquote outside the item, and
1081        // a blank line ends a blockquote, so the items after either are a
1082        // different list. Each list here is consistent on its own, and the
1083        // fix must not remove a blank line that separates two blockquotes.
1084        for content in [
1085            "- p\n  >- b1\n  >\n  >- b2\n>- c\n>- d\n",
1086            "- p\n  > - b1\n  > - b2\n\n  > - b3\n",
1087            "> - a\n>   - b\n>   - c\n\n>   - d\n",
1088        ] {
1089            let warnings = check(content, ListItemSpacingStyle::Consistent);
1090            assert!(warnings.is_empty(), "{content:?}: {warnings:?}");
1091            assert_eq!(fix(content, ListItemSpacingStyle::Consistent), content);
1092        }
1093
1094        // Positive controls: a bare `>` at the list's own depth is the gap
1095        // between its items, whether or not another blockquote follows.
1096        for (content, line, message) in [
1097            (
1098                "- p\n  > - b1\n  > - b2\n  >\n  > - b3\n",
1099                4,
1100                "Unexpected blank line between list items",
1101            ),
1102            (
1103                "- p\n  >- b1\n  >- b2\n  >\n  >- b3\n>- c\n>- d\n",
1104                4,
1105                "Unexpected blank line between list items",
1106            ),
1107        ] {
1108            let warnings = check(content, ListItemSpacingStyle::Consistent);
1109            assert_eq!(warnings.len(), 1, "{content:?}: {warnings:?}");
1110            assert_eq!(warnings[0].line, line, "{content:?}");
1111            assert_eq!(warnings[0].message, message, "{content:?}");
1112        }
1113    }
1114
1115    #[test]
1116    fn a_line_that_ends_the_top_level_list_starts_another_after_it() {
1117        // A fence or HTML block at column 0, or a blank line that ends a
1118        // blockquote, closes the top-level list as well as a nested one. The
1119        // items after it are another list, so there is no gap to judge
1120        // between the two, and a blank line between two blockquotes is not
1121        // list spacing the fix may remove.
1122        for (content, style) in [
1123            ("- p\n```\n```\n- q\n", ListItemSpacingStyle::Loose),
1124            ("- p\n<!-- x -->\n- q\n", ListItemSpacingStyle::Loose),
1125            ("> - a\n> - b\n\n> - c\n", ListItemSpacingStyle::Consistent),
1126            ("> - a\n> - b\n\n> - c\n", ListItemSpacingStyle::Tight),
1127        ] {
1128            let warnings = check(content, style.clone());
1129            assert!(warnings.is_empty(), "{content:?}: {warnings:?}");
1130            assert_eq!(fix(content, style), content);
1131        }
1132
1133        // The blockquote after the fence is not inside `p`, so the quoted
1134        // items are one list and `loose` wants a blank line between them.
1135        let content = "- p\n```\n```\n  > - b\n  lazy\n> - c\n";
1136        let warnings = check(content, ListItemSpacingStyle::Loose);
1137        assert_eq!(warnings.len(), 1, "{warnings:?}");
1138        assert_eq!(warnings[0].line, 6);
1139        assert_eq!(warnings[0].message, "Missing blank line between list items");
1140        assert_eq!(
1141            fix(content, ListItemSpacingStyle::Loose),
1142            "- p\n```\n```\n  > - b\n  lazy\n>\n> - c\n"
1143        );
1144
1145        // Positive control: a bare `>` inside one blockquote is list spacing.
1146        let content = "> - a\n>\n> - b\n> - c\n";
1147        let warnings = check(content, ListItemSpacingStyle::Consistent);
1148        assert_eq!(warnings.len(), 1, "{warnings:?}");
1149        assert_eq!(warnings[0].line, 2);
1150        assert_eq!(warnings[0].message, "Unexpected blank line between list items");
1151    }
1152
1153    #[test]
1154    fn explicit_style_applies_to_nested_lists() {
1155        // `loose` wants a blank line between the nested items too, and the
1156        // fix inserts it there; the parent gap is already loose.
1157        let content = "- a\n\n- b\n  - b1\n  - b2\n";
1158        let warnings = check(content, ListItemSpacingStyle::Loose);
1159        assert_eq!(warnings.len(), 1, "{warnings:?}");
1160        assert_eq!(warnings[0].line, 5);
1161        assert_eq!(warnings[0].message, "Missing blank line between list items");
1162        assert_eq!(
1163            fix(content, ListItemSpacingStyle::Loose),
1164            "- a\n\n- b\n  - b1\n\n  - b2\n"
1165        );
1166
1167        // `tight` removes the blank line between nested items and leaves a
1168        // tight parent alone.
1169        let content = "- a\n- b\n  - b1\n\n  - b2\n";
1170        let warnings = check(content, ListItemSpacingStyle::Tight);
1171        assert_eq!(warnings.len(), 1, "{warnings:?}");
1172        assert_eq!(warnings[0].line, 4);
1173        assert_eq!(fix(content, ListItemSpacingStyle::Tight), "- a\n- b\n  - b1\n  - b2\n");
1174    }
1175
1176    // ── Structural blank lines (code blocks, tables, HTML) ──────────
1177
1178    #[test]
1179    fn code_block_in_tight_list_no_false_positive() {
1180        // Blank line after closing fence is structural (required by MD031), not a separator
1181        let content = "\
1182- Item 1 with code:
1183
1184  ```python
1185  print('hello')
1186  ```
1187
1188- Item 2 simple.
1189- Item 3 simple.
1190";
1191        assert!(
1192            check(content, ListItemSpacingStyle::Consistent).is_empty(),
1193            "Structural blank after code block should not make item 1 appear loose"
1194        );
1195    }
1196
1197    #[test]
1198    fn table_in_tight_list_no_false_positive() {
1199        // Blank line after table is structural (required by MD058), not a separator
1200        let content = "\
1201- Item 1 with table:
1202
1203  | Col 1 | Col 2 |
1204  |-------|-------|
1205  | A     | B     |
1206
1207- Item 2 simple.
1208- Item 3 simple.
1209";
1210        assert!(
1211            check(content, ListItemSpacingStyle::Consistent).is_empty(),
1212            "Structural blank after table should not make item 1 appear loose"
1213        );
1214    }
1215
1216    #[test]
1217    fn html_block_in_tight_list_no_false_positive() {
1218        let content = "\
1219- Item 1 with HTML:
1220
1221  <details>
1222  <summary>Click</summary>
1223  Content
1224  </details>
1225
1226- Item 2 simple.
1227- Item 3 simple.
1228";
1229        assert!(
1230            check(content, ListItemSpacingStyle::Consistent).is_empty(),
1231            "Structural blank after HTML block should not make item 1 appear loose"
1232        );
1233    }
1234
1235    #[test]
1236    fn blockquote_in_tight_list_no_false_positive() {
1237        // Blank line around a blockquote in a list item is structural, not a separator
1238        let content = "\
1239- Item 1 with quote:
1240
1241  > This is a blockquote
1242  > with multiple lines.
1243
1244- Item 2 simple.
1245- Item 3 simple.
1246";
1247        assert!(
1248            check(content, ListItemSpacingStyle::Consistent).is_empty(),
1249            "Structural blank around blockquote should not make item 1 appear loose"
1250        );
1251        assert!(
1252            check(content, ListItemSpacingStyle::Tight).is_empty(),
1253            "Blockquote in tight list should not trigger a violation"
1254        );
1255    }
1256
1257    #[test]
1258    fn blockquote_multiple_items_with_quotes_tight() {
1259        // Multiple items with blockquotes should all be treated as structural
1260        let content = "\
1261- Item 1:
1262
1263  > Quote A
1264
1265- Item 2:
1266
1267  > Quote B
1268
1269- Item 3 plain.
1270";
1271        assert!(
1272            check(content, ListItemSpacingStyle::Tight).is_empty(),
1273            "Multiple items with blockquotes should remain tight"
1274        );
1275    }
1276
1277    #[test]
1278    fn blockquote_mixed_with_genuine_loose_gap() {
1279        // A blockquote item followed by a genuine loose gap should still be detected
1280        let content = "\
1281- Item 1:
1282
1283  > Quote
1284
1285- Item 2 plain.
1286
1287- Item 3 plain.
1288";
1289        let warnings = check(content, ListItemSpacingStyle::Tight);
1290        assert!(
1291            !warnings.is_empty(),
1292            "Genuine loose gap between Item 2 and Item 3 should be flagged"
1293        );
1294    }
1295
1296    #[test]
1297    fn blockquote_single_line_in_tight_list() {
1298        let content = "\
1299- Item 1:
1300
1301  > Single line quote.
1302
1303- Item 2.
1304- Item 3.
1305";
1306        assert!(
1307            check(content, ListItemSpacingStyle::Tight).is_empty(),
1308            "Single-line blockquote should be structural"
1309        );
1310    }
1311
1312    #[test]
1313    fn blockquote_in_ordered_list_tight() {
1314        let content = "\
13151. Item 1:
1316
1317   > Quoted text in ordered list.
1318
13191. Item 2.
13201. Item 3.
1321";
1322        assert!(
1323            check(content, ListItemSpacingStyle::Tight).is_empty(),
1324            "Blockquote in ordered list should be structural"
1325        );
1326    }
1327
1328    #[test]
1329    fn nested_blockquote_in_tight_list() {
1330        let content = "\
1331- Item 1:
1332
1333  > Outer quote
1334  > > Nested quote
1335
1336- Item 2.
1337- Item 3.
1338";
1339        assert!(
1340            check(content, ListItemSpacingStyle::Tight).is_empty(),
1341            "Nested blockquote in tight list should be structural"
1342        );
1343    }
1344
1345    #[test]
1346    fn blockquote_as_entire_item_is_loose() {
1347        // When a blockquote IS the item content (not nested within text),
1348        // a trailing blank line is a genuine loose gap, not structural.
1349        let content = "\
1350- > Quote is the entire item content.
1351
1352- Item 2.
1353- Item 3.
1354";
1355        let warnings = check(content, ListItemSpacingStyle::Tight);
1356        assert!(
1357            !warnings.is_empty(),
1358            "Blank after blockquote-only item is a genuine loose gap"
1359        );
1360    }
1361
1362    #[test]
1363    fn mixed_code_and_table_in_tight_list() {
1364        let content = "\
13651. Item with code:
1366
1367   ```markdown
1368   This is some Markdown
1369   ```
1370
13711. Simple item.
13721. Item with table:
1373
1374   | Col 1 | Col 2 |
1375   |:------|:------|
1376   | Row 1 | Row 1 |
1377   | Row 2 | Row 2 |
1378";
1379        assert!(
1380            check(content, ListItemSpacingStyle::Consistent).is_empty(),
1381            "Mix of code blocks and tables should not cause false positives"
1382        );
1383    }
1384
1385    #[test]
1386    fn code_block_with_genuinely_loose_gaps_still_warns() {
1387        // Item 1 has structural blank (code block), items 2-3 have genuine blank separator
1388        // Items 2-3 are genuinely loose, item 3-4 is tight → inconsistent
1389        let content = "\
1390- Item 1:
1391
1392  ```bash
1393  echo hi
1394  ```
1395
1396- Item 2
1397
1398- Item 3
1399- Item 4
1400";
1401        let warnings = check(content, ListItemSpacingStyle::Consistent);
1402        assert!(
1403            !warnings.is_empty(),
1404            "Genuine inconsistency with code blocks should still be flagged"
1405        );
1406    }
1407
1408    #[test]
1409    fn all_items_have_code_blocks_no_warnings() {
1410        let content = "\
1411- Item 1:
1412
1413  ```python
1414  print(1)
1415  ```
1416
1417- Item 2:
1418
1419  ```python
1420  print(2)
1421  ```
1422
1423- Item 3:
1424
1425  ```python
1426  print(3)
1427  ```
1428";
1429        assert!(
1430            check(content, ListItemSpacingStyle::Consistent).is_empty(),
1431            "All items with code blocks should be consistently tight"
1432        );
1433    }
1434
1435    #[test]
1436    fn tilde_fence_code_block_in_list() {
1437        let content = "\
1438- Item 1:
1439
1440  ~~~
1441  code here
1442  ~~~
1443
1444- Item 2 simple.
1445- Item 3 simple.
1446";
1447        assert!(
1448            check(content, ListItemSpacingStyle::Consistent).is_empty(),
1449            "Tilde fences should be recognized as structural content"
1450        );
1451    }
1452
1453    #[test]
1454    fn nested_list_with_code_block() {
1455        let content = "\
1456- Item 1
1457  - Nested with code:
1458
1459    ```
1460    nested code
1461    ```
1462
1463  - Nested simple.
1464- Item 2
1465";
1466        assert!(
1467            check(content, ListItemSpacingStyle::Consistent).is_empty(),
1468            "Nested list with code block should not cause false positives"
1469        );
1470    }
1471
1472    #[test]
1473    fn tight_style_with_code_block_no_warnings() {
1474        let content = "\
1475- Item 1:
1476
1477  ```
1478  code
1479  ```
1480
1481- Item 2.
1482- Item 3.
1483";
1484        assert!(
1485            check(content, ListItemSpacingStyle::Tight).is_empty(),
1486            "Tight style should not warn about structural blanks around code blocks"
1487        );
1488    }
1489
1490    #[test]
1491    fn loose_style_with_code_block_missing_separator() {
1492        // Loose style requires blank line between every pair of items.
1493        // Items 2-3 have no blank → should warn
1494        let content = "\
1495- Item 1:
1496
1497  ```
1498  code
1499  ```
1500
1501- Item 2.
1502- Item 3.
1503";
1504        let warnings = check(content, ListItemSpacingStyle::Loose);
1505        assert_eq!(
1506            warnings.len(),
1507            1,
1508            "Loose style should still require blank between simple items"
1509        );
1510        assert!(warnings[0].message.contains("Missing"));
1511    }
1512
1513    #[test]
1514    fn blockquote_list_with_code_block() {
1515        let content = "\
1516> - Item 1:
1517>
1518>   ```
1519>   code
1520>   ```
1521>
1522> - Item 2.
1523> - Item 3.
1524";
1525        assert!(
1526            check(content, ListItemSpacingStyle::Consistent).is_empty(),
1527            "Blockquote-prefixed list with code block should not cause false positives"
1528        );
1529    }
1530
1531    // ── Indented code block (not fenced) in list item ─────────────────
1532
1533    #[test]
1534    fn indented_code_block_in_list_no_false_positive() {
1535        // A 4-space indented code block inside a list item should be treated
1536        // as structural content, not trigger a loose gap detection.
1537        let content = "\
15381. Item with indented code:
1539
1540       some code here
1541       more code
1542
15431. Simple item
15441. Another item
1545";
1546        assert!(
1547            check(content, ListItemSpacingStyle::Consistent).is_empty(),
1548            "Structural blank after indented code block should not make item 1 appear loose"
1549        );
1550    }
1551
1552    // ── Issue #787: the marker-line exemption ends where MD031 does ───
1553
1554    #[test]
1555    fn fence_on_marker_line_keeps_its_structural_blank() {
1556        // A fence opened on the marker line itself needs the blank line above it
1557        // (MD031), so tight mode must not remove it. One to four spaces after the
1558        // marker all leave the fence at the item's content column, so all four are
1559        // genuine fenced blocks.
1560        for spaces in 1..=4 {
1561            let pad = " ".repeat(spaces);
1562            let indent = " ".repeat(spaces + 1);
1563            let content = format!("- a\n\n-{pad}```\n{indent}code\n{indent}```\n- c\n");
1564            assert!(
1565                check(&content, ListItemSpacingStyle::Tight).is_empty(),
1566                "a fence on the marker line with {spaces} space(s) opens a fenced block, so its blank is structural"
1567            );
1568            assert_eq!(
1569                fix(&content, ListItemSpacingStyle::Tight),
1570                content,
1571                "tight fix must keep the blank MD031 requires ({spaces} space(s))"
1572            );
1573        }
1574    }
1575
1576    #[test]
1577    fn over_indented_fence_on_marker_line_is_an_indented_block_not_an_exemption() {
1578        // Five spaces after the marker put the content column at 2, leaving the
1579        // fence at a relative indent of 4: an *indented* code block, which MD031
1580        // says nothing about. The blank above it is an ordinary loose separator and
1581        // tight mode must still remove it.
1582        for fence in ["```", "~~~"] {
1583            let content = format!("- a\n\n-     {fence}\n      code\n      {fence}\n- c\n");
1584            let warnings = check(&content, ListItemSpacingStyle::Tight);
1585            assert_eq!(
1586                warnings.len(),
1587                1,
1588                "no fenced block starts here, so the blank is a loose gap ({fence}): {warnings:?}"
1589            );
1590            assert_eq!(
1591                fix(&content, ListItemSpacingStyle::Tight),
1592                format!("- a\n-     {fence}\n      code\n      {fence}\n- c\n"),
1593                "tight fix must remove a blank that MD031 does not require ({fence})"
1594            );
1595        }
1596    }
1597
1598    // ── Code block in middle of item with text after ────────────────
1599
1600    #[test]
1601    fn code_block_in_middle_of_item_text_after_is_genuinely_loose() {
1602        // When a code block is in the middle of an item and there's regular text
1603        // after it, a blank line before the next item IS a genuine separator (loose),
1604        // not structural. The last non-blank line before item 2 is "Some text after
1605        // the code block." which is NOT structural content.
1606        let content = "\
16071. Item with code in middle:
1608
1609   ```
1610   code
1611   ```
1612
1613   Some text after the code block.
1614
16151. Simple item
16161. Another item
1617";
1618        let warnings = check(content, ListItemSpacingStyle::Consistent);
1619        assert!(
1620            !warnings.is_empty(),
1621            "Blank line after regular text (not structural content) is a genuine loose gap"
1622        );
1623    }
1624
1625    // ── Fix: tight mode preserves structural blanks ──────────────────
1626
1627    #[test]
1628    fn tight_fix_preserves_structural_blanks_around_code_blocks() {
1629        // When style is tight, the fix should NOT remove structural blank lines
1630        // around code blocks inside list items. Those blanks are required by MD031.
1631        let content = "\
1632- Item 1:
1633
1634  ```
1635  code
1636  ```
1637
1638- Item 2.
1639- Item 3.
1640";
1641        let fixed = fix(content, ListItemSpacingStyle::Tight);
1642        assert_eq!(
1643            fixed, content,
1644            "Tight fix should not remove structural blanks around code blocks"
1645        );
1646    }
1647
1648    // ── Issue #461: 4-space indented code block in loose list ──────────
1649
1650    #[test]
1651    fn four_space_indented_fence_in_loose_list_no_false_positive() {
1652        // Reproduction case from issue #461 comment by @sisp.
1653        // The fenced code block uses 4-space indentation inside an ordered list.
1654        // The blank line after the closing fence is structural (required by MD031)
1655        // and must not create a false "Missing blank line" warning.
1656        let content = "\
16571. First item
1658
16591. Second item with code block:
1660
1661    ```json
1662    {\"key\": \"value\"}
1663    ```
1664
16651. Third item
1666";
1667        assert!(
1668            check(content, ListItemSpacingStyle::Consistent).is_empty(),
1669            "Structural blank after 4-space indented code block should not cause false positive"
1670        );
1671    }
1672
1673    #[test]
1674    fn four_space_indented_fence_tight_style_no_warnings() {
1675        let content = "\
16761. First item
16771. Second item with code block:
1678
1679    ```json
1680    {\"key\": \"value\"}
1681    ```
1682
16831. Third item
1684";
1685        assert!(
1686            check(content, ListItemSpacingStyle::Tight).is_empty(),
1687            "Tight style should not warn about structural blanks with 4-space fences"
1688        );
1689    }
1690
1691    #[test]
1692    fn four_space_indented_fence_loose_style_no_warnings() {
1693        // All non-structural gaps are loose, structural gaps are excluded.
1694        let content = "\
16951. First item
1696
16971. Second item with code block:
1698
1699    ```json
1700    {\"key\": \"value\"}
1701    ```
1702
17031. Third item
1704";
1705        assert!(
1706            check(content, ListItemSpacingStyle::Loose).is_empty(),
1707            "Loose style should not warn when structural gaps are the only non-loose gaps"
1708        );
1709    }
1710
1711    #[test]
1712    fn structural_gap_with_genuine_inconsistency_still_warns() {
1713        // Item 1 has a structural code block. Items 2-3 are genuinely loose,
1714        // but items 3-4 are tight → genuine inconsistency should still warn.
1715        let content = "\
17161. First item with code:
1717
1718    ```json
1719    {\"key\": \"value\"}
1720    ```
1721
17221. Second item
1723
17241. Third item
17251. Fourth item
1726";
1727        let warnings = check(content, ListItemSpacingStyle::Consistent);
1728        assert!(
1729            !warnings.is_empty(),
1730            "Genuine loose/tight inconsistency should still warn even with structural gaps"
1731        );
1732    }
1733
1734    #[test]
1735    fn four_space_fence_fix_is_idempotent() {
1736        // Fix should not modify a list that has only structural gaps and
1737        // genuine loose gaps — it's already consistent.
1738        let content = "\
17391. First item
1740
17411. Second item with code block:
1742
1743    ```json
1744    {\"key\": \"value\"}
1745    ```
1746
17471. Third item
1748";
1749        let fixed = fix(content, ListItemSpacingStyle::Consistent);
1750        assert_eq!(fixed, content, "Fix should be a no-op for lists with structural gaps");
1751        let fixed_twice = fix(&fixed, ListItemSpacingStyle::Consistent);
1752        assert_eq!(fixed, fixed_twice, "Fix should be idempotent");
1753    }
1754
1755    #[test]
1756    fn four_space_fence_fix_does_not_insert_duplicate_blank() {
1757        // When tight style tries to fix, it should not insert a blank line
1758        // before item 3 when one already exists (structural).
1759        let content = "\
17601. First item
17611. Second item with code block:
1762
1763    ```json
1764    {\"key\": \"value\"}
1765    ```
1766
17671. Third item
1768";
1769        let fixed = fix(content, ListItemSpacingStyle::Tight);
1770        assert_eq!(fixed, content, "Tight fix should not modify structural blanks");
1771    }
1772
1773    #[test]
1774    fn mkdocs_flavor_code_block_in_list_no_false_positive() {
1775        // MkDocs flavor with code block inside a list item.
1776        // Reported by @sisp in issue #461 comment.
1777        let content = "\
17781. First item
1779
17801. Second item with code block:
1781
1782    ```json
1783    {\"key\": \"value\"}
1784    ```
1785
17861. Third item
1787";
1788        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1789        let rule = MD076ListItemSpacing::new(ListItemSpacingStyle::Consistent);
1790        let warnings = rule.check(&ctx).unwrap();
1791        assert!(
1792            warnings.is_empty(),
1793            "MkDocs flavor with structural code block blank should not produce false positive, got: {warnings:?}"
1794        );
1795    }
1796
1797    // ── Issue #500: code block inside list item splits list blocks ─────
1798
1799    #[test]
1800    fn code_block_in_second_item_detects_inconsistency() {
1801        // A code block inside item 2 must not split the list into separate blocks.
1802        // Items 1-2 are tight, items 3-4 are loose → inconsistent.
1803        let content = "\
1804# Test
1805
1806- Lorem ipsum dolor sit amet.
1807- Lorem ipsum dolor sit amet.
1808
1809    ```yaml
1810    hello: world
1811    ```
1812
1813- Lorem ipsum dolor sit amet.
1814
1815- Lorem ipsum dolor sit amet.
1816";
1817        let warnings = check(content, ListItemSpacingStyle::Consistent);
1818        assert!(
1819            !warnings.is_empty(),
1820            "Should detect inconsistent spacing when code block is inside a list item"
1821        );
1822    }
1823
1824    #[test]
1825    fn code_block_in_item_all_tight_no_warnings() {
1826        // All non-structural gaps are tight → consistent, no warnings.
1827        let content = "\
1828- Item 1
1829- Item 2
1830
1831    ```yaml
1832    hello: world
1833    ```
1834
1835- Item 3
1836- Item 4
1837";
1838        assert!(
1839            check(content, ListItemSpacingStyle::Consistent).is_empty(),
1840            "All tight gaps with structural code block should not warn"
1841        );
1842    }
1843
1844    #[test]
1845    fn code_block_in_item_all_loose_no_warnings() {
1846        // All non-structural gaps are loose → consistent, no warnings.
1847        let content = "\
1848- Item 1
1849
1850- Item 2
1851
1852    ```yaml
1853    hello: world
1854    ```
1855
1856- Item 3
1857
1858- Item 4
1859";
1860        assert!(
1861            check(content, ListItemSpacingStyle::Consistent).is_empty(),
1862            "All loose gaps with structural code block should not warn"
1863        );
1864    }
1865
1866    #[test]
1867    fn code_block_in_ordered_list_detects_inconsistency() {
1868        let content = "\
18691. First item
18701. Second item
1871
1872    ```json
1873    {\"key\": \"value\"}
1874    ```
1875
18761. Third item
1877
18781. Fourth item
1879";
1880        let warnings = check(content, ListItemSpacingStyle::Consistent);
1881        assert!(
1882            !warnings.is_empty(),
1883            "Ordered list with code block should still detect inconsistency"
1884        );
1885    }
1886
1887    #[test]
1888    fn code_block_in_item_fix_removes_loose_outlier_on_tie() {
1889        // Gap classification: 1→2 tight, 2→3 structural (excluded — fenced
1890        // code block in the body of item 2), 3→4 loose. After excluding the
1891        // structural gap, that's a 1 tight / 1 loose tie. The tight
1892        // tie-breaker (analyze_block) warns the loose gap, so fix removes the
1893        // blank between items 3 and 4 rather than adding one between 1 and 2.
1894        let content = "\
1895- Item 1
1896- Item 2
1897
1898    ```yaml
1899    code: here
1900    ```
1901
1902- Item 3
1903
1904- Item 4
1905";
1906        let fixed = fix(content, ListItemSpacingStyle::Consistent);
1907        assert!(
1908            fixed.contains("- Item 3\n- Item 4"),
1909            "Fix should remove blank line between items 3 and 4. Got:\n{fixed}"
1910        );
1911        assert!(
1912            !fixed.contains("- Item 1\n\n- Item 2"),
1913            "Fix should not insert a blank between items 1 and 2. Got:\n{fixed}"
1914        );
1915    }
1916
1917    #[test]
1918    fn tilde_code_block_in_item_detects_inconsistency() {
1919        let content = "\
1920- Item 1
1921- Item 2
1922
1923    ~~~
1924    code
1925    ~~~
1926
1927- Item 3
1928
1929- Item 4
1930";
1931        let warnings = check(content, ListItemSpacingStyle::Consistent);
1932        assert!(
1933            !warnings.is_empty(),
1934            "Tilde code block inside item should not prevent inconsistency detection"
1935        );
1936    }
1937
1938    #[test]
1939    fn multiple_code_blocks_all_tight_no_warnings() {
1940        // All non-structural gaps are tight → consistent.
1941        let content = "\
1942- Item 1
1943
1944    ```
1945    code1
1946    ```
1947
1948- Item 2
1949
1950    ```
1951    code2
1952    ```
1953
1954- Item 3
1955- Item 4
1956";
1957        assert!(
1958            check(content, ListItemSpacingStyle::Consistent).is_empty(),
1959            "All non-structural gaps are tight, so list is consistent"
1960        );
1961    }
1962
1963    #[test]
1964    fn code_block_with_mixed_genuine_gaps_warns() {
1965        // Items 1-2 structural, 2-3 loose, 3-4 tight → genuine inconsistency
1966        let content = "\
1967- Item 1
1968
1969    ```
1970    code1
1971    ```
1972
1973- Item 2
1974
1975- Item 3
1976- Item 4
1977";
1978        let warnings = check(content, ListItemSpacingStyle::Consistent);
1979        assert!(
1980            !warnings.is_empty(),
1981            "Mixed genuine gaps (loose + tight) with structural code block should still warn"
1982        );
1983    }
1984
1985    // ── allow-loose-continuation ─────────────────────────────────────
1986
1987    #[test]
1988    fn continuation_loose_tight_style_default_warns() {
1989        // Default (allow_loose_continuation=false): blank lines around
1990        // continuation paragraphs are treated as loose gaps → violation
1991        let content = "\
1992- Item 1.
1993
1994  Continuation paragraph.
1995
1996- Item 2.
1997
1998  Continuation paragraph.
1999
2000- Item 3.
2001";
2002        let warnings = check_with_continuation(content, ListItemSpacingStyle::Tight, false);
2003        assert!(
2004            !warnings.is_empty(),
2005            "Should warn about loose gaps when allow_loose_continuation is false"
2006        );
2007    }
2008
2009    #[test]
2010    fn continuation_loose_tight_style_allowed_no_warnings() {
2011        // With allow_loose_continuation=true: blank lines around continuation
2012        // paragraphs are permitted even in tight mode
2013        let content = "\
2014- Item 1.
2015
2016  Continuation paragraph.
2017
2018- Item 2.
2019
2020  Continuation paragraph.
2021
2022- Item 3.
2023";
2024        let warnings = check_with_continuation(content, ListItemSpacingStyle::Tight, true);
2025        assert!(
2026            warnings.is_empty(),
2027            "Should not warn when allow_loose_continuation is true, got: {warnings:?}"
2028        );
2029    }
2030
2031    #[test]
2032    fn continuation_loose_mixed_items_warns() {
2033        // Even with allow_loose_continuation, genuinely loose inter-item gaps
2034        // (blank line between items that have no continuation) should still warn
2035        let content = "\
2036- Item 1.
2037
2038- Item 2.
2039- Item 3.
2040";
2041        let warnings = check_with_continuation(content, ListItemSpacingStyle::Tight, true);
2042        assert!(
2043            !warnings.is_empty(),
2044            "Genuine loose gaps should still warn even with allow_loose_continuation"
2045        );
2046    }
2047
2048    #[test]
2049    fn continuation_loose_consistent_mode() {
2050        // In consistent mode with allow_loose_continuation, continuation gaps
2051        // should not count toward loose/tight consistency
2052        let content = "\
2053- Item 1.
2054
2055  Continuation paragraph.
2056
2057- Item 2.
2058- Item 3.
2059";
2060        let warnings = check_with_continuation(content, ListItemSpacingStyle::Consistent, true);
2061        assert!(
2062            warnings.is_empty(),
2063            "Continuation gaps should not affect consistency when allowed, got: {warnings:?}"
2064        );
2065    }
2066
2067    #[test]
2068    fn continuation_loose_fix_preserves_continuation_blanks() {
2069        let content = "\
2070- Item 1.
2071
2072  Continuation paragraph.
2073
2074- Item 2.
2075
2076  Continuation paragraph.
2077
2078- Item 3.
2079";
2080        let fixed = fix_with_continuation(content, ListItemSpacingStyle::Tight, true);
2081        assert_eq!(fixed, content, "Fix should preserve continuation blank lines");
2082    }
2083
2084    #[test]
2085    fn continuation_loose_fix_removes_genuine_loose_gaps() {
2086        let input = "\
2087- Item 1.
2088
2089- Item 2.
2090
2091- Item 3.
2092";
2093        let expected = "\
2094- Item 1.
2095- Item 2.
2096- Item 3.
2097";
2098        let fixed = fix_with_continuation(input, ListItemSpacingStyle::Tight, true);
2099        assert_eq!(fixed, expected);
2100    }
2101
2102    #[test]
2103    fn continuation_loose_ordered_list() {
2104        let content = "\
21051. Item 1.
2106
2107   Continuation paragraph.
2108
21092. Item 2.
2110
2111   Continuation paragraph.
2112
21133. Item 3.
2114";
2115        let warnings = check_with_continuation(content, ListItemSpacingStyle::Tight, true);
2116        assert!(
2117            warnings.is_empty(),
2118            "Ordered list continuation should work too, got: {warnings:?}"
2119        );
2120    }
2121
2122    #[test]
2123    fn continuation_loose_disabled_by_default() {
2124        // Verify the constructor defaults to false
2125        let rule = MD076ListItemSpacing::new(ListItemSpacingStyle::Tight);
2126        assert!(!rule.config.allow_loose_continuation);
2127    }
2128
2129    #[test]
2130    fn continuation_loose_ordered_under_indented_ends_the_list() {
2131        // "1. " puts the item's content at column 3, so text at column 2
2132        // after a blank line is not a continuation of the item: it ends the
2133        // list, and the items after it are a list of their own, tight and
2134        // consistent. Nothing to report, in either style, whether or not
2135        // continuation gaps are allowed. Text at column 3 continues the item,
2136        // and its gap is a continuation gap that the default rejects, reported
2137        // at the blank line the fix removes, the one before the next item.
2138        let content = "\
21391. Item 1.
2140
2141  Under-indented text.
2142
21431. Item 2.
21441. Item 3.
2145";
2146        for (style, allow) in [
2147            (ListItemSpacingStyle::Tight, true),
2148            (ListItemSpacingStyle::Tight, false),
2149            (ListItemSpacingStyle::Consistent, false),
2150        ] {
2151            let warnings = check_with_continuation(content, style, allow);
2152            assert!(warnings.is_empty(), "{content:?}: {warnings:?}");
2153        }
2154        let content = "\
21551. Item 1.
2156
2157   Continuation text.
2158
21591. Item 2.
21601. Item 3.
2161";
2162        let warnings = check_with_continuation(content, ListItemSpacingStyle::Tight, false);
2163        assert_eq!(warnings.len(), 1, "{content:?}: {warnings:?}");
2164        assert_eq!(warnings[0].line, 4);
2165        assert_eq!(warnings[0].message, "Unexpected blank line between list items");
2166    }
2167
2168    #[test]
2169    fn continuation_loose_mix_continuation_and_genuine_gaps() {
2170        // Some items have continuation (allowed), one gap is genuinely loose (not allowed)
2171        let content = "\
2172- Item 1.
2173
2174  Continuation paragraph.
2175
2176- Item 2.
2177
2178- Item 3.
2179";
2180        let warnings = check_with_continuation(content, ListItemSpacingStyle::Tight, true);
2181        assert!(
2182            !warnings.is_empty(),
2183            "Genuine loose gap between items 2-3 should warn even with continuation allowed"
2184        );
2185        // Only the genuine loose gap should warn, not the continuation gap
2186        assert_eq!(
2187            warnings.len(),
2188            1,
2189            "Expected exactly one warning for the genuine loose gap"
2190        );
2191    }
2192
2193    #[test]
2194    fn continuation_loose_fix_mixed_preserves_continuation_removes_genuine() {
2195        // Fix should preserve continuation blanks but remove genuine loose gaps
2196        let input = "\
2197- Item 1.
2198
2199  Continuation paragraph.
2200
2201- Item 2.
2202
2203- Item 3.
2204";
2205        let expected = "\
2206- Item 1.
2207
2208  Continuation paragraph.
2209
2210- Item 2.
2211- Item 3.
2212";
2213        let fixed = fix_with_continuation(input, ListItemSpacingStyle::Tight, true);
2214        assert_eq!(fixed, expected);
2215    }
2216
2217    #[test]
2218    fn continuation_loose_after_code_block() {
2219        // Code block is structural, continuation after code block should also work
2220        let content = "\
2221- Item 1.
2222
2223  ```python
2224  code
2225  ```
2226
2227  Continuation after code.
2228
2229- Item 2.
2230- Item 3.
2231";
2232        let warnings = check_with_continuation(content, ListItemSpacingStyle::Tight, true);
2233        assert!(
2234            warnings.is_empty(),
2235            "Code block + continuation should both be exempt, got: {warnings:?}"
2236        );
2237    }
2238
2239    #[test]
2240    fn continuation_loose_style_does_not_interfere() {
2241        // With style=loose, allow-loose-continuation shouldn't change behavior —
2242        // loose style already requires blank lines everywhere
2243        let content = "\
2244- Item 1.
2245
2246  Continuation paragraph.
2247
2248- Item 2.
2249
2250  Continuation paragraph.
2251
2252- Item 3.
2253";
2254        let warnings = check_with_continuation(content, ListItemSpacingStyle::Loose, true);
2255        assert!(
2256            warnings.is_empty(),
2257            "Loose style with continuation should not warn, got: {warnings:?}"
2258        );
2259    }
2260
2261    #[test]
2262    fn continuation_loose_tight_no_continuation_content() {
2263        // All items are simple (no continuation), tight style should work normally
2264        let content = "\
2265- Item 1.
2266- Item 2.
2267- Item 3.
2268";
2269        let warnings = check_with_continuation(content, ListItemSpacingStyle::Tight, true);
2270        assert!(
2271            warnings.is_empty(),
2272            "Simple tight list should pass with allow_loose_continuation, got: {warnings:?}"
2273        );
2274    }
2275
2276    // ── Config schema ──────────────────────────────────────────────────
2277
2278    #[test]
2279    fn default_config_section_provides_style_key() {
2280        let rule = MD076ListItemSpacing::new(ListItemSpacingStyle::Consistent);
2281        let section = rule.default_config_section();
2282        assert!(section.is_some());
2283        let (name, value) = section.unwrap();
2284        assert_eq!(name, "MD076");
2285        if let toml::Value::Table(map) = value {
2286            assert!(map.contains_key("style"));
2287            assert!(map.contains_key("allow-loose-continuation"));
2288        } else {
2289            panic!("Expected Table value from default_config_section");
2290        }
2291    }
2292}