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/// Per-block analysis result shared by check() and fix().
42struct BlockAnalysis {
43    /// 1-indexed line numbers of items at this block's nesting level.
44    items: Vec<usize>,
45    /// Whether each inter-item gap is loose (has a blank separator line).
46    gaps: Vec<bool>,
47    /// Whether loose gaps are violations (should have blank lines removed).
48    warn_loose_gaps: bool,
49    /// Whether tight gaps are violations (should have blank lines inserted).
50    warn_tight_gaps: bool,
51}
52
53impl MD076ListItemSpacing {
54    pub fn new(style: ListItemSpacingStyle) -> Self {
55        Self {
56            config: MD076Config { style },
57        }
58    }
59
60    /// Check whether a line is effectively blank, accounting for blockquote markers.
61    ///
62    /// A line like `>` or `> ` is considered blank in blockquote context even though
63    /// its raw content is non-empty.
64    fn is_effectively_blank(ctx: &LintContext, line_num: usize) -> bool {
65        if let Some(info) = ctx.line_info(line_num) {
66            let content = info.content(ctx.content);
67            if content.trim().is_empty() {
68                return true;
69            }
70            // In a blockquote, a line containing only markers (e.g., ">", "> ") is blank
71            if let Some(ref bq) = info.blockquote {
72                return bq.content.trim().is_empty();
73            }
74            false
75        } else {
76            false
77        }
78    }
79
80    /// Check whether a non-blank line is structural content (code block, table, or HTML block)
81    /// whose trailing blank line is required by other rules (MD031, MD058).
82    fn is_structural_content(ctx: &LintContext, line_num: usize) -> bool {
83        if let Some(info) = ctx.line_info(line_num) {
84            // Inside a code block (includes the closing fence itself)
85            if info.in_code_block {
86                return true;
87            }
88            // Inside an HTML block
89            if info.in_html_block {
90                return true;
91            }
92            // A table row or separator
93            let content = info.content(ctx.content);
94            // Strip blockquote prefix and list continuation indent before checking table syntax
95            let effective = if let Some(ref bq) = info.blockquote {
96                bq.content.as_str()
97            } else {
98                content
99            };
100            if is_table_line(effective.trim_start()) {
101                return true;
102            }
103        }
104        false
105    }
106
107    /// Determine whether the inter-item gap between two consecutive items is loose.
108    ///
109    /// Only considers blank lines that are actual inter-item separators: the
110    /// consecutive blank lines immediately preceding the next item's marker.
111    /// Blank lines required by MD031 (blanks-around-fences) or MD058 (blanks-around-tables)
112    /// after structural content (code blocks, tables, HTML blocks) are not counted as
113    /// inter-item separators.
114    fn gap_is_loose(ctx: &LintContext, first: usize, next: usize) -> bool {
115        if next <= first + 1 {
116            return false;
117        }
118        // The gap is loose if the line immediately before the next item is blank.
119        if !Self::is_effectively_blank(ctx, next - 1) {
120            return false;
121        }
122        // Walk backwards past blank lines to find the last non-blank content line.
123        // If that line is structural content, the blank is required (not a separator).
124        let mut scan = next - 1;
125        while scan > first && Self::is_effectively_blank(ctx, scan) {
126            scan -= 1;
127        }
128        // `scan` is now the last non-blank line before the next item
129        if scan > first && Self::is_structural_content(ctx, scan) {
130            return false;
131        }
132        true
133    }
134
135    /// Collect the 1-indexed line numbers of all inter-item blank lines in the gap.
136    ///
137    /// Walks backwards from the line before `next` collecting consecutive blank lines.
138    /// These are the actual separator lines between items, not blank lines within
139    /// multi-paragraph items. Structural blanks (after code blocks, tables, HTML blocks)
140    /// are excluded.
141    fn inter_item_blanks(ctx: &LintContext, first: usize, next: usize) -> Vec<usize> {
142        let mut blanks = Vec::new();
143        let mut line_num = next - 1;
144        while line_num > first && Self::is_effectively_blank(ctx, line_num) {
145            blanks.push(line_num);
146            line_num -= 1;
147        }
148        // If the last non-blank line is structural content, these blanks are structural
149        if line_num > first && Self::is_structural_content(ctx, line_num) {
150            return Vec::new();
151        }
152        blanks.reverse();
153        blanks
154    }
155
156    /// Analyze a single list block to determine which gaps need fixing.
157    ///
158    /// Returns `None` if the block has fewer than 2 items at its nesting level
159    /// or if no gaps violate the configured style.
160    fn analyze_block(
161        ctx: &LintContext,
162        block: &crate::lint_context::types::ListBlock,
163        style: &ListItemSpacingStyle,
164    ) -> Option<BlockAnalysis> {
165        // Only compare items at this block's own nesting level.
166        // item_lines may include nested list items (higher marker_column) that belong
167        // to a child list — those must not affect spacing analysis.
168        let items: Vec<usize> = block
169            .item_lines
170            .iter()
171            .copied()
172            .filter(|&line_num| {
173                ctx.line_info(line_num)
174                    .and_then(|li| li.list_item.as_ref())
175                    .map(|item| item.marker_column / 2 == block.nesting_level)
176                    .unwrap_or(false)
177            })
178            .collect();
179
180        if items.len() < 2 {
181            return None;
182        }
183
184        // Compute whether each inter-item gap is loose (has blank separator).
185        let gaps: Vec<bool> = items.windows(2).map(|w| Self::gap_is_loose(ctx, w[0], w[1])).collect();
186
187        let loose_count = gaps.iter().filter(|&&g| g).count();
188        let tight_count = gaps.len() - loose_count;
189
190        let (warn_loose_gaps, warn_tight_gaps) = match style {
191            ListItemSpacingStyle::Loose => (false, true),
192            ListItemSpacingStyle::Tight => (true, false),
193            ListItemSpacingStyle::Consistent => {
194                if loose_count == 0 || tight_count == 0 {
195                    return None; // Already consistent
196                }
197                // Majority wins; on a tie, prefer loose (warn tight).
198                if loose_count >= tight_count {
199                    (false, true)
200                } else {
201                    (true, false)
202                }
203            }
204        };
205
206        Some(BlockAnalysis {
207            items,
208            gaps,
209            warn_loose_gaps,
210            warn_tight_gaps,
211        })
212    }
213}
214
215impl Rule for MD076ListItemSpacing {
216    fn name(&self) -> &'static str {
217        "MD076"
218    }
219
220    fn description(&self) -> &'static str {
221        "List item spacing should be consistent"
222    }
223
224    fn check(&self, ctx: &LintContext) -> LintResult {
225        if ctx.content.is_empty() {
226            return Ok(Vec::new());
227        }
228
229        let mut warnings = Vec::new();
230
231        for block in &ctx.list_blocks {
232            let Some(analysis) = Self::analyze_block(ctx, block, &self.config.style) else {
233                continue;
234            };
235
236            for (i, &is_loose) in analysis.gaps.iter().enumerate() {
237                if is_loose && analysis.warn_loose_gaps {
238                    // Warn on the first inter-item blank line in this gap.
239                    let blanks = Self::inter_item_blanks(ctx, analysis.items[i], analysis.items[i + 1]);
240                    if let Some(&blank_line) = blanks.first() {
241                        let line_content = ctx
242                            .line_info(blank_line)
243                            .map(|li| li.content(ctx.content))
244                            .unwrap_or("");
245                        warnings.push(LintWarning {
246                            rule_name: Some(self.name().to_string()),
247                            line: blank_line,
248                            column: 1,
249                            end_line: blank_line,
250                            end_column: line_content.len() + 1,
251                            message: "Unexpected blank line between list items".to_string(),
252                            severity: Severity::Warning,
253                            fix: None,
254                        });
255                    }
256                } else if !is_loose && analysis.warn_tight_gaps {
257                    // Warn on the next item line (a blank line should precede it).
258                    let next_item = analysis.items[i + 1];
259                    let line_content = ctx.line_info(next_item).map(|li| li.content(ctx.content)).unwrap_or("");
260                    warnings.push(LintWarning {
261                        rule_name: Some(self.name().to_string()),
262                        line: next_item,
263                        column: 1,
264                        end_line: next_item,
265                        end_column: line_content.len() + 1,
266                        message: "Missing blank line between list items".to_string(),
267                        severity: Severity::Warning,
268                        fix: None,
269                    });
270                }
271            }
272        }
273
274        Ok(warnings)
275    }
276
277    fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
278        if ctx.content.is_empty() {
279            return Ok(ctx.content.to_string());
280        }
281
282        // Collect all inter-item blank lines to remove and lines to insert before.
283        let mut insert_before: std::collections::HashSet<usize> = std::collections::HashSet::new();
284        let mut remove_lines: std::collections::HashSet<usize> = std::collections::HashSet::new();
285
286        for block in &ctx.list_blocks {
287            let Some(analysis) = Self::analyze_block(ctx, block, &self.config.style) else {
288                continue;
289            };
290
291            for (i, &is_loose) in analysis.gaps.iter().enumerate() {
292                if is_loose && analysis.warn_loose_gaps {
293                    // Remove ALL inter-item blank lines in this gap
294                    for blank_line in Self::inter_item_blanks(ctx, analysis.items[i], analysis.items[i + 1]) {
295                        remove_lines.insert(blank_line);
296                    }
297                } else if !is_loose && analysis.warn_tight_gaps {
298                    insert_before.insert(analysis.items[i + 1]);
299                }
300            }
301        }
302
303        if insert_before.is_empty() && remove_lines.is_empty() {
304            return Ok(ctx.content.to_string());
305        }
306
307        let lines = ctx.raw_lines();
308        let mut result: Vec<String> = Vec::with_capacity(lines.len());
309
310        for (i, line) in lines.iter().enumerate() {
311            let line_num = i + 1;
312
313            if remove_lines.contains(&line_num) {
314                continue;
315            }
316
317            if insert_before.contains(&line_num) {
318                let bq_prefix = ctx.blockquote_prefix_for_blank_line(i);
319                result.push(bq_prefix);
320            }
321
322            result.push((*line).to_string());
323        }
324
325        let mut output = result.join("\n");
326        if ctx.content.ends_with('\n') {
327            output.push('\n');
328        }
329        Ok(output)
330    }
331
332    fn as_any(&self) -> &dyn std::any::Any {
333        self
334    }
335
336    fn default_config_section(&self) -> Option<(String, toml::Value)> {
337        let mut map = toml::map::Map::new();
338        let style_str = match self.config.style {
339            ListItemSpacingStyle::Consistent => "consistent",
340            ListItemSpacingStyle::Loose => "loose",
341            ListItemSpacingStyle::Tight => "tight",
342        };
343        map.insert("style".to_string(), toml::Value::String(style_str.to_string()));
344        Some((self.name().to_string(), toml::Value::Table(map)))
345    }
346
347    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
348    where
349        Self: Sized,
350    {
351        let style = crate::config::get_rule_config_value::<String>(config, "MD076", "style")
352            .unwrap_or_else(|| "consistent".to_string());
353        let style = match style.as_str() {
354            "loose" => ListItemSpacingStyle::Loose,
355            "tight" => ListItemSpacingStyle::Tight,
356            _ => ListItemSpacingStyle::Consistent,
357        };
358        Box::new(Self::new(style))
359    }
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365
366    fn check(content: &str, style: ListItemSpacingStyle) -> Vec<LintWarning> {
367        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
368        let rule = MD076ListItemSpacing::new(style);
369        rule.check(&ctx).unwrap()
370    }
371
372    fn fix(content: &str, style: ListItemSpacingStyle) -> String {
373        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
374        let rule = MD076ListItemSpacing::new(style);
375        rule.fix(&ctx).unwrap()
376    }
377
378    // ── Basic style detection ──────────────────────────────────────────
379
380    #[test]
381    fn tight_list_tight_style_no_warnings() {
382        let content = "- Item 1\n- Item 2\n- Item 3\n";
383        assert!(check(content, ListItemSpacingStyle::Tight).is_empty());
384    }
385
386    #[test]
387    fn loose_list_loose_style_no_warnings() {
388        let content = "- Item 1\n\n- Item 2\n\n- Item 3\n";
389        assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
390    }
391
392    #[test]
393    fn tight_list_loose_style_warns() {
394        let content = "- Item 1\n- Item 2\n- Item 3\n";
395        let warnings = check(content, ListItemSpacingStyle::Loose);
396        assert_eq!(warnings.len(), 2);
397        assert!(warnings.iter().all(|w| w.message.contains("Missing")));
398    }
399
400    #[test]
401    fn loose_list_tight_style_warns() {
402        let content = "- Item 1\n\n- Item 2\n\n- Item 3\n";
403        let warnings = check(content, ListItemSpacingStyle::Tight);
404        assert_eq!(warnings.len(), 2);
405        assert!(warnings.iter().all(|w| w.message.contains("Unexpected")));
406    }
407
408    // ── Consistent mode ────────────────────────────────────────────────
409
410    #[test]
411    fn consistent_all_tight_no_warnings() {
412        let content = "- Item 1\n- Item 2\n- Item 3\n";
413        assert!(check(content, ListItemSpacingStyle::Consistent).is_empty());
414    }
415
416    #[test]
417    fn consistent_all_loose_no_warnings() {
418        let content = "- Item 1\n\n- Item 2\n\n- Item 3\n";
419        assert!(check(content, ListItemSpacingStyle::Consistent).is_empty());
420    }
421
422    #[test]
423    fn consistent_mixed_majority_loose_warns_tight() {
424        // 2 loose gaps, 1 tight gap → tight is minority → warn on tight
425        let content = "- Item 1\n\n- Item 2\n- Item 3\n\n- Item 4\n";
426        let warnings = check(content, ListItemSpacingStyle::Consistent);
427        assert_eq!(warnings.len(), 1);
428        assert!(warnings[0].message.contains("Missing"));
429    }
430
431    #[test]
432    fn consistent_mixed_majority_tight_warns_loose() {
433        // 1 loose gap, 2 tight gaps → loose is minority → warn on loose blank line
434        let content = "- Item 1\n\n- Item 2\n- Item 3\n- Item 4\n";
435        let warnings = check(content, ListItemSpacingStyle::Consistent);
436        assert_eq!(warnings.len(), 1);
437        assert!(warnings[0].message.contains("Unexpected"));
438    }
439
440    #[test]
441    fn consistent_tie_prefers_loose() {
442        let content = "- Item 1\n\n- Item 2\n- Item 3\n";
443        let warnings = check(content, ListItemSpacingStyle::Consistent);
444        assert_eq!(warnings.len(), 1);
445        assert!(warnings[0].message.contains("Missing"));
446    }
447
448    // ── Edge cases ─────────────────────────────────────────────────────
449
450    #[test]
451    fn single_item_list_no_warnings() {
452        let content = "- Only item\n";
453        assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
454        assert!(check(content, ListItemSpacingStyle::Tight).is_empty());
455        assert!(check(content, ListItemSpacingStyle::Consistent).is_empty());
456    }
457
458    #[test]
459    fn empty_content_no_warnings() {
460        assert!(check("", ListItemSpacingStyle::Consistent).is_empty());
461    }
462
463    #[test]
464    fn ordered_list_tight_gaps_loose_style_warns() {
465        let content = "1. First\n2. Second\n3. Third\n";
466        let warnings = check(content, ListItemSpacingStyle::Loose);
467        assert_eq!(warnings.len(), 2);
468    }
469
470    #[test]
471    fn task_list_works() {
472        let content = "- [x] Task 1\n- [ ] Task 2\n- [x] Task 3\n";
473        let warnings = check(content, ListItemSpacingStyle::Loose);
474        assert_eq!(warnings.len(), 2);
475        let fixed = fix(content, ListItemSpacingStyle::Loose);
476        assert_eq!(fixed, "- [x] Task 1\n\n- [ ] Task 2\n\n- [x] Task 3\n");
477    }
478
479    #[test]
480    fn no_trailing_newline() {
481        let content = "- Item 1\n- Item 2";
482        let warnings = check(content, ListItemSpacingStyle::Loose);
483        assert_eq!(warnings.len(), 1);
484        let fixed = fix(content, ListItemSpacingStyle::Loose);
485        assert_eq!(fixed, "- Item 1\n\n- Item 2");
486    }
487
488    #[test]
489    fn two_separate_lists() {
490        let content = "- A\n- B\n\nText\n\n1. One\n2. Two\n";
491        let warnings = check(content, ListItemSpacingStyle::Loose);
492        assert_eq!(warnings.len(), 2);
493        let fixed = fix(content, ListItemSpacingStyle::Loose);
494        assert_eq!(fixed, "- A\n\n- B\n\nText\n\n1. One\n\n2. Two\n");
495    }
496
497    #[test]
498    fn no_list_content() {
499        let content = "Just a paragraph.\n\nAnother paragraph.\n";
500        assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
501        assert!(check(content, ListItemSpacingStyle::Tight).is_empty());
502    }
503
504    // ── Multi-line and continuation items ──────────────────────────────
505
506    #[test]
507    fn continuation_lines_tight_detected() {
508        let content = "- Item 1\n  continuation\n- Item 2\n";
509        let warnings = check(content, ListItemSpacingStyle::Loose);
510        assert_eq!(warnings.len(), 1);
511        assert!(warnings[0].message.contains("Missing"));
512    }
513
514    #[test]
515    fn continuation_lines_loose_detected() {
516        let content = "- Item 1\n  continuation\n\n- Item 2\n";
517        assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
518        let warnings = check(content, ListItemSpacingStyle::Tight);
519        assert_eq!(warnings.len(), 1);
520        assert!(warnings[0].message.contains("Unexpected"));
521    }
522
523    #[test]
524    fn multi_paragraph_item_not_treated_as_inter_item_gap() {
525        // Blank line between paragraphs within Item 1 must NOT trigger a warning.
526        // Only the blank line immediately before Item 2 is an inter-item separator.
527        let content = "- Item 1\n\n  Second paragraph\n\n- Item 2\n";
528        // Both gaps are loose (blank before Item 2), so tight should warn once
529        let warnings = check(content, ListItemSpacingStyle::Tight);
530        assert_eq!(
531            warnings.len(),
532            1,
533            "Should warn only on the inter-item blank, not the intra-item blank"
534        );
535        // The fix should remove only the inter-item blank (line 4), preserving the
536        // multi-paragraph structure
537        let fixed = fix(content, ListItemSpacingStyle::Tight);
538        assert_eq!(fixed, "- Item 1\n\n  Second paragraph\n- Item 2\n");
539    }
540
541    #[test]
542    fn multi_paragraph_item_loose_style_no_warnings() {
543        // A loose list with multi-paragraph items is already loose — no warnings
544        let content = "- Item 1\n\n  Second paragraph\n\n- Item 2\n";
545        assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
546    }
547
548    // ── Blockquote lists ───────────────────────────────────────────────
549
550    #[test]
551    fn blockquote_tight_list_loose_style_warns() {
552        let content = "> - Item 1\n> - Item 2\n> - Item 3\n";
553        let warnings = check(content, ListItemSpacingStyle::Loose);
554        assert_eq!(warnings.len(), 2);
555    }
556
557    #[test]
558    fn blockquote_loose_list_detected() {
559        // A line with only `>` is effectively blank in blockquote context
560        let content = "> - Item 1\n>\n> - Item 2\n";
561        let warnings = check(content, ListItemSpacingStyle::Tight);
562        assert_eq!(warnings.len(), 1, "Blockquote-only line should be detected as blank");
563        assert!(warnings[0].message.contains("Unexpected"));
564    }
565
566    #[test]
567    fn blockquote_loose_list_no_warnings_when_loose() {
568        let content = "> - Item 1\n>\n> - Item 2\n";
569        assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
570    }
571
572    // ── Multiple blank lines ───────────────────────────────────────────
573
574    #[test]
575    fn multiple_blanks_all_removed() {
576        let content = "- Item 1\n\n\n- Item 2\n";
577        let fixed = fix(content, ListItemSpacingStyle::Tight);
578        assert_eq!(fixed, "- Item 1\n- Item 2\n");
579    }
580
581    #[test]
582    fn multiple_blanks_fix_is_idempotent() {
583        let content = "- Item 1\n\n\n\n- Item 2\n";
584        let fixed_once = fix(content, ListItemSpacingStyle::Tight);
585        let fixed_twice = fix(&fixed_once, ListItemSpacingStyle::Tight);
586        assert_eq!(fixed_once, fixed_twice);
587        assert_eq!(fixed_once, "- Item 1\n- Item 2\n");
588    }
589
590    // ── Fix correctness ────────────────────────────────────────────────
591
592    #[test]
593    fn fix_adds_blank_lines() {
594        let content = "- Item 1\n- Item 2\n- Item 3\n";
595        let fixed = fix(content, ListItemSpacingStyle::Loose);
596        assert_eq!(fixed, "- Item 1\n\n- Item 2\n\n- Item 3\n");
597    }
598
599    #[test]
600    fn fix_removes_blank_lines() {
601        let content = "- Item 1\n\n- Item 2\n\n- Item 3\n";
602        let fixed = fix(content, ListItemSpacingStyle::Tight);
603        assert_eq!(fixed, "- Item 1\n- Item 2\n- Item 3\n");
604    }
605
606    #[test]
607    fn fix_consistent_adds_blank() {
608        // 2 loose gaps, 1 tight gap → add blank before Item 3
609        let content = "- Item 1\n\n- Item 2\n- Item 3\n\n- Item 4\n";
610        let fixed = fix(content, ListItemSpacingStyle::Consistent);
611        assert_eq!(fixed, "- Item 1\n\n- Item 2\n\n- Item 3\n\n- Item 4\n");
612    }
613
614    #[test]
615    fn fix_idempotent_loose() {
616        let content = "- Item 1\n- Item 2\n";
617        let fixed_once = fix(content, ListItemSpacingStyle::Loose);
618        let fixed_twice = fix(&fixed_once, ListItemSpacingStyle::Loose);
619        assert_eq!(fixed_once, fixed_twice);
620    }
621
622    #[test]
623    fn fix_idempotent_tight() {
624        let content = "- Item 1\n\n- Item 2\n";
625        let fixed_once = fix(content, ListItemSpacingStyle::Tight);
626        let fixed_twice = fix(&fixed_once, ListItemSpacingStyle::Tight);
627        assert_eq!(fixed_once, fixed_twice);
628    }
629
630    // ── Nested lists ───────────────────────────────────────────────────
631
632    #[test]
633    fn nested_list_does_not_affect_parent() {
634        // Nested items should not trigger warnings for the parent list
635        let content = "- Item 1\n  - Nested A\n  - Nested B\n- Item 2\n";
636        let warnings = check(content, ListItemSpacingStyle::Tight);
637        assert!(
638            warnings.is_empty(),
639            "Nested items should not cause parent-level warnings"
640        );
641    }
642
643    // ── Structural blank lines (code blocks, tables, HTML) ──────────
644
645    #[test]
646    fn code_block_in_tight_list_no_false_positive() {
647        // Blank line after closing fence is structural (required by MD031), not a separator
648        let content = "\
649- Item 1 with code:
650
651  ```python
652  print('hello')
653  ```
654
655- Item 2 simple.
656- Item 3 simple.
657";
658        assert!(
659            check(content, ListItemSpacingStyle::Consistent).is_empty(),
660            "Structural blank after code block should not make item 1 appear loose"
661        );
662    }
663
664    #[test]
665    fn table_in_tight_list_no_false_positive() {
666        // Blank line after table is structural (required by MD058), not a separator
667        let content = "\
668- Item 1 with table:
669
670  | Col 1 | Col 2 |
671  |-------|-------|
672  | A     | B     |
673
674- Item 2 simple.
675- Item 3 simple.
676";
677        assert!(
678            check(content, ListItemSpacingStyle::Consistent).is_empty(),
679            "Structural blank after table should not make item 1 appear loose"
680        );
681    }
682
683    #[test]
684    fn html_block_in_tight_list_no_false_positive() {
685        let content = "\
686- Item 1 with HTML:
687
688  <details>
689  <summary>Click</summary>
690  Content
691  </details>
692
693- Item 2 simple.
694- Item 3 simple.
695";
696        assert!(
697            check(content, ListItemSpacingStyle::Consistent).is_empty(),
698            "Structural blank after HTML block should not make item 1 appear loose"
699        );
700    }
701
702    #[test]
703    fn mixed_code_and_table_in_tight_list() {
704        let content = "\
7051. Item with code:
706
707   ```markdown
708   This is some Markdown
709   ```
710
7111. Simple item.
7121. Item with table:
713
714   | Col 1 | Col 2 |
715   |:------|:------|
716   | Row 1 | Row 1 |
717   | Row 2 | Row 2 |
718";
719        assert!(
720            check(content, ListItemSpacingStyle::Consistent).is_empty(),
721            "Mix of code blocks and tables should not cause false positives"
722        );
723    }
724
725    #[test]
726    fn code_block_with_genuinely_loose_gaps_still_warns() {
727        // Item 1 has structural blank (code block), items 2-3 have genuine blank separator
728        // Items 2-3 are genuinely loose, item 3-4 is tight → inconsistent
729        let content = "\
730- Item 1:
731
732  ```bash
733  echo hi
734  ```
735
736- Item 2
737
738- Item 3
739- Item 4
740";
741        let warnings = check(content, ListItemSpacingStyle::Consistent);
742        assert!(
743            !warnings.is_empty(),
744            "Genuine inconsistency with code blocks should still be flagged"
745        );
746    }
747
748    #[test]
749    fn all_items_have_code_blocks_no_warnings() {
750        let content = "\
751- Item 1:
752
753  ```python
754  print(1)
755  ```
756
757- Item 2:
758
759  ```python
760  print(2)
761  ```
762
763- Item 3:
764
765  ```python
766  print(3)
767  ```
768";
769        assert!(
770            check(content, ListItemSpacingStyle::Consistent).is_empty(),
771            "All items with code blocks should be consistently tight"
772        );
773    }
774
775    #[test]
776    fn tilde_fence_code_block_in_list() {
777        let content = "\
778- Item 1:
779
780  ~~~
781  code here
782  ~~~
783
784- Item 2 simple.
785- Item 3 simple.
786";
787        assert!(
788            check(content, ListItemSpacingStyle::Consistent).is_empty(),
789            "Tilde fences should be recognized as structural content"
790        );
791    }
792
793    #[test]
794    fn nested_list_with_code_block() {
795        let content = "\
796- Item 1
797  - Nested with code:
798
799    ```
800    nested code
801    ```
802
803  - Nested simple.
804- Item 2
805";
806        assert!(
807            check(content, ListItemSpacingStyle::Consistent).is_empty(),
808            "Nested list with code block should not cause false positives"
809        );
810    }
811
812    #[test]
813    fn tight_style_with_code_block_no_warnings() {
814        let content = "\
815- Item 1:
816
817  ```
818  code
819  ```
820
821- Item 2.
822- Item 3.
823";
824        assert!(
825            check(content, ListItemSpacingStyle::Tight).is_empty(),
826            "Tight style should not warn about structural blanks around code blocks"
827        );
828    }
829
830    #[test]
831    fn loose_style_with_code_block_missing_separator() {
832        // Loose style requires blank line between every pair of items.
833        // Items 2-3 have no blank → should warn
834        let content = "\
835- Item 1:
836
837  ```
838  code
839  ```
840
841- Item 2.
842- Item 3.
843";
844        let warnings = check(content, ListItemSpacingStyle::Loose);
845        assert_eq!(
846            warnings.len(),
847            1,
848            "Loose style should still require blank between simple items"
849        );
850        assert!(warnings[0].message.contains("Missing"));
851    }
852
853    #[test]
854    fn blockquote_list_with_code_block() {
855        let content = "\
856> - Item 1:
857>
858>   ```
859>   code
860>   ```
861>
862> - Item 2.
863> - Item 3.
864";
865        assert!(
866            check(content, ListItemSpacingStyle::Consistent).is_empty(),
867            "Blockquote-prefixed list with code block should not cause false positives"
868        );
869    }
870
871    // ── Indented code block (not fenced) in list item ─────────────────
872
873    #[test]
874    fn indented_code_block_in_list_no_false_positive() {
875        // A 4-space indented code block inside a list item should be treated
876        // as structural content, not trigger a loose gap detection.
877        let content = "\
8781. Item with indented code:
879
880       some code here
881       more code
882
8831. Simple item
8841. Another item
885";
886        assert!(
887            check(content, ListItemSpacingStyle::Consistent).is_empty(),
888            "Structural blank after indented code block should not make item 1 appear loose"
889        );
890    }
891
892    // ── Code block in middle of item with text after ────────────────
893
894    #[test]
895    fn code_block_in_middle_of_item_text_after_is_genuinely_loose() {
896        // When a code block is in the middle of an item and there's regular text
897        // after it, a blank line before the next item IS a genuine separator (loose),
898        // not structural. The last non-blank line before item 2 is "Some text after
899        // the code block." which is NOT structural content.
900        let content = "\
9011. Item with code in middle:
902
903   ```
904   code
905   ```
906
907   Some text after the code block.
908
9091. Simple item
9101. Another item
911";
912        let warnings = check(content, ListItemSpacingStyle::Consistent);
913        assert!(
914            !warnings.is_empty(),
915            "Blank line after regular text (not structural content) is a genuine loose gap"
916        );
917    }
918
919    // ── Fix: tight mode preserves structural blanks ──────────────────
920
921    #[test]
922    fn tight_fix_preserves_structural_blanks_around_code_blocks() {
923        // When style is tight, the fix should NOT remove structural blank lines
924        // around code blocks inside list items. Those blanks are required by MD031.
925        let content = "\
926- Item 1:
927
928  ```
929  code
930  ```
931
932- Item 2.
933- Item 3.
934";
935        let fixed = fix(content, ListItemSpacingStyle::Tight);
936        assert_eq!(
937            fixed, content,
938            "Tight fix should not remove structural blanks around code blocks"
939        );
940    }
941
942    // ── Config schema ──────────────────────────────────────────────────
943
944    #[test]
945    fn default_config_section_provides_style_key() {
946        let rule = MD076ListItemSpacing::new(ListItemSpacingStyle::Consistent);
947        let section = rule.default_config_section();
948        assert!(section.is_some());
949        let (name, value) = section.unwrap();
950        assert_eq!(name, "MD076");
951        if let toml::Value::Table(map) = value {
952            assert!(map.contains_key("style"));
953        } else {
954            panic!("Expected Table value from default_config_section");
955        }
956    }
957}