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