Skip to main content

rumdl_lib/rules/
md076_list_item_spacing.rs

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