Skip to main content

rumdl_lib/rules/
md077_list_continuation_indent.rs

1//!
2//! Rule MD077: List continuation content indentation
3//!
4//! See [docs/md077.md](../../docs/md077.md) for full documentation, configuration, and examples.
5
6use std::ops::ControlFlow;
7
8use serde::{Deserialize, Serialize};
9
10use crate::lint_context::{LineInfo, LintContext};
11use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
12
13mod md077_config;
14use md077_config::MD077Config;
15
16/// How strictly MD077 enforces continuation-line indentation.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
18#[serde(rename_all = "kebab-case")]
19pub enum ContinuationStyle {
20    /// Permit any continuation indent in `[0, content_column]` (CommonMark lazy
21    /// continuation). Only over-indentation and content that escapes the list
22    /// after a blank line are flagged.
23    #[default]
24    Any,
25    /// Require every continuation line to align to the item's content column.
26    /// Tight under-indented lazy continuation (which `any` permits) is also
27    /// flagged and snapped up to the content column.
28    Aligned,
29}
30
31/// Rule MD077: List continuation content indentation
32///
33/// In both tight continuation (no blank line) and loose continuation (after a
34/// blank line), content must not be **over-indented** beyond the item's content
35/// column. Additionally, in loose continuation content must not be
36/// **under-indented** below the content column (W+N rule), or it falls out of the
37/// list; tight under-indent is valid CommonMark lazy continuation and is left
38/// alone. Content indented to the content column + 4 or more is an indented code
39/// block, not continuation, and is not flagged.
40///
41/// Under the MkDocs flavor, a minimum of 4 spaces is enforced for ordered list
42/// items to satisfy Python-Markdown.
43///
44/// With `style = "aligned"`, tight under-indented continuation is additionally
45/// flagged and aligned to the content column (mdformat parity).
46#[derive(Clone, Default)]
47pub struct MD077ListContinuationIndent {
48    config: MD077Config,
49}
50
51impl MD077ListContinuationIndent {
52    /// Construct the rule with an explicit continuation style. `Default`
53    /// (`ContinuationStyle::Any`) preserves the historical behavior.
54    pub fn new(style: ContinuationStyle) -> Self {
55        Self {
56            config: MD077Config { style, indent: None },
57        }
58    }
59
60    pub fn from_config_struct(config: MD077Config) -> Self {
61        Self { config }
62    }
63}
64
65impl MD077ListContinuationIndent {
66    /// Width of a GFM task checkbox prefix including its trailing space:
67    /// `[ ] `, `[x] `, or `[X] ` — always exactly 4 bytes.
68    const TASK_CHECKBOX_PREFIX_LEN: usize = 4;
69
70    /// Returns true if the item line starts a GFM task list item, i.e. its
71    /// content column begins with `[ ] `, `[x] `, or `[X] `. The trailing
72    /// space is part of the match — `- [ ]` with no body is an empty list
73    /// item, not a task.
74    ///
75    /// Task items have a second, conventionally-accepted continuation column
76    /// at `content_col + 4` (aligned after the checkbox). MD013 reflow
77    /// produces this column for wrapped task lines, so MD077 has to accept
78    /// it to avoid a fix loop with MD013.
79    ///
80    /// `content_col` is a byte offset into `line`, not a visual column. The
81    /// CommonMark list parser produces byte-offset content columns, and the
82    /// checkbox prefix `[ ] ` is pure ASCII, so this byte-level comparison
83    /// is correct. Leading indent mixing tabs and spaces is irrelevant here
84    /// because `content_col` already points past any leading whitespace.
85    fn is_task_list_item(line: &str, content_col: usize) -> bool {
86        line.as_bytes()
87            .get(content_col..content_col + Self::TASK_CHECKBOX_PREFIX_LEN)
88            .is_some_and(|window| matches!(window, b"[ ] " | b"[x] " | b"[X] "))
89    }
90
91    /// Check if a trimmed line is a block-level construct (not list continuation).
92    fn is_block_level_construct(trimmed: &str) -> bool {
93        // Footnote definition: [^label]:
94        if trimmed.starts_with("[^") && trimmed.contains("]:") {
95            return true;
96        }
97        // Abbreviation definition: *[text]:
98        if trimmed.starts_with("*[") && trimmed.contains("]:") {
99            return true;
100        }
101        // Reference link definition: [label]: url
102        // Must start with [ but not be a regular link, footnote, or abbreviation
103        if trimmed.starts_with('[') && !trimmed.starts_with("[^") && trimmed.contains("]: ") {
104            return true;
105        }
106        false
107    }
108
109    /// Check if a trimmed line is a fenced code block delimiter (opener or closer).
110    fn is_code_fence(trimmed: &str) -> bool {
111        let bytes = trimmed.as_bytes();
112        if bytes.len() < 3 {
113            return false;
114        }
115        let ch = bytes[0];
116        (ch == b'`' || ch == b'~') && bytes[1] == ch && bytes[2] == ch
117    }
118
119    /// Check if a trimmed line starts with a list marker (*, -, +, or ordered).
120    /// Used to avoid flagging deeply indented list items that the parser doesn't
121    /// recognize as list items (e.g., with indent=8 configured in MD007).
122    fn starts_with_list_marker(trimmed: &str) -> bool {
123        let bytes = trimmed.as_bytes();
124        match bytes.first() {
125            Some(b'*' | b'-' | b'+') => bytes.get(1).is_some_and(|&b| b == b' ' || b == b'\t'),
126            Some(b'0'..=b'9') => {
127                let rest = trimmed.trim_start_matches(|c: char| c.is_ascii_digit());
128                rest.starts_with(". ") || rest.starts_with(") ")
129            }
130            _ => false,
131        }
132    }
133
134    /// Given the line number of a fenced code block opener, walk forward and
135    /// return the line number of the matching closer. Returns the opener itself
136    /// if no following line is in the code block (degenerate single-line block).
137    fn find_fence_closer(ctx: &LintContext, opener_line: usize) -> usize {
138        let mut closer_line = opener_line;
139        for peek in (opener_line + 1)..=ctx.lines.len() {
140            let Some(peek_info) = ctx.line_info(peek) else { break };
141            if peek_info.in_code_block {
142                closer_line = peek;
143            } else {
144                break;
145            }
146        }
147        closer_line
148    }
149
150    /// Build an atomic fix that reindents a fenced code block from its opener
151    /// through its matching closer.
152    ///
153    /// - **Opener and closer** are moved to `required` (the list item's
154    ///   content column, which is what MD077 actually flagged).
155    /// - **Interior lines** are *promoted* to `required` only if they sit
156    ///   below it; interior content at or above `required` is left at its
157    ///   original column. This preserves authored interior indentation when
158    ///   possible while guaranteeing fence pairing: every non-blank line in
159    ///   the block ends at column ≥ `required`, so the block stays inside
160    ///   the list item's scope after the fix.
161    ///
162    /// Only used for the under-indent direction (`required > opener_actual`);
163    /// over-indented fences are intentionally left untouched (see the
164    /// over-indent pass in `check`), so there is no down-shift case to handle.
165    ///
166    /// Why a compound fix rather than three independent fixes? MD077 and
167    /// MD031 run in the same iterative fix loop. If we only moved the
168    /// delimiters, an intermediate state would have mismatched
169    /// opener/closer indentation and MD031 would misread the block as
170    /// unpaired, injecting stray blank lines (issue #574).
171    ///
172    /// Why `max(interior, required)` instead of `interior + delta`? The
173    /// delta-shift version was not idempotent: if interior started below
174    /// the list scope (e.g., col 0 under an opener at col 2 that needs to
175    /// move to col 3), delta-shift landed interior at col 1 — still below
176    /// the list scope — and the next MD077 pass would re-flag it
177    /// individually and snap it to `required`. The promote-up rule reaches
178    /// that end state in a single pass.
179    ///
180    /// Leading tabs are normalized to spaces: CommonMark expands a tab to
181    /// the next column that's a multiple of 4, so simply prepending spaces
182    /// before a tab would let the tab snap back and cancel the shift. We
183    /// replace the whole leading-whitespace byte range with spaces.
184    fn build_compound_fence_fix(
185        ctx: &LintContext,
186        opener_line: usize,
187        closer_line: usize,
188        opener_actual: usize,
189        required: usize,
190    ) -> Option<Fix> {
191        if required <= opener_actual {
192            return None;
193        }
194        let opener_info = ctx.line_info(opener_line)?;
195        let closer_info = ctx.line_info(closer_line)?;
196
197        let fix_start = opener_info.byte_offset;
198        let fix_end = closer_info.byte_offset + closer_info.byte_len;
199
200        let mut replacement = String::new();
201        for i in opener_line..=closer_line {
202            let info = ctx.line_info(i)?;
203            if i > opener_line {
204                replacement.push('\n');
205            }
206            let line = info.content(ctx.content);
207            if info.is_blank {
208                // Blank lines have no content to shift; preserve verbatim.
209                replacement.push_str(line);
210            } else {
211                let new_visual = if i == opener_line || i == closer_line {
212                    required
213                } else {
214                    info.visual_indent.max(required)
215                };
216                for _ in 0..new_visual {
217                    replacement.push(' ');
218                }
219                replacement.push_str(&line[info.indent..]);
220            }
221        }
222
223        Some(Fix::new(fix_start..fix_end, replacement))
224    }
225
226    /// Walk the continuation lines owned by a single list item, invoking
227    /// `per_line` for each *in-scope, non-blank, non-nested, non-skipped*
228    /// line with its pre-computed visual column and loose/tight state.
229    ///
230    /// This is the **single source of truth** for MD077's item-scope
231    /// traversal: both the sibling-column pre-pass and the main check loop
232    /// route through this method so their termination semantics cannot
233    /// drift. The callback sees only lines the rule actually needs to
234    /// reason about; it can return `ControlFlow::Break` for early exit.
235    ///
236    /// Termination conditions (applied before the callback fires):
237    /// - Headings and horizontal rules end the item unconditionally.
238    /// - After a blank line, content at or below the marker column has
239    ///   escaped the item; further lines are not delivered.
240    ///
241    /// Skipped silently (do not fire the callback):
242    /// - Blank lines (toggle `saw_blank`).
243    /// - Nested list items (reset `saw_blank`, track their content column).
244    /// - Lines inside any still-open nested item's scope: the walk keeps a
245    ///   stack of open nested (marker, content) columns, so a continuation
246    ///   of a middle nesting level defers to that level's own walk instead
247    ///   of being claimed by a shallower ancestor.
248    /// - Reference/footnote/abbreviation definitions and similar block
249    ///   constructs that aren't list continuation.
250    /// - Lines that `should_skip_line` rejects (code-block interior etc.).
251    fn walk_item_continuation<F>(
252        ctx: &LintContext,
253        item_line: usize,
254        range_end: usize,
255        marker_col: usize,
256        mut per_line: F,
257    ) where
258        F: FnMut(&ContinuationLine<'_>) -> ControlFlow<()>,
259    {
260        let mut saw_blank = false;
261        let mut saw_nested = false;
262        // Stack of open nested items as (marker_column, content_column),
263        // outermost first. A single scalar is not enough: with 3+ levels a
264        // continuation of a middle level sits below the innermost content
265        // column but at-or-past its own level's, and must still be skipped
266        // here so the owning level's walk evaluates it.
267        let mut nested_stack: Vec<(usize, usize)> = Vec::new();
268
269        for line_num in (item_line + 1)..=range_end {
270            let Some(info) = ctx.line_info(line_num) else {
271                continue;
272            };
273
274            let trimmed = info.content(ctx.content).trim_start();
275
276            if Self::should_skip_line(info, trimmed) {
277                continue;
278            }
279
280            if info.is_blank {
281                saw_blank = true;
282                continue;
283            }
284
285            if let Some(ref li) = info.list_item {
286                if li.marker_column > marker_col {
287                    // A sibling-or-shallower marker closes every nested item at
288                    // or past its column before this one opens.
289                    while nested_stack.last().is_some_and(|&(m, _)| m >= li.marker_column) {
290                        nested_stack.pop();
291                    }
292                    nested_stack.push((li.marker_column, li.content_column));
293                    // Sticky: once a nested child appears, every later line is a
294                    // (lazy) continuation of the deeper item, which owns it under
295                    // CommonMark. Ancestors must defer so the deepest item claims
296                    // and aligns it.
297                    saw_nested = true;
298                } else {
299                    nested_stack.clear();
300                }
301                saw_blank = false;
302                continue;
303            }
304
305            if info.heading.is_some() || info.is_horizontal_rule {
306                break;
307            }
308
309            if Self::is_block_level_construct(trimmed) {
310                continue;
311            }
312
313            let col = info.visual_indent;
314
315            // The line escapes every nested item whose content column it sits
316            // below; if any nested item stays open, the line is that item's
317            // continuation and its own walk owns the judgement.
318            while nested_stack.last().is_some_and(|&(_, c)| c > col) {
319                nested_stack.pop();
320            }
321            if !nested_stack.is_empty() {
322                continue;
323            }
324
325            if saw_blank && col <= marker_col {
326                break;
327            }
328
329            let line = ContinuationLine {
330                line_num,
331                info,
332                trimmed,
333                actual: col,
334                saw_blank,
335                saw_nested,
336            };
337            if per_line(&line).is_break() {
338                break;
339            }
340        }
341    }
342
343    /// Whether the item's owned continuation range holds a *latent* structural
344    /// construct: a list marker or table line the parser has not yet promoted to
345    /// its own block, but would once an earlier continuation line is reindented.
346    /// Such a promotion re-attributes the following lines to a different block
347    /// (different content column) and breaks single-pass idempotency, so the
348    /// caller skips the whole item when this returns true.
349    ///
350    /// A setext underline counts too: as a lazy continuation the parser reads a
351    /// `=`/`-` run as paragraph text, but reindenting the prose line above it
352    /// into the item turns the pair into a heading.
353    ///
354    /// Unlike [`walk_item_continuation`], this scans the full owned range and
355    /// does NOT stop at a heading or horizontal rule. A heading partway through
356    /// the continuation can itself be parse-unstable: an empty backtick run, for
357    /// instance, is read as a setext heading, and reindenting an earlier line
358    /// dissolves it and promotes a list marker that sits past it. Stopping at the
359    /// heading would hide that marker from this check and let the reindent break
360    /// idempotency. Lines already parsed as list items are real nested children,
361    /// not latent, and are handled by the walk's `saw_nested` logic instead.
362    fn item_range_has_latent_structure(ctx: &LintContext, item_line: usize, range_end: usize) -> bool {
363        (item_line + 1..=range_end).any(|line_num| {
364            ctx.line_info(line_num).is_some_and(|info| {
365                if info.is_blank || info.list_item.is_some() {
366                    return false;
367                }
368                let trimmed = info.content(ctx.content).trim_start();
369                !Self::should_skip_line(info, trimmed)
370                    && (Self::starts_with_list_marker(trimmed)
371                        || crate::utils::skip_context::is_table_line(trimmed)
372                        || Self::is_latent_setext_underline(ctx, line_num, trimmed))
373            })
374        })
375    }
376
377    /// Whether reindenting the line above this one would turn the pair into a
378    /// setext heading. An underline that is a lazy continuation is paragraph
379    /// text, so the parser reports no heading and the walk runs straight past it.
380    ///
381    /// Only paragraph text can become the heading's text line, so an underline
382    /// under an ATX heading, a thematic break or another underline is latent
383    /// nothing and must not hold the whole item back. The line above has to pass
384    /// both readings: `is_paragraph_context` speaks for the block it sits in,
385    /// which is what tells a closing code fence from the prose it looks like,
386    /// and the text predicate speaks for the line's own markers, which is what
387    /// tells an empty list item from the text it holds none of. A blank line
388    /// needs no test of its own: blank means it holds nothing once its markers
389    /// are stripped, which is the first thing the text predicate rejects.
390    fn is_latent_setext_underline(ctx: &LintContext, line_num: usize, trimmed: &str) -> bool {
391        crate::lint_context::is_setext_underline_content(trimmed)
392            && ctx.line_info(line_num - 1).is_some_and(|prev| {
393                prev.is_paragraph_context() && crate::lint_context::is_paragraph_text_line(prev.content(ctx.content))
394            })
395    }
396
397    /// Scan an item's owned range and report whether any *other* continuation
398    /// line in the item uses the content column or the post-checkbox column.
399    ///
400    /// Used exclusively for tie-breaking the auto-fix target when an
401    /// over-indented line is exactly equidistant from `content_col` and
402    /// `task_col`. In that case the author's intent is ambiguous, so we
403    /// snap to whichever valid column *they're already using* elsewhere in
404    /// the same item. When neither or both columns are in use, the caller
405    /// falls back to a canonical default.
406    fn sibling_column_usage(
407        ctx: &LintContext,
408        item_line: usize,
409        range_end: usize,
410        marker_col: usize,
411        content_col: usize,
412        task_col: usize,
413    ) -> (bool, bool) {
414        let mut uses_content = false;
415        let mut uses_task = false;
416
417        Self::walk_item_continuation(ctx, item_line, range_end, marker_col, |line| {
418            if line.actual == content_col {
419                uses_content = true;
420            }
421            if line.actual == task_col {
422                uses_task = true;
423            }
424            if uses_content && uses_task {
425                ControlFlow::Break(())
426            } else {
427                ControlFlow::Continue(())
428            }
429        });
430
431        (uses_content, uses_task)
432    }
433
434    /// Compute the auto-fix target for an over-indented continuation line.
435    /// Snaps to the nearer of the two valid columns (content_col / task_col)
436    /// for task items, and on an exact tie uses sibling-column context to
437    /// pick whichever column the author is already using elsewhere in this
438    /// item. Non-task items always snap to `required`.
439    fn compute_fix_target(
440        actual: usize,
441        required: usize,
442        task_col: Option<usize>,
443        uses_content_col: bool,
444        uses_task_col: bool,
445    ) -> usize {
446        let Some(t) = task_col else { return required };
447        match actual.abs_diff(t).cmp(&actual.abs_diff(required)) {
448            std::cmp::Ordering::Less => t,
449            std::cmp::Ordering::Greater => required,
450            std::cmp::Ordering::Equal => match (uses_task_col, uses_content_col) {
451                (true, false) => t,
452                _ => required,
453            },
454        }
455    }
456
457    /// Check if a line should be skipped (inside code, HTML, frontmatter, etc.)
458    ///
459    /// Code block *content* is skipped, but fence opener/closer lines are not —
460    /// their indentation matters for list continuation in MkDocs.
461    ///
462    /// Footnote-definition lines (the `[^id]:` line and its indented body) are
463    /// skipped too: a footnote body that follows a list is its own block, not
464    /// list continuation, so its indentation is MD068's concern, not MD077's.
465    /// Treating it as continuation produced false over-indent warnings and a
466    /// damaging auto-fix that reindented the body and then tripped MD068.
467    fn should_skip_line(info: &crate::lint_context::LineInfo, trimmed: &str) -> bool {
468        if info.in_code_block && !Self::is_code_fence(trimmed) {
469            return true;
470        }
471        info.in_front_matter
472            || info.in_footnote_definition
473            || info.in_html_block
474            || info.in_html_comment
475            || info.in_mdx_comment
476            || info.in_mkdocstrings
477            || info.in_esm_block
478            || info.in_math_block
479            || info.in_admonition
480            || info.in_content_tab
481            || info.in_pymdown_block
482            || info.in_definition_list
483            || info.in_mkdocs_html_markdown
484            || info.in_kramdown_extension_block
485    }
486
487    /// Build the warning for an over-indented continuation line. The fix is a
488    /// single-line rewrite of the leading whitespace to `fix_target`.
489    ///
490    /// The over-indent pass never delivers fenced-code lines here (it skips
491    /// anything `in_code_block`), so this builder does not need the compound
492    /// fence handling that `build_under_indent_warning` uses: moving an
493    /// over-indented fence's delimiters without its body would corrupt the code
494    /// content, so over-indented fenced blocks are deliberately left as-is.
495    fn build_over_indent_warning(
496        ctx: &LintContext,
497        line: &ContinuationLine<'_>,
498        fix_target: usize,
499        message: String,
500    ) -> LintWarning {
501        let line_content = line.info.content(ctx.content);
502        let fix_start = line.info.byte_offset;
503        let fix_end = fix_start + line.info.indent;
504        LintWarning {
505            rule_name: Some("MD077".to_string()),
506            line: line.line_num,
507            column: 1,
508            end_line: line.line_num,
509            end_column: line_content.chars().count() + 1,
510            message,
511            severity: Severity::Warning,
512            fix: Some(Fix::new(fix_start..fix_end, " ".repeat(fix_target))),
513        }
514    }
515
516    /// Build the warning for a loose-mode under-indented continuation line.
517    /// When the line is the opener of a fenced code block, emit a compound
518    /// fix that reindents opener + interior + closer atomically so MD031
519    /// doesn't see a transiently-broken fence pair (see #574).
520    ///
521    /// Returns the warning plus, when the fix is compound, the closer
522    /// line number so the caller can mark it flagged (preventing the
523    /// main loop from double-flagging the closer as its own under-indent
524    /// case). Keeping the "also flag this line" signal out of band keeps
525    /// this function pure — it reads from `ctx` only and returns a
526    /// plain value.
527    fn build_under_indent_warning(
528        ctx: &LintContext,
529        line: &ContinuationLine<'_>,
530        required: usize,
531        message: String,
532    ) -> UnderIndentOutcome {
533        let line_content = line.info.content(ctx.content);
534        let is_fence_opener = line.info.in_code_block
535            && Self::is_code_fence(line.trimmed)
536            && ctx.line_info(line.line_num - 1).is_none_or(|p| !p.in_code_block);
537
538        let (fix, warn_end_line, warn_end_column, compound_closer) = if is_fence_opener {
539            let closer_line = Self::find_fence_closer(ctx, line.line_num);
540            let fix = Self::build_compound_fence_fix(ctx, line.line_num, closer_line, line.actual, required);
541            let end_column = ctx
542                .line_info(closer_line)
543                .map_or(line_content.chars().count() + 1, |ci| {
544                    ci.content(ctx.content).chars().count() + 1
545                });
546            let extra_flag = (closer_line != line.line_num).then_some(closer_line);
547            (fix, closer_line, end_column, extra_flag)
548        } else {
549            let fix_start = line.info.byte_offset;
550            let fix_end = fix_start + line.info.indent;
551            let fix = Some(Fix::new(fix_start..fix_end, " ".repeat(required)));
552            (fix, line.line_num, line_content.chars().count() + 1, None)
553        };
554
555        UnderIndentOutcome {
556            warning: LintWarning {
557                rule_name: Some("MD077".to_string()),
558                line: line.line_num,
559                column: 1,
560                end_line: warn_end_line,
561                end_column: warn_end_column,
562                message,
563                severity: Severity::Warning,
564                fix,
565            },
566            also_flag_line: compound_closer,
567        }
568    }
569}
570
571/// A continuation line yielded by `walk_item_continuation`. Bundles the
572/// per-line facts both checker branches need so helper functions don't
573/// balloon their argument lists.
574struct ContinuationLine<'a> {
575    line_num: usize,
576    info: &'a LineInfo,
577    trimmed: &'a str,
578    actual: usize,
579    saw_blank: bool,
580    /// True once a nested child item has appeared earlier in this item's scope.
581    /// Subsequent lines are lazy continuation of the deeper item (which owns
582    /// them under CommonMark), so an ancestor must not claim them.
583    saw_nested: bool,
584}
585
586/// Result of `build_under_indent_warning`. Carries both the warning and,
587/// when the fix is compound (fence opener → promote-to-required over the
588/// whole block), the closer line so the caller can record it as already
589/// handled. This keeps the warning builder free of external mutation.
590struct UnderIndentOutcome {
591    warning: LintWarning,
592    also_flag_line: Option<usize>,
593}
594
595impl Rule for MD077ListContinuationIndent {
596    fn name(&self) -> &'static str {
597        "MD077"
598    }
599
600    fn description(&self) -> &'static str {
601        "List continuation content indentation"
602    }
603
604    fn check(&self, ctx: &LintContext) -> LintResult {
605        if ctx.content.is_empty() {
606            return Ok(Vec::new());
607        }
608
609        let strict_indent = ctx.flavor.requires_strict_list_indent();
610        let total_lines = ctx.lines.len();
611        let mut warnings = Vec::new();
612        let mut flagged_lines = std::collections::HashSet::new();
613
614        // Collect all list item lines sorted, with their content_column,
615        // marker_column, and — if the item is a GFM task — its post-checkbox
616        // column. Precomputing task_col here (instead of re-reading line_info
617        // inside the hot inner loop) keeps the per-item cost O(1).
618        //
619        // We need the owned range to extend past block.end_line because the
620        // parser excludes under-indented continuation from the block, and
621        // MD077 specifically has to evaluate those escaped lines.
622        let mut items: Vec<(usize, usize, usize, Option<usize>)> = Vec::new();
623        for block in &ctx.list_blocks {
624            for &item_line in &block.item_lines {
625                if let Some(info) = ctx.line_info(item_line)
626                    && let Some(ref li) = info.list_item
627                {
628                    // Lists inside blockquotes are out of scope for MD077. Their
629                    // content column is measured on the raw line, so it is
630                    // meaningless for a lazy continuation sitting outside the
631                    // quote: scoping them lets two items with different content
632                    // columns each claim such a line and reindent it the other
633                    // way on alternating passes.
634                    if info.blockquote.is_some() {
635                        continue;
636                    }
637                    let line = info.content(ctx.content);
638                    let task_col = Self::is_task_list_item(line, li.content_column)
639                        .then_some(li.content_column + Self::TASK_CHECKBOX_PREFIX_LEN);
640                    items.push((item_line, li.marker_column, li.content_column, task_col));
641                }
642            }
643        }
644        items.sort_unstable();
645        items.dedup_by_key(|&mut (ln, _, _, _)| ln);
646
647        // Precompute each item's owned-range end. The range ends at the line
648        // before the next item whose marker column is <= this item's (its next
649        // sibling or an ancestor), or the last line if none follows.
650        //
651        // The direct "scan forward for the next marker_col <= mine" is O(n^2) on
652        // a monotonically deepening list, where no later item ever qualifies so
653        // every item scans to the end. A monotonic stack (nearest
654        // smaller-or-equal marker column to the right) computes all range ends in
655        // one linear pass. Walking right-to-left, pop every stacked item whose
656        // marker column is strictly greater than the current one (they can never
657        // be the sibling/ancestor of anything further left that is <= current),
658        // leaving the nearest qualifying item on top.
659        let mut range_ends = vec![total_lines; items.len()];
660        let mut stack: Vec<usize> = Vec::new();
661        for i in (0..items.len()).rev() {
662            let marker_col = items[i].1;
663            while let Some(&top) = stack.last() {
664                if items[top].1 > marker_col {
665                    stack.pop();
666                } else {
667                    break;
668                }
669            }
670            range_ends[i] = stack.last().map_or(total_lines, |&j| items[j].0 - 1);
671            stack.push(i);
672        }
673
674        // Precompute each item's required indent and owned line range so both
675        // passes below scope identically.
676        let scoped: Vec<(usize, usize, usize, Option<usize>, usize, usize)> = items
677            .iter()
678            .enumerate()
679            .map(|(item_idx, &(item_line, marker_col, content_col, task_col))| {
680                // A configured indent replaces the content column, but under a
681                // flavor that requires strict list indentation it may only raise
682                // the 4-space minimum. That minimum is a compatibility floor
683                // rather than a style default: below it the renderer stops
684                // treating the line as continuation, so honoring a smaller
685                // configured value would make `fix` rewrite documents into a
686                // form the flavor's renderer no longer reads as part of the item.
687                let required = match self.config.indent {
688                    Some(indent) if strict_indent => (marker_col + indent).max(4),
689                    Some(indent) => marker_col + indent,
690                    None if strict_indent => content_col.max(4),
691                    None => content_col,
692                };
693                (
694                    item_line,
695                    marker_col,
696                    content_col,
697                    task_col,
698                    required,
699                    range_ends[item_idx],
700                )
701            })
702            .collect();
703
704        // Precompute which lines can ever reach the per-line continuation
705        // callback. `walk_item_continuation` unconditionally skips (regardless of
706        // which item owns the line) blank lines, list-item lines, headings,
707        // horizontal rules, `should_skip_line` lines, and block-level constructs;
708        // only the lines that survive all of those can be flagged. An item whose
709        // owned range contains none of them produces no warning, so both passes
710        // can skip it without walking the range at all.
711        //
712        // This keeps a document of purely deeply-nested list items linear: such a
713        // document has no continuation prose, so every item skips its walk
714        // instead of re-scanning the whole tail (which was O(n^2) in the item
715        // count). Documents that do have continuation prose are unaffected - the
716        // guard only ever skips items that could not have produced a warning.
717        let prose_candidate_lines: Vec<usize> = (1..=total_lines)
718            .filter(|&line_num| {
719                let Some(info) = ctx.line_info(line_num) else {
720                    return false;
721                };
722                let trimmed = info.content(ctx.content).trim_start();
723                !Self::should_skip_line(info, trimmed)
724                    && !info.is_blank
725                    && info.list_item.is_none()
726                    && info.heading.is_none()
727                    && !info.is_horizontal_rule
728                    && !Self::is_block_level_construct(trimmed)
729            })
730            .collect();
731        // True when a continuation candidate falls in `(after_line, range_end]`,
732        // i.e. the half-open range `walk_item_continuation` actually visits.
733        let range_has_prose_candidate = |after_line: usize, range_end: usize| -> bool {
734            let start = prose_candidate_lines.partition_point(|&l| l <= after_line);
735            prose_candidate_lines.get(start).is_some_and(|&l| l <= range_end)
736        };
737
738        // Pass 1 - under-indented continuation.
739        //
740        // Loose under-indent (after a blank line) sits below the content column
741        // and would escape the list item; it is always flagged. Tight
742        // under-indent is valid CommonMark lazy continuation, so it is left
743        // alone under `style = "any"`.
744        //
745        // Under `style = "aligned"`, tight under-indent is additionally flagged
746        // and snapped up to the content column. MD077 scopes past the parser's
747        // block end (to catch escaped loose content), and the parser absorbs
748        // col-0 blockquotes, fences, and tables that sit tight under a list item
749        // as lazy continuation. Reindenting those would change the rendered
750        // structure, so the tight-aligned branch guards two ways: it skips lines
751        // the parser classified in place (code, blockquote - see
752        // `confirmed_structure`), and it bails on an item whose continuation
753        // holds a construct the parser would re-parse once indentation changes
754        // (a latent list marker, table or setext underline - see
755        // `has_latent_structure`). Headings and horizontal rules already
756        // terminate the walk; reference/footnote/abbreviation definitions are
757        // skipped inside it. Erring toward skipping yields false negatives
758        // (safe) rather than structural false positives.
759        //
760        // This pass runs first so that a deeply nested item claims an ambiguous
761        // line - one that is under-indented for it yet over-indented for a
762        // shallower ancestor - before pass 2 can mis-attribute it to the
763        // ancestor as an over-indent and snap it the wrong way.
764        let aligned = self.config.style == ContinuationStyle::Aligned;
765        for &(item_line, marker_col, _content_col, _task_col, required, range_end) in &scoped {
766            // No continuation candidate in this item's range: it cannot produce a
767            // warning, so skip the walk (and the latent-structure scan) entirely.
768            if !range_has_prose_candidate(item_line, range_end) {
769                continue;
770            }
771            // "Latent structure": a line in this item's continuation that the
772            // parser has NOT yet promoted to a list item or table, but that
773            // would be once an earlier continuation line is reindented. Such a
774            // promotion re-attributes the following lines to a different block
775            // (different content column) and breaks single-pass idempotency, so
776            // reindenting anything in the item is unsafe and the whole item is
777            // skipped. This is a *prediction* of how the parser would re-parse
778            // after a fix, which is why it stays a heuristic (markers, tables and
779            // setext underlines are the constructs the parser absorbs as lazy
780            // continuation rather than flagging on the line itself; code and
781            // blockquotes are classified in place and handled per line below).
782            // Well-formed prose continuation matches none, so this only bails
783            // on pathological input (a safe false negative). Real nested items
784            // never reach the callback (the walk handles them via `saw_nested`).
785            let has_latent_structure = aligned && Self::item_range_has_latent_structure(ctx, item_line, range_end);
786            // Which setting produced `required` decides what the message may
787            // claim. The content column carries a structural consequence (below
788            // it the content leaves the item) and the strict-flavor minimum
789            // carries a renderer requirement, but a configured indent above
790            // both is a house style, so it is reported as one.
791            let from_configured_indent = self.config.indent.is_some_and(|indent| marker_col + indent == required);
792            Self::walk_item_continuation(ctx, item_line, range_end, marker_col, |line| {
793                let actual = line.actual;
794                let under_indented = actual < required;
795                let loose_escape = line.saw_blank && under_indented;
796                // Don't reindent a line the parser classified in place as a code
797                // block (fenced or indented) or a blockquote: these are read
798                // straight from its per-line output, so the rule cannot drift
799                // from how rumdl parses the document. Headings and horizontal
800                // rules terminate the walk before the callback; latent list
801                // markers and tables are handled by `has_latent_structure`.
802                let confirmed_structure = line.info.in_code_block || line.info.blockquote.is_some();
803                let aligned_tight = aligned
804                    && !has_latent_structure
805                    && !line.saw_blank
806                    && !line.saw_nested
807                    && under_indented
808                    && !confirmed_structure;
809                if (loose_escape || aligned_tight) && flagged_lines.insert(line.line_num) {
810                    let message = if line.saw_blank {
811                        if from_configured_indent {
812                            format!(
813                                "Content after blank line in list item needs {required} spaces of \
814                                 indentation to match the configured indent (found {actual})",
815                            )
816                        } else if strict_indent {
817                            format!(
818                                "Content inside list item needs {required} spaces of indentation \
819                                 for MkDocs compatibility (found {actual})",
820                            )
821                        } else {
822                            format!(
823                                "Content after blank line in list item needs {required} spaces of \
824                                 indentation to remain part of the list (found {actual})",
825                            )
826                        }
827                    } else {
828                        format!("Continuation line under-indented (expected {required}, found {actual})")
829                    };
830                    let outcome = Self::build_under_indent_warning(ctx, line, required, message);
831                    if let Some(closer_line) = outcome.also_flag_line {
832                        flagged_lines.insert(closer_line);
833                    }
834                    warnings.push(outcome.warning);
835                }
836                ControlFlow::Continue(())
837            });
838        }
839
840        // Pass 2 - over-indented continuation (tight or loose): prose pushed
841        // past the content column is snapped back. Fenced code blocks are
842        // skipped here (`!in_code_block`): an over-indented fence is cosmetic
843        // (the code still renders), and reindenting only its delimiters - the
844        // body is skipped by `should_skip_line` - would alter the literal code
845        // content. Indented code blocks (content column + 4 or more) are also
846        // `in_code_block`, so a blank line before such a body does not exempt
847        // it from being recognized as code rather than over-indented prose.
848        for &(item_line, marker_col, content_col, task_col, required, range_end) in &scoped {
849            // No continuation candidate in this item's range: nothing to flag.
850            if !range_has_prose_candidate(item_line, range_end) {
851                continue;
852            }
853            // For task items, gather sibling-column usage once so the auto-fix
854            // can tie-break equidistant over-indents toward whichever valid
855            // column the author is already using.
856            let (uses_content_col, uses_task_col) = match task_col {
857                Some(t) => Self::sibling_column_usage(ctx, item_line, range_end, marker_col, content_col, t),
858                None => (false, false),
859            };
860
861            Self::walk_item_continuation(ctx, item_line, range_end, marker_col, |line| {
862                let actual = line.actual;
863                if actual > required
864                    && !line.info.in_code_block
865                    && Some(actual) != task_col
866                    && !Self::starts_with_list_marker(line.trimmed)
867                    && flagged_lines.insert(line.line_num)
868                {
869                    let fix_target =
870                        Self::compute_fix_target(actual, required, task_col, uses_content_col, uses_task_col);
871                    let message = match task_col {
872                        Some(t) => format!(
873                            "Continuation line over-indented \
874                             (expected {required} or {t}, found {actual})"
875                        ),
876                        None => {
877                            format!("Continuation line over-indented (expected {required}, found {actual})")
878                        }
879                    };
880                    warnings.push(Self::build_over_indent_warning(ctx, line, fix_target, message));
881                }
882                ControlFlow::Continue(())
883            });
884        }
885
886        // The two passes emit independently, so order by position before
887        // returning - callers and tests expect document order.
888        warnings.sort_by_key(|w| (w.line, w.column));
889
890        Ok(warnings)
891    }
892
893    fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
894        let warnings = self.check(ctx)?;
895        let warnings =
896            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
897        if warnings.is_empty() {
898            return Ok(ctx.content.to_string());
899        }
900
901        // Sort fixes by byte position descending to apply from end to start
902        let mut fixes: Vec<Fix> = warnings.into_iter().filter_map(|w| w.fix).collect();
903        fixes.sort_by_key(|f| std::cmp::Reverse(f.range.start));
904
905        let mut content = ctx.content.to_string();
906        for fix in fixes {
907            if fix.range.start <= content.len() && fix.range.end <= content.len() {
908                content.replace_range(fix.range, &fix.replacement);
909            }
910        }
911
912        Ok(content)
913    }
914
915    fn category(&self) -> RuleCategory {
916        RuleCategory::List
917    }
918
919    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
920        ctx.content.is_empty() || ctx.list_blocks.is_empty()
921    }
922
923    fn as_any(&self) -> &dyn std::any::Any {
924        self
925    }
926
927    crate::impl_rule_config_methods!(MD077Config);
928}
929
930#[cfg(test)]
931mod tests {
932    use super::*;
933    use crate::config::MarkdownFlavor;
934
935    fn check(content: &str) -> Vec<LintWarning> {
936        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
937        let rule = MD077ListContinuationIndent::default();
938        rule.check(&ctx).unwrap()
939    }
940
941    fn check_mkdocs(content: &str) -> Vec<LintWarning> {
942        let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
943        let rule = MD077ListContinuationIndent::default();
944        rule.check(&ctx).unwrap()
945    }
946
947    fn fix(content: &str) -> String {
948        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
949        let rule = MD077ListContinuationIndent::default();
950        rule.fix(&ctx).unwrap()
951    }
952
953    fn fix_mkdocs(content: &str) -> String {
954        let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
955        let rule = MD077ListContinuationIndent::default();
956        rule.fix(&ctx).unwrap()
957    }
958
959    fn aligned_rule() -> MD077ListContinuationIndent {
960        MD077ListContinuationIndent::new(ContinuationStyle::Aligned)
961    }
962
963    fn check_aligned(content: &str) -> Vec<LintWarning> {
964        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
965        aligned_rule().check(&ctx).unwrap()
966    }
967
968    fn check_aligned_mkdocs(content: &str) -> Vec<LintWarning> {
969        let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
970        aligned_rule().check(&ctx).unwrap()
971    }
972
973    fn fix_aligned(content: &str) -> String {
974        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
975        aligned_rule().fix(&ctx).unwrap()
976    }
977
978    fn fix_aligned_quarto(content: &str) -> String {
979        let ctx = LintContext::new(content, MarkdownFlavor::Quarto, None);
980        aligned_rule().fix(&ctx).unwrap()
981    }
982
983    #[test]
984    fn aligned_idempotent_with_latent_marker_behind_unstable_heading() {
985        // Regression: an empty backtick run (`` ``\n`` ``) is parsed as a setext
986        // heading. That heading used to terminate the latent-structure walk
987        // before it reached the `- ` marker on the next line, so the marker was
988        // not detected as latent. Reindenting the `1. ` continuation then
989        // promoted `- ` to a real list item, producing a fresh under-indent
990        // violation on the second pass and breaking single-pass idempotency.
991        // The latent-structure scan now spans the full owned range, detects the
992        // marker, and leaves the (ambiguous) item alone.
993        let input = "1. \n``\n``\n- \n``";
994        let once = fix_aligned_quarto(input);
995        let twice = fix_aligned_quarto(&once);
996        assert_eq!(once, twice, "MD077 aligned fix must be idempotent (Quarto)");
997    }
998
999    #[test]
1000    fn latent_underline_needs_paragraph_text_above_it() {
1001        // Only paragraph text can become a setext heading's text line, so an
1002        // underline sitting under an ATX heading or a thematic break is latent
1003        // nothing and must not hold the whole item back.
1004        for (label, content) in [
1005            ("ATX heading", "- item\nwrap\n# Heading\n===\n"),
1006            ("thematic break", "- item\nwrap\n***\n===\n"),
1007            ("HTML block", "- item\nwrap\n<div>\n===\n"),
1008        ] {
1009            assert_eq!(
1010                check_aligned(content).len(),
1011                1,
1012                "{label}: reindenting cannot make a setext heading here, so the under-indent is reportable"
1013            );
1014        }
1015
1016        // A closing code fence is the case only the block flag can answer: its
1017        // text is indistinguishable from prose, and nothing but the block it
1018        // sits in says the line cannot be a setext heading's text.
1019        assert_eq!(
1020            check_aligned("- item\nwrap\n```\ncode\n```\n===\n").len(),
1021            2,
1022            "a closing fence is not paragraph text, so both under-indents are reportable"
1023        );
1024
1025        // An empty list item is the case only the line's own markers can answer:
1026        // it is written inside a paragraph context and holds no text at all, and
1027        // an underline below it underlines nothing. A lone `-` is not this case
1028        // (under a paragraph it is itself an underline), so each marker carries
1029        // the space that opens an item holding nothing.
1030        for (label, content) in [
1031            ("empty bullet", "- item\n wrap\n > - \n ===\n"),
1032            ("empty ordered item", "- item\n wrap\n > 1. \n ===\n"),
1033            ("empty item in a nested quote", "- item\n wrap\n > > - \n ===\n"),
1034        ] {
1035            assert_eq!(
1036                check_aligned(content).len(),
1037                1,
1038                "{label}: an item holding no text cannot become a heading's text line"
1039            );
1040        }
1041
1042        // A blank line above the underline is the same answer for the same
1043        // reason, and needs no test of its own in the rule: stripped of its
1044        // markers it holds nothing, which is what the text predicate rejects.
1045        for (label, content) in [
1046            ("bare blank line", "- item\n wrap\n\n ===\n"),
1047            ("blank line in a quote", "- item\n wrap\n >\n ===\n"),
1048            ("quoted whitespace", "- item\n wrap\n >   \n ===\n"),
1049        ] {
1050            assert_eq!(
1051                check_aligned(content).len(),
1052                2,
1053                "{label}: nothing above the underline can become a heading's text line"
1054            );
1055        }
1056
1057        // Paired control: prose above the underline IS latent structure, because
1058        // reindenting the item would give the underline the content column and
1059        // turn the pair into a real heading.
1060        assert!(
1061            check_aligned("- item\nwrap\ntext\n===\n").is_empty(),
1062            "prose above the underline is latent structure, so the item is left alone"
1063        );
1064    }
1065
1066    #[test]
1067    fn aligned_idempotent_with_lazy_continuation_out_of_a_blockquote() {
1068        // Regression: a lazy continuation that follows a blockquote-nested list
1069        // item gained one space of indent on every pass, so the formatter never
1070        // converged.
1071        let input = "- \n> *\n> a\n``";
1072        let once = fix_aligned(input);
1073        let twice = fix_aligned(&once);
1074        assert_eq!(once, twice, "MD077 aligned fix must be idempotent");
1075    }
1076
1077    // ── Tight continuation (no blank line) ─────────────────────────────
1078
1079    #[test]
1080    fn tight_lazy_continuation_zero_indent_not_flagged() {
1081        // Zero-indent lazy continuation is valid CommonMark
1082        let content = "- Item\ncontinuation\n";
1083        assert!(check(content).is_empty());
1084    }
1085
1086    #[test]
1087    fn tight_continuation_correct_indent_not_flagged() {
1088        // Correctly indented tight continuation (aligns with content column)
1089        let content = "1. Item\n   continuation\n";
1090        assert!(check(content).is_empty());
1091    }
1092
1093    #[test]
1094    fn tight_continuation_over_indented_ordered() {
1095        // "1. " = 3 chars, but continuation has 4 spaces
1096        let content = "1. This is a list item with multiple lines.\n    The second line is over-indented.\n";
1097        let warnings = check(content);
1098        assert_eq!(warnings.len(), 1);
1099        assert_eq!(warnings[0].line, 2);
1100        assert!(warnings[0].message.contains("over-indented"));
1101    }
1102
1103    #[test]
1104    fn tight_continuation_over_indented_unordered() {
1105        // "- " = 2 chars, but continuation has 3 spaces
1106        let content = "- Item\n   over-indented\n";
1107        let warnings = check(content);
1108        assert_eq!(warnings.len(), 1);
1109        assert_eq!(warnings[0].line, 2);
1110    }
1111
1112    #[test]
1113    fn tight_continuation_multiple_over_indented_lines() {
1114        let content = "1. Item\n    line one\n    line two\n    line three\n";
1115        let warnings = check(content);
1116        assert_eq!(warnings.len(), 3);
1117    }
1118
1119    #[test]
1120    fn tight_continuation_mixed_correct_and_over() {
1121        let content = "1. Item\n   correct\n    over-indented\n   correct again\n";
1122        let warnings = check(content);
1123        assert_eq!(warnings.len(), 1);
1124        assert_eq!(warnings[0].line, 3);
1125    }
1126
1127    #[test]
1128    fn tight_continuation_nested_over_indented() {
1129        // L2 "- " at column 2, content_column = 4. Continuation at 5 is over-indented for L2.
1130        let content = "- L1\n  - L2\n     over-indented continuation of L2\n";
1131        let warnings = check(content);
1132        assert_eq!(warnings.len(), 1);
1133        assert_eq!(warnings[0].line, 3);
1134        // Must report expected=4 (L2's content_col), not expected=2 (L1's)
1135        assert!(warnings[0].message.contains("expected 4"));
1136        assert!(warnings[0].message.contains("found 5"));
1137    }
1138
1139    #[test]
1140    fn tight_continuation_nested_correct_indent_not_flagged() {
1141        // Continuation at 4 spaces is correct for L2 (content_col=4). Must NOT be
1142        // flagged as over-indented relative to L1 (content_col=2).
1143        let content = "- L1\n  - L2\n    correctly indented continuation of L2\n";
1144        assert!(check(content).is_empty());
1145    }
1146
1147    #[test]
1148    fn fix_tight_continuation_nested_over_indented() {
1149        // Fix should reduce to 4 spaces (L2's content_col), not 2 (L1's)
1150        let content = "- L1\n  - L2\n     over-indented continuation of L2\n";
1151        let fixed = fix(content);
1152        assert_eq!(fixed, "- L1\n  - L2\n    over-indented continuation of L2\n");
1153    }
1154
1155    #[test]
1156    fn tight_continuation_under_indented_not_flagged() {
1157        // 2 spaces instead of 3 for "1. " — under-indented, not over-indented.
1158        // Valid lazy continuation in CommonMark, so not flagged.
1159        let content = "1. Item\n  under-indented\n";
1160        assert!(check(content).is_empty());
1161    }
1162
1163    #[test]
1164    fn tight_continuation_tab_over_indented() {
1165        // A tab expands to 4 visual columns, which exceeds content_col=2 for "- "
1166        let content = "- Item\n\tover-indented\n";
1167        let warnings = check(content);
1168        assert_eq!(warnings.len(), 1);
1169    }
1170
1171    #[test]
1172    fn fix_tight_continuation_over_indented_ordered() {
1173        let content = "1. This is a list item with multiple lines.\n    The second line is over-indented.\n";
1174        let fixed = fix(content);
1175        assert_eq!(
1176            fixed,
1177            "1. This is a list item with multiple lines.\n   The second line is over-indented.\n"
1178        );
1179    }
1180
1181    #[test]
1182    fn fix_tight_continuation_over_indented_unordered() {
1183        let content = "- Item\n   over-indented\n";
1184        let fixed = fix(content);
1185        assert_eq!(fixed, "- Item\n  over-indented\n");
1186    }
1187
1188    #[test]
1189    fn fix_tight_continuation_multiple_lines() {
1190        let content = "1. Item\n    line one\n    line two\n";
1191        let fixed = fix(content);
1192        assert_eq!(fixed, "1. Item\n   line one\n   line two\n");
1193    }
1194
1195    #[test]
1196    fn tight_continuation_mkdocs_4space_ordered_not_flagged() {
1197        // MkDocs requires max(3, 4) = 4 spaces for "1. " items.
1198        // 4-space tight continuation is correct, not over-indented.
1199        let content = "1. Item\n    continuation\n";
1200        assert!(check_mkdocs(content).is_empty());
1201    }
1202
1203    #[test]
1204    fn tight_continuation_mkdocs_5space_ordered_flagged() {
1205        // 5 spaces exceeds the MkDocs required indent of 4
1206        let content = "1. Item\n     over-indented\n";
1207        let warnings = check_mkdocs(content);
1208        assert_eq!(warnings.len(), 1);
1209        assert!(warnings[0].message.contains("expected 4"));
1210        assert!(warnings[0].message.contains("found 5"));
1211    }
1212
1213    #[test]
1214    fn fix_tight_continuation_mkdocs_over_indented() {
1215        let content = "1. Item\n     over-indented\n";
1216        let fixed = fix_mkdocs(content);
1217        assert_eq!(fixed, "1. Item\n    over-indented\n");
1218    }
1219
1220    #[test]
1221    fn tight_continuation_deeply_indented_list_markers_not_flagged() {
1222        // Deeply indented list markers (e.g., indent=8 in MD007) may not be
1223        // recognized as list items by the parser. MD077 must not flag them.
1224        let content = "* Level 0\n        * Level 1\n                * Level 2\n";
1225        assert!(check(content).is_empty());
1226    }
1227
1228    #[test]
1229    fn tight_continuation_ordered_marker_not_flagged() {
1230        // Indented ordered list marker should not be flagged
1231        let content = "- Parent\n      1. Child item\n";
1232        assert!(check(content).is_empty());
1233    }
1234
1235    // ── Unordered list: correct indent after blank ────────────────────
1236
1237    #[test]
1238    fn unordered_correct_indent_no_warning() {
1239        let content = "- Item\n\n  continuation\n";
1240        assert!(check(content).is_empty());
1241    }
1242
1243    #[test]
1244    fn unordered_partial_indent_warns() {
1245        // Content with some indent (above marker column) but less than
1246        // content_column is likely an indentation mistake.
1247        let content = "- Item\n\n continuation\n";
1248        let warnings = check(content);
1249        assert_eq!(warnings.len(), 1);
1250        assert_eq!(warnings[0].line, 3);
1251        assert!(warnings[0].message.contains("2 spaces"));
1252        assert!(warnings[0].message.contains("found 1"));
1253    }
1254
1255    #[test]
1256    fn unordered_zero_indent_is_new_paragraph() {
1257        // Content at 0 indent after a top-level list is a new paragraph, not
1258        // under-indented continuation.
1259        let content = "- Item\n\ncontinuation\n";
1260        assert!(check(content).is_empty());
1261    }
1262
1263    // ── Ordered list: CommonMark W+N ──────────────────────────────────
1264
1265    #[test]
1266    fn ordered_3space_correct_commonmark() {
1267        // "1. " is 3 chars, content_column = 3
1268        let content = "1. Item\n\n   continuation\n";
1269        assert!(check(content).is_empty());
1270    }
1271
1272    #[test]
1273    fn ordered_2space_under_indent_commonmark() {
1274        let content = "1. Item\n\n  continuation\n";
1275        let warnings = check(content);
1276        assert_eq!(warnings.len(), 1);
1277        assert!(warnings[0].message.contains("3 spaces"));
1278        assert!(warnings[0].message.contains("found 2"));
1279    }
1280
1281    // ── Multi-digit ordered markers ───────────────────────────────────
1282
1283    #[test]
1284    fn multi_digit_marker_correct() {
1285        // "10. " is 4 chars, content_column = 4
1286        let content = "10. Item\n\n    continuation\n";
1287        assert!(check(content).is_empty());
1288    }
1289
1290    #[test]
1291    fn multi_digit_marker_under_indent() {
1292        let content = "10. Item\n\n   continuation\n";
1293        let warnings = check(content);
1294        assert_eq!(warnings.len(), 1);
1295        assert!(warnings[0].message.contains("4 spaces"));
1296    }
1297
1298    // ── MkDocs flavor: 4-space minimum ────────────────────────────────
1299
1300    #[test]
1301    fn mkdocs_3space_ordered_warns() {
1302        // In MkDocs mode, 3-space indent on "1. " is not enough
1303        let content = "1. Item\n\n   continuation\n";
1304        let warnings = check_mkdocs(content);
1305        assert_eq!(warnings.len(), 1);
1306        assert!(warnings[0].message.contains("4 spaces"));
1307        assert!(warnings[0].message.contains("MkDocs"));
1308    }
1309
1310    #[test]
1311    fn mkdocs_4space_ordered_no_warning() {
1312        let content = "1. Item\n\n    continuation\n";
1313        assert!(check_mkdocs(content).is_empty());
1314    }
1315
1316    #[test]
1317    fn mkdocs_unordered_2space_ok() {
1318        // Unordered "- " has content_column = 2; max(2, 4) = 4 in mkdocs
1319        let content = "- Item\n\n    continuation\n";
1320        assert!(check_mkdocs(content).is_empty());
1321    }
1322
1323    #[test]
1324    fn mkdocs_unordered_2space_warns() {
1325        // "- " has content_column 2; MkDocs requires max(2,4) = 4
1326        let content = "- Item\n\n  continuation\n";
1327        let warnings = check_mkdocs(content);
1328        assert_eq!(warnings.len(), 1);
1329        assert!(warnings[0].message.contains("4 spaces"));
1330    }
1331
1332    // ── Auto-fix ──────────────────────────────────────────────────────
1333
1334    #[test]
1335    fn fix_unordered_indent() {
1336        // Partial indent (above marker column, below content column) gets fixed
1337        let content = "- Item\n\n continuation\n";
1338        let fixed = fix(content);
1339        assert_eq!(fixed, "- Item\n\n  continuation\n");
1340    }
1341
1342    #[test]
1343    fn fix_ordered_indent() {
1344        let content = "1. Item\n\n continuation\n";
1345        let fixed = fix(content);
1346        assert_eq!(fixed, "1. Item\n\n   continuation\n");
1347    }
1348
1349    #[test]
1350    fn fix_mkdocs_indent() {
1351        let content = "1. Item\n\n   continuation\n";
1352        let fixed = fix_mkdocs(content);
1353        assert_eq!(fixed, "1. Item\n\n    continuation\n");
1354    }
1355
1356    // ── Nested lists: only flag continuation, not sub-items ───────────
1357
1358    #[test]
1359    fn nested_list_items_not_flagged() {
1360        let content = "- Parent\n\n  - Child\n";
1361        assert!(check(content).is_empty());
1362    }
1363
1364    #[test]
1365    fn nested_list_zero_indent_is_new_paragraph() {
1366        // Content at 0 indent ends the list, not continuation
1367        let content = "- Parent\n  - Child\n\ncontinuation of parent\n";
1368        assert!(check(content).is_empty());
1369    }
1370
1371    #[test]
1372    fn nested_list_partial_indent_flagged() {
1373        // Content with partial indent (above parent marker, below content col)
1374        let content = "- Parent\n  - Child\n\n continuation of parent\n";
1375        let warnings = check(content);
1376        assert_eq!(warnings.len(), 1);
1377        assert!(warnings[0].message.contains("2 spaces"));
1378    }
1379
1380    // ── Code blocks inside items ─────────────────────────────────────
1381
1382    #[test]
1383    fn code_block_correctly_indented_no_warning() {
1384        // Fence lines and content all at correct indent for "- " (content_column = 2)
1385        let content = "- Item\n\n  ```\n  code\n  ```\n";
1386        assert!(check(content).is_empty());
1387    }
1388
1389    #[test]
1390    fn code_fence_under_indented_warns() {
1391        // Fence opener has 1-space indent, but "- " needs 2.
1392        // Only the opener is flagged — its compound fix also covers the
1393        // interior content and the matching closer (see issue #574).
1394        let content = "- Item\n\n ```\n code\n ```\n";
1395        let warnings = check(content);
1396        assert_eq!(warnings.len(), 1);
1397        assert_eq!(warnings[0].line, 3);
1398    }
1399
1400    #[test]
1401    fn code_fence_under_indented_ordered_mkdocs() {
1402        // Ordered list in MkDocs: "1. " needs max(3, 4) = 4 spaces
1403        // Fence at 3 spaces is correct for CommonMark but wrong for MkDocs
1404        let content = "1. Item\n\n   ```toml\n   key = \"value\"\n   ```\n";
1405        assert!(check(content).is_empty()); // Standard mode: 3 is fine
1406        let warnings = check_mkdocs(content);
1407        assert_eq!(warnings.len(), 1); // MkDocs: opener's compound fix covers the whole block
1408        assert_eq!(warnings[0].line, 3);
1409        assert!(warnings[0].message.contains("4 spaces"));
1410        assert!(warnings[0].message.contains("MkDocs"));
1411    }
1412
1413    #[test]
1414    fn code_fence_tilde_under_indented() {
1415        let content = "- Item\n\n ~~~\n code\n ~~~\n";
1416        let warnings = check(content);
1417        assert_eq!(warnings.len(), 1); // Tilde fences: single compound-fix warning on opener
1418        assert_eq!(warnings[0].line, 3);
1419    }
1420
1421    // ── Multiple blank lines ──────────────────────────────────────────
1422
1423    #[test]
1424    fn multiple_blank_lines_zero_indent_is_new_paragraph() {
1425        // Even with multiple blanks, 0-indent content is a new paragraph
1426        let content = "- Item\n\n\ncontinuation\n";
1427        assert!(check(content).is_empty());
1428    }
1429
1430    #[test]
1431    fn multiple_blank_lines_partial_indent_flags() {
1432        let content = "- Item\n\n\n continuation\n";
1433        let warnings = check(content);
1434        assert_eq!(warnings.len(), 1);
1435    }
1436
1437    // ── Empty items: no continuation to check ─────────────────────────
1438
1439    #[test]
1440    fn empty_item_no_warning() {
1441        let content = "- \n- Second\n";
1442        assert!(check(content).is_empty());
1443    }
1444
1445    // ── Multiple items, only some under-indented ──────────────────────
1446
1447    #[test]
1448    fn multiple_items_mixed_indent() {
1449        let content = "1. First\n\n   correct continuation\n\n2. Second\n\n  wrong continuation\n";
1450        let warnings = check(content);
1451        assert_eq!(warnings.len(), 1);
1452        assert_eq!(warnings[0].line, 7);
1453    }
1454
1455    // ── Task list items ───────────────────────────────────────────────
1456
1457    #[test]
1458    fn task_list_correct_indent() {
1459        // "- [ ] " = content_column is typically at col 6
1460        let content = "- [ ] Task\n\n      continuation\n";
1461        assert!(check(content).is_empty());
1462    }
1463
1464    // ── Frontmatter skipped ───────────────────────────────────────────
1465
1466    #[test]
1467    fn frontmatter_not_flagged() {
1468        let content = "---\ntitle: test\n---\n\n- Item\n\n  continuation\n";
1469        assert!(check(content).is_empty());
1470    }
1471
1472    // ── Fix produces valid output with multiple fixes ─────────────────
1473
1474    #[test]
1475    fn fix_multiple_items() {
1476        let content = "1. First\n\n wrong1\n\n2. Second\n\n wrong2\n";
1477        let fixed = fix(content);
1478        assert_eq!(fixed, "1. First\n\n   wrong1\n\n2. Second\n\n   wrong2\n");
1479    }
1480
1481    #[test]
1482    fn fix_multiline_loose_continuation_all_lines() {
1483        let content = "1. Item\n\n  line one\n  line two\n  line three\n";
1484        let fixed = fix(content);
1485        assert_eq!(fixed, "1. Item\n\n   line one\n   line two\n   line three\n");
1486    }
1487
1488    // ── No false positive when content is after sibling item ──────────
1489
1490    #[test]
1491    fn sibling_item_boundary_respected() {
1492        // The "continuation" after a blank belongs to "- Second", not "- First"
1493        let content = "- First\n- Second\n\n  continuation\n";
1494        assert!(check(content).is_empty());
1495    }
1496
1497    // ── Blockquote-nested lists ────────────────────────────────────────
1498
1499    #[test]
1500    fn blockquote_list_correct_indent_no_warning() {
1501        // Lists inside blockquotes: visual_indent includes the blockquote
1502        // prefix, so comparisons work on raw line columns.
1503        let content = "> - Item\n>\n>   continuation\n";
1504        assert!(check(content).is_empty());
1505    }
1506
1507    #[test]
1508    fn blockquote_list_under_indent_no_false_positive() {
1509        // Under-indented continuation inside a blockquote: visual_indent
1510        // starts at 0 (the `>` char) which is <= marker_col, so the scan
1511        // breaks and no warning is emitted. This is a known false negative
1512        // (not a false positive), which is the safer default.
1513        let content = "> - Item\n>\n> continuation\n";
1514        assert!(check(content).is_empty());
1515    }
1516
1517    // ── Deep nesting (3+ levels) ──────────────────────────────────────
1518
1519    #[test]
1520    fn deep_nesting_correct_indent() {
1521        let content = "- L1\n  - L2\n    - L3\n\n      continuation of L3\n";
1522        assert!(check(content).is_empty());
1523    }
1524
1525    #[test]
1526    fn deep_nesting_under_indent() {
1527        // L3 starts at column 4 with "- " marker, content_column = 6
1528        // Continuation with 5 spaces is under-indented for L3.
1529        let content = "- L1\n  - L2\n    - L3\n\n     continuation of L3\n";
1530        let warnings = check(content);
1531        assert_eq!(warnings.len(), 1);
1532        assert!(warnings[0].message.contains("6 spaces"));
1533        assert!(warnings[0].message.contains("found 5"));
1534    }
1535
1536    #[test]
1537    fn deep_nesting_middle_level_continuation_bullets() {
1538        // A loose continuation of the MIDDLE level (L2, content_column = 4)
1539        // after a deeper L3 list is valid; the L1 walk must not claim it as
1540        // its own over-indented continuation (issue #739).
1541        let content = "- L1\n  - L2\n    - L3\n\n    continuation of L2\n";
1542        assert!(check(content).is_empty());
1543    }
1544
1545    #[test]
1546    fn deep_nesting_middle_level_continuation_ordered() {
1547        // Issue #739 repro: ordered lists, continuation of the level-2 item
1548        // (content_column = 6) after a level-3 bullet list.
1549        let content = "1. Level 1 item.\n1. Level 1 item:\n   1. Level 2 item.\n   1. Level 2 item.\n   1. Level 2 item:\n      - Level 3 item.\n      - Level 3 item.\n\n      Level 2 list continuation.\n1. Level 1 item.\n";
1550        assert!(check(content).is_empty());
1551    }
1552
1553    #[test]
1554    fn deep_nesting_outermost_continuation() {
1555        // A loose continuation of the OUTERMOST level (L1, content_column = 2)
1556        // past two open nested levels escapes both and stays valid.
1557        let content = "- L1\n  - L2\n    - L3\n\n  continuation of L1\n";
1558        assert!(check(content).is_empty());
1559    }
1560
1561    #[test]
1562    fn deep_nesting_between_levels_still_flagged() {
1563        // Col 3 sits strictly between L1's content column (2) and L2's (4):
1564        // the deepest applicable item (L2) claims it as loose under-indent.
1565        let content = "- L1\n  - L2\n    - L3\n\n   continuation\n";
1566        let warnings = check(content);
1567        assert_eq!(warnings.len(), 1);
1568        assert!(warnings[0].message.contains("4 spaces"));
1569        assert!(warnings[0].message.contains("found 3"));
1570    }
1571
1572    #[test]
1573    fn deep_nesting_beyond_deepest_still_flagged() {
1574        // Col 7 exceeds even L3's content column (6): over-indent for L3.
1575        let content = "- L1\n  - L2\n    - L3\n\n       continuation\n";
1576        let warnings = check(content);
1577        assert_eq!(warnings.len(), 1);
1578        assert!(warnings[0].message.contains("over-indented"));
1579        assert!(warnings[0].message.contains("expected 6, found 7"));
1580    }
1581
1582    #[test]
1583    fn four_levels_middle_continuation() {
1584        // Continuation of L2 (content_column = 4) after open L3 and L4 lists:
1585        // the walk must pop two deeper levels and still defer to L2.
1586        let content = "- L1\n  - L2\n    - L3\n      - L4\n\n    continuation of L2\n";
1587        assert!(check(content).is_empty());
1588    }
1589
1590    #[test]
1591    fn nested_sibling_closes_deeper_level() {
1592        // L2b closes L2a's deeper L3 list; a continuation at L2b's content
1593        // column belongs to L2b and stays valid.
1594        let content = "- L1\n  - L2a\n    - L3\n  - L2b\n\n    continuation of L2b\n";
1595        assert!(check(content).is_empty());
1596    }
1597
1598    #[test]
1599    fn deep_nesting_middle_level_continuation_fix_preserved() {
1600        // The valid middle-level continuation must survive fix() unchanged.
1601        let content = "- L1\n  - L2\n    - L3\n\n    continuation of L2\n";
1602        assert_eq!(fix(content), content);
1603    }
1604
1605    // ── Tab indentation ───────────────────────────────────────────────
1606
1607    #[test]
1608    fn loose_tab_continuation_over_indented() {
1609        // A tab expands to 4 visual columns, exceeding content_column = 2 for
1610        // "- ". Loose over-indent is flagged just like the tight tab case
1611        // (`tight_continuation_tab_over_indented`), and the fix normalizes the
1612        // tab down to the content-column indent.
1613        let content = "- Item\n\n\tcontinuation\n";
1614        let warnings = check(content);
1615        assert_eq!(warnings.len(), 1);
1616        assert_eq!(warnings[0].line, 3);
1617        assert_eq!(fix(content), "- Item\n\n  continuation\n");
1618    }
1619
1620    // ── Multiple continuation paragraphs ──────────────────────────────
1621
1622    #[test]
1623    fn multiple_continuations_correct() {
1624        let content = "- Item\n\n  para 1\n\n  para 2\n\n  para 3\n";
1625        assert!(check(content).is_empty());
1626    }
1627
1628    #[test]
1629    fn multiple_continuations_second_under_indent() {
1630        // First continuation is correct, second is under-indented
1631        let content = "- Item\n\n  para 1\n\n continuation 2\n";
1632        let warnings = check(content);
1633        assert_eq!(warnings.len(), 1);
1634        assert_eq!(warnings[0].line, 5);
1635    }
1636
1637    // ── Ordered list with `)` marker style ────────────────────────────
1638
1639    #[test]
1640    fn ordered_paren_marker_correct() {
1641        // "1) " is 3 chars, content_column = 3
1642        let content = "1) Item\n\n   continuation\n";
1643        assert!(check(content).is_empty());
1644    }
1645
1646    #[test]
1647    fn ordered_paren_marker_under_indent() {
1648        let content = "1) Item\n\n  continuation\n";
1649        let warnings = check(content);
1650        assert_eq!(warnings.len(), 1);
1651        assert!(warnings[0].message.contains("3 spaces"));
1652    }
1653
1654    // ── Star and plus markers ─────────────────────────────────────────
1655
1656    #[test]
1657    fn star_marker_correct() {
1658        let content = "* Item\n\n  continuation\n";
1659        assert!(check(content).is_empty());
1660    }
1661
1662    #[test]
1663    fn star_marker_under_indent() {
1664        let content = "* Item\n\n continuation\n";
1665        let warnings = check(content);
1666        assert_eq!(warnings.len(), 1);
1667    }
1668
1669    #[test]
1670    fn plus_marker_correct() {
1671        let content = "+ Item\n\n  continuation\n";
1672        assert!(check(content).is_empty());
1673    }
1674
1675    // ── Heading breaks scan ───────────────────────────────────────────
1676
1677    #[test]
1678    fn heading_after_list_no_warning() {
1679        let content = "- Item\n\n# Heading\n";
1680        assert!(check(content).is_empty());
1681    }
1682
1683    // ── Horizontal rule breaks scan ───────────────────────────────────
1684
1685    #[test]
1686    fn hr_after_list_no_warning() {
1687        let content = "- Item\n\n---\n";
1688        assert!(check(content).is_empty());
1689    }
1690
1691    // ── Reference link definitions skip ───────────────────────────────
1692
1693    #[test]
1694    fn reference_link_def_not_flagged() {
1695        let content = "- Item\n\n [link]: https://example.com\n";
1696        assert!(check(content).is_empty());
1697    }
1698
1699    // ── Footnote definitions skip ─────────────────────────────────────
1700
1701    #[test]
1702    fn footnote_def_not_flagged() {
1703        let content = "- Item\n\n [^1]: footnote text\n";
1704        assert!(check(content).is_empty());
1705    }
1706
1707    #[test]
1708    fn footnote_multiline_body_after_list_not_flagged() {
1709        // A footnote definition whose indented body follows a list must not be
1710        // read as over-indented list continuation. The 4-space body lines
1711        // belong to the footnote, not to `- This is a list.` (content col 2).
1712        let content = "# A list followed by a footnote\n\n\
1713                       Here is a paragraph.[^fn]\n\n\
1714                       - This is a list.\n\n\
1715                       [^fn]:\n\
1716                       \x20\x20\x20\x20Here is a footnote that spans multiple lines.\n\
1717                       \x20\x20\x20\x20It should thus be indented by at least four spaces.\n";
1718        assert!(check(content).is_empty());
1719    }
1720
1721    #[test]
1722    fn fix_footnote_multiline_body_after_list_is_noop() {
1723        // The false positive also produced a damaging auto-fix: reindenting the
1724        // footnote body from 4 spaces to 2 broke the footnote (then tripped
1725        // MD068). The fix must leave the footnote untouched.
1726        let content = "# A list followed by a footnote\n\n\
1727                       Here is a paragraph.[^fn]\n\n\
1728                       - This is a list.\n\n\
1729                       [^fn]:\n\
1730                       \x20\x20\x20\x20Here is a footnote that spans multiple lines.\n\
1731                       \x20\x20\x20\x20It should thus be indented by at least four spaces.\n";
1732        assert_eq!(fix(content), content);
1733    }
1734
1735    #[test]
1736    fn footnote_body_indented_past_list_content_col_not_flagged() {
1737        // Footnote body indented well past the list's content column (here a
1738        // single-space list marker would put content col at 2, body at 4) is
1739        // footnote content, not over-indented continuation.
1740        let content = "- Item\n\n[^fn]:\n    Body line one.\n    Body line two.\n";
1741        assert!(check(content).is_empty());
1742    }
1743
1744    #[test]
1745    fn list_inside_footnote_body_continuation_not_flagged() {
1746        // Accepted trade-off: a list living *inside* a footnote body is skipped
1747        // wholesale, so MD077 does not police its continuation indentation.
1748        // Consistent with MD007, which also skips footnote-internal content.
1749        let content = "Text.[^fn]\n\n[^fn]:\n\
1750                       \x20\x20\x20\x20- nested item\n\
1751                       \x20\x20\x20\x20\x20\x20\x20over-indented continuation\n";
1752        assert!(check(content).is_empty());
1753    }
1754
1755    #[test]
1756    fn footnote_multiline_body_after_list_not_flagged_mkdocs() {
1757        // Same protection under the MkDocs flavor. The body sits at 6 spaces,
1758        // past MkDocs's required indent of 4 for `- `, so without skipping
1759        // footnote content it would be flagged as over-indented continuation.
1760        let content = "Here is a paragraph.[^fn]\n\n\
1761                       - This is a list.\n\n\
1762                       [^fn]:\n\
1763                       \x20\x20\x20\x20\x20\x20Footnote body that spans\n\
1764                       \x20\x20\x20\x20\x20\x20multiple indented lines.\n";
1765        assert!(check_mkdocs(content).is_empty());
1766    }
1767
1768    // ── Fix preserves correct content ─────────────────────────────────
1769
1770    #[test]
1771    fn fix_deep_nesting() {
1772        let content = "- L1\n  - L2\n    - L3\n\n     under-indented\n";
1773        let fixed = fix(content);
1774        assert_eq!(fixed, "- L1\n  - L2\n    - L3\n\n      under-indented\n");
1775    }
1776
1777    #[test]
1778    fn fix_mkdocs_unordered() {
1779        // MkDocs: "- " has content_column 2, but MkDocs requires max(2,4) = 4
1780        let content = "- Item\n\n  continuation\n";
1781        let fixed = fix_mkdocs(content);
1782        assert_eq!(fixed, "- Item\n\n    continuation\n");
1783    }
1784
1785    #[test]
1786    fn fix_code_fence_indent() {
1787        // Fence opener, interior, and closer all shift by the same delta so
1788        // the parser keeps pairing the fences and MD031 doesn't misfire.
1789        let content = "- Item\n\n ```\n code\n ```\n";
1790        let fixed = fix(content);
1791        assert_eq!(fixed, "- Item\n\n  ```\n  code\n  ```\n");
1792    }
1793
1794    #[test]
1795    fn fix_mkdocs_code_fence_indent() {
1796        // MkDocs ordered list: fence at 3 spaces needs 4; interior shifts too
1797        let content = "1. Item\n\n   ```toml\n   key = \"val\"\n   ```\n";
1798        let fixed = fix_mkdocs(content);
1799        assert_eq!(fixed, "1. Item\n\n    ```toml\n    key = \"val\"\n    ```\n");
1800    }
1801
1802    // ── Empty document / whitespace-only ──────────────────────────────
1803
1804    #[test]
1805    fn empty_document_no_warning() {
1806        assert!(check("").is_empty());
1807    }
1808
1809    #[test]
1810    fn whitespace_only_no_warning() {
1811        assert!(check("   \n\n  \n").is_empty());
1812    }
1813
1814    // ── No list at all ────────────────────────────────────────────────
1815
1816    #[test]
1817    fn no_list_no_warning() {
1818        let content = "# Heading\n\nSome paragraph.\n\nAnother paragraph.\n";
1819        assert!(check(content).is_empty());
1820    }
1821
1822    // ── Multi-line continuation (additional coverage) ──────────────
1823
1824    #[test]
1825    fn multiline_continuation_all_lines_flagged() {
1826        let content = "1. This is a list item.\n\n  This is continuation text and\n  it has multiple lines.\n  This is yet another line.\n";
1827        let warnings = check(content);
1828        assert_eq!(warnings.len(), 3);
1829        assert_eq!(warnings[0].line, 3);
1830        assert_eq!(warnings[1].line, 4);
1831        assert_eq!(warnings[2].line, 5);
1832    }
1833
1834    #[test]
1835    fn multiline_continuation_with_frontmatter_fix() {
1836        let content = "---\ntitle: Heading\n---\n\nSome introductory text:\n\n1. This is a list item.\n\n  This is list continuation text and\n  it has multiple lines that aren't indented properly.\n  This is yet another line that isn't indented properly.\n1. This is a list item.\n\n  This is list continuation text and\n  it has multiple lines that aren't indented properly.\n  This is yet another line that isn't indented properly.\n";
1837        let fixed = fix(content);
1838        assert_eq!(
1839            fixed,
1840            "---\ntitle: Heading\n---\n\nSome introductory text:\n\n1. This is a list item.\n\n   This is list continuation text and\n   it has multiple lines that aren't indented properly.\n   This is yet another line that isn't indented properly.\n1. This is a list item.\n\n   This is list continuation text and\n   it has multiple lines that aren't indented properly.\n   This is yet another line that isn't indented properly.\n"
1841        );
1842    }
1843
1844    #[test]
1845    fn multiline_continuation_correct_indent_no_warning() {
1846        let content = "1. Item\n\n   line one\n   line two\n   line three\n";
1847        assert!(check(content).is_empty());
1848    }
1849
1850    #[test]
1851    fn multiline_continuation_mixed_indent() {
1852        let content = "1. Item\n\n   correct\n  wrong\n   correct\n";
1853        let warnings = check(content);
1854        assert_eq!(warnings.len(), 1);
1855        assert_eq!(warnings[0].line, 4);
1856    }
1857
1858    #[test]
1859    fn multiline_continuation_unordered() {
1860        let content = "- Item\n\n continuation 1\n continuation 2\n continuation 3\n";
1861        let warnings = check(content);
1862        assert_eq!(warnings.len(), 3);
1863        let fixed = fix(content);
1864        assert_eq!(
1865            fixed,
1866            "- Item\n\n  continuation 1\n  continuation 2\n  continuation 3\n"
1867        );
1868    }
1869
1870    #[test]
1871    fn multiline_continuation_two_items_fix() {
1872        let content = "1. First\n\n  cont a\n  cont b\n\n2. Second\n\n  cont c\n  cont d\n";
1873        let fixed = fix(content);
1874        assert_eq!(
1875            fixed,
1876            "1. First\n\n   cont a\n   cont b\n\n2. Second\n\n   cont c\n   cont d\n"
1877        );
1878    }
1879
1880    #[test]
1881    fn fence_fix_does_not_break_pairing_for_md031() {
1882        // Regression for issue #574: previously MD077 only reindented the
1883        // fence delimiter lines while leaving the code block's interior at
1884        // the old indent. Between iterations of the fix loop the parser
1885        // saw an opener-closer mismatch, and MD031 then injected stray
1886        // blank lines at the fence boundaries. MD077's compound fix must
1887        // now rewrite the whole block atomically so the fences stay paired.
1888        let content = "#### title\n\nabc\n\n\
1889                       1. ab\n\n\
1890                       \x20\x20`aabbccdd`\n\n\
1891                       2. cd\n\n\
1892                       \x20\x20`bbcc dd ee`\n\n\
1893                       \x20\x20```\n\
1894                       \x20\x20abcd\n\
1895                       \x20\x20ef gh\n\
1896                       \x20\x20```\n\n\
1897                       \x20\x20uu\n\n\
1898                       \x20\x20```\n\
1899                       \x20\x20cdef\n\
1900                       \x20\x20gh ij\n\
1901                       \x20\x20```\n";
1902        let expected = "#### title\n\nabc\n\n\
1903                        1. ab\n\n\
1904                        \x20\x20\x20`aabbccdd`\n\n\
1905                        2. cd\n\n\
1906                        \x20\x20\x20`bbcc dd ee`\n\n\
1907                        \x20\x20\x20```\n\
1908                        \x20\x20\x20abcd\n\
1909                        \x20\x20\x20ef gh\n\
1910                        \x20\x20\x20```\n\n\
1911                        \x20\x20\x20uu\n\n\
1912                        \x20\x20\x20```\n\
1913                        \x20\x20\x20cdef\n\
1914                        \x20\x20\x20gh ij\n\
1915                        \x20\x20\x20```\n";
1916        assert_eq!(fix(content), expected);
1917    }
1918
1919    #[test]
1920    fn multiline_continuation_separated_by_blank() {
1921        let content = "1. Item\n\n  para1 line1\n  para1 line2\n\n  para2 line1\n  para2 line2\n";
1922        let warnings = check(content);
1923        assert_eq!(warnings.len(), 4);
1924        let fixed = fix(content);
1925        assert_eq!(
1926            fixed,
1927            "1. Item\n\n   para1 line1\n   para1 line2\n\n   para2 line1\n   para2 line2\n"
1928        );
1929    }
1930
1931    #[test]
1932    fn tab_indented_fence_is_normalized_to_spaces() {
1933        // Leading tabs expand to the next multiple-of-4 column under
1934        // CommonMark, so simply prepending spaces before a tab would
1935        // silently no-op (the tab snaps back to column 4). The compound
1936        // fence fix must replace the leading whitespace with a fresh
1937        // (visual_indent + delta) run of spaces. A `100. ` item has
1938        // content_column = 5, so a tab-indented fence (visual col 4) is
1939        // under-indented by 1 and must end up at 5 spaces after the fix.
1940        let content = "100. ab\n\n\t```\n\tabcd\n\t```\n";
1941        let expected = "100. ab\n\n     ```\n     abcd\n     ```\n";
1942        assert_eq!(fix(content), expected);
1943    }
1944
1945    // ── Loose continuation (after a blank line): over-indent ──────────
1946    //
1947    // Over-indentation is a mistake in both tight and loose continuation:
1948    // the body looks aligned but isn't. A blank line between the marker and
1949    // the body must not exempt it. The only over-indent that is intentional
1950    // after a blank line is an indented code block (content column + 4 or
1951    // more), which the parser marks `in_code_block` and the rule skips.
1952
1953    #[test]
1954    fn loose_continuation_over_indented_flagged() {
1955        // "* " content column is 2; 3 spaces after a blank is over-indented
1956        // (the code-block threshold is content_col + 4 = 6).
1957        let content = "* Item\n\n   over-indented\n";
1958        let warnings = check(content);
1959        assert_eq!(warnings.len(), 1);
1960        assert_eq!(warnings[0].line, 3);
1961        assert!(warnings[0].message.contains("over-indented"));
1962        assert!(warnings[0].message.contains("expected 2"));
1963        assert!(warnings[0].message.contains("found 3"));
1964    }
1965
1966    #[test]
1967    fn loose_continuation_over_indented_multiline_mixed() {
1968        // Over, correct, over — only the two over-indented lines are flagged.
1969        let content = "* Item\n\n   over one\n  correct\n   over two\n";
1970        let warnings = check(content);
1971        assert_eq!(warnings.len(), 2);
1972        assert_eq!(warnings[0].line, 3);
1973        assert_eq!(warnings[1].line, 5);
1974    }
1975
1976    #[test]
1977    fn fix_loose_continuation_over_indented() {
1978        let content = "* Item\n\n   over one\n  correct\n   over two\n";
1979        let fixed = fix(content);
1980        assert_eq!(fixed, "* Item\n\n  over one\n  correct\n  over two\n");
1981    }
1982
1983    #[test]
1984    fn fix_tight_and_loose_items_normalized_identically() {
1985        // The reported document: a tight item and a loose item with the same
1986        // over-indented body must both normalize to the content column.
1987        let content = "---\ntitle: Heading\n---\n\nSome introductory text:\n\n\
1988                       * This is a list item.\n   This is list continuation text and\n  it has multiple lines that aren't indented properly.\n   This is yet another line that isn't indented properly.\n\n\
1989                       * This is a list item.\n\n   This is list continuation text and\n  it has multiple lines that aren't indented properly.\n   This is yet another line that isn't indented properly.\n";
1990        let expected = "---\ntitle: Heading\n---\n\nSome introductory text:\n\n\
1991                        * This is a list item.\n  This is list continuation text and\n  it has multiple lines that aren't indented properly.\n  This is yet another line that isn't indented properly.\n\n\
1992                        * This is a list item.\n\n  This is list continuation text and\n  it has multiple lines that aren't indented properly.\n  This is yet another line that isn't indented properly.\n";
1993        assert_eq!(fix(content), expected);
1994    }
1995
1996    #[test]
1997    fn multi_paragraph_item_loose_paragraph_over_indented() {
1998        // A tight first paragraph and a loose second paragraph (after an
1999        // internal blank line) are both over-indented; both must be flagged.
2000        let content = "* Item.\n   tight over\n\n   loose over\n";
2001        let warnings = check(content);
2002        assert_eq!(warnings.len(), 2);
2003        assert_eq!(warnings[0].line, 2);
2004        assert_eq!(warnings[1].line, 4);
2005    }
2006
2007    #[test]
2008    fn loose_indented_code_block_not_flagged() {
2009        // content_col = 2; a loose line at content_col + 4 (6 spaces) is a
2010        // CommonMark indented code block, not over-indented prose. The over-
2011        // indent check must never reach it (it is `in_code_block` and skipped).
2012        let content = "- Item\n\n      code line\n";
2013        assert!(check(content).is_empty());
2014    }
2015
2016    #[test]
2017    fn mkdocs_loose_over_indented_flagged() {
2018        // MkDocs requires max(3, 4) = 4 for "1. ". A loose line at 5 spaces is
2019        // over-indented (code-block threshold is content_col + 4 = 7).
2020        let content = "1. Item\n\n     over\n";
2021        let warnings = check_mkdocs(content);
2022        assert_eq!(warnings.len(), 1);
2023        assert_eq!(warnings[0].line, 3);
2024        assert!(warnings[0].message.contains("over-indented"));
2025        assert!(warnings[0].message.contains("expected 4"));
2026        assert!(warnings[0].message.contains("found 5"));
2027    }
2028
2029    #[test]
2030    fn task_list_loose_over_indented_flagged() {
2031        // "- [ ] " content_col = 2, task_col = 6. A loose line at 4 spaces is
2032        // neither valid column and below the code-block threshold (6); flagged.
2033        let content = "- [ ] Task\n\n    over\n";
2034        let warnings = check(content);
2035        assert_eq!(warnings.len(), 1);
2036        assert_eq!(warnings[0].line, 3);
2037    }
2038
2039    #[test]
2040    fn loose_over_indent_boundary_below_code_block_threshold_flagged() {
2041        // content_col = 2; 5 spaces (= content_col + 3) is the deepest loose
2042        // over-indent that is still prose. content_col + 4 (6 spaces) would be
2043        // an indented code block - see `loose_indented_code_block_not_flagged`.
2044        // This pins the boundary so a shift in the parser's threshold is caught.
2045        let content = "- Item\n\n     over\n";
2046        let warnings = check(content);
2047        assert_eq!(warnings.len(), 1);
2048        assert_eq!(warnings[0].line, 3);
2049        assert!(warnings[0].message.contains("expected 2"));
2050        assert!(warnings[0].message.contains("found 5"));
2051    }
2052
2053    #[test]
2054    fn loose_over_indent_does_not_steal_nested_under_indent() {
2055        // Inner content_col = 4, marker_col = 2. A loose continuation at column
2056        // 3 is under-indented for Inner yet over-indented for Outer (content_col
2057        // 2). The under-indent pass must claim it for Inner (snap *up* to 4,
2058        // preserving the apparent nesting), never letting the over-indent pass
2059        // mis-attribute it to Outer and snap it *down* to 2. This is the exact
2060        // ambiguity the two-pass ordering exists to resolve.
2061        let content = "- Outer\n  - Inner\n\n   continuation\n";
2062        let warnings = check(content);
2063        assert_eq!(warnings.len(), 1);
2064        assert_eq!(warnings[0].line, 4);
2065        assert!(warnings[0].message.contains("4 spaces"));
2066        assert!(warnings[0].message.contains("found 3"));
2067        assert_eq!(fix(content), "- Outer\n  - Inner\n\n    continuation\n");
2068    }
2069
2070    #[test]
2071    fn loose_over_indent_attributes_to_deepest_enclosing_item() {
2072        // Inner content_col = 4. A loose continuation at column 5 over-indents
2073        // Inner (the deepest item it sits within), so it is flagged against
2074        // Inner's column 4 - not Outer's column 2 - and snapped to 4.
2075        let content = "- Outer\n  - Inner\n\n     continuation\n";
2076        let warnings = check(content);
2077        assert_eq!(warnings.len(), 1);
2078        assert_eq!(warnings[0].line, 4);
2079        assert!(warnings[0].message.contains("expected 4"));
2080        assert!(warnings[0].message.contains("found 5"));
2081        assert_eq!(fix(content), "- Outer\n  - Inner\n\n    continuation\n");
2082    }
2083
2084    // ── Over-indented fenced code blocks are left untouched ───────────
2085    //
2086    // An over-indented fence is cosmetic: the code still renders inside the
2087    // list item. Reindenting only its delimiters (the body is skipped as code)
2088    // would change the literal code content, so the over-indent pass skips
2089    // anything `in_code_block`. The under-indent path still fixes fences, where
2090    // moving the block up is required to keep it inside the item.
2091
2092    #[test]
2093    fn loose_over_indented_fence_not_flagged() {
2094        let content = "- Item\n\n   ```\n   code\n   ```\n";
2095        assert!(check(content).is_empty());
2096        assert_eq!(fix(content), content);
2097    }
2098
2099    #[test]
2100    fn tight_over_indented_fence_not_flagged() {
2101        let content = "- Item\n   ```\n   code\n   ```\n";
2102        assert!(check(content).is_empty());
2103        assert_eq!(fix(content), content);
2104    }
2105
2106    #[test]
2107    fn over_indented_tilde_fence_not_flagged() {
2108        let content = "- Item\n\n   ~~~\n   code\n   ~~~\n";
2109        assert!(check(content).is_empty());
2110        assert_eq!(fix(content), content);
2111    }
2112
2113    #[test]
2114    fn fence_like_code_content_inside_fenced_block_not_flagged() {
2115        // A ``` line that is the *body* of a ~~~ block must not be treated as
2116        // over-indented continuation; rewriting it would corrupt code content.
2117        let content = "- Item\n\n  ~~~\n   ```\n  ~~~\n";
2118        assert!(check(content).is_empty());
2119        assert_eq!(fix(content), content);
2120    }
2121
2122    #[test]
2123    fn unterminated_over_indented_fence_not_flagged() {
2124        // No closing fence: the last code line must not be mistaken for a
2125        // closer and snapped to the content column.
2126        let content = "- Item\n\n   ```\n   code1\n     code2deeper\n";
2127        assert!(check(content).is_empty());
2128        assert_eq!(fix(content), content);
2129    }
2130
2131    // ── GFM task list items: post-checkbox continuation column ───────
2132    //
2133    // MD013's reflow indents wrapped task-list lines at `content_col + 4`
2134    // (the column after the checkbox). MD077 must accept that column for
2135    // both tight and loose continuation, for every marker flavour, so the
2136    // two rules don't fight over well-formed task items (issue #579).
2137
2138    #[test]
2139    fn task_list_tight_continuation_post_checkbox_reproducer_579() {
2140        // Exact reproducer from the bug report: content wraps to the
2141        // post-checkbox column (6) with no blank line.
2142        let content = "- [ ] Lorem ipsum dolor sit amet, consectetur adipiscing\n      tempor incididunt ut labore.\n";
2143        assert!(check(content).is_empty());
2144    }
2145
2146    #[test]
2147    fn task_list_tight_continuation_dash_unchecked() {
2148        let content = "- [ ] Task\n      continuation\n";
2149        assert!(check(content).is_empty());
2150    }
2151
2152    #[test]
2153    fn task_list_tight_continuation_dash_checked_lower() {
2154        let content = "- [x] Task\n      continuation\n";
2155        assert!(check(content).is_empty());
2156    }
2157
2158    #[test]
2159    fn task_list_tight_continuation_dash_checked_upper() {
2160        let content = "- [X] Task\n      continuation\n";
2161        assert!(check(content).is_empty());
2162    }
2163
2164    #[test]
2165    fn task_list_tight_continuation_star_marker() {
2166        let content = "* [ ] Task\n      continuation\n";
2167        assert!(check(content).is_empty());
2168    }
2169
2170    #[test]
2171    fn task_list_tight_continuation_plus_marker() {
2172        let content = "+ [ ] Task\n      continuation\n";
2173        assert!(check(content).is_empty());
2174    }
2175
2176    #[test]
2177    fn task_list_tight_continuation_content_column_still_valid() {
2178        // Column 2 is the CommonMark-canonical indent for "- " and remains
2179        // valid for task items too.
2180        let content = "- [ ] Task\n  continuation\n";
2181        assert!(check(content).is_empty());
2182    }
2183
2184    #[test]
2185    fn task_list_tight_continuation_between_columns_still_flagged() {
2186        // Column 4 matches neither content_col (2) nor post-checkbox (6).
2187        // A genuine indentation mistake — must remain flagged.
2188        let content = "- [ ] Task\n    continuation\n";
2189        let warnings = check(content);
2190        assert_eq!(warnings.len(), 1);
2191        // Task items advertise both valid columns to the user.
2192        assert!(warnings[0].message.contains("expected 2 or 6"));
2193        assert!(warnings[0].message.contains("found 4"));
2194    }
2195
2196    #[test]
2197    fn task_list_tight_continuation_overshoot_still_flagged() {
2198        // Column 7 overshoots the post-checkbox column. Genuine mistake.
2199        let content = "- [ ] Task\n       continuation\n";
2200        let warnings = check(content);
2201        assert_eq!(warnings.len(), 1);
2202        assert!(warnings[0].message.contains("expected 2 or 6"));
2203        assert!(warnings[0].message.contains("found 7"));
2204    }
2205
2206    // ── Task-list fix output: snap to nearer valid column ────────────
2207
2208    #[test]
2209    fn fix_task_list_overshoot_snaps_to_task_col() {
2210        // Col 7 is 1 away from post-checkbox (6), 5 away from content (2).
2211        // Snap to 6 — the author's intent was almost certainly the
2212        // post-checkbox alignment, not the content column.
2213        let content = "- [ ] Task\n       continuation\n";
2214        let fixed = fix(content);
2215        assert_eq!(fixed, "- [ ] Task\n      continuation\n");
2216    }
2217
2218    #[test]
2219    fn fix_task_list_col_5_snaps_to_task_col() {
2220        // Col 5 is 1 away from post-checkbox (6), 3 away from content (2).
2221        let content = "- [ ] Task\n     continuation\n";
2222        let fixed = fix(content);
2223        assert_eq!(fixed, "- [ ] Task\n      continuation\n");
2224    }
2225
2226    #[test]
2227    fn fix_task_list_col_3_snaps_to_content_col() {
2228        // Col 3 is 1 away from content (2), 3 away from post-checkbox (6).
2229        let content = "- [ ] Task\n   continuation\n";
2230        let fixed = fix(content);
2231        assert_eq!(fixed, "- [ ] Task\n  continuation\n");
2232    }
2233
2234    #[test]
2235    fn fix_task_list_col_4_ties_to_content_col() {
2236        // Col 4 is equidistant (±2) from both columns. Tie breaks to the
2237        // CommonMark-canonical content column — that's the default indent
2238        // MD077 would produce for a non-task item, so prefer it when the
2239        // author's intent is ambiguous.
2240        let content = "- [ ] Task\n    continuation\n";
2241        let fixed = fix(content);
2242        assert_eq!(fixed, "- [ ] Task\n  continuation\n");
2243    }
2244
2245    #[test]
2246    fn fix_task_list_ordered_overshoot_snaps_to_task_col() {
2247        // "1. [ ] " → content_col = 3, post-checkbox = 7.
2248        // Col 8 is nearer to 7.
2249        let content = "1. [ ] Task\n        continuation\n";
2250        let fixed = fix(content);
2251        assert_eq!(fixed, "1. [ ] Task\n       continuation\n");
2252    }
2253
2254    #[test]
2255    fn fix_task_list_ordered_under_overshoot_snaps_to_content_col() {
2256        // "1. [ ] " → content_col = 3, post-checkbox = 7.
2257        // Col 4 is nearer to 3.
2258        let content = "1. [ ] Task\n    continuation\n";
2259        let fixed = fix(content);
2260        assert_eq!(fixed, "1. [ ] Task\n   continuation\n");
2261    }
2262
2263    #[test]
2264    fn task_list_tight_continuation_ordered_single_digit() {
2265        // "1. [ ] " → content_col = 3, post-checkbox = 7
2266        let content = "1. [ ] Task\n       continuation\n";
2267        assert!(check(content).is_empty());
2268    }
2269
2270    #[test]
2271    fn task_list_tight_continuation_ordered_multi_digit() {
2272        // "10. [ ] " → content_col = 4, post-checkbox = 8
2273        let content = "10. [ ] Task\n        continuation\n";
2274        assert!(check(content).is_empty());
2275    }
2276
2277    #[test]
2278    fn task_list_tight_continuation_nested_dash() {
2279        // Nested "  - [ ] " at marker_col=2 → content_col=4, post-checkbox=8
2280        let content = "- Parent\n  - [ ] Nested task\n        continuation\n";
2281        assert!(check(content).is_empty());
2282    }
2283
2284    #[test]
2285    fn task_list_loose_continuation_post_checkbox_column_not_flagged() {
2286        // Loose continuation (blank line) at col 6 is also valid. This
2287        // already passed before the fix, but pin the intent: the 6-space
2288        // indent is accepted because it's the task-alignment column, not
2289        // because the under-indent check happens to let it through.
2290        let content = "- [ ] Task\n\n      continuation\n";
2291        assert!(check(content).is_empty());
2292    }
2293
2294    #[test]
2295    fn task_list_empty_body_is_not_a_task() {
2296        // "- [ ]" with nothing after is an empty regular list item, not a
2297        // task. Column 4 continuation has no task alignment to justify it
2298        // and must still be flagged as over-indented. (Col 6 would turn
2299        // the continuation into an indented code block inside the item,
2300        // which is a different code path.)
2301        let content = "- [ ]\n    continuation\n";
2302        let warnings = check(content);
2303        assert_eq!(warnings.len(), 1);
2304        assert!(warnings[0].message.contains("found 4"));
2305    }
2306
2307    #[test]
2308    fn task_list_malformed_checkbox_is_not_a_task() {
2309        // `[~] ` is not a GFM checkbox; only `[ ] `, `[x] `, `[X] ` count.
2310        let content = "- [~] Not a task\n      continuation\n";
2311        let warnings = check(content);
2312        assert_eq!(warnings.len(), 1);
2313    }
2314
2315    // ── MkDocs flavor × task checkbox ─────────────────────────────────
2316    //
2317    // MkDocs strict-indent and task alignment interact: required_min is
2318    // max(content_col, 4), and post-checkbox is content_col + 4. Both are
2319    // independently valid; values between them are flagged.
2320
2321    #[test]
2322    fn task_list_mkdocs_unordered_required_min_valid() {
2323        // "- [ ]" MkDocs: required_min = max(2, 4) = 4, post-checkbox = 6.
2324        let content = "- [ ] Task\n    continuation\n";
2325        assert!(check_mkdocs(content).is_empty());
2326    }
2327
2328    #[test]
2329    fn task_list_mkdocs_unordered_post_checkbox_valid() {
2330        let content = "- [ ] Task\n      continuation\n";
2331        assert!(check_mkdocs(content).is_empty());
2332    }
2333
2334    #[test]
2335    fn task_list_mkdocs_unordered_between_flagged() {
2336        // Column 5 is between required_min=4 and post-checkbox=6.
2337        let content = "- [ ] Task\n     continuation\n";
2338        let warnings = check_mkdocs(content);
2339        assert_eq!(warnings.len(), 1);
2340    }
2341
2342    #[test]
2343    fn task_list_mkdocs_ordered_both_columns_valid() {
2344        // "1. [ ]" MkDocs: required_min = max(3, 4) = 4, post-checkbox = 7.
2345        let at_4 = "1. [ ] Task\n    continuation\n";
2346        assert!(check_mkdocs(at_4).is_empty());
2347        let at_7 = "1. [ ] Task\n       continuation\n";
2348        assert!(check_mkdocs(at_7).is_empty());
2349    }
2350
2351    #[test]
2352    fn task_list_mkdocs_ordered_between_flagged() {
2353        // Column 5 and 6 are between required_min=4 and post-checkbox=7.
2354        let at_5 = "1. [ ] Task\n     continuation\n";
2355        assert_eq!(check_mkdocs(at_5).len(), 1);
2356        let at_6 = "1. [ ] Task\n      continuation\n";
2357        assert_eq!(check_mkdocs(at_6).len(), 1);
2358    }
2359
2360    // ── Context-aware tie-break ──────────────────────────────────────
2361    //
2362    // When a flagged line is exactly equidistant from `content_col` and
2363    // `task_col`, the author's intent is ambiguous. Before picking a
2364    // canonical default, look at whether other continuation lines in the
2365    // same item already use one of the valid columns — if so, snap to the
2366    // column they're using so the fix preserves the author's visible
2367    // convention.
2368
2369    #[test]
2370    fn fix_task_list_tie_sibling_at_task_col_snaps_to_task_col() {
2371        // Col 4 is equidistant from content_col (2) and task_col (6).
2372        // A valid sibling at col 6 proves the author is aligning under the
2373        // checkbox, so the tie resolves to col 6.
2374        let content = "- [ ] Task\n      aligned continuation\n    tied continuation\n";
2375        let fixed = fix(content);
2376        assert_eq!(
2377            fixed,
2378            "- [ ] Task\n      aligned continuation\n      tied continuation\n"
2379        );
2380    }
2381
2382    #[test]
2383    fn fix_task_list_tie_sibling_at_content_col_snaps_to_content_col() {
2384        // Valid sibling at col 2 proves the author is aligning to the
2385        // content column, so the col-4 tie resolves to col 2.
2386        let content = "- [ ] Task\n  aligned continuation\n    tied continuation\n";
2387        let fixed = fix(content);
2388        assert_eq!(fixed, "- [ ] Task\n  aligned continuation\n  tied continuation\n");
2389    }
2390
2391    #[test]
2392    fn fix_task_list_tie_both_siblings_snaps_to_content_col() {
2393        // When siblings exist at both valid columns, the author's pattern
2394        // is self-contradictory. Fall back to the CommonMark-canonical
2395        // content column.
2396        let content = "- [ ] Task\n  at content col\n      at task col\n    tied continuation\n";
2397        let fixed = fix(content);
2398        assert_eq!(
2399            fixed,
2400            "- [ ] Task\n  at content col\n      at task col\n  tied continuation\n"
2401        );
2402    }
2403
2404    #[test]
2405    fn fix_task_list_tie_sees_task_col_through_tight_lazy_continuation() {
2406        // CommonMark allows tight lazy continuation at col ≤ marker_col
2407        // (zero-indent continuation) inside a list item. The pre-pass
2408        // must MIRROR the main check loop's termination semantics: in
2409        // tight mode (no preceding blank) col ≤ marker_col is NOT a
2410        // termination signal — the lazy line still belongs to the item.
2411        //
2412        // This test pins that mirroring: a `lazy` line at col 0 is
2413        // followed by a legitimate task-col sibling at col 6, then a
2414        // tied col-4 line. If the pre-pass terminated eagerly at the
2415        // lazy line, the task-col sibling would be missed and the tied
2416        // line would fall back to content column. With correct
2417        // mirroring, the task-col sibling is seen and the tie resolves
2418        // to col 6.
2419        let content = concat!("- [ ] Task\n", "lazy\n", "      aligned at task col\n", "    tied\n",);
2420        let fixed = fix(content);
2421        assert!(
2422            fixed.contains("\n      tied\n"),
2423            "tied line should snap to col 6 (task col) because a task-col \
2424             sibling is visible past the tight lazy-continuation line; got:\n{fixed}"
2425        );
2426    }
2427
2428    // ── Tab-indented task continuation ───────────────────────────────
2429    //
2430    // Leading tabs expand to the next column that's a multiple of 4 under
2431    // CommonMark. The fix replaces the leading whitespace bytes wholesale,
2432    // turning tabs into space-indented output.
2433
2434    #[test]
2435    fn task_list_tab_indented_continuation_flagged() {
2436        // Two tabs → visual col 8, which overshoots both valid columns
2437        // for `- [ ] ` (content_col=2, task_col=6).
2438        let content = "- [ ] Task\n\t\twrap\n";
2439        let warnings = check(content);
2440        assert_eq!(warnings.len(), 1);
2441        assert!(warnings[0].message.contains("expected 2 or 6"));
2442        assert!(warnings[0].message.contains("found 8"));
2443    }
2444
2445    #[test]
2446    fn fix_task_list_tab_indented_snaps_to_task_col() {
2447        // abs_diff(8, 6) = 2 < abs_diff(8, 2) = 6 → snap to task_col (6).
2448        let content = "- [ ] Task\n\t\twrap\n";
2449        let fixed = fix(content);
2450        assert_eq!(fixed, "- [ ] Task\n      wrap\n");
2451    }
2452
2453    #[test]
2454    fn fix_task_list_single_tab_equidistant_snaps_to_content_col() {
2455        // One tab → visual col 4, equidistant from content_col (2) and
2456        // task_col (6). No siblings → tie-break to content_col.
2457        let content = "- [ ] Task\n\twrap\n";
2458        let fixed = fix(content);
2459        assert_eq!(fixed, "- [ ] Task\n  wrap\n");
2460    }
2461
2462    // ── Blockquote × task-list ───────────────────────────────────────
2463    //
2464    // Blockquote-nested lists are a known limitation on MD077: the list
2465    // parser doesn't always expose them with the same column semantics as
2466    // top-level lists, and the rule prefers a false-negative default to
2467    // avoid spurious warnings inside blockquotes (see
2468    // `blockquote_list_under_indent_no_false_positive`). These tests pin
2469    // the current behavior so any future change is intentional.
2470
2471    #[test]
2472    fn task_list_blockquote_post_checkbox_not_flagged() {
2473        // Post-checkbox alignment inside a blockquote — accepted as valid.
2474        let content = "> - [ ] Task\n>       continuation\n";
2475        assert!(check(content).is_empty());
2476    }
2477
2478    #[test]
2479    fn task_list_blockquote_between_cols_documented_limitation() {
2480        // Col-4-equivalent inside a blockquote is silently accepted — a
2481        // known MD077 limitation on blockquote-nested lists, not a task-
2482        // list-specific choice. Pinning the current behavior.
2483        let content = "> - [ ] Task\n>     continuation\n";
2484        assert!(check(content).is_empty());
2485    }
2486
2487    #[test]
2488    fn task_list_blockquote_overshoot_documented_limitation() {
2489        // Overshoot inside a blockquote — same known limitation.
2490        let content = "> - [ ] Task\n>        continuation\n";
2491        assert!(check(content).is_empty());
2492    }
2493
2494    // ── MkDocs × task × fix output ───────────────────────────────────
2495    //
2496    // MkDocs strict-indent raises `required` to max(content_col, 4) while
2497    // task_col stays at content_col + 4. The snap logic operates on the
2498    // raised required, not on the underlying content_col.
2499
2500    #[test]
2501    fn fix_task_list_mkdocs_unordered_overshoot_snaps_to_task_col() {
2502        // `- [ ]` MkDocs: required=4, task_col=6. Col 7 → abs_diff(7,6)=1
2503        // < abs_diff(7,4)=3. Snap to task_col.
2504        let content = "- [ ] Task\n       continuation\n";
2505        let fixed = fix_mkdocs(content);
2506        assert_eq!(fixed, "- [ ] Task\n      continuation\n");
2507    }
2508
2509    #[test]
2510    fn fix_task_list_mkdocs_unordered_tie_snaps_to_required() {
2511        // `- [ ]` MkDocs: required=4, task_col=6. Col 5 → abs_diff(5,6)=1
2512        // == abs_diff(5,4)=1. Tie with no siblings → required (4).
2513        let content = "- [ ] Task\n     continuation\n";
2514        let fixed = fix_mkdocs(content);
2515        assert_eq!(fixed, "- [ ] Task\n    continuation\n");
2516    }
2517
2518    #[test]
2519    fn fix_task_list_mkdocs_ordered_overshoot_snaps_to_task_col() {
2520        // `1. [ ]` MkDocs: required=4, task_col=7. Col 8 → abs_diff(8,7)=1
2521        // < abs_diff(8,4)=4. Snap to task_col.
2522        let content = "1. [ ] Task\n        continuation\n";
2523        let fixed = fix_mkdocs(content);
2524        assert_eq!(fixed, "1. [ ] Task\n       continuation\n");
2525    }
2526
2527    #[test]
2528    fn fix_task_list_mkdocs_ordered_near_required_snaps_to_required() {
2529        // `1. [ ]` MkDocs: required=4, task_col=7. Col 5 → abs_diff(5,7)=2
2530        // > abs_diff(5,4)=1. Snap to required (4). `1. [ ] Task\n     wrap`
2531        // has actual=5 which is over `required=4` so it's flagged in
2532        // strict mode, while in standard mode it falls under the lazy-
2533        // continuation window and isn't flagged at all.
2534        let content = "1. [ ] Task\n     continuation\n";
2535        let fixed = fix_mkdocs(content);
2536        assert_eq!(fixed, "1. [ ] Task\n    continuation\n");
2537    }
2538
2539    #[test]
2540    fn fix_task_list_mkdocs_ordered_between_cols_snaps_to_task_col() {
2541        // `1. [ ]` MkDocs: required=4, task_col=7. Col 6 → abs_diff(6,7)=1
2542        // < abs_diff(6,4)=2. Snap to task_col (7).
2543        let content = "1. [ ] Task\n      continuation\n";
2544        let fixed = fix_mkdocs(content);
2545        assert_eq!(fixed, "1. [ ] Task\n       continuation\n");
2546    }
2547
2548    // ── Fix idempotency (property test) ──────────────────────────────
2549    //
2550    // A fix pass on already-fixed content must produce the same content
2551    // — otherwise MD077 would oscillate on repeated invocations. This is
2552    // the core property that issue #579 was about (MD077 vs. MD013 fix
2553    // loop), and the integration test covers the MD013 interaction. The
2554    // property tests below pin the *internal* idempotency of MD077's own
2555    // fix, so any future change that introduces oscillation fails fast.
2556
2557    fn assert_idempotent(content: &str) {
2558        let once = fix(content);
2559        let twice = fix(&once);
2560        assert_eq!(once, twice, "MD077 fix was not idempotent on input: {content:?}");
2561    }
2562
2563    fn assert_idempotent_mkdocs(content: &str) {
2564        let once = fix_mkdocs(content);
2565        let twice = fix_mkdocs(&once);
2566        assert_eq!(
2567            once, twice,
2568            "MD077 (MkDocs) fix was not idempotent on input: {content:?}"
2569        );
2570    }
2571
2572    #[test]
2573    fn idempotent_task_list_between_cols() {
2574        assert_idempotent("- [ ] Task\n    continuation\n");
2575    }
2576
2577    #[test]
2578    fn idempotent_task_list_overshoot() {
2579        assert_idempotent("- [ ] Task\n       continuation\n");
2580    }
2581
2582    #[test]
2583    fn idempotent_task_list_under_post_checkbox() {
2584        assert_idempotent("- [ ] Task\n   continuation\n");
2585    }
2586
2587    #[test]
2588    fn idempotent_task_list_near_post_checkbox() {
2589        assert_idempotent("- [ ] Task\n     continuation\n");
2590    }
2591
2592    #[test]
2593    fn idempotent_task_list_tab_overshoot() {
2594        assert_idempotent("- [ ] Task\n\t\twrap\n");
2595    }
2596
2597    #[test]
2598    fn idempotent_task_list_single_tab() {
2599        assert_idempotent("- [ ] Task\n\twrap\n");
2600    }
2601
2602    #[test]
2603    fn idempotent_task_list_ordered_overshoot() {
2604        assert_idempotent("1. [ ] Task\n        continuation\n");
2605    }
2606
2607    #[test]
2608    fn idempotent_task_list_ordered_under() {
2609        assert_idempotent("1. [ ] Task\n    continuation\n");
2610    }
2611
2612    #[test]
2613    fn idempotent_task_list_tie_with_sibling_at_task_col() {
2614        assert_idempotent("- [ ] Task\n      aligned\n    tied\n");
2615    }
2616
2617    #[test]
2618    fn idempotent_task_list_tie_with_sibling_at_content_col() {
2619        assert_idempotent("- [ ] Task\n  aligned\n    tied\n");
2620    }
2621
2622    #[test]
2623    fn idempotent_task_list_mkdocs_unordered_overshoot() {
2624        assert_idempotent_mkdocs("- [ ] Task\n       continuation\n");
2625    }
2626
2627    #[test]
2628    fn idempotent_task_list_mkdocs_unordered_tie() {
2629        assert_idempotent_mkdocs("- [ ] Task\n     continuation\n");
2630    }
2631
2632    #[test]
2633    fn idempotent_task_list_mkdocs_ordered_overshoot() {
2634        assert_idempotent_mkdocs("1. [ ] Task\n        continuation\n");
2635    }
2636
2637    #[test]
2638    fn idempotent_task_list_mkdocs_ordered_between() {
2639        assert_idempotent_mkdocs("1. [ ] Task\n      continuation\n");
2640    }
2641
2642    #[test]
2643    fn idempotent_task_list_reproducer_579() {
2644        // The exact reproducer from issue #579 already has correct indent
2645        // (col 6 = post-checkbox), so idempotency is trivially true. Pin
2646        // it anyway as a smoke test against future regressions.
2647        assert_idempotent(
2648            "- [ ] Lorem ipsum dolor sit amet, consectetur adipiscing\n      tempor incididunt ut labore.\n",
2649        );
2650    }
2651
2652    #[test]
2653    fn idempotent_non_task_list_still_holds() {
2654        // Non-task items never enter the task_col code path; sanity-check
2655        // that idempotency is preserved for them too.
2656        assert_idempotent("1. Item\n    over-indented\n");
2657        assert_idempotent("- Item\n\n continuation\n");
2658    }
2659
2660    // ── Non-task idempotency: loose-mode under-indent ────────────────
2661    //
2662    // When a blank line precedes the continuation (loose mode),
2663    // under-indented content is flagged and fixed up to the content
2664    // column. Idempotency pins that one pass of the fix is sufficient.
2665
2666    #[test]
2667    fn idempotent_non_task_loose_under_indent_ordered() {
2668        // 1. Item → content col 3; "  x" is 2 spaces, under content col.
2669        assert_idempotent("1. Item\n\n  continuation\n");
2670    }
2671
2672    #[test]
2673    fn idempotent_non_task_loose_under_indent_multi_digit() {
2674        // 10. Item → content col 4; single-space continuation needs 4.
2675        assert_idempotent("10. Item\n\n continuation\n");
2676    }
2677
2678    #[test]
2679    fn idempotent_non_task_tight_over_indent_ordered() {
2680        // Tight-mode over-indent: 5 spaces where content col is 3.
2681        assert_idempotent("1. Item\n     over-indented\n");
2682    }
2683
2684    // ── Non-task idempotency: fenced code block compound fix ─────────
2685    //
2686    // A fence opener that needs re-indenting is repaired by the
2687    // compound-fence fix which shifts opener + interior + closer
2688    // together. Idempotency pins that the compound fix settles in one
2689    // pass and does not oscillate between runs.
2690
2691    #[test]
2692    fn idempotent_non_task_fence_ordered_loose() {
2693        // 1. Item → content col 3; fence at col 2 needs to shift to 3.
2694        assert_idempotent("1. Item\n\n  ```rust\n  let x = 1;\n  ```\n");
2695    }
2696
2697    #[test]
2698    fn idempotent_non_task_fence_tilde_under_indent() {
2699        // Tilde fences use the same compound-fix path as backtick fences.
2700        // Interior below the list scope (col 0 here, required col 3) must
2701        // be promoted up in the same pass as the fence delimiters —
2702        // otherwise a second pass would flag the interior individually
2703        // and defeat idempotency.
2704        assert_idempotent("1. Item\n\n  ~~~\nplain text\n  ~~~\n");
2705    }
2706
2707    #[test]
2708    fn idempotent_non_task_fence_interior_above_required() {
2709        // Interior already above the required column must not be pushed
2710        // further up by the compound fix — authored interior indentation
2711        // is preserved when it doesn't threaten fence pairing.
2712        assert_idempotent("1. Item\n\n  ```\n    deeply indented code\n  ```\n");
2713    }
2714
2715    #[test]
2716    fn fence_fix_promotes_interior_below_scope_in_single_pass() {
2717        // Concrete behavioral check, not just idempotency:
2718        // interior at col 0 with opener at col 2, required 3, must land
2719        // at col 3 (same as opener) so fence pairing is preserved.
2720        let content = "1. Item\n\n  ```\ncode\n  ```\n";
2721        let fixed = fix(content);
2722        assert_eq!(fixed, "1. Item\n\n   ```\n   code\n   ```\n");
2723    }
2724
2725    #[test]
2726    fn fence_fix_preserves_interior_above_required() {
2727        // Opener at col 2 → col 3 (required). Interior at col 4 stays at
2728        // col 4 (above required, no need to push it).
2729        let content = "1. Item\n\n  ```\n    code\n  ```\n";
2730        let fixed = fix(content);
2731        assert_eq!(fixed, "1. Item\n\n   ```\n    code\n   ```\n");
2732    }
2733
2734    // ── Non-task idempotency: MkDocs strict-indent ───────────────────
2735    //
2736    // Under MkDocs flavor, continuation requires max(content_col, 4),
2737    // which can force a fix even when CommonMark would accept the
2738    // content. Pin idempotency for the non-task path there too.
2739
2740    #[test]
2741    fn idempotent_non_task_mkdocs_ordered_at_3_spaces() {
2742        // CommonMark-valid (3 spaces) but MkDocs demands 4 → fix runs.
2743        assert_idempotent_mkdocs("1. Item\n\n   continuation\n");
2744    }
2745
2746    #[test]
2747    fn idempotent_non_task_mkdocs_unordered_at_2_spaces() {
2748        // "- Item" → content col 2, but MkDocs raises the floor to 4.
2749        assert_idempotent_mkdocs("- Item\n\n  continuation\n");
2750    }
2751
2752    #[test]
2753    fn idempotent_non_task_mkdocs_fence_compound() {
2754        // MkDocs non-task fence: opener/interior/closer shift together.
2755        assert_idempotent_mkdocs("1. Item\n\n   ```toml\n   k = 1\n   ```\n");
2756    }
2757
2758    // ── style = "aligned" ──────────────────────────────────────────────
2759
2760    #[test]
2761    fn aligned_tight_zero_indent_continuation_flagged() {
2762        // The core #682 case: a tight 0-indent lazy continuation is valid
2763        // CommonMark (and accepted by `any`), but `aligned` requires it to sit
2764        // at the content column.
2765        let content = "- this is a long line\nthat continues on a second line\n";
2766        let warnings = check_aligned(content);
2767        assert_eq!(warnings.len(), 1);
2768        assert_eq!(warnings[0].line, 2);
2769        assert_eq!(
2770            fix_aligned(content),
2771            "- this is a long line\n  that continues on a second line\n"
2772        );
2773    }
2774
2775    #[test]
2776    fn aligned_full_issue_example_made_consistent() {
2777        // The full #682 example: every continuation line is snapped to the
2778        // content column, producing the consistent result the reporter wants.
2779        let content = "- this is a long line\n\
2780                       that continues on a second line\n\
2781                       - this is another long line\n\
2782                       \x20\x20that continues on the next line\n\
2783                       - yet again a long line\n\
2784                       and still inconsistently spaced\n\
2785                       \x20\x20and even worse\n";
2786        let expected = "- this is a long line\n\
2787                        \x20\x20that continues on a second line\n\
2788                        - this is another long line\n\
2789                        \x20\x20that continues on the next line\n\
2790                        - yet again a long line\n\
2791                        \x20\x20and still inconsistently spaced\n\
2792                        \x20\x20and even worse\n";
2793        assert_eq!(fix_aligned(content), expected);
2794        // And the fix is a fixpoint.
2795        assert_eq!(fix_aligned(expected), expected);
2796    }
2797
2798    #[test]
2799    fn aligned_already_aligned_not_flagged() {
2800        let content = "- item\n  continuation at content column\n";
2801        assert!(check_aligned(content).is_empty());
2802    }
2803
2804    #[test]
2805    fn aligned_tight_partial_indent_flagged() {
2806        // 1 space under "- " (content col 2): tight partial under-indent.
2807        let content = "- item\n continuation\n";
2808        let warnings = check_aligned(content);
2809        assert_eq!(warnings.len(), 1);
2810        assert_eq!(fix_aligned(content), "- item\n  continuation\n");
2811    }
2812
2813    #[test]
2814    fn aligned_post_blank_zero_indent_still_new_paragraph() {
2815        // After a blank line, 0-indent content is a NEW paragraph that has left
2816        // the list - aligned mode must not reindent it back into the item.
2817        let content = "- item\n\nnew paragraph\n";
2818        assert!(check_aligned(content).is_empty());
2819        assert_eq!(fix_aligned(content), content);
2820    }
2821
2822    // ── aligned: structural blocks must NOT be pulled into the list ────
2823
2824    #[test]
2825    fn aligned_top_level_blockquote_after_list_untouched() {
2826        // A blockquote tight after a list item is a list-breaking separator,
2827        // not continuation. `in_list_block` is false for it, so aligned mode
2828        // must leave it alone (no reindent into the item).
2829        let content = "- item\n> quote\n";
2830        assert!(check_aligned(content).is_empty());
2831        assert_eq!(fix_aligned(content), content);
2832    }
2833
2834    #[test]
2835    fn aligned_top_level_fence_after_list_untouched() {
2836        let content = "- item\n```\ncode\n```\n";
2837        assert!(check_aligned(content).is_empty());
2838        assert_eq!(fix_aligned(content), content);
2839    }
2840
2841    #[test]
2842    fn aligned_top_level_table_after_list_untouched() {
2843        let content = "- item\n| a | b |\n|---|---|\n| 1 | 2 |\n";
2844        assert!(check_aligned(content).is_empty());
2845        assert_eq!(fix_aligned(content), content);
2846    }
2847
2848    // ── aligned: nested lists ──────────────────────────────────────────
2849
2850    #[test]
2851    fn aligned_nested_tight_lazy_continuation_aligns_to_inner() {
2852        // A tight lazy continuation after a nested item is, per CommonMark,
2853        // continuation of the innermost open item, so it aligns to the inner
2854        // item's content column (4), not the outer's (2). The outer item must
2855        // defer to the inner one.
2856        let content = "- Outer\n  - Inner\ncontinuation\n";
2857        let warnings = check_aligned(content);
2858        assert_eq!(warnings.len(), 1);
2859        assert_eq!(fix_aligned(content), "- Outer\n  - Inner\n    continuation\n");
2860    }
2861
2862    #[test]
2863    fn aligned_nested_continuation_already_aligned_not_flagged() {
2864        let content = "- L1\n  - L2\n    cont of L2 at 4\n";
2865        assert!(check_aligned(content).is_empty());
2866    }
2867
2868    #[test]
2869    fn aligned_nested_idempotent() {
2870        let content = "- Outer\n  - Inner\ncontinuation\n";
2871        let once = fix_aligned(content);
2872        assert_eq!(fix_aligned(&once), once);
2873    }
2874
2875    #[test]
2876    fn aligned_three_level_nesting_aligns_to_innermost() {
2877        // The saw_nested cascade defers through every level, so the tight lazy
2878        // line aligns to L3's content column (6), the innermost open item.
2879        let content = "- L1\n  - L2\n    - L3\ncont\n";
2880        assert_eq!(fix_aligned(content), "- L1\n  - L2\n    - L3\n      cont\n");
2881    }
2882
2883    #[test]
2884    fn aligned_continuation_after_sibling_owned_by_last_item() {
2885        // Tight lazy continuation after the second sibling belongs to that
2886        // sibling (no nesting), so it aligns to its content column (2).
2887        let content = "- a\n- b\nlazy\n";
2888        assert_eq!(fix_aligned(content), "- a\n- b\n  lazy\n");
2889    }
2890
2891    #[test]
2892    fn aligned_multi_digit_ordered_marker_aligns_to_content_column() {
2893        let content = "10. Item\nwrap\n";
2894        assert_eq!(fix_aligned(content), "10. Item\n    wrap\n");
2895    }
2896
2897    #[test]
2898    fn aligned_latent_setext_underline_is_left_alone() {
2899        // The underline is a lazy continuation, so the whole item renders as one
2900        // paragraph and no heading exists. Reindenting `Text` into the item would
2901        // promote the pair to a setext heading, changing the rendered structure,
2902        // so the item is skipped.
2903        let content = "- item\nText\n===\n";
2904        assert!(check_aligned(content).is_empty());
2905        assert_eq!(fix_aligned(content), content);
2906    }
2907
2908    #[test]
2909    fn aligned_reindents_prose_that_only_looks_like_an_underline() {
2910        // Positive control for the guard above: `= = =` has internal spaces, so
2911        // it is not an underline and cannot be promoted. The item is reindented.
2912        let content = "- item\nText\n= = =\n";
2913        assert_eq!(fix_aligned(content), "- item\n  Text\n  = = =\n");
2914    }
2915
2916    #[test]
2917    fn aligned_latent_marker_in_continuation_is_idempotent() {
2918        // Regression (fuzzer-found): a list-marker-looking line ("2. ") that the
2919        // parser absorbs as lazy continuation can be promoted to a real list
2920        // item once an earlier continuation line is reindented, re-attributing
2921        // later lines and breaking single-pass idempotency. Such items are not
2922        // reindented at all.
2923        let content = "# \n- \n``\n2. \n![]()";
2924        let once = fix_aligned(content);
2925        assert_eq!(fix_aligned(&once), once, "fix must be idempotent in one pass");
2926        assert_eq!(once, content, "item with a latent marker is left untouched");
2927    }
2928
2929    #[test]
2930    fn aligned_latent_table_in_continuation_is_idempotent() {
2931        // Regression (fuzzer-found): a table-looking line that the parser
2932        // absorbs as lazy continuation (so it is NOT yet `in_table_block`) can
2933        // be promoted to a real table once an earlier line is reindented. Like
2934        // a latent marker, this re-parses the block, so the item is left alone.
2935        let content = "- \n![`]()\n|  | ` |\n| --- | --- |";
2936        let once = fix_aligned(content);
2937        assert_eq!(fix_aligned(&once), once, "fix must be idempotent in one pass");
2938        assert_eq!(once, content, "item with a latent table is left untouched");
2939    }
2940
2941    #[test]
2942    fn aligned_blockquote_nested_list_not_touched() {
2943        // Lists inside blockquotes are a documented MD077 limitation (the scan
2944        // breaks at the blockquote prefix). Aligned inherits that conservative
2945        // no-false-positive behavior.
2946        let content = "> - item\n> wrap\n";
2947        assert!(check_aligned(content).is_empty());
2948        assert_eq!(fix_aligned(content), content);
2949    }
2950
2951    // ── aligned: GFM task list items ───────────────────────────────────
2952
2953    #[test]
2954    fn aligned_task_post_checkbox_column_accepted() {
2955        // Continuation aligned to the post-checkbox column (content_col + 4 = 6)
2956        // stays valid so aligned mode doesn't fight MD013 reflow.
2957        let content = "- [ ] Task\n      wrap\n";
2958        assert!(check_aligned(content).is_empty());
2959        assert_eq!(fix_aligned(content), content);
2960    }
2961
2962    #[test]
2963    fn aligned_task_under_indent_snaps_to_content_column() {
2964        let content = "- [ ] Task\nwrap\n";
2965        let warnings = check_aligned(content);
2966        assert_eq!(warnings.len(), 1);
2967        assert_eq!(fix_aligned(content), "- [ ] Task\n  wrap\n");
2968    }
2969
2970    // ── aligned: MkDocs flavor ─────────────────────────────────────────
2971
2972    #[test]
2973    fn aligned_mkdocs_tight_under_indent_snaps_to_four() {
2974        // MkDocs raises the required indent to max(content_col, 4) = 4 for "- ".
2975        let content = "- item\nwrap\n";
2976        let warnings = check_aligned_mkdocs(content);
2977        assert_eq!(warnings.len(), 1);
2978        let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
2979        assert_eq!(aligned_rule().fix(&ctx).unwrap(), "- item\n    wrap\n");
2980    }
2981
2982    // ── any (default) regression: tight lazy continuation untouched ────
2983
2984    #[test]
2985    fn any_default_does_not_flag_tight_lazy_continuation() {
2986        // The default style must preserve CommonMark lazy continuation.
2987        let content = "- item\nwrapped at zero indent\n";
2988        assert!(check(content).is_empty());
2989        assert_eq!(fix(content), content);
2990    }
2991
2992    #[test]
2993    fn from_config_aligned_enables_tight_flagging() {
2994        // End-to-end: `[MD077] style = "aligned"` wires through from_config.
2995        let mut config = crate::config::Config::default();
2996        let mut rule_config = crate::config::RuleConfig::default();
2997        rule_config
2998            .values
2999            .insert("style".to_string(), toml::Value::String("aligned".to_string()));
3000        config.rules.insert("MD077".to_string(), rule_config);
3001
3002        let rule = MD077ListContinuationIndent::from_config(&config);
3003        let ctx = LintContext::new("- item\nwrap\n", MarkdownFlavor::Standard, None);
3004        assert_eq!(rule.check(&ctx).unwrap().len(), 1);
3005    }
3006
3007    #[test]
3008    fn from_config_default_is_any() {
3009        // No config -> default `any` -> tight lazy continuation untouched.
3010        let config = crate::config::Config::default();
3011        let rule = MD077ListContinuationIndent::from_config(&config);
3012        let ctx = LintContext::new("- item\nwrap\n", MarkdownFlavor::Standard, None);
3013        assert!(rule.check(&ctx).unwrap().is_empty());
3014    }
3015
3016    #[test]
3017    fn from_config_indent_sets_fixed_requirement() {
3018        // End-to-end: `[MD077] indent = 4` wires through from_config and
3019        // requires continuation content to sit 4 spaces past the marker.
3020        let mut config = crate::config::Config::default();
3021        let mut rule_config = crate::config::RuleConfig::default();
3022        rule_config.values.insert("indent".to_string(), toml::Value::Integer(4));
3023        config.rules.insert("MD077".to_string(), rule_config);
3024
3025        let rule = MD077ListContinuationIndent::from_config(&config);
3026
3027        // "- item\n    wrap\n" -> continuation at 4 spaces: accepted.
3028        let ok_ctx = LintContext::new("- item\n    wrap\n", MarkdownFlavor::Standard, None);
3029        assert!(rule.check(&ok_ctx).unwrap().is_empty());
3030        assert_eq!(rule.fix(&ok_ctx).unwrap(), "- item\n    wrap\n");
3031
3032        // "- item\n\n  wrap\n" -> continuation at 2 spaces after a blank line:
3033        // flagged as under-indented (would escape the list item).
3034        let bad_ctx = LintContext::new("- item\n\n  wrap\n", MarkdownFlavor::Standard, None);
3035        let warnings = rule.check(&bad_ctx).unwrap();
3036        assert_eq!(warnings.len(), 1);
3037        assert!(warnings[0].message.contains("needs 4 spaces"));
3038    }
3039
3040    #[test]
3041    fn from_config_indent_applies_per_nested_marker() {
3042        // With a fixed indent, each item's requirement is its own marker
3043        // column plus the configured indent (nested item marker at 2 -> 6).
3044        let mut config = crate::config::Config::default();
3045        let mut rule_config = crate::config::RuleConfig::default();
3046        rule_config.values.insert("indent".to_string(), toml::Value::Integer(4));
3047        config.rules.insert("MD077".to_string(), rule_config);
3048
3049        let rule = MD077ListContinuationIndent::from_config(&config);
3050        let ctx = LintContext::new("- a\n  - b\n      wrap\n", MarkdownFlavor::Standard, None);
3051        let warnings = rule.check(&ctx).unwrap();
3052        assert!(
3053            warnings.is_empty(),
3054            "continuation at 6 spaces should pass: {warnings:?}"
3055        );
3056    }
3057
3058    /// A rule configured through the real `[MD077]` config path.
3059    fn rule_with(settings: &[(&str, toml::Value)]) -> Box<dyn Rule> {
3060        let mut config = crate::config::Config::default();
3061        let mut rule_config = crate::config::RuleConfig::default();
3062        for (key, value) in settings {
3063            rule_config.values.insert((*key).to_string(), value.clone());
3064        }
3065        config.rules.insert("MD077".to_string(), rule_config);
3066        MD077ListContinuationIndent::from_config(&config)
3067    }
3068
3069    #[test]
3070    fn configured_indent_cannot_lower_the_strict_flavor_minimum() {
3071        // The MkDocs 4-space minimum is what the renderer needs to read a line
3072        // as part of the item, so `indent = 2` may not pull it down to 2 - that
3073        // would have `fix` rewrite a rendering document into a broken one.
3074        let rule = rule_with(&[("indent", toml::Value::Integer(2))]);
3075
3076        let two = LintContext::new("- item\n\n  wrap\n", MarkdownFlavor::MkDocs, None);
3077        let warnings = rule.check(&two).unwrap();
3078        assert_eq!(warnings.len(), 1, "2 spaces is below the MkDocs minimum: {warnings:?}");
3079        assert!(
3080            warnings[0].message.contains("needs 4 spaces") && warnings[0].message.contains("MkDocs"),
3081            "the requirement comes from MkDocs, so the message must say so: {}",
3082            warnings[0].message
3083        );
3084        assert_eq!(rule.fix(&two).unwrap(), "- item\n\n    wrap\n");
3085
3086        // Control: a document already at the minimum is left alone.
3087        let four = LintContext::new("- item\n\n    wrap\n", MarkdownFlavor::MkDocs, None);
3088        assert!(rule.check(&four).unwrap().is_empty());
3089
3090        // Control: the clamp is scoped to the strict flavor. Under the standard
3091        // flavor the same setting means exactly what it says, and 4 is then an
3092        // over-indent to be snapped back to 2.
3093        let standard = LintContext::new("- item\n\n    wrap\n", MarkdownFlavor::Standard, None);
3094        assert_eq!(rule.check(&standard).unwrap().len(), 1);
3095        assert_eq!(rule.fix(&standard).unwrap(), "- item\n\n  wrap\n");
3096    }
3097
3098    #[test]
3099    fn configured_indent_can_raise_the_strict_flavor_minimum() {
3100        // The clamp is a floor, not a fixed value: above 4 the configured indent
3101        // still governs under the MkDocs flavor.
3102        let rule = rule_with(&[("indent", toml::Value::Integer(6))]);
3103        let ctx = LintContext::new("- item\n\n    wrap\n", MarkdownFlavor::MkDocs, None);
3104        let warnings = rule.check(&ctx).unwrap();
3105        assert_eq!(warnings.len(), 1);
3106        assert!(
3107            warnings[0].message.contains("needs 6 spaces"),
3108            "configured 6 must win over the 4-space floor: {}",
3109            warnings[0].message
3110        );
3111        assert_eq!(rule.fix(&ctx).unwrap(), "- item\n\n      wrap\n");
3112    }
3113
3114    #[test]
3115    fn configured_indent_message_does_not_claim_a_structural_consequence() {
3116        // Two spaces under a `- ` marker keeps the content in the list item, so
3117        // a message blaming list membership would be false. The requirement is
3118        // the configured one and the message has to say which.
3119        let rule = rule_with(&[("indent", toml::Value::Integer(4))]);
3120        let ctx = LintContext::new("- item\n\n  wrap\n", MarkdownFlavor::Standard, None);
3121        let warnings = rule.check(&ctx).unwrap();
3122        assert_eq!(warnings.len(), 1);
3123        assert!(
3124            warnings[0].message.contains("match the configured indent"),
3125            "expected the configured-indent wording, got: {}",
3126            warnings[0].message
3127        );
3128        assert!(
3129            !warnings[0].message.contains("remain part of the list"),
3130            "the content does remain part of the list here: {}",
3131            warnings[0].message
3132        );
3133
3134        // Control: the default rule reports the same document as clean, which is
3135        // what makes the structural claim wrong above.
3136        assert!(check("- item\n\n  wrap\n").is_empty());
3137
3138        // Control: content that really would escape the item still gets the
3139        // structural message, so the wording was narrowed and not replaced.
3140        let escaping = check("- item\n\n wrap\n");
3141        assert_eq!(escaping.len(), 1);
3142        assert!(
3143            escaping[0].message.contains("remain part of the list"),
3144            "unconfigured under-indent keeps its structural message, got: {}",
3145            escaping[0].message
3146        );
3147    }
3148
3149    #[test]
3150    fn configured_indent_leaves_tight_lazy_continuation_to_style() {
3151        // `indent` sets what the requirement is; `style` decides which lines are
3152        // measured against it. A tight continuation is CommonMark lazy
3153        // continuation, so `any` accepts it however the requirement was derived.
3154        let any = rule_with(&[("indent", toml::Value::Integer(4))]);
3155        let ctx = LintContext::new("- item\n  wrap\n", MarkdownFlavor::Standard, None);
3156        assert!(
3157            any.check(&ctx).unwrap().is_empty(),
3158            "style = any accepts tight lazy continuation"
3159        );
3160
3161        let aligned = rule_with(&[
3162            ("indent", toml::Value::Integer(4)),
3163            ("style", toml::Value::String("aligned".to_string())),
3164        ]);
3165        let warnings = aligned.check(&ctx).unwrap();
3166        assert_eq!(warnings.len(), 1, "style = aligned raises it: {warnings:?}");
3167        assert!(warnings[0].message.contains("expected 4"));
3168        assert_eq!(aligned.fix(&ctx).unwrap(), "- item\n    wrap\n");
3169    }
3170
3171    #[test]
3172    fn aligned_tight_underindented_fence_inside_item_left_alone() {
3173        // A fenced block is a structural construct; aligned mode does not
3174        // reindent it (the `in_code_block` guard), so a tight under-indented
3175        // fence is a no-op rather than a risky delimiter-only rewrite.
3176        let content = "- item\n ```\n code\n ```\n";
3177        assert!(check_aligned(content).is_empty());
3178        assert_eq!(fix_aligned(content), content);
3179    }
3180
3181    #[test]
3182    fn aligned_task_under_indent_fix_is_idempotent() {
3183        let content = "- [ ] Task\nwrap\n";
3184        let once = fix_aligned(content);
3185        assert_eq!(fix_aligned(&once), once);
3186    }
3187
3188    #[test]
3189    fn aligned_partial_indent_fix_is_idempotent() {
3190        let content = "- item\n continuation\n";
3191        let once = fix_aligned(content);
3192        assert_eq!(fix_aligned(&once), once);
3193    }
3194}