Skip to main content

rumdl_lib/rules/
md076_list_item_spacing.rs

1use std::cell::OnceCell;
2
3use crate::lint_context::LintContext;
4use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
5use crate::utils::skip_context::is_table_line;
6
7/// The blank-line style required between consecutive list items.
8#[derive(Debug, Clone, PartialEq, Eq, Default)]
9pub enum ListItemSpacingStyle {
10    #[default]
11    Consistent,
12    Loose,
13    Tight,
14}
15
16#[derive(Debug, Clone, Default, PartialEq)]
17pub(super) struct MD076Config {
18    pub style: ListItemSpacingStyle,
19    /// When true, blank lines around continuation paragraphs within a list item
20    /// are permitted even in tight mode. This allows tight inter-item spacing
21    /// while using blank lines to visually separate continuation content.
22    pub allow_loose_continuation: bool,
23}
24
25/// Rule MD076: Enforce consistent blank lines between list items
26///
27/// See [docs/md076.md](../../docs/md076.md) for full documentation and examples.
28///
29/// Enforces that the spacing between consecutive list items is consistent
30/// within each list: either all gaps have a blank line (loose) or none do (tight).
31///
32/// ## Configuration
33///
34/// ```toml
35/// [MD076]
36/// style = "consistent"  # "loose", "tight", or "consistent" (default)
37/// ```
38///
39/// - `"consistent"` — within each list, all gaps must use the same style (majority wins)
40/// - `"loose"` — blank line required between every pair of items
41/// - `"tight"` — no blank lines allowed between any items
42#[derive(Debug, Clone, Default)]
43pub struct MD076ListItemSpacing {
44    config: MD076Config,
45}
46
47/// Classification of the spacing between two consecutive list items.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49enum GapKind {
50    /// No blank line between items.
51    Tight,
52    /// Blank line that is a genuine inter-item separator.
53    Loose,
54    /// Blank line required by another rule (MD031, MD058) around structural content.
55    /// Excluded from consistency analysis — neither loose nor tight.
56    Structural,
57    /// Blank line after continuation content within a list item.
58    /// Treated as `Structural` when `allow_loose_continuation` is enabled,
59    /// or as `Loose` when disabled (default).
60    ContinuationLoose,
61}
62
63/// Per-list analysis result shared by check() and fix().
64struct ListAnalysis {
65    /// 1-indexed line numbers of the list's items, in order.
66    items: Vec<usize>,
67    /// Classification of each inter-item gap.
68    gaps: Vec<GapKind>,
69    /// Whether loose gaps are violations (should have blank lines removed).
70    warn_loose_gaps: bool,
71    /// Whether tight gaps are violations (should have blank lines inserted).
72    warn_tight_gaps: bool,
73}
74
75impl MD076ListItemSpacing {
76    pub fn new(style: ListItemSpacingStyle) -> Self {
77        Self {
78            config: MD076Config {
79                style,
80                allow_loose_continuation: false,
81            },
82        }
83    }
84
85    pub fn with_allow_loose_continuation(mut self, allow: bool) -> Self {
86        self.config.allow_loose_continuation = allow;
87        self
88    }
89
90    /// Check whether a line is effectively blank, accounting for blockquote markers.
91    ///
92    /// Only spaces and tabs are blank: U+00A0 and the other Unicode spaces are paragraph
93    /// text, so a line holding one neither separates items nor ends a list item.
94    ///
95    /// A line like `>` or `> ` is considered blank in blockquote context even though
96    /// its raw content is non-empty.
97    fn is_effectively_blank(ctx: &LintContext, line_num: usize) -> bool {
98        if let Some(info) = ctx.line_info(line_num) {
99            let content = info.content(ctx.content);
100            if content.trim_matches([' ', '\t', '\r']).is_empty() {
101                return true;
102            }
103            // In a blockquote, a line containing only markers (e.g., ">", "> ") is blank
104            if let Some(ref bq) = info.blockquote {
105                return bq.content.trim_matches([' ', '\t', '\r']).is_empty();
106            }
107            false
108        } else {
109            false
110        }
111    }
112
113    /// Check whether a non-blank line is structural content (code block, table, HTML block,
114    /// or blockquote) whose trailing blank line is required by other rules (MD031, MD058).
115    fn is_structural_content(ctx: &LintContext, line_num: usize) -> bool {
116        if let Some(info) = ctx.line_info(line_num) {
117            // Inside a code block (includes the closing fence itself)
118            if info.in_code_block {
119                return true;
120            }
121            // Inside an HTML block
122            if info.in_html_block {
123                return true;
124            }
125            // Inside a blockquote
126            if info.blockquote.is_some() {
127                return true;
128            }
129            // A table row or separator
130            let content = info.content(ctx.content);
131            // Strip blockquote prefix and list continuation indent before checking table syntax
132            let effective = if let Some(ref bq) = info.blockquote {
133                bq.content.as_str()
134            } else {
135                content
136            };
137            if is_table_line(effective.trim_start()) {
138                return true;
139            }
140        }
141        false
142    }
143
144    /// Check whether a list item opens a fenced code block on its marker line.
145    ///
146    /// MD031 requires a blank line before that fence. MD076 must treat the same
147    /// blank as structural rather than removing it as an inter-item separator.
148    ///
149    /// The question goes to the parser, through the same `is_fenced` details MD031
150    /// itself reads, so the two rules cannot disagree about what a fence is. Line
151    /// text is not enough: a marker followed by five spaces and a fence is an
152    /// *indented* code block, which MD031 says nothing about, and the `in_code_block`
153    /// flag is true for indented blocks as well as fenced ones.
154    fn is_fenced_code_block_list_item(ctx: &LintContext, line_num: usize, fence_starts: &OnceCell<Vec<usize>>) -> bool {
155        let Some(info) = ctx.line_info(line_num) else {
156            return false;
157        };
158        if info.list_item.is_none() {
159            return false;
160        }
161
162        let line_range = info.byte_offset..info.byte_offset + info.byte_len;
163        // Initialize only for a list marker after a blank gap. Tight lists do
164        // not need this index, and all lists in this analysis share it.
165        let starts = fence_starts.get_or_init(|| {
166            let mut starts: Vec<_> = ctx
167                .code_block_details
168                .iter()
169                .filter(|detail| detail.is_fenced)
170                .map(|detail| detail.start)
171                .collect();
172            starts.sort_unstable();
173            starts
174        });
175        let next = starts.partition_point(|&start| start < line_range.start);
176        starts.get(next).is_some_and(|start| line_range.contains(start))
177    }
178
179    /// Check whether a non-blank line is continuation content within a list item
180    /// (indented prose that is not itself a list marker or structural content).
181    ///
182    /// `parent_content_col` is the content column of the parent list item marker
183    /// (e.g., 2 for `- item`, 3 for `1. item`). Continuation must be indented
184    /// to at least this column to belong to the parent item.
185    fn is_continuation_content(ctx: &LintContext, line_num: usize, parent_content_col: usize) -> bool {
186        let Some(info) = ctx.line_info(line_num) else {
187            return false;
188        };
189        // Lines with a list marker are items, not continuation
190        if info.list_item.is_some() {
191            return false;
192        }
193        // Structural content is handled separately by is_structural_content
194        if info.in_code_block
195            || info.in_html_block
196            || info.in_html_comment
197            || info.in_mdx_comment
198            || info.in_front_matter
199            || info.in_math_block
200            || info.blockquote.is_some()
201        {
202            return false;
203        }
204        let content = info.content(ctx.content);
205        if content.trim_matches([' ', '\t', '\r']).is_empty() {
206            return false;
207        }
208        // Continuation must be indented to at least the parent item's content column
209        let indent = content.len() - content.trim_start().len();
210        indent >= parent_content_col
211    }
212
213    /// Classify the inter-item gap between two consecutive items.
214    ///
215    /// Returns `Tight` if there is no blank line, `Loose` if there is a genuine
216    /// inter-item separator blank, `Structural` if the only blank line is
217    /// required by another rule (MD031/MD058) after structural content, or
218    /// `ContinuationLoose` if the blank line follows continuation content
219    /// within a list item.
220    fn classify_gap(ctx: &LintContext, first: usize, next: usize, fence_starts: &OnceCell<Vec<usize>>) -> GapKind {
221        if next <= first + 1 {
222            return GapKind::Tight;
223        }
224        // The gap has a blank line only if the line immediately before the next item is blank.
225        if !Self::is_effectively_blank(ctx, next - 1) {
226            return GapKind::Tight;
227        }
228        // A fence opened directly on a list marker is still part of that list
229        // item. The blank before it belongs to MD031, not to MD076's spacing
230        // consistency policy, so removing it would create a fix loop.
231        if Self::is_fenced_code_block_list_item(ctx, next, fence_starts) {
232            return GapKind::Structural;
233        }
234        // Walk backwards past blank lines to find the last non-blank content line.
235        // If that line is structural content, the blank is required (not a separator).
236        let mut scan = next - 1;
237        while scan > first && Self::is_effectively_blank(ctx, scan) {
238            scan -= 1;
239        }
240        // `scan` is now the last non-blank line before the next item
241        if scan > first && Self::is_structural_content(ctx, scan) {
242            return GapKind::Structural;
243        }
244        // Check if the last non-blank line is continuation content.
245        // Use the first item's content column to verify proper indentation.
246        let parent_content_col = ctx
247            .line_info(first)
248            .and_then(|li| li.list_item.as_ref())
249            .map_or(2, |item| item.content_column);
250        if scan > first && Self::is_continuation_content(ctx, scan, parent_content_col) {
251            return GapKind::ContinuationLoose;
252        }
253        GapKind::Loose
254    }
255
256    /// Collect the 1-indexed line numbers of all inter-item blank lines in the gap.
257    ///
258    /// Walks backwards from the line before `next` collecting consecutive blank lines.
259    /// These are the actual separator lines between items, not blank lines within
260    /// multi-paragraph items. Structural blanks (after code blocks, tables, HTML blocks)
261    /// are excluded.
262    fn inter_item_blanks(ctx: &LintContext, first: usize, next: usize) -> Vec<usize> {
263        let mut blanks = Vec::new();
264        let mut line_num = next - 1;
265        while line_num > first && Self::is_effectively_blank(ctx, line_num) {
266            blanks.push(line_num);
267            line_num -= 1;
268        }
269        // If the last non-blank line is structural content, these blanks are structural
270        if line_num > first && Self::is_structural_content(ctx, line_num) {
271            return Vec::new();
272        }
273        blanks.reverse();
274        blanks
275    }
276
277    /// Analyze every list in the document: each block's items grouped into
278    /// the lists they form, one per run of items at one nesting level, so a
279    /// nested list is judged on its own spacing and never on its parent's.
280    fn analyze(&self, ctx: &LintContext) -> Vec<ListAnalysis> {
281        let fence_starts = OnceCell::new();
282        ctx.list_blocks
283            .iter()
284            .flat_map(|block| ctx.list_block_item_groups(block))
285            .filter_map(|items| {
286                Self::analyze_list(
287                    ctx,
288                    items,
289                    &self.config.style,
290                    self.config.allow_loose_continuation,
291                    &fence_starts,
292                )
293            })
294            .collect()
295    }
296
297    /// Analyze one list, given the lines of its items in order, to determine
298    /// which gaps need fixing.
299    ///
300    /// Returns `None` if the list has fewer than 2 items or if no gaps violate
301    /// the configured style.
302    fn analyze_list(
303        ctx: &LintContext,
304        items: Vec<usize>,
305        style: &ListItemSpacingStyle,
306        allow_loose_continuation: bool,
307        fence_starts: &OnceCell<Vec<usize>>,
308    ) -> Option<ListAnalysis> {
309        if items.len() < 2 {
310            return None;
311        }
312
313        // Classify each inter-item gap.
314        let gaps: Vec<GapKind> = items
315            .windows(2)
316            .map(|w| Self::classify_gap(ctx, w[0], w[1], fence_starts))
317            .collect();
318
319        // Structural gaps and (when allowed) continuation gaps are excluded
320        // from consistency analysis — they should not influence whether the
321        // list is considered loose or tight.
322        let loose_count = gaps
323            .iter()
324            .filter(|&&g| g == GapKind::Loose || (g == GapKind::ContinuationLoose && !allow_loose_continuation))
325            .count();
326        let tight_count = gaps.iter().filter(|&&g| g == GapKind::Tight).count();
327
328        let (warn_loose_gaps, warn_tight_gaps) = match style {
329            ListItemSpacingStyle::Loose => (false, true),
330            ListItemSpacingStyle::Tight => (true, false),
331            ListItemSpacingStyle::Consistent => {
332                if loose_count == 0 || tight_count == 0 {
333                    return None; // Already consistent (structural gaps excluded)
334                }
335                // Majority wins. On a tie, prefer tight (warn loose):
336                //   - tight is the dominant style in real-world Markdown;
337                //     loose is opt-in for multi-paragraph items,
338                //   - matches the minimal-whitespace convention used by
339                //     Prettier and most other Markdown formatters,
340                //   - removes a blank line rather than inserting one, which
341                //     is the lower-impact edit on a tied document.
342                if tight_count >= loose_count {
343                    (true, false)
344                } else {
345                    (false, true)
346                }
347            }
348        };
349
350        Some(ListAnalysis {
351            items,
352            gaps,
353            warn_loose_gaps,
354            warn_tight_gaps,
355        })
356    }
357}
358
359impl Rule for MD076ListItemSpacing {
360    fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
361        (self.config == Default::default()).then_some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(76))
362    }
363
364    fn name(&self) -> &'static str {
365        "MD076"
366    }
367
368    fn description(&self) -> &'static str {
369        "List item spacing should be consistent"
370    }
371
372    fn category(&self) -> RuleCategory {
373        RuleCategory::List
374    }
375
376    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
377        ctx.content.is_empty() || ctx.list_blocks.is_empty()
378    }
379
380    fn check(&self, ctx: &LintContext) -> LintResult {
381        if ctx.content.is_empty() {
382            return Ok(Vec::new());
383        }
384
385        let mut warnings = Vec::new();
386
387        let allow_cont = self.config.allow_loose_continuation;
388        // The edits are applied to the document as the rule saw it, which an
389        // editor hands over with its own line endings, so an inserted line
390        // ends the way the document's lines do.
391        let line_ending = crate::utils::line_ending::detect_line_ending(ctx.content);
392
393        for analysis in self.analyze(ctx) {
394            for (i, &gap) in analysis.gaps.iter().enumerate() {
395                let is_loose_violation = match gap {
396                    GapKind::Loose => analysis.warn_loose_gaps,
397                    GapKind::ContinuationLoose => !allow_cont && analysis.warn_loose_gaps,
398                    _ => false,
399                };
400
401                if is_loose_violation {
402                    let next_item = analysis.items[i + 1];
403                    let blanks = Self::inter_item_blanks(ctx, analysis.items[i], next_item);
404                    if let Some(&blank_line) = blanks.first() {
405                        let line_content = ctx.line_info(blank_line).map_or("", |li| li.content(ctx.content));
406                        // The edit removes the whole run of blank lines the
407                        // fix removes, so applying it alone closes the gap.
408                        let fix = ctx
409                            .line_start_byte(blank_line)
410                            .zip(ctx.line_start_byte(next_item))
411                            .map(|(start, end)| Fix::new(start..end, String::new()));
412                        warnings.push(LintWarning {
413                            rule_name: Some(self.name().to_string()),
414                            line: blank_line,
415                            column: 1,
416                            end_line: blank_line,
417                            end_column: line_content.chars().count() + 1,
418                            message: "Unexpected blank line between list items".to_string(),
419                            severity: Severity::Warning,
420                            fix,
421                        });
422                    }
423                } else if gap == GapKind::Tight && analysis.warn_tight_gaps {
424                    let next_item = analysis.items[i + 1];
425                    let line_content = ctx.line_info(next_item).map_or("", |li| li.content(ctx.content));
426                    // The blank line goes in front of the item, carrying the
427                    // item's blockquote prefix as the fix writes it.
428                    let fix = ctx.line_start_byte(next_item).map(|start| {
429                        let prefix = ctx.blockquote_prefix_for_blank_line(next_item - 1);
430                        Fix::new(start..start, format!("{prefix}{line_ending}"))
431                    });
432                    warnings.push(LintWarning {
433                        rule_name: Some(self.name().to_string()),
434                        line: next_item,
435                        column: 1,
436                        end_line: next_item,
437                        end_column: line_content.chars().count() + 1,
438                        message: "Missing blank line between list items".to_string(),
439                        severity: Severity::Warning,
440                        fix,
441                    });
442                }
443            }
444        }
445
446        Ok(warnings)
447    }
448
449    fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
450        if ctx.content.is_empty() {
451            return Ok(ctx.content.to_string());
452        }
453
454        // Collect all inter-item blank lines to remove and lines to insert before.
455        let mut insert_before: std::collections::HashSet<usize> = std::collections::HashSet::new();
456        let mut remove_lines: std::collections::HashSet<usize> = std::collections::HashSet::new();
457
458        let allow_cont = self.config.allow_loose_continuation;
459
460        for analysis in self.analyze(ctx) {
461            for (i, &gap) in analysis.gaps.iter().enumerate() {
462                let is_loose_violation = match gap {
463                    GapKind::Loose => analysis.warn_loose_gaps,
464                    GapKind::ContinuationLoose => !allow_cont && analysis.warn_loose_gaps,
465                    _ => false,
466                };
467
468                if is_loose_violation {
469                    for blank_line in Self::inter_item_blanks(ctx, analysis.items[i], analysis.items[i + 1]) {
470                        remove_lines.insert(blank_line);
471                    }
472                } else if gap == GapKind::Tight && analysis.warn_tight_gaps {
473                    insert_before.insert(analysis.items[i + 1]);
474                }
475            }
476        }
477
478        if insert_before.is_empty() && remove_lines.is_empty() {
479            return Ok(ctx.content.to_string());
480        }
481
482        let lines = ctx.raw_lines();
483        let mut result: Vec<String> = Vec::with_capacity(lines.len());
484
485        for (i, line) in lines.iter().enumerate() {
486            let line_num = i + 1;
487
488            // Skip modifications for lines where the rule is disabled via inline config
489            if ctx.is_rule_disabled(self.name(), line_num) {
490                result.push((*line).to_string());
491                continue;
492            }
493
494            if remove_lines.contains(&line_num) {
495                continue;
496            }
497
498            if insert_before.contains(&line_num) {
499                let bq_prefix = ctx.blockquote_prefix_for_blank_line(i);
500                result.push(bq_prefix);
501            }
502
503            result.push((*line).to_string());
504        }
505
506        let mut output = result.join("\n");
507        if ctx.content.ends_with('\n') {
508            output.push('\n');
509        }
510        Ok(output)
511    }
512
513    fn as_any(&self) -> &dyn std::any::Any {
514        self
515    }
516
517    fn default_config_section(&self) -> Option<(String, toml::Value)> {
518        let mut map = toml::map::Map::new();
519        let style_str = match self.config.style {
520            ListItemSpacingStyle::Consistent => "consistent",
521            ListItemSpacingStyle::Loose => "loose",
522            ListItemSpacingStyle::Tight => "tight",
523        };
524        map.insert("style".to_string(), toml::Value::String(style_str.to_string()));
525        map.insert(
526            "allow-loose-continuation".to_string(),
527            toml::Value::Boolean(self.config.allow_loose_continuation),
528        );
529        Some((self.name().to_string(), toml::Value::Table(map)))
530    }
531
532    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
533    where
534        Self: Sized,
535    {
536        let style = crate::config::get_rule_config_value::<String>(config, "MD076", "style")
537            .unwrap_or_else(|| "consistent".to_string());
538        let style = match style.as_str() {
539            "loose" => ListItemSpacingStyle::Loose,
540            "tight" => ListItemSpacingStyle::Tight,
541            _ => ListItemSpacingStyle::Consistent,
542        };
543        let allow_loose_continuation =
544            crate::config::get_rule_config_value::<bool>(config, "MD076", "allow-loose-continuation")
545                .or_else(|| crate::config::get_rule_config_value::<bool>(config, "MD076", "allow_loose_continuation"))
546                .unwrap_or(false);
547        Box::new(Self::new(style).with_allow_loose_continuation(allow_loose_continuation))
548    }
549}
550
551#[cfg(test)]
552mod tests {
553    use super::*;
554
555    fn check(content: &str, style: ListItemSpacingStyle) -> Vec<LintWarning> {
556        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
557        let rule = MD076ListItemSpacing::new(style);
558        rule.check(&ctx).unwrap()
559    }
560
561    fn check_with_continuation(
562        content: &str,
563        style: ListItemSpacingStyle,
564        allow_loose_continuation: bool,
565    ) -> Vec<LintWarning> {
566        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
567        let rule = MD076ListItemSpacing::new(style).with_allow_loose_continuation(allow_loose_continuation);
568        rule.check(&ctx).unwrap()
569    }
570
571    fn fix(content: &str, style: ListItemSpacingStyle) -> String {
572        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
573        let rule = MD076ListItemSpacing::new(style);
574        rule.fix(&ctx).unwrap()
575    }
576
577    fn fix_with_continuation(content: &str, style: ListItemSpacingStyle, allow_loose_continuation: bool) -> String {
578        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
579        let rule = MD076ListItemSpacing::new(style).with_allow_loose_continuation(allow_loose_continuation);
580        rule.fix(&ctx).unwrap()
581    }
582
583    // ── Basic style detection ──────────────────────────────────────────
584
585    #[test]
586    fn tight_list_tight_style_no_warnings() {
587        let content = "- Item 1\n- Item 2\n- Item 3\n";
588        assert!(check(content, ListItemSpacingStyle::Tight).is_empty());
589    }
590
591    #[test]
592    fn loose_list_loose_style_no_warnings() {
593        let content = "- Item 1\n\n- Item 2\n\n- Item 3\n";
594        assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
595    }
596
597    #[test]
598    fn tight_list_loose_style_warns() {
599        let content = "- Item 1\n- Item 2\n- Item 3\n";
600        let warnings = check(content, ListItemSpacingStyle::Loose);
601        assert_eq!(warnings.len(), 2);
602        assert!(warnings.iter().all(|w| w.message.contains("Missing")));
603    }
604
605    #[test]
606    fn loose_list_tight_style_warns() {
607        let content = "- Item 1\n\n- Item 2\n\n- Item 3\n";
608        let warnings = check(content, ListItemSpacingStyle::Tight);
609        assert_eq!(warnings.len(), 2);
610        assert!(warnings.iter().all(|w| w.message.contains("Unexpected")));
611    }
612
613    // ── Consistent mode ────────────────────────────────────────────────
614
615    #[test]
616    fn consistent_all_tight_no_warnings() {
617        let content = "- Item 1\n- Item 2\n- Item 3\n";
618        assert!(check(content, ListItemSpacingStyle::Consistent).is_empty());
619    }
620
621    #[test]
622    fn consistent_all_loose_no_warnings() {
623        let content = "- Item 1\n\n- Item 2\n\n- Item 3\n";
624        assert!(check(content, ListItemSpacingStyle::Consistent).is_empty());
625    }
626
627    #[test]
628    fn consistent_mixed_majority_loose_warns_tight() {
629        // 2 loose gaps, 1 tight gap → tight is minority → warn on tight
630        let content = "- Item 1\n\n- Item 2\n- Item 3\n\n- Item 4\n";
631        let warnings = check(content, ListItemSpacingStyle::Consistent);
632        assert_eq!(warnings.len(), 1);
633        assert!(warnings[0].message.contains("Missing"));
634    }
635
636    #[test]
637    fn consistent_mixed_majority_tight_warns_loose() {
638        // 1 loose gap, 2 tight gaps → loose is minority → warn on loose blank line
639        let content = "- Item 1\n\n- Item 2\n- Item 3\n- Item 4\n";
640        let warnings = check(content, ListItemSpacingStyle::Consistent);
641        assert_eq!(warnings.len(), 1);
642        assert!(warnings[0].message.contains("Unexpected"));
643    }
644
645    #[test]
646    fn consistent_tie_prefers_tight() {
647        // 1 loose + 1 tight gap → tied. Prefer tight: warn on the loose gap
648        // ("Unexpected blank line") so fmt removes the blank rather than
649        // inserting one. See `analyze_block` for the rationale.
650        let content = "- Item 1\n\n- Item 2\n- Item 3\n";
651        let warnings = check(content, ListItemSpacingStyle::Consistent);
652        assert_eq!(warnings.len(), 1);
653        assert!(warnings[0].message.contains("Unexpected"));
654    }
655
656    // ── Edge cases ─────────────────────────────────────────────────────
657
658    #[test]
659    fn single_item_list_no_warnings() {
660        let content = "- Only item\n";
661        assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
662        assert!(check(content, ListItemSpacingStyle::Tight).is_empty());
663        assert!(check(content, ListItemSpacingStyle::Consistent).is_empty());
664    }
665
666    #[test]
667    fn empty_content_no_warnings() {
668        assert!(check("", ListItemSpacingStyle::Consistent).is_empty());
669    }
670
671    #[test]
672    fn ordered_list_tight_gaps_loose_style_warns() {
673        let content = "1. First\n2. Second\n3. Third\n";
674        let warnings = check(content, ListItemSpacingStyle::Loose);
675        assert_eq!(warnings.len(), 2);
676    }
677
678    #[test]
679    fn task_list_works() {
680        let content = "- [x] Task 1\n- [ ] Task 2\n- [x] Task 3\n";
681        let warnings = check(content, ListItemSpacingStyle::Loose);
682        assert_eq!(warnings.len(), 2);
683        let fixed = fix(content, ListItemSpacingStyle::Loose);
684        assert_eq!(fixed, "- [x] Task 1\n\n- [ ] Task 2\n\n- [x] Task 3\n");
685    }
686
687    #[test]
688    fn no_trailing_newline() {
689        let content = "- Item 1\n- Item 2";
690        let warnings = check(content, ListItemSpacingStyle::Loose);
691        assert_eq!(warnings.len(), 1);
692        let fixed = fix(content, ListItemSpacingStyle::Loose);
693        assert_eq!(fixed, "- Item 1\n\n- Item 2");
694    }
695
696    #[test]
697    fn two_separate_lists() {
698        let content = "- A\n- B\n\nText\n\n1. One\n2. Two\n";
699        let warnings = check(content, ListItemSpacingStyle::Loose);
700        assert_eq!(warnings.len(), 2);
701        let fixed = fix(content, ListItemSpacingStyle::Loose);
702        assert_eq!(fixed, "- A\n\n- B\n\nText\n\n1. One\n\n2. Two\n");
703    }
704
705    #[test]
706    fn no_list_content() {
707        let content = "Just a paragraph.\n\nAnother paragraph.\n";
708        assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
709        assert!(check(content, ListItemSpacingStyle::Tight).is_empty());
710    }
711
712    // ── Multi-line and continuation items ──────────────────────────────
713
714    #[test]
715    fn continuation_lines_tight_detected() {
716        let content = "- Item 1\n  continuation\n- Item 2\n";
717        let warnings = check(content, ListItemSpacingStyle::Loose);
718        assert_eq!(warnings.len(), 1);
719        assert!(warnings[0].message.contains("Missing"));
720    }
721
722    #[test]
723    fn continuation_lines_loose_detected() {
724        let content = "- Item 1\n  continuation\n\n- Item 2\n";
725        assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
726        let warnings = check(content, ListItemSpacingStyle::Tight);
727        assert_eq!(warnings.len(), 1);
728        assert!(warnings[0].message.contains("Unexpected"));
729    }
730
731    #[test]
732    fn multi_paragraph_item_not_treated_as_inter_item_gap() {
733        // Blank line between paragraphs within Item 1 must NOT trigger a warning.
734        // Only the blank line immediately before Item 2 is an inter-item separator.
735        let content = "- Item 1\n\n  Second paragraph\n\n- Item 2\n";
736        // Both gaps are loose (blank before Item 2), so tight should warn once
737        let warnings = check(content, ListItemSpacingStyle::Tight);
738        assert_eq!(
739            warnings.len(),
740            1,
741            "Should warn only on the inter-item blank, not the intra-item blank"
742        );
743        // The fix should remove only the inter-item blank (line 4), preserving the
744        // multi-paragraph structure
745        let fixed = fix(content, ListItemSpacingStyle::Tight);
746        assert_eq!(fixed, "- Item 1\n\n  Second paragraph\n- Item 2\n");
747    }
748
749    #[test]
750    fn multi_paragraph_item_loose_style_no_warnings() {
751        // A loose list with multi-paragraph items is already loose — no warnings
752        let content = "- Item 1\n\n  Second paragraph\n\n- Item 2\n";
753        assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
754    }
755
756    // ── Blockquote lists ───────────────────────────────────────────────
757
758    #[test]
759    fn blockquote_tight_list_loose_style_warns() {
760        let content = "> - Item 1\n> - Item 2\n> - Item 3\n";
761        let warnings = check(content, ListItemSpacingStyle::Loose);
762        assert_eq!(warnings.len(), 2);
763    }
764
765    #[test]
766    fn blockquote_loose_list_detected() {
767        // A line with only `>` is effectively blank in blockquote context
768        let content = "> - Item 1\n>\n> - Item 2\n";
769        let warnings = check(content, ListItemSpacingStyle::Tight);
770        assert_eq!(warnings.len(), 1, "Blockquote-only line should be detected as blank");
771        assert!(warnings[0].message.contains("Unexpected"));
772    }
773
774    #[test]
775    fn blockquote_loose_list_no_warnings_when_loose() {
776        let content = "> - Item 1\n>\n> - Item 2\n";
777        assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
778    }
779
780    // ── Multiple blank lines ───────────────────────────────────────────
781
782    #[test]
783    fn multiple_blanks_all_removed() {
784        let content = "- Item 1\n\n\n- Item 2\n";
785        let fixed = fix(content, ListItemSpacingStyle::Tight);
786        assert_eq!(fixed, "- Item 1\n- Item 2\n");
787    }
788
789    #[test]
790    fn multiple_blanks_fix_is_idempotent() {
791        let content = "- Item 1\n\n\n\n- Item 2\n";
792        let fixed_once = fix(content, ListItemSpacingStyle::Tight);
793        let fixed_twice = fix(&fixed_once, ListItemSpacingStyle::Tight);
794        assert_eq!(fixed_once, fixed_twice);
795        assert_eq!(fixed_once, "- Item 1\n- Item 2\n");
796    }
797
798    // ── Fix correctness ────────────────────────────────────────────────
799
800    #[test]
801    fn fix_adds_blank_lines() {
802        let content = "- Item 1\n- Item 2\n- Item 3\n";
803        let fixed = fix(content, ListItemSpacingStyle::Loose);
804        assert_eq!(fixed, "- Item 1\n\n- Item 2\n\n- Item 3\n");
805    }
806
807    #[test]
808    fn fix_removes_blank_lines() {
809        let content = "- Item 1\n\n- Item 2\n\n- Item 3\n";
810        let fixed = fix(content, ListItemSpacingStyle::Tight);
811        assert_eq!(fixed, "- Item 1\n- Item 2\n- Item 3\n");
812    }
813
814    #[test]
815    fn fix_consistent_adds_blank() {
816        // 2 loose gaps, 1 tight gap → add blank before Item 3
817        let content = "- Item 1\n\n- Item 2\n- Item 3\n\n- Item 4\n";
818        let fixed = fix(content, ListItemSpacingStyle::Consistent);
819        assert_eq!(fixed, "- Item 1\n\n- Item 2\n\n- Item 3\n\n- Item 4\n");
820    }
821
822    #[test]
823    fn fix_idempotent_loose() {
824        let content = "- Item 1\n- Item 2\n";
825        let fixed_once = fix(content, ListItemSpacingStyle::Loose);
826        let fixed_twice = fix(&fixed_once, ListItemSpacingStyle::Loose);
827        assert_eq!(fixed_once, fixed_twice);
828    }
829
830    #[test]
831    fn fix_idempotent_tight() {
832        let content = "- Item 1\n\n- Item 2\n";
833        let fixed_once = fix(content, ListItemSpacingStyle::Tight);
834        let fixed_twice = fix(&fixed_once, ListItemSpacingStyle::Tight);
835        assert_eq!(fixed_once, fixed_twice);
836    }
837
838    // ── Nested lists ───────────────────────────────────────────────────
839
840    #[test]
841    fn nested_list_does_not_affect_parent() {
842        // Nested items should not trigger warnings for the parent list
843        let content = "- Item 1\n  - Nested A\n  - Nested B\n- Item 2\n";
844        let warnings = check(content, ListItemSpacingStyle::Tight);
845        assert!(
846            warnings.is_empty(),
847            "Nested items should not cause parent-level warnings"
848        );
849    }
850
851    #[test]
852    fn tab_nested_child_is_not_a_sibling() {
853        // A tab before the child's marker puts it at column 4, one level below
854        // the parent items, so the parent list is `parent` and `next` with no
855        // blank line between them and nothing to report. Measuring the child
856        // in bytes puts it at level 0, the parent's own, and reads the blank
857        // line as a loose gap between siblings.
858        let content = "* parent\n\n\t1. child\n* next\n";
859        let warnings = check(content, ListItemSpacingStyle::Consistent);
860        assert!(
861            warnings.is_empty(),
862            "a tab-nested child is not a sibling of the parent items: {warnings:?}"
863        );
864        assert_eq!(fix(content, ListItemSpacingStyle::Consistent), content);
865
866        // Positive control: the same shape with the child at the parent's
867        // level is a real spacing inconsistency.
868        let sibling = "* parent\n\n* child\n* next\n";
869        let warnings = check(sibling, ListItemSpacingStyle::Consistent);
870        assert_eq!(warnings.len(), 1, "{warnings:?}");
871        assert_eq!(warnings[0].line, 2);
872    }
873
874    #[test]
875    fn nested_list_is_analysed_at_its_own_level() {
876        // The nested list mixes a loose gap and a tight gap while the parent
877        // list is tight, so the inconsistency is the nested list's own: the
878        // tie resolves to tight and the blank line between its first two
879        // items is reported and removed. Space and tab indentation nest the
880        // same way.
881        for (label, content, fixed) in [
882            (
883                "spaces",
884                "- parent\n  - a\n\n  - b\n  - c\n- next\n",
885                "- parent\n  - a\n  - b\n  - c\n- next\n",
886            ),
887            (
888                "tab",
889                "* parent\n\t1. child A\n\n\t2. child B\n\t3. child C\n",
890                "* parent\n\t1. child A\n\t2. child B\n\t3. child C\n",
891            ),
892        ] {
893            let warnings = check(content, ListItemSpacingStyle::Consistent);
894            assert_eq!(warnings.len(), 1, "{label}: {warnings:?}");
895            assert_eq!(warnings[0].line, 3, "{label}: {warnings:?}");
896            assert_eq!(
897                warnings[0].message, "Unexpected blank line between list items",
898                "{label}"
899            );
900            assert_eq!(fix(content, ListItemSpacingStyle::Consistent), fixed, "{label}");
901        }
902
903        // Negative control: a nested list that is uniformly loose under a
904        // tight parent is consistent at both levels.
905        let content = "- parent\n  - a\n\n  - b\n- next\n";
906        let warnings = check(content, ListItemSpacingStyle::Consistent);
907        assert!(warnings.is_empty(), "{warnings:?}");
908        assert_eq!(fix(content, ListItemSpacingStyle::Consistent), content);
909    }
910
911    #[test]
912    fn nested_lists_under_different_parents_are_separate_lists() {
913        // `a1`/`a2` and `b1`/`b2` sit at the same nesting level but belong to
914        // different parent items, so each pair is judged on its own: the
915        // first is consistently tight, the second consistently loose, and a
916        // per-level view that ran them together would call the whole set
917        // inconsistent.
918        let content = "- a\n  - a1\n  - a2\n- b\n  - b1\n\n  - b2\n";
919        let warnings = check(content, ListItemSpacingStyle::Consistent);
920        assert!(warnings.is_empty(), "{warnings:?}");
921        assert_eq!(fix(content, ListItemSpacingStyle::Consistent), content);
922
923        // Positive control: the same two pairs under one parent are one list
924        // and its gaps do disagree.
925        let content = "- a\n  - a1\n  - a2\n  - b1\n\n  - b2\n";
926        let warnings = check(content, ListItemSpacingStyle::Consistent);
927        assert_eq!(warnings.len(), 1, "{warnings:?}");
928        assert_eq!(warnings[0].line, 5);
929    }
930
931    #[test]
932    fn every_warning_carries_the_edit_the_fix_applies() {
933        // A warning without an edit reads as unfixable to `check`, counts as
934        // not fixed after `fmt`, and offers no quick fix in an editor. Each
935        // MD076 warning carries its own edit, and applying the edits alone
936        // produces what the document-level fix produces: a removed gap loses
937        // its whole run of blank lines, an inserted blank line carries the
938        // item's blockquote prefix, and line endings are kept.
939        let cases = [
940            ("- a\n\n\n- b\n- c\n", ListItemSpacingStyle::Consistent),
941            ("- a\n- b\n\n- c\n", ListItemSpacingStyle::Loose),
942            ("> - a\n>\n> - b\n> - c\n", ListItemSpacingStyle::Consistent),
943            ("> - a\n> - b\n>\n> - c\n", ListItemSpacingStyle::Loose),
944            ("- p\n  - a\n\n  - b\n  - c\n", ListItemSpacingStyle::Consistent),
945        ];
946        for (content, style) in cases {
947            let warnings = check(content, style.clone());
948            assert!(!warnings.is_empty(), "{content:?}");
949            assert!(warnings.iter().all(|w| w.fix.is_some()), "{content:?}: {warnings:?}");
950            let applied = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
951            let fixed = fix(content, style);
952            assert_eq!(applied, fixed, "{content:?}");
953            assert_ne!(applied, content, "{content:?}");
954        }
955
956        // The edits are byte ranges into the document as written, and an
957        // inserted line ends the way the document's lines do, so a CRLF
958        // document keeps its line endings. The editor applies each edit as
959        // given, so the replacement itself is checked, not only the result of
960        // `apply_warning_fixes`, which restores the document's endings.
961        let content = "- a\r\n\r\n- b\r\n- c\r\n";
962        let warnings = check(content, ListItemSpacingStyle::Consistent);
963        assert_eq!(
964            crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap(),
965            "- a\r\n- b\r\n- c\r\n"
966        );
967        for (content, replacement) in [("- a\r\n- b\r\n\r\n- c\r\n", "\r\n"), ("> - a\r\n> - b\r\n", ">\r\n")] {
968            let warnings = check(content, ListItemSpacingStyle::Loose);
969            assert_eq!(warnings.len(), 1, "{content:?}: {warnings:?}");
970            let fix = warnings[0].fix.as_ref().expect("the warning carries its edit");
971            assert_eq!(fix.replacement, replacement, "{content:?}");
972            assert_eq!(fix.range.start, fix.range.end, "{content:?}: an insertion");
973        }
974        let content = "- a\r\n- b\r\n\r\n- c\r\n";
975        let warnings = check(content, ListItemSpacingStyle::Loose);
976        assert_eq!(
977            crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap(),
978            "- a\r\n\r\n- b\r\n\r\n- c\r\n"
979        );
980    }
981
982    #[test]
983    fn nested_lists_separated_by_parent_content_are_separate_lists() {
984        // A paragraph belonging to the parent item ends the nested list, so
985        // the tight pair before it and the loose pair after it are two lists,
986        // each consistent on its own; the gap around the paragraph is nobody's
987        // inter-item gap.
988        // The same holds for parent content that interrupts a paragraph
989        // without a blank line before it: an HTML comment, or a blockquote
990        // (a bare `>` opens one; under an unquoted list it is not a blank
991        // line, and inside a quoted list a deeper `>` is not one either).
992        for content in [
993            "- p\n  - a\n  - b\n\n  With:\n\n  - c\n\n  - d\n",
994            "- p\n  - a\n  - b\n  <!-- parent comment -->\n  - c\n\n  - d\n",
995            "- p\n  - a\n  - b\n  >\n  - c\n\n  - d\n",
996            "> - p\n>   - a\n>   - b\n>   >\n>   - c\n>\n>   - d\n",
997            "- p\n  - a\n    >\n  parent\n  - c\n\n  - d\n",
998            "- p\n  - a\n  - ```\n  more\n  - c\n\n  - d\n",
999            "- p\n  - a\n  - | h |\n    | --- |\n  more\n  - c\n\n  - d\n",
1000        ] {
1001            let warnings = check(content, ListItemSpacingStyle::Consistent);
1002            assert!(warnings.is_empty(), "{content:?}: {warnings:?}");
1003            assert_eq!(fix(content, ListItemSpacingStyle::Consistent), content, "{content:?}");
1004        }
1005
1006        // Positive controls: without the paragraph the four items are one
1007        // list whose gaps disagree, and so are they when the dedented line
1008        // continues the paragraph an item's text opened (a backtick fence
1009        // whose info string holds a backtick is text, not a fence).
1010        for (content, line, message) in [
1011            (
1012                "- p\n  - a\n  - b\n\n  - c\n\n  - d\n",
1013                3,
1014                "Missing blank line between list items",
1015            ),
1016            (
1017                "- p\n  - a\n  - ```lang`bad\n  more\n  - c\n\n  - d\n",
1018                6,
1019                "Unexpected blank line between list items",
1020            ),
1021        ] {
1022            let warnings = check(content, ListItemSpacingStyle::Consistent);
1023            assert_eq!(warnings.len(), 1, "{content:?}: {warnings:?}");
1024            assert_eq!(warnings[0].line, line, "{content:?}");
1025            assert_eq!(warnings[0].message, message, "{content:?}");
1026        }
1027    }
1028
1029    #[test]
1030    fn lists_of_different_marker_types_are_separate_lists() {
1031        // A bullet list followed by an ordered list, or one bullet character
1032        // followed by another, is two lists at that level, so a tight pair
1033        // and a loose pair next to each other are each consistent and the
1034        // blank line between the second pair stays. Nested or not.
1035        for content in [
1036            "- parent\n  - bullet a\n  - bullet b\n  1. ordered a\n\n  2. ordered b\n- next\n",
1037            "- parent\n  - dash a\n  - dash b\n  * star a\n\n  * star b\n- next\n",
1038            "- parent\n  1. dot a\n  2. dot b\n  1) paren a\n\n  2) paren b\n- next\n",
1039            "- dash a\n- dash b\n* star a\n\n* star b\n",
1040        ] {
1041            let warnings = check(content, ListItemSpacingStyle::Consistent);
1042            assert!(warnings.is_empty(), "{content:?}: {warnings:?}");
1043            assert_eq!(fix(content, ListItemSpacingStyle::Consistent), content, "{content:?}");
1044        }
1045
1046        // Positive controls: with one marker type throughout, the four items
1047        // are one list whose gaps disagree, and the blank line goes.
1048        for (content, line) in [
1049            ("- parent\n  - a\n  - b\n  - c\n\n  - d\n- next\n", 5),
1050            ("- parent\n  1. a\n  2. b\n  3. c\n\n  4. d\n- next\n", 5),
1051            ("- a\n- b\n- c\n\n- d\n", 4),
1052        ] {
1053            let warnings = check(content, ListItemSpacingStyle::Consistent);
1054            assert_eq!(warnings.len(), 1, "{content:?}: {warnings:?}");
1055            assert_eq!(warnings[0].line, line, "{content:?}");
1056            assert_eq!(
1057                warnings[0].message, "Unexpected blank line between list items",
1058                "{content:?}"
1059            );
1060        }
1061    }
1062
1063    #[test]
1064    fn siblings_at_a_different_indent_are_not_the_nested_list() {
1065        // The siblings at column 2 sit left of the parent's content column, so
1066        // they continue the outer list, which is loose throughout; the child
1067        // list at column 3 is tight throughout. Neither is reported, and the
1068        // fix leaves the child list tight.
1069        let content = " - parent\n   - child a\n   - child b\n\n  - sibling a\n\n  - sibling b\n";
1070        let warnings = check(content, ListItemSpacingStyle::Consistent);
1071        assert!(warnings.is_empty(), "{warnings:?}");
1072        assert_eq!(fix(content, ListItemSpacingStyle::Consistent), content);
1073
1074        // Inside a blockquote the columns count from the quote's content, so
1075        // an indent before the `>` does not make the child list a sibling of
1076        // its parent: the loose child list is consistent on its own.
1077        let content = " > - parent\n>   - child a\n>\n>   - child b\n";
1078        let warnings = check(content, ListItemSpacingStyle::Consistent);
1079        assert!(warnings.is_empty(), "{warnings:?}");
1080        assert_eq!(fix(content, ListItemSpacingStyle::Consistent), content);
1081
1082        // Positive controls: an inconsistent gap in either list is reported
1083        // against that list, on the blank line that makes it loose.
1084        for (content, line, message) in [
1085            (
1086                " - parent\n   - child a\n   - child b\n\n  - sibling a\n  - sibling b\n",
1087                4,
1088                "Unexpected blank line between list items",
1089            ),
1090            (
1091                " - parent\n   - child a\n\n   - child b\n   - child c\n  - sibling\n",
1092                3,
1093                "Unexpected blank line between list items",
1094            ),
1095            (
1096                " > - parent\n>   - child a\n>\n>   - child b\n>   - child c\n",
1097                3,
1098                "Unexpected blank line between list items",
1099            ),
1100        ] {
1101            let warnings = check(content, ListItemSpacingStyle::Consistent);
1102            assert_eq!(warnings.len(), 1, "{content:?}: {warnings:?}");
1103            assert_eq!(warnings[0].line, line, "{content:?}");
1104            assert_eq!(warnings[0].message, message, "{content:?}");
1105        }
1106    }
1107
1108    #[test]
1109    fn lists_in_different_blockquotes_are_different_lists() {
1110        // A blockquote inside a list item holds its own list; a `>` left of
1111        // the item's content starts another blockquote outside the item, and
1112        // a blank line ends a blockquote, so the items after either are a
1113        // different list. Each list here is consistent on its own, and the
1114        // fix must not remove a blank line that separates two blockquotes.
1115        for content in [
1116            "- p\n  >- b1\n  >\n  >- b2\n>- c\n>- d\n",
1117            "- p\n  > - b1\n  > - b2\n\n  > - b3\n",
1118            "> - a\n>   - b\n>   - c\n\n>   - d\n",
1119        ] {
1120            let warnings = check(content, ListItemSpacingStyle::Consistent);
1121            assert!(warnings.is_empty(), "{content:?}: {warnings:?}");
1122            assert_eq!(fix(content, ListItemSpacingStyle::Consistent), content);
1123        }
1124
1125        // Positive controls: a bare `>` at the list's own depth is the gap
1126        // between its items, whether or not another blockquote follows.
1127        for (content, line, message) in [
1128            (
1129                "- p\n  > - b1\n  > - b2\n  >\n  > - b3\n",
1130                4,
1131                "Unexpected blank line between list items",
1132            ),
1133            (
1134                "- p\n  >- b1\n  >- b2\n  >\n  >- b3\n>- c\n>- d\n",
1135                4,
1136                "Unexpected blank line between list items",
1137            ),
1138        ] {
1139            let warnings = check(content, ListItemSpacingStyle::Consistent);
1140            assert_eq!(warnings.len(), 1, "{content:?}: {warnings:?}");
1141            assert_eq!(warnings[0].line, line, "{content:?}");
1142            assert_eq!(warnings[0].message, message, "{content:?}");
1143        }
1144    }
1145
1146    #[test]
1147    fn a_line_that_ends_the_top_level_list_starts_another_after_it() {
1148        // A fence or HTML block at column 0, or a blank line that ends a
1149        // blockquote, closes the top-level list as well as a nested one. The
1150        // items after it are another list, so there is no gap to judge
1151        // between the two, and a blank line between two blockquotes is not
1152        // list spacing the fix may remove.
1153        for (content, style) in [
1154            ("- p\n```\n```\n- q\n", ListItemSpacingStyle::Loose),
1155            ("- p\n<!-- x -->\n- q\n", ListItemSpacingStyle::Loose),
1156            ("> - a\n> - b\n\n> - c\n", ListItemSpacingStyle::Consistent),
1157            ("> - a\n> - b\n\n> - c\n", ListItemSpacingStyle::Tight),
1158        ] {
1159            let warnings = check(content, style.clone());
1160            assert!(warnings.is_empty(), "{content:?}: {warnings:?}");
1161            assert_eq!(fix(content, style), content);
1162        }
1163
1164        // The blockquote after the fence is not inside `p`, so the quoted
1165        // items are one list and `loose` wants a blank line between them.
1166        let content = "- p\n```\n```\n  > - b\n  lazy\n> - c\n";
1167        let warnings = check(content, ListItemSpacingStyle::Loose);
1168        assert_eq!(warnings.len(), 1, "{warnings:?}");
1169        assert_eq!(warnings[0].line, 6);
1170        assert_eq!(warnings[0].message, "Missing blank line between list items");
1171        assert_eq!(
1172            fix(content, ListItemSpacingStyle::Loose),
1173            "- p\n```\n```\n  > - b\n  lazy\n>\n> - c\n"
1174        );
1175
1176        // Positive control: a bare `>` inside one blockquote is list spacing.
1177        let content = "> - a\n>\n> - b\n> - c\n";
1178        let warnings = check(content, ListItemSpacingStyle::Consistent);
1179        assert_eq!(warnings.len(), 1, "{warnings:?}");
1180        assert_eq!(warnings[0].line, 2);
1181        assert_eq!(warnings[0].message, "Unexpected blank line between list items");
1182    }
1183
1184    #[test]
1185    fn explicit_style_applies_to_nested_lists() {
1186        // `loose` wants a blank line between the nested items too, and the
1187        // fix inserts it there; the parent gap is already loose.
1188        let content = "- a\n\n- b\n  - b1\n  - b2\n";
1189        let warnings = check(content, ListItemSpacingStyle::Loose);
1190        assert_eq!(warnings.len(), 1, "{warnings:?}");
1191        assert_eq!(warnings[0].line, 5);
1192        assert_eq!(warnings[0].message, "Missing blank line between list items");
1193        assert_eq!(
1194            fix(content, ListItemSpacingStyle::Loose),
1195            "- a\n\n- b\n  - b1\n\n  - b2\n"
1196        );
1197
1198        // `tight` removes the blank line between nested items and leaves a
1199        // tight parent alone.
1200        let content = "- a\n- b\n  - b1\n\n  - b2\n";
1201        let warnings = check(content, ListItemSpacingStyle::Tight);
1202        assert_eq!(warnings.len(), 1, "{warnings:?}");
1203        assert_eq!(warnings[0].line, 4);
1204        assert_eq!(fix(content, ListItemSpacingStyle::Tight), "- a\n- b\n  - b1\n  - b2\n");
1205    }
1206
1207    // ── Structural blank lines (code blocks, tables, HTML) ──────────
1208
1209    #[test]
1210    fn code_block_in_tight_list_no_false_positive() {
1211        // Blank line after closing fence is structural (required by MD031), not a separator
1212        let content = "\
1213- Item 1 with code:
1214
1215  ```python
1216  print('hello')
1217  ```
1218
1219- Item 2 simple.
1220- Item 3 simple.
1221";
1222        assert!(
1223            check(content, ListItemSpacingStyle::Consistent).is_empty(),
1224            "Structural blank after code block should not make item 1 appear loose"
1225        );
1226    }
1227
1228    #[test]
1229    fn table_in_tight_list_no_false_positive() {
1230        // Blank line after table is structural (required by MD058), not a separator
1231        let content = "\
1232- Item 1 with table:
1233
1234  | Col 1 | Col 2 |
1235  |-------|-------|
1236  | A     | B     |
1237
1238- Item 2 simple.
1239- Item 3 simple.
1240";
1241        assert!(
1242            check(content, ListItemSpacingStyle::Consistent).is_empty(),
1243            "Structural blank after table should not make item 1 appear loose"
1244        );
1245    }
1246
1247    #[test]
1248    fn html_block_in_tight_list_no_false_positive() {
1249        let content = "\
1250- Item 1 with HTML:
1251
1252  <details>
1253  <summary>Click</summary>
1254  Content
1255  </details>
1256
1257- Item 2 simple.
1258- Item 3 simple.
1259";
1260        assert!(
1261            check(content, ListItemSpacingStyle::Consistent).is_empty(),
1262            "Structural blank after HTML block should not make item 1 appear loose"
1263        );
1264    }
1265
1266    #[test]
1267    fn blockquote_in_tight_list_no_false_positive() {
1268        // Blank line around a blockquote in a list item is structural, not a separator
1269        let content = "\
1270- Item 1 with quote:
1271
1272  > This is a blockquote
1273  > with multiple lines.
1274
1275- Item 2 simple.
1276- Item 3 simple.
1277";
1278        assert!(
1279            check(content, ListItemSpacingStyle::Consistent).is_empty(),
1280            "Structural blank around blockquote should not make item 1 appear loose"
1281        );
1282        assert!(
1283            check(content, ListItemSpacingStyle::Tight).is_empty(),
1284            "Blockquote in tight list should not trigger a violation"
1285        );
1286    }
1287
1288    #[test]
1289    fn blockquote_multiple_items_with_quotes_tight() {
1290        // Multiple items with blockquotes should all be treated as structural
1291        let content = "\
1292- Item 1:
1293
1294  > Quote A
1295
1296- Item 2:
1297
1298  > Quote B
1299
1300- Item 3 plain.
1301";
1302        assert!(
1303            check(content, ListItemSpacingStyle::Tight).is_empty(),
1304            "Multiple items with blockquotes should remain tight"
1305        );
1306    }
1307
1308    #[test]
1309    fn blockquote_mixed_with_genuine_loose_gap() {
1310        // A blockquote item followed by a genuine loose gap should still be detected
1311        let content = "\
1312- Item 1:
1313
1314  > Quote
1315
1316- Item 2 plain.
1317
1318- Item 3 plain.
1319";
1320        let warnings = check(content, ListItemSpacingStyle::Tight);
1321        assert!(
1322            !warnings.is_empty(),
1323            "Genuine loose gap between Item 2 and Item 3 should be flagged"
1324        );
1325    }
1326
1327    #[test]
1328    fn blockquote_single_line_in_tight_list() {
1329        let content = "\
1330- Item 1:
1331
1332  > Single line quote.
1333
1334- Item 2.
1335- Item 3.
1336";
1337        assert!(
1338            check(content, ListItemSpacingStyle::Tight).is_empty(),
1339            "Single-line blockquote should be structural"
1340        );
1341    }
1342
1343    #[test]
1344    fn blockquote_in_ordered_list_tight() {
1345        let content = "\
13461. Item 1:
1347
1348   > Quoted text in ordered list.
1349
13501. Item 2.
13511. Item 3.
1352";
1353        assert!(
1354            check(content, ListItemSpacingStyle::Tight).is_empty(),
1355            "Blockquote in ordered list should be structural"
1356        );
1357    }
1358
1359    #[test]
1360    fn nested_blockquote_in_tight_list() {
1361        let content = "\
1362- Item 1:
1363
1364  > Outer quote
1365  > > Nested quote
1366
1367- Item 2.
1368- Item 3.
1369";
1370        assert!(
1371            check(content, ListItemSpacingStyle::Tight).is_empty(),
1372            "Nested blockquote in tight list should be structural"
1373        );
1374    }
1375
1376    #[test]
1377    fn blockquote_as_entire_item_is_loose() {
1378        // When a blockquote IS the item content (not nested within text),
1379        // a trailing blank line is a genuine loose gap, not structural.
1380        let content = "\
1381- > Quote is the entire item content.
1382
1383- Item 2.
1384- Item 3.
1385";
1386        let warnings = check(content, ListItemSpacingStyle::Tight);
1387        assert!(
1388            !warnings.is_empty(),
1389            "Blank after blockquote-only item is a genuine loose gap"
1390        );
1391    }
1392
1393    #[test]
1394    fn mixed_code_and_table_in_tight_list() {
1395        let content = "\
13961. Item with code:
1397
1398   ```markdown
1399   This is some Markdown
1400   ```
1401
14021. Simple item.
14031. Item with table:
1404
1405   | Col 1 | Col 2 |
1406   |:------|:------|
1407   | Row 1 | Row 1 |
1408   | Row 2 | Row 2 |
1409";
1410        assert!(
1411            check(content, ListItemSpacingStyle::Consistent).is_empty(),
1412            "Mix of code blocks and tables should not cause false positives"
1413        );
1414    }
1415
1416    #[test]
1417    fn code_block_with_genuinely_loose_gaps_still_warns() {
1418        // Item 1 has structural blank (code block), items 2-3 have genuine blank separator
1419        // Items 2-3 are genuinely loose, item 3-4 is tight → inconsistent
1420        let content = "\
1421- Item 1:
1422
1423  ```bash
1424  echo hi
1425  ```
1426
1427- Item 2
1428
1429- Item 3
1430- Item 4
1431";
1432        let warnings = check(content, ListItemSpacingStyle::Consistent);
1433        assert!(
1434            !warnings.is_empty(),
1435            "Genuine inconsistency with code blocks should still be flagged"
1436        );
1437    }
1438
1439    #[test]
1440    fn all_items_have_code_blocks_no_warnings() {
1441        let content = "\
1442- Item 1:
1443
1444  ```python
1445  print(1)
1446  ```
1447
1448- Item 2:
1449
1450  ```python
1451  print(2)
1452  ```
1453
1454- Item 3:
1455
1456  ```python
1457  print(3)
1458  ```
1459";
1460        assert!(
1461            check(content, ListItemSpacingStyle::Consistent).is_empty(),
1462            "All items with code blocks should be consistently tight"
1463        );
1464    }
1465
1466    #[test]
1467    fn tilde_fence_code_block_in_list() {
1468        let content = "\
1469- Item 1:
1470
1471  ~~~
1472  code here
1473  ~~~
1474
1475- Item 2 simple.
1476- Item 3 simple.
1477";
1478        assert!(
1479            check(content, ListItemSpacingStyle::Consistent).is_empty(),
1480            "Tilde fences should be recognized as structural content"
1481        );
1482    }
1483
1484    #[test]
1485    fn nested_list_with_code_block() {
1486        let content = "\
1487- Item 1
1488  - Nested with code:
1489
1490    ```
1491    nested code
1492    ```
1493
1494  - Nested simple.
1495- Item 2
1496";
1497        assert!(
1498            check(content, ListItemSpacingStyle::Consistent).is_empty(),
1499            "Nested list with code block should not cause false positives"
1500        );
1501    }
1502
1503    #[test]
1504    fn tight_style_with_code_block_no_warnings() {
1505        let content = "\
1506- Item 1:
1507
1508  ```
1509  code
1510  ```
1511
1512- Item 2.
1513- Item 3.
1514";
1515        assert!(
1516            check(content, ListItemSpacingStyle::Tight).is_empty(),
1517            "Tight style should not warn about structural blanks around code blocks"
1518        );
1519    }
1520
1521    #[test]
1522    fn loose_style_with_code_block_missing_separator() {
1523        // Loose style requires blank line between every pair of items.
1524        // Items 2-3 have no blank → should warn
1525        let content = "\
1526- Item 1:
1527
1528  ```
1529  code
1530  ```
1531
1532- Item 2.
1533- Item 3.
1534";
1535        let warnings = check(content, ListItemSpacingStyle::Loose);
1536        assert_eq!(
1537            warnings.len(),
1538            1,
1539            "Loose style should still require blank between simple items"
1540        );
1541        assert!(warnings[0].message.contains("Missing"));
1542    }
1543
1544    #[test]
1545    fn blockquote_list_with_code_block() {
1546        let content = "\
1547> - Item 1:
1548>
1549>   ```
1550>   code
1551>   ```
1552>
1553> - Item 2.
1554> - Item 3.
1555";
1556        assert!(
1557            check(content, ListItemSpacingStyle::Consistent).is_empty(),
1558            "Blockquote-prefixed list with code block should not cause false positives"
1559        );
1560    }
1561
1562    // ── Indented code block (not fenced) in list item ─────────────────
1563
1564    #[test]
1565    fn indented_code_block_in_list_no_false_positive() {
1566        // A 4-space indented code block inside a list item should be treated
1567        // as structural content, not trigger a loose gap detection.
1568        let content = "\
15691. Item with indented code:
1570
1571       some code here
1572       more code
1573
15741. Simple item
15751. Another item
1576";
1577        assert!(
1578            check(content, ListItemSpacingStyle::Consistent).is_empty(),
1579            "Structural blank after indented code block should not make item 1 appear loose"
1580        );
1581    }
1582
1583    // ── Issue #787: the marker-line exemption ends where MD031 does ───
1584
1585    #[test]
1586    fn fence_on_marker_line_keeps_its_structural_blank() {
1587        // A fence opened on the marker line itself needs the blank line above it
1588        // (MD031), so tight mode must not remove it. One to four spaces after the
1589        // marker all leave the fence at the item's content column, so all four are
1590        // genuine fenced blocks.
1591        for spaces in 1..=4 {
1592            let pad = " ".repeat(spaces);
1593            let indent = " ".repeat(spaces + 1);
1594            let content = format!("- a\n\n-{pad}```\n{indent}code\n{indent}```\n- c\n");
1595            assert!(
1596                check(&content, ListItemSpacingStyle::Tight).is_empty(),
1597                "a fence on the marker line with {spaces} space(s) opens a fenced block, so its blank is structural"
1598            );
1599            assert_eq!(
1600                fix(&content, ListItemSpacingStyle::Tight),
1601                content,
1602                "tight fix must keep the blank MD031 requires ({spaces} space(s))"
1603            );
1604        }
1605    }
1606
1607    #[test]
1608    fn over_indented_fence_on_marker_line_is_an_indented_block_not_an_exemption() {
1609        // Five spaces after the marker put the content column at 2, leaving the
1610        // fence at a relative indent of 4: an *indented* code block, which MD031
1611        // says nothing about. The blank above it is an ordinary loose separator and
1612        // tight mode must still remove it.
1613        for fence in ["```", "~~~"] {
1614            let content = format!("- a\n\n-     {fence}\n      code\n      {fence}\n- c\n");
1615            let warnings = check(&content, ListItemSpacingStyle::Tight);
1616            assert_eq!(
1617                warnings.len(),
1618                1,
1619                "no fenced block starts here, so the blank is a loose gap ({fence}): {warnings:?}"
1620            );
1621            assert_eq!(
1622                fix(&content, ListItemSpacingStyle::Tight),
1623                format!("- a\n-     {fence}\n      code\n      {fence}\n- c\n"),
1624                "tight fix must remove a blank that MD031 does not require ({fence})"
1625            );
1626        }
1627    }
1628
1629    // ── Code block in middle of item with text after ────────────────
1630
1631    #[test]
1632    fn code_block_in_middle_of_item_text_after_is_genuinely_loose() {
1633        // When a code block is in the middle of an item and there's regular text
1634        // after it, a blank line before the next item IS a genuine separator (loose),
1635        // not structural. The last non-blank line before item 2 is "Some text after
1636        // the code block." which is NOT structural content.
1637        let content = "\
16381. Item with code in middle:
1639
1640   ```
1641   code
1642   ```
1643
1644   Some text after the code block.
1645
16461. Simple item
16471. Another item
1648";
1649        let warnings = check(content, ListItemSpacingStyle::Consistent);
1650        assert!(
1651            !warnings.is_empty(),
1652            "Blank line after regular text (not structural content) is a genuine loose gap"
1653        );
1654    }
1655
1656    // ── Fix: tight mode preserves structural blanks ──────────────────
1657
1658    #[test]
1659    fn tight_fix_preserves_structural_blanks_around_code_blocks() {
1660        // When style is tight, the fix should NOT remove structural blank lines
1661        // around code blocks inside list items. Those blanks are required by MD031.
1662        let content = "\
1663- Item 1:
1664
1665  ```
1666  code
1667  ```
1668
1669- Item 2.
1670- Item 3.
1671";
1672        let fixed = fix(content, ListItemSpacingStyle::Tight);
1673        assert_eq!(
1674            fixed, content,
1675            "Tight fix should not remove structural blanks around code blocks"
1676        );
1677    }
1678
1679    // ── Issue #461: 4-space indented code block in loose list ──────────
1680
1681    #[test]
1682    fn four_space_indented_fence_in_loose_list_no_false_positive() {
1683        // Reproduction case from issue #461 comment by @sisp.
1684        // The fenced code block uses 4-space indentation inside an ordered list.
1685        // The blank line after the closing fence is structural (required by MD031)
1686        // and must not create a false "Missing blank line" warning.
1687        let content = "\
16881. First item
1689
16901. Second item with code block:
1691
1692    ```json
1693    {\"key\": \"value\"}
1694    ```
1695
16961. Third item
1697";
1698        assert!(
1699            check(content, ListItemSpacingStyle::Consistent).is_empty(),
1700            "Structural blank after 4-space indented code block should not cause false positive"
1701        );
1702    }
1703
1704    #[test]
1705    fn four_space_indented_fence_tight_style_no_warnings() {
1706        let content = "\
17071. First item
17081. Second item with code block:
1709
1710    ```json
1711    {\"key\": \"value\"}
1712    ```
1713
17141. Third item
1715";
1716        assert!(
1717            check(content, ListItemSpacingStyle::Tight).is_empty(),
1718            "Tight style should not warn about structural blanks with 4-space fences"
1719        );
1720    }
1721
1722    #[test]
1723    fn four_space_indented_fence_loose_style_no_warnings() {
1724        // All non-structural gaps are loose, structural gaps are excluded.
1725        let content = "\
17261. First item
1727
17281. Second item with code block:
1729
1730    ```json
1731    {\"key\": \"value\"}
1732    ```
1733
17341. Third item
1735";
1736        assert!(
1737            check(content, ListItemSpacingStyle::Loose).is_empty(),
1738            "Loose style should not warn when structural gaps are the only non-loose gaps"
1739        );
1740    }
1741
1742    #[test]
1743    fn structural_gap_with_genuine_inconsistency_still_warns() {
1744        // Item 1 has a structural code block. Items 2-3 are genuinely loose,
1745        // but items 3-4 are tight → genuine inconsistency should still warn.
1746        let content = "\
17471. First item with code:
1748
1749    ```json
1750    {\"key\": \"value\"}
1751    ```
1752
17531. Second item
1754
17551. Third item
17561. Fourth item
1757";
1758        let warnings = check(content, ListItemSpacingStyle::Consistent);
1759        assert!(
1760            !warnings.is_empty(),
1761            "Genuine loose/tight inconsistency should still warn even with structural gaps"
1762        );
1763    }
1764
1765    #[test]
1766    fn four_space_fence_fix_is_idempotent() {
1767        // Fix should not modify a list that has only structural gaps and
1768        // genuine loose gaps — it's already consistent.
1769        let content = "\
17701. First item
1771
17721. Second item with code block:
1773
1774    ```json
1775    {\"key\": \"value\"}
1776    ```
1777
17781. Third item
1779";
1780        let fixed = fix(content, ListItemSpacingStyle::Consistent);
1781        assert_eq!(fixed, content, "Fix should be a no-op for lists with structural gaps");
1782        let fixed_twice = fix(&fixed, ListItemSpacingStyle::Consistent);
1783        assert_eq!(fixed, fixed_twice, "Fix should be idempotent");
1784    }
1785
1786    #[test]
1787    fn four_space_fence_fix_does_not_insert_duplicate_blank() {
1788        // When tight style tries to fix, it should not insert a blank line
1789        // before item 3 when one already exists (structural).
1790        let content = "\
17911. First item
17921. Second item with code block:
1793
1794    ```json
1795    {\"key\": \"value\"}
1796    ```
1797
17981. Third item
1799";
1800        let fixed = fix(content, ListItemSpacingStyle::Tight);
1801        assert_eq!(fixed, content, "Tight fix should not modify structural blanks");
1802    }
1803
1804    #[test]
1805    fn mkdocs_flavor_code_block_in_list_no_false_positive() {
1806        // MkDocs flavor with code block inside a list item.
1807        // Reported by @sisp in issue #461 comment.
1808        let content = "\
18091. First item
1810
18111. Second item with code block:
1812
1813    ```json
1814    {\"key\": \"value\"}
1815    ```
1816
18171. Third item
1818";
1819        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1820        let rule = MD076ListItemSpacing::new(ListItemSpacingStyle::Consistent);
1821        let warnings = rule.check(&ctx).unwrap();
1822        assert!(
1823            warnings.is_empty(),
1824            "MkDocs flavor with structural code block blank should not produce false positive, got: {warnings:?}"
1825        );
1826    }
1827
1828    // ── Issue #500: code block inside list item splits list blocks ─────
1829
1830    #[test]
1831    fn code_block_in_second_item_detects_inconsistency() {
1832        // A code block inside item 2 must not split the list into separate blocks.
1833        // Items 1-2 are tight, items 3-4 are loose → inconsistent.
1834        let content = "\
1835# Test
1836
1837- Lorem ipsum dolor sit amet.
1838- Lorem ipsum dolor sit amet.
1839
1840    ```yaml
1841    hello: world
1842    ```
1843
1844- Lorem ipsum dolor sit amet.
1845
1846- Lorem ipsum dolor sit amet.
1847";
1848        let warnings = check(content, ListItemSpacingStyle::Consistent);
1849        assert!(
1850            !warnings.is_empty(),
1851            "Should detect inconsistent spacing when code block is inside a list item"
1852        );
1853    }
1854
1855    #[test]
1856    fn code_block_in_item_all_tight_no_warnings() {
1857        // All non-structural gaps are tight → consistent, no warnings.
1858        let content = "\
1859- Item 1
1860- Item 2
1861
1862    ```yaml
1863    hello: world
1864    ```
1865
1866- Item 3
1867- Item 4
1868";
1869        assert!(
1870            check(content, ListItemSpacingStyle::Consistent).is_empty(),
1871            "All tight gaps with structural code block should not warn"
1872        );
1873    }
1874
1875    #[test]
1876    fn code_block_in_item_all_loose_no_warnings() {
1877        // All non-structural gaps are loose → consistent, no warnings.
1878        let content = "\
1879- Item 1
1880
1881- Item 2
1882
1883    ```yaml
1884    hello: world
1885    ```
1886
1887- Item 3
1888
1889- Item 4
1890";
1891        assert!(
1892            check(content, ListItemSpacingStyle::Consistent).is_empty(),
1893            "All loose gaps with structural code block should not warn"
1894        );
1895    }
1896
1897    #[test]
1898    fn code_block_in_ordered_list_detects_inconsistency() {
1899        let content = "\
19001. First item
19011. Second item
1902
1903    ```json
1904    {\"key\": \"value\"}
1905    ```
1906
19071. Third item
1908
19091. Fourth item
1910";
1911        let warnings = check(content, ListItemSpacingStyle::Consistent);
1912        assert!(
1913            !warnings.is_empty(),
1914            "Ordered list with code block should still detect inconsistency"
1915        );
1916    }
1917
1918    #[test]
1919    fn code_block_in_item_fix_removes_loose_outlier_on_tie() {
1920        // Gap classification: 1→2 tight, 2→3 structural (excluded — fenced
1921        // code block in the body of item 2), 3→4 loose. After excluding the
1922        // structural gap, that's a 1 tight / 1 loose tie. The tight
1923        // tie-breaker (analyze_block) warns the loose gap, so fix removes the
1924        // blank between items 3 and 4 rather than adding one between 1 and 2.
1925        let content = "\
1926- Item 1
1927- Item 2
1928
1929    ```yaml
1930    code: here
1931    ```
1932
1933- Item 3
1934
1935- Item 4
1936";
1937        let fixed = fix(content, ListItemSpacingStyle::Consistent);
1938        assert!(
1939            fixed.contains("- Item 3\n- Item 4"),
1940            "Fix should remove blank line between items 3 and 4. Got:\n{fixed}"
1941        );
1942        assert!(
1943            !fixed.contains("- Item 1\n\n- Item 2"),
1944            "Fix should not insert a blank between items 1 and 2. Got:\n{fixed}"
1945        );
1946    }
1947
1948    #[test]
1949    fn tilde_code_block_in_item_detects_inconsistency() {
1950        let content = "\
1951- Item 1
1952- Item 2
1953
1954    ~~~
1955    code
1956    ~~~
1957
1958- Item 3
1959
1960- Item 4
1961";
1962        let warnings = check(content, ListItemSpacingStyle::Consistent);
1963        assert!(
1964            !warnings.is_empty(),
1965            "Tilde code block inside item should not prevent inconsistency detection"
1966        );
1967    }
1968
1969    #[test]
1970    fn multiple_code_blocks_all_tight_no_warnings() {
1971        // All non-structural gaps are tight → consistent.
1972        let content = "\
1973- Item 1
1974
1975    ```
1976    code1
1977    ```
1978
1979- Item 2
1980
1981    ```
1982    code2
1983    ```
1984
1985- Item 3
1986- Item 4
1987";
1988        assert!(
1989            check(content, ListItemSpacingStyle::Consistent).is_empty(),
1990            "All non-structural gaps are tight, so list is consistent"
1991        );
1992    }
1993
1994    #[test]
1995    fn code_block_with_mixed_genuine_gaps_warns() {
1996        // Items 1-2 structural, 2-3 loose, 3-4 tight → genuine inconsistency
1997        let content = "\
1998- Item 1
1999
2000    ```
2001    code1
2002    ```
2003
2004- Item 2
2005
2006- Item 3
2007- Item 4
2008";
2009        let warnings = check(content, ListItemSpacingStyle::Consistent);
2010        assert!(
2011            !warnings.is_empty(),
2012            "Mixed genuine gaps (loose + tight) with structural code block should still warn"
2013        );
2014    }
2015
2016    // ── allow-loose-continuation ─────────────────────────────────────
2017
2018    #[test]
2019    fn continuation_loose_tight_style_default_warns() {
2020        // Default (allow_loose_continuation=false): blank lines around
2021        // continuation paragraphs are treated as loose gaps → violation
2022        let content = "\
2023- Item 1.
2024
2025  Continuation paragraph.
2026
2027- Item 2.
2028
2029  Continuation paragraph.
2030
2031- Item 3.
2032";
2033        let warnings = check_with_continuation(content, ListItemSpacingStyle::Tight, false);
2034        assert!(
2035            !warnings.is_empty(),
2036            "Should warn about loose gaps when allow_loose_continuation is false"
2037        );
2038    }
2039
2040    #[test]
2041    fn continuation_loose_tight_style_allowed_no_warnings() {
2042        // With allow_loose_continuation=true: blank lines around continuation
2043        // paragraphs are permitted even in tight mode
2044        let content = "\
2045- Item 1.
2046
2047  Continuation paragraph.
2048
2049- Item 2.
2050
2051  Continuation paragraph.
2052
2053- Item 3.
2054";
2055        let warnings = check_with_continuation(content, ListItemSpacingStyle::Tight, true);
2056        assert!(
2057            warnings.is_empty(),
2058            "Should not warn when allow_loose_continuation is true, got: {warnings:?}"
2059        );
2060    }
2061
2062    #[test]
2063    fn continuation_loose_mixed_items_warns() {
2064        // Even with allow_loose_continuation, genuinely loose inter-item gaps
2065        // (blank line between items that have no continuation) should still warn
2066        let content = "\
2067- Item 1.
2068
2069- Item 2.
2070- Item 3.
2071";
2072        let warnings = check_with_continuation(content, ListItemSpacingStyle::Tight, true);
2073        assert!(
2074            !warnings.is_empty(),
2075            "Genuine loose gaps should still warn even with allow_loose_continuation"
2076        );
2077    }
2078
2079    #[test]
2080    fn continuation_loose_consistent_mode() {
2081        // In consistent mode with allow_loose_continuation, continuation gaps
2082        // should not count toward loose/tight consistency
2083        let content = "\
2084- Item 1.
2085
2086  Continuation paragraph.
2087
2088- Item 2.
2089- Item 3.
2090";
2091        let warnings = check_with_continuation(content, ListItemSpacingStyle::Consistent, true);
2092        assert!(
2093            warnings.is_empty(),
2094            "Continuation gaps should not affect consistency when allowed, got: {warnings:?}"
2095        );
2096    }
2097
2098    #[test]
2099    fn continuation_loose_fix_preserves_continuation_blanks() {
2100        let content = "\
2101- Item 1.
2102
2103  Continuation paragraph.
2104
2105- Item 2.
2106
2107  Continuation paragraph.
2108
2109- Item 3.
2110";
2111        let fixed = fix_with_continuation(content, ListItemSpacingStyle::Tight, true);
2112        assert_eq!(fixed, content, "Fix should preserve continuation blank lines");
2113    }
2114
2115    #[test]
2116    fn continuation_loose_fix_removes_genuine_loose_gaps() {
2117        let input = "\
2118- Item 1.
2119
2120- Item 2.
2121
2122- Item 3.
2123";
2124        let expected = "\
2125- Item 1.
2126- Item 2.
2127- Item 3.
2128";
2129        let fixed = fix_with_continuation(input, ListItemSpacingStyle::Tight, true);
2130        assert_eq!(fixed, expected);
2131    }
2132
2133    #[test]
2134    fn continuation_loose_ordered_list() {
2135        let content = "\
21361. Item 1.
2137
2138   Continuation paragraph.
2139
21402. Item 2.
2141
2142   Continuation paragraph.
2143
21443. Item 3.
2145";
2146        let warnings = check_with_continuation(content, ListItemSpacingStyle::Tight, true);
2147        assert!(
2148            warnings.is_empty(),
2149            "Ordered list continuation should work too, got: {warnings:?}"
2150        );
2151    }
2152
2153    #[test]
2154    fn continuation_loose_disabled_by_default() {
2155        // Verify the constructor defaults to false
2156        let rule = MD076ListItemSpacing::new(ListItemSpacingStyle::Tight);
2157        assert!(!rule.config.allow_loose_continuation);
2158    }
2159
2160    #[test]
2161    fn continuation_loose_ordered_under_indented_ends_the_list() {
2162        // "1. " puts the item's content at column 3, so text at column 2
2163        // after a blank line is not a continuation of the item: it ends the
2164        // list, and the items after it are a list of their own, tight and
2165        // consistent. Nothing to report, in either style, whether or not
2166        // continuation gaps are allowed. Text at column 3 continues the item,
2167        // and its gap is a continuation gap that the default rejects, reported
2168        // at the blank line the fix removes, the one before the next item.
2169        let content = "\
21701. Item 1.
2171
2172  Under-indented text.
2173
21741. Item 2.
21751. Item 3.
2176";
2177        for (style, allow) in [
2178            (ListItemSpacingStyle::Tight, true),
2179            (ListItemSpacingStyle::Tight, false),
2180            (ListItemSpacingStyle::Consistent, false),
2181        ] {
2182            let warnings = check_with_continuation(content, style, allow);
2183            assert!(warnings.is_empty(), "{content:?}: {warnings:?}");
2184        }
2185        let content = "\
21861. Item 1.
2187
2188   Continuation text.
2189
21901. Item 2.
21911. Item 3.
2192";
2193        let warnings = check_with_continuation(content, ListItemSpacingStyle::Tight, false);
2194        assert_eq!(warnings.len(), 1, "{content:?}: {warnings:?}");
2195        assert_eq!(warnings[0].line, 4);
2196        assert_eq!(warnings[0].message, "Unexpected blank line between list items");
2197    }
2198
2199    #[test]
2200    fn continuation_loose_mix_continuation_and_genuine_gaps() {
2201        // Some items have continuation (allowed), one gap is genuinely loose (not allowed)
2202        let content = "\
2203- Item 1.
2204
2205  Continuation paragraph.
2206
2207- Item 2.
2208
2209- Item 3.
2210";
2211        let warnings = check_with_continuation(content, ListItemSpacingStyle::Tight, true);
2212        assert!(
2213            !warnings.is_empty(),
2214            "Genuine loose gap between items 2-3 should warn even with continuation allowed"
2215        );
2216        // Only the genuine loose gap should warn, not the continuation gap
2217        assert_eq!(
2218            warnings.len(),
2219            1,
2220            "Expected exactly one warning for the genuine loose gap"
2221        );
2222    }
2223
2224    #[test]
2225    fn continuation_loose_fix_mixed_preserves_continuation_removes_genuine() {
2226        // Fix should preserve continuation blanks but remove genuine loose gaps
2227        let input = "\
2228- Item 1.
2229
2230  Continuation paragraph.
2231
2232- Item 2.
2233
2234- Item 3.
2235";
2236        let expected = "\
2237- Item 1.
2238
2239  Continuation paragraph.
2240
2241- Item 2.
2242- Item 3.
2243";
2244        let fixed = fix_with_continuation(input, ListItemSpacingStyle::Tight, true);
2245        assert_eq!(fixed, expected);
2246    }
2247
2248    #[test]
2249    fn continuation_loose_after_code_block() {
2250        // Code block is structural, continuation after code block should also work
2251        let content = "\
2252- Item 1.
2253
2254  ```python
2255  code
2256  ```
2257
2258  Continuation after code.
2259
2260- Item 2.
2261- Item 3.
2262";
2263        let warnings = check_with_continuation(content, ListItemSpacingStyle::Tight, true);
2264        assert!(
2265            warnings.is_empty(),
2266            "Code block + continuation should both be exempt, got: {warnings:?}"
2267        );
2268    }
2269
2270    #[test]
2271    fn continuation_loose_style_does_not_interfere() {
2272        // With style=loose, allow-loose-continuation shouldn't change behavior —
2273        // loose style already requires blank lines everywhere
2274        let content = "\
2275- Item 1.
2276
2277  Continuation paragraph.
2278
2279- Item 2.
2280
2281  Continuation paragraph.
2282
2283- Item 3.
2284";
2285        let warnings = check_with_continuation(content, ListItemSpacingStyle::Loose, true);
2286        assert!(
2287            warnings.is_empty(),
2288            "Loose style with continuation should not warn, got: {warnings:?}"
2289        );
2290    }
2291
2292    #[test]
2293    fn continuation_loose_tight_no_continuation_content() {
2294        // All items are simple (no continuation), tight style should work normally
2295        let content = "\
2296- Item 1.
2297- Item 2.
2298- Item 3.
2299";
2300        let warnings = check_with_continuation(content, ListItemSpacingStyle::Tight, true);
2301        assert!(
2302            warnings.is_empty(),
2303            "Simple tight list should pass with allow_loose_continuation, got: {warnings:?}"
2304        );
2305    }
2306
2307    // ── Config schema ──────────────────────────────────────────────────
2308
2309    #[test]
2310    fn default_config_section_provides_style_key() {
2311        let rule = MD076ListItemSpacing::new(ListItemSpacingStyle::Consistent);
2312        let section = rule.default_config_section();
2313        assert!(section.is_some());
2314        let (name, value) = section.unwrap();
2315        assert_eq!(name, "MD076");
2316        if let toml::Value::Table(map) = value {
2317            assert!(map.contains_key("style"));
2318            assert!(map.contains_key("allow-loose-continuation"));
2319        } else {
2320            panic!("Expected Table value from default_config_section");
2321        }
2322    }
2323
2324    #[test]
2325    fn unicode_space_line_is_text_not_a_blank_line() {
2326        // Each NBSP line is a paragraph line (a lazy continuation of the item above),
2327        // so the fix must not read it as the blank line before the next list.
2328        let rule = MD076ListItemSpacing::new(ListItemSpacingStyle::Consistent);
2329        for source in [
2330            "- a\n\u{a0}\n- b\n- c\n",
2331            "- a\n\u{a0}\ntext\n\u{a0}\n- b\n  c\n- d\n\n- e\n",
2332            "- a\n\n- b\n\u{a0}\n- c\n- d\n",
2333        ] {
2334            let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
2335            let fixed = rule.fix(&ctx).unwrap();
2336            assert_eq!(
2337                fixed.matches('\u{a0}').count(),
2338                source.matches('\u{a0}').count(),
2339                "{source:?} -> {fixed:?}"
2340            );
2341        }
2342    }
2343}