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            // Skip modifications for lines where the rule is disabled via inline config
342            if ctx.is_rule_disabled(self.name(), line_num) {
343                result.push((*line).to_string());
344                continue;
345            }
346
347            if remove_lines.contains(&line_num) {
348                continue;
349            }
350
351            if insert_before.contains(&line_num) {
352                let bq_prefix = ctx.blockquote_prefix_for_blank_line(i);
353                result.push(bq_prefix);
354            }
355
356            result.push((*line).to_string());
357        }
358
359        let mut output = result.join("\n");
360        if ctx.content.ends_with('\n') {
361            output.push('\n');
362        }
363        Ok(output)
364    }
365
366    fn as_any(&self) -> &dyn std::any::Any {
367        self
368    }
369
370    fn default_config_section(&self) -> Option<(String, toml::Value)> {
371        let mut map = toml::map::Map::new();
372        let style_str = match self.config.style {
373            ListItemSpacingStyle::Consistent => "consistent",
374            ListItemSpacingStyle::Loose => "loose",
375            ListItemSpacingStyle::Tight => "tight",
376        };
377        map.insert("style".to_string(), toml::Value::String(style_str.to_string()));
378        Some((self.name().to_string(), toml::Value::Table(map)))
379    }
380
381    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
382    where
383        Self: Sized,
384    {
385        let style = crate::config::get_rule_config_value::<String>(config, "MD076", "style")
386            .unwrap_or_else(|| "consistent".to_string());
387        let style = match style.as_str() {
388            "loose" => ListItemSpacingStyle::Loose,
389            "tight" => ListItemSpacingStyle::Tight,
390            _ => ListItemSpacingStyle::Consistent,
391        };
392        Box::new(Self::new(style))
393    }
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399
400    fn check(content: &str, style: ListItemSpacingStyle) -> Vec<LintWarning> {
401        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
402        let rule = MD076ListItemSpacing::new(style);
403        rule.check(&ctx).unwrap()
404    }
405
406    fn fix(content: &str, style: ListItemSpacingStyle) -> String {
407        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
408        let rule = MD076ListItemSpacing::new(style);
409        rule.fix(&ctx).unwrap()
410    }
411
412    // ── Basic style detection ──────────────────────────────────────────
413
414    #[test]
415    fn tight_list_tight_style_no_warnings() {
416        let content = "- Item 1\n- Item 2\n- Item 3\n";
417        assert!(check(content, ListItemSpacingStyle::Tight).is_empty());
418    }
419
420    #[test]
421    fn loose_list_loose_style_no_warnings() {
422        let content = "- Item 1\n\n- Item 2\n\n- Item 3\n";
423        assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
424    }
425
426    #[test]
427    fn tight_list_loose_style_warns() {
428        let content = "- Item 1\n- Item 2\n- Item 3\n";
429        let warnings = check(content, ListItemSpacingStyle::Loose);
430        assert_eq!(warnings.len(), 2);
431        assert!(warnings.iter().all(|w| w.message.contains("Missing")));
432    }
433
434    #[test]
435    fn loose_list_tight_style_warns() {
436        let content = "- Item 1\n\n- Item 2\n\n- Item 3\n";
437        let warnings = check(content, ListItemSpacingStyle::Tight);
438        assert_eq!(warnings.len(), 2);
439        assert!(warnings.iter().all(|w| w.message.contains("Unexpected")));
440    }
441
442    // ── Consistent mode ────────────────────────────────────────────────
443
444    #[test]
445    fn consistent_all_tight_no_warnings() {
446        let content = "- Item 1\n- Item 2\n- Item 3\n";
447        assert!(check(content, ListItemSpacingStyle::Consistent).is_empty());
448    }
449
450    #[test]
451    fn consistent_all_loose_no_warnings() {
452        let content = "- Item 1\n\n- Item 2\n\n- Item 3\n";
453        assert!(check(content, ListItemSpacingStyle::Consistent).is_empty());
454    }
455
456    #[test]
457    fn consistent_mixed_majority_loose_warns_tight() {
458        // 2 loose gaps, 1 tight gap → tight is minority → warn on tight
459        let content = "- Item 1\n\n- Item 2\n- Item 3\n\n- Item 4\n";
460        let warnings = check(content, ListItemSpacingStyle::Consistent);
461        assert_eq!(warnings.len(), 1);
462        assert!(warnings[0].message.contains("Missing"));
463    }
464
465    #[test]
466    fn consistent_mixed_majority_tight_warns_loose() {
467        // 1 loose gap, 2 tight gaps → loose is minority → warn on loose blank line
468        let content = "- Item 1\n\n- Item 2\n- Item 3\n- Item 4\n";
469        let warnings = check(content, ListItemSpacingStyle::Consistent);
470        assert_eq!(warnings.len(), 1);
471        assert!(warnings[0].message.contains("Unexpected"));
472    }
473
474    #[test]
475    fn consistent_tie_prefers_loose() {
476        let content = "- Item 1\n\n- Item 2\n- Item 3\n";
477        let warnings = check(content, ListItemSpacingStyle::Consistent);
478        assert_eq!(warnings.len(), 1);
479        assert!(warnings[0].message.contains("Missing"));
480    }
481
482    // ── Edge cases ─────────────────────────────────────────────────────
483
484    #[test]
485    fn single_item_list_no_warnings() {
486        let content = "- Only item\n";
487        assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
488        assert!(check(content, ListItemSpacingStyle::Tight).is_empty());
489        assert!(check(content, ListItemSpacingStyle::Consistent).is_empty());
490    }
491
492    #[test]
493    fn empty_content_no_warnings() {
494        assert!(check("", ListItemSpacingStyle::Consistent).is_empty());
495    }
496
497    #[test]
498    fn ordered_list_tight_gaps_loose_style_warns() {
499        let content = "1. First\n2. Second\n3. Third\n";
500        let warnings = check(content, ListItemSpacingStyle::Loose);
501        assert_eq!(warnings.len(), 2);
502    }
503
504    #[test]
505    fn task_list_works() {
506        let content = "- [x] Task 1\n- [ ] Task 2\n- [x] Task 3\n";
507        let warnings = check(content, ListItemSpacingStyle::Loose);
508        assert_eq!(warnings.len(), 2);
509        let fixed = fix(content, ListItemSpacingStyle::Loose);
510        assert_eq!(fixed, "- [x] Task 1\n\n- [ ] Task 2\n\n- [x] Task 3\n");
511    }
512
513    #[test]
514    fn no_trailing_newline() {
515        let content = "- Item 1\n- Item 2";
516        let warnings = check(content, ListItemSpacingStyle::Loose);
517        assert_eq!(warnings.len(), 1);
518        let fixed = fix(content, ListItemSpacingStyle::Loose);
519        assert_eq!(fixed, "- Item 1\n\n- Item 2");
520    }
521
522    #[test]
523    fn two_separate_lists() {
524        let content = "- A\n- B\n\nText\n\n1. One\n2. Two\n";
525        let warnings = check(content, ListItemSpacingStyle::Loose);
526        assert_eq!(warnings.len(), 2);
527        let fixed = fix(content, ListItemSpacingStyle::Loose);
528        assert_eq!(fixed, "- A\n\n- B\n\nText\n\n1. One\n\n2. Two\n");
529    }
530
531    #[test]
532    fn no_list_content() {
533        let content = "Just a paragraph.\n\nAnother paragraph.\n";
534        assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
535        assert!(check(content, ListItemSpacingStyle::Tight).is_empty());
536    }
537
538    // ── Multi-line and continuation items ──────────────────────────────
539
540    #[test]
541    fn continuation_lines_tight_detected() {
542        let content = "- Item 1\n  continuation\n- Item 2\n";
543        let warnings = check(content, ListItemSpacingStyle::Loose);
544        assert_eq!(warnings.len(), 1);
545        assert!(warnings[0].message.contains("Missing"));
546    }
547
548    #[test]
549    fn continuation_lines_loose_detected() {
550        let content = "- Item 1\n  continuation\n\n- Item 2\n";
551        assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
552        let warnings = check(content, ListItemSpacingStyle::Tight);
553        assert_eq!(warnings.len(), 1);
554        assert!(warnings[0].message.contains("Unexpected"));
555    }
556
557    #[test]
558    fn multi_paragraph_item_not_treated_as_inter_item_gap() {
559        // Blank line between paragraphs within Item 1 must NOT trigger a warning.
560        // Only the blank line immediately before Item 2 is an inter-item separator.
561        let content = "- Item 1\n\n  Second paragraph\n\n- Item 2\n";
562        // Both gaps are loose (blank before Item 2), so tight should warn once
563        let warnings = check(content, ListItemSpacingStyle::Tight);
564        assert_eq!(
565            warnings.len(),
566            1,
567            "Should warn only on the inter-item blank, not the intra-item blank"
568        );
569        // The fix should remove only the inter-item blank (line 4), preserving the
570        // multi-paragraph structure
571        let fixed = fix(content, ListItemSpacingStyle::Tight);
572        assert_eq!(fixed, "- Item 1\n\n  Second paragraph\n- Item 2\n");
573    }
574
575    #[test]
576    fn multi_paragraph_item_loose_style_no_warnings() {
577        // A loose list with multi-paragraph items is already loose — no warnings
578        let content = "- Item 1\n\n  Second paragraph\n\n- Item 2\n";
579        assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
580    }
581
582    // ── Blockquote lists ───────────────────────────────────────────────
583
584    #[test]
585    fn blockquote_tight_list_loose_style_warns() {
586        let content = "> - Item 1\n> - Item 2\n> - Item 3\n";
587        let warnings = check(content, ListItemSpacingStyle::Loose);
588        assert_eq!(warnings.len(), 2);
589    }
590
591    #[test]
592    fn blockquote_loose_list_detected() {
593        // A line with only `>` is effectively blank in blockquote context
594        let content = "> - Item 1\n>\n> - Item 2\n";
595        let warnings = check(content, ListItemSpacingStyle::Tight);
596        assert_eq!(warnings.len(), 1, "Blockquote-only line should be detected as blank");
597        assert!(warnings[0].message.contains("Unexpected"));
598    }
599
600    #[test]
601    fn blockquote_loose_list_no_warnings_when_loose() {
602        let content = "> - Item 1\n>\n> - Item 2\n";
603        assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
604    }
605
606    // ── Multiple blank lines ───────────────────────────────────────────
607
608    #[test]
609    fn multiple_blanks_all_removed() {
610        let content = "- Item 1\n\n\n- Item 2\n";
611        let fixed = fix(content, ListItemSpacingStyle::Tight);
612        assert_eq!(fixed, "- Item 1\n- Item 2\n");
613    }
614
615    #[test]
616    fn multiple_blanks_fix_is_idempotent() {
617        let content = "- Item 1\n\n\n\n- Item 2\n";
618        let fixed_once = fix(content, ListItemSpacingStyle::Tight);
619        let fixed_twice = fix(&fixed_once, ListItemSpacingStyle::Tight);
620        assert_eq!(fixed_once, fixed_twice);
621        assert_eq!(fixed_once, "- Item 1\n- Item 2\n");
622    }
623
624    // ── Fix correctness ────────────────────────────────────────────────
625
626    #[test]
627    fn fix_adds_blank_lines() {
628        let content = "- Item 1\n- Item 2\n- Item 3\n";
629        let fixed = fix(content, ListItemSpacingStyle::Loose);
630        assert_eq!(fixed, "- Item 1\n\n- Item 2\n\n- Item 3\n");
631    }
632
633    #[test]
634    fn fix_removes_blank_lines() {
635        let content = "- Item 1\n\n- Item 2\n\n- Item 3\n";
636        let fixed = fix(content, ListItemSpacingStyle::Tight);
637        assert_eq!(fixed, "- Item 1\n- Item 2\n- Item 3\n");
638    }
639
640    #[test]
641    fn fix_consistent_adds_blank() {
642        // 2 loose gaps, 1 tight gap → add blank before Item 3
643        let content = "- Item 1\n\n- Item 2\n- Item 3\n\n- Item 4\n";
644        let fixed = fix(content, ListItemSpacingStyle::Consistent);
645        assert_eq!(fixed, "- Item 1\n\n- Item 2\n\n- Item 3\n\n- Item 4\n");
646    }
647
648    #[test]
649    fn fix_idempotent_loose() {
650        let content = "- Item 1\n- Item 2\n";
651        let fixed_once = fix(content, ListItemSpacingStyle::Loose);
652        let fixed_twice = fix(&fixed_once, ListItemSpacingStyle::Loose);
653        assert_eq!(fixed_once, fixed_twice);
654    }
655
656    #[test]
657    fn fix_idempotent_tight() {
658        let content = "- Item 1\n\n- Item 2\n";
659        let fixed_once = fix(content, ListItemSpacingStyle::Tight);
660        let fixed_twice = fix(&fixed_once, ListItemSpacingStyle::Tight);
661        assert_eq!(fixed_once, fixed_twice);
662    }
663
664    // ── Nested lists ───────────────────────────────────────────────────
665
666    #[test]
667    fn nested_list_does_not_affect_parent() {
668        // Nested items should not trigger warnings for the parent list
669        let content = "- Item 1\n  - Nested A\n  - Nested B\n- Item 2\n";
670        let warnings = check(content, ListItemSpacingStyle::Tight);
671        assert!(
672            warnings.is_empty(),
673            "Nested items should not cause parent-level warnings"
674        );
675    }
676
677    // ── Structural blank lines (code blocks, tables, HTML) ──────────
678
679    #[test]
680    fn code_block_in_tight_list_no_false_positive() {
681        // Blank line after closing fence is structural (required by MD031), not a separator
682        let content = "\
683- Item 1 with code:
684
685  ```python
686  print('hello')
687  ```
688
689- Item 2 simple.
690- Item 3 simple.
691";
692        assert!(
693            check(content, ListItemSpacingStyle::Consistent).is_empty(),
694            "Structural blank after code block should not make item 1 appear loose"
695        );
696    }
697
698    #[test]
699    fn table_in_tight_list_no_false_positive() {
700        // Blank line after table is structural (required by MD058), not a separator
701        let content = "\
702- Item 1 with table:
703
704  | Col 1 | Col 2 |
705  |-------|-------|
706  | A     | B     |
707
708- Item 2 simple.
709- Item 3 simple.
710";
711        assert!(
712            check(content, ListItemSpacingStyle::Consistent).is_empty(),
713            "Structural blank after table should not make item 1 appear loose"
714        );
715    }
716
717    #[test]
718    fn html_block_in_tight_list_no_false_positive() {
719        let content = "\
720- Item 1 with HTML:
721
722  <details>
723  <summary>Click</summary>
724  Content
725  </details>
726
727- Item 2 simple.
728- Item 3 simple.
729";
730        assert!(
731            check(content, ListItemSpacingStyle::Consistent).is_empty(),
732            "Structural blank after HTML block should not make item 1 appear loose"
733        );
734    }
735
736    #[test]
737    fn mixed_code_and_table_in_tight_list() {
738        let content = "\
7391. Item with code:
740
741   ```markdown
742   This is some Markdown
743   ```
744
7451. Simple item.
7461. Item with table:
747
748   | Col 1 | Col 2 |
749   |:------|:------|
750   | Row 1 | Row 1 |
751   | Row 2 | Row 2 |
752";
753        assert!(
754            check(content, ListItemSpacingStyle::Consistent).is_empty(),
755            "Mix of code blocks and tables should not cause false positives"
756        );
757    }
758
759    #[test]
760    fn code_block_with_genuinely_loose_gaps_still_warns() {
761        // Item 1 has structural blank (code block), items 2-3 have genuine blank separator
762        // Items 2-3 are genuinely loose, item 3-4 is tight → inconsistent
763        let content = "\
764- Item 1:
765
766  ```bash
767  echo hi
768  ```
769
770- Item 2
771
772- Item 3
773- Item 4
774";
775        let warnings = check(content, ListItemSpacingStyle::Consistent);
776        assert!(
777            !warnings.is_empty(),
778            "Genuine inconsistency with code blocks should still be flagged"
779        );
780    }
781
782    #[test]
783    fn all_items_have_code_blocks_no_warnings() {
784        let content = "\
785- Item 1:
786
787  ```python
788  print(1)
789  ```
790
791- Item 2:
792
793  ```python
794  print(2)
795  ```
796
797- Item 3:
798
799  ```python
800  print(3)
801  ```
802";
803        assert!(
804            check(content, ListItemSpacingStyle::Consistent).is_empty(),
805            "All items with code blocks should be consistently tight"
806        );
807    }
808
809    #[test]
810    fn tilde_fence_code_block_in_list() {
811        let content = "\
812- Item 1:
813
814  ~~~
815  code here
816  ~~~
817
818- Item 2 simple.
819- Item 3 simple.
820";
821        assert!(
822            check(content, ListItemSpacingStyle::Consistent).is_empty(),
823            "Tilde fences should be recognized as structural content"
824        );
825    }
826
827    #[test]
828    fn nested_list_with_code_block() {
829        let content = "\
830- Item 1
831  - Nested with code:
832
833    ```
834    nested code
835    ```
836
837  - Nested simple.
838- Item 2
839";
840        assert!(
841            check(content, ListItemSpacingStyle::Consistent).is_empty(),
842            "Nested list with code block should not cause false positives"
843        );
844    }
845
846    #[test]
847    fn tight_style_with_code_block_no_warnings() {
848        let content = "\
849- Item 1:
850
851  ```
852  code
853  ```
854
855- Item 2.
856- Item 3.
857";
858        assert!(
859            check(content, ListItemSpacingStyle::Tight).is_empty(),
860            "Tight style should not warn about structural blanks around code blocks"
861        );
862    }
863
864    #[test]
865    fn loose_style_with_code_block_missing_separator() {
866        // Loose style requires blank line between every pair of items.
867        // Items 2-3 have no blank → should warn
868        let content = "\
869- Item 1:
870
871  ```
872  code
873  ```
874
875- Item 2.
876- Item 3.
877";
878        let warnings = check(content, ListItemSpacingStyle::Loose);
879        assert_eq!(
880            warnings.len(),
881            1,
882            "Loose style should still require blank between simple items"
883        );
884        assert!(warnings[0].message.contains("Missing"));
885    }
886
887    #[test]
888    fn blockquote_list_with_code_block() {
889        let content = "\
890> - Item 1:
891>
892>   ```
893>   code
894>   ```
895>
896> - Item 2.
897> - Item 3.
898";
899        assert!(
900            check(content, ListItemSpacingStyle::Consistent).is_empty(),
901            "Blockquote-prefixed list with code block should not cause false positives"
902        );
903    }
904
905    // ── Indented code block (not fenced) in list item ─────────────────
906
907    #[test]
908    fn indented_code_block_in_list_no_false_positive() {
909        // A 4-space indented code block inside a list item should be treated
910        // as structural content, not trigger a loose gap detection.
911        let content = "\
9121. Item with indented code:
913
914       some code here
915       more code
916
9171. Simple item
9181. Another item
919";
920        assert!(
921            check(content, ListItemSpacingStyle::Consistent).is_empty(),
922            "Structural blank after indented code block should not make item 1 appear loose"
923        );
924    }
925
926    // ── Code block in middle of item with text after ────────────────
927
928    #[test]
929    fn code_block_in_middle_of_item_text_after_is_genuinely_loose() {
930        // When a code block is in the middle of an item and there's regular text
931        // after it, a blank line before the next item IS a genuine separator (loose),
932        // not structural. The last non-blank line before item 2 is "Some text after
933        // the code block." which is NOT structural content.
934        let content = "\
9351. Item with code in middle:
936
937   ```
938   code
939   ```
940
941   Some text after the code block.
942
9431. Simple item
9441. Another item
945";
946        let warnings = check(content, ListItemSpacingStyle::Consistent);
947        assert!(
948            !warnings.is_empty(),
949            "Blank line after regular text (not structural content) is a genuine loose gap"
950        );
951    }
952
953    // ── Fix: tight mode preserves structural blanks ──────────────────
954
955    #[test]
956    fn tight_fix_preserves_structural_blanks_around_code_blocks() {
957        // When style is tight, the fix should NOT remove structural blank lines
958        // around code blocks inside list items. Those blanks are required by MD031.
959        let content = "\
960- Item 1:
961
962  ```
963  code
964  ```
965
966- Item 2.
967- Item 3.
968";
969        let fixed = fix(content, ListItemSpacingStyle::Tight);
970        assert_eq!(
971            fixed, content,
972            "Tight fix should not remove structural blanks around code blocks"
973        );
974    }
975
976    // ── Issue #461: 4-space indented code block in loose list ──────────
977
978    #[test]
979    fn four_space_indented_fence_in_loose_list_no_false_positive() {
980        // Reproduction case from issue #461 comment by @sisp.
981        // The fenced code block uses 4-space indentation inside an ordered list.
982        // The blank line after the closing fence is structural (required by MD031)
983        // and must not create a false "Missing blank line" warning.
984        let content = "\
9851. First item
986
9871. Second item with code block:
988
989    ```json
990    {\"key\": \"value\"}
991    ```
992
9931. Third item
994";
995        assert!(
996            check(content, ListItemSpacingStyle::Consistent).is_empty(),
997            "Structural blank after 4-space indented code block should not cause false positive"
998        );
999    }
1000
1001    #[test]
1002    fn four_space_indented_fence_tight_style_no_warnings() {
1003        let content = "\
10041. First item
10051. Second item with code block:
1006
1007    ```json
1008    {\"key\": \"value\"}
1009    ```
1010
10111. Third item
1012";
1013        assert!(
1014            check(content, ListItemSpacingStyle::Tight).is_empty(),
1015            "Tight style should not warn about structural blanks with 4-space fences"
1016        );
1017    }
1018
1019    #[test]
1020    fn four_space_indented_fence_loose_style_no_warnings() {
1021        // All non-structural gaps are loose, structural gaps are excluded.
1022        let content = "\
10231. First item
1024
10251. Second item with code block:
1026
1027    ```json
1028    {\"key\": \"value\"}
1029    ```
1030
10311. Third item
1032";
1033        assert!(
1034            check(content, ListItemSpacingStyle::Loose).is_empty(),
1035            "Loose style should not warn when structural gaps are the only non-loose gaps"
1036        );
1037    }
1038
1039    #[test]
1040    fn structural_gap_with_genuine_inconsistency_still_warns() {
1041        // Item 1 has a structural code block. Items 2-3 are genuinely loose,
1042        // but items 3-4 are tight → genuine inconsistency should still warn.
1043        let content = "\
10441. First item with code:
1045
1046    ```json
1047    {\"key\": \"value\"}
1048    ```
1049
10501. Second item
1051
10521. Third item
10531. Fourth item
1054";
1055        let warnings = check(content, ListItemSpacingStyle::Consistent);
1056        assert!(
1057            !warnings.is_empty(),
1058            "Genuine loose/tight inconsistency should still warn even with structural gaps"
1059        );
1060    }
1061
1062    #[test]
1063    fn four_space_fence_fix_is_idempotent() {
1064        // Fix should not modify a list that has only structural gaps and
1065        // genuine loose gaps — it's already consistent.
1066        let content = "\
10671. First item
1068
10691. Second item with code block:
1070
1071    ```json
1072    {\"key\": \"value\"}
1073    ```
1074
10751. Third item
1076";
1077        let fixed = fix(content, ListItemSpacingStyle::Consistent);
1078        assert_eq!(fixed, content, "Fix should be a no-op for lists with structural gaps");
1079        let fixed_twice = fix(&fixed, ListItemSpacingStyle::Consistent);
1080        assert_eq!(fixed, fixed_twice, "Fix should be idempotent");
1081    }
1082
1083    #[test]
1084    fn four_space_fence_fix_does_not_insert_duplicate_blank() {
1085        // When tight style tries to fix, it should not insert a blank line
1086        // before item 3 when one already exists (structural).
1087        let content = "\
10881. First item
10891. Second item with code block:
1090
1091    ```json
1092    {\"key\": \"value\"}
1093    ```
1094
10951. Third item
1096";
1097        let fixed = fix(content, ListItemSpacingStyle::Tight);
1098        assert_eq!(fixed, content, "Tight fix should not modify structural blanks");
1099    }
1100
1101    #[test]
1102    fn mkdocs_flavor_code_block_in_list_no_false_positive() {
1103        // MkDocs flavor with code block inside a list item.
1104        // Reported by @sisp in issue #461 comment.
1105        let content = "\
11061. First item
1107
11081. Second item with code block:
1109
1110    ```json
1111    {\"key\": \"value\"}
1112    ```
1113
11141. Third item
1115";
1116        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1117        let rule = MD076ListItemSpacing::new(ListItemSpacingStyle::Consistent);
1118        let warnings = rule.check(&ctx).unwrap();
1119        assert!(
1120            warnings.is_empty(),
1121            "MkDocs flavor with structural code block blank should not produce false positive, got: {warnings:?}"
1122        );
1123    }
1124
1125    // ── Issue #500: code block inside list item splits list blocks ─────
1126
1127    #[test]
1128    fn code_block_in_second_item_detects_inconsistency() {
1129        // A code block inside item 2 must not split the list into separate blocks.
1130        // Items 1-2 are tight, items 3-4 are loose → inconsistent.
1131        let content = "\
1132# Test
1133
1134- Lorem ipsum dolor sit amet.
1135- Lorem ipsum dolor sit amet.
1136
1137    ```yaml
1138    hello: world
1139    ```
1140
1141- Lorem ipsum dolor sit amet.
1142
1143- Lorem ipsum dolor sit amet.
1144";
1145        let warnings = check(content, ListItemSpacingStyle::Consistent);
1146        assert!(
1147            !warnings.is_empty(),
1148            "Should detect inconsistent spacing when code block is inside a list item"
1149        );
1150    }
1151
1152    #[test]
1153    fn code_block_in_item_all_tight_no_warnings() {
1154        // All non-structural gaps are tight → consistent, no warnings.
1155        let content = "\
1156- Item 1
1157- Item 2
1158
1159    ```yaml
1160    hello: world
1161    ```
1162
1163- Item 3
1164- Item 4
1165";
1166        assert!(
1167            check(content, ListItemSpacingStyle::Consistent).is_empty(),
1168            "All tight gaps with structural code block should not warn"
1169        );
1170    }
1171
1172    #[test]
1173    fn code_block_in_item_all_loose_no_warnings() {
1174        // All non-structural gaps are loose → consistent, no warnings.
1175        let content = "\
1176- Item 1
1177
1178- Item 2
1179
1180    ```yaml
1181    hello: world
1182    ```
1183
1184- Item 3
1185
1186- Item 4
1187";
1188        assert!(
1189            check(content, ListItemSpacingStyle::Consistent).is_empty(),
1190            "All loose gaps with structural code block should not warn"
1191        );
1192    }
1193
1194    #[test]
1195    fn code_block_in_ordered_list_detects_inconsistency() {
1196        let content = "\
11971. First item
11981. Second item
1199
1200    ```json
1201    {\"key\": \"value\"}
1202    ```
1203
12041. Third item
1205
12061. Fourth item
1207";
1208        let warnings = check(content, ListItemSpacingStyle::Consistent);
1209        assert!(
1210            !warnings.is_empty(),
1211            "Ordered list with code block should still detect inconsistency"
1212        );
1213    }
1214
1215    #[test]
1216    fn code_block_in_item_fix_adds_missing_blanks() {
1217        // Items 1-2 are tight, items 3-4 are loose → majority loose → fix adds blank before item 2
1218        let content = "\
1219- Item 1
1220- Item 2
1221
1222    ```yaml
1223    code: here
1224    ```
1225
1226- Item 3
1227
1228- Item 4
1229";
1230        let fixed = fix(content, ListItemSpacingStyle::Consistent);
1231        assert!(
1232            fixed.contains("- Item 1\n\n- Item 2"),
1233            "Fix should add blank line between items 1 and 2"
1234        );
1235    }
1236
1237    #[test]
1238    fn tilde_code_block_in_item_detects_inconsistency() {
1239        let content = "\
1240- Item 1
1241- Item 2
1242
1243    ~~~
1244    code
1245    ~~~
1246
1247- Item 3
1248
1249- Item 4
1250";
1251        let warnings = check(content, ListItemSpacingStyle::Consistent);
1252        assert!(
1253            !warnings.is_empty(),
1254            "Tilde code block inside item should not prevent inconsistency detection"
1255        );
1256    }
1257
1258    #[test]
1259    fn multiple_code_blocks_all_tight_no_warnings() {
1260        // All non-structural gaps are tight → consistent.
1261        let content = "\
1262- Item 1
1263
1264    ```
1265    code1
1266    ```
1267
1268- Item 2
1269
1270    ```
1271    code2
1272    ```
1273
1274- Item 3
1275- Item 4
1276";
1277        assert!(
1278            check(content, ListItemSpacingStyle::Consistent).is_empty(),
1279            "All non-structural gaps are tight, so list is consistent"
1280        );
1281    }
1282
1283    #[test]
1284    fn code_block_with_mixed_genuine_gaps_warns() {
1285        // Items 1-2 structural, 2-3 loose, 3-4 tight → genuine inconsistency
1286        let content = "\
1287- Item 1
1288
1289    ```
1290    code1
1291    ```
1292
1293- Item 2
1294
1295- Item 3
1296- Item 4
1297";
1298        let warnings = check(content, ListItemSpacingStyle::Consistent);
1299        assert!(
1300            !warnings.is_empty(),
1301            "Mixed genuine gaps (loose + tight) with structural code block should still warn"
1302        );
1303    }
1304
1305    // ── Config schema ──────────────────────────────────────────────────
1306
1307    #[test]
1308    fn default_config_section_provides_style_key() {
1309        let rule = MD076ListItemSpacing::new(ListItemSpacingStyle::Consistent);
1310        let section = rule.default_config_section();
1311        assert!(section.is_some());
1312        let (name, value) = section.unwrap();
1313        assert_eq!(name, "MD076");
1314        if let toml::Value::Table(map) = value {
1315            assert!(map.contains_key("style"));
1316        } else {
1317            panic!("Expected Table value from default_config_section");
1318        }
1319    }
1320}