Skip to main content

rumdl_lib/rules/
md076_list_item_spacing.rs

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