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