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