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