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::{CommonMarkItem, LineInfo, LintContext, NativeListSource};
11use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
12use crate::utils::list_indent_shift::is_lazy_continuation_source as 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 per nesting level is enforced
43/// to satisfy Python-Markdown: a top-level item needs 4, an item nested in it 8.
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    /// Whether the list marker written at visual column `marker_column` is an
93    /// ordered marker closed by `)`, which Python-Markdown does not read as a
94    /// list marker. Tabs advance to the next multiple of four.
95    fn is_paren_marker(line: &str, marker_column: usize) -> bool {
96        let mut column = 0;
97        for (byte, c) in line.char_indices() {
98            if column >= marker_column {
99                let rest = &line[byte..];
100                return rest.trim_start_matches(|c: char| c.is_ascii_digit()).starts_with(')');
101            }
102            column = if c == '\t' { (column / 4 + 1) * 4 } else { column + 1 };
103        }
104        false
105    }
106
107    /// Whether the text at visual column `content_col` of `line` is a GFM
108    /// checkbox. The column is where CommonMark starts the item's content, so
109    /// an item whose first content is indented code (five or more columns after
110    /// the marker) or an item that starts with a blank line reads no checkbox.
111    fn is_task_at_column(line: &str, content_col: usize) -> bool {
112        let mut column = 0;
113        for (byte, ch) in line.char_indices() {
114            if column == content_col {
115                return Self::is_task_list_item(line, byte);
116            }
117            if column > content_col {
118                return false;
119            }
120            column = crate::lint_context::column_after(column, ch);
121        }
122        false
123    }
124
125    /// Whether `text` holds nothing but spaces and tabs, the only whitespace
126    /// CommonMark's block structure counts as blank.
127    fn is_cm_blank(text: &str) -> bool {
128        text.trim_matches([' ', '\t']).is_empty()
129    }
130
131    /// Check if a trimmed line is a block-level construct (not list continuation).
132    fn is_block_level_construct(trimmed: &str) -> bool {
133        // Footnote definition: [^label]:
134        if trimmed.starts_with("[^") && trimmed.contains("]:") {
135            return true;
136        }
137        // Abbreviation definition: *[text]:
138        if trimmed.starts_with("*[") && trimmed.contains("]:") {
139            return true;
140        }
141        // Reference link definition: [label]: url
142        // Must start with [ but not be a regular link, footnote, or abbreviation
143        if trimmed.starts_with('[') && !trimmed.starts_with("[^") && trimmed.contains("]: ") {
144            return true;
145        }
146        false
147    }
148
149    /// Check if a trimmed line is a fenced code block delimiter (opener or closer).
150    fn is_code_fence(trimmed: &str) -> bool {
151        let bytes = trimmed.as_bytes();
152        if bytes.len() < 3 {
153            return false;
154        }
155        let ch = bytes[0];
156        (ch == b'`' || ch == b'~') && bytes[1] == ch && bytes[2] == ch
157    }
158
159    /// Whether a line is the delimiter of a fenced code block. Inside an
160    /// indented code block, fence-like text is code content.
161    fn is_fence_delimiter(ctx: &NativeListSource<'_, '_>, info: &LineInfo, trimmed: &str) -> bool {
162        let line_end = info.byte_offset + info.byte_len;
163        Self::is_code_fence(trimmed)
164            && ctx
165                .code_block_details
166                .iter()
167                .any(|b| b.is_fenced && b.start <= line_end && info.byte_offset < b.end)
168    }
169
170    /// Column of the `>` that opens a blockquote deeper than `depth`, if the
171    /// line has one.
172    fn deeper_quote_column(ctx: &NativeListSource<'_, '_>, info: &LineInfo, depth: usize) -> Option<usize> {
173        if info.blockquote.as_ref()?.nesting_level <= depth {
174            return None;
175        }
176        // The prefix holds only whitespace and `>`, so the (depth + 1)th `>` is
177        // the one that opens the deeper quote.
178        info.content(ctx.content).match_indices('>').nth(depth).map(|(i, _)| i)
179    }
180
181    /// Whether moving a paragraph line from `from` to `to` turns its text into
182    /// the start of a block.
183    ///
184    /// Four or more columns past the content column a line cannot interrupt the
185    /// paragraph, so a fence, blockquote marker, setext underline, thematic break,
186    /// table row or ATX heading there is paragraph text. Moved closer than that, the same
187    /// text opens the block and swallows or splits what follows. Closer in, the
188    /// parser already reads such a line as its block, so only this crossing
189    /// changes what the line is.
190    fn opens_block_when_moved(trimmed: &str, content_col: usize, from: usize, to: usize) -> bool {
191        const MAX_BLOCK_INDENT: usize = 3;
192        from > content_col + MAX_BLOCK_INDENT
193            && to <= content_col + MAX_BLOCK_INDENT
194            && (Self::is_code_fence(trimmed)
195                || trimmed.starts_with('>')
196                || crate::lint_context::is_setext_underline_content(trimmed)
197                || crate::lint_context::is_horizontal_rule_content(trimmed)
198                || crate::utils::skip_context::is_table_line(trimmed)
199                || Self::is_atx_heading_start(trimmed))
200    }
201
202    /// CommonMark 4.2: one to six `#` followed by a space, a tab or the end of
203    /// the line.
204    fn is_atx_heading_start(trimmed: &str) -> bool {
205        let hashes = trimmed.bytes().take_while(|&b| b == b'#').count();
206        (1..=6).contains(&hashes) && matches!(trimmed.as_bytes().get(hashes), None | Some(b' ' | b'\t'))
207    }
208
209    /// Check if a trimmed line starts with a list marker (*, -, +, or ordered).
210    /// Used to avoid flagging deeply indented list items that the parser doesn't
211    /// recognize as list items (e.g., with indent=8 configured in MD007).
212    fn starts_with_list_marker(trimmed: &str) -> bool {
213        let bytes = trimmed.as_bytes();
214        match bytes.first() {
215            Some(b'*' | b'-' | b'+') => bytes.get(1).is_some_and(|&b| b == b' ' || b == b'\t'),
216            Some(b'0'..=b'9') => {
217                let rest = trimmed.trim_start_matches(|c: char| c.is_ascii_digit());
218                rest.starts_with(". ") || rest.starts_with(") ")
219            }
220            _ => false,
221        }
222    }
223
224    /// When the line opens a fenced code block, the line number of the block's
225    /// last line (its closer, or the last line of an unclosed block). The
226    /// block's own byte range decides both, so an adjacent indented code block
227    /// on either side is never mistaken for part of it.
228    fn fenced_block_last_line(ctx: &NativeListSource<'_, '_>, info: &LineInfo, trimmed: &str) -> Option<usize> {
229        if !Self::is_code_fence(trimmed) {
230            return None;
231        }
232        let line_end = info.byte_offset + info.byte_len;
233        ctx.code_block_details
234            .iter()
235            .find(|b| b.is_fenced && info.byte_offset <= b.start && b.start <= line_end)
236            .map(|b| ctx.offset_to_line_col(b.end.saturating_sub(1).max(b.start)).0)
237    }
238
239    /// Build an atomic fix that reindents a fenced code block from its opener
240    /// through its matching closer.
241    ///
242    /// - **Opener and closer** are moved to `required` (the list item's
243    ///   content column, which is what MD077 actually flagged).
244    /// - **Interior lines** move by the same delta as the fence and are then
245    ///   clamped to `required`. Shifting by the delta preserves each line's
246    ///   offset from the fence, so the code block's internal structure (JSON
247    ///   nesting, indented sub-blocks) survives the move; the clamp
248    ///   guarantees fence pairing, since every non-blank line in the block
249    ///   ends at column ≥ `required` and the block stays inside the list
250    ///   item's scope after the fix.
251    ///
252    /// Only used for the under-indent direction (`required > opener_actual`);
253    /// over-indented fences are intentionally left untouched (see the
254    /// over-indent pass in `check`), so there is no down-shift case to handle.
255    ///
256    /// Why a compound fix rather than three independent fixes? MD077 and
257    /// MD031 run in the same iterative fix loop. If we only moved the
258    /// delimiters, an intermediate state would have mismatched
259    /// opener/closer indentation and MD031 would misread the block as
260    /// unpaired, injecting stray blank lines (issue #574).
261    ///
262    /// Why clamp the shift rather than shift alone? A bare delta shift is
263    /// not idempotent: interior starting below the list scope (e.g., col 0
264    /// under an opener at col 2 that needs to move to col 3) lands at col 1,
265    /// still below the scope, and the next MD077 pass re-flags it and snaps
266    /// it to `required`. The clamp reaches that end state in a single pass.
267    /// Clamping alone is not enough either: it pins every interior line at
268    /// or above `required` to its original column while the fence rises, so
269    /// the block's interior indentation shrinks by the delta and one-space
270    /// nesting disappears entirely.
271    ///
272    /// Leading tabs are normalized to spaces: CommonMark expands a tab to
273    /// the next column that's a multiple of 4, so simply prepending spaces
274    /// before a tab would let the tab snap back and cancel the shift. We
275    /// replace the whole leading-whitespace byte range with spaces.
276    fn build_compound_fence_fix(
277        ctx: &NativeListSource<'_, '_>,
278        opener_line: usize,
279        closer_line: usize,
280        opener_actual: usize,
281        required: usize,
282    ) -> Option<Fix> {
283        if required <= opener_actual {
284            return None;
285        }
286        let opener_info = ctx.line_info(opener_line)?;
287        let closer_info = ctx.line_info(closer_line)?;
288
289        let fix_start = opener_info.byte_offset;
290        let fix_end = closer_info.byte_offset + closer_info.byte_len;
291
292        let mut replacement = String::new();
293        for i in opener_line..=closer_line {
294            let info = ctx.line_info(i)?;
295            if i > opener_line {
296                replacement.push('\n');
297            }
298            let line = info.content(ctx.content);
299            if info.is_blank {
300                // Blank lines have no content to shift; preserve verbatim.
301                replacement.push_str(line);
302            } else {
303                let new_visual = if i == opener_line || i == closer_line {
304                    required
305                } else {
306                    (info.visual_indent + (required - opener_actual)).max(required)
307                };
308                for _ in 0..new_visual {
309                    replacement.push(' ');
310                }
311                replacement.push_str(&line[info.indent..]);
312            }
313        }
314
315        Some(Fix::new(fix_start..fix_end, replacement))
316    }
317
318    /// Walk the continuation lines owned by a single list item, invoking
319    /// `per_line` for each *in-scope, non-blank, non-nested, non-skipped*
320    /// line with its pre-computed visual column and loose/tight state.
321    ///
322    /// This is the **single source of truth** for MD077's item-scope
323    /// traversal: both the sibling-column pre-pass and the main check loop
324    /// route through this method so their termination semantics cannot
325    /// drift. The callback sees only lines the rule actually needs to
326    /// reason about; it can return `ControlFlow::Break` for early exit.
327    ///
328    /// Termination conditions (applied before the callback fires):
329    /// - Headings and horizontal rules end the item unconditionally.
330    /// - After a blank line, content at or below the marker column has
331    ///   escaped the item; further lines are not delivered.
332    ///
333    /// Skipped silently (do not fire the callback):
334    /// - Blank lines (toggle `saw_blank`).
335    /// - Nested list items (reset `saw_blank`, track their content column).
336    /// - Lines inside any still-open nested item's scope: the walk keeps a
337    ///   stack of open nested (marker, content) columns, so a continuation
338    ///   of a middle nesting level defers to that level's own walk instead
339    ///   of being claimed by a shallower ancestor.
340    /// - Reference/footnote/abbreviation definitions and similar block
341    ///   constructs that aren't list continuation.
342    /// - Lines that `should_skip_line` rejects (code-block interior etc.).
343    fn walk_item_continuation<F>(ctx: &NativeListSource<'_, '_>, scope: &ItemScope, mut per_line: F)
344    where
345        F: FnMut(&ContinuationLine<'_>) -> ControlFlow<()>,
346    {
347        let item_line = scope.item_line;
348        let range_end = scope.range_end;
349        let marker_col = scope.marker_col;
350        let content_col = scope.content_col;
351        let mut saw_blank = false;
352        // Stack of open nested items as (marker_column, content_column,
353        // awaiting_first_content), outermost first. A single scalar is not
354        // enough: with 3+ levels a continuation of a middle level sits below the
355        // innermost content column but at-or-past its own level's, and must
356        // still be skipped here so the owning level's walk evaluates it. The
357        // items written on this item's own line start out on the stack.
358        let mut nested_stack: Vec<(usize, usize, bool)> = scope
359            .same_line
360            .iter()
361            .map(|item| (item.marker_col, item.content_col, item.is_empty))
362            .collect();
363        let mut saw_nested = !nested_stack.is_empty();
364        // CommonMark 5.2: an item can begin with at most one blank line, so
365        // while an item that started empty has no content yet, a blank line or
366        // a line left of its content column closes it.
367        let mut awaiting_content = scope.starts_empty;
368        let item_info = ctx.line_info(item_line);
369        let item_quotes = item_info
370            .and_then(|i| i.blockquote.as_ref())
371            .map_or(0, |q| q.nesting_level);
372
373        for line_num in (item_line + 1)..=range_end {
374            let Some(info) = ctx.line_info(line_num) else {
375                continue;
376            };
377
378            // A `>` left of the content column opens a blockquote outside the
379            // item, which ends the item (an empty quote line is not a blank line
380            // inside it).
381            if Self::deeper_quote_column(ctx, info, item_quotes).is_some_and(|col| col < content_col) {
382                break;
383            }
384
385            let raw_line = info.content(ctx.content);
386            let trimmed = raw_line.trim_start();
387            let blank = Self::is_cm_blank(raw_line);
388
389            // An item that started empty and is still waiting for its content.
390            if awaiting_content {
391                if blank || info.visual_indent < content_col {
392                    break;
393                }
394                awaiting_content = false;
395            }
396            // The same for the nested items written on the item's line.
397            if blank {
398                while nested_stack.last().is_some_and(|&(_, _, awaiting)| awaiting) {
399                    nested_stack.pop();
400                }
401            } else {
402                while nested_stack
403                    .last()
404                    .is_some_and(|&(_, content, awaiting)| awaiting && content > info.visual_indent)
405                {
406                    nested_stack.pop();
407                }
408                if let Some(top) = nested_stack.last_mut() {
409                    top.2 = false;
410                }
411            }
412
413            if Self::should_skip_line(ctx, info, trimmed) {
414                continue;
415            }
416
417            if blank {
418                saw_blank = true;
419                continue;
420            }
421
422            // Whitespace that is not CommonMark's is text, which the rule has
423            // no indentation to judge.
424            if trimmed.is_empty() {
425                continue;
426            }
427
428            if let Some(ref li) = info.list_item {
429                // The items CommonMark opens on this line; the byte columns are
430                // the fallback for a line the container pass did not read.
431                let fallback = [CommonMarkItem {
432                    marker_column: li.marker_column,
433                    content_column: li.content_column,
434                    is_empty: false,
435                }];
436                let chain: &[CommonMarkItem] = if li.commonmark_items.is_empty() {
437                    &fallback
438                } else {
439                    &li.commonmark_items
440                };
441                let first = chain[0];
442                // A marker left of the content column cannot nest inside this
443                // item: it closes the item and starts a list of its own, which
444                // then owns every line below it.
445                if first.marker_column > marker_col && first.marker_column < content_col {
446                    break;
447                }
448                if first.marker_column > marker_col {
449                    // A sibling-or-shallower marker closes every nested item at
450                    // or past its column before this one opens.
451                    while nested_stack.last().is_some_and(|&(m, _, _)| m >= first.marker_column) {
452                        nested_stack.pop();
453                    }
454                    for item in chain {
455                        nested_stack.push((item.marker_column, item.content_column, item.is_empty));
456                    }
457                    // Sticky: once a nested child appears, every later line is a
458                    // (lazy) continuation of the deeper item, which owns it under
459                    // CommonMark. Ancestors must defer so the deepest item claims
460                    // and aligns it.
461                    saw_nested = true;
462                } else {
463                    nested_stack.clear();
464                }
465                saw_blank = false;
466                continue;
467            }
468
469            // A setext heading's text is the whole paragraph its underline ends,
470            // so every one of those lines is a heading rather than a
471            // continuation the walk could reindent.
472            if info.heading.is_some() || info.is_setext_heading_text || info.is_horizontal_rule {
473                break;
474            }
475
476            if Self::is_block_level_construct(trimmed) {
477                continue;
478            }
479
480            let col = info.visual_indent;
481
482            // Straight below a line, left of the content column, and not
483            // continuing the paragraph above it: the line opens a block of its
484            // own (a fence at the margin) or follows one that closed (text under
485            // a closing fence), and either way the item has ended. What follows
486            // is top-level content that no indentation of ours should claim;
487            // moving it can make it an indented code block, which changes the
488            // lines below it too.
489            if !saw_blank && col < content_col && !is_lazy_continuation(ctx, line_num - 1) {
490                break;
491            }
492
493            // A lazy continuation line continues the open paragraph of the
494            // innermost nested item without closing it, whatever its indent, so
495            // that item keeps owning the lines after it.
496            if !nested_stack.is_empty() && is_lazy_continuation(ctx, line_num - 1) {
497                continue;
498            }
499
500            // Any other line escapes every nested item whose content column it
501            // sits below; if any nested item stays open, the line is that item's
502            // continuation and its own walk owns the judgement.
503            while nested_stack.last().is_some_and(|&(_, c, _)| c > col) {
504                nested_stack.pop();
505            }
506            if !nested_stack.is_empty() {
507                continue;
508            }
509
510            if saw_blank && col <= marker_col {
511                break;
512            }
513
514            let line = ContinuationLine {
515                line_num,
516                info,
517                trimmed,
518                actual: col,
519                saw_blank,
520                saw_nested,
521            };
522            if per_line(&line).is_break() {
523                break;
524            }
525        }
526    }
527
528    /// Whether the item's owned continuation range holds a *latent* structural
529    /// construct: a list marker or table line the parser has not yet promoted to
530    /// its own block, but would once an earlier continuation line is reindented.
531    /// Such a promotion re-attributes the following lines to a different block
532    /// (different content column) and breaks single-pass idempotency, so the
533    /// caller skips the whole item when this returns true.
534    ///
535    /// A setext underline counts too: as a lazy continuation the parser reads a
536    /// `=`/`-` run as paragraph text, but reindenting the prose line above it
537    /// into the item turns the pair into a heading.
538    ///
539    /// Unlike [`Self::walk_item_continuation`], this scans the full owned range and
540    /// does NOT stop at a heading or horizontal rule. A heading partway through
541    /// the continuation can itself be parse-unstable: an empty backtick run, for
542    /// instance, is read as a setext heading, and reindenting an earlier line
543    /// dissolves it and promotes a list marker that sits past it. Stopping at the
544    /// heading would hide that marker from this check and let the reindent break
545    /// idempotency. Lines already parsed as list items are real nested children,
546    /// not latent, and are handled by the walk's `saw_nested` logic instead.
547    fn item_range_has_latent_structure(ctx: &NativeListSource<'_, '_>, item_line: usize, range_end: usize) -> bool {
548        (item_line + 1..=range_end).any(|line_num| {
549            ctx.line_info(line_num).is_some_and(|info| {
550                if info.is_blank || info.list_item.is_some() {
551                    return false;
552                }
553                let trimmed = info.content(ctx.content).trim_start();
554                !Self::should_skip_line(ctx, info, trimmed)
555                    && (Self::starts_with_list_marker(trimmed)
556                        || crate::utils::skip_context::is_table_line(trimmed)
557                        || Self::is_latent_setext_underline(ctx, line_num, trimmed))
558            })
559        })
560    }
561
562    /// Whether reindenting the line above this one would turn the pair into a
563    /// setext heading. An underline that is a lazy continuation is paragraph
564    /// text, so the parser reports no heading and the walk runs straight past it.
565    ///
566    /// Only paragraph text can become the heading's text line, so an underline
567    /// under an ATX heading, a thematic break or another underline is latent
568    /// nothing and must not hold the whole item back. The line above has to pass
569    /// both readings: `is_paragraph_context` speaks for the block it sits in,
570    /// which is what tells a closing code fence from the prose it looks like,
571    /// and the text predicate speaks for the line's own markers, which is what
572    /// tells an empty list item from the text it holds none of. A blank line
573    /// needs no test of its own: blank means it holds nothing once its markers
574    /// are stripped, which is the first thing the text predicate rejects.
575    fn is_latent_setext_underline(ctx: &NativeListSource<'_, '_>, line_num: usize, trimmed: &str) -> bool {
576        crate::lint_context::is_setext_underline_content(trimmed)
577            && ctx.line_info(line_num - 1).is_some_and(|prev| {
578                prev.is_paragraph_context() && crate::lint_context::is_paragraph_text_line(prev.content(ctx.content))
579            })
580    }
581
582    /// Scan an item's owned range and report whether any *other* continuation
583    /// line in the item uses the content column or the post-checkbox column.
584    ///
585    /// Used exclusively for tie-breaking the auto-fix target when an
586    /// over-indented line is exactly equidistant from `content_col` and
587    /// `task_col`. In that case the author's intent is ambiguous, so we
588    /// snap to whichever valid column *they're already using* elsewhere in
589    /// the same item. When neither or both columns are in use, the caller
590    /// falls back to a canonical default.
591    fn sibling_column_usage(ctx: &NativeListSource<'_, '_>, scope: &ItemScope, task_col: usize) -> (bool, bool) {
592        let content_col = scope.content_col;
593        let mut uses_content = false;
594        let mut uses_task = false;
595
596        Self::walk_item_continuation(ctx, scope, |line| {
597            if line.actual == content_col {
598                uses_content = true;
599            }
600            if line.actual == task_col {
601                uses_task = true;
602            }
603            if uses_content && uses_task {
604                ControlFlow::Break(())
605            } else {
606                ControlFlow::Continue(())
607            }
608        });
609
610        (uses_content, uses_task)
611    }
612
613    /// Compute the auto-fix target for an over-indented continuation line.
614    /// Snaps to the nearer of the two valid columns (content_col / task_col)
615    /// for task items, and on an exact tie uses sibling-column context to
616    /// pick whichever column the author is already using elsewhere in this
617    /// item. Non-task items always snap to `required`.
618    fn compute_fix_target(
619        actual: usize,
620        required: usize,
621        task_col: Option<usize>,
622        uses_content_col: bool,
623        uses_task_col: bool,
624    ) -> usize {
625        let Some(t) = task_col else { return required };
626        match actual.abs_diff(t).cmp(&actual.abs_diff(required)) {
627            std::cmp::Ordering::Less => t,
628            std::cmp::Ordering::Greater => required,
629            std::cmp::Ordering::Equal => match (uses_task_col, uses_content_col) {
630                (true, false) => t,
631                _ => required,
632            },
633        }
634    }
635
636    /// The opening line of every fenced block, and the other lines inside one.
637    fn fence_lines(
638        ctx: &NativeListSource<'_, '_>,
639        line_count: usize,
640    ) -> (std::collections::BTreeSet<usize>, std::collections::BTreeSet<usize>) {
641        let last_line = line_count.saturating_sub(1);
642        let line_of = |offset: usize| ctx.offset_to_line_col(offset).0.saturating_sub(1).min(last_line);
643        let mut openers = std::collections::BTreeSet::new();
644        let mut inside = std::collections::BTreeSet::new();
645        for block in ctx.code_block_details.iter().filter(|b| b.is_fenced) {
646            let first = line_of(block.start);
647            let last = line_of(block.end.saturating_sub(1).max(block.start));
648            openers.insert(first);
649            inside.extend(first + 1..=last);
650        }
651        (openers, inside)
652    }
653
654    /// Per line, whether Python-Markdown reads it as the start of a list item.
655    fn parser_marker_lines(ctx: &NativeListSource<'_, '_>) -> Option<Vec<bool>> {
656        use crate::utils::python_markdown_lists::{Source, marker_lines};
657        let lines: Vec<&str> = ctx.content.split('\n').collect();
658        let (openers, inside) = Self::fence_lines(ctx, lines.len());
659        let mut markers = marker_lines(&Source {
660            lines: &lines,
661            fence_openers: &openers,
662            fence_inside: &inside,
663        })?;
664        // The model knows lists, code and headings only. A line it reads as
665        // text counts as text only when the line above is plain prose it can
666        // judge; quotes, admonitions, tables, rules and the like are left to
667        // the indentation rules.
668        for (idx, marker) in markers.iter_mut().enumerate() {
669            if !*marker && idx > 0 && !Self::is_plain_prose(lines[idx - 1]) {
670                *marker = true;
671            }
672        }
673        let admonition = lines.iter().any(|l| {
674            let t = l.trim_start();
675            t.starts_with("!!! ") || t.starts_with("??? ") || t.starts_with("???+ ")
676        });
677        (!admonition).then_some(markers)
678    }
679
680    /// A line the list model reads the same way Python-Markdown does when it
681    /// is the line above a possible list marker.
682    fn is_plain_prose(line: &str) -> bool {
683        let t = line.trim();
684        if t.is_empty() {
685            return false;
686        }
687        let first = t.chars().next().unwrap_or(' ');
688        if matches!(first, '>' | '#' | ':' | '|' | '<' | '[' | '=' | '-' | '*' | '_' | '+') {
689            return false;
690        }
691        !t.contains('|') && !t.chars().next().is_some_and(|c| c.is_ascii_digit())
692    }
693
694    /// Withhold every fix that Python-Markdown would read as a different
695    /// document.
696    ///
697    /// The indentation rules above approximate how Python-Markdown nests list
698    /// items. This check does not rely on them: it applies the fixes, parses
699    /// the original and the fixed text with a model of Python-Markdown's list
700    /// parser, and keeps a fix only if every line stays the same kind of thing
701    /// (item marker, paragraph, code) and every paragraph and code block keeps
702    /// its lines. A paragraph moving under a different item is the intended
703    /// effect and passes; prose becoming code, a marker becoming text, or two
704    /// paragraphs merging does not. A region whose result differs keeps its
705    /// warnings and loses its fixes.
706    fn drop_unsafe_fixes(
707        ctx: &NativeListSource<'_, '_>,
708        warnings: &mut [LintWarning],
709        owners: &std::collections::HashMap<usize, usize>,
710    ) {
711        use crate::utils::python_markdown_lists::{Source, differing_regions, line_regions};
712        use std::collections::BTreeSet;
713
714        // A warning that inline configuration suppresses never reaches the fixer,
715        // so only the fixes that survive it are simulated.
716        let mut live: Vec<usize> = (0..warnings.len())
717            .filter(|&i| warnings[i].fix.is_some() && !ctx.is_rule_disabled("MD077", warnings[i].line))
718            .collect();
719        if live.is_empty() {
720            return;
721        }
722        // MkDocs removes the front matter before Python-Markdown sees the page.
723        let blank_front_matter = |lines: &mut Vec<&str>| {
724            for line in lines.iter_mut().take(ctx.front_matter_end_line()) {
725                *line = "";
726            }
727        };
728        let mut lines: Vec<&str> = ctx.content.split('\n').collect();
729        blank_front_matter(&mut lines);
730        let line_of = |offset: usize| {
731            ctx.offset_to_line_col(offset)
732                .0
733                .saturating_sub(1)
734                .min(lines.len().saturating_sub(1))
735        };
736        let (openers, inside) = Self::fence_lines(ctx, lines.len());
737        let before = Source {
738            lines: &lines,
739            fence_openers: &openers,
740            fence_inside: &inside,
741        };
742        let region_of = line_regions(&before);
743        let touched = |fix: &Fix| -> BTreeSet<usize> {
744            (line_of(fix.range.start)..=line_of(fix.range.end.max(fix.range.start)))
745                .map(|l| region_of[l])
746                .collect()
747        };
748
749        const MAX_ROUNDS: usize = 4;
750        for _ in 0..MAX_ROUNDS {
751            let mut ranges: Vec<(usize, &Fix)> = live
752                .iter()
753                .filter_map(|&i| warnings[i].fix.as_ref().map(|f| (i, f)))
754                .collect();
755            ranges.sort_by_key(|(_, f)| std::cmp::Reverse(f.range.start));
756            let mut fixed = ctx.content.to_string();
757            let mut floor = usize::MAX;
758            let mut overlapping = Vec::new();
759            for (i, fix) in ranges {
760                if fix.range.end <= floor && fix.range.end <= fixed.len() {
761                    fixed.replace_range(fix.range.clone(), &fix.replacement);
762                    floor = fix.range.start;
763                } else {
764                    overlapping.push(i);
765                }
766            }
767            // Fixes that overlap an applied one cannot be applied together
768            // with it, so they are withheld rather than simulated away.
769            if !overlapping.is_empty() {
770                for &i in &overlapping {
771                    warnings[i].fix = None;
772                }
773                live.retain(|i| !overlapping.contains(i));
774                if live.is_empty() {
775                    return;
776                }
777                continue;
778            }
779            let mut after_lines: Vec<&str> = fixed.split('\n').collect();
780            blank_front_matter(&mut after_lines);
781            let after = Source {
782                lines: &after_lines,
783                fence_openers: &openers,
784                fence_inside: &inside,
785            };
786            // The line each fixed line is meant to belong to, as a zero-based
787            // item marker line.
788            let mut owner_of_line: std::collections::HashMap<usize, usize> = std::collections::HashMap::new();
789            for &i in &live {
790                let (Some(fix), Some(&owner)) = (warnings[i].fix.as_ref(), owners.get(&warnings[i].line)) else {
791                    continue;
792                };
793                for l in line_of(fix.range.start)..=line_of(fix.range.end.max(fix.range.start)) {
794                    owner_of_line.insert(l, owner.saturating_sub(1));
795                }
796            }
797            let bad = match differing_regions(&before, &after, &owner_of_line) {
798                Some(bad) if bad.is_empty() => return,
799                Some(bad) => bad,
800                None => break,
801            };
802            let kept: Vec<usize> = live
803                .iter()
804                .copied()
805                .filter(|&i| warnings[i].fix.as_ref().is_some_and(|f| touched(f).is_disjoint(&bad)))
806                .collect();
807            if kept.len() == live.len() {
808                break;
809            }
810            for &i in live.iter().filter(|i| !kept.contains(i)) {
811                warnings[i].fix = None;
812            }
813            live = kept;
814            if live.is_empty() {
815                return;
816            }
817        }
818        for i in live {
819            warnings[i].fix = None;
820        }
821    }
822
823    /// Whether Python-Markdown reads the line as an indented code block of the
824    /// item: after a blank line, four columns past the item's paragraph column.
825    /// Its levels differ from the parser's for an item whose marker sits less
826    /// than four columns past its parent's, so the parser's own code-block
827    /// classification does not cover every such line, and moving one would turn
828    /// code into a paragraph (or the reverse).
829    fn is_python_markdown_code(ctx: &NativeListSource<'_, '_>, scope: &ItemScope, line: &ContinuationLine<'_>) -> bool {
830        // Only the line right after a blank can open the code block; a deeper
831        // line further into a paragraph is that paragraph's continuation.
832        (scope.code_column.is_some_and(|column| line.actual >= column)
833            && ctx
834                .line_info(line.line_num - 1)
835                .is_some_and(|prev| Self::is_cm_blank(prev.content(ctx.content))))
836            || Self::is_python_markdown_nested_content(scope, line)
837    }
838
839    /// Whether Python-Markdown reads the line as content of the deeper item
840    /// written most recently above it: a line at that item's paragraph column or
841    /// past it, with no line between that fell back below the column and so
842    /// closed the item. The parser can leave such a line to the outer item when
843    /// the deeper item's content column lies past it (a wide marker, or a marker
844    /// sharing its line with another).
845    fn is_python_markdown_nested_content(scope: &ItemScope, line: &ContinuationLine<'_>) -> bool {
846        let above = scope
847            .nested
848            .partition_point(|&(item_line, ..)| item_line < line.line_num);
849        above
850            .checked_sub(1)
851            .and_then(|last| scope.nested.get(last))
852            .is_some_and(|&(_, column, closed_at)| line.actual >= column && line.line_num < closed_at)
853    }
854
855    /// Check if a line should be skipped (inside code, HTML, frontmatter, etc.)
856    ///
857    /// Code block *content* is skipped, but fence opener/closer lines are not —
858    /// their indentation matters for list continuation in MkDocs.
859    ///
860    /// Footnote-definition lines (the `[^id]:` line and its indented body) are
861    /// skipped too: a footnote body that follows a list is its own block, not
862    /// list continuation, so its indentation is MD068's concern, not MD077's.
863    /// Treating it as continuation produced false over-indent warnings and a
864    /// damaging auto-fix that reindented the body and then tripped MD068.
865    fn should_skip_line(ctx: &NativeListSource<'_, '_>, info: &LineInfo, trimmed: &str) -> bool {
866        if info.in_code_block && !Self::is_fence_delimiter(ctx, info, trimmed) {
867            return true;
868        }
869        let structural = || {
870            info.in_front_matter
871                || info.in_footnote_definition
872                || info.in_html_block
873                || info.in_html_comment
874                || info.in_mdx_comment
875                || info.in_mkdocstrings
876                || info.in_esm_block
877                || info.in_math_block
878                || info.in_admonition
879                || info.in_content_tab
880                || info.in_pymdown_block
881                || info.in_definition_list
882                || info.in_mkdocs_html_markdown
883                || info.in_kramdown_extension_block
884        };
885        let protected = || {
886            ctx.is_byte_offset_in_code_span(info.byte_offset)
887                || ctx.is_in_code_span_byte_with_or_without_math(info.byte_offset)
888        };
889        // The structural flags settle opaque lines without source-range queries.
890        // The alternate reading remains mandatory for every eligible line.
891
892        // A line beginning inside a code span retains its literal whitespace:
893        // re-indenting it would change rendered code in either parser reading.
894        structural() || protected()
895    }
896
897    /// Build the warning for an over-indented continuation line. The fix is a
898    /// single-line rewrite of the leading whitespace to `fix_target`.
899    ///
900    /// The over-indent pass never delivers fenced-code lines here (it skips
901    /// anything `in_code_block`), so this builder does not need the compound
902    /// fence handling that `build_under_indent_warning` uses: moving an
903    /// over-indented fence's delimiters without its body would corrupt the code
904    /// content, so over-indented fenced blocks are deliberately left as-is.
905    fn build_over_indent_warning(
906        ctx: &NativeListSource<'_, '_>,
907        line: &ContinuationLine<'_>,
908        fix_target: usize,
909        message: String,
910    ) -> LintWarning {
911        let line_content = line.info.content(ctx.content);
912        let fix_start = line.info.byte_offset;
913        let fix_end = fix_start + line.info.indent;
914        LintWarning {
915            rule_name: Some("MD077".to_string()),
916            line: line.line_num,
917            column: 1,
918            end_line: line.line_num,
919            end_column: line_content.chars().count() + 1,
920            message,
921            severity: Severity::Warning,
922            fix: Some(Fix::new(fix_start..fix_end, " ".repeat(fix_target))),
923        }
924    }
925
926    /// Build the warning for a loose-mode under-indented continuation line.
927    /// When the line is the opener of a fenced code block, emit a compound
928    /// fix that reindents opener + interior + closer atomically so MD031
929    /// doesn't see a transiently-broken fence pair (see #574).
930    ///
931    /// Returns the warning plus, when the fix is compound, the closer
932    /// line number so the caller can mark it flagged (preventing the
933    /// main loop from double-flagging the closer as its own under-indent
934    /// case). Keeping the "also flag this line" signal out of band keeps
935    /// this function pure — it reads from `ctx` only and returns a
936    /// plain value.
937    fn build_under_indent_warning(
938        ctx: &NativeListSource<'_, '_>,
939        line: &ContinuationLine<'_>,
940        required: usize,
941        message: String,
942    ) -> UnderIndentOutcome {
943        let line_content = line.info.content(ctx.content);
944        let fence_closer = Self::fenced_block_last_line(ctx, line.info, line.trimmed);
945
946        let (fix, warn_end_line, warn_end_column, compound_closer) = if let Some(closer_line) = fence_closer {
947            let fix = Self::build_compound_fence_fix(ctx, line.line_num, closer_line, line.actual, required);
948            let end_column = ctx
949                .line_info(closer_line)
950                .map_or(line_content.chars().count() + 1, |ci| {
951                    ci.content(ctx.content).chars().count() + 1
952                });
953            let extra_flag = (closer_line != line.line_num).then_some(closer_line);
954            (fix, closer_line, end_column, extra_flag)
955        } else {
956            let fix_start = line.info.byte_offset;
957            let fix_end = fix_start + line.info.indent;
958            let fix = Some(Fix::new(fix_start..fix_end, " ".repeat(required)));
959            (fix, line.line_num, line_content.chars().count() + 1, None)
960        };
961
962        UnderIndentOutcome {
963            warning: LintWarning {
964                rule_name: Some("MD077".to_string()),
965                line: line.line_num,
966                column: 1,
967                end_line: warn_end_line,
968                end_column: warn_end_column,
969                message,
970                severity: Severity::Warning,
971                fix,
972            },
973            also_flag_line: compound_closer,
974        }
975    }
976}
977
978/// A list item written on the same line as the item that holds it (`- 1. x`),
979/// as the columns a continuation has to reach to belong to it.
980#[derive(Clone, Copy)]
981struct SameLineItem {
982    marker_col: usize,
983    content_col: usize,
984    /// Nothing follows its marker on the line.
985    is_empty: bool,
986}
987
988/// One list item as MD077 scopes it, in visual columns.
989struct ItemScope {
990    item_line: usize,
991    marker_col: usize,
992    content_col: usize,
993    /// The post-checkbox column of a GFM task item.
994    task_col: Option<usize>,
995    /// The indent a continuation line has to reach.
996    required: usize,
997    /// Only lines without a blank line before them are enforced, because the
998    /// required column is one where Python-Markdown reads code after a blank.
999    tight_only: bool,
1000    /// First column at which Python-Markdown reads the item's content as an
1001    /// indented code block, or `None` outside the strict-indent flavors.
1002    code_column: Option<usize>,
1003    /// The items inside this one that Python-Markdown reads as items, as
1004    /// (line, paragraph column, first line past the item's content), in line order; empty outside the strict-indent
1005    /// flavors.
1006    nested: Vec<(usize, usize, usize)>,
1007    /// The last line the item can own.
1008    range_end: usize,
1009    /// Nothing follows the marker on its line, so the item holds at most one
1010    /// blank line before its content.
1011    starts_empty: bool,
1012    /// The items written on this item's line inside it, outermost first.
1013    same_line: Vec<SameLineItem>,
1014}
1015
1016/// A continuation line yielded by `walk_item_continuation`. Bundles the
1017/// per-line facts both checker branches need so helper functions don't
1018/// balloon their argument lists.
1019struct ContinuationLine<'a> {
1020    line_num: usize,
1021    info: &'a LineInfo,
1022    trimmed: &'a str,
1023    actual: usize,
1024    saw_blank: bool,
1025    /// True once a nested child item has appeared earlier in this item's scope.
1026    /// Subsequent lines are lazy continuation of the deeper item (which owns
1027    /// them under CommonMark), so an ancestor must not claim them.
1028    saw_nested: bool,
1029}
1030
1031/// Result of `build_under_indent_warning`. Carries both the warning and,
1032/// when the fix is compound (fence opener → promote-to-required over the
1033/// whole block), the closer line so the caller can record it as already
1034/// handled. This keeps the warning builder free of external mutation.
1035struct UnderIndentOutcome {
1036    warning: LintWarning,
1037    also_flag_line: Option<usize>,
1038}
1039
1040impl MD077ListContinuationIndent {
1041    // Keep the native fixer authoritative: its stable descending replacements
1042    // intentionally retain their existing overlap and line-ending semantics.
1043    pub(crate) fn apply_native_fixes(content: &str, mut fixes: Vec<Fix>) -> String {
1044        fixes.sort_by_key(|fix| std::cmp::Reverse(fix.range.start));
1045        let mut content = content.to_string();
1046        for fix in fixes {
1047            if fix.range.start <= content.len() && fix.range.end <= content.len() {
1048                content.replace_range(fix.range, &fix.replacement);
1049            }
1050        }
1051        content
1052    }
1053
1054    pub(crate) fn check_native_source(&self, ctx: &NativeListSource<'_, '_>) -> Vec<LintWarning> {
1055        if ctx.content.is_empty() {
1056            return Vec::new();
1057        }
1058
1059        let strict_indent = ctx.flavor.requires_strict_list_indent();
1060        let total_lines = ctx.lines.len();
1061        let mut warnings = Vec::new();
1062        // The item whose scope produced the warning on each line.
1063        let mut owners: std::collections::HashMap<usize, usize> = std::collections::HashMap::new();
1064        let mut flagged_lines = std::collections::HashSet::new();
1065
1066        // Collect all list item lines sorted, with their content_column,
1067        // marker_column, and — if the item is a GFM task — its post-checkbox
1068        // column. Precomputing task_col here (instead of re-reading line_info
1069        // inside the hot inner loop) keeps the per-item cost O(1).
1070        //
1071        // We need the owned range to extend past block.end_line because the
1072        // parser excludes under-indented continuation from the block, and
1073        // MD077 specifically has to evaluate those escaped lines.
1074        let mut items: Vec<(usize, CommonMarkItem, Option<usize>)> = Vec::new();
1075        // The items each list item line opens, to find the ones written on the
1076        // same line as another.
1077        let mut chains: Vec<(usize, Vec<CommonMarkItem>)> = Vec::new();
1078        // The list block each item line belongs to, so that nesting is only read
1079        // between items of one block.
1080        let mut block_of_line: std::collections::HashMap<usize, usize> = std::collections::HashMap::new();
1081        for (block_idx, block) in ctx.list_blocks.iter().enumerate() {
1082            for &item_line in &block.item_lines {
1083                block_of_line.insert(item_line, block_idx);
1084                if let Some(info) = ctx.line_info(item_line)
1085                    && let Some(ref li) = info.list_item
1086                {
1087                    // Lists inside blockquotes are out of scope for MD077. Their
1088                    // content column is measured on the raw line, so it is
1089                    // meaningless for a lazy continuation sitting outside the
1090                    // quote: scoping them lets two items with different content
1091                    // columns each claim such a line and reindent it the other
1092                    // way on alternating passes.
1093                    if info.blockquote.is_some() {
1094                        continue;
1095                    }
1096                    // The container pass did not read this line as opening an
1097                    // item, so its columns are unknown and nothing is judged
1098                    // against a guess.
1099                    if li.commonmark_items.is_empty() {
1100                        continue;
1101                    }
1102                    let line = info.content(ctx.content);
1103                    chains.push((item_line, li.commonmark_items.clone()));
1104                    for item in &li.commonmark_items {
1105                        let task_col = Self::is_task_at_column(line, item.content_column)
1106                            .then_some(item.content_column + Self::TASK_CHECKBOX_PREFIX_LEN);
1107                        items.push((item_line, *item, task_col));
1108                    }
1109                }
1110            }
1111        }
1112
1113        // Every warning below belongs to a collected item or its continuation.
1114        if items.is_empty() {
1115            return warnings;
1116        }
1117
1118        chains.sort_by_key(|&(line, _)| line);
1119        chains.dedup_by_key(|&mut (line, _)| line);
1120        items.sort_unstable_by_key(|&(line, item, _)| (line, item.marker_column));
1121        items.dedup_by_key(|&mut (line, item, _)| (line, item.marker_column));
1122
1123        // Precompute each item's owned-range end. The range ends at the line
1124        // before the next item whose marker column is <= this item's (its next
1125        // sibling or an ancestor), or the last line if none follows.
1126        //
1127        // The direct "scan forward for the next marker_col <= mine" is O(n^2) on
1128        // a monotonically deepening list, where no later item ever qualifies so
1129        // every item scans to the end. A monotonic stack (nearest
1130        // smaller-or-equal marker column to the right) computes all range ends in
1131        // one linear pass. Walking right-to-left, pop every stacked item whose
1132        // marker column is strictly greater than the current one (they can never
1133        // be the sibling/ancestor of anything further left that is <= current),
1134        // leaving the nearest qualifying item on top.
1135        let mut range_ends = vec![total_lines; items.len()];
1136        let mut stack: Vec<usize> = Vec::new();
1137        for i in (0..items.len()).rev() {
1138            let marker_col = items[i].1.marker_column;
1139            while let Some(&top) = stack.last() {
1140                if items[top].1.marker_column > marker_col {
1141                    stack.pop();
1142                } else {
1143                    break;
1144                }
1145            }
1146            range_ends[i] = stack.last().map_or(total_lines, |&j| items[j].0 - 1);
1147            stack.push(i);
1148        }
1149
1150        // Python-Markdown's nesting level of every item, and whether it reads the
1151        // item as a list item at all. It reads a line's level as its indent
1152        // divided by four, so an item that opens its line sits at `marker
1153        // column / 4`, and each item written after another on the same line
1154        // (`- - a`) one level deeper than the one before it. A level more than
1155        // one below its parent's is no item for it, only text of the parent's
1156        // paragraph, and neither is a marker with nothing after it; the items
1157        // written directly under either are text too. The parent is the
1158        // nearest earlier item of the same list block with a marker further left.
1159        let mut pm_levels: Vec<usize> = Vec::with_capacity(items.len());
1160        let mut pm_items: Vec<bool> = Vec::with_capacity(items.len());
1161        let mut shared_line: Vec<bool> = Vec::with_capacity(items.len());
1162        // Which lines Python-Markdown reads as an item marker: a list never
1163        // starts in the middle of a paragraph, and an item's text can follow
1164        // lines of the item above in the same block.
1165        let parser_markers: Option<Vec<bool>> = strict_indent.then(|| Self::parser_marker_lines(ctx)).flatten();
1166        // The level of the first item written on each item's line.
1167        let mut head_levels: Vec<usize> = Vec::with_capacity(items.len());
1168        let mut open: Vec<usize> = Vec::new();
1169        for (i, &(item_line, item, _)) in items.iter().enumerate() {
1170            let block = block_of_line.get(&item_line);
1171            while open.last().is_some_and(|&top| {
1172                items[top].1.marker_column >= item.marker_column || block_of_line.get(&items[top].0) != block
1173            }) {
1174                open.pop();
1175            }
1176            let (level, is_item) = match open.last() {
1177                Some(&parent) if items[parent].0 == item_line => (pm_levels[parent] + 1, pm_items[parent]),
1178                Some(&parent) => {
1179                    let level = item.marker_column / 4;
1180                    let after_blank = ctx
1181                        .line_info(item_line - 1)
1182                        .is_some_and(|prev| Self::is_cm_blank(prev.content(ctx.content)));
1183                    // Text opens no list for what follows it directly, but a blank
1184                    // line ends its paragraph, and the marker is then read against
1185                    // the nearest item above it that is one.
1186                    let anchor = if pm_items[parent] {
1187                        Some(Some(parent))
1188                    } else if after_blank {
1189                        Some(open.iter().rev().copied().find(|&above| pm_items[above]))
1190                    } else {
1191                        None
1192                    };
1193                    let is_item = match anchor {
1194                        None => false,
1195                        // No item above: a list of its own, whose items sit at
1196                        // the first level or are code.
1197                        Some(None) => level <= items[parent].1.marker_column / 4,
1198                        // An item written on its parent's line holds the text
1199                        // after it in one paragraph: a deeper marker on the next
1200                        // line only opens a list after a blank line.
1201                        Some(Some(above)) if shared_line[above] => {
1202                            level <= head_levels[above] + 1 || (after_blank && level <= pm_levels[above] + 1)
1203                        }
1204                        Some(Some(above)) => level <= items[above].1.marker_column / 4 + 1,
1205                    };
1206                    (level, is_item)
1207                }
1208                None => (item.marker_column / 4, true),
1209            };
1210            // A marker with nothing after it, not even a space, is text there,
1211            // and so is a `)` ordered marker, which it does not know.
1212            let raw = ctx.line_info(item_line).map(|info| info.content(ctx.content));
1213            let bare = item.is_empty && raw.is_some_and(|line| !line.ends_with([' ', '\t']));
1214            let paren = raw.is_some_and(|line| Self::is_paren_marker(line, item.marker_column));
1215            let shared = open.last().filter(|&&parent| items[parent].0 == item_line);
1216            shared_line.push(shared.is_some());
1217            head_levels.push(shared.map_or(level, |&parent| head_levels[parent]));
1218            let in_quote = ctx.line_info(item_line).is_some_and(|info| info.blockquote.is_some());
1219            let read_as_marker = in_quote
1220                || parser_markers
1221                    .as_ref()
1222                    .is_none_or(|markers| markers.get(item_line - 1).copied().unwrap_or(false));
1223            let is_pm_item = is_item && read_as_marker && !bare && !paren;
1224            pm_levels.push(level);
1225            pm_items.push(is_pm_item);
1226            open.push(i);
1227        }
1228
1229        // Precompute which lines can ever reach the per-line continuation
1230        // callback. `walk_item_continuation` unconditionally skips (regardless of
1231        // which item owns the line) blank lines, list-item lines, headings,
1232        // horizontal rules, `should_skip_line` lines, and block-level constructs;
1233        // only the lines that survive all of those can be flagged. An item whose
1234        // owned range contains none of them produces no warning, so both passes
1235        // can skip it without walking the range at all.
1236        //
1237        // This keeps a document of purely deeply-nested list items linear: such a
1238        // document has no continuation prose, so every item skips its walk
1239        // instead of re-scanning the whole tail (which was O(n^2) in the item
1240        // count). Documents that do have continuation prose are unaffected - the
1241        // guard only ever skips items that could not have produced a warning.
1242        let prose_candidate_lines: Vec<usize> = (1..=total_lines)
1243            .filter(|&line_num| {
1244                let Some(info) = ctx.line_info(line_num) else {
1245                    return false;
1246                };
1247                let trimmed = info.content(ctx.content).trim_start();
1248                !Self::should_skip_line(ctx, info, trimmed)
1249                    && !info.is_blank
1250                    && info.list_item.is_none()
1251                    && info.heading.is_none()
1252                    && !info.is_setext_heading_text
1253                    && !info.is_horizontal_rule
1254                    && !Self::is_block_level_construct(trimmed)
1255            })
1256            .collect();
1257        // True when a continuation candidate falls in `(after_line, range_end]`,
1258        // i.e. the half-open range `walk_item_continuation` actually visits.
1259        let range_has_prose_candidate = |after_line: usize, range_end: usize| -> bool {
1260            let start = prose_candidate_lines.partition_point(|&l| l <= after_line);
1261            prose_candidate_lines.get(start).is_some_and(|&l| l <= range_end)
1262        };
1263
1264        // Precompute each item's required indent and owned line range so both
1265        // passes below scope identically.
1266        // The first line past each item that Python-Markdown still reads as the
1267        // item's own content: the first non-blank line indented less than the
1268        // item's paragraph column.
1269        // Evaluated on demand: only the scopes that can produce a warning ask.
1270        let closes: Vec<std::cell::OnceCell<usize>> = items.iter().map(|_| std::cell::OnceCell::new()).collect();
1271        let close_of = |idx: usize| -> usize {
1272            *closes[idx].get_or_init(|| {
1273                let item_line = items[idx].0;
1274                let column = 4 * (pm_levels[idx] + 1);
1275                (item_line + 1..=total_lines)
1276                    .find(|&line_num| {
1277                        ctx.line_info(line_num).is_some_and(|info| {
1278                            info.visual_indent < column && !Self::is_cm_blank(info.content(ctx.content))
1279                        })
1280                    })
1281                    .unwrap_or(total_lines + 1)
1282            })
1283        };
1284        let scoped: Vec<ItemScope> = items
1285            .iter()
1286            .enumerate()
1287            .filter(|&(item_idx, _)| !strict_indent || pm_items[item_idx])
1288            .map(|(item_idx, &(item_line, item, task_col))| {
1289                let marker_col = item.marker_column;
1290                let content_col = item.content_column;
1291                // A configured indent replaces the content column, but under a
1292                // flavor that requires strict list indentation it may only raise
1293                // the 4-space minimum. That minimum is a compatibility floor
1294                // rather than a style default: below it the renderer stops
1295                // treating the line as continuation, so honoring a smaller
1296                // configured value would make `fix` rewrite documents into a
1297                // form the flavor's renderer no longer reads as part of the item.
1298                // The minimum is four columns per nesting level: Python-Markdown
1299                // reads an item's paragraphs at four columns past the item's own
1300                // level, whatever the marker width.
1301                let level_column = 4 * (pm_levels[item_idx] + 1);
1302                let required = match self.config.indent {
1303                    Some(indent) if strict_indent => (marker_col + indent).max(level_column),
1304                    Some(indent) => marker_col + indent,
1305                    None if strict_indent => content_col.max(level_column),
1306                    None => content_col,
1307                };
1308                // No column is both inside the item for the parser and a paragraph
1309                // for Python-Markdown when the required column is already where
1310                // it starts reading code, so only the item's tight continuation
1311                // lines can be enforced: after a blank line the column would
1312                // turn the paragraph into code.
1313                let tight_only = strict_indent && required >= level_column + 4;
1314                // `chains` is sorted by line with one entry per item line.
1315                let same_line = chains
1316                    .binary_search_by_key(&item_line, |(line, _)| *line)
1317                    .ok()
1318                    .map(|at| &chains[at].1)
1319                    .map(|chain| {
1320                        chain
1321                            .iter()
1322                            .filter(|inner| inner.marker_column > marker_col)
1323                            .map(|inner| SameLineItem {
1324                                marker_col: inner.marker_column,
1325                                content_col: inner.content_column,
1326                                is_empty: inner.is_empty,
1327                            })
1328                            .collect()
1329                    })
1330                    .unwrap_or_default();
1331                ItemScope {
1332                    item_line,
1333                    marker_col,
1334                    content_col,
1335                    task_col,
1336                    required,
1337                    tight_only,
1338                    code_column: strict_indent.then_some(level_column + 4),
1339                    nested: if strict_indent && range_has_prose_candidate(item_line, range_ends[item_idx]) {
1340                        items[item_idx + 1..]
1341                            .iter()
1342                            .enumerate()
1343                            .take_while(|&(_, &(line, _, _))| line <= range_ends[item_idx])
1344                            .map(|(offset, &(line, _, _))| (line, item_idx + 1 + offset))
1345                            .filter(|&(_, other)| pm_items[other])
1346                            .map(|(line, other)| (line, 4 * (pm_levels[other] + 1), close_of(other)))
1347                            .collect()
1348                    } else {
1349                        Vec::new()
1350                    },
1351                    range_end: range_ends[item_idx],
1352                    starts_empty: item.is_empty,
1353                    same_line,
1354                }
1355            })
1356            .collect();
1357
1358        // Pass 1 - under-indented continuation.
1359        //
1360        // Loose under-indent (after a blank line) sits below the content column
1361        // and would escape the list item; it is always flagged. Tight
1362        // under-indent is valid CommonMark lazy continuation, so it is left
1363        // alone under `style = "any"`.
1364        //
1365        // Under `style = "aligned"`, tight under-indent is additionally flagged
1366        // and snapped up to the content column. MD077 scopes past the parser's
1367        // block end (to catch escaped loose content), and the parser absorbs
1368        // col-0 blockquotes, fences, and tables that sit tight under a list item
1369        // as lazy continuation. Reindenting those would change the rendered
1370        // structure, so the tight-aligned branch guards two ways: it skips lines
1371        // the parser classified in place (code, blockquote - see
1372        // `confirmed_structure`), and it bails on an item whose continuation
1373        // holds a construct the parser would re-parse once indentation changes
1374        // (a latent list marker, table or setext underline - see
1375        // `has_latent_structure`). Headings and horizontal rules already
1376        // terminate the walk; reference/footnote/abbreviation definitions are
1377        // skipped inside it. Erring toward skipping yields false negatives
1378        // (safe) rather than structural false positives.
1379        //
1380        // This pass runs first so that a deeply nested item claims an ambiguous
1381        // line - one that is under-indented for it yet over-indented for a
1382        // shallower ancestor - before pass 2 can mis-attribute it to the
1383        // ancestor as an over-indent and snap it the wrong way.
1384        let aligned = self.config.style == ContinuationStyle::Aligned;
1385        for scope in &scoped {
1386            let (item_line, marker_col, required, range_end) =
1387                (scope.item_line, scope.marker_col, scope.required, scope.range_end);
1388            // No continuation candidate in this item's range: it cannot produce a
1389            // warning, so skip the walk (and the latent-structure scan) entirely.
1390            if !range_has_prose_candidate(item_line, range_end) {
1391                continue;
1392            }
1393            // "Latent structure": a line in this item's continuation that the
1394            // parser has NOT yet promoted to a list item or table, but that
1395            // would be once an earlier continuation line is reindented. Such a
1396            // promotion re-attributes the following lines to a different block
1397            // (different content column) and breaks single-pass idempotency, so
1398            // reindenting anything in the item is unsafe and the whole item is
1399            // skipped. This is a *prediction* of how the parser would re-parse
1400            // after a fix, which is why it stays a heuristic (markers, tables and
1401            // setext underlines are the constructs the parser absorbs as lazy
1402            // continuation rather than flagging on the line itself; code and
1403            // blockquotes are classified in place and handled per line below).
1404            // Well-formed prose continuation matches none, so this only bails
1405            // on pathological input (a safe false negative). Real nested items
1406            // never reach the callback (the walk handles them via `saw_nested`).
1407            let has_latent_structure = aligned && Self::item_range_has_latent_structure(ctx, item_line, range_end);
1408            // Which setting produced `required` decides what the message may
1409            // claim. The content column carries a structural consequence (below
1410            // it the content leaves the item) and the strict-flavor minimum
1411            // carries a renderer requirement, but a configured indent above
1412            // both is a house style, so it is reported as one.
1413            let from_configured_indent = self.config.indent.is_some_and(|indent| marker_col + indent == required);
1414            Self::walk_item_continuation(ctx, scope, |line| {
1415                let actual = line.actual;
1416                if Self::is_python_markdown_code(ctx, scope, line) || (scope.tight_only && line.saw_blank) {
1417                    return ControlFlow::Continue(());
1418                }
1419                let under_indented = actual < required;
1420                let loose_escape = line.saw_blank && under_indented;
1421                // Don't reindent a line the parser classified in place as a code
1422                // block (fenced or indented) or a blockquote: these are read
1423                // straight from its per-line output, so the rule cannot drift
1424                // from how rumdl parses the document. Headings and horizontal
1425                // rules terminate the walk before the callback; latent list
1426                // markers and tables are handled by `has_latent_structure`.
1427                let confirmed_structure = line.info.in_code_block || line.info.blockquote.is_some();
1428                let aligned_tight = aligned
1429                    && !has_latent_structure
1430                    && !line.saw_blank
1431                    && !line.saw_nested
1432                    && under_indented
1433                    && !confirmed_structure;
1434                if (loose_escape || aligned_tight) && flagged_lines.insert(line.line_num) {
1435                    let message = if line.saw_blank {
1436                        if from_configured_indent {
1437                            format!(
1438                                "Content after blank line in list item needs {required} spaces of \
1439                                 indentation to match the configured indent (found {actual})",
1440                            )
1441                        } else if strict_indent {
1442                            format!(
1443                                "Content inside list item needs {required} spaces of indentation \
1444                                 for MkDocs compatibility (found {actual})",
1445                            )
1446                        } else {
1447                            format!(
1448                                "Content after blank line in list item needs {required} spaces of \
1449                                 indentation to remain part of the list (found {actual})",
1450                            )
1451                        }
1452                    } else {
1453                        format!("Continuation line under-indented (expected {required}, found {actual})")
1454                    };
1455                    let outcome = Self::build_under_indent_warning(ctx, line, required, message);
1456                    if let Some(closer_line) = outcome.also_flag_line {
1457                        flagged_lines.insert(closer_line);
1458                    }
1459                    owners.insert(outcome.warning.line, item_line);
1460                    warnings.push(outcome.warning);
1461                }
1462                ControlFlow::Continue(())
1463            });
1464        }
1465
1466        // Pass 2 - over-indented continuation (tight or loose): prose pushed
1467        // past the content column is snapped back. Fenced code blocks are
1468        // skipped here (`!in_code_block`): an over-indented fence is cosmetic
1469        // (the code still renders), and reindenting only its delimiters - the
1470        // body is skipped by `should_skip_line` - would alter the literal code
1471        // content. Indented code blocks (content column + 4 or more) are also
1472        // `in_code_block`, so a blank line before such a body does not exempt
1473        // it from being recognized as code rather than over-indented prose.
1474        for scope in &scoped {
1475            let (item_line, content_col, task_col, required, range_end) = (
1476                scope.item_line,
1477                scope.content_col,
1478                scope.task_col,
1479                scope.required,
1480                scope.range_end,
1481            );
1482            // No continuation candidate in this item's range: nothing to flag.
1483            if !range_has_prose_candidate(item_line, range_end) {
1484                continue;
1485            }
1486            // For task items, gather sibling-column usage once so the auto-fix
1487            // can tie-break equidistant over-indents toward whichever valid
1488            // column the author is already using.
1489            let (uses_content_col, uses_task_col) = match task_col {
1490                Some(t) => Self::sibling_column_usage(ctx, scope, t),
1491                None => (false, false),
1492            };
1493
1494            Self::walk_item_continuation(ctx, scope, |line| {
1495                let actual = line.actual;
1496                if Self::is_python_markdown_code(ctx, scope, line) || (scope.tight_only && line.saw_blank) {
1497                    return ControlFlow::Continue(());
1498                }
1499                if actual <= required {
1500                    return ControlFlow::Continue(());
1501                }
1502                let fix_target = Self::compute_fix_target(actual, required, task_col, uses_content_col, uses_task_col);
1503                if !line.info.in_code_block
1504                    && Some(actual) != task_col
1505                    && !Self::starts_with_list_marker(line.trimmed)
1506                    && !Self::opens_block_when_moved(line.trimmed, content_col, actual, fix_target)
1507                    && flagged_lines.insert(line.line_num)
1508                {
1509                    let message = match task_col {
1510                        Some(t) => format!(
1511                            "Continuation line over-indented \
1512                             (expected {required} or {t}, found {actual})"
1513                        ),
1514                        None => {
1515                            format!("Continuation line over-indented (expected {required}, found {actual})")
1516                        }
1517                    };
1518                    let warning = Self::build_over_indent_warning(ctx, line, fix_target, message);
1519                    owners.insert(warning.line, item_line);
1520                    warnings.push(warning);
1521                }
1522                ControlFlow::Continue(())
1523            });
1524        }
1525
1526        // The two passes emit independently, so order by position before
1527        // returning - callers and tests expect document order.
1528        warnings.sort_by_key(|w| (w.line, w.column));
1529
1530        if strict_indent {
1531            Self::drop_unsafe_fixes(ctx, &mut warnings, &owners);
1532        }
1533
1534        warnings
1535    }
1536}
1537
1538impl Rule for MD077ListContinuationIndent {
1539    fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
1540        (self.config == Default::default()).then_some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(77))
1541    }
1542
1543    fn name(&self) -> &'static str {
1544        "MD077"
1545    }
1546
1547    fn description(&self) -> &'static str {
1548        "List continuation content indentation"
1549    }
1550
1551    fn check(&self, ctx: &LintContext) -> LintResult {
1552        Ok(self.check_native_source(&ctx.native_list_source()))
1553    }
1554
1555    fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
1556        let warnings = self.check(ctx)?;
1557        let warnings =
1558            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
1559        if warnings.is_empty() {
1560            return Ok(ctx.content.to_string());
1561        }
1562
1563        let fixes: Vec<Fix> = warnings.into_iter().filter_map(|w| w.fix).collect();
1564        Ok(Self::apply_native_fixes(ctx.content, fixes))
1565    }
1566
1567    fn fix_with_diagnostics(&self, ctx: &LintContext, warnings: &[LintWarning]) -> Result<String, LintError> {
1568        let fixes = warnings
1569            .iter()
1570            .filter(|warning| crate::utils::fix_utils::warning_is_enabled(warning, ctx.inline_config(), self.name()))
1571            .filter_map(|warning| warning.fix.clone())
1572            .collect();
1573        Ok(Self::apply_native_fixes(ctx.content, fixes))
1574    }
1575
1576    fn category(&self) -> RuleCategory {
1577        RuleCategory::List
1578    }
1579
1580    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
1581        ctx.content.is_empty() || ctx.list_blocks.is_empty()
1582    }
1583
1584    fn as_any(&self) -> &dyn std::any::Any {
1585        self
1586    }
1587
1588    crate::impl_rule_config_methods!(MD077Config);
1589}
1590
1591#[cfg(test)]
1592mod tests {
1593    use super::*;
1594    use crate::config::MarkdownFlavor;
1595
1596    fn check(content: &str) -> Vec<LintWarning> {
1597        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1598        let rule = MD077ListContinuationIndent::default();
1599        rule.check(&ctx).unwrap()
1600    }
1601
1602    fn check_mkdocs(content: &str) -> Vec<LintWarning> {
1603        let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
1604        let rule = MD077ListContinuationIndent::default();
1605        rule.check(&ctx).unwrap()
1606    }
1607
1608    fn fix(content: &str) -> String {
1609        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1610        let rule = MD077ListContinuationIndent::default();
1611        rule.fix(&ctx).unwrap()
1612    }
1613
1614    fn fix_mkdocs(content: &str) -> String {
1615        let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
1616        let rule = MD077ListContinuationIndent::default();
1617        rule.fix(&ctx).unwrap()
1618    }
1619
1620    #[test]
1621    fn test_blockquote_line_ends_the_item() {
1622        // A `>` left of the content column opens a blockquote after the list,
1623        // so the text below it is a paragraph of its own, not the item's.
1624        for content in ["1.  item\n>\n  continuation text\n", "- item\n> quote\n\n text\n"] {
1625            assert!(check(content).is_empty(), "{content:?}: {:?}", check(content));
1626            assert_eq!(fix(content), content);
1627        }
1628        // A quote inside the item keeps the item open.
1629        let inside = "- item\n\n  > quote\n\n text\n";
1630        assert_eq!(check(inside).len(), 1);
1631        assert_eq!(fix(inside), "- item\n\n  > quote\n\n  text\n");
1632    }
1633
1634    #[test]
1635    fn test_fence_after_an_indented_code_block_moves_as_one_block() {
1636        // The fence opens right below an indented code block, so the whole
1637        // fenced block, not just its opener, has to move into the item.
1638        let content = " 2) item\n\n          ```\n   ```\n   code\n   ```\n";
1639        let fixed = fix(content);
1640        assert_eq!(fixed, " 2) item\n\n          ```\n    ```\n    code\n    ```\n");
1641        assert_eq!(fix(&fixed), fixed);
1642    }
1643
1644    #[test]
1645    fn test_marker_left_of_the_content_column_ends_the_item() {
1646        // `1.` sits left of the bullet's content column, so it starts a list of
1647        // its own rather than nesting, and the text below continues it.
1648        let content = "*  a\n 1.  b\n\n  text\n";
1649        let warnings = check(content);
1650        assert_eq!(warnings.len(), 1, "{warnings:?}");
1651        let message = &warnings[0].message;
1652        assert!(message.contains("needs 5 spaces"), "{message}");
1653        let fixed = fix(content);
1654        assert_eq!(fixed, "*  a\n 1.  b\n\n     text\n");
1655        assert_eq!(fix(&fixed), fixed);
1656
1657        // At or left of that list's marker, the text is outside every item.
1658        let outside = "- a\n 1.  b\n\n text\n";
1659        assert!(check(outside).is_empty(), "{:?}", check(outside));
1660    }
1661
1662    #[test]
1663    fn test_fence_text_in_indented_code_is_not_a_fence() {
1664        // Each last line is an indented code block whose text looks like a fence
1665        // (4 columns past the top level, or past the outer item's content).
1666        // Moving it would turn literal code into a fenced block.
1667        for content in [
1668            "  *  item\n     ```\n     ```\n\n    ```\n",
1669            "   1. item\n        -  item\n\n          ```\n",
1670        ] {
1671            assert!(check(content).is_empty(), "{content:?}: {:?}", check(content));
1672            assert_eq!(fix(content), content);
1673        }
1674    }
1675
1676    fn aligned_rule() -> MD077ListContinuationIndent {
1677        MD077ListContinuationIndent::new(ContinuationStyle::Aligned)
1678    }
1679
1680    fn check_aligned(content: &str) -> Vec<LintWarning> {
1681        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1682        aligned_rule().check(&ctx).unwrap()
1683    }
1684
1685    fn check_aligned_mkdocs(content: &str) -> Vec<LintWarning> {
1686        let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
1687        aligned_rule().check(&ctx).unwrap()
1688    }
1689
1690    fn fix_aligned(content: &str) -> String {
1691        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1692        aligned_rule().fix(&ctx).unwrap()
1693    }
1694
1695    fn fix_aligned_quarto(content: &str) -> String {
1696        let ctx = LintContext::new(content, MarkdownFlavor::Quarto, None);
1697        aligned_rule().fix(&ctx).unwrap()
1698    }
1699
1700    #[test]
1701    fn html_block_opened_by_an_inline_level_tag_is_left_alone() {
1702        // `<math>` names no block-level element, yet alone on its line it opens
1703        // an HTML block, whose lines are raw HTML rather than continuation.
1704        let content = "- item\n\n    <math display=\"block\">\n          <mi>x</mi>\n    </math>\n";
1705        assert!(check(content).is_empty(), "{:?}", check(content));
1706        assert_eq!(fix(content), content);
1707    }
1708
1709    #[test]
1710    fn html_block_in_an_item_is_left_alone_past_a_broken_tag() {
1711        // The `>` closing a tag broken across lines is HTML text, so the lines
1712        // after it stay inside the HTML block rather than becoming continuation.
1713        let content = "- item\n\n    <table>\n      <td>\n        <a\n          >a</a\n        >\n        text after\n      </td>\n    </table>\n";
1714        assert!(check(content).is_empty(), "{:?}", check(content));
1715        assert_eq!(fix(content), content);
1716    }
1717
1718    #[test]
1719    fn lazy_continuation_does_not_close_the_nested_item() {
1720        // `    lazy` continues `b`'s paragraph below its content column without
1721        // closing `b`, so `para` at column 8 is `b`'s content, correctly indented.
1722        // Attributing it to `a` (content column 4) and snapping it there would move
1723        // the paragraph out of `b`.
1724        for content in [
1725            "*   a\n\n    *   b\n    lazy\n\n        para\n",
1726            "*   a\n\n    *   b\n    lazy\n\n        ```\n        x\n        ```\n\n        para\n",
1727            "* a\n\n  * b\n  lazy\n  lazy again\n\n    para\n",
1728        ] {
1729            assert!(check(content).is_empty(), "{content:?}: {:?}", check(content));
1730            assert_eq!(fix(content), content);
1731        }
1732    }
1733
1734    #[test]
1735    fn paragraph_text_that_would_open_a_block_keeps_its_indent() {
1736        // Four or more columns past the content column these lines cannot
1737        // interrupt the paragraph, so they are its text. At the content column
1738        // each would open a block instead: a fence, a blockquote, a setext
1739        // heading, a thematic break, a table or an ATX heading.
1740        for construct in [
1741            "```",
1742            "```rust",
1743            "~~~",
1744            "> q",
1745            "---",
1746            "===",
1747            "***",
1748            "_ _ _",
1749            "| x | y |\n      | - | - |",
1750            "# A",
1751            "###### A",
1752            "## A ##",
1753            "#\tA",
1754            "#",
1755        ] {
1756            let content = format!("- a\n      {construct}\n");
1757            assert!(check(&content).is_empty(), "{content:?}: {:?}", check(&content));
1758            assert_eq!(fix(&content), content);
1759        }
1760    }
1761
1762    #[test]
1763    fn hash_text_that_is_no_atx_heading_is_still_snapped_back() {
1764        for text in ["#A", "####### A", "#5"] {
1765            let content = format!("- a\n      {text}\n");
1766            assert_eq!(check(&content).len(), 1, "{content:?}");
1767            assert_eq!(fix(&content), format!("- a\n  {text}\n"));
1768        }
1769    }
1770
1771    #[test]
1772    fn paragraph_text_past_the_threshold_is_still_snapped_back() {
1773        let content = "- a\n      text\n";
1774        assert_eq!(check(content).len(), 1);
1775        assert_eq!(fix(content), "- a\n  text\n");
1776        // Block-like text is still snapped back when its target column keeps it
1777        // four or more columns past the content column, where it stays text.
1778        let rule = MD077ListContinuationIndent::from_config_struct(MD077Config {
1779            indent: Some(6),
1780            ..Default::default()
1781        });
1782        let content = "- a\n         > q\n";
1783        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1784        assert_eq!(rule.fix(&ctx).unwrap(), "- a\n      > q\n");
1785        // Within three columns of the content column the line already is the
1786        // block, so moving it keeps it the same block.
1787        let content = "- a\n\n   > q\n";
1788        assert_eq!(check(content).len(), 1);
1789        assert_eq!(fix(content), "- a\n\n  > q\n");
1790    }
1791
1792    #[test]
1793    fn over_indent_after_a_lazy_line_is_measured_against_the_nested_item() {
1794        let content = "* a\n\n  * b\n  lazy\n\n       para\n";
1795        let warnings = check(content);
1796        assert_eq!(warnings.len(), 1, "{warnings:?}");
1797        assert_eq!(warnings[0].line, 6);
1798        assert!(
1799            warnings[0].message.contains("expected 4, found 7"),
1800            "{}",
1801            warnings[0].message
1802        );
1803        assert_eq!(fix(content), "* a\n\n  * b\n  lazy\n\n    para\n");
1804    }
1805
1806    #[test]
1807    fn aligned_idempotent_with_latent_marker_behind_unstable_heading() {
1808        // Regression: an empty backtick run (`` ``\n`` ``) is parsed as a setext
1809        // heading. That heading used to terminate the latent-structure walk
1810        // before it reached the `- ` marker on the next line, so the marker was
1811        // not detected as latent. Reindenting the `1. ` continuation then
1812        // promoted `- ` to a real list item, producing a fresh under-indent
1813        // violation on the second pass and breaking single-pass idempotency.
1814        // The latent-structure scan now spans the full owned range, detects the
1815        // marker, and leaves the (ambiguous) item alone.
1816        let input = "1. \n``\n``\n- \n``";
1817        let once = fix_aligned_quarto(input);
1818        let twice = fix_aligned_quarto(&once);
1819        assert_eq!(once, twice, "MD077 aligned fix must be idempotent (Quarto)");
1820    }
1821
1822    #[test]
1823    fn latent_underline_needs_paragraph_text_above_it() {
1824        // Only paragraph text can become a setext heading's text line, so an
1825        // underline sitting under an ATX heading or a thematic break is latent
1826        // nothing and must not hold the whole item back.
1827        for (label, content) in [
1828            ("ATX heading", "- item\nwrap\n# Heading\n===\n"),
1829            ("thematic break", "- item\nwrap\n***\n===\n"),
1830            ("HTML block", "- item\nwrap\n<div>\n===\n"),
1831        ] {
1832            assert_eq!(
1833                check_aligned(content).len(),
1834                1,
1835                "{label}: reindenting cannot make a setext heading here, so the under-indent is reportable"
1836            );
1837        }
1838
1839        // A closing code fence is the case only the block flag can answer: its
1840        // text is indistinguishable from prose, and nothing but the block it
1841        // sits in says the line cannot be a setext heading's text. The fence at
1842        // the margin ends the item, so the underline below it is not the item's
1843        // to move; only the lazy line above the fence is reported.
1844        let warnings = check_aligned("- item\nwrap\n```\ncode\n```\n===\n");
1845        assert_eq!(
1846            warnings.len(),
1847            1,
1848            "a closing fence is not paragraph text, so it holds nothing back: {warnings:?}"
1849        );
1850        assert_eq!(warnings[0].line, 2);
1851
1852        // An empty list item is the case only the line's own markers can answer:
1853        // it is written inside a paragraph context and holds no text at all, and
1854        // an underline below it underlines nothing. A lone `-` is not this case
1855        // (under a paragraph it is itself an underline), so each marker carries
1856        // the space that opens an item holding nothing.
1857        for (label, content) in [
1858            ("empty bullet", "- item\n wrap\n > - \n ===\n"),
1859            ("empty ordered item", "- item\n wrap\n > 1. \n ===\n"),
1860            ("empty item in a nested quote", "- item\n wrap\n > > - \n ===\n"),
1861        ] {
1862            assert_eq!(
1863                check_aligned(content).len(),
1864                1,
1865                "{label}: an item holding no text cannot become a heading's text line"
1866            );
1867        }
1868
1869        // A blank line above the underline is the same answer for the same
1870        // reason, and needs no test of its own in the rule: stripped of its
1871        // markers it holds nothing, which is what the text predicate rejects.
1872        assert_eq!(
1873            check_aligned("- item\n wrap\n\n ===\n").len(),
1874            2,
1875            "nothing above the underline can become a heading's text line"
1876        );
1877        // A quote line left of the content column ends the item instead, so the
1878        // underline after it is not the item's to align.
1879        for (label, content) in [
1880            ("blank line in a quote", "- item\n wrap\n >\n ===\n"),
1881            ("quoted whitespace", "- item\n wrap\n >   \n ===\n"),
1882        ] {
1883            let warnings = check_aligned(content);
1884            assert_eq!(warnings.len(), 1, "{label}: {warnings:?}");
1885            assert_eq!(warnings[0].line, 2, "{label}");
1886        }
1887
1888        // Paired control: prose above the underline IS latent structure, because
1889        // reindenting the item would give the underline the content column and
1890        // turn the pair into a real heading.
1891        assert!(
1892            check_aligned("- item\nwrap\ntext\n===\n").is_empty(),
1893            "prose above the underline is latent structure, so the item is left alone"
1894        );
1895    }
1896
1897    #[test]
1898    fn aligned_idempotent_with_lazy_continuation_out_of_a_blockquote() {
1899        // Regression: a lazy continuation that follows a blockquote-nested list
1900        // item gained one space of indent on every pass, so the formatter never
1901        // converged.
1902        let input = "- \n> *\n> a\n``";
1903        let once = fix_aligned(input);
1904        let twice = fix_aligned(&once);
1905        assert_eq!(once, twice, "MD077 aligned fix must be idempotent");
1906    }
1907
1908    fn fix_aligned_flavor(content: &str, flavor: MarkdownFlavor) -> String {
1909        let ctx = LintContext::new(content, flavor, None);
1910        aligned_rule().fix(&ctx).unwrap()
1911    }
1912
1913    #[test]
1914    fn aligned_text_after_a_margin_fence_is_not_the_items() {
1915        // A fence at the margin cannot continue the item's paragraph, so it
1916        // closes the item. The paragraph below it is top level: moving it four
1917        // columns (the MkDocs requirement) would make it an indented code
1918        // block, and the second pass would then read the line below it
1919        // differently.
1920        for input in [
1921            "- A\n```\n\n```\n![`]()\n`",
1922            "- \u{1d400}\n```\n\n```\n*`*\n**`**",
1923            "- A\n```\nx\n```\ntext\nmore\n",
1924        ] {
1925            for flavor in [
1926                MarkdownFlavor::Standard,
1927                MarkdownFlavor::MkDocs,
1928                MarkdownFlavor::Quarto,
1929                MarkdownFlavor::MDX,
1930            ] {
1931                let ctx = LintContext::new(input, flavor, None);
1932                let warnings = aligned_rule().check(&ctx).unwrap();
1933                assert!(warnings.is_empty(), "{flavor:?} {input:?}: {warnings:?}");
1934                assert_eq!(fix_aligned_flavor(input, flavor), input, "{flavor:?} {input:?}");
1935            }
1936        }
1937    }
1938
1939    #[test]
1940    fn aligned_text_after_an_in_item_fence_is_not_the_items() {
1941        // The fence closes inside the item, but the line below it sits left of
1942        // the content column and no paragraph is open for it to continue.
1943        let input = "- A\n  ```\n  x\n  ```\ntext\n";
1944        for flavor in [MarkdownFlavor::Standard, MarkdownFlavor::MkDocs] {
1945            assert_eq!(fix_aligned_flavor(input, flavor), input, "{flavor:?}");
1946        }
1947    }
1948
1949    #[test]
1950    fn aligned_still_aligns_the_paragraph_before_a_margin_fence() {
1951        // Positive control for the two tests above: the lazy lines before the
1952        // fence are the item's paragraph and are still aligned, in each flavor's
1953        // own column.
1954        let input = "- A\nb\nc\n```\nx\n```\ntext\n";
1955        assert_eq!(
1956            fix_aligned_flavor(input, MarkdownFlavor::Standard),
1957            "- A\n  b\n  c\n```\nx\n```\ntext\n"
1958        );
1959        assert_eq!(
1960            fix_aligned_flavor(input, MarkdownFlavor::MkDocs),
1961            "- A\n    b\n    c\n```\nx\n```\ntext\n"
1962        );
1963    }
1964
1965    #[test]
1966    fn aligned_one_application_is_a_fixed_point_after_a_margin_fence() {
1967        for flavor in [MarkdownFlavor::Standard, MarkdownFlavor::MkDocs, MarkdownFlavor::Quarto] {
1968            for input in [
1969                "- A\n```\n\n```\n![`]()\n`",
1970                "- \u{1d400}\n```\n\n```\n*`*\n**`**",
1971                "- A\nb\n```\n\n```\n![`]()\n`",
1972            ] {
1973                let once = fix_aligned_flavor(input, flavor);
1974                let twice = fix_aligned_flavor(&once, flavor);
1975                assert_eq!(once, twice, "{flavor:?} {input:?}");
1976            }
1977        }
1978    }
1979
1980    // ── Tight continuation (no blank line) ─────────────────────────────
1981
1982    #[test]
1983    fn tight_lazy_continuation_zero_indent_not_flagged() {
1984        // Zero-indent lazy continuation is valid CommonMark
1985        let content = "- Item\ncontinuation\n";
1986        assert!(check(content).is_empty());
1987    }
1988
1989    #[test]
1990    fn tight_continuation_correct_indent_not_flagged() {
1991        // Correctly indented tight continuation (aligns with content column)
1992        let content = "1. Item\n   continuation\n";
1993        assert!(check(content).is_empty());
1994    }
1995
1996    #[test]
1997    fn tight_continuation_over_indented_ordered() {
1998        // "1. " = 3 chars, but continuation has 4 spaces
1999        let content = "1. This is a list item with multiple lines.\n    The second line is over-indented.\n";
2000        let warnings = check(content);
2001        assert_eq!(warnings.len(), 1);
2002        assert_eq!(warnings[0].line, 2);
2003        assert!(warnings[0].message.contains("over-indented"));
2004    }
2005
2006    #[test]
2007    fn tight_continuation_over_indented_unordered() {
2008        // "- " = 2 chars, but continuation has 3 spaces
2009        let content = "- Item\n   over-indented\n";
2010        let warnings = check(content);
2011        assert_eq!(warnings.len(), 1);
2012        assert_eq!(warnings[0].line, 2);
2013    }
2014
2015    #[test]
2016    fn tight_continuation_multiple_over_indented_lines() {
2017        let content = "1. Item\n    line one\n    line two\n    line three\n";
2018        let warnings = check(content);
2019        assert_eq!(warnings.len(), 3);
2020    }
2021
2022    #[test]
2023    fn tight_continuation_mixed_correct_and_over() {
2024        let content = "1. Item\n   correct\n    over-indented\n   correct again\n";
2025        let warnings = check(content);
2026        assert_eq!(warnings.len(), 1);
2027        assert_eq!(warnings[0].line, 3);
2028    }
2029
2030    #[test]
2031    fn tight_continuation_nested_over_indented() {
2032        // L2 "- " at column 2, content_column = 4. Continuation at 5 is over-indented for L2.
2033        let content = "- L1\n  - L2\n     over-indented continuation of L2\n";
2034        let warnings = check(content);
2035        assert_eq!(warnings.len(), 1);
2036        assert_eq!(warnings[0].line, 3);
2037        // Must report expected=4 (L2's content_col), not expected=2 (L1's)
2038        assert!(warnings[0].message.contains("expected 4"));
2039        assert!(warnings[0].message.contains("found 5"));
2040    }
2041
2042    #[test]
2043    fn tight_continuation_nested_correct_indent_not_flagged() {
2044        // Continuation at 4 spaces is correct for L2 (content_col=4). Must NOT be
2045        // flagged as over-indented relative to L1 (content_col=2).
2046        let content = "- L1\n  - L2\n    correctly indented continuation of L2\n";
2047        assert!(check(content).is_empty());
2048    }
2049
2050    #[test]
2051    fn fix_tight_continuation_nested_over_indented() {
2052        // Fix should reduce to 4 spaces (L2's content_col), not 2 (L1's)
2053        let content = "- L1\n  - L2\n     over-indented continuation of L2\n";
2054        let fixed = fix(content);
2055        assert_eq!(fixed, "- L1\n  - L2\n    over-indented continuation of L2\n");
2056    }
2057
2058    #[test]
2059    fn tight_continuation_under_indented_not_flagged() {
2060        // 2 spaces instead of 3 for "1. " — under-indented, not over-indented.
2061        // Valid lazy continuation in CommonMark, so not flagged.
2062        let content = "1. Item\n  under-indented\n";
2063        assert!(check(content).is_empty());
2064    }
2065
2066    #[test]
2067    fn tight_continuation_tab_over_indented() {
2068        // A tab expands to 4 visual columns, which exceeds content_col=2 for "- "
2069        let content = "- Item\n\tover-indented\n";
2070        let warnings = check(content);
2071        assert_eq!(warnings.len(), 1);
2072    }
2073
2074    #[test]
2075    fn fix_tight_continuation_over_indented_ordered() {
2076        let content = "1. This is a list item with multiple lines.\n    The second line is over-indented.\n";
2077        let fixed = fix(content);
2078        assert_eq!(
2079            fixed,
2080            "1. This is a list item with multiple lines.\n   The second line is over-indented.\n"
2081        );
2082    }
2083
2084    #[test]
2085    fn fix_tight_continuation_over_indented_unordered() {
2086        let content = "- Item\n   over-indented\n";
2087        let fixed = fix(content);
2088        assert_eq!(fixed, "- Item\n  over-indented\n");
2089    }
2090
2091    #[test]
2092    fn fix_tight_continuation_multiple_lines() {
2093        let content = "1. Item\n    line one\n    line two\n";
2094        let fixed = fix(content);
2095        assert_eq!(fixed, "1. Item\n   line one\n   line two\n");
2096    }
2097
2098    #[test]
2099    fn tight_continuation_mkdocs_4space_ordered_not_flagged() {
2100        // MkDocs requires max(3, 4) = 4 spaces for "1. " items.
2101        // 4-space tight continuation is correct, not over-indented.
2102        let content = "1. Item\n    continuation\n";
2103        assert!(check_mkdocs(content).is_empty());
2104    }
2105
2106    #[test]
2107    fn tight_continuation_mkdocs_5space_ordered_flagged() {
2108        // 5 spaces exceeds the MkDocs required indent of 4
2109        let content = "1. Item\n     over-indented\n";
2110        let warnings = check_mkdocs(content);
2111        assert_eq!(warnings.len(), 1);
2112        assert!(warnings[0].message.contains("expected 4"));
2113        assert!(warnings[0].message.contains("found 5"));
2114    }
2115
2116    #[test]
2117    fn fix_tight_continuation_mkdocs_over_indented() {
2118        let content = "1. Item\n     over-indented\n";
2119        let fixed = fix_mkdocs(content);
2120        assert_eq!(fixed, "1. Item\n    over-indented\n");
2121    }
2122
2123    #[test]
2124    fn tight_continuation_deeply_indented_list_markers_not_flagged() {
2125        // Deeply indented list markers (e.g., indent=8 in MD007) may not be
2126        // recognized as list items by the parser. MD077 must not flag them.
2127        let content = "* Level 0\n        * Level 1\n                * Level 2\n";
2128        assert!(check(content).is_empty());
2129    }
2130
2131    #[test]
2132    fn tight_continuation_ordered_marker_not_flagged() {
2133        // Indented ordered list marker should not be flagged
2134        let content = "- Parent\n      1. Child item\n";
2135        assert!(check(content).is_empty());
2136    }
2137
2138    // ── Unordered list: correct indent after blank ────────────────────
2139
2140    #[test]
2141    fn unordered_correct_indent_no_warning() {
2142        let content = "- Item\n\n  continuation\n";
2143        assert!(check(content).is_empty());
2144    }
2145
2146    #[test]
2147    fn unordered_partial_indent_warns() {
2148        // Content with some indent (above marker column) but less than
2149        // content_column is likely an indentation mistake.
2150        let content = "- Item\n\n continuation\n";
2151        let warnings = check(content);
2152        assert_eq!(warnings.len(), 1);
2153        assert_eq!(warnings[0].line, 3);
2154        assert!(warnings[0].message.contains("2 spaces"));
2155        assert!(warnings[0].message.contains("found 1"));
2156    }
2157
2158    #[test]
2159    fn unordered_zero_indent_is_new_paragraph() {
2160        // Content at 0 indent after a top-level list is a new paragraph, not
2161        // under-indented continuation.
2162        let content = "- Item\n\ncontinuation\n";
2163        assert!(check(content).is_empty());
2164    }
2165
2166    // ── Ordered list: CommonMark W+N ──────────────────────────────────
2167
2168    #[test]
2169    fn ordered_3space_correct_commonmark() {
2170        // "1. " is 3 chars, content_column = 3
2171        let content = "1. Item\n\n   continuation\n";
2172        assert!(check(content).is_empty());
2173    }
2174
2175    #[test]
2176    fn ordered_2space_under_indent_commonmark() {
2177        let content = "1. Item\n\n  continuation\n";
2178        let warnings = check(content);
2179        assert_eq!(warnings.len(), 1);
2180        assert!(warnings[0].message.contains("3 spaces"));
2181        assert!(warnings[0].message.contains("found 2"));
2182    }
2183
2184    // ── Multi-digit ordered markers ───────────────────────────────────
2185
2186    #[test]
2187    fn multi_digit_marker_correct() {
2188        // "10. " is 4 chars, content_column = 4
2189        let content = "10. Item\n\n    continuation\n";
2190        assert!(check(content).is_empty());
2191    }
2192
2193    #[test]
2194    fn multi_digit_marker_under_indent() {
2195        let content = "10. Item\n\n   continuation\n";
2196        let warnings = check(content);
2197        assert_eq!(warnings.len(), 1);
2198        assert!(warnings[0].message.contains("4 spaces"));
2199    }
2200
2201    // ── MkDocs flavor: 4-space minimum ────────────────────────────────
2202
2203    #[test]
2204    fn mkdocs_3space_ordered_warns() {
2205        // In MkDocs mode, 3-space indent on "1. " is not enough
2206        let content = "1. Item\n\n   continuation\n";
2207        let warnings = check_mkdocs(content);
2208        assert_eq!(warnings.len(), 1);
2209        assert!(warnings[0].message.contains("4 spaces"));
2210        assert!(warnings[0].message.contains("MkDocs"));
2211    }
2212
2213    #[test]
2214    fn mkdocs_4space_ordered_no_warning() {
2215        let content = "1. Item\n\n    continuation\n";
2216        assert!(check_mkdocs(content).is_empty());
2217    }
2218
2219    #[test]
2220    fn mkdocs_unordered_2space_ok() {
2221        // Unordered "- " has content_column = 2; max(2, 4) = 4 in mkdocs
2222        let content = "- Item\n\n    continuation\n";
2223        assert!(check_mkdocs(content).is_empty());
2224    }
2225
2226    #[test]
2227    fn mkdocs_unordered_2space_warns() {
2228        // "- " has content_column 2; MkDocs requires max(2,4) = 4
2229        let content = "- Item\n\n  continuation\n";
2230        let warnings = check_mkdocs(content);
2231        assert_eq!(warnings.len(), 1);
2232        assert!(warnings[0].message.contains("4 spaces"));
2233    }
2234
2235    // ── Auto-fix ──────────────────────────────────────────────────────
2236
2237    #[test]
2238    fn fix_unordered_indent() {
2239        // Partial indent (above marker column, below content column) gets fixed
2240        let content = "- Item\n\n continuation\n";
2241        let fixed = fix(content);
2242        assert_eq!(fixed, "- Item\n\n  continuation\n");
2243    }
2244
2245    #[test]
2246    fn fix_ordered_indent() {
2247        let content = "1. Item\n\n continuation\n";
2248        let fixed = fix(content);
2249        assert_eq!(fixed, "1. Item\n\n   continuation\n");
2250    }
2251
2252    #[test]
2253    fn fix_mkdocs_indent() {
2254        let content = "1. Item\n\n   continuation\n";
2255        let fixed = fix_mkdocs(content);
2256        assert_eq!(fixed, "1. Item\n\n    continuation\n");
2257        assert_eq!(fix_mkdocs(&fixed), fixed);
2258    }
2259
2260    // ── MkDocs flavor: the 4-column rule applies per nesting level ────
2261    //
2262    // Python-Markdown reads a line's level as indent / 4 (at most one deeper
2263    // than the open list), so an item at level L holds its paragraphs at
2264    // 4 * (L + 1). Each document below was rendered with Python-Markdown 3.11
2265    // and the layout it reads as continuation of the innermost item is
2266    // asserted clean.
2267
2268    #[test]
2269    fn mkdocs_nested_continuation_at_the_level_column_is_clean() {
2270        for content in [
2271            // <li>a<ul><li><p>b</p><p>text</p></li></ul></li>
2272            "- a\n    - b\n\n        text\n",
2273            // three levels
2274            "- a\n    - b\n        - c\n\n            text\n",
2275            // ordered, mixed ordered and bullet in both directions
2276            "1. a\n    1. b\n\n        text\n",
2277            "1. a\n    - b\n\n        text\n",
2278            "- a\n    1. b\n\n        text\n",
2279            // a sibling after a nested item holds its text at the same column
2280            "- a\n    - b\n\n    - c\n\n        text\n",
2281            // the item written on its parent's line is one level deeper
2282            "- - a\n\n        text\n",
2283            // and a sibling of that inner item shares its level
2284            "- - a\n    - b\n\n        text\n",
2285            // a fenced block at the level column
2286            "- a\n    - b\n\n        ```\n        code\n        ```\n",
2287        ] {
2288            assert!(
2289                check_mkdocs(content).is_empty(),
2290                "{content:?}: {:?}",
2291                check_mkdocs(content)
2292            );
2293            assert_eq!(fix_mkdocs(content), content);
2294        }
2295    }
2296
2297    #[test]
2298    fn mkdocs_nested_continuation_below_the_level_column_moves_to_it() {
2299        // Text at the CommonMark column of the nested item reads as a paragraph
2300        // of the parent in Python-Markdown; the item's own column is 8.
2301        for (content, fixed) in [
2302            ("- a\n    - b\n\n      text\n", "- a\n    - b\n\n        text\n"),
2303            (
2304                "- a\n    - b\n        - c\n\n          text\n",
2305                "- a\n    - b\n        - c\n\n            text\n",
2306            ),
2307            ("1. a\n    1. b\n\n       text\n", "1. a\n    1. b\n\n        text\n"),
2308            ("- - a\n\n      text\n", "- - a\n\n        text\n"),
2309        ] {
2310            let warnings = check_mkdocs(content);
2311            assert_eq!(warnings.len(), 1, "{content:?}: {warnings:?}");
2312            assert!(warnings[0].message.contains("MkDocs"), "{}", warnings[0].message);
2313            assert_eq!(fix_mkdocs(content), fixed);
2314            assert!(check_mkdocs(fixed).is_empty(), "{fixed:?}");
2315        }
2316    }
2317
2318    #[test]
2319    fn mkdocs_nested_over_indent_snaps_to_the_level_column() {
2320        assert_eq!(
2321            fix_mkdocs("- a\n    - b\n\n         text\n"),
2322            "- a\n    - b\n\n        text\n"
2323        );
2324        let warnings = check_mkdocs("- a\n    - b\n\n         text\n");
2325        assert_eq!(warnings.len(), 1);
2326        assert!(
2327            warnings[0].message.contains("expected 8, found 9"),
2328            "{}",
2329            warnings[0].message
2330        );
2331    }
2332
2333    #[test]
2334    fn mkdocs_text_left_of_the_nested_marker_stays_with_the_parent() {
2335        // Python-Markdown: a paragraph of `a`. Text at the nested marker's
2336        // column is not the nested item's to claim.
2337        let content = "- a\n    - b\n\n    text\n";
2338        assert!(check_mkdocs(content).is_empty());
2339        assert_eq!(fix_mkdocs(content), content);
2340    }
2341
2342    #[test]
2343    fn mkdocs_code_at_the_python_markdown_threshold_is_not_reindented() {
2344        // `b` is a sibling of `a` for Python-Markdown (its marker is under four
2345        // columns past `a`'s), so text at 8 is an indented code block there.
2346        let content = "1. a\n   - b\n\n        text\n";
2347        assert!(check_mkdocs(content).is_empty(), "{:?}", check_mkdocs(content));
2348        assert_eq!(fix_mkdocs(content), content);
2349        // The same under a configured indent that raises the requirement above
2350        // the code column.
2351        let rule = MD077ListContinuationIndent::from_config_struct(MD077Config {
2352            indent: Some(6),
2353            ..Default::default()
2354        });
2355        let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
2356        assert!(rule.check(&ctx).unwrap().is_empty());
2357        assert_eq!(rule.fix(&ctx).unwrap(), content);
2358    }
2359
2360    #[test]
2361    fn mkdocs_over_indented_paragraph_continuation_is_not_code() {
2362        // Only the first line after a blank can open Python-Markdown's code
2363        // block; `text` continues the `para` paragraph (<p>para text</p>).
2364        let content = "- a\n\n    para\n        text\n";
2365        assert_eq!(check_mkdocs(content).len(), 1);
2366        assert_eq!(fix_mkdocs(content), "- a\n\n    para\n    text\n");
2367        let nested = "- a\n    - b\n\n        para\n            text\n";
2368        assert_eq!(fix_mkdocs(nested), "- a\n    - b\n\n        para\n        text\n");
2369    }
2370
2371    #[test]
2372    fn mkdocs_item_python_markdown_does_not_read_is_not_judged() {
2373        // `- b` sits two levels below `123456. a`, which Python-Markdown reads
2374        // as text of the paragraph, so the line below is a code block whose
2375        // literal indentation must stay.
2376        for content in [
2377            "123456. a\n        - b\n\n          text\n",
2378            "-    a\n        - b\n\n            text\n",
2379        ] {
2380            assert!(
2381                check_mkdocs(content).is_empty(),
2382                "{content:?}: {:?}",
2383                check_mkdocs(content)
2384            );
2385            assert_eq!(fix_mkdocs(content), content);
2386        }
2387    }
2388
2389    #[test]
2390    fn mkdocs_bare_marker_is_not_an_item_for_python_markdown() {
2391        // A marker with nothing after it is paragraph text, so the lines below
2392        // are indented code (`<p>-    - b</p><pre><code>  text`) whose literal
2393        // indentation must stay; the same marker followed by a space is an item.
2394        for content in [
2395            "-\n    - b\n\n      text\n",
2396            "1.\n    - b\n\n      text\n",
2397            "- a\n    -\n\n      text\n",
2398            "-\n    -\n\n        text\n",
2399        ] {
2400            assert!(
2401                check_mkdocs(content).is_empty(),
2402                "{content:?}: {:?}",
2403                check_mkdocs(content)
2404            );
2405            assert_eq!(fix_mkdocs(content), content);
2406        }
2407        for content in ["- \n    - b\n\n      text\n", "1. \n    - b\n\n      text\n"] {
2408            assert_eq!(check_mkdocs(content).len(), 1, "{content:?}");
2409        }
2410    }
2411
2412    #[test]
2413    fn mkdocs_bare_marker_does_not_unscope_a_later_list() {
2414        // The list after the heading is a list of its own; the bare marker
2415        // above it is not its parent.
2416        let content = "-\n\n# Heading\n\n  - b\n\n     text\n";
2417        assert_eq!(check_mkdocs(content).len(), 1, "{:?}", check_mkdocs(content));
2418        assert_eq!(fix_mkdocs(content), "-\n\n# Heading\n\n  - b\n\n    text\n");
2419    }
2420
2421    #[test]
2422    fn mkdocs_paren_marker_is_not_an_item_for_python_markdown() {
2423        // Python-Markdown renders `1) b` as text of `a`, so text at column 7 is
2424        // a paragraph of `a` and text at column 8 is its code.
2425        let paragraph = "- a\n    1) b\n\n       text\n";
2426        assert!(check_mkdocs(paragraph).is_empty(), "{:?}", check_mkdocs(paragraph));
2427        let code = "- a\n    1) b\n\n        text\n";
2428        assert!(check_mkdocs(code).is_empty(), "{:?}", check_mkdocs(code));
2429    }
2430
2431    #[test]
2432    fn mkdocs_item_below_a_same_line_item_needs_the_written_marker_column() {
2433        // Python-Markdown reads `- b` as text of `- a`: it is two levels past
2434        // where the outer marker is written. The text at column 10 is a
2435        // paragraph of that item.
2436        let content = "1. - a\n        - b\n\n          text\n";
2437        assert!(check_mkdocs(content).is_empty(), "{:?}", check_mkdocs(content));
2438    }
2439
2440    #[test]
2441    fn mkdocs_list_after_a_blank_line_below_a_text_marker_is_an_item() {
2442        // `1) a` is a paragraph; after the blank line `  - b` opens a list of its
2443        // own (<p>1) a</p><ul><li>b</li></ul><p>text</p>), so the three-column
2444        // text escapes it and needs four. Its owner `- b` is a real item, so the
2445        // fix re-parents the paragraph under it, as for any item.
2446        for (content, fixed) in [
2447            ("1) a\n\n  - b\n\n   text\n", "1) a\n\n  - b\n\n    text\n"),
2448            ("-\n\n  - b\n\n   text\n", "-\n\n  - b\n\n    text\n"),
2449        ] {
2450            assert_eq!(check_mkdocs(content).len(), 1, "{content:?}");
2451            assert_eq!(fix_mkdocs(content), fixed);
2452            assert_eq!(fix_mkdocs(fixed), fixed);
2453        }
2454        // Without the blank line the marker line continues the paragraph, so
2455        // nothing below it belongs to an item.
2456        for content in ["1) a\n  - b\n\n   text\n", "-\n  - b\n\n   text\n"] {
2457            assert!(check_mkdocs(content).is_empty(), "{content:?}");
2458        }
2459    }
2460
2461    #[test]
2462    fn mkdocs_marker_under_a_padded_same_line_item_is_text_until_a_blank_line() {
2463        // Python-Markdown: the inner item holds `- b` as text, whatever padding
2464        // the outer marker has, so the 10-column text is a paragraph of it.
2465        for content in [
2466            "-   - a\n        - b\n\n          text\n",
2467            "- - a\n        - b\n\n          text\n",
2468        ] {
2469            assert!(check_mkdocs(content).is_empty(), "{content:?}");
2470        }
2471        // After a blank line the same marker opens a list of its own, whose
2472        // text sits at 12.
2473        let content = "-   - a\n\n        - b\n\n            text\n";
2474        assert!(check_mkdocs(content).is_empty(), "{content:?}");
2475        assert_eq!(check_mkdocs("-   - a\n\n        - b\n\n          text\n").len(), 1);
2476    }
2477
2478    #[test]
2479    fn mkdocs_markers_under_a_same_line_chain_are_text_until_a_blank_line() {
2480        // Python-Markdown: `- - b` is text of the innermost `a`, so the 12-column
2481        // text is its paragraph and must stay.
2482        let content = "- - - a\n        - - b\n\n            text\n";
2483        assert!(check_mkdocs(content).is_empty(), "{:?}", check_mkdocs(content));
2484        // After a blank line the marker is a sibling of the innermost item.
2485        let content = "- - - a\n\n        - b\n\n            text\n";
2486        assert!(check_mkdocs(content).is_empty(), "{:?}", check_mkdocs(content));
2487    }
2488
2489    #[test]
2490    fn mkdocs_list_after_an_unsupported_marker_nests_in_the_enclosing_item() {
2491        // `1) b` is text of `a`; after the blank line `c` opens a list inside `a`
2492        // (<li><p>a  1) b</p><ul><li><p>c</p><p>text</p></li></ul></li>), so the
2493        // nine-column text moves to eight.
2494        let content = "- a\n  1) b\n\n    - c\n\n         text\n";
2495        assert_eq!(check_mkdocs(content).len(), 1);
2496        assert_eq!(fix_mkdocs(content), "- a\n  1) b\n\n    - c\n\n        text\n");
2497        // Two levels below `a` is code, which stays.
2498        let code = "- a\n    1) b\n\n        - c\n\n            text\n";
2499        assert!(check_mkdocs(code).is_empty(), "{:?}", check_mkdocs(code));
2500    }
2501
2502    #[test]
2503    fn mkdocs_text_at_a_deeper_item_paragraph_column_is_not_claimed_by_the_outer_item() {
2504        // Python-Markdown: `text` is a paragraph of `b` (level 1), though the
2505        // parser leaves it to `a`, whose content column lies below `b`'s. It
2506        // stays a paragraph of `b`; only `b`'s own content column (9) is asked
2507        // of it, never the 12 columns of `a`'s level.
2508        let content = "- - - a\n      1. b\n\n        text\n";
2509        let warnings = check_mkdocs(content);
2510        assert_eq!(warnings.len(), 1, "{warnings:?}");
2511        assert!(warnings[0].message.contains("needs 9"), "{}", warnings[0].message);
2512        let fixed = fix_mkdocs(content);
2513        assert_eq!(fixed, "- - - a\n      1. b\n\n         text\n");
2514        assert!(check_mkdocs(&fixed).is_empty());
2515    }
2516
2517    #[test]
2518    fn mkdocs_nested_item_closed_by_a_shallower_line_no_longer_owns_later_text() {
2519        // Python-Markdown: `para` at four columns closes `b`, so `para` and the
2520        // over-indented `text` below it are one paragraph of `a`.
2521        let content = "- a\n    - b\n\n    para\n        text\n";
2522        let warnings = check_mkdocs(content);
2523        assert_eq!(warnings.len(), 1, "{warnings:?}");
2524        assert_eq!(fix_mkdocs(content), "- a\n    - b\n\n    para\n    text\n");
2525    }
2526
2527    /// Documents whose naive fix changes what Python-Markdown makes of a line:
2528    /// the warning stays, the fix is withheld.
2529    fn assert_mkdocs_warns_without_fix(content: &str) {
2530        let warnings = check_mkdocs(content);
2531        assert!(!warnings.is_empty(), "expected a warning for {content:?}");
2532        assert!(
2533            warnings.iter().all(|w| w.fix.is_none()),
2534            "fix must be withheld for {content:?}: {warnings:?}"
2535        );
2536        assert_eq!(fix_mkdocs(content), content);
2537    }
2538
2539    #[test]
2540    fn mkdocs_fix_does_not_turn_prose_after_a_text_marker_into_code() {
2541        // `1)` is text, so `text` is already a paragraph of the outer item;
2542        // moving it to the inner item's column would make it indented code.
2543        assert_mkdocs_warns_without_fix("- - a\n\n    1) b\n\n    text\n");
2544        // Control: with no text line between, the same move only re-parents.
2545        assert_eq!(fix_mkdocs("- - a\n\n    text\n"), "- - a\n\n        text\n");
2546    }
2547
2548    #[test]
2549    fn mkdocs_item_after_the_continuation_lines_of_the_item_above_is_an_item() {
2550        // Python-Markdown splits the block at `- b`, so `text` (two columns in)
2551        // falls out of that item and needs four; the owner `- b` is a real item,
2552        // so the paragraph is re-parented under it.
2553        assert_eq!(
2554            fix_mkdocs("- a\n  continuation\n- b\n\n  text\n"),
2555            "- a\n  continuation\n- b\n\n    text\n"
2556        );
2557        // Control: a marker line under plain paragraph text starts no list.
2558        assert_eq!(check_mkdocs("para\n- b\n\n  text\n").len(), 0);
2559        // A heading above is not paragraph text: the list
2560        // after it is a list.
2561        assert_eq!(check_mkdocs("# Heading\n- a\n\n  text\n").len(), 1);
2562    }
2563
2564    #[test]
2565    fn mkdocs_fix_is_withheld_when_it_would_change_how_later_lines_read() {
2566        // Moving `a` one column makes the item own the indented line after it,
2567        // which turns that code line into a nested list item.
2568        assert_mkdocs_warns_without_fix("10. word two\n\n     1.\n\n   a\n\n      - word two\n");
2569        assert_mkdocs_warns_without_fix(" - - word two\n\n    a\n          text\n\n           para\n");
2570    }
2571
2572    #[test]
2573    fn mkdocs_fix_is_withheld_when_it_would_change_code_text_or_a_quote() {
2574        // Moving `text` lets the item own the code block after it, which
2575        // loses four columns of the code's own indentation.
2576        assert_mkdocs_warns_without_fix("- a\n    - b\n\n      text\n\n            code\n");
2577        // The quote after `text` would become part of its paragraph.
2578        assert_mkdocs_warns_without_fix("- a\n    - b\n\n      text\n    > quote\n");
2579    }
2580
2581    #[test]
2582    fn mkdocs_fix_is_withheld_around_a_single_hyphen_underline_and_a_split_code_span() {
2583        // A lone `-` under `text` underlines it into a heading.
2584        assert_mkdocs_warns_without_fix("- a\n    - b\n\n      text\n    - \n");
2585        // The code span's second line keeps its own indentation, so moving only
2586        // the lines around it changes the span's text.
2587        assert_mkdocs_warns_without_fix("- a\n    - b\n\n      para\n         `one\n      two`\n");
2588        // Backtick runs of different lengths on one line can nest, so the span
2589        // that continues onto the next line cannot be told apart.
2590        assert_mkdocs_warns_without_fix("- a\n    - b\n\n      `a `` b` c ``x\n      d`` e `f `` g`\n");
2591        // Control: a span that closes on its own line moves with the paragraph.
2592        assert_eq!(
2593            fix_mkdocs("- a\n    - b\n\n      para `one`\n"),
2594            "- a\n    - b\n\n        para `one`\n"
2595        );
2596    }
2597
2598    #[test]
2599    fn mkdocs_fix_is_withheld_when_it_would_change_list_kind_or_tab_expansion() {
2600        // At eight columns `text` ends the nested list differently: the ordered
2601        // list below it would be absorbed into the bullet list and lose its numbers.
2602        assert_mkdocs_warns_without_fix("- a\n    - b\n\n      text\n\n    1. ordered\n");
2603        // A tab after the text expands to the next multiple of four columns, so
2604        // re-indenting the fence changes the spaces the tab stands for.
2605        assert_mkdocs_warns_without_fix("- a\n    - b\n\n      ```\n      a\tb\n      ```\n");
2606    }
2607
2608    #[test]
2609    fn mkdocs_fix_is_withheld_for_a_quote_after_an_earlier_quote_and_for_exotic_space() {
2610        // The quote at the top of the document does not make the quote below the
2611        // list safe to move text across: at eight columns it joins `text`.
2612        assert_mkdocs_warns_without_fix("> intro\n\n- a\n    - b\n\n      text\n    > quote\n");
2613        // A code block holding only a no-break space is a block for
2614        // Python-Markdown; moving `text` would absorb it.
2615        assert_mkdocs_warns_without_fix("- a\n    - b\n\n      text\n\n        \u{a0}\n");
2616    }
2617
2618    #[test]
2619    fn mkdocs_fix_is_withheld_below_a_whitespace_only_first_line() {
2620        // The first line keeps its spaces, so `- - a` is text of a paragraph
2621        // and `text` below it is code that a move would indent further.
2622        assert_mkdocs_warns_without_fix("   \n- - a\n\n    text\n");
2623        // Control: a whitespace-only line later in the document is blank.
2624        assert_eq!(
2625            fix_mkdocs("para\n\n   \n- - a\n\n    text\n"),
2626            "para\n\n   \n- - a\n\n        text\n"
2627        );
2628    }
2629
2630    #[test]
2631    fn mkdocs_fix_ignores_front_matter_and_is_withheld_around_abbreviation_definitions() {
2632        // The front matter is removed before Python-Markdown reads the page, so
2633        // its delimiters do not make the list below it unmodeled.
2634        assert_eq!(
2635            fix_mkdocs("---\ntitle: Example\n---\n\n- a\n\n  text\n"),
2636            "---\ntitle: Example\n---\n\n- a\n\n    text\n"
2637        );
2638        // An abbreviation definition swallows the line below it, so moving that
2639        // line turns it into code.
2640        assert_mkdocs_warns_without_fix("- a\n    - b\n\n    *[X]: abbreviation\n      ## H\n");
2641    }
2642
2643    #[test]
2644    fn mkdocs_fix_is_judged_on_the_fixes_inline_configuration_leaves() {
2645        // `one` is exempt, so only `two` would move, and that turns it into code.
2646        let content = "- a\n    - b\n\n      one <!-- rumdl-disable-line MD077 -->\n\n      two\n";
2647        let fixed = fix_mkdocs(content);
2648        assert_eq!(fixed, content);
2649    }
2650
2651    #[test]
2652    fn mkdocs_fix_does_not_pull_a_fence_outside_the_list_into_it() {
2653        // The two-column fence sits after the list; moving it under its item is
2654        // the documented re-parenting, and the interior moves with it.
2655        assert_eq!(
2656            fix_mkdocs("- a\n\n  ```\n  code\n  ```\n"),
2657            "- a\n\n    ```\n    code\n    ```\n"
2658        );
2659        // Inside the list the fence and its interior move together.
2660        assert_eq!(
2661            fix_mkdocs("- a\n    - b\n\n      ```\n      code\n      ```\n"),
2662            "- a\n    - b\n\n        ```\n        code\n        ```\n"
2663        );
2664    }
2665
2666    #[test]
2667    fn mkdocs_fix_is_withheld_around_constructs_the_model_does_not_read() {
2668        // Each fix would change how Python-Markdown reads a later line: a
2669        // reference definition is dropped from the text, a thematic break ends
2670        // the paragraph before indented code, and an indented fence is plain
2671        // text to it, so the list marker inside is a list.
2672        for content in [
2673            "- a\n    - b\n\n      more\n        [id]: url\n",
2674            "- a\n    - b\n\n      ---\n          - c\n",
2675            "- a\n    - b\n\n      ```\n\n        - c\n      ```\n",
2676            "- a\n    - b\n\n     text\n      ===\n",
2677            "- a\n    - b\n\n      text\n  <!-- x -->\n            code\n",
2678            "- a\n    - b\n\n      ```\n        ***\n      ```\n",
2679        ] {
2680            assert_eq!(fix_mkdocs(content), content, "{content:?}");
2681        }
2682    }
2683
2684    #[test]
2685    fn mkdocs_fix_is_withheld_for_a_fence_whose_lines_do_not_share_a_column() {
2686        // Python-Markdown reads the indented fence as a paragraph and keeps
2687        // each line's leading spaces inside the inline code span.
2688        let content = "- a\n    - b\n\n      ```\n        code\n      code\n```\n";
2689        assert_eq!(fix_mkdocs(content), content);
2690        assert!(check_mkdocs(content).iter().all(|w| w.fix.is_none()));
2691    }
2692
2693    #[test]
2694    fn mkdocs_warnings_never_carry_overlapping_fixes() {
2695        // The caller applies every fix it is given by its original offsets, so
2696        // two fixes over the same bytes would duplicate text.
2697        let content = "- a\n    - b\n\n     ```\n       ```\n         ```\n";
2698        let mut ranges: Vec<_> = check_mkdocs(content)
2699            .iter()
2700            .filter_map(|w| w.fix.as_ref().map(|f| f.range.clone()))
2701            .collect();
2702        ranges.sort_by_key(|r| r.start);
2703        for pair in ranges.windows(2) {
2704            assert!(pair[0].end <= pair[1].start, "{ranges:?}");
2705        }
2706    }
2707
2708    #[test]
2709    fn mkdocs_fence_on_the_items_own_line_keeps_the_item() {
2710        // Python-Markdown reads the marker line as an item whose text is
2711        // inline code, so the five-column paragraph below still needs four.
2712        let content = "- ```\n  code\n  ```\n\n     text\n";
2713        assert_eq!(check_mkdocs(content).len(), 1, "{:?}", check_mkdocs(content));
2714    }
2715
2716    #[test]
2717    fn mkdocs_wide_marker_still_enforces_tight_continuation() {
2718        // The column a loose paragraph would need is where code starts, but a
2719        // tight continuation line has no such constraint.
2720        assert_eq!(
2721            fix_mkdocs("123456. a\n         continuation\n"),
2722            "123456. a\n        continuation\n"
2723        );
2724    }
2725
2726    #[test]
2727    fn mkdocs_paren_marker_after_a_tab_is_found_by_column() {
2728        // The tab puts `x)` at column 4; byte offsets would read `x)` as `)`.
2729        let content = "- a\n\t- x) b\n\n         text\n";
2730        assert_eq!(check_mkdocs(content).len(), 1, "{:?}", check_mkdocs(content));
2731    }
2732
2733    #[test]
2734    fn mkdocs_configured_indent_cannot_go_below_the_level_column() {
2735        let rule = MD077ListContinuationIndent::from_config_struct(MD077Config {
2736            indent: Some(2),
2737            ..Default::default()
2738        });
2739        let content = "- a\n    - b\n\n      text\n";
2740        let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
2741        assert_eq!(rule.fix(&ctx).unwrap(), "- a\n    - b\n\n        text\n");
2742        let clean = "- a\n    - b\n\n        text\n";
2743        let ctx = LintContext::new(clean, MarkdownFlavor::MkDocs, None);
2744        assert!(rule.check(&ctx).unwrap().is_empty());
2745    }
2746
2747    #[test]
2748    fn standard_flavor_keeps_the_content_column_for_nested_items() {
2749        // Control: the per-level rule is Python-Markdown's only.
2750        let content = "- a\n    - b\n\n      text\n";
2751        assert!(check(content).is_empty());
2752        assert_eq!(fix("- a\n    - b\n\n        text\n"), "- a\n    - b\n\n      text\n");
2753    }
2754
2755    // ── Nested lists: only flag continuation, not sub-items ───────────
2756
2757    #[test]
2758    fn nested_list_items_not_flagged() {
2759        let content = "- Parent\n\n  - Child\n";
2760        assert!(check(content).is_empty());
2761    }
2762
2763    #[test]
2764    fn nested_list_zero_indent_is_new_paragraph() {
2765        // Content at 0 indent ends the list, not continuation
2766        let content = "- Parent\n  - Child\n\ncontinuation of parent\n";
2767        assert!(check(content).is_empty());
2768    }
2769
2770    #[test]
2771    fn nested_list_partial_indent_flagged() {
2772        // Content with partial indent (above parent marker, below content col)
2773        let content = "- Parent\n  - Child\n\n continuation of parent\n";
2774        let warnings = check(content);
2775        assert_eq!(warnings.len(), 1);
2776        assert!(warnings[0].message.contains("2 spaces"));
2777    }
2778
2779    // ── Code blocks inside items ─────────────────────────────────────
2780
2781    #[test]
2782    fn code_block_correctly_indented_no_warning() {
2783        // Fence lines and content all at correct indent for "- " (content_column = 2)
2784        let content = "- Item\n\n  ```\n  code\n  ```\n";
2785        assert!(check(content).is_empty());
2786    }
2787
2788    #[test]
2789    fn code_fence_under_indented_warns() {
2790        // Fence opener has 1-space indent, but "- " needs 2.
2791        // Only the opener is flagged — its compound fix also covers the
2792        // interior content and the matching closer (see issue #574).
2793        let content = "- Item\n\n ```\n code\n ```\n";
2794        let warnings = check(content);
2795        assert_eq!(warnings.len(), 1);
2796        assert_eq!(warnings[0].line, 3);
2797    }
2798
2799    #[test]
2800    fn code_fence_under_indented_ordered_mkdocs() {
2801        // Ordered list in MkDocs: "1. " needs max(3, 4) = 4 spaces
2802        // Fence at 3 spaces is correct for CommonMark but wrong for MkDocs
2803        let content = "1. Item\n\n   ```toml\n   key = \"value\"\n   ```\n";
2804        assert!(check(content).is_empty()); // Standard mode: 3 is fine
2805        let warnings = check_mkdocs(content);
2806        assert_eq!(warnings.len(), 1); // MkDocs: opener's compound fix covers the whole block
2807        assert_eq!(warnings[0].line, 3);
2808        assert!(warnings[0].message.contains("4 spaces"));
2809        assert!(warnings[0].message.contains("MkDocs"));
2810    }
2811
2812    #[test]
2813    fn code_fence_tilde_under_indented() {
2814        let content = "- Item\n\n ~~~\n code\n ~~~\n";
2815        let warnings = check(content);
2816        assert_eq!(warnings.len(), 1); // Tilde fences: single compound-fix warning on opener
2817        assert_eq!(warnings[0].line, 3);
2818    }
2819
2820    // ── Multiple blank lines ──────────────────────────────────────────
2821
2822    #[test]
2823    fn multiple_blank_lines_zero_indent_is_new_paragraph() {
2824        // Even with multiple blanks, 0-indent content is a new paragraph
2825        let content = "- Item\n\n\ncontinuation\n";
2826        assert!(check(content).is_empty());
2827    }
2828
2829    #[test]
2830    fn multiple_blank_lines_partial_indent_flags() {
2831        let content = "- Item\n\n\n continuation\n";
2832        let warnings = check(content);
2833        assert_eq!(warnings.len(), 1);
2834    }
2835
2836    // ── Empty items: no continuation to check ─────────────────────────
2837
2838    #[test]
2839    fn empty_item_no_warning() {
2840        let content = "- \n- Second\n";
2841        assert!(check(content).is_empty());
2842    }
2843
2844    // ── Multiple items, only some under-indented ──────────────────────
2845
2846    #[test]
2847    fn multiple_items_mixed_indent() {
2848        let content = "1. First\n\n   correct continuation\n\n2. Second\n\n  wrong continuation\n";
2849        let warnings = check(content);
2850        assert_eq!(warnings.len(), 1);
2851        assert_eq!(warnings[0].line, 7);
2852    }
2853
2854    // ── Task list items ───────────────────────────────────────────────
2855
2856    #[test]
2857    fn task_list_correct_indent() {
2858        // "- [ ] " = content_column is typically at col 6
2859        let content = "- [ ] Task\n\n      continuation\n";
2860        assert!(check(content).is_empty());
2861    }
2862
2863    // ── Frontmatter skipped ───────────────────────────────────────────
2864
2865    #[test]
2866    fn frontmatter_not_flagged() {
2867        let content = "---\ntitle: test\n---\n\n- Item\n\n  continuation\n";
2868        assert!(check(content).is_empty());
2869    }
2870
2871    // ── Fix produces valid output with multiple fixes ─────────────────
2872
2873    #[test]
2874    fn fix_multiple_items() {
2875        let content = "1. First\n\n wrong1\n\n2. Second\n\n wrong2\n";
2876        let fixed = fix(content);
2877        assert_eq!(fixed, "1. First\n\n   wrong1\n\n2. Second\n\n   wrong2\n");
2878    }
2879
2880    #[test]
2881    fn fix_multiline_loose_continuation_all_lines() {
2882        let content = "1. Item\n\n  line one\n  line two\n  line three\n";
2883        let fixed = fix(content);
2884        assert_eq!(fixed, "1. Item\n\n   line one\n   line two\n   line three\n");
2885    }
2886
2887    #[test]
2888    fn multi_line_setext_heading_after_a_list_is_not_a_continuation() {
2889        // A setext heading's text is the whole paragraph its underline ends, so
2890        // every line of that paragraph is heading text. Reindenting the first of
2891        // them would move the heading, not a continuation line.
2892        let content = "1. Item\n\n  First line\n  second line\n  ===\n";
2893        let warnings = check(content);
2894        assert!(
2895            warnings.is_empty(),
2896            "the text lines of a setext heading are not continuation candidates. Got: {warnings:?}"
2897        );
2898        assert_eq!(fix(content), content);
2899    }
2900
2901    // ── No false positive when content is after sibling item ──────────
2902
2903    #[test]
2904    fn sibling_item_boundary_respected() {
2905        // The "continuation" after a blank belongs to "- Second", not "- First"
2906        let content = "- First\n- Second\n\n  continuation\n";
2907        assert!(check(content).is_empty());
2908    }
2909
2910    // ── Blockquote-nested lists ────────────────────────────────────────
2911
2912    #[test]
2913    fn blockquote_list_correct_indent_no_warning() {
2914        // Lists inside blockquotes: visual_indent includes the blockquote
2915        // prefix, so comparisons work on raw line columns.
2916        let content = "> - Item\n>\n>   continuation\n";
2917        assert!(check(content).is_empty());
2918    }
2919
2920    #[test]
2921    fn blockquote_list_under_indent_no_false_positive() {
2922        // Under-indented continuation inside a blockquote: visual_indent
2923        // starts at 0 (the `>` char) which is <= marker_col, so the scan
2924        // breaks and no warning is emitted. This is a known false negative
2925        // (not a false positive), which is the safer default.
2926        let content = "> - Item\n>\n> continuation\n";
2927        assert!(check(content).is_empty());
2928    }
2929
2930    // ── Deep nesting (3+ levels) ──────────────────────────────────────
2931
2932    #[test]
2933    fn deep_nesting_correct_indent() {
2934        let content = "- L1\n  - L2\n    - L3\n\n      continuation of L3\n";
2935        assert!(check(content).is_empty());
2936    }
2937
2938    #[test]
2939    fn deep_nesting_under_indent() {
2940        // L3 starts at column 4 with "- " marker, content_column = 6
2941        // Continuation with 5 spaces is under-indented for L3.
2942        let content = "- L1\n  - L2\n    - L3\n\n     continuation of L3\n";
2943        let warnings = check(content);
2944        assert_eq!(warnings.len(), 1);
2945        assert!(warnings[0].message.contains("6 spaces"));
2946        assert!(warnings[0].message.contains("found 5"));
2947    }
2948
2949    #[test]
2950    fn deep_nesting_middle_level_continuation_bullets() {
2951        // A loose continuation of the MIDDLE level (L2, content_column = 4)
2952        // after a deeper L3 list is valid; the L1 walk must not claim it as
2953        // its own over-indented continuation (issue #739).
2954        let content = "- L1\n  - L2\n    - L3\n\n    continuation of L2\n";
2955        assert!(check(content).is_empty());
2956    }
2957
2958    #[test]
2959    fn deep_nesting_middle_level_continuation_ordered() {
2960        // Issue #739 repro: ordered lists, continuation of the level-2 item
2961        // (content_column = 6) after a level-3 bullet list.
2962        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";
2963        assert!(check(content).is_empty());
2964    }
2965
2966    #[test]
2967    fn deep_nesting_outermost_continuation() {
2968        // A loose continuation of the OUTERMOST level (L1, content_column = 2)
2969        // past two open nested levels escapes both and stays valid.
2970        let content = "- L1\n  - L2\n    - L3\n\n  continuation of L1\n";
2971        assert!(check(content).is_empty());
2972    }
2973
2974    #[test]
2975    fn deep_nesting_between_levels_still_flagged() {
2976        // Col 3 sits strictly between L1's content column (2) and L2's (4):
2977        // the deepest applicable item (L2) claims it as loose under-indent.
2978        let content = "- L1\n  - L2\n    - L3\n\n   continuation\n";
2979        let warnings = check(content);
2980        assert_eq!(warnings.len(), 1);
2981        assert!(warnings[0].message.contains("4 spaces"));
2982        assert!(warnings[0].message.contains("found 3"));
2983    }
2984
2985    #[test]
2986    fn deep_nesting_beyond_deepest_still_flagged() {
2987        // Col 7 exceeds even L3's content column (6): over-indent for L3.
2988        let content = "- L1\n  - L2\n    - L3\n\n       continuation\n";
2989        let warnings = check(content);
2990        assert_eq!(warnings.len(), 1);
2991        assert!(warnings[0].message.contains("over-indented"));
2992        assert!(warnings[0].message.contains("expected 6, found 7"));
2993    }
2994
2995    #[test]
2996    fn four_levels_middle_continuation() {
2997        // Continuation of L2 (content_column = 4) after open L3 and L4 lists:
2998        // the walk must pop two deeper levels and still defer to L2.
2999        let content = "- L1\n  - L2\n    - L3\n      - L4\n\n    continuation of L2\n";
3000        assert!(check(content).is_empty());
3001    }
3002
3003    #[test]
3004    fn nested_sibling_closes_deeper_level() {
3005        // L2b closes L2a's deeper L3 list; a continuation at L2b's content
3006        // column belongs to L2b and stays valid.
3007        let content = "- L1\n  - L2a\n    - L3\n  - L2b\n\n    continuation of L2b\n";
3008        assert!(check(content).is_empty());
3009    }
3010
3011    #[test]
3012    fn deep_nesting_middle_level_continuation_fix_preserved() {
3013        // The valid middle-level continuation must survive fix() unchanged.
3014        let content = "- L1\n  - L2\n    - L3\n\n    continuation of L2\n";
3015        assert_eq!(fix(content), content);
3016    }
3017
3018    // ── Tab indentation ───────────────────────────────────────────────
3019
3020    #[test]
3021    fn loose_tab_continuation_over_indented() {
3022        // A tab expands to 4 visual columns, exceeding content_column = 2 for
3023        // "- ". Loose over-indent is flagged just like the tight tab case
3024        // (`tight_continuation_tab_over_indented`), and the fix normalizes the
3025        // tab down to the content-column indent.
3026        let content = "- Item\n\n\tcontinuation\n";
3027        let warnings = check(content);
3028        assert_eq!(warnings.len(), 1);
3029        assert_eq!(warnings[0].line, 3);
3030        assert_eq!(fix(content), "- Item\n\n  continuation\n");
3031    }
3032
3033    // ── Multiple continuation paragraphs ──────────────────────────────
3034
3035    #[test]
3036    fn multiple_continuations_correct() {
3037        let content = "- Item\n\n  para 1\n\n  para 2\n\n  para 3\n";
3038        assert!(check(content).is_empty());
3039    }
3040
3041    #[test]
3042    fn multiple_continuations_second_under_indent() {
3043        // First continuation is correct, second is under-indented
3044        let content = "- Item\n\n  para 1\n\n continuation 2\n";
3045        let warnings = check(content);
3046        assert_eq!(warnings.len(), 1);
3047        assert_eq!(warnings[0].line, 5);
3048    }
3049
3050    // ── Ordered list with `)` marker style ────────────────────────────
3051
3052    #[test]
3053    fn ordered_paren_marker_correct() {
3054        // "1) " is 3 chars, content_column = 3
3055        let content = "1) Item\n\n   continuation\n";
3056        assert!(check(content).is_empty());
3057    }
3058
3059    #[test]
3060    fn ordered_paren_marker_under_indent() {
3061        let content = "1) Item\n\n  continuation\n";
3062        let warnings = check(content);
3063        assert_eq!(warnings.len(), 1);
3064        assert!(warnings[0].message.contains("3 spaces"));
3065    }
3066
3067    // ── Star and plus markers ─────────────────────────────────────────
3068
3069    #[test]
3070    fn star_marker_correct() {
3071        let content = "* Item\n\n  continuation\n";
3072        assert!(check(content).is_empty());
3073    }
3074
3075    #[test]
3076    fn star_marker_under_indent() {
3077        let content = "* Item\n\n continuation\n";
3078        let warnings = check(content);
3079        assert_eq!(warnings.len(), 1);
3080    }
3081
3082    #[test]
3083    fn plus_marker_correct() {
3084        let content = "+ Item\n\n  continuation\n";
3085        assert!(check(content).is_empty());
3086    }
3087
3088    // ── Heading breaks scan ───────────────────────────────────────────
3089
3090    #[test]
3091    fn heading_after_list_no_warning() {
3092        let content = "- Item\n\n# Heading\n";
3093        assert!(check(content).is_empty());
3094    }
3095
3096    // ── Horizontal rule breaks scan ───────────────────────────────────
3097
3098    #[test]
3099    fn hr_after_list_no_warning() {
3100        let content = "- Item\n\n---\n";
3101        assert!(check(content).is_empty());
3102    }
3103
3104    // ── Reference link definitions skip ───────────────────────────────
3105
3106    #[test]
3107    fn reference_link_def_not_flagged() {
3108        let content = "- Item\n\n [link]: https://example.com\n";
3109        assert!(check(content).is_empty());
3110    }
3111
3112    // ── Footnote definitions skip ─────────────────────────────────────
3113
3114    #[test]
3115    fn footnote_def_not_flagged() {
3116        let content = "- Item\n\n [^1]: footnote text\n";
3117        assert!(check(content).is_empty());
3118    }
3119
3120    #[test]
3121    fn footnote_multiline_body_after_list_not_flagged() {
3122        // A footnote definition whose indented body follows a list must not be
3123        // read as over-indented list continuation. The 4-space body lines
3124        // belong to the footnote, not to `- This is a list.` (content col 2).
3125        let content = "# A list followed by a footnote\n\n\
3126                       Here is a paragraph.[^fn]\n\n\
3127                       - This is a list.\n\n\
3128                       [^fn]:\n\
3129                       \x20\x20\x20\x20Here is a footnote that spans multiple lines.\n\
3130                       \x20\x20\x20\x20It should thus be indented by at least four spaces.\n";
3131        assert!(check(content).is_empty());
3132    }
3133
3134    #[test]
3135    fn fix_footnote_multiline_body_after_list_is_noop() {
3136        // The false positive also produced a damaging auto-fix: reindenting the
3137        // footnote body from 4 spaces to 2 broke the footnote (then tripped
3138        // MD068). The fix must leave the footnote untouched.
3139        let content = "# A list followed by a footnote\n\n\
3140                       Here is a paragraph.[^fn]\n\n\
3141                       - This is a list.\n\n\
3142                       [^fn]:\n\
3143                       \x20\x20\x20\x20Here is a footnote that spans multiple lines.\n\
3144                       \x20\x20\x20\x20It should thus be indented by at least four spaces.\n";
3145        assert_eq!(fix(content), content);
3146    }
3147
3148    #[test]
3149    fn footnote_body_indented_past_list_content_col_not_flagged() {
3150        // Footnote body indented well past the list's content column (here a
3151        // single-space list marker would put content col at 2, body at 4) is
3152        // footnote content, not over-indented continuation.
3153        let content = "- Item\n\n[^fn]:\n    Body line one.\n    Body line two.\n";
3154        assert!(check(content).is_empty());
3155    }
3156
3157    #[test]
3158    fn list_inside_footnote_body_continuation_not_flagged() {
3159        // Accepted trade-off: a list living *inside* a footnote body is skipped
3160        // wholesale, so MD077 does not police its continuation indentation.
3161        // Consistent with MD007, which also skips footnote-internal content.
3162        let content = "Text.[^fn]\n\n[^fn]:\n\
3163                       \x20\x20\x20\x20- nested item\n\
3164                       \x20\x20\x20\x20\x20\x20\x20over-indented continuation\n";
3165        assert!(check(content).is_empty());
3166    }
3167
3168    #[test]
3169    fn footnote_multiline_body_after_list_not_flagged_mkdocs() {
3170        // Same protection under the MkDocs flavor. The body sits at 6 spaces,
3171        // past MkDocs's required indent of 4 for `- `, so without skipping
3172        // footnote content it would be flagged as over-indented continuation.
3173        let content = "Here is a paragraph.[^fn]\n\n\
3174                       - This is a list.\n\n\
3175                       [^fn]:\n\
3176                       \x20\x20\x20\x20\x20\x20Footnote body that spans\n\
3177                       \x20\x20\x20\x20\x20\x20multiple indented lines.\n";
3178        assert!(check_mkdocs(content).is_empty());
3179    }
3180
3181    // ── Fix preserves correct content ─────────────────────────────────
3182
3183    #[test]
3184    fn fix_deep_nesting() {
3185        let content = "- L1\n  - L2\n    - L3\n\n     under-indented\n";
3186        let fixed = fix(content);
3187        assert_eq!(fixed, "- L1\n  - L2\n    - L3\n\n      under-indented\n");
3188    }
3189
3190    #[test]
3191    fn fix_mkdocs_unordered() {
3192        // MkDocs: "- " has content_column 2, but MkDocs requires max(2,4) = 4
3193        let content = "- Item\n\n  continuation\n";
3194        let fixed = fix_mkdocs(content);
3195        assert_eq!(fixed, "- Item\n\n    continuation\n");
3196        assert_eq!(fix_mkdocs(&fixed), fixed);
3197    }
3198
3199    #[test]
3200    fn fix_code_fence_indent() {
3201        // Fence opener, interior, and closer all shift by the same delta so
3202        // the parser keeps pairing the fences and MD031 doesn't misfire.
3203        let content = "- Item\n\n ```\n code\n ```\n";
3204        let fixed = fix(content);
3205        assert_eq!(fixed, "- Item\n\n  ```\n  code\n  ```\n");
3206    }
3207
3208    #[test]
3209    fn fix_mkdocs_code_fence_indent() {
3210        // MkDocs ordered list: fence at 3 spaces needs 4; interior shifts too
3211        let content = "1. Item\n\n   ```toml\n   key = \"val\"\n   ```\n";
3212        let fixed = fix_mkdocs(content);
3213        assert_eq!(fixed, "1. Item\n\n    ```toml\n    key = \"val\"\n    ```\n");
3214        assert_eq!(fix_mkdocs(&fixed), fixed);
3215    }
3216
3217    // ── Empty document / whitespace-only ──────────────────────────────
3218
3219    #[test]
3220    fn empty_document_no_warning() {
3221        assert!(check("").is_empty());
3222    }
3223
3224    #[test]
3225    fn whitespace_only_no_warning() {
3226        assert!(check("   \n\n  \n").is_empty());
3227    }
3228
3229    // ── No list at all ────────────────────────────────────────────────
3230
3231    #[test]
3232    fn no_list_no_warning() {
3233        let content = "# Heading\n\nSome paragraph.\n\nAnother paragraph.\n";
3234        assert!(check(content).is_empty());
3235    }
3236
3237    // ── Multi-line continuation (additional coverage) ──────────────
3238
3239    #[test]
3240    fn multiline_continuation_all_lines_flagged() {
3241        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";
3242        let warnings = check(content);
3243        assert_eq!(warnings.len(), 3);
3244        assert_eq!(warnings[0].line, 3);
3245        assert_eq!(warnings[1].line, 4);
3246        assert_eq!(warnings[2].line, 5);
3247    }
3248
3249    #[test]
3250    fn multiline_continuation_with_frontmatter_fix() {
3251        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";
3252        let fixed = fix(content);
3253        assert_eq!(
3254            fixed,
3255            "---\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"
3256        );
3257    }
3258
3259    #[test]
3260    fn multiline_continuation_correct_indent_no_warning() {
3261        let content = "1. Item\n\n   line one\n   line two\n   line three\n";
3262        assert!(check(content).is_empty());
3263    }
3264
3265    #[test]
3266    fn multiline_continuation_mixed_indent() {
3267        let content = "1. Item\n\n   correct\n  wrong\n   correct\n";
3268        let warnings = check(content);
3269        assert_eq!(warnings.len(), 1);
3270        assert_eq!(warnings[0].line, 4);
3271    }
3272
3273    #[test]
3274    fn multiline_continuation_unordered() {
3275        let content = "- Item\n\n continuation 1\n continuation 2\n continuation 3\n";
3276        let warnings = check(content);
3277        assert_eq!(warnings.len(), 3);
3278        let fixed = fix(content);
3279        assert_eq!(
3280            fixed,
3281            "- Item\n\n  continuation 1\n  continuation 2\n  continuation 3\n"
3282        );
3283    }
3284
3285    #[test]
3286    fn multiline_continuation_two_items_fix() {
3287        let content = "1. First\n\n  cont a\n  cont b\n\n2. Second\n\n  cont c\n  cont d\n";
3288        let fixed = fix(content);
3289        assert_eq!(
3290            fixed,
3291            "1. First\n\n   cont a\n   cont b\n\n2. Second\n\n   cont c\n   cont d\n"
3292        );
3293    }
3294
3295    #[test]
3296    fn fence_fix_does_not_break_pairing_for_md031() {
3297        // Regression for issue #574: previously MD077 only reindented the
3298        // fence delimiter lines while leaving the code block's interior at
3299        // the old indent. Between iterations of the fix loop the parser
3300        // saw an opener-closer mismatch, and MD031 then injected stray
3301        // blank lines at the fence boundaries. MD077's compound fix must
3302        // now rewrite the whole block atomically so the fences stay paired.
3303        let content = "#### title\n\nabc\n\n\
3304                       1. ab\n\n\
3305                       \x20\x20`aabbccdd`\n\n\
3306                       2. cd\n\n\
3307                       \x20\x20`bbcc dd ee`\n\n\
3308                       \x20\x20```\n\
3309                       \x20\x20abcd\n\
3310                       \x20\x20ef gh\n\
3311                       \x20\x20```\n\n\
3312                       \x20\x20uu\n\n\
3313                       \x20\x20```\n\
3314                       \x20\x20cdef\n\
3315                       \x20\x20gh ij\n\
3316                       \x20\x20```\n";
3317        let expected = "#### title\n\nabc\n\n\
3318                        1. ab\n\n\
3319                        \x20\x20\x20`aabbccdd`\n\n\
3320                        2. cd\n\n\
3321                        \x20\x20\x20`bbcc dd ee`\n\n\
3322                        \x20\x20\x20```\n\
3323                        \x20\x20\x20abcd\n\
3324                        \x20\x20\x20ef gh\n\
3325                        \x20\x20\x20```\n\n\
3326                        \x20\x20\x20uu\n\n\
3327                        \x20\x20\x20```\n\
3328                        \x20\x20\x20cdef\n\
3329                        \x20\x20\x20gh ij\n\
3330                        \x20\x20\x20```\n";
3331        assert_eq!(fix(content), expected);
3332    }
3333
3334    #[test]
3335    fn multiline_continuation_separated_by_blank() {
3336        let content = "1. Item\n\n  para1 line1\n  para1 line2\n\n  para2 line1\n  para2 line2\n";
3337        let warnings = check(content);
3338        assert_eq!(warnings.len(), 4);
3339        let fixed = fix(content);
3340        assert_eq!(
3341            fixed,
3342            "1. Item\n\n   para1 line1\n   para1 line2\n\n   para2 line1\n   para2 line2\n"
3343        );
3344    }
3345
3346    #[test]
3347    fn tab_indented_fence_is_normalized_to_spaces() {
3348        // Leading tabs expand to the next multiple-of-4 column under
3349        // CommonMark, so simply prepending spaces before a tab would
3350        // silently no-op (the tab snaps back to column 4). The compound
3351        // fence fix must replace the leading whitespace with a fresh
3352        // (visual_indent + delta) run of spaces. The fence (visual col 4)
3353        // belongs to the outer item, short of the nested `100. ` item's
3354        // content column 7, and must end up at 7 spaces after the fix.
3355        let content = "- a\n\n  100. ab\n\n\t```\n\tabcd\n\t```\n";
3356        let expected = "- a\n\n  100. ab\n\n       ```\n       abcd\n       ```\n";
3357        assert_eq!(fix(content), expected);
3358        // At the top level the same lines are an indented code block holding
3359        // fence-like text, which is not continuation of the item.
3360        let code = "100. ab\n\n\t```\n\tabcd\n\t```\n";
3361        assert_eq!(fix(code), code);
3362    }
3363
3364    // ── Loose continuation (after a blank line): over-indent ──────────
3365    //
3366    // Over-indentation is a mistake in both tight and loose continuation:
3367    // the body looks aligned but isn't. A blank line between the marker and
3368    // the body must not exempt it. The only over-indent that is intentional
3369    // after a blank line is an indented code block (content column + 4 or
3370    // more), which the parser marks `in_code_block` and the rule skips.
3371
3372    #[test]
3373    fn loose_continuation_over_indented_flagged() {
3374        // "* " content column is 2; 3 spaces after a blank is over-indented
3375        // (the code-block threshold is content_col + 4 = 6).
3376        let content = "* Item\n\n   over-indented\n";
3377        let warnings = check(content);
3378        assert_eq!(warnings.len(), 1);
3379        assert_eq!(warnings[0].line, 3);
3380        assert!(warnings[0].message.contains("over-indented"));
3381        assert!(warnings[0].message.contains("expected 2"));
3382        assert!(warnings[0].message.contains("found 3"));
3383    }
3384
3385    #[test]
3386    fn loose_continuation_over_indented_multiline_mixed() {
3387        // Over, correct, over — only the two over-indented lines are flagged.
3388        let content = "* Item\n\n   over one\n  correct\n   over two\n";
3389        let warnings = check(content);
3390        assert_eq!(warnings.len(), 2);
3391        assert_eq!(warnings[0].line, 3);
3392        assert_eq!(warnings[1].line, 5);
3393    }
3394
3395    #[test]
3396    fn fix_loose_continuation_over_indented() {
3397        let content = "* Item\n\n   over one\n  correct\n   over two\n";
3398        let fixed = fix(content);
3399        assert_eq!(fixed, "* Item\n\n  over one\n  correct\n  over two\n");
3400    }
3401
3402    #[test]
3403    fn fix_tight_and_loose_items_normalized_identically() {
3404        // The reported document: a tight item and a loose item with the same
3405        // over-indented body must both normalize to the content column.
3406        let content = "---\ntitle: Heading\n---\n\nSome introductory text:\n\n\
3407                       * 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\
3408                       * 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";
3409        let expected = "---\ntitle: Heading\n---\n\nSome introductory text:\n\n\
3410                        * 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\
3411                        * 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";
3412        assert_eq!(fix(content), expected);
3413    }
3414
3415    #[test]
3416    fn multi_paragraph_item_loose_paragraph_over_indented() {
3417        // A tight first paragraph and a loose second paragraph (after an
3418        // internal blank line) are both over-indented; both must be flagged.
3419        let content = "* Item.\n   tight over\n\n   loose over\n";
3420        let warnings = check(content);
3421        assert_eq!(warnings.len(), 2);
3422        assert_eq!(warnings[0].line, 2);
3423        assert_eq!(warnings[1].line, 4);
3424    }
3425
3426    #[test]
3427    fn loose_indented_code_block_not_flagged() {
3428        // content_col = 2; a loose line at content_col + 4 (6 spaces) is a
3429        // CommonMark indented code block, not over-indented prose. The over-
3430        // indent check must never reach it (it is `in_code_block` and skipped).
3431        let content = "- Item\n\n      code line\n";
3432        assert!(check(content).is_empty());
3433    }
3434
3435    #[test]
3436    fn mkdocs_loose_over_indented_flagged() {
3437        // MkDocs requires max(3, 4) = 4 for "1. ". A loose line at 5 spaces is
3438        // over-indented (code-block threshold is content_col + 4 = 7).
3439        let content = "1. Item\n\n     over\n";
3440        let warnings = check_mkdocs(content);
3441        assert_eq!(warnings.len(), 1);
3442        assert_eq!(warnings[0].line, 3);
3443        assert!(warnings[0].message.contains("over-indented"));
3444        assert!(warnings[0].message.contains("expected 4"));
3445        assert!(warnings[0].message.contains("found 5"));
3446    }
3447
3448    #[test]
3449    fn task_list_loose_over_indented_flagged() {
3450        // "- [ ] " content_col = 2, task_col = 6. A loose line at 4 spaces is
3451        // neither valid column and below the code-block threshold (6); flagged.
3452        let content = "- [ ] Task\n\n    over\n";
3453        let warnings = check(content);
3454        assert_eq!(warnings.len(), 1);
3455        assert_eq!(warnings[0].line, 3);
3456    }
3457
3458    #[test]
3459    fn loose_over_indent_boundary_below_code_block_threshold_flagged() {
3460        // content_col = 2; 5 spaces (= content_col + 3) is the deepest loose
3461        // over-indent that is still prose. content_col + 4 (6 spaces) would be
3462        // an indented code block - see `loose_indented_code_block_not_flagged`.
3463        // This pins the boundary so a shift in the parser's threshold is caught.
3464        let content = "- Item\n\n     over\n";
3465        let warnings = check(content);
3466        assert_eq!(warnings.len(), 1);
3467        assert_eq!(warnings[0].line, 3);
3468        assert!(warnings[0].message.contains("expected 2"));
3469        assert!(warnings[0].message.contains("found 5"));
3470    }
3471
3472    #[test]
3473    fn loose_over_indent_does_not_steal_nested_under_indent() {
3474        // Inner content_col = 4, marker_col = 2. A loose continuation at column
3475        // 3 is under-indented for Inner yet over-indented for Outer (content_col
3476        // 2). The under-indent pass must claim it for Inner (snap *up* to 4,
3477        // preserving the apparent nesting), never letting the over-indent pass
3478        // mis-attribute it to Outer and snap it *down* to 2. This is the exact
3479        // ambiguity the two-pass ordering exists to resolve.
3480        let content = "- Outer\n  - Inner\n\n   continuation\n";
3481        let warnings = check(content);
3482        assert_eq!(warnings.len(), 1);
3483        assert_eq!(warnings[0].line, 4);
3484        assert!(warnings[0].message.contains("4 spaces"));
3485        assert!(warnings[0].message.contains("found 3"));
3486        assert_eq!(fix(content), "- Outer\n  - Inner\n\n    continuation\n");
3487    }
3488
3489    #[test]
3490    fn loose_over_indent_attributes_to_deepest_enclosing_item() {
3491        // Inner content_col = 4. A loose continuation at column 5 over-indents
3492        // Inner (the deepest item it sits within), so it is flagged against
3493        // Inner's column 4 - not Outer's column 2 - and snapped to 4.
3494        let content = "- Outer\n  - Inner\n\n     continuation\n";
3495        let warnings = check(content);
3496        assert_eq!(warnings.len(), 1);
3497        assert_eq!(warnings[0].line, 4);
3498        assert!(warnings[0].message.contains("expected 4"));
3499        assert!(warnings[0].message.contains("found 5"));
3500        assert_eq!(fix(content), "- Outer\n  - Inner\n\n    continuation\n");
3501    }
3502
3503    // ── Over-indented fenced code blocks are left untouched ───────────
3504    //
3505    // An over-indented fence is cosmetic: the code still renders inside the
3506    // list item. Reindenting only its delimiters (the body is skipped as code)
3507    // would change the literal code content, so the over-indent pass skips
3508    // anything `in_code_block`. The under-indent path still fixes fences, where
3509    // moving the block up is required to keep it inside the item.
3510
3511    #[test]
3512    fn loose_over_indented_fence_not_flagged() {
3513        let content = "- Item\n\n   ```\n   code\n   ```\n";
3514        assert!(check(content).is_empty());
3515        assert_eq!(fix(content), content);
3516    }
3517
3518    #[test]
3519    fn tight_over_indented_fence_not_flagged() {
3520        let content = "- Item\n   ```\n   code\n   ```\n";
3521        assert!(check(content).is_empty());
3522        assert_eq!(fix(content), content);
3523    }
3524
3525    #[test]
3526    fn over_indented_tilde_fence_not_flagged() {
3527        let content = "- Item\n\n   ~~~\n   code\n   ~~~\n";
3528        assert!(check(content).is_empty());
3529        assert_eq!(fix(content), content);
3530    }
3531
3532    #[test]
3533    fn fence_like_code_content_inside_fenced_block_not_flagged() {
3534        // A ``` line that is the *body* of a ~~~ block must not be treated as
3535        // over-indented continuation; rewriting it would corrupt code content.
3536        let content = "- Item\n\n  ~~~\n   ```\n  ~~~\n";
3537        assert!(check(content).is_empty());
3538        assert_eq!(fix(content), content);
3539    }
3540
3541    #[test]
3542    fn unterminated_over_indented_fence_not_flagged() {
3543        // No closing fence: the last code line must not be mistaken for a
3544        // closer and snapped to the content column.
3545        let content = "- Item\n\n   ```\n   code1\n     code2deeper\n";
3546        assert!(check(content).is_empty());
3547        assert_eq!(fix(content), content);
3548    }
3549
3550    // ── GFM task list items: post-checkbox continuation column ───────
3551    //
3552    // MD013's reflow indents wrapped task-list lines at `content_col + 4`
3553    // (the column after the checkbox). MD077 must accept that column for
3554    // both tight and loose continuation, for every marker flavour, so the
3555    // two rules don't fight over well-formed task items (issue #579).
3556
3557    #[test]
3558    fn task_list_tight_continuation_post_checkbox_reproducer_579() {
3559        // Exact reproducer from the bug report: content wraps to the
3560        // post-checkbox column (6) with no blank line.
3561        let content = "- [ ] Lorem ipsum dolor sit amet, consectetur adipiscing\n      tempor incididunt ut labore.\n";
3562        assert!(check(content).is_empty());
3563    }
3564
3565    #[test]
3566    fn task_list_tight_continuation_dash_unchecked() {
3567        let content = "- [ ] Task\n      continuation\n";
3568        assert!(check(content).is_empty());
3569    }
3570
3571    #[test]
3572    fn task_list_tight_continuation_dash_checked_lower() {
3573        let content = "- [x] Task\n      continuation\n";
3574        assert!(check(content).is_empty());
3575    }
3576
3577    #[test]
3578    fn task_list_tight_continuation_dash_checked_upper() {
3579        let content = "- [X] Task\n      continuation\n";
3580        assert!(check(content).is_empty());
3581    }
3582
3583    #[test]
3584    fn task_list_tight_continuation_star_marker() {
3585        let content = "* [ ] Task\n      continuation\n";
3586        assert!(check(content).is_empty());
3587    }
3588
3589    #[test]
3590    fn task_list_tight_continuation_plus_marker() {
3591        let content = "+ [ ] Task\n      continuation\n";
3592        assert!(check(content).is_empty());
3593    }
3594
3595    #[test]
3596    fn task_list_tight_continuation_content_column_still_valid() {
3597        // Column 2 is the CommonMark-canonical indent for "- " and remains
3598        // valid for task items too.
3599        let content = "- [ ] Task\n  continuation\n";
3600        assert!(check(content).is_empty());
3601    }
3602
3603    #[test]
3604    fn task_list_tight_continuation_between_columns_still_flagged() {
3605        // Column 4 matches neither content_col (2) nor post-checkbox (6).
3606        // A genuine indentation mistake — must remain flagged.
3607        let content = "- [ ] Task\n    continuation\n";
3608        let warnings = check(content);
3609        assert_eq!(warnings.len(), 1);
3610        // Task items advertise both valid columns to the user.
3611        assert!(warnings[0].message.contains("expected 2 or 6"));
3612        assert!(warnings[0].message.contains("found 4"));
3613    }
3614
3615    #[test]
3616    fn task_list_tight_continuation_overshoot_still_flagged() {
3617        // Column 7 overshoots the post-checkbox column. Genuine mistake.
3618        let content = "- [ ] Task\n       continuation\n";
3619        let warnings = check(content);
3620        assert_eq!(warnings.len(), 1);
3621        assert!(warnings[0].message.contains("expected 2 or 6"));
3622        assert!(warnings[0].message.contains("found 7"));
3623    }
3624
3625    // ── Task-list fix output: snap to nearer valid column ────────────
3626
3627    #[test]
3628    fn fix_task_list_overshoot_snaps_to_task_col() {
3629        // Col 7 is 1 away from post-checkbox (6), 5 away from content (2).
3630        // Snap to 6 — the author's intent was almost certainly the
3631        // post-checkbox alignment, not the content column.
3632        let content = "- [ ] Task\n       continuation\n";
3633        let fixed = fix(content);
3634        assert_eq!(fixed, "- [ ] Task\n      continuation\n");
3635    }
3636
3637    #[test]
3638    fn fix_task_list_col_5_snaps_to_task_col() {
3639        // Col 5 is 1 away from post-checkbox (6), 3 away from content (2).
3640        let content = "- [ ] Task\n     continuation\n";
3641        let fixed = fix(content);
3642        assert_eq!(fixed, "- [ ] Task\n      continuation\n");
3643    }
3644
3645    #[test]
3646    fn fix_task_list_col_3_snaps_to_content_col() {
3647        // Col 3 is 1 away from content (2), 3 away from post-checkbox (6).
3648        let content = "- [ ] Task\n   continuation\n";
3649        let fixed = fix(content);
3650        assert_eq!(fixed, "- [ ] Task\n  continuation\n");
3651    }
3652
3653    #[test]
3654    fn fix_task_list_col_4_ties_to_content_col() {
3655        // Col 4 is equidistant (±2) from both columns. Tie breaks to the
3656        // CommonMark-canonical content column — that's the default indent
3657        // MD077 would produce for a non-task item, so prefer it when the
3658        // author's intent is ambiguous.
3659        let content = "- [ ] Task\n    continuation\n";
3660        let fixed = fix(content);
3661        assert_eq!(fixed, "- [ ] Task\n  continuation\n");
3662    }
3663
3664    #[test]
3665    fn fix_task_list_ordered_overshoot_snaps_to_task_col() {
3666        // "1. [ ] " → content_col = 3, post-checkbox = 7.
3667        // Col 8 is nearer to 7.
3668        let content = "1. [ ] Task\n        continuation\n";
3669        let fixed = fix(content);
3670        assert_eq!(fixed, "1. [ ] Task\n       continuation\n");
3671    }
3672
3673    #[test]
3674    fn fix_task_list_ordered_under_overshoot_snaps_to_content_col() {
3675        // "1. [ ] " → content_col = 3, post-checkbox = 7.
3676        // Col 4 is nearer to 3.
3677        let content = "1. [ ] Task\n    continuation\n";
3678        let fixed = fix(content);
3679        assert_eq!(fixed, "1. [ ] Task\n   continuation\n");
3680    }
3681
3682    #[test]
3683    fn task_list_tight_continuation_ordered_single_digit() {
3684        // "1. [ ] " → content_col = 3, post-checkbox = 7
3685        let content = "1. [ ] Task\n       continuation\n";
3686        assert!(check(content).is_empty());
3687    }
3688
3689    #[test]
3690    fn task_list_tight_continuation_ordered_multi_digit() {
3691        // "10. [ ] " → content_col = 4, post-checkbox = 8
3692        let content = "10. [ ] Task\n        continuation\n";
3693        assert!(check(content).is_empty());
3694    }
3695
3696    #[test]
3697    fn task_list_tight_continuation_nested_dash() {
3698        // Nested "  - [ ] " at marker_col=2 → content_col=4, post-checkbox=8
3699        let content = "- Parent\n  - [ ] Nested task\n        continuation\n";
3700        assert!(check(content).is_empty());
3701    }
3702
3703    #[test]
3704    fn task_list_loose_continuation_post_checkbox_column_not_flagged() {
3705        // Loose continuation (blank line) at col 6 is also valid. This
3706        // already passed before the fix, but pin the intent: the 6-space
3707        // indent is accepted because it's the task-alignment column, not
3708        // because the under-indent check happens to let it through.
3709        let content = "- [ ] Task\n\n      continuation\n";
3710        assert!(check(content).is_empty());
3711    }
3712
3713    #[test]
3714    fn task_list_empty_body_is_not_a_task() {
3715        // "- [ ]" with nothing after is an empty regular list item, not a
3716        // task. Column 4 continuation has no task alignment to justify it
3717        // and must still be flagged as over-indented. (Col 6 would turn
3718        // the continuation into an indented code block inside the item,
3719        // which is a different code path.)
3720        let content = "- [ ]\n    continuation\n";
3721        let warnings = check(content);
3722        assert_eq!(warnings.len(), 1);
3723        assert!(warnings[0].message.contains("found 4"));
3724    }
3725
3726    #[test]
3727    fn task_list_malformed_checkbox_is_not_a_task() {
3728        // `[~] ` is not a GFM checkbox; only `[ ] `, `[x] `, `[X] ` count.
3729        let content = "- [~] Not a task\n      continuation\n";
3730        let warnings = check(content);
3731        assert_eq!(warnings.len(), 1);
3732    }
3733
3734    // ── MkDocs flavor × task checkbox ─────────────────────────────────
3735    //
3736    // MkDocs strict-indent and task alignment interact: required_min is
3737    // max(content_col, 4), and post-checkbox is content_col + 4. Both are
3738    // independently valid; values between them are flagged.
3739
3740    #[test]
3741    fn task_list_mkdocs_unordered_required_min_valid() {
3742        // "- [ ]" MkDocs: required_min = max(2, 4) = 4, post-checkbox = 6.
3743        let content = "- [ ] Task\n    continuation\n";
3744        assert!(check_mkdocs(content).is_empty());
3745    }
3746
3747    #[test]
3748    fn task_list_mkdocs_unordered_post_checkbox_valid() {
3749        let content = "- [ ] Task\n      continuation\n";
3750        assert!(check_mkdocs(content).is_empty());
3751    }
3752
3753    #[test]
3754    fn task_list_mkdocs_unordered_between_flagged() {
3755        // Column 5 is between required_min=4 and post-checkbox=6.
3756        let content = "- [ ] Task\n     continuation\n";
3757        let warnings = check_mkdocs(content);
3758        assert_eq!(warnings.len(), 1);
3759    }
3760
3761    #[test]
3762    fn task_list_mkdocs_ordered_both_columns_valid() {
3763        // "1. [ ]" MkDocs: required_min = max(3, 4) = 4, post-checkbox = 7.
3764        let at_4 = "1. [ ] Task\n    continuation\n";
3765        assert!(check_mkdocs(at_4).is_empty());
3766        let at_7 = "1. [ ] Task\n       continuation\n";
3767        assert!(check_mkdocs(at_7).is_empty());
3768    }
3769
3770    #[test]
3771    fn task_list_mkdocs_ordered_between_flagged() {
3772        // Column 5 and 6 are between required_min=4 and post-checkbox=7.
3773        let at_5 = "1. [ ] Task\n     continuation\n";
3774        assert_eq!(check_mkdocs(at_5).len(), 1);
3775        let at_6 = "1. [ ] Task\n      continuation\n";
3776        assert_eq!(check_mkdocs(at_6).len(), 1);
3777    }
3778
3779    // ── Context-aware tie-break ──────────────────────────────────────
3780    //
3781    // When a flagged line is exactly equidistant from `content_col` and
3782    // `task_col`, the author's intent is ambiguous. Before picking a
3783    // canonical default, look at whether other continuation lines in the
3784    // same item already use one of the valid columns — if so, snap to the
3785    // column they're using so the fix preserves the author's visible
3786    // convention.
3787
3788    #[test]
3789    fn fix_task_list_tie_sibling_at_task_col_snaps_to_task_col() {
3790        // Col 4 is equidistant from content_col (2) and task_col (6).
3791        // A valid sibling at col 6 proves the author is aligning under the
3792        // checkbox, so the tie resolves to col 6.
3793        let content = "- [ ] Task\n      aligned continuation\n    tied continuation\n";
3794        let fixed = fix(content);
3795        assert_eq!(
3796            fixed,
3797            "- [ ] Task\n      aligned continuation\n      tied continuation\n"
3798        );
3799    }
3800
3801    #[test]
3802    fn fix_task_list_tie_sibling_at_content_col_snaps_to_content_col() {
3803        // Valid sibling at col 2 proves the author is aligning to the
3804        // content column, so the col-4 tie resolves to col 2.
3805        let content = "- [ ] Task\n  aligned continuation\n    tied continuation\n";
3806        let fixed = fix(content);
3807        assert_eq!(fixed, "- [ ] Task\n  aligned continuation\n  tied continuation\n");
3808    }
3809
3810    #[test]
3811    fn fix_task_list_tie_both_siblings_snaps_to_content_col() {
3812        // When siblings exist at both valid columns, the author's pattern
3813        // is self-contradictory. Fall back to the CommonMark-canonical
3814        // content column.
3815        let content = "- [ ] Task\n  at content col\n      at task col\n    tied continuation\n";
3816        let fixed = fix(content);
3817        assert_eq!(
3818            fixed,
3819            "- [ ] Task\n  at content col\n      at task col\n  tied continuation\n"
3820        );
3821    }
3822
3823    #[test]
3824    fn fix_task_list_tie_sees_task_col_through_tight_lazy_continuation() {
3825        // CommonMark allows tight lazy continuation at col ≤ marker_col
3826        // (zero-indent continuation) inside a list item. The pre-pass
3827        // must MIRROR the main check loop's termination semantics: in
3828        // tight mode (no preceding blank) col ≤ marker_col is NOT a
3829        // termination signal — the lazy line still belongs to the item.
3830        //
3831        // This test pins that mirroring: a `lazy` line at col 0 is
3832        // followed by a legitimate task-col sibling at col 6, then a
3833        // tied col-4 line. If the pre-pass terminated eagerly at the
3834        // lazy line, the task-col sibling would be missed and the tied
3835        // line would fall back to content column. With correct
3836        // mirroring, the task-col sibling is seen and the tie resolves
3837        // to col 6.
3838        let content = concat!("- [ ] Task\n", "lazy\n", "      aligned at task col\n", "    tied\n",);
3839        let fixed = fix(content);
3840        assert!(
3841            fixed.contains("\n      tied\n"),
3842            "tied line should snap to col 6 (task col) because a task-col \
3843             sibling is visible past the tight lazy-continuation line; got:\n{fixed}"
3844        );
3845    }
3846
3847    // ── Tab-indented task continuation ───────────────────────────────
3848    //
3849    // Leading tabs expand to the next column that's a multiple of 4 under
3850    // CommonMark. The fix replaces the leading whitespace bytes wholesale,
3851    // turning tabs into space-indented output.
3852
3853    #[test]
3854    fn task_list_tab_indented_continuation_flagged() {
3855        // Two tabs → visual col 8, which overshoots both valid columns
3856        // for `- [ ] ` (content_col=2, task_col=6).
3857        let content = "- [ ] Task\n\t\twrap\n";
3858        let warnings = check(content);
3859        assert_eq!(warnings.len(), 1);
3860        assert!(warnings[0].message.contains("expected 2 or 6"));
3861        assert!(warnings[0].message.contains("found 8"));
3862    }
3863
3864    #[test]
3865    fn fix_task_list_tab_indented_snaps_to_task_col() {
3866        // abs_diff(8, 6) = 2 < abs_diff(8, 2) = 6 → snap to task_col (6).
3867        let content = "- [ ] Task\n\t\twrap\n";
3868        let fixed = fix(content);
3869        assert_eq!(fixed, "- [ ] Task\n      wrap\n");
3870    }
3871
3872    #[test]
3873    fn fix_task_list_single_tab_equidistant_snaps_to_content_col() {
3874        // One tab → visual col 4, equidistant from content_col (2) and
3875        // task_col (6). No siblings → tie-break to content_col.
3876        let content = "- [ ] Task\n\twrap\n";
3877        let fixed = fix(content);
3878        assert_eq!(fixed, "- [ ] Task\n  wrap\n");
3879    }
3880
3881    // ── Blockquote × task-list ───────────────────────────────────────
3882    //
3883    // Blockquote-nested lists are a known limitation on MD077: the list
3884    // parser doesn't always expose them with the same column semantics as
3885    // top-level lists, and the rule prefers a false-negative default to
3886    // avoid spurious warnings inside blockquotes (see
3887    // `blockquote_list_under_indent_no_false_positive`). These tests pin
3888    // the current behavior so any future change is intentional.
3889
3890    #[test]
3891    fn task_list_blockquote_post_checkbox_not_flagged() {
3892        // Post-checkbox alignment inside a blockquote — accepted as valid.
3893        let content = "> - [ ] Task\n>       continuation\n";
3894        assert!(check(content).is_empty());
3895    }
3896
3897    #[test]
3898    fn task_list_blockquote_between_cols_documented_limitation() {
3899        // Col-4-equivalent inside a blockquote is silently accepted — a
3900        // known MD077 limitation on blockquote-nested lists, not a task-
3901        // list-specific choice. Pinning the current behavior.
3902        let content = "> - [ ] Task\n>     continuation\n";
3903        assert!(check(content).is_empty());
3904    }
3905
3906    #[test]
3907    fn task_list_blockquote_overshoot_documented_limitation() {
3908        // Overshoot inside a blockquote — same known limitation.
3909        let content = "> - [ ] Task\n>        continuation\n";
3910        assert!(check(content).is_empty());
3911    }
3912
3913    // ── MkDocs × task × fix output ───────────────────────────────────
3914    //
3915    // MkDocs strict-indent raises `required` to max(content_col, 4) while
3916    // task_col stays at content_col + 4. The snap logic operates on the
3917    // raised required, not on the underlying content_col.
3918
3919    #[test]
3920    fn fix_task_list_mkdocs_unordered_overshoot_snaps_to_task_col() {
3921        // `- [ ]` MkDocs: required=4, task_col=6. Col 7 → abs_diff(7,6)=1
3922        // < abs_diff(7,4)=3. Snap to task_col.
3923        let content = "- [ ] Task\n       continuation\n";
3924        let fixed = fix_mkdocs(content);
3925        assert_eq!(fixed, "- [ ] Task\n      continuation\n");
3926    }
3927
3928    #[test]
3929    fn fix_task_list_mkdocs_unordered_tie_snaps_to_required() {
3930        // `- [ ]` MkDocs: required=4, task_col=6. Col 5 → abs_diff(5,6)=1
3931        // == abs_diff(5,4)=1. Tie with no siblings → required (4).
3932        let content = "- [ ] Task\n     continuation\n";
3933        let fixed = fix_mkdocs(content);
3934        assert_eq!(fixed, "- [ ] Task\n    continuation\n");
3935    }
3936
3937    #[test]
3938    fn fix_task_list_mkdocs_ordered_overshoot_snaps_to_task_col() {
3939        // `1. [ ]` MkDocs: required=4, task_col=7. Col 8 → abs_diff(8,7)=1
3940        // < abs_diff(8,4)=4. Snap to task_col.
3941        let content = "1. [ ] Task\n        continuation\n";
3942        let fixed = fix_mkdocs(content);
3943        assert_eq!(fixed, "1. [ ] Task\n       continuation\n");
3944    }
3945
3946    #[test]
3947    fn fix_task_list_mkdocs_ordered_near_required_snaps_to_required() {
3948        // `1. [ ]` MkDocs: required=4, task_col=7. Col 5 → abs_diff(5,7)=2
3949        // > abs_diff(5,4)=1. Snap to required (4). `1. [ ] Task\n     wrap`
3950        // has actual=5 which is over `required=4` so it's flagged in
3951        // strict mode, while in standard mode it falls under the lazy-
3952        // continuation window and isn't flagged at all.
3953        let content = "1. [ ] Task\n     continuation\n";
3954        let fixed = fix_mkdocs(content);
3955        assert_eq!(fixed, "1. [ ] Task\n    continuation\n");
3956    }
3957
3958    #[test]
3959    fn fix_task_list_mkdocs_ordered_between_cols_snaps_to_task_col() {
3960        // `1. [ ]` MkDocs: required=4, task_col=7. Col 6 → abs_diff(6,7)=1
3961        // < abs_diff(6,4)=2. Snap to task_col (7).
3962        let content = "1. [ ] Task\n      continuation\n";
3963        let fixed = fix_mkdocs(content);
3964        assert_eq!(fixed, "1. [ ] Task\n       continuation\n");
3965    }
3966
3967    // ── Fix idempotency (property test) ──────────────────────────────
3968    //
3969    // A fix pass on already-fixed content must produce the same content
3970    // — otherwise MD077 would oscillate on repeated invocations. This is
3971    // the core property that issue #579 was about (MD077 vs. MD013 fix
3972    // loop), and the integration test covers the MD013 interaction. The
3973    // property tests below pin the *internal* idempotency of MD077's own
3974    // fix, so any future change that introduces oscillation fails fast.
3975
3976    fn assert_idempotent(content: &str) {
3977        let once = fix(content);
3978        let twice = fix(&once);
3979        assert_eq!(once, twice, "MD077 fix was not idempotent on input: {content:?}");
3980    }
3981
3982    fn assert_idempotent_mkdocs(content: &str) {
3983        let once = fix_mkdocs(content);
3984        let twice = fix_mkdocs(&once);
3985        assert_eq!(
3986            once, twice,
3987            "MD077 (MkDocs) fix was not idempotent on input: {content:?}"
3988        );
3989    }
3990
3991    #[test]
3992    fn idempotent_task_list_between_cols() {
3993        assert_idempotent("- [ ] Task\n    continuation\n");
3994    }
3995
3996    #[test]
3997    fn idempotent_task_list_overshoot() {
3998        assert_idempotent("- [ ] Task\n       continuation\n");
3999    }
4000
4001    #[test]
4002    fn idempotent_task_list_under_post_checkbox() {
4003        assert_idempotent("- [ ] Task\n   continuation\n");
4004    }
4005
4006    #[test]
4007    fn idempotent_task_list_near_post_checkbox() {
4008        assert_idempotent("- [ ] Task\n     continuation\n");
4009    }
4010
4011    #[test]
4012    fn idempotent_task_list_tab_overshoot() {
4013        assert_idempotent("- [ ] Task\n\t\twrap\n");
4014    }
4015
4016    #[test]
4017    fn idempotent_task_list_single_tab() {
4018        assert_idempotent("- [ ] Task\n\twrap\n");
4019    }
4020
4021    #[test]
4022    fn idempotent_task_list_ordered_overshoot() {
4023        assert_idempotent("1. [ ] Task\n        continuation\n");
4024    }
4025
4026    #[test]
4027    fn idempotent_task_list_ordered_under() {
4028        assert_idempotent("1. [ ] Task\n    continuation\n");
4029    }
4030
4031    #[test]
4032    fn idempotent_task_list_tie_with_sibling_at_task_col() {
4033        assert_idempotent("- [ ] Task\n      aligned\n    tied\n");
4034    }
4035
4036    #[test]
4037    fn idempotent_task_list_tie_with_sibling_at_content_col() {
4038        assert_idempotent("- [ ] Task\n  aligned\n    tied\n");
4039    }
4040
4041    #[test]
4042    fn idempotent_task_list_mkdocs_unordered_overshoot() {
4043        assert_idempotent_mkdocs("- [ ] Task\n       continuation\n");
4044    }
4045
4046    #[test]
4047    fn idempotent_task_list_mkdocs_unordered_tie() {
4048        assert_idempotent_mkdocs("- [ ] Task\n     continuation\n");
4049    }
4050
4051    #[test]
4052    fn idempotent_task_list_mkdocs_ordered_overshoot() {
4053        assert_idempotent_mkdocs("1. [ ] Task\n        continuation\n");
4054    }
4055
4056    #[test]
4057    fn idempotent_task_list_mkdocs_ordered_between() {
4058        assert_idempotent_mkdocs("1. [ ] Task\n      continuation\n");
4059    }
4060
4061    #[test]
4062    fn idempotent_task_list_reproducer_579() {
4063        // The exact reproducer from issue #579 already has correct indent
4064        // (col 6 = post-checkbox), so idempotency is trivially true. Pin
4065        // it anyway as a smoke test against future regressions.
4066        assert_idempotent(
4067            "- [ ] Lorem ipsum dolor sit amet, consectetur adipiscing\n      tempor incididunt ut labore.\n",
4068        );
4069    }
4070
4071    #[test]
4072    fn idempotent_non_task_list_still_holds() {
4073        // Non-task items never enter the task_col code path; sanity-check
4074        // that idempotency is preserved for them too.
4075        assert_idempotent("1. Item\n    over-indented\n");
4076        assert_idempotent("- Item\n\n continuation\n");
4077    }
4078
4079    // ── Non-task idempotency: loose-mode under-indent ────────────────
4080    //
4081    // When a blank line precedes the continuation (loose mode),
4082    // under-indented content is flagged and fixed up to the content
4083    // column. Idempotency pins that one pass of the fix is sufficient.
4084
4085    #[test]
4086    fn idempotent_non_task_loose_under_indent_ordered() {
4087        // 1. Item → content col 3; "  x" is 2 spaces, under content col.
4088        assert_idempotent("1. Item\n\n  continuation\n");
4089    }
4090
4091    #[test]
4092    fn idempotent_non_task_loose_under_indent_multi_digit() {
4093        // 10. Item → content col 4; single-space continuation needs 4.
4094        assert_idempotent("10. Item\n\n continuation\n");
4095    }
4096
4097    #[test]
4098    fn idempotent_non_task_tight_over_indent_ordered() {
4099        // Tight-mode over-indent: 5 spaces where content col is 3.
4100        assert_idempotent("1. Item\n     over-indented\n");
4101    }
4102
4103    // ── Non-task idempotency: fenced code block compound fix ─────────
4104    //
4105    // A fence opener that needs re-indenting is repaired by the
4106    // compound-fence fix which shifts opener + interior + closer
4107    // together. Idempotency pins that the compound fix settles in one
4108    // pass and does not oscillate between runs.
4109
4110    #[test]
4111    fn idempotent_non_task_fence_ordered_loose() {
4112        // 1. Item → content col 3; fence at col 2 needs to shift to 3.
4113        assert_idempotent("1. Item\n\n  ```rust\n  let x = 1;\n  ```\n");
4114    }
4115
4116    #[test]
4117    fn idempotent_non_task_fence_tilde_under_indent() {
4118        // Tilde fences use the same compound-fix path as backtick fences.
4119        // Interior below the list scope (col 0 here, required col 3) must
4120        // be promoted up in the same pass as the fence delimiters —
4121        // otherwise a second pass would flag the interior individually
4122        // and defeat idempotency.
4123        assert_idempotent("1. Item\n\n  ~~~\nplain text\n  ~~~\n");
4124    }
4125
4126    #[test]
4127    fn idempotent_non_task_fence_interior_above_required() {
4128        // Interior already above the required column must not be pushed
4129        // further up by the compound fix — authored interior indentation
4130        // is preserved when it doesn't threaten fence pairing.
4131        assert_idempotent("1. Item\n\n  ```\n    deeply indented code\n  ```\n");
4132    }
4133
4134    #[test]
4135    fn fence_fix_promotes_interior_below_scope_in_single_pass() {
4136        // Concrete behavioral check, not just idempotency:
4137        // interior at col 0 with opener at col 2, required 3, must land
4138        // at col 3 (same as opener) so fence pairing is preserved.
4139        let content = "1. Item\n\n  ```\ncode\n  ```\n";
4140        let fixed = fix(content);
4141        assert_eq!(fixed, "1. Item\n\n   ```\n   code\n   ```\n");
4142    }
4143
4144    #[test]
4145    fn fence_fix_preserves_interior_offset_from_the_fence() {
4146        // Opener at col 2 → col 3 (required). Interior at col 4 sits two
4147        // columns inside the fence and must still sit two columns inside it
4148        // afterwards, at col 5: leaving it at col 4 would silently flatten
4149        // one level of the code block's own indentation.
4150        let content = "1. Item\n\n  ```\n    code\n  ```\n";
4151        let fixed = fix(content);
4152        assert_eq!(fixed, "1. Item\n\n   ```\n     code\n   ```\n");
4153    }
4154
4155    // ── Non-task idempotency: MkDocs strict-indent ───────────────────
4156    //
4157    // Under MkDocs flavor, continuation requires max(content_col, 4),
4158    // which can force a fix even when CommonMark would accept the
4159    // content. Pin idempotency for the non-task path there too.
4160
4161    #[test]
4162    fn idempotent_non_task_mkdocs_ordered_at_3_spaces() {
4163        // CommonMark-valid (3 spaces) but MkDocs demands 4 → fix runs.
4164        assert_idempotent_mkdocs("1. Item\n\n   continuation\n");
4165    }
4166
4167    #[test]
4168    fn idempotent_non_task_mkdocs_unordered_at_2_spaces() {
4169        // "- Item" → content col 2, but MkDocs raises the floor to 4.
4170        assert_idempotent_mkdocs("- Item\n\n  continuation\n");
4171    }
4172
4173    #[test]
4174    fn idempotent_non_task_mkdocs_fence_compound() {
4175        // MkDocs non-task fence: opener/interior/closer shift together.
4176        assert_idempotent_mkdocs("1. Item\n\n   ```toml\n   k = 1\n   ```\n");
4177    }
4178
4179    // ── style = "aligned" ──────────────────────────────────────────────
4180
4181    #[test]
4182    fn aligned_tight_zero_indent_continuation_flagged() {
4183        // The core #682 case: a tight 0-indent lazy continuation is valid
4184        // CommonMark (and accepted by `any`), but `aligned` requires it to sit
4185        // at the content column.
4186        let content = "- this is a long line\nthat continues on a second line\n";
4187        let warnings = check_aligned(content);
4188        assert_eq!(warnings.len(), 1);
4189        assert_eq!(warnings[0].line, 2);
4190        assert_eq!(
4191            fix_aligned(content),
4192            "- this is a long line\n  that continues on a second line\n"
4193        );
4194    }
4195
4196    #[test]
4197    fn aligned_full_issue_example_made_consistent() {
4198        // The full #682 example: every continuation line is snapped to the
4199        // content column, producing the consistent result the reporter wants.
4200        let content = "- this is a long line\n\
4201                       that continues on a second line\n\
4202                       - this is another long line\n\
4203                       \x20\x20that continues on the next line\n\
4204                       - yet again a long line\n\
4205                       and still inconsistently spaced\n\
4206                       \x20\x20and even worse\n";
4207        let expected = "- this is a long line\n\
4208                        \x20\x20that continues on a second line\n\
4209                        - this is another long line\n\
4210                        \x20\x20that continues on the next line\n\
4211                        - yet again a long line\n\
4212                        \x20\x20and still inconsistently spaced\n\
4213                        \x20\x20and even worse\n";
4214        assert_eq!(fix_aligned(content), expected);
4215        // And the fix is a fixpoint.
4216        assert_eq!(fix_aligned(expected), expected);
4217    }
4218
4219    #[test]
4220    fn aligned_already_aligned_not_flagged() {
4221        let content = "- item\n  continuation at content column\n";
4222        assert!(check_aligned(content).is_empty());
4223    }
4224
4225    #[test]
4226    fn aligned_tight_partial_indent_flagged() {
4227        // 1 space under "- " (content col 2): tight partial under-indent.
4228        let content = "- item\n continuation\n";
4229        let warnings = check_aligned(content);
4230        assert_eq!(warnings.len(), 1);
4231        assert_eq!(fix_aligned(content), "- item\n  continuation\n");
4232    }
4233
4234    #[test]
4235    fn aligned_post_blank_zero_indent_still_new_paragraph() {
4236        // After a blank line, 0-indent content is a NEW paragraph that has left
4237        // the list - aligned mode must not reindent it back into the item.
4238        let content = "- item\n\nnew paragraph\n";
4239        assert!(check_aligned(content).is_empty());
4240        assert_eq!(fix_aligned(content), content);
4241    }
4242
4243    // ── aligned: structural blocks must NOT be pulled into the list ────
4244
4245    #[test]
4246    fn aligned_top_level_blockquote_after_list_untouched() {
4247        // A blockquote tight after a list item is a list-breaking separator,
4248        // not continuation. `in_list_block` is false for it, so aligned mode
4249        // must leave it alone (no reindent into the item).
4250        let content = "- item\n> quote\n";
4251        assert!(check_aligned(content).is_empty());
4252        assert_eq!(fix_aligned(content), content);
4253    }
4254
4255    #[test]
4256    fn aligned_top_level_fence_after_list_untouched() {
4257        let content = "- item\n```\ncode\n```\n";
4258        assert!(check_aligned(content).is_empty());
4259        assert_eq!(fix_aligned(content), content);
4260    }
4261
4262    #[test]
4263    fn aligned_top_level_table_after_list_untouched() {
4264        let content = "- item\n| a | b |\n|---|---|\n| 1 | 2 |\n";
4265        assert!(check_aligned(content).is_empty());
4266        assert_eq!(fix_aligned(content), content);
4267    }
4268
4269    // ── aligned: nested lists ──────────────────────────────────────────
4270
4271    #[test]
4272    fn aligned_nested_tight_lazy_continuation_aligns_to_inner() {
4273        // A tight lazy continuation after a nested item is, per CommonMark,
4274        // continuation of the innermost open item, so it aligns to the inner
4275        // item's content column (4), not the outer's (2). The outer item must
4276        // defer to the inner one.
4277        let content = "- Outer\n  - Inner\ncontinuation\n";
4278        let warnings = check_aligned(content);
4279        assert_eq!(warnings.len(), 1);
4280        assert_eq!(fix_aligned(content), "- Outer\n  - Inner\n    continuation\n");
4281    }
4282
4283    #[test]
4284    fn aligned_nested_continuation_already_aligned_not_flagged() {
4285        let content = "- L1\n  - L2\n    cont of L2 at 4\n";
4286        assert!(check_aligned(content).is_empty());
4287    }
4288
4289    #[test]
4290    fn aligned_nested_idempotent() {
4291        let content = "- Outer\n  - Inner\ncontinuation\n";
4292        let once = fix_aligned(content);
4293        assert_eq!(fix_aligned(&once), once);
4294    }
4295
4296    #[test]
4297    fn aligned_three_level_nesting_aligns_to_innermost() {
4298        // The saw_nested cascade defers through every level, so the tight lazy
4299        // line aligns to L3's content column (6), the innermost open item.
4300        let content = "- L1\n  - L2\n    - L3\ncont\n";
4301        assert_eq!(fix_aligned(content), "- L1\n  - L2\n    - L3\n      cont\n");
4302    }
4303
4304    #[test]
4305    fn aligned_continuation_after_sibling_owned_by_last_item() {
4306        // Tight lazy continuation after the second sibling belongs to that
4307        // sibling (no nesting), so it aligns to its content column (2).
4308        let content = "- a\n- b\nlazy\n";
4309        assert_eq!(fix_aligned(content), "- a\n- b\n  lazy\n");
4310    }
4311
4312    #[test]
4313    fn aligned_multi_digit_ordered_marker_aligns_to_content_column() {
4314        let content = "10. Item\nwrap\n";
4315        assert_eq!(fix_aligned(content), "10. Item\n    wrap\n");
4316    }
4317
4318    #[test]
4319    fn aligned_latent_setext_underline_is_left_alone() {
4320        // The underline is a lazy continuation, so the whole item renders as one
4321        // paragraph and no heading exists. Reindenting `Text` into the item would
4322        // promote the pair to a setext heading, changing the rendered structure,
4323        // so the item is skipped.
4324        let content = "- item\nText\n===\n";
4325        assert!(check_aligned(content).is_empty());
4326        assert_eq!(fix_aligned(content), content);
4327    }
4328
4329    #[test]
4330    fn aligned_reindents_prose_that_only_looks_like_an_underline() {
4331        // Positive control for the guard above: `= = =` has internal spaces, so
4332        // it is not an underline and cannot be promoted. The item is reindented.
4333        let content = "- item\nText\n= = =\n";
4334        assert_eq!(fix_aligned(content), "- item\n  Text\n  = = =\n");
4335    }
4336
4337    #[test]
4338    fn aligned_latent_marker_in_continuation_is_idempotent() {
4339        // Regression (fuzzer-found): a list-marker-looking line ("2. ") that the
4340        // parser absorbs as lazy continuation can be promoted to a real list
4341        // item once an earlier continuation line is reindented, re-attributing
4342        // later lines and breaking single-pass idempotency. Such items are not
4343        // reindented at all.
4344        let content = "# \n- \n``\n2. \n![]()";
4345        let once = fix_aligned(content);
4346        assert_eq!(fix_aligned(&once), once, "fix must be idempotent in one pass");
4347        assert_eq!(once, content, "item with a latent marker is left untouched");
4348    }
4349
4350    #[test]
4351    fn aligned_latent_table_in_continuation_is_idempotent() {
4352        // Regression (fuzzer-found): a table-looking line that the parser
4353        // absorbs as lazy continuation (so it is NOT yet `in_table_block`) can
4354        // be promoted to a real table once an earlier line is reindented. Like
4355        // a latent marker, this re-parses the block, so the item is left alone.
4356        let content = "- \n![`]()\n|  | ` |\n| --- | --- |";
4357        let once = fix_aligned(content);
4358        assert_eq!(fix_aligned(&once), once, "fix must be idempotent in one pass");
4359        assert_eq!(once, content, "item with a latent table is left untouched");
4360    }
4361
4362    #[test]
4363    fn aligned_blockquote_nested_list_not_touched() {
4364        // Lists inside blockquotes are a documented MD077 limitation (the scan
4365        // breaks at the blockquote prefix). Aligned inherits that conservative
4366        // no-false-positive behavior.
4367        let content = "> - item\n> wrap\n";
4368        assert!(check_aligned(content).is_empty());
4369        assert_eq!(fix_aligned(content), content);
4370    }
4371
4372    // ── aligned: GFM task list items ───────────────────────────────────
4373
4374    #[test]
4375    fn aligned_task_post_checkbox_column_accepted() {
4376        // Continuation aligned to the post-checkbox column (content_col + 4 = 6)
4377        // stays valid so aligned mode doesn't fight MD013 reflow.
4378        let content = "- [ ] Task\n      wrap\n";
4379        assert!(check_aligned(content).is_empty());
4380        assert_eq!(fix_aligned(content), content);
4381    }
4382
4383    #[test]
4384    fn aligned_task_under_indent_snaps_to_content_column() {
4385        let content = "- [ ] Task\nwrap\n";
4386        let warnings = check_aligned(content);
4387        assert_eq!(warnings.len(), 1);
4388        assert_eq!(fix_aligned(content), "- [ ] Task\n  wrap\n");
4389    }
4390
4391    // ── aligned: MkDocs flavor ─────────────────────────────────────────
4392
4393    #[test]
4394    fn aligned_mkdocs_tight_under_indent_snaps_to_four() {
4395        // MkDocs raises the required indent to max(content_col, 4) = 4 for "- ".
4396        let content = "- item\nwrap\n";
4397        let warnings = check_aligned_mkdocs(content);
4398        assert_eq!(warnings.len(), 1);
4399        let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
4400        assert_eq!(aligned_rule().fix(&ctx).unwrap(), "- item\n    wrap\n");
4401    }
4402
4403    // ── any (default) regression: tight lazy continuation untouched ────
4404
4405    #[test]
4406    fn any_default_does_not_flag_tight_lazy_continuation() {
4407        // The default style must preserve CommonMark lazy continuation.
4408        let content = "- item\nwrapped at zero indent\n";
4409        assert!(check(content).is_empty());
4410        assert_eq!(fix(content), content);
4411    }
4412
4413    #[test]
4414    fn from_config_aligned_enables_tight_flagging() {
4415        // End-to-end: `[MD077] style = "aligned"` wires through from_config.
4416        let mut config = crate::config::Config::default();
4417        let mut rule_config = crate::config::RuleConfig::default();
4418        rule_config
4419            .values
4420            .insert("style".to_string(), toml::Value::String("aligned".to_string()));
4421        config.rules.insert("MD077".to_string(), rule_config);
4422
4423        let rule = MD077ListContinuationIndent::from_config(&config);
4424        let ctx = LintContext::new("- item\nwrap\n", MarkdownFlavor::Standard, None);
4425        assert_eq!(rule.check(&ctx).unwrap().len(), 1);
4426    }
4427
4428    #[test]
4429    fn from_config_default_is_any() {
4430        // No config -> default `any` -> tight lazy continuation untouched.
4431        let config = crate::config::Config::default();
4432        let rule = MD077ListContinuationIndent::from_config(&config);
4433        let ctx = LintContext::new("- item\nwrap\n", MarkdownFlavor::Standard, None);
4434        assert!(rule.check(&ctx).unwrap().is_empty());
4435    }
4436
4437    #[test]
4438    fn from_config_indent_sets_fixed_requirement() {
4439        // End-to-end: `[MD077] indent = 4` wires through from_config and
4440        // requires continuation content to sit 4 spaces past the marker.
4441        let mut config = crate::config::Config::default();
4442        let mut rule_config = crate::config::RuleConfig::default();
4443        rule_config.values.insert("indent".to_string(), toml::Value::Integer(4));
4444        config.rules.insert("MD077".to_string(), rule_config);
4445
4446        let rule = MD077ListContinuationIndent::from_config(&config);
4447
4448        // "- item\n    wrap\n" -> continuation at 4 spaces: accepted.
4449        let ok_ctx = LintContext::new("- item\n    wrap\n", MarkdownFlavor::Standard, None);
4450        assert!(rule.check(&ok_ctx).unwrap().is_empty());
4451        assert_eq!(rule.fix(&ok_ctx).unwrap(), "- item\n    wrap\n");
4452
4453        // "- item\n\n  wrap\n" -> continuation at 2 spaces after a blank line:
4454        // flagged as under-indented (would escape the list item).
4455        let bad_ctx = LintContext::new("- item\n\n  wrap\n", MarkdownFlavor::Standard, None);
4456        let warnings = rule.check(&bad_ctx).unwrap();
4457        assert_eq!(warnings.len(), 1);
4458        assert!(warnings[0].message.contains("needs 4 spaces"));
4459    }
4460
4461    #[test]
4462    fn from_config_indent_applies_per_nested_marker() {
4463        // With a fixed indent, each item's requirement is its own marker
4464        // column plus the configured indent (nested item marker at 2 -> 6).
4465        let mut config = crate::config::Config::default();
4466        let mut rule_config = crate::config::RuleConfig::default();
4467        rule_config.values.insert("indent".to_string(), toml::Value::Integer(4));
4468        config.rules.insert("MD077".to_string(), rule_config);
4469
4470        let rule = MD077ListContinuationIndent::from_config(&config);
4471        let ctx = LintContext::new("- a\n  - b\n      wrap\n", MarkdownFlavor::Standard, None);
4472        let warnings = rule.check(&ctx).unwrap();
4473        assert!(
4474            warnings.is_empty(),
4475            "continuation at 6 spaces should pass: {warnings:?}"
4476        );
4477    }
4478
4479    /// A rule configured through the real `[MD077]` config path.
4480    fn rule_with(settings: &[(&str, toml::Value)]) -> Box<dyn Rule> {
4481        let mut config = crate::config::Config::default();
4482        let mut rule_config = crate::config::RuleConfig::default();
4483        for (key, value) in settings {
4484            rule_config.values.insert((*key).to_string(), value.clone());
4485        }
4486        config.rules.insert("MD077".to_string(), rule_config);
4487        MD077ListContinuationIndent::from_config(&config)
4488    }
4489
4490    #[test]
4491    fn configured_indent_cannot_lower_the_strict_flavor_minimum() {
4492        // The MkDocs 4-space minimum is what the renderer needs to read a line
4493        // as part of the item, so `indent = 2` may not pull it down to 2 - that
4494        // would have `fix` rewrite a rendering document into a broken one.
4495        let rule = rule_with(&[("indent", toml::Value::Integer(2))]);
4496
4497        let two = LintContext::new("- item\n\n  wrap\n", MarkdownFlavor::MkDocs, None);
4498        let warnings = rule.check(&two).unwrap();
4499        assert_eq!(warnings.len(), 1, "2 spaces is below the MkDocs minimum: {warnings:?}");
4500        assert!(
4501            warnings[0].message.contains("needs 4 spaces") && warnings[0].message.contains("MkDocs"),
4502            "the requirement comes from MkDocs, so the message must say so: {}",
4503            warnings[0].message
4504        );
4505        assert_eq!(rule.fix(&two).unwrap(), "- item\n\n    wrap\n");
4506
4507        // Control: a document already at the minimum is left alone.
4508        let four = LintContext::new("- item\n\n    wrap\n", MarkdownFlavor::MkDocs, None);
4509        assert!(rule.check(&four).unwrap().is_empty());
4510
4511        // Control: the clamp is scoped to the strict flavor. Under the standard
4512        // flavor the same setting means exactly what it says, and 4 is then an
4513        // over-indent to be snapped back to 2.
4514        let standard = LintContext::new("- item\n\n    wrap\n", MarkdownFlavor::Standard, None);
4515        assert_eq!(rule.check(&standard).unwrap().len(), 1);
4516        assert_eq!(rule.fix(&standard).unwrap(), "- item\n\n  wrap\n");
4517    }
4518
4519    #[test]
4520    fn configured_indent_can_raise_the_strict_flavor_minimum() {
4521        // The clamp is a floor, not a fixed value: above 4 the configured indent
4522        // still governs under the MkDocs flavor.
4523        let rule = rule_with(&[("indent", toml::Value::Integer(6))]);
4524        let ctx = LintContext::new("- item\n\n    wrap\n", MarkdownFlavor::MkDocs, None);
4525        let warnings = rule.check(&ctx).unwrap();
4526        assert_eq!(warnings.len(), 1);
4527        assert!(
4528            warnings[0].message.contains("needs 6 spaces"),
4529            "configured 6 must win over the 4-space floor: {}",
4530            warnings[0].message
4531        );
4532        assert_eq!(rule.fix(&ctx).unwrap(), "- item\n\n      wrap\n");
4533    }
4534
4535    #[test]
4536    fn configured_indent_message_does_not_claim_a_structural_consequence() {
4537        // Two spaces under a `- ` marker keeps the content in the list item, so
4538        // a message blaming list membership would be false. The requirement is
4539        // the configured one and the message has to say which.
4540        let rule = rule_with(&[("indent", toml::Value::Integer(4))]);
4541        let ctx = LintContext::new("- item\n\n  wrap\n", MarkdownFlavor::Standard, None);
4542        let warnings = rule.check(&ctx).unwrap();
4543        assert_eq!(warnings.len(), 1);
4544        assert!(
4545            warnings[0].message.contains("match the configured indent"),
4546            "expected the configured-indent wording, got: {}",
4547            warnings[0].message
4548        );
4549        assert!(
4550            !warnings[0].message.contains("remain part of the list"),
4551            "the content does remain part of the list here: {}",
4552            warnings[0].message
4553        );
4554
4555        // Control: the default rule reports the same document as clean, which is
4556        // what makes the structural claim wrong above.
4557        assert!(check("- item\n\n  wrap\n").is_empty());
4558
4559        // Control: content that really would escape the item still gets the
4560        // structural message, so the wording was narrowed and not replaced.
4561        let escaping = check("- item\n\n wrap\n");
4562        assert_eq!(escaping.len(), 1);
4563        assert!(
4564            escaping[0].message.contains("remain part of the list"),
4565            "unconfigured under-indent keeps its structural message, got: {}",
4566            escaping[0].message
4567        );
4568    }
4569
4570    #[test]
4571    fn configured_indent_leaves_tight_lazy_continuation_to_style() {
4572        // `indent` sets what the requirement is; `style` decides which lines are
4573        // measured against it. A tight continuation is CommonMark lazy
4574        // continuation, so `any` accepts it however the requirement was derived.
4575        let any = rule_with(&[("indent", toml::Value::Integer(4))]);
4576        let ctx = LintContext::new("- item\n  wrap\n", MarkdownFlavor::Standard, None);
4577        assert!(
4578            any.check(&ctx).unwrap().is_empty(),
4579            "style = any accepts tight lazy continuation"
4580        );
4581
4582        let aligned = rule_with(&[
4583            ("indent", toml::Value::Integer(4)),
4584            ("style", toml::Value::String("aligned".to_string())),
4585        ]);
4586        let warnings = aligned.check(&ctx).unwrap();
4587        assert_eq!(warnings.len(), 1, "style = aligned raises it: {warnings:?}");
4588        assert!(warnings[0].message.contains("expected 4"));
4589        assert_eq!(aligned.fix(&ctx).unwrap(), "- item\n    wrap\n");
4590    }
4591
4592    #[test]
4593    fn aligned_tight_underindented_fence_inside_item_left_alone() {
4594        // A fenced block is a structural construct; aligned mode does not
4595        // reindent it (the `in_code_block` guard), so a tight under-indented
4596        // fence is a no-op rather than a risky delimiter-only rewrite.
4597        let content = "- item\n ```\n code\n ```\n";
4598        assert!(check_aligned(content).is_empty());
4599        assert_eq!(fix_aligned(content), content);
4600    }
4601
4602    #[test]
4603    fn aligned_task_under_indent_fix_is_idempotent() {
4604        let content = "- [ ] Task\nwrap\n";
4605        let once = fix_aligned(content);
4606        assert_eq!(fix_aligned(&once), once);
4607    }
4608
4609    #[test]
4610    fn aligned_partial_indent_fix_is_idempotent() {
4611        let content = "- item\n continuation\n";
4612        let once = fix_aligned(content);
4613        assert_eq!(fix_aligned(&once), once);
4614    }
4615
4616    #[test]
4617    fn fence_shift_preserves_one_space_of_interior_nesting() {
4618        // Reindenting the fence moves the whole block: an interior line keeps
4619        // its offset from the opener, so one space of JSON nesting survives.
4620        let content = "1. Configure:\n\n  ```json\n  {\n   \"a\": 1\n  }\n  ```\n";
4621        let expected = "1. Configure:\n\n   ```json\n   {\n    \"a\": 1\n   }\n   ```\n";
4622        assert_eq!(fix(content), expected);
4623    }
4624
4625    #[test]
4626    fn fence_shift_preserves_every_interior_nesting_level() {
4627        let content = "1. Configure:\n\n  ```json\n  {\n    \"a\": {\n      \"b\": 1\n    }\n  }\n  ```\n";
4628        let expected = "1. Configure:\n\n   ```json\n   {\n     \"a\": {\n       \"b\": 1\n     }\n   }\n   ```\n";
4629        assert_eq!(fix(content), expected);
4630    }
4631
4632    #[test]
4633    fn fence_shift_lifts_interior_below_the_list_scope_all_the_way() {
4634        // The case the promote-up rule was written for: an interior line
4635        // starting below the item's content column must land at that column,
4636        // not one space above where it began.
4637        let content = "1. Configure:\n\n  ```json\n{\n  ```\n";
4638        let expected = "1. Configure:\n\n   ```json\n   {\n   ```\n";
4639        assert_eq!(fix(content), expected);
4640        assert_eq!(fix(expected), expected, "and the result is stable");
4641    }
4642
4643    #[test]
4644    fn fence_shift_is_idempotent() {
4645        for content in [
4646            "1. Configure:\n\n  ```json\n  {\n   \"a\": 1\n  }\n  ```\n",
4647            "1. Configure:\n\n  ```json\n{\n    deep\n  }\n  ```\n",
4648            "- item\n\n ```\n  nested\n ```\n",
4649        ] {
4650            let once = fix(content);
4651            assert_eq!(fix(&once), once, "MD077 fence fix must be idempotent: {content:?}");
4652        }
4653    }
4654
4655    #[test]
4656    fn fence_already_at_the_content_column_is_left_alone() {
4657        let content = "1. Configure:\n\n   ```json\n   {\n    \"a\": 1\n   }\n   ```\n";
4658        assert!(check(content).is_empty());
4659        assert_eq!(fix(content), content);
4660    }
4661
4662    #[test]
4663    fn over_indented_fence_keeps_its_interior_untouched() {
4664        // The over-indent direction deliberately leaves fenced blocks alone.
4665        let content = "1. Configure:\n\n       ```json\n       {\n        \"a\": 1\n       }\n       ```\n";
4666        assert_eq!(fix(content), content);
4667    }
4668
4669    /// Documents MD077 has nothing to say about, with the same result under both
4670    /// styles: every continuation sits where CommonMark puts the item's content.
4671    fn assert_untouched(content: &str) {
4672        assert!(check(content).is_empty(), "{content:?}: {:?}", check(content));
4673        assert_eq!(fix(content), content, "{content:?}");
4674        assert!(
4675            check_aligned(content).is_empty(),
4676            "{content:?}: {:?}",
4677            check_aligned(content)
4678        );
4679        assert_eq!(fix_aligned(content), content, "{content:?} (aligned)");
4680    }
4681
4682    #[test]
4683    fn mkdocs_aligned_fix_settles_when_a_number_follows_re_indented_prose() {
4684        // `29.` continues the paragraph; indenting the line above it must not
4685        // turn it into a list item on the second pass.
4686        let content = "# \n`?`\n<span>x</span>\n- X\n![t](u)\n29. y\n*z*";
4687        let once = fix_aligned_flavor(content, MarkdownFlavor::MkDocs);
4688        assert_eq!(
4689            fix_aligned_flavor(&once, MarkdownFlavor::MkDocs),
4690            once,
4691            "{content:?} -> {once:?}"
4692        );
4693    }
4694
4695    /// An exact re-indent that settles: fixing the result changes nothing.
4696    fn assert_fixed(content: &str, expected: &str) {
4697        assert_eq!(fix(content), expected, "{content:?}");
4698        assert_eq!(fix(expected), expected, "{content:?} is not idempotent");
4699    }
4700
4701    #[test]
4702    fn bare_marker_content_sits_one_column_past_the_marker() {
4703        for content in [
4704            "-\n  a\n",
4705            "+\n  a\n",
4706            "*\n  a\n  b\n",
4707            "1.\n   a\n",
4708            "10.\n    a\n",
4709            "1)\n   a\n",
4710            "- x\n\n  -\n    a\n",
4711            "-\n  foo\n-\n  ```\n  bar\n  ```\n-\n      baz\n",
4712            "-\n  a\n\n  b\n",
4713        ] {
4714            assert_untouched(content);
4715        }
4716    }
4717
4718    #[test]
4719    fn bare_marker_item_followed_by_a_fence_keeps_the_text_after_it() {
4720        // The fence is the first content, so the blank line after it no longer
4721        // ends the item, and `after` belongs to it.
4722        assert_untouched("-\n  ```\n  x\n  ```\n\n  after\n");
4723        assert_untouched("1.\n   ```\n   x\n   ```\n\n   after\n");
4724    }
4725
4726    #[test]
4727    fn bare_marker_over_indent_still_snaps_back() {
4728        assert_fixed("-\n    a\n", "-\n  a\n");
4729        assert_fixed("1.\n     a\n", "1.\n   a\n");
4730        // Four columns past the content column is indented code.
4731        assert_untouched("-\n      a\n");
4732    }
4733
4734    #[test]
4735    fn bare_marker_item_holds_at_most_one_blank_line_before_its_content() {
4736        // The item is empty, so whatever follows is outside it and is not
4737        // pulled in.
4738        for content in ["-\n\n  a\n", "-\n\n a\n", "1.\n\n   a\n", "-\n a\n"] {
4739            assert_untouched(content);
4740        }
4741        // Once the item has content, a blank line no longer closes it.
4742        assert_fixed("-\n  a\n\n b\n", "-\n  a\n\n  b\n");
4743    }
4744
4745    #[test]
4746    fn tab_after_the_marker_is_measured_in_columns() {
4747        assert_untouched("-\tx\n\n\ty\n");
4748        assert_untouched("1.\tx\n\n\ty\n");
4749        assert_untouched("-\tx\n\n    y\n");
4750        assert_fixed("-\tx\n\n  y\n", "-\tx\n\n    y\n");
4751        assert_fixed("-\tx\n\n\t y\n", "-\tx\n\n    y\n");
4752    }
4753
4754    #[test]
4755    fn indented_code_first_puts_the_content_one_column_past_the_marker() {
4756        assert_untouched("-     t\n\n  y\n");
4757        assert_untouched("-     t\n\n      y\n");
4758        assert_untouched("1.     t\n\n   y\n");
4759        assert_fixed("-     t\n\n y\n", "-     t\n\n  y\n");
4760        assert_fixed("1.     t\n\n  y\n", "1.     t\n\n   y\n");
4761    }
4762
4763    #[test]
4764    fn checkbox_text_in_indented_code_is_not_a_task() {
4765        // `[ ] task` is the code, so there is no post-checkbox column.
4766        assert_fixed("-     [ ] task\n\n     y\n", "-     [ ] task\n\n  y\n");
4767    }
4768
4769    #[test]
4770    fn items_written_on_one_line_each_own_their_continuation() {
4771        for content in [
4772            "- 1. x\n\n     y\n",
4773            "1. -\n     f\n",
4774            "- 1. ```\n     ```\n     b\n",
4775            "- - a\n    ---\n",
4776            "- - a\n\n    b\n",
4777            "- 1. x\n\n  y\n",
4778        ] {
4779            assert_untouched(content);
4780        }
4781        assert_fixed("- 1. x\n\n      y\n", "- 1. x\n\n     y\n");
4782    }
4783
4784    #[test]
4785    fn items_written_on_one_line_keep_their_task_columns() {
4786        for content in ["- - [ ] task\n        wrap\n", "- - [ ] task\n    wrap\n"] {
4787            assert_untouched(content);
4788        }
4789        assert_untouched("- [ ] t\n\n      w\n");
4790    }
4791
4792    #[test]
4793    fn an_empty_inner_item_closes_at_the_blank_line() {
4794        // The inner item has no content, so `a` belongs to the outer one.
4795        assert_fixed("- -\n\n    a\n", "- -\n\n  a\n");
4796        // Once the inner item has content, a blank line keeps it open.
4797        assert_untouched("- -\n    a\n\n    b\n");
4798    }
4799
4800    #[test]
4801    fn text_that_commonmark_does_not_treat_as_a_marker_or_blank_is_content() {
4802        // Only the outer item opens, so `y` is judged against column 2.
4803        assert_fixed(
4804            "- \u{661}\u{662}\u{663}\u{664}. x\n\n   y\n",
4805            "- \u{661}\u{662}\u{663}\u{664}. x\n\n  y\n",
4806        );
4807        assert_untouched("-  \u{a0}\n   a\n\n   y\n");
4808        assert_untouched("- -\n    \u{a0}\n\n    b\n");
4809    }
4810
4811    #[test]
4812    fn intended_reindents_still_happen() {
4813        assert_fixed("- e\n\n t\n", "- e\n\n  t\n");
4814        assert_fixed("1. a\n\n  b\n", "1. a\n\n   b\n");
4815        assert_fixed("- a\n\n    b\n", "- a\n\n  b\n");
4816        assert_eq!(fix_aligned("- a\n b\n"), "- a\n  b\n");
4817        assert_eq!(check("- e\n\n t\n").len(), 1);
4818    }
4819
4820    #[test]
4821    fn whitespace_at_the_start_of_a_line_inside_a_code_span_is_kept() {
4822        assert_untouched("- > para\n  `a\n  \tb`\n");
4823        assert_untouched("- x\n\n  `a\n      b`\n");
4824        // A span closed on the previous line does not shield the next line.
4825        assert_fixed("- x\n\n  `a`\n     b\n", "- x\n\n  `a`\n  b\n");
4826    }
4827}