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}
35
36#[derive(Debug, Clone, Default)]
37pub struct MD076ListItemSpacing {
38    config: MD076Config,
39}
40
41/// Classification of the spacing between two consecutive list items.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43enum GapKind {
44    /// No blank line between items.
45    Tight,
46    /// Blank line that is a genuine inter-item separator.
47    Loose,
48    /// Blank line required by another rule (MD031, MD058) after structural content.
49    /// Excluded from consistency analysis — neither loose nor tight.
50    Structural,
51}
52
53/// Per-block analysis result shared by check() and fix().
54struct BlockAnalysis {
55    /// 1-indexed line numbers of items at this block's nesting level.
56    items: Vec<usize>,
57    /// Classification of each inter-item gap.
58    gaps: Vec<GapKind>,
59    /// Whether loose gaps are violations (should have blank lines removed).
60    warn_loose_gaps: bool,
61    /// Whether tight gaps are violations (should have blank lines inserted).
62    warn_tight_gaps: bool,
63}
64
65impl MD076ListItemSpacing {
66    pub fn new(style: ListItemSpacingStyle) -> Self {
67        Self {
68            config: MD076Config { style },
69        }
70    }
71
72    /// Check whether a line is effectively blank, accounting for blockquote markers.
73    ///
74    /// A line like `>` or `> ` is considered blank in blockquote context even though
75    /// its raw content is non-empty.
76    fn is_effectively_blank(ctx: &LintContext, line_num: usize) -> bool {
77        if let Some(info) = ctx.line_info(line_num) {
78            let content = info.content(ctx.content);
79            if content.trim().is_empty() {
80                return true;
81            }
82            // In a blockquote, a line containing only markers (e.g., ">", "> ") is blank
83            if let Some(ref bq) = info.blockquote {
84                return bq.content.trim().is_empty();
85            }
86            false
87        } else {
88            false
89        }
90    }
91
92    /// Check whether a non-blank line is structural content (code block, table, HTML block,
93    /// or blockquote) whose trailing blank line is required by other rules (MD031, MD058).
94    fn is_structural_content(ctx: &LintContext, line_num: usize) -> bool {
95        if let Some(info) = ctx.line_info(line_num) {
96            // Inside a code block (includes the closing fence itself)
97            if info.in_code_block {
98                return true;
99            }
100            // Inside an HTML block
101            if info.in_html_block {
102                return true;
103            }
104            // Inside a blockquote
105            if info.blockquote.is_some() {
106                return true;
107            }
108            // A table row or separator
109            let content = info.content(ctx.content);
110            // Strip blockquote prefix and list continuation indent before checking table syntax
111            let effective = if let Some(ref bq) = info.blockquote {
112                bq.content.as_str()
113            } else {
114                content
115            };
116            if is_table_line(effective.trim_start()) {
117                return true;
118            }
119        }
120        false
121    }
122
123    /// Classify the inter-item gap between two consecutive items.
124    ///
125    /// Returns `Tight` if there is no blank line, `Loose` if there is a genuine
126    /// inter-item separator blank, or `Structural` if the only blank line is
127    /// required by another rule (MD031/MD058) after structural content.
128    fn classify_gap(ctx: &LintContext, first: usize, next: usize) -> GapKind {
129        if next <= first + 1 {
130            return GapKind::Tight;
131        }
132        // The gap has a blank line only if the line immediately before the next item is blank.
133        if !Self::is_effectively_blank(ctx, next - 1) {
134            return GapKind::Tight;
135        }
136        // Walk backwards past blank lines to find the last non-blank content line.
137        // If that line is structural content, the blank is required (not a separator).
138        let mut scan = next - 1;
139        while scan > first && Self::is_effectively_blank(ctx, scan) {
140            scan -= 1;
141        }
142        // `scan` is now the last non-blank line before the next item
143        if scan > first && Self::is_structural_content(ctx, scan) {
144            return GapKind::Structural;
145        }
146        GapKind::Loose
147    }
148
149    /// Collect the 1-indexed line numbers of all inter-item blank lines in the gap.
150    ///
151    /// Walks backwards from the line before `next` collecting consecutive blank lines.
152    /// These are the actual separator lines between items, not blank lines within
153    /// multi-paragraph items. Structural blanks (after code blocks, tables, HTML blocks)
154    /// are excluded.
155    fn inter_item_blanks(ctx: &LintContext, first: usize, next: usize) -> Vec<usize> {
156        let mut blanks = Vec::new();
157        let mut line_num = next - 1;
158        while line_num > first && Self::is_effectively_blank(ctx, line_num) {
159            blanks.push(line_num);
160            line_num -= 1;
161        }
162        // If the last non-blank line is structural content, these blanks are structural
163        if line_num > first && Self::is_structural_content(ctx, line_num) {
164            return Vec::new();
165        }
166        blanks.reverse();
167        blanks
168    }
169
170    /// Analyze a single list block to determine which gaps need fixing.
171    ///
172    /// Returns `None` if the block has fewer than 2 items at its nesting level
173    /// or if no gaps violate the configured style.
174    fn analyze_block(
175        ctx: &LintContext,
176        block: &crate::lint_context::types::ListBlock,
177        style: &ListItemSpacingStyle,
178    ) -> Option<BlockAnalysis> {
179        // Only compare items at this block's own nesting level.
180        // item_lines may include nested list items (higher marker_column) that belong
181        // to a child list — those must not affect spacing analysis.
182        let items: Vec<usize> = block
183            .item_lines
184            .iter()
185            .copied()
186            .filter(|&line_num| {
187                ctx.line_info(line_num)
188                    .and_then(|li| li.list_item.as_ref())
189                    .map(|item| item.marker_column / 2 == block.nesting_level)
190                    .unwrap_or(false)
191            })
192            .collect();
193
194        if items.len() < 2 {
195            return None;
196        }
197
198        // Classify each inter-item gap.
199        let gaps: Vec<GapKind> = items.windows(2).map(|w| Self::classify_gap(ctx, w[0], w[1])).collect();
200
201        // Structural gaps are excluded from consistency analysis — they are
202        // required by other rules (MD031, MD058) and should not influence
203        // whether the list is considered loose or tight.
204        let loose_count = gaps.iter().filter(|&&g| g == GapKind::Loose).count();
205        let tight_count = gaps.iter().filter(|&&g| g == GapKind::Tight).count();
206
207        let (warn_loose_gaps, warn_tight_gaps) = match style {
208            ListItemSpacingStyle::Loose => (false, true),
209            ListItemSpacingStyle::Tight => (true, false),
210            ListItemSpacingStyle::Consistent => {
211                if loose_count == 0 || tight_count == 0 {
212                    return None; // Already consistent (structural gaps excluded)
213                }
214                // Majority wins; on a tie, prefer loose (warn tight).
215                if loose_count >= tight_count {
216                    (false, true)
217                } else {
218                    (true, false)
219                }
220            }
221        };
222
223        Some(BlockAnalysis {
224            items,
225            gaps,
226            warn_loose_gaps,
227            warn_tight_gaps,
228        })
229    }
230}
231
232impl Rule for MD076ListItemSpacing {
233    fn name(&self) -> &'static str {
234        "MD076"
235    }
236
237    fn description(&self) -> &'static str {
238        "List item spacing should be consistent"
239    }
240
241    fn category(&self) -> RuleCategory {
242        RuleCategory::List
243    }
244
245    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
246        ctx.content.is_empty() || ctx.list_blocks.is_empty()
247    }
248
249    fn check(&self, ctx: &LintContext) -> LintResult {
250        if ctx.content.is_empty() {
251            return Ok(Vec::new());
252        }
253
254        let mut warnings = Vec::new();
255
256        for block in &ctx.list_blocks {
257            let Some(analysis) = Self::analyze_block(ctx, block, &self.config.style) else {
258                continue;
259            };
260
261            for (i, &gap) in analysis.gaps.iter().enumerate() {
262                match gap {
263                    GapKind::Structural => {
264                        // Structural gaps are never warned about — they are required
265                        // by other rules (MD031, MD058).
266                    }
267                    GapKind::Loose if analysis.warn_loose_gaps => {
268                        // Warn on the first inter-item blank line in this gap.
269                        let blanks = Self::inter_item_blanks(ctx, analysis.items[i], analysis.items[i + 1]);
270                        if let Some(&blank_line) = blanks.first() {
271                            let line_content = ctx
272                                .line_info(blank_line)
273                                .map(|li| li.content(ctx.content))
274                                .unwrap_or("");
275                            warnings.push(LintWarning {
276                                rule_name: Some(self.name().to_string()),
277                                line: blank_line,
278                                column: 1,
279                                end_line: blank_line,
280                                end_column: line_content.len() + 1,
281                                message: "Unexpected blank line between list items".to_string(),
282                                severity: Severity::Warning,
283                                fix: None,
284                            });
285                        }
286                    }
287                    GapKind::Tight if analysis.warn_tight_gaps => {
288                        // Warn on the next item line (a blank line should precede it).
289                        let next_item = analysis.items[i + 1];
290                        let line_content = ctx.line_info(next_item).map(|li| li.content(ctx.content)).unwrap_or("");
291                        warnings.push(LintWarning {
292                            rule_name: Some(self.name().to_string()),
293                            line: next_item,
294                            column: 1,
295                            end_line: next_item,
296                            end_column: line_content.len() + 1,
297                            message: "Missing blank line between list items".to_string(),
298                            severity: Severity::Warning,
299                            fix: None,
300                        });
301                    }
302                    _ => {}
303                }
304            }
305        }
306
307        Ok(warnings)
308    }
309
310    fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
311        if ctx.content.is_empty() {
312            return Ok(ctx.content.to_string());
313        }
314
315        // Collect all inter-item blank lines to remove and lines to insert before.
316        let mut insert_before: std::collections::HashSet<usize> = std::collections::HashSet::new();
317        let mut remove_lines: std::collections::HashSet<usize> = std::collections::HashSet::new();
318
319        for block in &ctx.list_blocks {
320            let Some(analysis) = Self::analyze_block(ctx, block, &self.config.style) else {
321                continue;
322            };
323
324            for (i, &gap) in analysis.gaps.iter().enumerate() {
325                match gap {
326                    GapKind::Structural => {
327                        // Never modify structural blank lines.
328                    }
329                    GapKind::Loose if analysis.warn_loose_gaps => {
330                        // Remove ALL inter-item blank lines in this gap
331                        for blank_line in Self::inter_item_blanks(ctx, analysis.items[i], analysis.items[i + 1]) {
332                            remove_lines.insert(blank_line);
333                        }
334                    }
335                    GapKind::Tight if analysis.warn_tight_gaps => {
336                        insert_before.insert(analysis.items[i + 1]);
337                    }
338                    _ => {}
339                }
340            }
341        }
342
343        if insert_before.is_empty() && remove_lines.is_empty() {
344            return Ok(ctx.content.to_string());
345        }
346
347        let lines = ctx.raw_lines();
348        let mut result: Vec<String> = Vec::with_capacity(lines.len());
349
350        for (i, line) in lines.iter().enumerate() {
351            let line_num = i + 1;
352
353            // Skip modifications for lines where the rule is disabled via inline config
354            if ctx.is_rule_disabled(self.name(), line_num) {
355                result.push((*line).to_string());
356                continue;
357            }
358
359            if remove_lines.contains(&line_num) {
360                continue;
361            }
362
363            if insert_before.contains(&line_num) {
364                let bq_prefix = ctx.blockquote_prefix_for_blank_line(i);
365                result.push(bq_prefix);
366            }
367
368            result.push((*line).to_string());
369        }
370
371        let mut output = result.join("\n");
372        if ctx.content.ends_with('\n') {
373            output.push('\n');
374        }
375        Ok(output)
376    }
377
378    fn as_any(&self) -> &dyn std::any::Any {
379        self
380    }
381
382    fn default_config_section(&self) -> Option<(String, toml::Value)> {
383        let mut map = toml::map::Map::new();
384        let style_str = match self.config.style {
385            ListItemSpacingStyle::Consistent => "consistent",
386            ListItemSpacingStyle::Loose => "loose",
387            ListItemSpacingStyle::Tight => "tight",
388        };
389        map.insert("style".to_string(), toml::Value::String(style_str.to_string()));
390        Some((self.name().to_string(), toml::Value::Table(map)))
391    }
392
393    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
394    where
395        Self: Sized,
396    {
397        let style = crate::config::get_rule_config_value::<String>(config, "MD076", "style")
398            .unwrap_or_else(|| "consistent".to_string());
399        let style = match style.as_str() {
400            "loose" => ListItemSpacingStyle::Loose,
401            "tight" => ListItemSpacingStyle::Tight,
402            _ => ListItemSpacingStyle::Consistent,
403        };
404        Box::new(Self::new(style))
405    }
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411
412    fn check(content: &str, style: ListItemSpacingStyle) -> Vec<LintWarning> {
413        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
414        let rule = MD076ListItemSpacing::new(style);
415        rule.check(&ctx).unwrap()
416    }
417
418    fn fix(content: &str, style: ListItemSpacingStyle) -> String {
419        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
420        let rule = MD076ListItemSpacing::new(style);
421        rule.fix(&ctx).unwrap()
422    }
423
424    // ── Basic style detection ──────────────────────────────────────────
425
426    #[test]
427    fn tight_list_tight_style_no_warnings() {
428        let content = "- Item 1\n- Item 2\n- Item 3\n";
429        assert!(check(content, ListItemSpacingStyle::Tight).is_empty());
430    }
431
432    #[test]
433    fn loose_list_loose_style_no_warnings() {
434        let content = "- Item 1\n\n- Item 2\n\n- Item 3\n";
435        assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
436    }
437
438    #[test]
439    fn tight_list_loose_style_warns() {
440        let content = "- Item 1\n- Item 2\n- Item 3\n";
441        let warnings = check(content, ListItemSpacingStyle::Loose);
442        assert_eq!(warnings.len(), 2);
443        assert!(warnings.iter().all(|w| w.message.contains("Missing")));
444    }
445
446    #[test]
447    fn loose_list_tight_style_warns() {
448        let content = "- Item 1\n\n- Item 2\n\n- Item 3\n";
449        let warnings = check(content, ListItemSpacingStyle::Tight);
450        assert_eq!(warnings.len(), 2);
451        assert!(warnings.iter().all(|w| w.message.contains("Unexpected")));
452    }
453
454    // ── Consistent mode ────────────────────────────────────────────────
455
456    #[test]
457    fn consistent_all_tight_no_warnings() {
458        let content = "- Item 1\n- Item 2\n- Item 3\n";
459        assert!(check(content, ListItemSpacingStyle::Consistent).is_empty());
460    }
461
462    #[test]
463    fn consistent_all_loose_no_warnings() {
464        let content = "- Item 1\n\n- Item 2\n\n- Item 3\n";
465        assert!(check(content, ListItemSpacingStyle::Consistent).is_empty());
466    }
467
468    #[test]
469    fn consistent_mixed_majority_loose_warns_tight() {
470        // 2 loose gaps, 1 tight gap → tight is minority → warn on tight
471        let content = "- Item 1\n\n- Item 2\n- Item 3\n\n- Item 4\n";
472        let warnings = check(content, ListItemSpacingStyle::Consistent);
473        assert_eq!(warnings.len(), 1);
474        assert!(warnings[0].message.contains("Missing"));
475    }
476
477    #[test]
478    fn consistent_mixed_majority_tight_warns_loose() {
479        // 1 loose gap, 2 tight gaps → loose is minority → warn on loose blank line
480        let content = "- Item 1\n\n- Item 2\n- Item 3\n- Item 4\n";
481        let warnings = check(content, ListItemSpacingStyle::Consistent);
482        assert_eq!(warnings.len(), 1);
483        assert!(warnings[0].message.contains("Unexpected"));
484    }
485
486    #[test]
487    fn consistent_tie_prefers_loose() {
488        let content = "- Item 1\n\n- Item 2\n- Item 3\n";
489        let warnings = check(content, ListItemSpacingStyle::Consistent);
490        assert_eq!(warnings.len(), 1);
491        assert!(warnings[0].message.contains("Missing"));
492    }
493
494    // ── Edge cases ─────────────────────────────────────────────────────
495
496    #[test]
497    fn single_item_list_no_warnings() {
498        let content = "- Only item\n";
499        assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
500        assert!(check(content, ListItemSpacingStyle::Tight).is_empty());
501        assert!(check(content, ListItemSpacingStyle::Consistent).is_empty());
502    }
503
504    #[test]
505    fn empty_content_no_warnings() {
506        assert!(check("", ListItemSpacingStyle::Consistent).is_empty());
507    }
508
509    #[test]
510    fn ordered_list_tight_gaps_loose_style_warns() {
511        let content = "1. First\n2. Second\n3. Third\n";
512        let warnings = check(content, ListItemSpacingStyle::Loose);
513        assert_eq!(warnings.len(), 2);
514    }
515
516    #[test]
517    fn task_list_works() {
518        let content = "- [x] Task 1\n- [ ] Task 2\n- [x] Task 3\n";
519        let warnings = check(content, ListItemSpacingStyle::Loose);
520        assert_eq!(warnings.len(), 2);
521        let fixed = fix(content, ListItemSpacingStyle::Loose);
522        assert_eq!(fixed, "- [x] Task 1\n\n- [ ] Task 2\n\n- [x] Task 3\n");
523    }
524
525    #[test]
526    fn no_trailing_newline() {
527        let content = "- Item 1\n- Item 2";
528        let warnings = check(content, ListItemSpacingStyle::Loose);
529        assert_eq!(warnings.len(), 1);
530        let fixed = fix(content, ListItemSpacingStyle::Loose);
531        assert_eq!(fixed, "- Item 1\n\n- Item 2");
532    }
533
534    #[test]
535    fn two_separate_lists() {
536        let content = "- A\n- B\n\nText\n\n1. One\n2. Two\n";
537        let warnings = check(content, ListItemSpacingStyle::Loose);
538        assert_eq!(warnings.len(), 2);
539        let fixed = fix(content, ListItemSpacingStyle::Loose);
540        assert_eq!(fixed, "- A\n\n- B\n\nText\n\n1. One\n\n2. Two\n");
541    }
542
543    #[test]
544    fn no_list_content() {
545        let content = "Just a paragraph.\n\nAnother paragraph.\n";
546        assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
547        assert!(check(content, ListItemSpacingStyle::Tight).is_empty());
548    }
549
550    // ── Multi-line and continuation items ──────────────────────────────
551
552    #[test]
553    fn continuation_lines_tight_detected() {
554        let content = "- Item 1\n  continuation\n- Item 2\n";
555        let warnings = check(content, ListItemSpacingStyle::Loose);
556        assert_eq!(warnings.len(), 1);
557        assert!(warnings[0].message.contains("Missing"));
558    }
559
560    #[test]
561    fn continuation_lines_loose_detected() {
562        let content = "- Item 1\n  continuation\n\n- Item 2\n";
563        assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
564        let warnings = check(content, ListItemSpacingStyle::Tight);
565        assert_eq!(warnings.len(), 1);
566        assert!(warnings[0].message.contains("Unexpected"));
567    }
568
569    #[test]
570    fn multi_paragraph_item_not_treated_as_inter_item_gap() {
571        // Blank line between paragraphs within Item 1 must NOT trigger a warning.
572        // Only the blank line immediately before Item 2 is an inter-item separator.
573        let content = "- Item 1\n\n  Second paragraph\n\n- Item 2\n";
574        // Both gaps are loose (blank before Item 2), so tight should warn once
575        let warnings = check(content, ListItemSpacingStyle::Tight);
576        assert_eq!(
577            warnings.len(),
578            1,
579            "Should warn only on the inter-item blank, not the intra-item blank"
580        );
581        // The fix should remove only the inter-item blank (line 4), preserving the
582        // multi-paragraph structure
583        let fixed = fix(content, ListItemSpacingStyle::Tight);
584        assert_eq!(fixed, "- Item 1\n\n  Second paragraph\n- Item 2\n");
585    }
586
587    #[test]
588    fn multi_paragraph_item_loose_style_no_warnings() {
589        // A loose list with multi-paragraph items is already loose — no warnings
590        let content = "- Item 1\n\n  Second paragraph\n\n- Item 2\n";
591        assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
592    }
593
594    // ── Blockquote lists ───────────────────────────────────────────────
595
596    #[test]
597    fn blockquote_tight_list_loose_style_warns() {
598        let content = "> - Item 1\n> - Item 2\n> - Item 3\n";
599        let warnings = check(content, ListItemSpacingStyle::Loose);
600        assert_eq!(warnings.len(), 2);
601    }
602
603    #[test]
604    fn blockquote_loose_list_detected() {
605        // A line with only `>` is effectively blank in blockquote context
606        let content = "> - Item 1\n>\n> - Item 2\n";
607        let warnings = check(content, ListItemSpacingStyle::Tight);
608        assert_eq!(warnings.len(), 1, "Blockquote-only line should be detected as blank");
609        assert!(warnings[0].message.contains("Unexpected"));
610    }
611
612    #[test]
613    fn blockquote_loose_list_no_warnings_when_loose() {
614        let content = "> - Item 1\n>\n> - Item 2\n";
615        assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
616    }
617
618    // ── Multiple blank lines ───────────────────────────────────────────
619
620    #[test]
621    fn multiple_blanks_all_removed() {
622        let content = "- Item 1\n\n\n- Item 2\n";
623        let fixed = fix(content, ListItemSpacingStyle::Tight);
624        assert_eq!(fixed, "- Item 1\n- Item 2\n");
625    }
626
627    #[test]
628    fn multiple_blanks_fix_is_idempotent() {
629        let content = "- Item 1\n\n\n\n- Item 2\n";
630        let fixed_once = fix(content, ListItemSpacingStyle::Tight);
631        let fixed_twice = fix(&fixed_once, ListItemSpacingStyle::Tight);
632        assert_eq!(fixed_once, fixed_twice);
633        assert_eq!(fixed_once, "- Item 1\n- Item 2\n");
634    }
635
636    // ── Fix correctness ────────────────────────────────────────────────
637
638    #[test]
639    fn fix_adds_blank_lines() {
640        let content = "- Item 1\n- Item 2\n- Item 3\n";
641        let fixed = fix(content, ListItemSpacingStyle::Loose);
642        assert_eq!(fixed, "- Item 1\n\n- Item 2\n\n- Item 3\n");
643    }
644
645    #[test]
646    fn fix_removes_blank_lines() {
647        let content = "- Item 1\n\n- Item 2\n\n- Item 3\n";
648        let fixed = fix(content, ListItemSpacingStyle::Tight);
649        assert_eq!(fixed, "- Item 1\n- Item 2\n- Item 3\n");
650    }
651
652    #[test]
653    fn fix_consistent_adds_blank() {
654        // 2 loose gaps, 1 tight gap → add blank before Item 3
655        let content = "- Item 1\n\n- Item 2\n- Item 3\n\n- Item 4\n";
656        let fixed = fix(content, ListItemSpacingStyle::Consistent);
657        assert_eq!(fixed, "- Item 1\n\n- Item 2\n\n- Item 3\n\n- Item 4\n");
658    }
659
660    #[test]
661    fn fix_idempotent_loose() {
662        let content = "- Item 1\n- Item 2\n";
663        let fixed_once = fix(content, ListItemSpacingStyle::Loose);
664        let fixed_twice = fix(&fixed_once, ListItemSpacingStyle::Loose);
665        assert_eq!(fixed_once, fixed_twice);
666    }
667
668    #[test]
669    fn fix_idempotent_tight() {
670        let content = "- Item 1\n\n- Item 2\n";
671        let fixed_once = fix(content, ListItemSpacingStyle::Tight);
672        let fixed_twice = fix(&fixed_once, ListItemSpacingStyle::Tight);
673        assert_eq!(fixed_once, fixed_twice);
674    }
675
676    // ── Nested lists ───────────────────────────────────────────────────
677
678    #[test]
679    fn nested_list_does_not_affect_parent() {
680        // Nested items should not trigger warnings for the parent list
681        let content = "- Item 1\n  - Nested A\n  - Nested B\n- Item 2\n";
682        let warnings = check(content, ListItemSpacingStyle::Tight);
683        assert!(
684            warnings.is_empty(),
685            "Nested items should not cause parent-level warnings"
686        );
687    }
688
689    // ── Structural blank lines (code blocks, tables, HTML) ──────────
690
691    #[test]
692    fn code_block_in_tight_list_no_false_positive() {
693        // Blank line after closing fence is structural (required by MD031), not a separator
694        let content = "\
695- Item 1 with code:
696
697  ```python
698  print('hello')
699  ```
700
701- Item 2 simple.
702- Item 3 simple.
703";
704        assert!(
705            check(content, ListItemSpacingStyle::Consistent).is_empty(),
706            "Structural blank after code block should not make item 1 appear loose"
707        );
708    }
709
710    #[test]
711    fn table_in_tight_list_no_false_positive() {
712        // Blank line after table is structural (required by MD058), not a separator
713        let content = "\
714- Item 1 with table:
715
716  | Col 1 | Col 2 |
717  |-------|-------|
718  | A     | B     |
719
720- Item 2 simple.
721- Item 3 simple.
722";
723        assert!(
724            check(content, ListItemSpacingStyle::Consistent).is_empty(),
725            "Structural blank after table should not make item 1 appear loose"
726        );
727    }
728
729    #[test]
730    fn html_block_in_tight_list_no_false_positive() {
731        let content = "\
732- Item 1 with HTML:
733
734  <details>
735  <summary>Click</summary>
736  Content
737  </details>
738
739- Item 2 simple.
740- Item 3 simple.
741";
742        assert!(
743            check(content, ListItemSpacingStyle::Consistent).is_empty(),
744            "Structural blank after HTML block should not make item 1 appear loose"
745        );
746    }
747
748    #[test]
749    fn blockquote_in_tight_list_no_false_positive() {
750        // Blank line around a blockquote in a list item is structural, not a separator
751        let content = "\
752- Item 1 with quote:
753
754  > This is a blockquote
755  > with multiple lines.
756
757- Item 2 simple.
758- Item 3 simple.
759";
760        assert!(
761            check(content, ListItemSpacingStyle::Consistent).is_empty(),
762            "Structural blank around blockquote should not make item 1 appear loose"
763        );
764        assert!(
765            check(content, ListItemSpacingStyle::Tight).is_empty(),
766            "Blockquote in tight list should not trigger a violation"
767        );
768    }
769
770    #[test]
771    fn blockquote_multiple_items_with_quotes_tight() {
772        // Multiple items with blockquotes should all be treated as structural
773        let content = "\
774- Item 1:
775
776  > Quote A
777
778- Item 2:
779
780  > Quote B
781
782- Item 3 plain.
783";
784        assert!(
785            check(content, ListItemSpacingStyle::Tight).is_empty(),
786            "Multiple items with blockquotes should remain tight"
787        );
788    }
789
790    #[test]
791    fn blockquote_mixed_with_genuine_loose_gap() {
792        // A blockquote item followed by a genuine loose gap should still be detected
793        let content = "\
794- Item 1:
795
796  > Quote
797
798- Item 2 plain.
799
800- Item 3 plain.
801";
802        let warnings = check(content, ListItemSpacingStyle::Tight);
803        assert!(
804            !warnings.is_empty(),
805            "Genuine loose gap between Item 2 and Item 3 should be flagged"
806        );
807    }
808
809    #[test]
810    fn blockquote_single_line_in_tight_list() {
811        let content = "\
812- Item 1:
813
814  > Single line quote.
815
816- Item 2.
817- Item 3.
818";
819        assert!(
820            check(content, ListItemSpacingStyle::Tight).is_empty(),
821            "Single-line blockquote should be structural"
822        );
823    }
824
825    #[test]
826    fn blockquote_in_ordered_list_tight() {
827        let content = "\
8281. Item 1:
829
830   > Quoted text in ordered list.
831
8321. Item 2.
8331. Item 3.
834";
835        assert!(
836            check(content, ListItemSpacingStyle::Tight).is_empty(),
837            "Blockquote in ordered list should be structural"
838        );
839    }
840
841    #[test]
842    fn nested_blockquote_in_tight_list() {
843        let content = "\
844- Item 1:
845
846  > Outer quote
847  > > Nested quote
848
849- Item 2.
850- Item 3.
851";
852        assert!(
853            check(content, ListItemSpacingStyle::Tight).is_empty(),
854            "Nested blockquote in tight list should be structural"
855        );
856    }
857
858    #[test]
859    fn blockquote_as_entire_item_is_loose() {
860        // When a blockquote IS the item content (not nested within text),
861        // a trailing blank line is a genuine loose gap, not structural.
862        let content = "\
863- > Quote is the entire item content.
864
865- Item 2.
866- Item 3.
867";
868        let warnings = check(content, ListItemSpacingStyle::Tight);
869        assert!(
870            !warnings.is_empty(),
871            "Blank after blockquote-only item is a genuine loose gap"
872        );
873    }
874
875    #[test]
876    fn mixed_code_and_table_in_tight_list() {
877        let content = "\
8781. Item with code:
879
880   ```markdown
881   This is some Markdown
882   ```
883
8841. Simple item.
8851. Item with table:
886
887   | Col 1 | Col 2 |
888   |:------|:------|
889   | Row 1 | Row 1 |
890   | Row 2 | Row 2 |
891";
892        assert!(
893            check(content, ListItemSpacingStyle::Consistent).is_empty(),
894            "Mix of code blocks and tables should not cause false positives"
895        );
896    }
897
898    #[test]
899    fn code_block_with_genuinely_loose_gaps_still_warns() {
900        // Item 1 has structural blank (code block), items 2-3 have genuine blank separator
901        // Items 2-3 are genuinely loose, item 3-4 is tight → inconsistent
902        let content = "\
903- Item 1:
904
905  ```bash
906  echo hi
907  ```
908
909- Item 2
910
911- Item 3
912- Item 4
913";
914        let warnings = check(content, ListItemSpacingStyle::Consistent);
915        assert!(
916            !warnings.is_empty(),
917            "Genuine inconsistency with code blocks should still be flagged"
918        );
919    }
920
921    #[test]
922    fn all_items_have_code_blocks_no_warnings() {
923        let content = "\
924- Item 1:
925
926  ```python
927  print(1)
928  ```
929
930- Item 2:
931
932  ```python
933  print(2)
934  ```
935
936- Item 3:
937
938  ```python
939  print(3)
940  ```
941";
942        assert!(
943            check(content, ListItemSpacingStyle::Consistent).is_empty(),
944            "All items with code blocks should be consistently tight"
945        );
946    }
947
948    #[test]
949    fn tilde_fence_code_block_in_list() {
950        let content = "\
951- Item 1:
952
953  ~~~
954  code here
955  ~~~
956
957- Item 2 simple.
958- Item 3 simple.
959";
960        assert!(
961            check(content, ListItemSpacingStyle::Consistent).is_empty(),
962            "Tilde fences should be recognized as structural content"
963        );
964    }
965
966    #[test]
967    fn nested_list_with_code_block() {
968        let content = "\
969- Item 1
970  - Nested with code:
971
972    ```
973    nested code
974    ```
975
976  - Nested simple.
977- Item 2
978";
979        assert!(
980            check(content, ListItemSpacingStyle::Consistent).is_empty(),
981            "Nested list with code block should not cause false positives"
982        );
983    }
984
985    #[test]
986    fn tight_style_with_code_block_no_warnings() {
987        let content = "\
988- Item 1:
989
990  ```
991  code
992  ```
993
994- Item 2.
995- Item 3.
996";
997        assert!(
998            check(content, ListItemSpacingStyle::Tight).is_empty(),
999            "Tight style should not warn about structural blanks around code blocks"
1000        );
1001    }
1002
1003    #[test]
1004    fn loose_style_with_code_block_missing_separator() {
1005        // Loose style requires blank line between every pair of items.
1006        // Items 2-3 have no blank → should warn
1007        let content = "\
1008- Item 1:
1009
1010  ```
1011  code
1012  ```
1013
1014- Item 2.
1015- Item 3.
1016";
1017        let warnings = check(content, ListItemSpacingStyle::Loose);
1018        assert_eq!(
1019            warnings.len(),
1020            1,
1021            "Loose style should still require blank between simple items"
1022        );
1023        assert!(warnings[0].message.contains("Missing"));
1024    }
1025
1026    #[test]
1027    fn blockquote_list_with_code_block() {
1028        let content = "\
1029> - Item 1:
1030>
1031>   ```
1032>   code
1033>   ```
1034>
1035> - Item 2.
1036> - Item 3.
1037";
1038        assert!(
1039            check(content, ListItemSpacingStyle::Consistent).is_empty(),
1040            "Blockquote-prefixed list with code block should not cause false positives"
1041        );
1042    }
1043
1044    // ── Indented code block (not fenced) in list item ─────────────────
1045
1046    #[test]
1047    fn indented_code_block_in_list_no_false_positive() {
1048        // A 4-space indented code block inside a list item should be treated
1049        // as structural content, not trigger a loose gap detection.
1050        let content = "\
10511. Item with indented code:
1052
1053       some code here
1054       more code
1055
10561. Simple item
10571. Another item
1058";
1059        assert!(
1060            check(content, ListItemSpacingStyle::Consistent).is_empty(),
1061            "Structural blank after indented code block should not make item 1 appear loose"
1062        );
1063    }
1064
1065    // ── Code block in middle of item with text after ────────────────
1066
1067    #[test]
1068    fn code_block_in_middle_of_item_text_after_is_genuinely_loose() {
1069        // When a code block is in the middle of an item and there's regular text
1070        // after it, a blank line before the next item IS a genuine separator (loose),
1071        // not structural. The last non-blank line before item 2 is "Some text after
1072        // the code block." which is NOT structural content.
1073        let content = "\
10741. Item with code in middle:
1075
1076   ```
1077   code
1078   ```
1079
1080   Some text after the code block.
1081
10821. Simple item
10831. Another item
1084";
1085        let warnings = check(content, ListItemSpacingStyle::Consistent);
1086        assert!(
1087            !warnings.is_empty(),
1088            "Blank line after regular text (not structural content) is a genuine loose gap"
1089        );
1090    }
1091
1092    // ── Fix: tight mode preserves structural blanks ──────────────────
1093
1094    #[test]
1095    fn tight_fix_preserves_structural_blanks_around_code_blocks() {
1096        // When style is tight, the fix should NOT remove structural blank lines
1097        // around code blocks inside list items. Those blanks are required by MD031.
1098        let content = "\
1099- Item 1:
1100
1101  ```
1102  code
1103  ```
1104
1105- Item 2.
1106- Item 3.
1107";
1108        let fixed = fix(content, ListItemSpacingStyle::Tight);
1109        assert_eq!(
1110            fixed, content,
1111            "Tight fix should not remove structural blanks around code blocks"
1112        );
1113    }
1114
1115    // ── Issue #461: 4-space indented code block in loose list ──────────
1116
1117    #[test]
1118    fn four_space_indented_fence_in_loose_list_no_false_positive() {
1119        // Reproduction case from issue #461 comment by @sisp.
1120        // The fenced code block uses 4-space indentation inside an ordered list.
1121        // The blank line after the closing fence is structural (required by MD031)
1122        // and must not create a false "Missing blank line" warning.
1123        let content = "\
11241. First item
1125
11261. Second item with code block:
1127
1128    ```json
1129    {\"key\": \"value\"}
1130    ```
1131
11321. Third item
1133";
1134        assert!(
1135            check(content, ListItemSpacingStyle::Consistent).is_empty(),
1136            "Structural blank after 4-space indented code block should not cause false positive"
1137        );
1138    }
1139
1140    #[test]
1141    fn four_space_indented_fence_tight_style_no_warnings() {
1142        let content = "\
11431. First item
11441. Second item with code block:
1145
1146    ```json
1147    {\"key\": \"value\"}
1148    ```
1149
11501. Third item
1151";
1152        assert!(
1153            check(content, ListItemSpacingStyle::Tight).is_empty(),
1154            "Tight style should not warn about structural blanks with 4-space fences"
1155        );
1156    }
1157
1158    #[test]
1159    fn four_space_indented_fence_loose_style_no_warnings() {
1160        // All non-structural gaps are loose, structural gaps are excluded.
1161        let content = "\
11621. First item
1163
11641. Second item with code block:
1165
1166    ```json
1167    {\"key\": \"value\"}
1168    ```
1169
11701. Third item
1171";
1172        assert!(
1173            check(content, ListItemSpacingStyle::Loose).is_empty(),
1174            "Loose style should not warn when structural gaps are the only non-loose gaps"
1175        );
1176    }
1177
1178    #[test]
1179    fn structural_gap_with_genuine_inconsistency_still_warns() {
1180        // Item 1 has a structural code block. Items 2-3 are genuinely loose,
1181        // but items 3-4 are tight → genuine inconsistency should still warn.
1182        let content = "\
11831. First item with code:
1184
1185    ```json
1186    {\"key\": \"value\"}
1187    ```
1188
11891. Second item
1190
11911. Third item
11921. Fourth item
1193";
1194        let warnings = check(content, ListItemSpacingStyle::Consistent);
1195        assert!(
1196            !warnings.is_empty(),
1197            "Genuine loose/tight inconsistency should still warn even with structural gaps"
1198        );
1199    }
1200
1201    #[test]
1202    fn four_space_fence_fix_is_idempotent() {
1203        // Fix should not modify a list that has only structural gaps and
1204        // genuine loose gaps — it's already consistent.
1205        let content = "\
12061. First item
1207
12081. Second item with code block:
1209
1210    ```json
1211    {\"key\": \"value\"}
1212    ```
1213
12141. Third item
1215";
1216        let fixed = fix(content, ListItemSpacingStyle::Consistent);
1217        assert_eq!(fixed, content, "Fix should be a no-op for lists with structural gaps");
1218        let fixed_twice = fix(&fixed, ListItemSpacingStyle::Consistent);
1219        assert_eq!(fixed, fixed_twice, "Fix should be idempotent");
1220    }
1221
1222    #[test]
1223    fn four_space_fence_fix_does_not_insert_duplicate_blank() {
1224        // When tight style tries to fix, it should not insert a blank line
1225        // before item 3 when one already exists (structural).
1226        let content = "\
12271. First item
12281. Second item with code block:
1229
1230    ```json
1231    {\"key\": \"value\"}
1232    ```
1233
12341. Third item
1235";
1236        let fixed = fix(content, ListItemSpacingStyle::Tight);
1237        assert_eq!(fixed, content, "Tight fix should not modify structural blanks");
1238    }
1239
1240    #[test]
1241    fn mkdocs_flavor_code_block_in_list_no_false_positive() {
1242        // MkDocs flavor with code block inside a list item.
1243        // Reported by @sisp in issue #461 comment.
1244        let content = "\
12451. First item
1246
12471. Second item with code block:
1248
1249    ```json
1250    {\"key\": \"value\"}
1251    ```
1252
12531. Third item
1254";
1255        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1256        let rule = MD076ListItemSpacing::new(ListItemSpacingStyle::Consistent);
1257        let warnings = rule.check(&ctx).unwrap();
1258        assert!(
1259            warnings.is_empty(),
1260            "MkDocs flavor with structural code block blank should not produce false positive, got: {warnings:?}"
1261        );
1262    }
1263
1264    // ── Issue #500: code block inside list item splits list blocks ─────
1265
1266    #[test]
1267    fn code_block_in_second_item_detects_inconsistency() {
1268        // A code block inside item 2 must not split the list into separate blocks.
1269        // Items 1-2 are tight, items 3-4 are loose → inconsistent.
1270        let content = "\
1271# Test
1272
1273- Lorem ipsum dolor sit amet.
1274- Lorem ipsum dolor sit amet.
1275
1276    ```yaml
1277    hello: world
1278    ```
1279
1280- Lorem ipsum dolor sit amet.
1281
1282- Lorem ipsum dolor sit amet.
1283";
1284        let warnings = check(content, ListItemSpacingStyle::Consistent);
1285        assert!(
1286            !warnings.is_empty(),
1287            "Should detect inconsistent spacing when code block is inside a list item"
1288        );
1289    }
1290
1291    #[test]
1292    fn code_block_in_item_all_tight_no_warnings() {
1293        // All non-structural gaps are tight → consistent, no warnings.
1294        let content = "\
1295- Item 1
1296- Item 2
1297
1298    ```yaml
1299    hello: world
1300    ```
1301
1302- Item 3
1303- Item 4
1304";
1305        assert!(
1306            check(content, ListItemSpacingStyle::Consistent).is_empty(),
1307            "All tight gaps with structural code block should not warn"
1308        );
1309    }
1310
1311    #[test]
1312    fn code_block_in_item_all_loose_no_warnings() {
1313        // All non-structural gaps are loose → consistent, no warnings.
1314        let content = "\
1315- Item 1
1316
1317- Item 2
1318
1319    ```yaml
1320    hello: world
1321    ```
1322
1323- Item 3
1324
1325- Item 4
1326";
1327        assert!(
1328            check(content, ListItemSpacingStyle::Consistent).is_empty(),
1329            "All loose gaps with structural code block should not warn"
1330        );
1331    }
1332
1333    #[test]
1334    fn code_block_in_ordered_list_detects_inconsistency() {
1335        let content = "\
13361. First item
13371. Second item
1338
1339    ```json
1340    {\"key\": \"value\"}
1341    ```
1342
13431. Third item
1344
13451. Fourth item
1346";
1347        let warnings = check(content, ListItemSpacingStyle::Consistent);
1348        assert!(
1349            !warnings.is_empty(),
1350            "Ordered list with code block should still detect inconsistency"
1351        );
1352    }
1353
1354    #[test]
1355    fn code_block_in_item_fix_adds_missing_blanks() {
1356        // Items 1-2 are tight, items 3-4 are loose → majority loose → fix adds blank before item 2
1357        let content = "\
1358- Item 1
1359- Item 2
1360
1361    ```yaml
1362    code: here
1363    ```
1364
1365- Item 3
1366
1367- Item 4
1368";
1369        let fixed = fix(content, ListItemSpacingStyle::Consistent);
1370        assert!(
1371            fixed.contains("- Item 1\n\n- Item 2"),
1372            "Fix should add blank line between items 1 and 2"
1373        );
1374    }
1375
1376    #[test]
1377    fn tilde_code_block_in_item_detects_inconsistency() {
1378        let content = "\
1379- Item 1
1380- Item 2
1381
1382    ~~~
1383    code
1384    ~~~
1385
1386- Item 3
1387
1388- Item 4
1389";
1390        let warnings = check(content, ListItemSpacingStyle::Consistent);
1391        assert!(
1392            !warnings.is_empty(),
1393            "Tilde code block inside item should not prevent inconsistency detection"
1394        );
1395    }
1396
1397    #[test]
1398    fn multiple_code_blocks_all_tight_no_warnings() {
1399        // All non-structural gaps are tight → consistent.
1400        let content = "\
1401- Item 1
1402
1403    ```
1404    code1
1405    ```
1406
1407- Item 2
1408
1409    ```
1410    code2
1411    ```
1412
1413- Item 3
1414- Item 4
1415";
1416        assert!(
1417            check(content, ListItemSpacingStyle::Consistent).is_empty(),
1418            "All non-structural gaps are tight, so list is consistent"
1419        );
1420    }
1421
1422    #[test]
1423    fn code_block_with_mixed_genuine_gaps_warns() {
1424        // Items 1-2 structural, 2-3 loose, 3-4 tight → genuine inconsistency
1425        let content = "\
1426- Item 1
1427
1428    ```
1429    code1
1430    ```
1431
1432- Item 2
1433
1434- Item 3
1435- Item 4
1436";
1437        let warnings = check(content, ListItemSpacingStyle::Consistent);
1438        assert!(
1439            !warnings.is_empty(),
1440            "Mixed genuine gaps (loose + tight) with structural code block should still warn"
1441        );
1442    }
1443
1444    // ── Config schema ──────────────────────────────────────────────────
1445
1446    #[test]
1447    fn default_config_section_provides_style_key() {
1448        let rule = MD076ListItemSpacing::new(ListItemSpacingStyle::Consistent);
1449        let section = rule.default_config_section();
1450        assert!(section.is_some());
1451        let (name, value) = section.unwrap();
1452        assert_eq!(name, "MD076");
1453        if let toml::Value::Table(map) = value {
1454            assert!(map.contains_key("style"));
1455        } else {
1456            panic!("Expected Table value from default_config_section");
1457        }
1458    }
1459}