Skip to main content

rumdl_lib/rules/
md077_list_continuation_indent.rs

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