Skip to main content

rumdl_lib/rules/
md076_list_item_spacing.rs

1use crate::lint_context::LintContext;
2use crate::rule::{LintError, LintResult, LintWarning, Rule, 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, or HTML block)
93    /// 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            // A table row or separator
105            let content = info.content(ctx.content);
106            // Strip blockquote prefix and list continuation indent before checking table syntax
107            let effective = if let Some(ref bq) = info.blockquote {
108                bq.content.as_str()
109            } else {
110                content
111            };
112            if is_table_line(effective.trim_start()) {
113                return true;
114            }
115        }
116        false
117    }
118
119    /// Classify the inter-item gap between two consecutive items.
120    ///
121    /// Returns `Tight` if there is no blank line, `Loose` if there is a genuine
122    /// inter-item separator blank, or `Structural` if the only blank line is
123    /// required by another rule (MD031/MD058) after structural content.
124    fn classify_gap(ctx: &LintContext, first: usize, next: usize) -> GapKind {
125        if next <= first + 1 {
126            return GapKind::Tight;
127        }
128        // The gap has a blank line only if the line immediately before the next item is blank.
129        if !Self::is_effectively_blank(ctx, next - 1) {
130            return GapKind::Tight;
131        }
132        // Walk backwards past blank lines to find the last non-blank content line.
133        // If that line is structural content, the blank is required (not a separator).
134        let mut scan = next - 1;
135        while scan > first && Self::is_effectively_blank(ctx, scan) {
136            scan -= 1;
137        }
138        // `scan` is now the last non-blank line before the next item
139        if scan > first && Self::is_structural_content(ctx, scan) {
140            return GapKind::Structural;
141        }
142        GapKind::Loose
143    }
144
145    /// Collect the 1-indexed line numbers of all inter-item blank lines in the gap.
146    ///
147    /// Walks backwards from the line before `next` collecting consecutive blank lines.
148    /// These are the actual separator lines between items, not blank lines within
149    /// multi-paragraph items. Structural blanks (after code blocks, tables, HTML blocks)
150    /// are excluded.
151    fn inter_item_blanks(ctx: &LintContext, first: usize, next: usize) -> Vec<usize> {
152        let mut blanks = Vec::new();
153        let mut line_num = next - 1;
154        while line_num > first && Self::is_effectively_blank(ctx, line_num) {
155            blanks.push(line_num);
156            line_num -= 1;
157        }
158        // If the last non-blank line is structural content, these blanks are structural
159        if line_num > first && Self::is_structural_content(ctx, line_num) {
160            return Vec::new();
161        }
162        blanks.reverse();
163        blanks
164    }
165
166    /// Analyze a single list block to determine which gaps need fixing.
167    ///
168    /// Returns `None` if the block has fewer than 2 items at its nesting level
169    /// or if no gaps violate the configured style.
170    fn analyze_block(
171        ctx: &LintContext,
172        block: &crate::lint_context::types::ListBlock,
173        style: &ListItemSpacingStyle,
174    ) -> Option<BlockAnalysis> {
175        // Only compare items at this block's own nesting level.
176        // item_lines may include nested list items (higher marker_column) that belong
177        // to a child list — those must not affect spacing analysis.
178        let items: Vec<usize> = block
179            .item_lines
180            .iter()
181            .copied()
182            .filter(|&line_num| {
183                ctx.line_info(line_num)
184                    .and_then(|li| li.list_item.as_ref())
185                    .map(|item| item.marker_column / 2 == block.nesting_level)
186                    .unwrap_or(false)
187            })
188            .collect();
189
190        if items.len() < 2 {
191            return None;
192        }
193
194        // Classify each inter-item gap.
195        let gaps: Vec<GapKind> = items.windows(2).map(|w| Self::classify_gap(ctx, w[0], w[1])).collect();
196
197        // Structural gaps are excluded from consistency analysis — they are
198        // required by other rules (MD031, MD058) and should not influence
199        // whether the list is considered loose or tight.
200        let loose_count = gaps.iter().filter(|&&g| g == GapKind::Loose).count();
201        let tight_count = gaps.iter().filter(|&&g| g == GapKind::Tight).count();
202
203        let (warn_loose_gaps, warn_tight_gaps) = match style {
204            ListItemSpacingStyle::Loose => (false, true),
205            ListItemSpacingStyle::Tight => (true, false),
206            ListItemSpacingStyle::Consistent => {
207                if loose_count == 0 || tight_count == 0 {
208                    return None; // Already consistent (structural gaps excluded)
209                }
210                // Majority wins; on a tie, prefer loose (warn tight).
211                if loose_count >= tight_count {
212                    (false, true)
213                } else {
214                    (true, false)
215                }
216            }
217        };
218
219        Some(BlockAnalysis {
220            items,
221            gaps,
222            warn_loose_gaps,
223            warn_tight_gaps,
224        })
225    }
226}
227
228impl Rule for MD076ListItemSpacing {
229    fn name(&self) -> &'static str {
230        "MD076"
231    }
232
233    fn description(&self) -> &'static str {
234        "List item spacing should be consistent"
235    }
236
237    fn check(&self, ctx: &LintContext) -> LintResult {
238        if ctx.content.is_empty() {
239            return Ok(Vec::new());
240        }
241
242        let mut warnings = Vec::new();
243
244        for block in &ctx.list_blocks {
245            let Some(analysis) = Self::analyze_block(ctx, block, &self.config.style) else {
246                continue;
247            };
248
249            for (i, &gap) in analysis.gaps.iter().enumerate() {
250                match gap {
251                    GapKind::Structural => {
252                        // Structural gaps are never warned about — they are required
253                        // by other rules (MD031, MD058).
254                    }
255                    GapKind::Loose if analysis.warn_loose_gaps => {
256                        // Warn on the first inter-item blank line in this gap.
257                        let blanks = Self::inter_item_blanks(ctx, analysis.items[i], analysis.items[i + 1]);
258                        if let Some(&blank_line) = blanks.first() {
259                            let line_content = ctx
260                                .line_info(blank_line)
261                                .map(|li| li.content(ctx.content))
262                                .unwrap_or("");
263                            warnings.push(LintWarning {
264                                rule_name: Some(self.name().to_string()),
265                                line: blank_line,
266                                column: 1,
267                                end_line: blank_line,
268                                end_column: line_content.len() + 1,
269                                message: "Unexpected blank line between list items".to_string(),
270                                severity: Severity::Warning,
271                                fix: None,
272                            });
273                        }
274                    }
275                    GapKind::Tight if analysis.warn_tight_gaps => {
276                        // Warn on the next item line (a blank line should precede it).
277                        let next_item = analysis.items[i + 1];
278                        let line_content = ctx.line_info(next_item).map(|li| li.content(ctx.content)).unwrap_or("");
279                        warnings.push(LintWarning {
280                            rule_name: Some(self.name().to_string()),
281                            line: next_item,
282                            column: 1,
283                            end_line: next_item,
284                            end_column: line_content.len() + 1,
285                            message: "Missing blank line between list items".to_string(),
286                            severity: Severity::Warning,
287                            fix: None,
288                        });
289                    }
290                    _ => {}
291                }
292            }
293        }
294
295        Ok(warnings)
296    }
297
298    fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
299        if ctx.content.is_empty() {
300            return Ok(ctx.content.to_string());
301        }
302
303        // Collect all inter-item blank lines to remove and lines to insert before.
304        let mut insert_before: std::collections::HashSet<usize> = std::collections::HashSet::new();
305        let mut remove_lines: std::collections::HashSet<usize> = std::collections::HashSet::new();
306
307        for block in &ctx.list_blocks {
308            let Some(analysis) = Self::analyze_block(ctx, block, &self.config.style) else {
309                continue;
310            };
311
312            for (i, &gap) in analysis.gaps.iter().enumerate() {
313                match gap {
314                    GapKind::Structural => {
315                        // Never modify structural blank lines.
316                    }
317                    GapKind::Loose if analysis.warn_loose_gaps => {
318                        // Remove ALL inter-item blank lines in this gap
319                        for blank_line in Self::inter_item_blanks(ctx, analysis.items[i], analysis.items[i + 1]) {
320                            remove_lines.insert(blank_line);
321                        }
322                    }
323                    GapKind::Tight if analysis.warn_tight_gaps => {
324                        insert_before.insert(analysis.items[i + 1]);
325                    }
326                    _ => {}
327                }
328            }
329        }
330
331        if insert_before.is_empty() && remove_lines.is_empty() {
332            return Ok(ctx.content.to_string());
333        }
334
335        let lines = ctx.raw_lines();
336        let mut result: Vec<String> = Vec::with_capacity(lines.len());
337
338        for (i, line) in lines.iter().enumerate() {
339            let line_num = i + 1;
340
341            if remove_lines.contains(&line_num) {
342                continue;
343            }
344
345            if insert_before.contains(&line_num) {
346                let bq_prefix = ctx.blockquote_prefix_for_blank_line(i);
347                result.push(bq_prefix);
348            }
349
350            result.push((*line).to_string());
351        }
352
353        let mut output = result.join("\n");
354        if ctx.content.ends_with('\n') {
355            output.push('\n');
356        }
357        Ok(output)
358    }
359
360    fn as_any(&self) -> &dyn std::any::Any {
361        self
362    }
363
364    fn default_config_section(&self) -> Option<(String, toml::Value)> {
365        let mut map = toml::map::Map::new();
366        let style_str = match self.config.style {
367            ListItemSpacingStyle::Consistent => "consistent",
368            ListItemSpacingStyle::Loose => "loose",
369            ListItemSpacingStyle::Tight => "tight",
370        };
371        map.insert("style".to_string(), toml::Value::String(style_str.to_string()));
372        Some((self.name().to_string(), toml::Value::Table(map)))
373    }
374
375    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
376    where
377        Self: Sized,
378    {
379        let style = crate::config::get_rule_config_value::<String>(config, "MD076", "style")
380            .unwrap_or_else(|| "consistent".to_string());
381        let style = match style.as_str() {
382            "loose" => ListItemSpacingStyle::Loose,
383            "tight" => ListItemSpacingStyle::Tight,
384            _ => ListItemSpacingStyle::Consistent,
385        };
386        Box::new(Self::new(style))
387    }
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393
394    fn check(content: &str, style: ListItemSpacingStyle) -> Vec<LintWarning> {
395        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
396        let rule = MD076ListItemSpacing::new(style);
397        rule.check(&ctx).unwrap()
398    }
399
400    fn fix(content: &str, style: ListItemSpacingStyle) -> String {
401        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
402        let rule = MD076ListItemSpacing::new(style);
403        rule.fix(&ctx).unwrap()
404    }
405
406    // ── Basic style detection ──────────────────────────────────────────
407
408    #[test]
409    fn tight_list_tight_style_no_warnings() {
410        let content = "- Item 1\n- Item 2\n- Item 3\n";
411        assert!(check(content, ListItemSpacingStyle::Tight).is_empty());
412    }
413
414    #[test]
415    fn loose_list_loose_style_no_warnings() {
416        let content = "- Item 1\n\n- Item 2\n\n- Item 3\n";
417        assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
418    }
419
420    #[test]
421    fn tight_list_loose_style_warns() {
422        let content = "- Item 1\n- Item 2\n- Item 3\n";
423        let warnings = check(content, ListItemSpacingStyle::Loose);
424        assert_eq!(warnings.len(), 2);
425        assert!(warnings.iter().all(|w| w.message.contains("Missing")));
426    }
427
428    #[test]
429    fn loose_list_tight_style_warns() {
430        let content = "- Item 1\n\n- Item 2\n\n- Item 3\n";
431        let warnings = check(content, ListItemSpacingStyle::Tight);
432        assert_eq!(warnings.len(), 2);
433        assert!(warnings.iter().all(|w| w.message.contains("Unexpected")));
434    }
435
436    // ── Consistent mode ────────────────────────────────────────────────
437
438    #[test]
439    fn consistent_all_tight_no_warnings() {
440        let content = "- Item 1\n- Item 2\n- Item 3\n";
441        assert!(check(content, ListItemSpacingStyle::Consistent).is_empty());
442    }
443
444    #[test]
445    fn consistent_all_loose_no_warnings() {
446        let content = "- Item 1\n\n- Item 2\n\n- Item 3\n";
447        assert!(check(content, ListItemSpacingStyle::Consistent).is_empty());
448    }
449
450    #[test]
451    fn consistent_mixed_majority_loose_warns_tight() {
452        // 2 loose gaps, 1 tight gap → tight is minority → warn on tight
453        let content = "- Item 1\n\n- Item 2\n- Item 3\n\n- Item 4\n";
454        let warnings = check(content, ListItemSpacingStyle::Consistent);
455        assert_eq!(warnings.len(), 1);
456        assert!(warnings[0].message.contains("Missing"));
457    }
458
459    #[test]
460    fn consistent_mixed_majority_tight_warns_loose() {
461        // 1 loose gap, 2 tight gaps → loose is minority → warn on loose blank line
462        let content = "- Item 1\n\n- Item 2\n- Item 3\n- Item 4\n";
463        let warnings = check(content, ListItemSpacingStyle::Consistent);
464        assert_eq!(warnings.len(), 1);
465        assert!(warnings[0].message.contains("Unexpected"));
466    }
467
468    #[test]
469    fn consistent_tie_prefers_loose() {
470        let content = "- Item 1\n\n- Item 2\n- Item 3\n";
471        let warnings = check(content, ListItemSpacingStyle::Consistent);
472        assert_eq!(warnings.len(), 1);
473        assert!(warnings[0].message.contains("Missing"));
474    }
475
476    // ── Edge cases ─────────────────────────────────────────────────────
477
478    #[test]
479    fn single_item_list_no_warnings() {
480        let content = "- Only item\n";
481        assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
482        assert!(check(content, ListItemSpacingStyle::Tight).is_empty());
483        assert!(check(content, ListItemSpacingStyle::Consistent).is_empty());
484    }
485
486    #[test]
487    fn empty_content_no_warnings() {
488        assert!(check("", ListItemSpacingStyle::Consistent).is_empty());
489    }
490
491    #[test]
492    fn ordered_list_tight_gaps_loose_style_warns() {
493        let content = "1. First\n2. Second\n3. Third\n";
494        let warnings = check(content, ListItemSpacingStyle::Loose);
495        assert_eq!(warnings.len(), 2);
496    }
497
498    #[test]
499    fn task_list_works() {
500        let content = "- [x] Task 1\n- [ ] Task 2\n- [x] Task 3\n";
501        let warnings = check(content, ListItemSpacingStyle::Loose);
502        assert_eq!(warnings.len(), 2);
503        let fixed = fix(content, ListItemSpacingStyle::Loose);
504        assert_eq!(fixed, "- [x] Task 1\n\n- [ ] Task 2\n\n- [x] Task 3\n");
505    }
506
507    #[test]
508    fn no_trailing_newline() {
509        let content = "- Item 1\n- Item 2";
510        let warnings = check(content, ListItemSpacingStyle::Loose);
511        assert_eq!(warnings.len(), 1);
512        let fixed = fix(content, ListItemSpacingStyle::Loose);
513        assert_eq!(fixed, "- Item 1\n\n- Item 2");
514    }
515
516    #[test]
517    fn two_separate_lists() {
518        let content = "- A\n- B\n\nText\n\n1. One\n2. Two\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, "- A\n\n- B\n\nText\n\n1. One\n\n2. Two\n");
523    }
524
525    #[test]
526    fn no_list_content() {
527        let content = "Just a paragraph.\n\nAnother paragraph.\n";
528        assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
529        assert!(check(content, ListItemSpacingStyle::Tight).is_empty());
530    }
531
532    // ── Multi-line and continuation items ──────────────────────────────
533
534    #[test]
535    fn continuation_lines_tight_detected() {
536        let content = "- Item 1\n  continuation\n- Item 2\n";
537        let warnings = check(content, ListItemSpacingStyle::Loose);
538        assert_eq!(warnings.len(), 1);
539        assert!(warnings[0].message.contains("Missing"));
540    }
541
542    #[test]
543    fn continuation_lines_loose_detected() {
544        let content = "- Item 1\n  continuation\n\n- Item 2\n";
545        assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
546        let warnings = check(content, ListItemSpacingStyle::Tight);
547        assert_eq!(warnings.len(), 1);
548        assert!(warnings[0].message.contains("Unexpected"));
549    }
550
551    #[test]
552    fn multi_paragraph_item_not_treated_as_inter_item_gap() {
553        // Blank line between paragraphs within Item 1 must NOT trigger a warning.
554        // Only the blank line immediately before Item 2 is an inter-item separator.
555        let content = "- Item 1\n\n  Second paragraph\n\n- Item 2\n";
556        // Both gaps are loose (blank before Item 2), so tight should warn once
557        let warnings = check(content, ListItemSpacingStyle::Tight);
558        assert_eq!(
559            warnings.len(),
560            1,
561            "Should warn only on the inter-item blank, not the intra-item blank"
562        );
563        // The fix should remove only the inter-item blank (line 4), preserving the
564        // multi-paragraph structure
565        let fixed = fix(content, ListItemSpacingStyle::Tight);
566        assert_eq!(fixed, "- Item 1\n\n  Second paragraph\n- Item 2\n");
567    }
568
569    #[test]
570    fn multi_paragraph_item_loose_style_no_warnings() {
571        // A loose list with multi-paragraph items is already loose — no warnings
572        let content = "- Item 1\n\n  Second paragraph\n\n- Item 2\n";
573        assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
574    }
575
576    // ── Blockquote lists ───────────────────────────────────────────────
577
578    #[test]
579    fn blockquote_tight_list_loose_style_warns() {
580        let content = "> - Item 1\n> - Item 2\n> - Item 3\n";
581        let warnings = check(content, ListItemSpacingStyle::Loose);
582        assert_eq!(warnings.len(), 2);
583    }
584
585    #[test]
586    fn blockquote_loose_list_detected() {
587        // A line with only `>` is effectively blank in blockquote context
588        let content = "> - Item 1\n>\n> - Item 2\n";
589        let warnings = check(content, ListItemSpacingStyle::Tight);
590        assert_eq!(warnings.len(), 1, "Blockquote-only line should be detected as blank");
591        assert!(warnings[0].message.contains("Unexpected"));
592    }
593
594    #[test]
595    fn blockquote_loose_list_no_warnings_when_loose() {
596        let content = "> - Item 1\n>\n> - Item 2\n";
597        assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
598    }
599
600    // ── Multiple blank lines ───────────────────────────────────────────
601
602    #[test]
603    fn multiple_blanks_all_removed() {
604        let content = "- Item 1\n\n\n- Item 2\n";
605        let fixed = fix(content, ListItemSpacingStyle::Tight);
606        assert_eq!(fixed, "- Item 1\n- Item 2\n");
607    }
608
609    #[test]
610    fn multiple_blanks_fix_is_idempotent() {
611        let content = "- Item 1\n\n\n\n- Item 2\n";
612        let fixed_once = fix(content, ListItemSpacingStyle::Tight);
613        let fixed_twice = fix(&fixed_once, ListItemSpacingStyle::Tight);
614        assert_eq!(fixed_once, fixed_twice);
615        assert_eq!(fixed_once, "- Item 1\n- Item 2\n");
616    }
617
618    // ── Fix correctness ────────────────────────────────────────────────
619
620    #[test]
621    fn fix_adds_blank_lines() {
622        let content = "- Item 1\n- Item 2\n- Item 3\n";
623        let fixed = fix(content, ListItemSpacingStyle::Loose);
624        assert_eq!(fixed, "- Item 1\n\n- Item 2\n\n- Item 3\n");
625    }
626
627    #[test]
628    fn fix_removes_blank_lines() {
629        let content = "- Item 1\n\n- Item 2\n\n- Item 3\n";
630        let fixed = fix(content, ListItemSpacingStyle::Tight);
631        assert_eq!(fixed, "- Item 1\n- Item 2\n- Item 3\n");
632    }
633
634    #[test]
635    fn fix_consistent_adds_blank() {
636        // 2 loose gaps, 1 tight gap → add blank before Item 3
637        let content = "- Item 1\n\n- Item 2\n- Item 3\n\n- Item 4\n";
638        let fixed = fix(content, ListItemSpacingStyle::Consistent);
639        assert_eq!(fixed, "- Item 1\n\n- Item 2\n\n- Item 3\n\n- Item 4\n");
640    }
641
642    #[test]
643    fn fix_idempotent_loose() {
644        let content = "- Item 1\n- Item 2\n";
645        let fixed_once = fix(content, ListItemSpacingStyle::Loose);
646        let fixed_twice = fix(&fixed_once, ListItemSpacingStyle::Loose);
647        assert_eq!(fixed_once, fixed_twice);
648    }
649
650    #[test]
651    fn fix_idempotent_tight() {
652        let content = "- Item 1\n\n- Item 2\n";
653        let fixed_once = fix(content, ListItemSpacingStyle::Tight);
654        let fixed_twice = fix(&fixed_once, ListItemSpacingStyle::Tight);
655        assert_eq!(fixed_once, fixed_twice);
656    }
657
658    // ── Nested lists ───────────────────────────────────────────────────
659
660    #[test]
661    fn nested_list_does_not_affect_parent() {
662        // Nested items should not trigger warnings for the parent list
663        let content = "- Item 1\n  - Nested A\n  - Nested B\n- Item 2\n";
664        let warnings = check(content, ListItemSpacingStyle::Tight);
665        assert!(
666            warnings.is_empty(),
667            "Nested items should not cause parent-level warnings"
668        );
669    }
670
671    // ── Structural blank lines (code blocks, tables, HTML) ──────────
672
673    #[test]
674    fn code_block_in_tight_list_no_false_positive() {
675        // Blank line after closing fence is structural (required by MD031), not a separator
676        let content = "\
677- Item 1 with code:
678
679  ```python
680  print('hello')
681  ```
682
683- Item 2 simple.
684- Item 3 simple.
685";
686        assert!(
687            check(content, ListItemSpacingStyle::Consistent).is_empty(),
688            "Structural blank after code block should not make item 1 appear loose"
689        );
690    }
691
692    #[test]
693    fn table_in_tight_list_no_false_positive() {
694        // Blank line after table is structural (required by MD058), not a separator
695        let content = "\
696- Item 1 with table:
697
698  | Col 1 | Col 2 |
699  |-------|-------|
700  | A     | B     |
701
702- Item 2 simple.
703- Item 3 simple.
704";
705        assert!(
706            check(content, ListItemSpacingStyle::Consistent).is_empty(),
707            "Structural blank after table should not make item 1 appear loose"
708        );
709    }
710
711    #[test]
712    fn html_block_in_tight_list_no_false_positive() {
713        let content = "\
714- Item 1 with HTML:
715
716  <details>
717  <summary>Click</summary>
718  Content
719  </details>
720
721- Item 2 simple.
722- Item 3 simple.
723";
724        assert!(
725            check(content, ListItemSpacingStyle::Consistent).is_empty(),
726            "Structural blank after HTML block should not make item 1 appear loose"
727        );
728    }
729
730    #[test]
731    fn mixed_code_and_table_in_tight_list() {
732        let content = "\
7331. Item with code:
734
735   ```markdown
736   This is some Markdown
737   ```
738
7391. Simple item.
7401. Item with table:
741
742   | Col 1 | Col 2 |
743   |:------|:------|
744   | Row 1 | Row 1 |
745   | Row 2 | Row 2 |
746";
747        assert!(
748            check(content, ListItemSpacingStyle::Consistent).is_empty(),
749            "Mix of code blocks and tables should not cause false positives"
750        );
751    }
752
753    #[test]
754    fn code_block_with_genuinely_loose_gaps_still_warns() {
755        // Item 1 has structural blank (code block), items 2-3 have genuine blank separator
756        // Items 2-3 are genuinely loose, item 3-4 is tight → inconsistent
757        let content = "\
758- Item 1:
759
760  ```bash
761  echo hi
762  ```
763
764- Item 2
765
766- Item 3
767- Item 4
768";
769        let warnings = check(content, ListItemSpacingStyle::Consistent);
770        assert!(
771            !warnings.is_empty(),
772            "Genuine inconsistency with code blocks should still be flagged"
773        );
774    }
775
776    #[test]
777    fn all_items_have_code_blocks_no_warnings() {
778        let content = "\
779- Item 1:
780
781  ```python
782  print(1)
783  ```
784
785- Item 2:
786
787  ```python
788  print(2)
789  ```
790
791- Item 3:
792
793  ```python
794  print(3)
795  ```
796";
797        assert!(
798            check(content, ListItemSpacingStyle::Consistent).is_empty(),
799            "All items with code blocks should be consistently tight"
800        );
801    }
802
803    #[test]
804    fn tilde_fence_code_block_in_list() {
805        let content = "\
806- Item 1:
807
808  ~~~
809  code here
810  ~~~
811
812- Item 2 simple.
813- Item 3 simple.
814";
815        assert!(
816            check(content, ListItemSpacingStyle::Consistent).is_empty(),
817            "Tilde fences should be recognized as structural content"
818        );
819    }
820
821    #[test]
822    fn nested_list_with_code_block() {
823        let content = "\
824- Item 1
825  - Nested with code:
826
827    ```
828    nested code
829    ```
830
831  - Nested simple.
832- Item 2
833";
834        assert!(
835            check(content, ListItemSpacingStyle::Consistent).is_empty(),
836            "Nested list with code block should not cause false positives"
837        );
838    }
839
840    #[test]
841    fn tight_style_with_code_block_no_warnings() {
842        let content = "\
843- Item 1:
844
845  ```
846  code
847  ```
848
849- Item 2.
850- Item 3.
851";
852        assert!(
853            check(content, ListItemSpacingStyle::Tight).is_empty(),
854            "Tight style should not warn about structural blanks around code blocks"
855        );
856    }
857
858    #[test]
859    fn loose_style_with_code_block_missing_separator() {
860        // Loose style requires blank line between every pair of items.
861        // Items 2-3 have no blank → should warn
862        let content = "\
863- Item 1:
864
865  ```
866  code
867  ```
868
869- Item 2.
870- Item 3.
871";
872        let warnings = check(content, ListItemSpacingStyle::Loose);
873        assert_eq!(
874            warnings.len(),
875            1,
876            "Loose style should still require blank between simple items"
877        );
878        assert!(warnings[0].message.contains("Missing"));
879    }
880
881    #[test]
882    fn blockquote_list_with_code_block() {
883        let content = "\
884> - Item 1:
885>
886>   ```
887>   code
888>   ```
889>
890> - Item 2.
891> - Item 3.
892";
893        assert!(
894            check(content, ListItemSpacingStyle::Consistent).is_empty(),
895            "Blockquote-prefixed list with code block should not cause false positives"
896        );
897    }
898
899    // ── Indented code block (not fenced) in list item ─────────────────
900
901    #[test]
902    fn indented_code_block_in_list_no_false_positive() {
903        // A 4-space indented code block inside a list item should be treated
904        // as structural content, not trigger a loose gap detection.
905        let content = "\
9061. Item with indented code:
907
908       some code here
909       more code
910
9111. Simple item
9121. Another item
913";
914        assert!(
915            check(content, ListItemSpacingStyle::Consistent).is_empty(),
916            "Structural blank after indented code block should not make item 1 appear loose"
917        );
918    }
919
920    // ── Code block in middle of item with text after ────────────────
921
922    #[test]
923    fn code_block_in_middle_of_item_text_after_is_genuinely_loose() {
924        // When a code block is in the middle of an item and there's regular text
925        // after it, a blank line before the next item IS a genuine separator (loose),
926        // not structural. The last non-blank line before item 2 is "Some text after
927        // the code block." which is NOT structural content.
928        let content = "\
9291. Item with code in middle:
930
931   ```
932   code
933   ```
934
935   Some text after the code block.
936
9371. Simple item
9381. Another item
939";
940        let warnings = check(content, ListItemSpacingStyle::Consistent);
941        assert!(
942            !warnings.is_empty(),
943            "Blank line after regular text (not structural content) is a genuine loose gap"
944        );
945    }
946
947    // ── Fix: tight mode preserves structural blanks ──────────────────
948
949    #[test]
950    fn tight_fix_preserves_structural_blanks_around_code_blocks() {
951        // When style is tight, the fix should NOT remove structural blank lines
952        // around code blocks inside list items. Those blanks are required by MD031.
953        let content = "\
954- Item 1:
955
956  ```
957  code
958  ```
959
960- Item 2.
961- Item 3.
962";
963        let fixed = fix(content, ListItemSpacingStyle::Tight);
964        assert_eq!(
965            fixed, content,
966            "Tight fix should not remove structural blanks around code blocks"
967        );
968    }
969
970    // ── Issue #461: 4-space indented code block in loose list ──────────
971
972    #[test]
973    fn four_space_indented_fence_in_loose_list_no_false_positive() {
974        // Reproduction case from issue #461 comment by @sisp.
975        // The fenced code block uses 4-space indentation inside an ordered list.
976        // The blank line after the closing fence is structural (required by MD031)
977        // and must not create a false "Missing blank line" warning.
978        let content = "\
9791. First item
980
9811. Second item with code block:
982
983    ```json
984    {\"key\": \"value\"}
985    ```
986
9871. Third item
988";
989        assert!(
990            check(content, ListItemSpacingStyle::Consistent).is_empty(),
991            "Structural blank after 4-space indented code block should not cause false positive"
992        );
993    }
994
995    #[test]
996    fn four_space_indented_fence_tight_style_no_warnings() {
997        let content = "\
9981. First item
9991. Second item with code block:
1000
1001    ```json
1002    {\"key\": \"value\"}
1003    ```
1004
10051. Third item
1006";
1007        assert!(
1008            check(content, ListItemSpacingStyle::Tight).is_empty(),
1009            "Tight style should not warn about structural blanks with 4-space fences"
1010        );
1011    }
1012
1013    #[test]
1014    fn four_space_indented_fence_loose_style_no_warnings() {
1015        // All non-structural gaps are loose, structural gaps are excluded.
1016        let content = "\
10171. First item
1018
10191. Second item with code block:
1020
1021    ```json
1022    {\"key\": \"value\"}
1023    ```
1024
10251. Third item
1026";
1027        assert!(
1028            check(content, ListItemSpacingStyle::Loose).is_empty(),
1029            "Loose style should not warn when structural gaps are the only non-loose gaps"
1030        );
1031    }
1032
1033    #[test]
1034    fn structural_gap_with_genuine_inconsistency_still_warns() {
1035        // Item 1 has a structural code block. Items 2-3 are genuinely loose,
1036        // but items 3-4 are tight → genuine inconsistency should still warn.
1037        let content = "\
10381. First item with code:
1039
1040    ```json
1041    {\"key\": \"value\"}
1042    ```
1043
10441. Second item
1045
10461. Third item
10471. Fourth item
1048";
1049        let warnings = check(content, ListItemSpacingStyle::Consistent);
1050        assert!(
1051            !warnings.is_empty(),
1052            "Genuine loose/tight inconsistency should still warn even with structural gaps"
1053        );
1054    }
1055
1056    #[test]
1057    fn four_space_fence_fix_is_idempotent() {
1058        // Fix should not modify a list that has only structural gaps and
1059        // genuine loose gaps — it's already consistent.
1060        let content = "\
10611. First item
1062
10631. Second item with code block:
1064
1065    ```json
1066    {\"key\": \"value\"}
1067    ```
1068
10691. Third item
1070";
1071        let fixed = fix(content, ListItemSpacingStyle::Consistent);
1072        assert_eq!(fixed, content, "Fix should be a no-op for lists with structural gaps");
1073        let fixed_twice = fix(&fixed, ListItemSpacingStyle::Consistent);
1074        assert_eq!(fixed, fixed_twice, "Fix should be idempotent");
1075    }
1076
1077    #[test]
1078    fn four_space_fence_fix_does_not_insert_duplicate_blank() {
1079        // When tight style tries to fix, it should not insert a blank line
1080        // before item 3 when one already exists (structural).
1081        let content = "\
10821. First item
10831. Second item with code block:
1084
1085    ```json
1086    {\"key\": \"value\"}
1087    ```
1088
10891. Third item
1090";
1091        let fixed = fix(content, ListItemSpacingStyle::Tight);
1092        assert_eq!(fixed, content, "Tight fix should not modify structural blanks");
1093    }
1094
1095    #[test]
1096    fn mkdocs_flavor_code_block_in_list_no_false_positive() {
1097        // MkDocs flavor with code block inside a list item.
1098        // Reported by @sisp in issue #461 comment.
1099        let content = "\
11001. First item
1101
11021. Second item with code block:
1103
1104    ```json
1105    {\"key\": \"value\"}
1106    ```
1107
11081. Third item
1109";
1110        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1111        let rule = MD076ListItemSpacing::new(ListItemSpacingStyle::Consistent);
1112        let warnings = rule.check(&ctx).unwrap();
1113        assert!(
1114            warnings.is_empty(),
1115            "MkDocs flavor with structural code block blank should not produce false positive, got: {warnings:?}"
1116        );
1117    }
1118
1119    // ── Config schema ──────────────────────────────────────────────────
1120
1121    #[test]
1122    fn default_config_section_provides_style_key() {
1123        let rule = MD076ListItemSpacing::new(ListItemSpacingStyle::Consistent);
1124        let section = rule.default_config_section();
1125        assert!(section.is_some());
1126        let (name, value) = section.unwrap();
1127        assert_eq!(name, "MD076");
1128        if let toml::Value::Table(map) = value {
1129            assert!(map.contains_key("style"));
1130        } else {
1131            panic!("Expected Table value from default_config_section");
1132        }
1133    }
1134}