Skip to main content

rumdl_lib/rules/
md076_list_item_spacing.rs

1use crate::lint_context::LintContext;
2use crate::rule::{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) after 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-block analysis result shared by check() and fix().
62struct BlockAnalysis {
63    /// 1-indexed line numbers of items at this block's nesting level.
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 non-blank line is continuation content within a list item
140    /// (indented prose that is not itself a list marker or structural content).
141    ///
142    /// `parent_content_col` is the content column of the parent list item marker
143    /// (e.g., 2 for `- item`, 3 for `1. item`). Continuation must be indented
144    /// to at least this column to belong to the parent item.
145    fn is_continuation_content(ctx: &LintContext, line_num: usize, parent_content_col: usize) -> bool {
146        let Some(info) = ctx.line_info(line_num) else {
147            return false;
148        };
149        // Lines with a list marker are items, not continuation
150        if info.list_item.is_some() {
151            return false;
152        }
153        // Structural content is handled separately by is_structural_content
154        if info.in_code_block
155            || info.in_html_block
156            || info.in_html_comment
157            || info.in_mdx_comment
158            || info.in_front_matter
159            || info.in_math_block
160            || info.blockquote.is_some()
161        {
162            return false;
163        }
164        let content = info.content(ctx.content);
165        if content.trim().is_empty() {
166            return false;
167        }
168        // Continuation must be indented to at least the parent item's content column
169        let indent = content.len() - content.trim_start().len();
170        indent >= parent_content_col
171    }
172
173    /// Classify the inter-item gap between two consecutive items.
174    ///
175    /// Returns `Tight` if there is no blank line, `Loose` if there is a genuine
176    /// inter-item separator blank, `Structural` if the only blank line is
177    /// required by another rule (MD031/MD058) after structural content, or
178    /// `ContinuationLoose` if the blank line follows continuation content
179    /// within a list item.
180    fn classify_gap(ctx: &LintContext, first: usize, next: usize) -> GapKind {
181        if next <= first + 1 {
182            return GapKind::Tight;
183        }
184        // The gap has a blank line only if the line immediately before the next item is blank.
185        if !Self::is_effectively_blank(ctx, next - 1) {
186            return GapKind::Tight;
187        }
188        // Walk backwards past blank lines to find the last non-blank content line.
189        // If that line is structural content, the blank is required (not a separator).
190        let mut scan = next - 1;
191        while scan > first && Self::is_effectively_blank(ctx, scan) {
192            scan -= 1;
193        }
194        // `scan` is now the last non-blank line before the next item
195        if scan > first && Self::is_structural_content(ctx, scan) {
196            return GapKind::Structural;
197        }
198        // Check if the last non-blank line is continuation content.
199        // Use the first item's content column to verify proper indentation.
200        let parent_content_col = ctx
201            .line_info(first)
202            .and_then(|li| li.list_item.as_ref())
203            .map_or(2, |item| item.content_column);
204        if scan > first && Self::is_continuation_content(ctx, scan, parent_content_col) {
205            return GapKind::ContinuationLoose;
206        }
207        GapKind::Loose
208    }
209
210    /// Collect the 1-indexed line numbers of all inter-item blank lines in the gap.
211    ///
212    /// Walks backwards from the line before `next` collecting consecutive blank lines.
213    /// These are the actual separator lines between items, not blank lines within
214    /// multi-paragraph items. Structural blanks (after code blocks, tables, HTML blocks)
215    /// are excluded.
216    fn inter_item_blanks(ctx: &LintContext, first: usize, next: usize) -> Vec<usize> {
217        let mut blanks = Vec::new();
218        let mut line_num = next - 1;
219        while line_num > first && Self::is_effectively_blank(ctx, line_num) {
220            blanks.push(line_num);
221            line_num -= 1;
222        }
223        // If the last non-blank line is structural content, these blanks are structural
224        if line_num > first && Self::is_structural_content(ctx, line_num) {
225            return Vec::new();
226        }
227        blanks.reverse();
228        blanks
229    }
230
231    /// Analyze a single list block to determine which gaps need fixing.
232    ///
233    /// Returns `None` if the block has fewer than 2 items at its nesting level
234    /// or if no gaps violate the configured style.
235    fn analyze_block(
236        ctx: &LintContext,
237        block: &crate::lint_context::types::ListBlock,
238        style: &ListItemSpacingStyle,
239        allow_loose_continuation: bool,
240    ) -> Option<BlockAnalysis> {
241        // Only compare items at this block's own nesting level.
242        // item_lines may include nested list items (higher marker_column) that belong
243        // to a child list — those must not affect spacing analysis.
244        let items: Vec<usize> = block
245            .item_lines
246            .iter()
247            .copied()
248            .filter(|&line_num| {
249                ctx.line_info(line_num)
250                    .and_then(|li| li.list_item.as_ref())
251                    .is_some_and(|item| item.marker_column / 2 == block.nesting_level)
252            })
253            .collect();
254
255        if items.len() < 2 {
256            return None;
257        }
258
259        // Classify each inter-item gap.
260        let gaps: Vec<GapKind> = items.windows(2).map(|w| Self::classify_gap(ctx, w[0], w[1])).collect();
261
262        // Structural gaps and (when allowed) continuation gaps are excluded
263        // from consistency analysis — they should not influence whether the
264        // list is considered loose or tight.
265        let loose_count = gaps
266            .iter()
267            .filter(|&&g| g == GapKind::Loose || (g == GapKind::ContinuationLoose && !allow_loose_continuation))
268            .count();
269        let tight_count = gaps.iter().filter(|&&g| g == GapKind::Tight).count();
270
271        let (warn_loose_gaps, warn_tight_gaps) = match style {
272            ListItemSpacingStyle::Loose => (false, true),
273            ListItemSpacingStyle::Tight => (true, false),
274            ListItemSpacingStyle::Consistent => {
275                if loose_count == 0 || tight_count == 0 {
276                    return None; // Already consistent (structural gaps excluded)
277                }
278                // Majority wins; on a tie, prefer loose (warn tight).
279                if loose_count >= tight_count {
280                    (false, true)
281                } else {
282                    (true, false)
283                }
284            }
285        };
286
287        Some(BlockAnalysis {
288            items,
289            gaps,
290            warn_loose_gaps,
291            warn_tight_gaps,
292        })
293    }
294}
295
296impl Rule for MD076ListItemSpacing {
297    fn name(&self) -> &'static str {
298        "MD076"
299    }
300
301    fn description(&self) -> &'static str {
302        "List item spacing should be consistent"
303    }
304
305    fn category(&self) -> RuleCategory {
306        RuleCategory::List
307    }
308
309    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
310        ctx.content.is_empty() || ctx.list_blocks.is_empty()
311    }
312
313    fn check(&self, ctx: &LintContext) -> LintResult {
314        if ctx.content.is_empty() {
315            return Ok(Vec::new());
316        }
317
318        let mut warnings = Vec::new();
319
320        let allow_cont = self.config.allow_loose_continuation;
321
322        for block in &ctx.list_blocks {
323            let Some(analysis) = Self::analyze_block(ctx, block, &self.config.style, allow_cont) else {
324                continue;
325            };
326
327            for (i, &gap) in analysis.gaps.iter().enumerate() {
328                let is_loose_violation = match gap {
329                    GapKind::Loose => analysis.warn_loose_gaps,
330                    GapKind::ContinuationLoose => !allow_cont && analysis.warn_loose_gaps,
331                    _ => false,
332                };
333
334                if is_loose_violation {
335                    let blanks = Self::inter_item_blanks(ctx, analysis.items[i], analysis.items[i + 1]);
336                    if let Some(&blank_line) = blanks.first() {
337                        let line_content = ctx.line_info(blank_line).map_or("", |li| li.content(ctx.content));
338                        warnings.push(LintWarning {
339                            rule_name: Some(self.name().to_string()),
340                            line: blank_line,
341                            column: 1,
342                            end_line: blank_line,
343                            end_column: line_content.len() + 1,
344                            message: "Unexpected blank line between list items".to_string(),
345                            severity: Severity::Warning,
346                            fix: None,
347                        });
348                    }
349                } else if gap == GapKind::Tight && analysis.warn_tight_gaps {
350                    let next_item = analysis.items[i + 1];
351                    let line_content = ctx.line_info(next_item).map_or("", |li| li.content(ctx.content));
352                    warnings.push(LintWarning {
353                        rule_name: Some(self.name().to_string()),
354                        line: next_item,
355                        column: 1,
356                        end_line: next_item,
357                        end_column: line_content.len() + 1,
358                        message: "Missing blank line between list items".to_string(),
359                        severity: Severity::Warning,
360                        fix: None,
361                    });
362                }
363            }
364        }
365
366        Ok(warnings)
367    }
368
369    fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
370        if ctx.content.is_empty() {
371            return Ok(ctx.content.to_string());
372        }
373
374        // Collect all inter-item blank lines to remove and lines to insert before.
375        let mut insert_before: std::collections::HashSet<usize> = std::collections::HashSet::new();
376        let mut remove_lines: std::collections::HashSet<usize> = std::collections::HashSet::new();
377
378        let allow_cont = self.config.allow_loose_continuation;
379
380        for block in &ctx.list_blocks {
381            let Some(analysis) = Self::analyze_block(ctx, block, &self.config.style, allow_cont) else {
382                continue;
383            };
384
385            for (i, &gap) in analysis.gaps.iter().enumerate() {
386                let is_loose_violation = match gap {
387                    GapKind::Loose => analysis.warn_loose_gaps,
388                    GapKind::ContinuationLoose => !allow_cont && analysis.warn_loose_gaps,
389                    _ => false,
390                };
391
392                if is_loose_violation {
393                    for blank_line in Self::inter_item_blanks(ctx, analysis.items[i], analysis.items[i + 1]) {
394                        remove_lines.insert(blank_line);
395                    }
396                } else if gap == GapKind::Tight && analysis.warn_tight_gaps {
397                    insert_before.insert(analysis.items[i + 1]);
398                }
399            }
400        }
401
402        if insert_before.is_empty() && remove_lines.is_empty() {
403            return Ok(ctx.content.to_string());
404        }
405
406        let lines = ctx.raw_lines();
407        let mut result: Vec<String> = Vec::with_capacity(lines.len());
408
409        for (i, line) in lines.iter().enumerate() {
410            let line_num = i + 1;
411
412            // Skip modifications for lines where the rule is disabled via inline config
413            if ctx.is_rule_disabled(self.name(), line_num) {
414                result.push((*line).to_string());
415                continue;
416            }
417
418            if remove_lines.contains(&line_num) {
419                continue;
420            }
421
422            if insert_before.contains(&line_num) {
423                let bq_prefix = ctx.blockquote_prefix_for_blank_line(i);
424                result.push(bq_prefix);
425            }
426
427            result.push((*line).to_string());
428        }
429
430        let mut output = result.join("\n");
431        if ctx.content.ends_with('\n') {
432            output.push('\n');
433        }
434        Ok(output)
435    }
436
437    fn as_any(&self) -> &dyn std::any::Any {
438        self
439    }
440
441    fn default_config_section(&self) -> Option<(String, toml::Value)> {
442        let mut map = toml::map::Map::new();
443        let style_str = match self.config.style {
444            ListItemSpacingStyle::Consistent => "consistent",
445            ListItemSpacingStyle::Loose => "loose",
446            ListItemSpacingStyle::Tight => "tight",
447        };
448        map.insert("style".to_string(), toml::Value::String(style_str.to_string()));
449        map.insert(
450            "allow-loose-continuation".to_string(),
451            toml::Value::Boolean(self.config.allow_loose_continuation),
452        );
453        Some((self.name().to_string(), toml::Value::Table(map)))
454    }
455
456    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
457    where
458        Self: Sized,
459    {
460        let style = crate::config::get_rule_config_value::<String>(config, "MD076", "style")
461            .unwrap_or_else(|| "consistent".to_string());
462        let style = match style.as_str() {
463            "loose" => ListItemSpacingStyle::Loose,
464            "tight" => ListItemSpacingStyle::Tight,
465            _ => ListItemSpacingStyle::Consistent,
466        };
467        let allow_loose_continuation =
468            crate::config::get_rule_config_value::<bool>(config, "MD076", "allow-loose-continuation")
469                .or_else(|| crate::config::get_rule_config_value::<bool>(config, "MD076", "allow_loose_continuation"))
470                .unwrap_or(false);
471        Box::new(Self::new(style).with_allow_loose_continuation(allow_loose_continuation))
472    }
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478
479    fn check(content: &str, style: ListItemSpacingStyle) -> Vec<LintWarning> {
480        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
481        let rule = MD076ListItemSpacing::new(style);
482        rule.check(&ctx).unwrap()
483    }
484
485    fn check_with_continuation(
486        content: &str,
487        style: ListItemSpacingStyle,
488        allow_loose_continuation: bool,
489    ) -> Vec<LintWarning> {
490        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
491        let rule = MD076ListItemSpacing::new(style).with_allow_loose_continuation(allow_loose_continuation);
492        rule.check(&ctx).unwrap()
493    }
494
495    fn fix(content: &str, style: ListItemSpacingStyle) -> String {
496        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
497        let rule = MD076ListItemSpacing::new(style);
498        rule.fix(&ctx).unwrap()
499    }
500
501    fn fix_with_continuation(content: &str, style: ListItemSpacingStyle, allow_loose_continuation: bool) -> String {
502        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
503        let rule = MD076ListItemSpacing::new(style).with_allow_loose_continuation(allow_loose_continuation);
504        rule.fix(&ctx).unwrap()
505    }
506
507    // ── Basic style detection ──────────────────────────────────────────
508
509    #[test]
510    fn tight_list_tight_style_no_warnings() {
511        let content = "- Item 1\n- Item 2\n- Item 3\n";
512        assert!(check(content, ListItemSpacingStyle::Tight).is_empty());
513    }
514
515    #[test]
516    fn loose_list_loose_style_no_warnings() {
517        let content = "- Item 1\n\n- Item 2\n\n- Item 3\n";
518        assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
519    }
520
521    #[test]
522    fn tight_list_loose_style_warns() {
523        let content = "- Item 1\n- Item 2\n- Item 3\n";
524        let warnings = check(content, ListItemSpacingStyle::Loose);
525        assert_eq!(warnings.len(), 2);
526        assert!(warnings.iter().all(|w| w.message.contains("Missing")));
527    }
528
529    #[test]
530    fn loose_list_tight_style_warns() {
531        let content = "- Item 1\n\n- Item 2\n\n- Item 3\n";
532        let warnings = check(content, ListItemSpacingStyle::Tight);
533        assert_eq!(warnings.len(), 2);
534        assert!(warnings.iter().all(|w| w.message.contains("Unexpected")));
535    }
536
537    // ── Consistent mode ────────────────────────────────────────────────
538
539    #[test]
540    fn consistent_all_tight_no_warnings() {
541        let content = "- Item 1\n- Item 2\n- Item 3\n";
542        assert!(check(content, ListItemSpacingStyle::Consistent).is_empty());
543    }
544
545    #[test]
546    fn consistent_all_loose_no_warnings() {
547        let content = "- Item 1\n\n- Item 2\n\n- Item 3\n";
548        assert!(check(content, ListItemSpacingStyle::Consistent).is_empty());
549    }
550
551    #[test]
552    fn consistent_mixed_majority_loose_warns_tight() {
553        // 2 loose gaps, 1 tight gap → tight is minority → warn on tight
554        let content = "- Item 1\n\n- Item 2\n- Item 3\n\n- Item 4\n";
555        let warnings = check(content, ListItemSpacingStyle::Consistent);
556        assert_eq!(warnings.len(), 1);
557        assert!(warnings[0].message.contains("Missing"));
558    }
559
560    #[test]
561    fn consistent_mixed_majority_tight_warns_loose() {
562        // 1 loose gap, 2 tight gaps → loose is minority → warn on loose blank line
563        let content = "- Item 1\n\n- Item 2\n- Item 3\n- Item 4\n";
564        let warnings = check(content, ListItemSpacingStyle::Consistent);
565        assert_eq!(warnings.len(), 1);
566        assert!(warnings[0].message.contains("Unexpected"));
567    }
568
569    #[test]
570    fn consistent_tie_prefers_loose() {
571        let content = "- Item 1\n\n- Item 2\n- Item 3\n";
572        let warnings = check(content, ListItemSpacingStyle::Consistent);
573        assert_eq!(warnings.len(), 1);
574        assert!(warnings[0].message.contains("Missing"));
575    }
576
577    // ── Edge cases ─────────────────────────────────────────────────────
578
579    #[test]
580    fn single_item_list_no_warnings() {
581        let content = "- Only item\n";
582        assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
583        assert!(check(content, ListItemSpacingStyle::Tight).is_empty());
584        assert!(check(content, ListItemSpacingStyle::Consistent).is_empty());
585    }
586
587    #[test]
588    fn empty_content_no_warnings() {
589        assert!(check("", ListItemSpacingStyle::Consistent).is_empty());
590    }
591
592    #[test]
593    fn ordered_list_tight_gaps_loose_style_warns() {
594        let content = "1. First\n2. Second\n3. Third\n";
595        let warnings = check(content, ListItemSpacingStyle::Loose);
596        assert_eq!(warnings.len(), 2);
597    }
598
599    #[test]
600    fn task_list_works() {
601        let content = "- [x] Task 1\n- [ ] Task 2\n- [x] Task 3\n";
602        let warnings = check(content, ListItemSpacingStyle::Loose);
603        assert_eq!(warnings.len(), 2);
604        let fixed = fix(content, ListItemSpacingStyle::Loose);
605        assert_eq!(fixed, "- [x] Task 1\n\n- [ ] Task 2\n\n- [x] Task 3\n");
606    }
607
608    #[test]
609    fn no_trailing_newline() {
610        let content = "- Item 1\n- Item 2";
611        let warnings = check(content, ListItemSpacingStyle::Loose);
612        assert_eq!(warnings.len(), 1);
613        let fixed = fix(content, ListItemSpacingStyle::Loose);
614        assert_eq!(fixed, "- Item 1\n\n- Item 2");
615    }
616
617    #[test]
618    fn two_separate_lists() {
619        let content = "- A\n- B\n\nText\n\n1. One\n2. Two\n";
620        let warnings = check(content, ListItemSpacingStyle::Loose);
621        assert_eq!(warnings.len(), 2);
622        let fixed = fix(content, ListItemSpacingStyle::Loose);
623        assert_eq!(fixed, "- A\n\n- B\n\nText\n\n1. One\n\n2. Two\n");
624    }
625
626    #[test]
627    fn no_list_content() {
628        let content = "Just a paragraph.\n\nAnother paragraph.\n";
629        assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
630        assert!(check(content, ListItemSpacingStyle::Tight).is_empty());
631    }
632
633    // ── Multi-line and continuation items ──────────────────────────────
634
635    #[test]
636    fn continuation_lines_tight_detected() {
637        let content = "- Item 1\n  continuation\n- Item 2\n";
638        let warnings = check(content, ListItemSpacingStyle::Loose);
639        assert_eq!(warnings.len(), 1);
640        assert!(warnings[0].message.contains("Missing"));
641    }
642
643    #[test]
644    fn continuation_lines_loose_detected() {
645        let content = "- Item 1\n  continuation\n\n- Item 2\n";
646        assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
647        let warnings = check(content, ListItemSpacingStyle::Tight);
648        assert_eq!(warnings.len(), 1);
649        assert!(warnings[0].message.contains("Unexpected"));
650    }
651
652    #[test]
653    fn multi_paragraph_item_not_treated_as_inter_item_gap() {
654        // Blank line between paragraphs within Item 1 must NOT trigger a warning.
655        // Only the blank line immediately before Item 2 is an inter-item separator.
656        let content = "- Item 1\n\n  Second paragraph\n\n- Item 2\n";
657        // Both gaps are loose (blank before Item 2), so tight should warn once
658        let warnings = check(content, ListItemSpacingStyle::Tight);
659        assert_eq!(
660            warnings.len(),
661            1,
662            "Should warn only on the inter-item blank, not the intra-item blank"
663        );
664        // The fix should remove only the inter-item blank (line 4), preserving the
665        // multi-paragraph structure
666        let fixed = fix(content, ListItemSpacingStyle::Tight);
667        assert_eq!(fixed, "- Item 1\n\n  Second paragraph\n- Item 2\n");
668    }
669
670    #[test]
671    fn multi_paragraph_item_loose_style_no_warnings() {
672        // A loose list with multi-paragraph items is already loose — no warnings
673        let content = "- Item 1\n\n  Second paragraph\n\n- Item 2\n";
674        assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
675    }
676
677    // ── Blockquote lists ───────────────────────────────────────────────
678
679    #[test]
680    fn blockquote_tight_list_loose_style_warns() {
681        let content = "> - Item 1\n> - Item 2\n> - Item 3\n";
682        let warnings = check(content, ListItemSpacingStyle::Loose);
683        assert_eq!(warnings.len(), 2);
684    }
685
686    #[test]
687    fn blockquote_loose_list_detected() {
688        // A line with only `>` is effectively blank in blockquote context
689        let content = "> - Item 1\n>\n> - Item 2\n";
690        let warnings = check(content, ListItemSpacingStyle::Tight);
691        assert_eq!(warnings.len(), 1, "Blockquote-only line should be detected as blank");
692        assert!(warnings[0].message.contains("Unexpected"));
693    }
694
695    #[test]
696    fn blockquote_loose_list_no_warnings_when_loose() {
697        let content = "> - Item 1\n>\n> - Item 2\n";
698        assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
699    }
700
701    // ── Multiple blank lines ───────────────────────────────────────────
702
703    #[test]
704    fn multiple_blanks_all_removed() {
705        let content = "- Item 1\n\n\n- Item 2\n";
706        let fixed = fix(content, ListItemSpacingStyle::Tight);
707        assert_eq!(fixed, "- Item 1\n- Item 2\n");
708    }
709
710    #[test]
711    fn multiple_blanks_fix_is_idempotent() {
712        let content = "- Item 1\n\n\n\n- Item 2\n";
713        let fixed_once = fix(content, ListItemSpacingStyle::Tight);
714        let fixed_twice = fix(&fixed_once, ListItemSpacingStyle::Tight);
715        assert_eq!(fixed_once, fixed_twice);
716        assert_eq!(fixed_once, "- Item 1\n- Item 2\n");
717    }
718
719    // ── Fix correctness ────────────────────────────────────────────────
720
721    #[test]
722    fn fix_adds_blank_lines() {
723        let content = "- Item 1\n- Item 2\n- Item 3\n";
724        let fixed = fix(content, ListItemSpacingStyle::Loose);
725        assert_eq!(fixed, "- Item 1\n\n- Item 2\n\n- Item 3\n");
726    }
727
728    #[test]
729    fn fix_removes_blank_lines() {
730        let content = "- Item 1\n\n- Item 2\n\n- Item 3\n";
731        let fixed = fix(content, ListItemSpacingStyle::Tight);
732        assert_eq!(fixed, "- Item 1\n- Item 2\n- Item 3\n");
733    }
734
735    #[test]
736    fn fix_consistent_adds_blank() {
737        // 2 loose gaps, 1 tight gap → add blank before Item 3
738        let content = "- Item 1\n\n- Item 2\n- Item 3\n\n- Item 4\n";
739        let fixed = fix(content, ListItemSpacingStyle::Consistent);
740        assert_eq!(fixed, "- Item 1\n\n- Item 2\n\n- Item 3\n\n- Item 4\n");
741    }
742
743    #[test]
744    fn fix_idempotent_loose() {
745        let content = "- Item 1\n- Item 2\n";
746        let fixed_once = fix(content, ListItemSpacingStyle::Loose);
747        let fixed_twice = fix(&fixed_once, ListItemSpacingStyle::Loose);
748        assert_eq!(fixed_once, fixed_twice);
749    }
750
751    #[test]
752    fn fix_idempotent_tight() {
753        let content = "- Item 1\n\n- Item 2\n";
754        let fixed_once = fix(content, ListItemSpacingStyle::Tight);
755        let fixed_twice = fix(&fixed_once, ListItemSpacingStyle::Tight);
756        assert_eq!(fixed_once, fixed_twice);
757    }
758
759    // ── Nested lists ───────────────────────────────────────────────────
760
761    #[test]
762    fn nested_list_does_not_affect_parent() {
763        // Nested items should not trigger warnings for the parent list
764        let content = "- Item 1\n  - Nested A\n  - Nested B\n- Item 2\n";
765        let warnings = check(content, ListItemSpacingStyle::Tight);
766        assert!(
767            warnings.is_empty(),
768            "Nested items should not cause parent-level warnings"
769        );
770    }
771
772    // ── Structural blank lines (code blocks, tables, HTML) ──────────
773
774    #[test]
775    fn code_block_in_tight_list_no_false_positive() {
776        // Blank line after closing fence is structural (required by MD031), not a separator
777        let content = "\
778- Item 1 with code:
779
780  ```python
781  print('hello')
782  ```
783
784- Item 2 simple.
785- Item 3 simple.
786";
787        assert!(
788            check(content, ListItemSpacingStyle::Consistent).is_empty(),
789            "Structural blank after code block should not make item 1 appear loose"
790        );
791    }
792
793    #[test]
794    fn table_in_tight_list_no_false_positive() {
795        // Blank line after table is structural (required by MD058), not a separator
796        let content = "\
797- Item 1 with table:
798
799  | Col 1 | Col 2 |
800  |-------|-------|
801  | A     | B     |
802
803- Item 2 simple.
804- Item 3 simple.
805";
806        assert!(
807            check(content, ListItemSpacingStyle::Consistent).is_empty(),
808            "Structural blank after table should not make item 1 appear loose"
809        );
810    }
811
812    #[test]
813    fn html_block_in_tight_list_no_false_positive() {
814        let content = "\
815- Item 1 with HTML:
816
817  <details>
818  <summary>Click</summary>
819  Content
820  </details>
821
822- Item 2 simple.
823- Item 3 simple.
824";
825        assert!(
826            check(content, ListItemSpacingStyle::Consistent).is_empty(),
827            "Structural blank after HTML block should not make item 1 appear loose"
828        );
829    }
830
831    #[test]
832    fn blockquote_in_tight_list_no_false_positive() {
833        // Blank line around a blockquote in a list item is structural, not a separator
834        let content = "\
835- Item 1 with quote:
836
837  > This is a blockquote
838  > with multiple lines.
839
840- Item 2 simple.
841- Item 3 simple.
842";
843        assert!(
844            check(content, ListItemSpacingStyle::Consistent).is_empty(),
845            "Structural blank around blockquote should not make item 1 appear loose"
846        );
847        assert!(
848            check(content, ListItemSpacingStyle::Tight).is_empty(),
849            "Blockquote in tight list should not trigger a violation"
850        );
851    }
852
853    #[test]
854    fn blockquote_multiple_items_with_quotes_tight() {
855        // Multiple items with blockquotes should all be treated as structural
856        let content = "\
857- Item 1:
858
859  > Quote A
860
861- Item 2:
862
863  > Quote B
864
865- Item 3 plain.
866";
867        assert!(
868            check(content, ListItemSpacingStyle::Tight).is_empty(),
869            "Multiple items with blockquotes should remain tight"
870        );
871    }
872
873    #[test]
874    fn blockquote_mixed_with_genuine_loose_gap() {
875        // A blockquote item followed by a genuine loose gap should still be detected
876        let content = "\
877- Item 1:
878
879  > Quote
880
881- Item 2 plain.
882
883- Item 3 plain.
884";
885        let warnings = check(content, ListItemSpacingStyle::Tight);
886        assert!(
887            !warnings.is_empty(),
888            "Genuine loose gap between Item 2 and Item 3 should be flagged"
889        );
890    }
891
892    #[test]
893    fn blockquote_single_line_in_tight_list() {
894        let content = "\
895- Item 1:
896
897  > Single line quote.
898
899- Item 2.
900- Item 3.
901";
902        assert!(
903            check(content, ListItemSpacingStyle::Tight).is_empty(),
904            "Single-line blockquote should be structural"
905        );
906    }
907
908    #[test]
909    fn blockquote_in_ordered_list_tight() {
910        let content = "\
9111. Item 1:
912
913   > Quoted text in ordered list.
914
9151. Item 2.
9161. Item 3.
917";
918        assert!(
919            check(content, ListItemSpacingStyle::Tight).is_empty(),
920            "Blockquote in ordered list should be structural"
921        );
922    }
923
924    #[test]
925    fn nested_blockquote_in_tight_list() {
926        let content = "\
927- Item 1:
928
929  > Outer quote
930  > > Nested quote
931
932- Item 2.
933- Item 3.
934";
935        assert!(
936            check(content, ListItemSpacingStyle::Tight).is_empty(),
937            "Nested blockquote in tight list should be structural"
938        );
939    }
940
941    #[test]
942    fn blockquote_as_entire_item_is_loose() {
943        // When a blockquote IS the item content (not nested within text),
944        // a trailing blank line is a genuine loose gap, not structural.
945        let content = "\
946- > Quote is the entire item content.
947
948- Item 2.
949- Item 3.
950";
951        let warnings = check(content, ListItemSpacingStyle::Tight);
952        assert!(
953            !warnings.is_empty(),
954            "Blank after blockquote-only item is a genuine loose gap"
955        );
956    }
957
958    #[test]
959    fn mixed_code_and_table_in_tight_list() {
960        let content = "\
9611. Item with code:
962
963   ```markdown
964   This is some Markdown
965   ```
966
9671. Simple item.
9681. Item with table:
969
970   | Col 1 | Col 2 |
971   |:------|:------|
972   | Row 1 | Row 1 |
973   | Row 2 | Row 2 |
974";
975        assert!(
976            check(content, ListItemSpacingStyle::Consistent).is_empty(),
977            "Mix of code blocks and tables should not cause false positives"
978        );
979    }
980
981    #[test]
982    fn code_block_with_genuinely_loose_gaps_still_warns() {
983        // Item 1 has structural blank (code block), items 2-3 have genuine blank separator
984        // Items 2-3 are genuinely loose, item 3-4 is tight → inconsistent
985        let content = "\
986- Item 1:
987
988  ```bash
989  echo hi
990  ```
991
992- Item 2
993
994- Item 3
995- Item 4
996";
997        let warnings = check(content, ListItemSpacingStyle::Consistent);
998        assert!(
999            !warnings.is_empty(),
1000            "Genuine inconsistency with code blocks should still be flagged"
1001        );
1002    }
1003
1004    #[test]
1005    fn all_items_have_code_blocks_no_warnings() {
1006        let content = "\
1007- Item 1:
1008
1009  ```python
1010  print(1)
1011  ```
1012
1013- Item 2:
1014
1015  ```python
1016  print(2)
1017  ```
1018
1019- Item 3:
1020
1021  ```python
1022  print(3)
1023  ```
1024";
1025        assert!(
1026            check(content, ListItemSpacingStyle::Consistent).is_empty(),
1027            "All items with code blocks should be consistently tight"
1028        );
1029    }
1030
1031    #[test]
1032    fn tilde_fence_code_block_in_list() {
1033        let content = "\
1034- Item 1:
1035
1036  ~~~
1037  code here
1038  ~~~
1039
1040- Item 2 simple.
1041- Item 3 simple.
1042";
1043        assert!(
1044            check(content, ListItemSpacingStyle::Consistent).is_empty(),
1045            "Tilde fences should be recognized as structural content"
1046        );
1047    }
1048
1049    #[test]
1050    fn nested_list_with_code_block() {
1051        let content = "\
1052- Item 1
1053  - Nested with code:
1054
1055    ```
1056    nested code
1057    ```
1058
1059  - Nested simple.
1060- Item 2
1061";
1062        assert!(
1063            check(content, ListItemSpacingStyle::Consistent).is_empty(),
1064            "Nested list with code block should not cause false positives"
1065        );
1066    }
1067
1068    #[test]
1069    fn tight_style_with_code_block_no_warnings() {
1070        let content = "\
1071- Item 1:
1072
1073  ```
1074  code
1075  ```
1076
1077- Item 2.
1078- Item 3.
1079";
1080        assert!(
1081            check(content, ListItemSpacingStyle::Tight).is_empty(),
1082            "Tight style should not warn about structural blanks around code blocks"
1083        );
1084    }
1085
1086    #[test]
1087    fn loose_style_with_code_block_missing_separator() {
1088        // Loose style requires blank line between every pair of items.
1089        // Items 2-3 have no blank → should warn
1090        let content = "\
1091- Item 1:
1092
1093  ```
1094  code
1095  ```
1096
1097- Item 2.
1098- Item 3.
1099";
1100        let warnings = check(content, ListItemSpacingStyle::Loose);
1101        assert_eq!(
1102            warnings.len(),
1103            1,
1104            "Loose style should still require blank between simple items"
1105        );
1106        assert!(warnings[0].message.contains("Missing"));
1107    }
1108
1109    #[test]
1110    fn blockquote_list_with_code_block() {
1111        let content = "\
1112> - Item 1:
1113>
1114>   ```
1115>   code
1116>   ```
1117>
1118> - Item 2.
1119> - Item 3.
1120";
1121        assert!(
1122            check(content, ListItemSpacingStyle::Consistent).is_empty(),
1123            "Blockquote-prefixed list with code block should not cause false positives"
1124        );
1125    }
1126
1127    // ── Indented code block (not fenced) in list item ─────────────────
1128
1129    #[test]
1130    fn indented_code_block_in_list_no_false_positive() {
1131        // A 4-space indented code block inside a list item should be treated
1132        // as structural content, not trigger a loose gap detection.
1133        let content = "\
11341. Item with indented code:
1135
1136       some code here
1137       more code
1138
11391. Simple item
11401. Another item
1141";
1142        assert!(
1143            check(content, ListItemSpacingStyle::Consistent).is_empty(),
1144            "Structural blank after indented code block should not make item 1 appear loose"
1145        );
1146    }
1147
1148    // ── Code block in middle of item with text after ────────────────
1149
1150    #[test]
1151    fn code_block_in_middle_of_item_text_after_is_genuinely_loose() {
1152        // When a code block is in the middle of an item and there's regular text
1153        // after it, a blank line before the next item IS a genuine separator (loose),
1154        // not structural. The last non-blank line before item 2 is "Some text after
1155        // the code block." which is NOT structural content.
1156        let content = "\
11571. Item with code in middle:
1158
1159   ```
1160   code
1161   ```
1162
1163   Some text after the code block.
1164
11651. Simple item
11661. Another item
1167";
1168        let warnings = check(content, ListItemSpacingStyle::Consistent);
1169        assert!(
1170            !warnings.is_empty(),
1171            "Blank line after regular text (not structural content) is a genuine loose gap"
1172        );
1173    }
1174
1175    // ── Fix: tight mode preserves structural blanks ──────────────────
1176
1177    #[test]
1178    fn tight_fix_preserves_structural_blanks_around_code_blocks() {
1179        // When style is tight, the fix should NOT remove structural blank lines
1180        // around code blocks inside list items. Those blanks are required by MD031.
1181        let content = "\
1182- Item 1:
1183
1184  ```
1185  code
1186  ```
1187
1188- Item 2.
1189- Item 3.
1190";
1191        let fixed = fix(content, ListItemSpacingStyle::Tight);
1192        assert_eq!(
1193            fixed, content,
1194            "Tight fix should not remove structural blanks around code blocks"
1195        );
1196    }
1197
1198    // ── Issue #461: 4-space indented code block in loose list ──────────
1199
1200    #[test]
1201    fn four_space_indented_fence_in_loose_list_no_false_positive() {
1202        // Reproduction case from issue #461 comment by @sisp.
1203        // The fenced code block uses 4-space indentation inside an ordered list.
1204        // The blank line after the closing fence is structural (required by MD031)
1205        // and must not create a false "Missing blank line" warning.
1206        let content = "\
12071. First item
1208
12091. Second item with code block:
1210
1211    ```json
1212    {\"key\": \"value\"}
1213    ```
1214
12151. Third item
1216";
1217        assert!(
1218            check(content, ListItemSpacingStyle::Consistent).is_empty(),
1219            "Structural blank after 4-space indented code block should not cause false positive"
1220        );
1221    }
1222
1223    #[test]
1224    fn four_space_indented_fence_tight_style_no_warnings() {
1225        let content = "\
12261. First item
12271. Second item with code block:
1228
1229    ```json
1230    {\"key\": \"value\"}
1231    ```
1232
12331. Third item
1234";
1235        assert!(
1236            check(content, ListItemSpacingStyle::Tight).is_empty(),
1237            "Tight style should not warn about structural blanks with 4-space fences"
1238        );
1239    }
1240
1241    #[test]
1242    fn four_space_indented_fence_loose_style_no_warnings() {
1243        // All non-structural gaps are loose, structural gaps are excluded.
1244        let content = "\
12451. First item
1246
12471. Second item with code block:
1248
1249    ```json
1250    {\"key\": \"value\"}
1251    ```
1252
12531. Third item
1254";
1255        assert!(
1256            check(content, ListItemSpacingStyle::Loose).is_empty(),
1257            "Loose style should not warn when structural gaps are the only non-loose gaps"
1258        );
1259    }
1260
1261    #[test]
1262    fn structural_gap_with_genuine_inconsistency_still_warns() {
1263        // Item 1 has a structural code block. Items 2-3 are genuinely loose,
1264        // but items 3-4 are tight → genuine inconsistency should still warn.
1265        let content = "\
12661. First item with code:
1267
1268    ```json
1269    {\"key\": \"value\"}
1270    ```
1271
12721. Second item
1273
12741. Third item
12751. Fourth item
1276";
1277        let warnings = check(content, ListItemSpacingStyle::Consistent);
1278        assert!(
1279            !warnings.is_empty(),
1280            "Genuine loose/tight inconsistency should still warn even with structural gaps"
1281        );
1282    }
1283
1284    #[test]
1285    fn four_space_fence_fix_is_idempotent() {
1286        // Fix should not modify a list that has only structural gaps and
1287        // genuine loose gaps — it's already consistent.
1288        let content = "\
12891. First item
1290
12911. Second item with code block:
1292
1293    ```json
1294    {\"key\": \"value\"}
1295    ```
1296
12971. Third item
1298";
1299        let fixed = fix(content, ListItemSpacingStyle::Consistent);
1300        assert_eq!(fixed, content, "Fix should be a no-op for lists with structural gaps");
1301        let fixed_twice = fix(&fixed, ListItemSpacingStyle::Consistent);
1302        assert_eq!(fixed, fixed_twice, "Fix should be idempotent");
1303    }
1304
1305    #[test]
1306    fn four_space_fence_fix_does_not_insert_duplicate_blank() {
1307        // When tight style tries to fix, it should not insert a blank line
1308        // before item 3 when one already exists (structural).
1309        let content = "\
13101. First item
13111. Second item with code block:
1312
1313    ```json
1314    {\"key\": \"value\"}
1315    ```
1316
13171. Third item
1318";
1319        let fixed = fix(content, ListItemSpacingStyle::Tight);
1320        assert_eq!(fixed, content, "Tight fix should not modify structural blanks");
1321    }
1322
1323    #[test]
1324    fn mkdocs_flavor_code_block_in_list_no_false_positive() {
1325        // MkDocs flavor with code block inside a list item.
1326        // Reported by @sisp in issue #461 comment.
1327        let content = "\
13281. First item
1329
13301. Second item with code block:
1331
1332    ```json
1333    {\"key\": \"value\"}
1334    ```
1335
13361. Third item
1337";
1338        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1339        let rule = MD076ListItemSpacing::new(ListItemSpacingStyle::Consistent);
1340        let warnings = rule.check(&ctx).unwrap();
1341        assert!(
1342            warnings.is_empty(),
1343            "MkDocs flavor with structural code block blank should not produce false positive, got: {warnings:?}"
1344        );
1345    }
1346
1347    // ── Issue #500: code block inside list item splits list blocks ─────
1348
1349    #[test]
1350    fn code_block_in_second_item_detects_inconsistency() {
1351        // A code block inside item 2 must not split the list into separate blocks.
1352        // Items 1-2 are tight, items 3-4 are loose → inconsistent.
1353        let content = "\
1354# Test
1355
1356- Lorem ipsum dolor sit amet.
1357- Lorem ipsum dolor sit amet.
1358
1359    ```yaml
1360    hello: world
1361    ```
1362
1363- Lorem ipsum dolor sit amet.
1364
1365- Lorem ipsum dolor sit amet.
1366";
1367        let warnings = check(content, ListItemSpacingStyle::Consistent);
1368        assert!(
1369            !warnings.is_empty(),
1370            "Should detect inconsistent spacing when code block is inside a list item"
1371        );
1372    }
1373
1374    #[test]
1375    fn code_block_in_item_all_tight_no_warnings() {
1376        // All non-structural gaps are tight → consistent, no warnings.
1377        let content = "\
1378- Item 1
1379- Item 2
1380
1381    ```yaml
1382    hello: world
1383    ```
1384
1385- Item 3
1386- Item 4
1387";
1388        assert!(
1389            check(content, ListItemSpacingStyle::Consistent).is_empty(),
1390            "All tight gaps with structural code block should not warn"
1391        );
1392    }
1393
1394    #[test]
1395    fn code_block_in_item_all_loose_no_warnings() {
1396        // All non-structural gaps are loose → consistent, no warnings.
1397        let content = "\
1398- Item 1
1399
1400- Item 2
1401
1402    ```yaml
1403    hello: world
1404    ```
1405
1406- Item 3
1407
1408- Item 4
1409";
1410        assert!(
1411            check(content, ListItemSpacingStyle::Consistent).is_empty(),
1412            "All loose gaps with structural code block should not warn"
1413        );
1414    }
1415
1416    #[test]
1417    fn code_block_in_ordered_list_detects_inconsistency() {
1418        let content = "\
14191. First item
14201. Second item
1421
1422    ```json
1423    {\"key\": \"value\"}
1424    ```
1425
14261. Third item
1427
14281. Fourth item
1429";
1430        let warnings = check(content, ListItemSpacingStyle::Consistent);
1431        assert!(
1432            !warnings.is_empty(),
1433            "Ordered list with code block should still detect inconsistency"
1434        );
1435    }
1436
1437    #[test]
1438    fn code_block_in_item_fix_adds_missing_blanks() {
1439        // Items 1-2 are tight, items 3-4 are loose → majority loose → fix adds blank before item 2
1440        let content = "\
1441- Item 1
1442- Item 2
1443
1444    ```yaml
1445    code: here
1446    ```
1447
1448- Item 3
1449
1450- Item 4
1451";
1452        let fixed = fix(content, ListItemSpacingStyle::Consistent);
1453        assert!(
1454            fixed.contains("- Item 1\n\n- Item 2"),
1455            "Fix should add blank line between items 1 and 2"
1456        );
1457    }
1458
1459    #[test]
1460    fn tilde_code_block_in_item_detects_inconsistency() {
1461        let content = "\
1462- Item 1
1463- Item 2
1464
1465    ~~~
1466    code
1467    ~~~
1468
1469- Item 3
1470
1471- Item 4
1472";
1473        let warnings = check(content, ListItemSpacingStyle::Consistent);
1474        assert!(
1475            !warnings.is_empty(),
1476            "Tilde code block inside item should not prevent inconsistency detection"
1477        );
1478    }
1479
1480    #[test]
1481    fn multiple_code_blocks_all_tight_no_warnings() {
1482        // All non-structural gaps are tight → consistent.
1483        let content = "\
1484- Item 1
1485
1486    ```
1487    code1
1488    ```
1489
1490- Item 2
1491
1492    ```
1493    code2
1494    ```
1495
1496- Item 3
1497- Item 4
1498";
1499        assert!(
1500            check(content, ListItemSpacingStyle::Consistent).is_empty(),
1501            "All non-structural gaps are tight, so list is consistent"
1502        );
1503    }
1504
1505    #[test]
1506    fn code_block_with_mixed_genuine_gaps_warns() {
1507        // Items 1-2 structural, 2-3 loose, 3-4 tight → genuine inconsistency
1508        let content = "\
1509- Item 1
1510
1511    ```
1512    code1
1513    ```
1514
1515- Item 2
1516
1517- Item 3
1518- Item 4
1519";
1520        let warnings = check(content, ListItemSpacingStyle::Consistent);
1521        assert!(
1522            !warnings.is_empty(),
1523            "Mixed genuine gaps (loose + tight) with structural code block should still warn"
1524        );
1525    }
1526
1527    // ── allow-loose-continuation ─────────────────────────────────────
1528
1529    #[test]
1530    fn continuation_loose_tight_style_default_warns() {
1531        // Default (allow_loose_continuation=false): blank lines around
1532        // continuation paragraphs are treated as loose gaps → violation
1533        let content = "\
1534- Item 1.
1535
1536  Continuation paragraph.
1537
1538- Item 2.
1539
1540  Continuation paragraph.
1541
1542- Item 3.
1543";
1544        let warnings = check_with_continuation(content, ListItemSpacingStyle::Tight, false);
1545        assert!(
1546            !warnings.is_empty(),
1547            "Should warn about loose gaps when allow_loose_continuation is false"
1548        );
1549    }
1550
1551    #[test]
1552    fn continuation_loose_tight_style_allowed_no_warnings() {
1553        // With allow_loose_continuation=true: blank lines around continuation
1554        // paragraphs are permitted even in tight mode
1555        let content = "\
1556- Item 1.
1557
1558  Continuation paragraph.
1559
1560- Item 2.
1561
1562  Continuation paragraph.
1563
1564- Item 3.
1565";
1566        let warnings = check_with_continuation(content, ListItemSpacingStyle::Tight, true);
1567        assert!(
1568            warnings.is_empty(),
1569            "Should not warn when allow_loose_continuation is true, got: {warnings:?}"
1570        );
1571    }
1572
1573    #[test]
1574    fn continuation_loose_mixed_items_warns() {
1575        // Even with allow_loose_continuation, genuinely loose inter-item gaps
1576        // (blank line between items that have no continuation) should still warn
1577        let content = "\
1578- Item 1.
1579
1580- Item 2.
1581- Item 3.
1582";
1583        let warnings = check_with_continuation(content, ListItemSpacingStyle::Tight, true);
1584        assert!(
1585            !warnings.is_empty(),
1586            "Genuine loose gaps should still warn even with allow_loose_continuation"
1587        );
1588    }
1589
1590    #[test]
1591    fn continuation_loose_consistent_mode() {
1592        // In consistent mode with allow_loose_continuation, continuation gaps
1593        // should not count toward loose/tight consistency
1594        let content = "\
1595- Item 1.
1596
1597  Continuation paragraph.
1598
1599- Item 2.
1600- Item 3.
1601";
1602        let warnings = check_with_continuation(content, ListItemSpacingStyle::Consistent, true);
1603        assert!(
1604            warnings.is_empty(),
1605            "Continuation gaps should not affect consistency when allowed, got: {warnings:?}"
1606        );
1607    }
1608
1609    #[test]
1610    fn continuation_loose_fix_preserves_continuation_blanks() {
1611        let content = "\
1612- Item 1.
1613
1614  Continuation paragraph.
1615
1616- Item 2.
1617
1618  Continuation paragraph.
1619
1620- Item 3.
1621";
1622        let fixed = fix_with_continuation(content, ListItemSpacingStyle::Tight, true);
1623        assert_eq!(fixed, content, "Fix should preserve continuation blank lines");
1624    }
1625
1626    #[test]
1627    fn continuation_loose_fix_removes_genuine_loose_gaps() {
1628        let input = "\
1629- Item 1.
1630
1631- Item 2.
1632
1633- Item 3.
1634";
1635        let expected = "\
1636- Item 1.
1637- Item 2.
1638- Item 3.
1639";
1640        let fixed = fix_with_continuation(input, ListItemSpacingStyle::Tight, true);
1641        assert_eq!(fixed, expected);
1642    }
1643
1644    #[test]
1645    fn continuation_loose_ordered_list() {
1646        let content = "\
16471. Item 1.
1648
1649   Continuation paragraph.
1650
16512. Item 2.
1652
1653   Continuation paragraph.
1654
16553. Item 3.
1656";
1657        let warnings = check_with_continuation(content, ListItemSpacingStyle::Tight, true);
1658        assert!(
1659            warnings.is_empty(),
1660            "Ordered list continuation should work too, got: {warnings:?}"
1661        );
1662    }
1663
1664    #[test]
1665    fn continuation_loose_disabled_by_default() {
1666        // Verify the constructor defaults to false
1667        let rule = MD076ListItemSpacing::new(ListItemSpacingStyle::Tight);
1668        assert!(!rule.config.allow_loose_continuation);
1669    }
1670
1671    #[test]
1672    fn continuation_loose_ordered_under_indented_warns() {
1673        // Ordered list: "1. " has content_column=3, so 2-space indent
1674        // is under-indented and should NOT be treated as continuation
1675        let content = "\
16761. Item 1.
1677
1678  Under-indented text.
1679
16801. Item 2.
16811. Item 3.
1682";
1683        let warnings = check_with_continuation(content, ListItemSpacingStyle::Tight, true);
1684        assert!(
1685            !warnings.is_empty(),
1686            "Under-indented text should not be treated as continuation, got: {warnings:?}"
1687        );
1688    }
1689
1690    #[test]
1691    fn continuation_loose_mix_continuation_and_genuine_gaps() {
1692        // Some items have continuation (allowed), one gap is genuinely loose (not allowed)
1693        let content = "\
1694- Item 1.
1695
1696  Continuation paragraph.
1697
1698- Item 2.
1699
1700- Item 3.
1701";
1702        let warnings = check_with_continuation(content, ListItemSpacingStyle::Tight, true);
1703        assert!(
1704            !warnings.is_empty(),
1705            "Genuine loose gap between items 2-3 should warn even with continuation allowed"
1706        );
1707        // Only the genuine loose gap should warn, not the continuation gap
1708        assert_eq!(
1709            warnings.len(),
1710            1,
1711            "Expected exactly one warning for the genuine loose gap"
1712        );
1713    }
1714
1715    #[test]
1716    fn continuation_loose_fix_mixed_preserves_continuation_removes_genuine() {
1717        // Fix should preserve continuation blanks but remove genuine loose gaps
1718        let input = "\
1719- Item 1.
1720
1721  Continuation paragraph.
1722
1723- Item 2.
1724
1725- Item 3.
1726";
1727        let expected = "\
1728- Item 1.
1729
1730  Continuation paragraph.
1731
1732- Item 2.
1733- Item 3.
1734";
1735        let fixed = fix_with_continuation(input, ListItemSpacingStyle::Tight, true);
1736        assert_eq!(fixed, expected);
1737    }
1738
1739    #[test]
1740    fn continuation_loose_after_code_block() {
1741        // Code block is structural, continuation after code block should also work
1742        let content = "\
1743- Item 1.
1744
1745  ```python
1746  code
1747  ```
1748
1749  Continuation after code.
1750
1751- Item 2.
1752- Item 3.
1753";
1754        let warnings = check_with_continuation(content, ListItemSpacingStyle::Tight, true);
1755        assert!(
1756            warnings.is_empty(),
1757            "Code block + continuation should both be exempt, got: {warnings:?}"
1758        );
1759    }
1760
1761    #[test]
1762    fn continuation_loose_style_does_not_interfere() {
1763        // With style=loose, allow-loose-continuation shouldn't change behavior —
1764        // loose style already requires blank lines everywhere
1765        let content = "\
1766- Item 1.
1767
1768  Continuation paragraph.
1769
1770- Item 2.
1771
1772  Continuation paragraph.
1773
1774- Item 3.
1775";
1776        let warnings = check_with_continuation(content, ListItemSpacingStyle::Loose, true);
1777        assert!(
1778            warnings.is_empty(),
1779            "Loose style with continuation should not warn, got: {warnings:?}"
1780        );
1781    }
1782
1783    #[test]
1784    fn continuation_loose_tight_no_continuation_content() {
1785        // All items are simple (no continuation), tight style should work normally
1786        let content = "\
1787- Item 1.
1788- Item 2.
1789- Item 3.
1790";
1791        let warnings = check_with_continuation(content, ListItemSpacingStyle::Tight, true);
1792        assert!(
1793            warnings.is_empty(),
1794            "Simple tight list should pass with allow_loose_continuation, got: {warnings:?}"
1795        );
1796    }
1797
1798    // ── Config schema ──────────────────────────────────────────────────
1799
1800    #[test]
1801    fn default_config_section_provides_style_key() {
1802        let rule = MD076ListItemSpacing::new(ListItemSpacingStyle::Consistent);
1803        let section = rule.default_config_section();
1804        assert!(section.is_some());
1805        let (name, value) = section.unwrap();
1806        assert_eq!(name, "MD076");
1807        if let toml::Value::Table(map) = value {
1808            assert!(map.contains_key("style"));
1809            assert!(map.contains_key("allow-loose-continuation"));
1810        } else {
1811            panic!("Expected Table value from default_config_section");
1812        }
1813    }
1814}