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