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 required indent and owned line range so both
597        // passes below scope identically. The owned range ends at the line
598        // before the next sibling-or-higher item, or end of document.
599        let scoped: Vec<(usize, usize, usize, Option<usize>, usize, usize)> = items
600            .iter()
601            .enumerate()
602            .map(|(item_idx, &(item_line, marker_col, content_col, task_col))| {
603                let required = if strict_indent { content_col.max(4) } else { content_col };
604                let range_end = items
605                    .iter()
606                    .skip(item_idx + 1)
607                    .find(|&&(_, mc, _, _)| mc <= marker_col)
608                    .map_or(total_lines, |&(ln, _, _, _)| ln - 1);
609                (item_line, marker_col, content_col, task_col, required, range_end)
610            })
611            .collect();
612
613        // Pass 1 - under-indented continuation.
614        //
615        // Loose under-indent (after a blank line) sits below the content column
616        // and would escape the list item; it is always flagged. Tight
617        // under-indent is valid CommonMark lazy continuation, so it is left
618        // alone under `style = "any"`.
619        //
620        // Under `style = "aligned"`, tight under-indent is additionally flagged
621        // and snapped up to the content column. MD077 scopes past the parser's
622        // block end (to catch escaped loose content), and the parser absorbs
623        // col-0 blockquotes, fences, and tables that sit tight under a list item
624        // as lazy continuation. Reindenting those would change the rendered
625        // structure, so the tight-aligned branch guards two ways: it skips lines
626        // the parser classified in place (code, blockquote - see
627        // `confirmed_structure`), and it bails on an item whose continuation
628        // holds a construct the parser would re-parse once indentation changes
629        // (a latent list marker or table - see `has_latent_structure`). Headings
630        // and horizontal rules already terminate the walk; reference/footnote/
631        // abbreviation definitions are skipped inside it. Erring toward skipping
632        // yields false negatives (safe) rather than structural false positives.
633        //
634        // This pass runs first so that a deeply nested item claims an ambiguous
635        // line - one that is under-indented for it yet over-indented for a
636        // shallower ancestor - before pass 2 can mis-attribute it to the
637        // ancestor as an over-indent and snap it the wrong way.
638        let aligned = self.config.style == ContinuationStyle::Aligned;
639        for &(item_line, marker_col, _content_col, _task_col, required, range_end) in &scoped {
640            // "Latent structure": a line in this item's continuation that the
641            // parser has NOT yet promoted to a list item or table, but that
642            // would be once an earlier continuation line is reindented. Such a
643            // promotion re-attributes the following lines to a different block
644            // (different content column) and breaks single-pass idempotency, so
645            // reindenting anything in the item is unsafe and the whole item is
646            // skipped. This is a *prediction* of how the parser would re-parse
647            // after a fix, which is why it stays a heuristic (markers and tables
648            // are the only constructs the parser absorbs as lazy continuation
649            // rather than flagging on the line itself; code and blockquotes are
650            // classified in place and handled per line below). Well-formed prose
651            // continuation matches neither, so this only bails on pathological
652            // input (a safe false negative). Real nested items never reach the
653            // callback (the walk handles them via `saw_nested`).
654            let has_latent_structure = aligned && Self::item_range_has_latent_structure(ctx, item_line, range_end);
655            Self::walk_item_continuation(ctx, item_line, range_end, marker_col, |line| {
656                let actual = line.actual;
657                let under_indented = actual < required;
658                let loose_escape = line.saw_blank && under_indented;
659                // Don't reindent a line the parser classified in place as a code
660                // block (fenced or indented) or a blockquote: these are read
661                // straight from its per-line output, so the rule cannot drift
662                // from how rumdl parses the document. Headings and horizontal
663                // rules terminate the walk before the callback; latent list
664                // markers and tables are handled by `has_latent_structure`.
665                let confirmed_structure = line.info.in_code_block || line.info.blockquote.is_some();
666                let aligned_tight = aligned
667                    && !has_latent_structure
668                    && !line.saw_blank
669                    && !line.saw_nested
670                    && under_indented
671                    && !confirmed_structure;
672                if (loose_escape || aligned_tight) && flagged_lines.insert(line.line_num) {
673                    let message = if line.saw_blank {
674                        if strict_indent {
675                            format!(
676                                "Content inside list item needs {required} spaces of indentation \
677                                 for MkDocs compatibility (found {actual})",
678                            )
679                        } else {
680                            format!(
681                                "Content after blank line in list item needs {required} spaces of \
682                                 indentation to remain part of the list (found {actual})",
683                            )
684                        }
685                    } else {
686                        format!("Continuation line under-indented (expected {required}, found {actual})")
687                    };
688                    let outcome = Self::build_under_indent_warning(ctx, line, required, message);
689                    if let Some(closer_line) = outcome.also_flag_line {
690                        flagged_lines.insert(closer_line);
691                    }
692                    warnings.push(outcome.warning);
693                }
694                ControlFlow::Continue(())
695            });
696        }
697
698        // Pass 2 - over-indented continuation (tight or loose): prose pushed
699        // past the content column is snapped back. Fenced code blocks are
700        // skipped here (`!in_code_block`): an over-indented fence is cosmetic
701        // (the code still renders), and reindenting only its delimiters - the
702        // body is skipped by `should_skip_line` - would alter the literal code
703        // content. Indented code blocks (content column + 4 or more) are also
704        // `in_code_block`, so a blank line before such a body does not exempt
705        // it from being recognized as code rather than over-indented prose.
706        for &(item_line, marker_col, content_col, task_col, required, range_end) in &scoped {
707            // For task items, gather sibling-column usage once so the auto-fix
708            // can tie-break equidistant over-indents toward whichever valid
709            // column the author is already using.
710            let (uses_content_col, uses_task_col) = match task_col {
711                Some(t) => Self::sibling_column_usage(ctx, item_line, range_end, marker_col, content_col, t),
712                None => (false, false),
713            };
714
715            Self::walk_item_continuation(ctx, item_line, range_end, marker_col, |line| {
716                let actual = line.actual;
717                if actual > required
718                    && !line.info.in_code_block
719                    && Some(actual) != task_col
720                    && !Self::starts_with_list_marker(line.trimmed)
721                    && flagged_lines.insert(line.line_num)
722                {
723                    let fix_target =
724                        Self::compute_fix_target(actual, required, task_col, uses_content_col, uses_task_col);
725                    let message = match task_col {
726                        Some(t) => format!(
727                            "Continuation line over-indented \
728                             (expected {required} or {t}, found {actual})"
729                        ),
730                        None => {
731                            format!("Continuation line over-indented (expected {required}, found {actual})")
732                        }
733                    };
734                    warnings.push(Self::build_over_indent_warning(ctx, line, fix_target, message));
735                }
736                ControlFlow::Continue(())
737            });
738        }
739
740        // The two passes emit independently, so order by position before
741        // returning - callers and tests expect document order.
742        warnings.sort_by_key(|w| (w.line, w.column));
743
744        Ok(warnings)
745    }
746
747    fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
748        let warnings = self.check(ctx)?;
749        let warnings =
750            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
751        if warnings.is_empty() {
752            return Ok(ctx.content.to_string());
753        }
754
755        // Sort fixes by byte position descending to apply from end to start
756        let mut fixes: Vec<Fix> = warnings.into_iter().filter_map(|w| w.fix).collect();
757        fixes.sort_by_key(|f| std::cmp::Reverse(f.range.start));
758
759        let mut content = ctx.content.to_string();
760        for fix in fixes {
761            if fix.range.start <= content.len() && fix.range.end <= content.len() {
762                content.replace_range(fix.range, &fix.replacement);
763            }
764        }
765
766        Ok(content)
767    }
768
769    fn category(&self) -> RuleCategory {
770        RuleCategory::List
771    }
772
773    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
774        ctx.content.is_empty() || ctx.list_blocks.is_empty()
775    }
776
777    fn as_any(&self) -> &dyn std::any::Any {
778        self
779    }
780
781    crate::impl_rule_config_methods!(MD077Config);
782}
783
784#[cfg(test)]
785mod tests {
786    use super::*;
787    use crate::config::MarkdownFlavor;
788
789    fn check(content: &str) -> Vec<LintWarning> {
790        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
791        let rule = MD077ListContinuationIndent::default();
792        rule.check(&ctx).unwrap()
793    }
794
795    fn check_mkdocs(content: &str) -> Vec<LintWarning> {
796        let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
797        let rule = MD077ListContinuationIndent::default();
798        rule.check(&ctx).unwrap()
799    }
800
801    fn fix(content: &str) -> String {
802        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
803        let rule = MD077ListContinuationIndent::default();
804        rule.fix(&ctx).unwrap()
805    }
806
807    fn fix_mkdocs(content: &str) -> String {
808        let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
809        let rule = MD077ListContinuationIndent::default();
810        rule.fix(&ctx).unwrap()
811    }
812
813    fn aligned_rule() -> MD077ListContinuationIndent {
814        MD077ListContinuationIndent::new(ContinuationStyle::Aligned)
815    }
816
817    fn check_aligned(content: &str) -> Vec<LintWarning> {
818        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
819        aligned_rule().check(&ctx).unwrap()
820    }
821
822    fn check_aligned_mkdocs(content: &str) -> Vec<LintWarning> {
823        let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
824        aligned_rule().check(&ctx).unwrap()
825    }
826
827    fn fix_aligned(content: &str) -> String {
828        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
829        aligned_rule().fix(&ctx).unwrap()
830    }
831
832    fn fix_aligned_quarto(content: &str) -> String {
833        let ctx = LintContext::new(content, MarkdownFlavor::Quarto, None);
834        aligned_rule().fix(&ctx).unwrap()
835    }
836
837    #[test]
838    fn aligned_idempotent_with_latent_marker_behind_unstable_heading() {
839        // Regression: an empty backtick run (`` ``\n`` ``) is parsed as a setext
840        // heading. That heading used to terminate the latent-structure walk
841        // before it reached the `- ` marker on the next line, so the marker was
842        // not detected as latent. Reindenting the `1. ` continuation then
843        // promoted `- ` to a real list item, producing a fresh under-indent
844        // violation on the second pass and breaking single-pass idempotency.
845        // The latent-structure scan now spans the full owned range, detects the
846        // marker, and leaves the (ambiguous) item alone.
847        let input = "1. \n``\n``\n- \n``";
848        let once = fix_aligned_quarto(input);
849        let twice = fix_aligned_quarto(&once);
850        assert_eq!(once, twice, "MD077 aligned fix must be idempotent (Quarto)");
851    }
852
853    // ── Tight continuation (no blank line) ─────────────────────────────
854
855    #[test]
856    fn tight_lazy_continuation_zero_indent_not_flagged() {
857        // Zero-indent lazy continuation is valid CommonMark
858        let content = "- Item\ncontinuation\n";
859        assert!(check(content).is_empty());
860    }
861
862    #[test]
863    fn tight_continuation_correct_indent_not_flagged() {
864        // Correctly indented tight continuation (aligns with content column)
865        let content = "1. Item\n   continuation\n";
866        assert!(check(content).is_empty());
867    }
868
869    #[test]
870    fn tight_continuation_over_indented_ordered() {
871        // "1. " = 3 chars, but continuation has 4 spaces
872        let content = "1. This is a list item with multiple lines.\n    The second line is over-indented.\n";
873        let warnings = check(content);
874        assert_eq!(warnings.len(), 1);
875        assert_eq!(warnings[0].line, 2);
876        assert!(warnings[0].message.contains("over-indented"));
877    }
878
879    #[test]
880    fn tight_continuation_over_indented_unordered() {
881        // "- " = 2 chars, but continuation has 3 spaces
882        let content = "- Item\n   over-indented\n";
883        let warnings = check(content);
884        assert_eq!(warnings.len(), 1);
885        assert_eq!(warnings[0].line, 2);
886    }
887
888    #[test]
889    fn tight_continuation_multiple_over_indented_lines() {
890        let content = "1. Item\n    line one\n    line two\n    line three\n";
891        let warnings = check(content);
892        assert_eq!(warnings.len(), 3);
893    }
894
895    #[test]
896    fn tight_continuation_mixed_correct_and_over() {
897        let content = "1. Item\n   correct\n    over-indented\n   correct again\n";
898        let warnings = check(content);
899        assert_eq!(warnings.len(), 1);
900        assert_eq!(warnings[0].line, 3);
901    }
902
903    #[test]
904    fn tight_continuation_nested_over_indented() {
905        // L2 "- " at column 2, content_column = 4. Continuation at 5 is over-indented for L2.
906        let content = "- L1\n  - L2\n     over-indented continuation of L2\n";
907        let warnings = check(content);
908        assert_eq!(warnings.len(), 1);
909        assert_eq!(warnings[0].line, 3);
910        // Must report expected=4 (L2's content_col), not expected=2 (L1's)
911        assert!(warnings[0].message.contains("expected 4"));
912        assert!(warnings[0].message.contains("found 5"));
913    }
914
915    #[test]
916    fn tight_continuation_nested_correct_indent_not_flagged() {
917        // Continuation at 4 spaces is correct for L2 (content_col=4). Must NOT be
918        // flagged as over-indented relative to L1 (content_col=2).
919        let content = "- L1\n  - L2\n    correctly indented continuation of L2\n";
920        assert!(check(content).is_empty());
921    }
922
923    #[test]
924    fn fix_tight_continuation_nested_over_indented() {
925        // Fix should reduce to 4 spaces (L2's content_col), not 2 (L1's)
926        let content = "- L1\n  - L2\n     over-indented continuation of L2\n";
927        let fixed = fix(content);
928        assert_eq!(fixed, "- L1\n  - L2\n    over-indented continuation of L2\n");
929    }
930
931    #[test]
932    fn tight_continuation_under_indented_not_flagged() {
933        // 2 spaces instead of 3 for "1. " — under-indented, not over-indented.
934        // Valid lazy continuation in CommonMark, so not flagged.
935        let content = "1. Item\n  under-indented\n";
936        assert!(check(content).is_empty());
937    }
938
939    #[test]
940    fn tight_continuation_tab_over_indented() {
941        // A tab expands to 4 visual columns, which exceeds content_col=2 for "- "
942        let content = "- Item\n\tover-indented\n";
943        let warnings = check(content);
944        assert_eq!(warnings.len(), 1);
945    }
946
947    #[test]
948    fn fix_tight_continuation_over_indented_ordered() {
949        let content = "1. This is a list item with multiple lines.\n    The second line is over-indented.\n";
950        let fixed = fix(content);
951        assert_eq!(
952            fixed,
953            "1. This is a list item with multiple lines.\n   The second line is over-indented.\n"
954        );
955    }
956
957    #[test]
958    fn fix_tight_continuation_over_indented_unordered() {
959        let content = "- Item\n   over-indented\n";
960        let fixed = fix(content);
961        assert_eq!(fixed, "- Item\n  over-indented\n");
962    }
963
964    #[test]
965    fn fix_tight_continuation_multiple_lines() {
966        let content = "1. Item\n    line one\n    line two\n";
967        let fixed = fix(content);
968        assert_eq!(fixed, "1. Item\n   line one\n   line two\n");
969    }
970
971    #[test]
972    fn tight_continuation_mkdocs_4space_ordered_not_flagged() {
973        // MkDocs requires max(3, 4) = 4 spaces for "1. " items.
974        // 4-space tight continuation is correct, not over-indented.
975        let content = "1. Item\n    continuation\n";
976        assert!(check_mkdocs(content).is_empty());
977    }
978
979    #[test]
980    fn tight_continuation_mkdocs_5space_ordered_flagged() {
981        // 5 spaces exceeds the MkDocs required indent of 4
982        let content = "1. Item\n     over-indented\n";
983        let warnings = check_mkdocs(content);
984        assert_eq!(warnings.len(), 1);
985        assert!(warnings[0].message.contains("expected 4"));
986        assert!(warnings[0].message.contains("found 5"));
987    }
988
989    #[test]
990    fn fix_tight_continuation_mkdocs_over_indented() {
991        let content = "1. Item\n     over-indented\n";
992        let fixed = fix_mkdocs(content);
993        assert_eq!(fixed, "1. Item\n    over-indented\n");
994    }
995
996    #[test]
997    fn tight_continuation_deeply_indented_list_markers_not_flagged() {
998        // Deeply indented list markers (e.g., indent=8 in MD007) may not be
999        // recognized as list items by the parser. MD077 must not flag them.
1000        let content = "* Level 0\n        * Level 1\n                * Level 2\n";
1001        assert!(check(content).is_empty());
1002    }
1003
1004    #[test]
1005    fn tight_continuation_ordered_marker_not_flagged() {
1006        // Indented ordered list marker should not be flagged
1007        let content = "- Parent\n      1. Child item\n";
1008        assert!(check(content).is_empty());
1009    }
1010
1011    // ── Unordered list: correct indent after blank ────────────────────
1012
1013    #[test]
1014    fn unordered_correct_indent_no_warning() {
1015        let content = "- Item\n\n  continuation\n";
1016        assert!(check(content).is_empty());
1017    }
1018
1019    #[test]
1020    fn unordered_partial_indent_warns() {
1021        // Content with some indent (above marker column) but less than
1022        // content_column is likely an indentation mistake.
1023        let content = "- Item\n\n continuation\n";
1024        let warnings = check(content);
1025        assert_eq!(warnings.len(), 1);
1026        assert_eq!(warnings[0].line, 3);
1027        assert!(warnings[0].message.contains("2 spaces"));
1028        assert!(warnings[0].message.contains("found 1"));
1029    }
1030
1031    #[test]
1032    fn unordered_zero_indent_is_new_paragraph() {
1033        // Content at 0 indent after a top-level list is a new paragraph, not
1034        // under-indented continuation.
1035        let content = "- Item\n\ncontinuation\n";
1036        assert!(check(content).is_empty());
1037    }
1038
1039    // ── Ordered list: CommonMark W+N ──────────────────────────────────
1040
1041    #[test]
1042    fn ordered_3space_correct_commonmark() {
1043        // "1. " is 3 chars, content_column = 3
1044        let content = "1. Item\n\n   continuation\n";
1045        assert!(check(content).is_empty());
1046    }
1047
1048    #[test]
1049    fn ordered_2space_under_indent_commonmark() {
1050        let content = "1. Item\n\n  continuation\n";
1051        let warnings = check(content);
1052        assert_eq!(warnings.len(), 1);
1053        assert!(warnings[0].message.contains("3 spaces"));
1054        assert!(warnings[0].message.contains("found 2"));
1055    }
1056
1057    // ── Multi-digit ordered markers ───────────────────────────────────
1058
1059    #[test]
1060    fn multi_digit_marker_correct() {
1061        // "10. " is 4 chars, content_column = 4
1062        let content = "10. Item\n\n    continuation\n";
1063        assert!(check(content).is_empty());
1064    }
1065
1066    #[test]
1067    fn multi_digit_marker_under_indent() {
1068        let content = "10. Item\n\n   continuation\n";
1069        let warnings = check(content);
1070        assert_eq!(warnings.len(), 1);
1071        assert!(warnings[0].message.contains("4 spaces"));
1072    }
1073
1074    // ── MkDocs flavor: 4-space minimum ────────────────────────────────
1075
1076    #[test]
1077    fn mkdocs_3space_ordered_warns() {
1078        // In MkDocs mode, 3-space indent on "1. " is not enough
1079        let content = "1. Item\n\n   continuation\n";
1080        let warnings = check_mkdocs(content);
1081        assert_eq!(warnings.len(), 1);
1082        assert!(warnings[0].message.contains("4 spaces"));
1083        assert!(warnings[0].message.contains("MkDocs"));
1084    }
1085
1086    #[test]
1087    fn mkdocs_4space_ordered_no_warning() {
1088        let content = "1. Item\n\n    continuation\n";
1089        assert!(check_mkdocs(content).is_empty());
1090    }
1091
1092    #[test]
1093    fn mkdocs_unordered_2space_ok() {
1094        // Unordered "- " has content_column = 2; max(2, 4) = 4 in mkdocs
1095        let content = "- Item\n\n    continuation\n";
1096        assert!(check_mkdocs(content).is_empty());
1097    }
1098
1099    #[test]
1100    fn mkdocs_unordered_2space_warns() {
1101        // "- " has content_column 2; MkDocs requires max(2,4) = 4
1102        let content = "- Item\n\n  continuation\n";
1103        let warnings = check_mkdocs(content);
1104        assert_eq!(warnings.len(), 1);
1105        assert!(warnings[0].message.contains("4 spaces"));
1106    }
1107
1108    // ── Auto-fix ──────────────────────────────────────────────────────
1109
1110    #[test]
1111    fn fix_unordered_indent() {
1112        // Partial indent (above marker column, below content column) gets fixed
1113        let content = "- Item\n\n continuation\n";
1114        let fixed = fix(content);
1115        assert_eq!(fixed, "- Item\n\n  continuation\n");
1116    }
1117
1118    #[test]
1119    fn fix_ordered_indent() {
1120        let content = "1. Item\n\n continuation\n";
1121        let fixed = fix(content);
1122        assert_eq!(fixed, "1. Item\n\n   continuation\n");
1123    }
1124
1125    #[test]
1126    fn fix_mkdocs_indent() {
1127        let content = "1. Item\n\n   continuation\n";
1128        let fixed = fix_mkdocs(content);
1129        assert_eq!(fixed, "1. Item\n\n    continuation\n");
1130    }
1131
1132    // ── Nested lists: only flag continuation, not sub-items ───────────
1133
1134    #[test]
1135    fn nested_list_items_not_flagged() {
1136        let content = "- Parent\n\n  - Child\n";
1137        assert!(check(content).is_empty());
1138    }
1139
1140    #[test]
1141    fn nested_list_zero_indent_is_new_paragraph() {
1142        // Content at 0 indent ends the list, not continuation
1143        let content = "- Parent\n  - Child\n\ncontinuation of parent\n";
1144        assert!(check(content).is_empty());
1145    }
1146
1147    #[test]
1148    fn nested_list_partial_indent_flagged() {
1149        // Content with partial indent (above parent marker, below content col)
1150        let content = "- Parent\n  - Child\n\n continuation of parent\n";
1151        let warnings = check(content);
1152        assert_eq!(warnings.len(), 1);
1153        assert!(warnings[0].message.contains("2 spaces"));
1154    }
1155
1156    // ── Code blocks inside items ─────────────────────────────────────
1157
1158    #[test]
1159    fn code_block_correctly_indented_no_warning() {
1160        // Fence lines and content all at correct indent for "- " (content_column = 2)
1161        let content = "- Item\n\n  ```\n  code\n  ```\n";
1162        assert!(check(content).is_empty());
1163    }
1164
1165    #[test]
1166    fn code_fence_under_indented_warns() {
1167        // Fence opener has 1-space indent, but "- " needs 2.
1168        // Only the opener is flagged — its compound fix also covers the
1169        // interior content and the matching closer (see issue #574).
1170        let content = "- Item\n\n ```\n code\n ```\n";
1171        let warnings = check(content);
1172        assert_eq!(warnings.len(), 1);
1173        assert_eq!(warnings[0].line, 3);
1174    }
1175
1176    #[test]
1177    fn code_fence_under_indented_ordered_mkdocs() {
1178        // Ordered list in MkDocs: "1. " needs max(3, 4) = 4 spaces
1179        // Fence at 3 spaces is correct for CommonMark but wrong for MkDocs
1180        let content = "1. Item\n\n   ```toml\n   key = \"value\"\n   ```\n";
1181        assert!(check(content).is_empty()); // Standard mode: 3 is fine
1182        let warnings = check_mkdocs(content);
1183        assert_eq!(warnings.len(), 1); // MkDocs: opener's compound fix covers the whole block
1184        assert_eq!(warnings[0].line, 3);
1185        assert!(warnings[0].message.contains("4 spaces"));
1186        assert!(warnings[0].message.contains("MkDocs"));
1187    }
1188
1189    #[test]
1190    fn code_fence_tilde_under_indented() {
1191        let content = "- Item\n\n ~~~\n code\n ~~~\n";
1192        let warnings = check(content);
1193        assert_eq!(warnings.len(), 1); // Tilde fences: single compound-fix warning on opener
1194        assert_eq!(warnings[0].line, 3);
1195    }
1196
1197    // ── Multiple blank lines ──────────────────────────────────────────
1198
1199    #[test]
1200    fn multiple_blank_lines_zero_indent_is_new_paragraph() {
1201        // Even with multiple blanks, 0-indent content is a new paragraph
1202        let content = "- Item\n\n\ncontinuation\n";
1203        assert!(check(content).is_empty());
1204    }
1205
1206    #[test]
1207    fn multiple_blank_lines_partial_indent_flags() {
1208        let content = "- Item\n\n\n continuation\n";
1209        let warnings = check(content);
1210        assert_eq!(warnings.len(), 1);
1211    }
1212
1213    // ── Empty items: no continuation to check ─────────────────────────
1214
1215    #[test]
1216    fn empty_item_no_warning() {
1217        let content = "- \n- Second\n";
1218        assert!(check(content).is_empty());
1219    }
1220
1221    // ── Multiple items, only some under-indented ──────────────────────
1222
1223    #[test]
1224    fn multiple_items_mixed_indent() {
1225        let content = "1. First\n\n   correct continuation\n\n2. Second\n\n  wrong continuation\n";
1226        let warnings = check(content);
1227        assert_eq!(warnings.len(), 1);
1228        assert_eq!(warnings[0].line, 7);
1229    }
1230
1231    // ── Task list items ───────────────────────────────────────────────
1232
1233    #[test]
1234    fn task_list_correct_indent() {
1235        // "- [ ] " = content_column is typically at col 6
1236        let content = "- [ ] Task\n\n      continuation\n";
1237        assert!(check(content).is_empty());
1238    }
1239
1240    // ── Frontmatter skipped ───────────────────────────────────────────
1241
1242    #[test]
1243    fn frontmatter_not_flagged() {
1244        let content = "---\ntitle: test\n---\n\n- Item\n\n  continuation\n";
1245        assert!(check(content).is_empty());
1246    }
1247
1248    // ── Fix produces valid output with multiple fixes ─────────────────
1249
1250    #[test]
1251    fn fix_multiple_items() {
1252        let content = "1. First\n\n wrong1\n\n2. Second\n\n wrong2\n";
1253        let fixed = fix(content);
1254        assert_eq!(fixed, "1. First\n\n   wrong1\n\n2. Second\n\n   wrong2\n");
1255    }
1256
1257    #[test]
1258    fn fix_multiline_loose_continuation_all_lines() {
1259        let content = "1. Item\n\n  line one\n  line two\n  line three\n";
1260        let fixed = fix(content);
1261        assert_eq!(fixed, "1. Item\n\n   line one\n   line two\n   line three\n");
1262    }
1263
1264    // ── No false positive when content is after sibling item ──────────
1265
1266    #[test]
1267    fn sibling_item_boundary_respected() {
1268        // The "continuation" after a blank belongs to "- Second", not "- First"
1269        let content = "- First\n- Second\n\n  continuation\n";
1270        assert!(check(content).is_empty());
1271    }
1272
1273    // ── Blockquote-nested lists ────────────────────────────────────────
1274
1275    #[test]
1276    fn blockquote_list_correct_indent_no_warning() {
1277        // Lists inside blockquotes: visual_indent includes the blockquote
1278        // prefix, so comparisons work on raw line columns.
1279        let content = "> - Item\n>\n>   continuation\n";
1280        assert!(check(content).is_empty());
1281    }
1282
1283    #[test]
1284    fn blockquote_list_under_indent_no_false_positive() {
1285        // Under-indented continuation inside a blockquote: visual_indent
1286        // starts at 0 (the `>` char) which is <= marker_col, so the scan
1287        // breaks and no warning is emitted. This is a known false negative
1288        // (not a false positive), which is the safer default.
1289        let content = "> - Item\n>\n> continuation\n";
1290        assert!(check(content).is_empty());
1291    }
1292
1293    // ── Deep nesting (3+ levels) ──────────────────────────────────────
1294
1295    #[test]
1296    fn deeply_nested_correct_indent() {
1297        let content = "- L1\n  - L2\n    - L3\n\n      continuation of L3\n";
1298        assert!(check(content).is_empty());
1299    }
1300
1301    #[test]
1302    fn deeply_nested_under_indent() {
1303        // L3 starts at column 4 with "- " marker, content_column = 6
1304        // Continuation with 5 spaces is under-indented for L3.
1305        let content = "- L1\n  - L2\n    - L3\n\n     continuation of L3\n";
1306        let warnings = check(content);
1307        assert_eq!(warnings.len(), 1);
1308        assert!(warnings[0].message.contains("6 spaces"));
1309        assert!(warnings[0].message.contains("found 5"));
1310    }
1311
1312    // ── Tab indentation ───────────────────────────────────────────────
1313
1314    #[test]
1315    fn loose_tab_continuation_over_indented() {
1316        // A tab expands to 4 visual columns, exceeding content_column = 2 for
1317        // "- ". Loose over-indent is flagged just like the tight tab case
1318        // (`tight_continuation_tab_over_indented`), and the fix normalizes the
1319        // tab down to the content-column indent.
1320        let content = "- Item\n\n\tcontinuation\n";
1321        let warnings = check(content);
1322        assert_eq!(warnings.len(), 1);
1323        assert_eq!(warnings[0].line, 3);
1324        assert_eq!(fix(content), "- Item\n\n  continuation\n");
1325    }
1326
1327    // ── Multiple continuation paragraphs ──────────────────────────────
1328
1329    #[test]
1330    fn multiple_continuations_correct() {
1331        let content = "- Item\n\n  para 1\n\n  para 2\n\n  para 3\n";
1332        assert!(check(content).is_empty());
1333    }
1334
1335    #[test]
1336    fn multiple_continuations_second_under_indent() {
1337        // First continuation is correct, second is under-indented
1338        let content = "- Item\n\n  para 1\n\n continuation 2\n";
1339        let warnings = check(content);
1340        assert_eq!(warnings.len(), 1);
1341        assert_eq!(warnings[0].line, 5);
1342    }
1343
1344    // ── Ordered list with `)` marker style ────────────────────────────
1345
1346    #[test]
1347    fn ordered_paren_marker_correct() {
1348        // "1) " is 3 chars, content_column = 3
1349        let content = "1) Item\n\n   continuation\n";
1350        assert!(check(content).is_empty());
1351    }
1352
1353    #[test]
1354    fn ordered_paren_marker_under_indent() {
1355        let content = "1) Item\n\n  continuation\n";
1356        let warnings = check(content);
1357        assert_eq!(warnings.len(), 1);
1358        assert!(warnings[0].message.contains("3 spaces"));
1359    }
1360
1361    // ── Star and plus markers ─────────────────────────────────────────
1362
1363    #[test]
1364    fn star_marker_correct() {
1365        let content = "* Item\n\n  continuation\n";
1366        assert!(check(content).is_empty());
1367    }
1368
1369    #[test]
1370    fn star_marker_under_indent() {
1371        let content = "* Item\n\n continuation\n";
1372        let warnings = check(content);
1373        assert_eq!(warnings.len(), 1);
1374    }
1375
1376    #[test]
1377    fn plus_marker_correct() {
1378        let content = "+ Item\n\n  continuation\n";
1379        assert!(check(content).is_empty());
1380    }
1381
1382    // ── Heading breaks scan ───────────────────────────────────────────
1383
1384    #[test]
1385    fn heading_after_list_no_warning() {
1386        let content = "- Item\n\n# Heading\n";
1387        assert!(check(content).is_empty());
1388    }
1389
1390    // ── Horizontal rule breaks scan ───────────────────────────────────
1391
1392    #[test]
1393    fn hr_after_list_no_warning() {
1394        let content = "- Item\n\n---\n";
1395        assert!(check(content).is_empty());
1396    }
1397
1398    // ── Reference link definitions skip ───────────────────────────────
1399
1400    #[test]
1401    fn reference_link_def_not_flagged() {
1402        let content = "- Item\n\n [link]: https://example.com\n";
1403        assert!(check(content).is_empty());
1404    }
1405
1406    // ── Footnote definitions skip ─────────────────────────────────────
1407
1408    #[test]
1409    fn footnote_def_not_flagged() {
1410        let content = "- Item\n\n [^1]: footnote text\n";
1411        assert!(check(content).is_empty());
1412    }
1413
1414    #[test]
1415    fn footnote_multiline_body_after_list_not_flagged() {
1416        // A footnote definition whose indented body follows a list must not be
1417        // read as over-indented list continuation. The 4-space body lines
1418        // belong to the footnote, not to `- This is a list.` (content col 2).
1419        let content = "# A list followed by a footnote\n\n\
1420                       Here is a paragraph.[^fn]\n\n\
1421                       - This is a list.\n\n\
1422                       [^fn]:\n\
1423                       \x20\x20\x20\x20Here is a footnote that spans multiple lines.\n\
1424                       \x20\x20\x20\x20It should thus be indented by at least four spaces.\n";
1425        assert!(check(content).is_empty());
1426    }
1427
1428    #[test]
1429    fn fix_footnote_multiline_body_after_list_is_noop() {
1430        // The false positive also produced a damaging auto-fix: reindenting the
1431        // footnote body from 4 spaces to 2 broke the footnote (then tripped
1432        // MD068). The fix must leave the footnote untouched.
1433        let content = "# A list followed by a footnote\n\n\
1434                       Here is a paragraph.[^fn]\n\n\
1435                       - This is a list.\n\n\
1436                       [^fn]:\n\
1437                       \x20\x20\x20\x20Here is a footnote that spans multiple lines.\n\
1438                       \x20\x20\x20\x20It should thus be indented by at least four spaces.\n";
1439        assert_eq!(fix(content), content);
1440    }
1441
1442    #[test]
1443    fn footnote_body_indented_past_list_content_col_not_flagged() {
1444        // Footnote body indented well past the list's content column (here a
1445        // single-space list marker would put content col at 2, body at 4) is
1446        // footnote content, not over-indented continuation.
1447        let content = "- Item\n\n[^fn]:\n    Body line one.\n    Body line two.\n";
1448        assert!(check(content).is_empty());
1449    }
1450
1451    #[test]
1452    fn list_inside_footnote_body_continuation_not_flagged() {
1453        // Accepted trade-off: a list living *inside* a footnote body is skipped
1454        // wholesale, so MD077 does not police its continuation indentation.
1455        // Consistent with MD007, which also skips footnote-internal content.
1456        let content = "Text.[^fn]\n\n[^fn]:\n\
1457                       \x20\x20\x20\x20- nested item\n\
1458                       \x20\x20\x20\x20\x20\x20\x20over-indented continuation\n";
1459        assert!(check(content).is_empty());
1460    }
1461
1462    #[test]
1463    fn footnote_multiline_body_after_list_not_flagged_mkdocs() {
1464        // Same protection under the MkDocs flavor. The body sits at 6 spaces,
1465        // past MkDocs's required indent of 4 for `- `, so without skipping
1466        // footnote content it would be flagged as over-indented continuation.
1467        let content = "Here is a paragraph.[^fn]\n\n\
1468                       - This is a list.\n\n\
1469                       [^fn]:\n\
1470                       \x20\x20\x20\x20\x20\x20Footnote body that spans\n\
1471                       \x20\x20\x20\x20\x20\x20multiple indented lines.\n";
1472        assert!(check_mkdocs(content).is_empty());
1473    }
1474
1475    // ── Fix preserves correct content ─────────────────────────────────
1476
1477    #[test]
1478    fn fix_deeply_nested() {
1479        let content = "- L1\n  - L2\n    - L3\n\n     under-indented\n";
1480        let fixed = fix(content);
1481        assert_eq!(fixed, "- L1\n  - L2\n    - L3\n\n      under-indented\n");
1482    }
1483
1484    #[test]
1485    fn fix_mkdocs_unordered() {
1486        // MkDocs: "- " has content_column 2, but MkDocs requires max(2,4) = 4
1487        let content = "- Item\n\n  continuation\n";
1488        let fixed = fix_mkdocs(content);
1489        assert_eq!(fixed, "- Item\n\n    continuation\n");
1490    }
1491
1492    #[test]
1493    fn fix_code_fence_indent() {
1494        // Fence opener, interior, and closer all shift by the same delta so
1495        // the parser keeps pairing the fences and MD031 doesn't misfire.
1496        let content = "- Item\n\n ```\n code\n ```\n";
1497        let fixed = fix(content);
1498        assert_eq!(fixed, "- Item\n\n  ```\n  code\n  ```\n");
1499    }
1500
1501    #[test]
1502    fn fix_mkdocs_code_fence_indent() {
1503        // MkDocs ordered list: fence at 3 spaces needs 4; interior shifts too
1504        let content = "1. Item\n\n   ```toml\n   key = \"val\"\n   ```\n";
1505        let fixed = fix_mkdocs(content);
1506        assert_eq!(fixed, "1. Item\n\n    ```toml\n    key = \"val\"\n    ```\n");
1507    }
1508
1509    // ── Empty document / whitespace-only ──────────────────────────────
1510
1511    #[test]
1512    fn empty_document_no_warning() {
1513        assert!(check("").is_empty());
1514    }
1515
1516    #[test]
1517    fn whitespace_only_no_warning() {
1518        assert!(check("   \n\n  \n").is_empty());
1519    }
1520
1521    // ── No list at all ────────────────────────────────────────────────
1522
1523    #[test]
1524    fn no_list_no_warning() {
1525        let content = "# Heading\n\nSome paragraph.\n\nAnother paragraph.\n";
1526        assert!(check(content).is_empty());
1527    }
1528
1529    // ── Multi-line continuation (additional coverage) ──────────────
1530
1531    #[test]
1532    fn multiline_continuation_all_lines_flagged() {
1533        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";
1534        let warnings = check(content);
1535        assert_eq!(warnings.len(), 3);
1536        assert_eq!(warnings[0].line, 3);
1537        assert_eq!(warnings[1].line, 4);
1538        assert_eq!(warnings[2].line, 5);
1539    }
1540
1541    #[test]
1542    fn multiline_continuation_with_frontmatter_fix() {
1543        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";
1544        let fixed = fix(content);
1545        assert_eq!(
1546            fixed,
1547            "---\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"
1548        );
1549    }
1550
1551    #[test]
1552    fn multiline_continuation_correct_indent_no_warning() {
1553        let content = "1. Item\n\n   line one\n   line two\n   line three\n";
1554        assert!(check(content).is_empty());
1555    }
1556
1557    #[test]
1558    fn multiline_continuation_mixed_indent() {
1559        let content = "1. Item\n\n   correct\n  wrong\n   correct\n";
1560        let warnings = check(content);
1561        assert_eq!(warnings.len(), 1);
1562        assert_eq!(warnings[0].line, 4);
1563    }
1564
1565    #[test]
1566    fn multiline_continuation_unordered() {
1567        let content = "- Item\n\n continuation 1\n continuation 2\n continuation 3\n";
1568        let warnings = check(content);
1569        assert_eq!(warnings.len(), 3);
1570        let fixed = fix(content);
1571        assert_eq!(
1572            fixed,
1573            "- Item\n\n  continuation 1\n  continuation 2\n  continuation 3\n"
1574        );
1575    }
1576
1577    #[test]
1578    fn multiline_continuation_two_items_fix() {
1579        let content = "1. First\n\n  cont a\n  cont b\n\n2. Second\n\n  cont c\n  cont d\n";
1580        let fixed = fix(content);
1581        assert_eq!(
1582            fixed,
1583            "1. First\n\n   cont a\n   cont b\n\n2. Second\n\n   cont c\n   cont d\n"
1584        );
1585    }
1586
1587    #[test]
1588    fn fence_fix_does_not_break_pairing_for_md031() {
1589        // Regression for issue #574: previously MD077 only reindented the
1590        // fence delimiter lines while leaving the code block's interior at
1591        // the old indent. Between iterations of the fix loop the parser
1592        // saw an opener-closer mismatch, and MD031 then injected stray
1593        // blank lines at the fence boundaries. MD077's compound fix must
1594        // now rewrite the whole block atomically so the fences stay paired.
1595        let content = "#### title\n\nabc\n\n\
1596                       1. ab\n\n\
1597                       \x20\x20`aabbccdd`\n\n\
1598                       2. cd\n\n\
1599                       \x20\x20`bbcc dd ee`\n\n\
1600                       \x20\x20```\n\
1601                       \x20\x20abcd\n\
1602                       \x20\x20ef gh\n\
1603                       \x20\x20```\n\n\
1604                       \x20\x20uu\n\n\
1605                       \x20\x20```\n\
1606                       \x20\x20cdef\n\
1607                       \x20\x20gh ij\n\
1608                       \x20\x20```\n";
1609        let expected = "#### title\n\nabc\n\n\
1610                        1. ab\n\n\
1611                        \x20\x20\x20`aabbccdd`\n\n\
1612                        2. cd\n\n\
1613                        \x20\x20\x20`bbcc dd ee`\n\n\
1614                        \x20\x20\x20```\n\
1615                        \x20\x20\x20abcd\n\
1616                        \x20\x20\x20ef gh\n\
1617                        \x20\x20\x20```\n\n\
1618                        \x20\x20\x20uu\n\n\
1619                        \x20\x20\x20```\n\
1620                        \x20\x20\x20cdef\n\
1621                        \x20\x20\x20gh ij\n\
1622                        \x20\x20\x20```\n";
1623        assert_eq!(fix(content), expected);
1624    }
1625
1626    #[test]
1627    fn multiline_continuation_separated_by_blank() {
1628        let content = "1. Item\n\n  para1 line1\n  para1 line2\n\n  para2 line1\n  para2 line2\n";
1629        let warnings = check(content);
1630        assert_eq!(warnings.len(), 4);
1631        let fixed = fix(content);
1632        assert_eq!(
1633            fixed,
1634            "1. Item\n\n   para1 line1\n   para1 line2\n\n   para2 line1\n   para2 line2\n"
1635        );
1636    }
1637
1638    #[test]
1639    fn tab_indented_fence_is_normalized_to_spaces() {
1640        // Leading tabs expand to the next multiple-of-4 column under
1641        // CommonMark, so simply prepending spaces before a tab would
1642        // silently no-op (the tab snaps back to column 4). The compound
1643        // fence fix must replace the leading whitespace with a fresh
1644        // (visual_indent + delta) run of spaces. A `100. ` item has
1645        // content_column = 5, so a tab-indented fence (visual col 4) is
1646        // under-indented by 1 and must end up at 5 spaces after the fix.
1647        let content = "100. ab\n\n\t```\n\tabcd\n\t```\n";
1648        let expected = "100. ab\n\n     ```\n     abcd\n     ```\n";
1649        assert_eq!(fix(content), expected);
1650    }
1651
1652    // ── Loose continuation (after a blank line): over-indent ──────────
1653    //
1654    // Over-indentation is a mistake in both tight and loose continuation:
1655    // the body looks aligned but isn't. A blank line between the marker and
1656    // the body must not exempt it. The only over-indent that is intentional
1657    // after a blank line is an indented code block (content column + 4 or
1658    // more), which the parser marks `in_code_block` and the rule skips.
1659
1660    #[test]
1661    fn loose_continuation_over_indented_flagged() {
1662        // "* " content column is 2; 3 spaces after a blank is over-indented
1663        // (the code-block threshold is content_col + 4 = 6).
1664        let content = "* Item\n\n   over-indented\n";
1665        let warnings = check(content);
1666        assert_eq!(warnings.len(), 1);
1667        assert_eq!(warnings[0].line, 3);
1668        assert!(warnings[0].message.contains("over-indented"));
1669        assert!(warnings[0].message.contains("expected 2"));
1670        assert!(warnings[0].message.contains("found 3"));
1671    }
1672
1673    #[test]
1674    fn loose_continuation_over_indented_multiline_mixed() {
1675        // Over, correct, over — only the two over-indented lines are flagged.
1676        let content = "* Item\n\n   over one\n  correct\n   over two\n";
1677        let warnings = check(content);
1678        assert_eq!(warnings.len(), 2);
1679        assert_eq!(warnings[0].line, 3);
1680        assert_eq!(warnings[1].line, 5);
1681    }
1682
1683    #[test]
1684    fn fix_loose_continuation_over_indented() {
1685        let content = "* Item\n\n   over one\n  correct\n   over two\n";
1686        let fixed = fix(content);
1687        assert_eq!(fixed, "* Item\n\n  over one\n  correct\n  over two\n");
1688    }
1689
1690    #[test]
1691    fn fix_tight_and_loose_items_normalized_identically() {
1692        // The reported document: a tight item and a loose item with the same
1693        // over-indented body must both normalize to the content column.
1694        let content = "---\ntitle: Heading\n---\n\nSome introductory text:\n\n\
1695                       * 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\
1696                       * 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";
1697        let expected = "---\ntitle: Heading\n---\n\nSome introductory text:\n\n\
1698                        * 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\
1699                        * 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";
1700        assert_eq!(fix(content), expected);
1701    }
1702
1703    #[test]
1704    fn multi_paragraph_item_loose_paragraph_over_indented() {
1705        // A tight first paragraph and a loose second paragraph (after an
1706        // internal blank line) are both over-indented; both must be flagged.
1707        let content = "* Item.\n   tight over\n\n   loose over\n";
1708        let warnings = check(content);
1709        assert_eq!(warnings.len(), 2);
1710        assert_eq!(warnings[0].line, 2);
1711        assert_eq!(warnings[1].line, 4);
1712    }
1713
1714    #[test]
1715    fn loose_indented_code_block_not_flagged() {
1716        // content_col = 2; a loose line at content_col + 4 (6 spaces) is a
1717        // CommonMark indented code block, not over-indented prose. The over-
1718        // indent check must never reach it (it is `in_code_block` and skipped).
1719        let content = "- Item\n\n      code line\n";
1720        assert!(check(content).is_empty());
1721    }
1722
1723    #[test]
1724    fn mkdocs_loose_over_indented_flagged() {
1725        // MkDocs requires max(3, 4) = 4 for "1. ". A loose line at 5 spaces is
1726        // over-indented (code-block threshold is content_col + 4 = 7).
1727        let content = "1. Item\n\n     over\n";
1728        let warnings = check_mkdocs(content);
1729        assert_eq!(warnings.len(), 1);
1730        assert_eq!(warnings[0].line, 3);
1731        assert!(warnings[0].message.contains("over-indented"));
1732        assert!(warnings[0].message.contains("expected 4"));
1733        assert!(warnings[0].message.contains("found 5"));
1734    }
1735
1736    #[test]
1737    fn task_list_loose_over_indented_flagged() {
1738        // "- [ ] " content_col = 2, task_col = 6. A loose line at 4 spaces is
1739        // neither valid column and below the code-block threshold (6); flagged.
1740        let content = "- [ ] Task\n\n    over\n";
1741        let warnings = check(content);
1742        assert_eq!(warnings.len(), 1);
1743        assert_eq!(warnings[0].line, 3);
1744    }
1745
1746    #[test]
1747    fn loose_over_indent_boundary_below_code_block_threshold_flagged() {
1748        // content_col = 2; 5 spaces (= content_col + 3) is the deepest loose
1749        // over-indent that is still prose. content_col + 4 (6 spaces) would be
1750        // an indented code block - see `loose_indented_code_block_not_flagged`.
1751        // This pins the boundary so a shift in the parser's threshold is caught.
1752        let content = "- Item\n\n     over\n";
1753        let warnings = check(content);
1754        assert_eq!(warnings.len(), 1);
1755        assert_eq!(warnings[0].line, 3);
1756        assert!(warnings[0].message.contains("expected 2"));
1757        assert!(warnings[0].message.contains("found 5"));
1758    }
1759
1760    #[test]
1761    fn loose_over_indent_does_not_steal_nested_under_indent() {
1762        // Inner content_col = 4, marker_col = 2. A loose continuation at column
1763        // 3 is under-indented for Inner yet over-indented for Outer (content_col
1764        // 2). The under-indent pass must claim it for Inner (snap *up* to 4,
1765        // preserving the apparent nesting), never letting the over-indent pass
1766        // mis-attribute it to Outer and snap it *down* to 2. This is the exact
1767        // ambiguity the two-pass ordering exists to resolve.
1768        let content = "- Outer\n  - Inner\n\n   continuation\n";
1769        let warnings = check(content);
1770        assert_eq!(warnings.len(), 1);
1771        assert_eq!(warnings[0].line, 4);
1772        assert!(warnings[0].message.contains("4 spaces"));
1773        assert!(warnings[0].message.contains("found 3"));
1774        assert_eq!(fix(content), "- Outer\n  - Inner\n\n    continuation\n");
1775    }
1776
1777    #[test]
1778    fn loose_over_indent_attributes_to_deepest_enclosing_item() {
1779        // Inner content_col = 4. A loose continuation at column 5 over-indents
1780        // Inner (the deepest item it sits within), so it is flagged against
1781        // Inner's column 4 - not Outer's column 2 - and snapped to 4.
1782        let content = "- Outer\n  - Inner\n\n     continuation\n";
1783        let warnings = check(content);
1784        assert_eq!(warnings.len(), 1);
1785        assert_eq!(warnings[0].line, 4);
1786        assert!(warnings[0].message.contains("expected 4"));
1787        assert!(warnings[0].message.contains("found 5"));
1788        assert_eq!(fix(content), "- Outer\n  - Inner\n\n    continuation\n");
1789    }
1790
1791    // ── Over-indented fenced code blocks are left untouched ───────────
1792    //
1793    // An over-indented fence is cosmetic: the code still renders inside the
1794    // list item. Reindenting only its delimiters (the body is skipped as code)
1795    // would change the literal code content, so the over-indent pass skips
1796    // anything `in_code_block`. The under-indent path still fixes fences, where
1797    // moving the block up is required to keep it inside the item.
1798
1799    #[test]
1800    fn loose_over_indented_fence_not_flagged() {
1801        let content = "- Item\n\n   ```\n   code\n   ```\n";
1802        assert!(check(content).is_empty());
1803        assert_eq!(fix(content), content);
1804    }
1805
1806    #[test]
1807    fn tight_over_indented_fence_not_flagged() {
1808        let content = "- Item\n   ```\n   code\n   ```\n";
1809        assert!(check(content).is_empty());
1810        assert_eq!(fix(content), content);
1811    }
1812
1813    #[test]
1814    fn over_indented_tilde_fence_not_flagged() {
1815        let content = "- Item\n\n   ~~~\n   code\n   ~~~\n";
1816        assert!(check(content).is_empty());
1817        assert_eq!(fix(content), content);
1818    }
1819
1820    #[test]
1821    fn fence_like_code_content_inside_fenced_block_not_flagged() {
1822        // A ``` line that is the *body* of a ~~~ block must not be treated as
1823        // over-indented continuation; rewriting it would corrupt code content.
1824        let content = "- Item\n\n  ~~~\n   ```\n  ~~~\n";
1825        assert!(check(content).is_empty());
1826        assert_eq!(fix(content), content);
1827    }
1828
1829    #[test]
1830    fn unterminated_over_indented_fence_not_flagged() {
1831        // No closing fence: the last code line must not be mistaken for a
1832        // closer and snapped to the content column.
1833        let content = "- Item\n\n   ```\n   code1\n     code2deeper\n";
1834        assert!(check(content).is_empty());
1835        assert_eq!(fix(content), content);
1836    }
1837
1838    // ── GFM task list items: post-checkbox continuation column ───────
1839    //
1840    // MD013's reflow indents wrapped task-list lines at `content_col + 4`
1841    // (the column after the checkbox). MD077 must accept that column for
1842    // both tight and loose continuation, for every marker flavour, so the
1843    // two rules don't fight over well-formed task items (issue #579).
1844
1845    #[test]
1846    fn task_list_tight_continuation_post_checkbox_reproducer_579() {
1847        // Exact reproducer from the bug report: content wraps to the
1848        // post-checkbox column (6) with no blank line.
1849        let content = "- [ ] Lorem ipsum dolor sit amet, consectetur adipiscing\n      tempor incididunt ut labore.\n";
1850        assert!(check(content).is_empty());
1851    }
1852
1853    #[test]
1854    fn task_list_tight_continuation_dash_unchecked() {
1855        let content = "- [ ] Task\n      continuation\n";
1856        assert!(check(content).is_empty());
1857    }
1858
1859    #[test]
1860    fn task_list_tight_continuation_dash_checked_lower() {
1861        let content = "- [x] Task\n      continuation\n";
1862        assert!(check(content).is_empty());
1863    }
1864
1865    #[test]
1866    fn task_list_tight_continuation_dash_checked_upper() {
1867        let content = "- [X] Task\n      continuation\n";
1868        assert!(check(content).is_empty());
1869    }
1870
1871    #[test]
1872    fn task_list_tight_continuation_star_marker() {
1873        let content = "* [ ] Task\n      continuation\n";
1874        assert!(check(content).is_empty());
1875    }
1876
1877    #[test]
1878    fn task_list_tight_continuation_plus_marker() {
1879        let content = "+ [ ] Task\n      continuation\n";
1880        assert!(check(content).is_empty());
1881    }
1882
1883    #[test]
1884    fn task_list_tight_continuation_content_column_still_valid() {
1885        // Column 2 is the CommonMark-canonical indent for "- " and remains
1886        // valid for task items too.
1887        let content = "- [ ] Task\n  continuation\n";
1888        assert!(check(content).is_empty());
1889    }
1890
1891    #[test]
1892    fn task_list_tight_continuation_between_columns_still_flagged() {
1893        // Column 4 matches neither content_col (2) nor post-checkbox (6).
1894        // A genuine indentation mistake — must remain flagged.
1895        let content = "- [ ] Task\n    continuation\n";
1896        let warnings = check(content);
1897        assert_eq!(warnings.len(), 1);
1898        // Task items advertise both valid columns to the user.
1899        assert!(warnings[0].message.contains("expected 2 or 6"));
1900        assert!(warnings[0].message.contains("found 4"));
1901    }
1902
1903    #[test]
1904    fn task_list_tight_continuation_overshoot_still_flagged() {
1905        // Column 7 overshoots the post-checkbox column. Genuine mistake.
1906        let content = "- [ ] Task\n       continuation\n";
1907        let warnings = check(content);
1908        assert_eq!(warnings.len(), 1);
1909        assert!(warnings[0].message.contains("expected 2 or 6"));
1910        assert!(warnings[0].message.contains("found 7"));
1911    }
1912
1913    // ── Task-list fix output: snap to nearer valid column ────────────
1914
1915    #[test]
1916    fn fix_task_list_overshoot_snaps_to_task_col() {
1917        // Col 7 is 1 away from post-checkbox (6), 5 away from content (2).
1918        // Snap to 6 — the author's intent was almost certainly the
1919        // post-checkbox alignment, not the content column.
1920        let content = "- [ ] Task\n       continuation\n";
1921        let fixed = fix(content);
1922        assert_eq!(fixed, "- [ ] Task\n      continuation\n");
1923    }
1924
1925    #[test]
1926    fn fix_task_list_col_5_snaps_to_task_col() {
1927        // Col 5 is 1 away from post-checkbox (6), 3 away from content (2).
1928        let content = "- [ ] Task\n     continuation\n";
1929        let fixed = fix(content);
1930        assert_eq!(fixed, "- [ ] Task\n      continuation\n");
1931    }
1932
1933    #[test]
1934    fn fix_task_list_col_3_snaps_to_content_col() {
1935        // Col 3 is 1 away from content (2), 3 away from post-checkbox (6).
1936        let content = "- [ ] Task\n   continuation\n";
1937        let fixed = fix(content);
1938        assert_eq!(fixed, "- [ ] Task\n  continuation\n");
1939    }
1940
1941    #[test]
1942    fn fix_task_list_col_4_ties_to_content_col() {
1943        // Col 4 is equidistant (±2) from both columns. Tie breaks to the
1944        // CommonMark-canonical content column — that's the default indent
1945        // MD077 would produce for a non-task item, so prefer it when the
1946        // author's intent is ambiguous.
1947        let content = "- [ ] Task\n    continuation\n";
1948        let fixed = fix(content);
1949        assert_eq!(fixed, "- [ ] Task\n  continuation\n");
1950    }
1951
1952    #[test]
1953    fn fix_task_list_ordered_overshoot_snaps_to_task_col() {
1954        // "1. [ ] " → content_col = 3, post-checkbox = 7.
1955        // Col 8 is nearer to 7.
1956        let content = "1. [ ] Task\n        continuation\n";
1957        let fixed = fix(content);
1958        assert_eq!(fixed, "1. [ ] Task\n       continuation\n");
1959    }
1960
1961    #[test]
1962    fn fix_task_list_ordered_under_overshoot_snaps_to_content_col() {
1963        // "1. [ ] " → content_col = 3, post-checkbox = 7.
1964        // Col 4 is nearer to 3.
1965        let content = "1. [ ] Task\n    continuation\n";
1966        let fixed = fix(content);
1967        assert_eq!(fixed, "1. [ ] Task\n   continuation\n");
1968    }
1969
1970    #[test]
1971    fn task_list_tight_continuation_ordered_single_digit() {
1972        // "1. [ ] " → content_col = 3, post-checkbox = 7
1973        let content = "1. [ ] Task\n       continuation\n";
1974        assert!(check(content).is_empty());
1975    }
1976
1977    #[test]
1978    fn task_list_tight_continuation_ordered_multi_digit() {
1979        // "10. [ ] " → content_col = 4, post-checkbox = 8
1980        let content = "10. [ ] Task\n        continuation\n";
1981        assert!(check(content).is_empty());
1982    }
1983
1984    #[test]
1985    fn task_list_tight_continuation_nested_dash() {
1986        // Nested "  - [ ] " at marker_col=2 → content_col=4, post-checkbox=8
1987        let content = "- Parent\n  - [ ] Nested task\n        continuation\n";
1988        assert!(check(content).is_empty());
1989    }
1990
1991    #[test]
1992    fn task_list_loose_continuation_post_checkbox_column_not_flagged() {
1993        // Loose continuation (blank line) at col 6 is also valid. This
1994        // already passed before the fix, but pin the intent: the 6-space
1995        // indent is accepted because it's the task-alignment column, not
1996        // because the under-indent check happens to let it through.
1997        let content = "- [ ] Task\n\n      continuation\n";
1998        assert!(check(content).is_empty());
1999    }
2000
2001    #[test]
2002    fn task_list_empty_body_is_not_a_task() {
2003        // "- [ ]" with nothing after is an empty regular list item, not a
2004        // task. Column 4 continuation has no task alignment to justify it
2005        // and must still be flagged as over-indented. (Col 6 would turn
2006        // the continuation into an indented code block inside the item,
2007        // which is a different code path.)
2008        let content = "- [ ]\n    continuation\n";
2009        let warnings = check(content);
2010        assert_eq!(warnings.len(), 1);
2011        assert!(warnings[0].message.contains("found 4"));
2012    }
2013
2014    #[test]
2015    fn task_list_malformed_checkbox_is_not_a_task() {
2016        // `[~] ` is not a GFM checkbox; only `[ ] `, `[x] `, `[X] ` count.
2017        let content = "- [~] Not a task\n      continuation\n";
2018        let warnings = check(content);
2019        assert_eq!(warnings.len(), 1);
2020    }
2021
2022    // ── MkDocs flavor × task checkbox ─────────────────────────────────
2023    //
2024    // MkDocs strict-indent and task alignment interact: required_min is
2025    // max(content_col, 4), and post-checkbox is content_col + 4. Both are
2026    // independently valid; values between them are flagged.
2027
2028    #[test]
2029    fn task_list_mkdocs_unordered_required_min_valid() {
2030        // "- [ ]" MkDocs: required_min = max(2, 4) = 4, post-checkbox = 6.
2031        let content = "- [ ] Task\n    continuation\n";
2032        assert!(check_mkdocs(content).is_empty());
2033    }
2034
2035    #[test]
2036    fn task_list_mkdocs_unordered_post_checkbox_valid() {
2037        let content = "- [ ] Task\n      continuation\n";
2038        assert!(check_mkdocs(content).is_empty());
2039    }
2040
2041    #[test]
2042    fn task_list_mkdocs_unordered_between_flagged() {
2043        // Column 5 is between required_min=4 and post-checkbox=6.
2044        let content = "- [ ] Task\n     continuation\n";
2045        let warnings = check_mkdocs(content);
2046        assert_eq!(warnings.len(), 1);
2047    }
2048
2049    #[test]
2050    fn task_list_mkdocs_ordered_both_columns_valid() {
2051        // "1. [ ]" MkDocs: required_min = max(3, 4) = 4, post-checkbox = 7.
2052        let at_4 = "1. [ ] Task\n    continuation\n";
2053        assert!(check_mkdocs(at_4).is_empty());
2054        let at_7 = "1. [ ] Task\n       continuation\n";
2055        assert!(check_mkdocs(at_7).is_empty());
2056    }
2057
2058    #[test]
2059    fn task_list_mkdocs_ordered_between_flagged() {
2060        // Column 5 and 6 are between required_min=4 and post-checkbox=7.
2061        let at_5 = "1. [ ] Task\n     continuation\n";
2062        assert_eq!(check_mkdocs(at_5).len(), 1);
2063        let at_6 = "1. [ ] Task\n      continuation\n";
2064        assert_eq!(check_mkdocs(at_6).len(), 1);
2065    }
2066
2067    // ── Context-aware tie-break ──────────────────────────────────────
2068    //
2069    // When a flagged line is exactly equidistant from `content_col` and
2070    // `task_col`, the author's intent is ambiguous. Before picking a
2071    // canonical default, look at whether other continuation lines in the
2072    // same item already use one of the valid columns — if so, snap to the
2073    // column they're using so the fix preserves the author's visible
2074    // convention.
2075
2076    #[test]
2077    fn fix_task_list_tie_sibling_at_task_col_snaps_to_task_col() {
2078        // Col 4 is equidistant from content_col (2) and task_col (6).
2079        // A valid sibling at col 6 proves the author is aligning under the
2080        // checkbox, so the tie resolves to col 6.
2081        let content = "- [ ] Task\n      aligned continuation\n    tied continuation\n";
2082        let fixed = fix(content);
2083        assert_eq!(
2084            fixed,
2085            "- [ ] Task\n      aligned continuation\n      tied continuation\n"
2086        );
2087    }
2088
2089    #[test]
2090    fn fix_task_list_tie_sibling_at_content_col_snaps_to_content_col() {
2091        // Valid sibling at col 2 proves the author is aligning to the
2092        // content column, so the col-4 tie resolves to col 2.
2093        let content = "- [ ] Task\n  aligned continuation\n    tied continuation\n";
2094        let fixed = fix(content);
2095        assert_eq!(fixed, "- [ ] Task\n  aligned continuation\n  tied continuation\n");
2096    }
2097
2098    #[test]
2099    fn fix_task_list_tie_both_siblings_snaps_to_content_col() {
2100        // When siblings exist at both valid columns, the author's pattern
2101        // is self-contradictory. Fall back to the CommonMark-canonical
2102        // content column.
2103        let content = "- [ ] Task\n  at content col\n      at task col\n    tied continuation\n";
2104        let fixed = fix(content);
2105        assert_eq!(
2106            fixed,
2107            "- [ ] Task\n  at content col\n      at task col\n  tied continuation\n"
2108        );
2109    }
2110
2111    #[test]
2112    fn fix_task_list_tie_sees_task_col_through_tight_lazy_continuation() {
2113        // CommonMark allows tight lazy continuation at col ≤ marker_col
2114        // (zero-indent continuation) inside a list item. The pre-pass
2115        // must MIRROR the main check loop's termination semantics: in
2116        // tight mode (no preceding blank) col ≤ marker_col is NOT a
2117        // termination signal — the lazy line still belongs to the item.
2118        //
2119        // This test pins that mirroring: a `lazy` line at col 0 is
2120        // followed by a legitimate task-col sibling at col 6, then a
2121        // tied col-4 line. If the pre-pass terminated eagerly at the
2122        // lazy line, the task-col sibling would be missed and the tied
2123        // line would fall back to content column. With correct
2124        // mirroring, the task-col sibling is seen and the tie resolves
2125        // to col 6.
2126        let content = concat!("- [ ] Task\n", "lazy\n", "      aligned at task col\n", "    tied\n",);
2127        let fixed = fix(content);
2128        assert!(
2129            fixed.contains("\n      tied\n"),
2130            "tied line should snap to col 6 (task col) because a task-col \
2131             sibling is visible past the tight lazy-continuation line; got:\n{fixed}"
2132        );
2133    }
2134
2135    // ── Tab-indented task continuation ───────────────────────────────
2136    //
2137    // Leading tabs expand to the next column that's a multiple of 4 under
2138    // CommonMark. The fix replaces the leading whitespace bytes wholesale,
2139    // turning tabs into space-indented output.
2140
2141    #[test]
2142    fn task_list_tab_indented_continuation_flagged() {
2143        // Two tabs → visual col 8, which overshoots both valid columns
2144        // for `- [ ] ` (content_col=2, task_col=6).
2145        let content = "- [ ] Task\n\t\twrap\n";
2146        let warnings = check(content);
2147        assert_eq!(warnings.len(), 1);
2148        assert!(warnings[0].message.contains("expected 2 or 6"));
2149        assert!(warnings[0].message.contains("found 8"));
2150    }
2151
2152    #[test]
2153    fn fix_task_list_tab_indented_snaps_to_task_col() {
2154        // abs_diff(8, 6) = 2 < abs_diff(8, 2) = 6 → snap to task_col (6).
2155        let content = "- [ ] Task\n\t\twrap\n";
2156        let fixed = fix(content);
2157        assert_eq!(fixed, "- [ ] Task\n      wrap\n");
2158    }
2159
2160    #[test]
2161    fn fix_task_list_single_tab_equidistant_snaps_to_content_col() {
2162        // One tab → visual col 4, equidistant from content_col (2) and
2163        // task_col (6). No siblings → tie-break to content_col.
2164        let content = "- [ ] Task\n\twrap\n";
2165        let fixed = fix(content);
2166        assert_eq!(fixed, "- [ ] Task\n  wrap\n");
2167    }
2168
2169    // ── Blockquote × task-list ───────────────────────────────────────
2170    //
2171    // Blockquote-nested lists are a known limitation on MD077: the list
2172    // parser doesn't always expose them with the same column semantics as
2173    // top-level lists, and the rule prefers a false-negative default to
2174    // avoid spurious warnings inside blockquotes (see
2175    // `blockquote_list_under_indent_no_false_positive`). These tests pin
2176    // the current behavior so any future change is intentional.
2177
2178    #[test]
2179    fn task_list_blockquote_post_checkbox_not_flagged() {
2180        // Post-checkbox alignment inside a blockquote — accepted as valid.
2181        let content = "> - [ ] Task\n>       continuation\n";
2182        assert!(check(content).is_empty());
2183    }
2184
2185    #[test]
2186    fn task_list_blockquote_between_cols_documented_limitation() {
2187        // Col-4-equivalent inside a blockquote is silently accepted — a
2188        // known MD077 limitation on blockquote-nested lists, not a task-
2189        // list-specific choice. Pinning the current behavior.
2190        let content = "> - [ ] Task\n>     continuation\n";
2191        assert!(check(content).is_empty());
2192    }
2193
2194    #[test]
2195    fn task_list_blockquote_overshoot_documented_limitation() {
2196        // Overshoot inside a blockquote — same known limitation.
2197        let content = "> - [ ] Task\n>        continuation\n";
2198        assert!(check(content).is_empty());
2199    }
2200
2201    // ── MkDocs × task × fix output ───────────────────────────────────
2202    //
2203    // MkDocs strict-indent raises `required` to max(content_col, 4) while
2204    // task_col stays at content_col + 4. The snap logic operates on the
2205    // raised required, not on the underlying content_col.
2206
2207    #[test]
2208    fn fix_task_list_mkdocs_unordered_overshoot_snaps_to_task_col() {
2209        // `- [ ]` MkDocs: required=4, task_col=6. Col 7 → abs_diff(7,6)=1
2210        // < abs_diff(7,4)=3. Snap to task_col.
2211        let content = "- [ ] Task\n       continuation\n";
2212        let fixed = fix_mkdocs(content);
2213        assert_eq!(fixed, "- [ ] Task\n      continuation\n");
2214    }
2215
2216    #[test]
2217    fn fix_task_list_mkdocs_unordered_tie_snaps_to_required() {
2218        // `- [ ]` MkDocs: required=4, task_col=6. Col 5 → abs_diff(5,6)=1
2219        // == abs_diff(5,4)=1. Tie with no siblings → required (4).
2220        let content = "- [ ] Task\n     continuation\n";
2221        let fixed = fix_mkdocs(content);
2222        assert_eq!(fixed, "- [ ] Task\n    continuation\n");
2223    }
2224
2225    #[test]
2226    fn fix_task_list_mkdocs_ordered_overshoot_snaps_to_task_col() {
2227        // `1. [ ]` MkDocs: required=4, task_col=7. Col 8 → abs_diff(8,7)=1
2228        // < abs_diff(8,4)=4. Snap to task_col.
2229        let content = "1. [ ] Task\n        continuation\n";
2230        let fixed = fix_mkdocs(content);
2231        assert_eq!(fixed, "1. [ ] Task\n       continuation\n");
2232    }
2233
2234    #[test]
2235    fn fix_task_list_mkdocs_ordered_near_required_snaps_to_required() {
2236        // `1. [ ]` MkDocs: required=4, task_col=7. Col 5 → abs_diff(5,7)=2
2237        // > abs_diff(5,4)=1. Snap to required (4). `1. [ ] Task\n     wrap`
2238        // has actual=5 which is over `required=4` so it's flagged in
2239        // strict mode, while in standard mode it falls under the lazy-
2240        // continuation window and isn't flagged at all.
2241        let content = "1. [ ] Task\n     continuation\n";
2242        let fixed = fix_mkdocs(content);
2243        assert_eq!(fixed, "1. [ ] Task\n    continuation\n");
2244    }
2245
2246    #[test]
2247    fn fix_task_list_mkdocs_ordered_between_cols_snaps_to_task_col() {
2248        // `1. [ ]` MkDocs: required=4, task_col=7. Col 6 → abs_diff(6,7)=1
2249        // < abs_diff(6,4)=2. Snap to task_col (7).
2250        let content = "1. [ ] Task\n      continuation\n";
2251        let fixed = fix_mkdocs(content);
2252        assert_eq!(fixed, "1. [ ] Task\n       continuation\n");
2253    }
2254
2255    // ── Fix idempotency (property test) ──────────────────────────────
2256    //
2257    // A fix pass on already-fixed content must produce the same content
2258    // — otherwise MD077 would oscillate on repeated invocations. This is
2259    // the core property that issue #579 was about (MD077 vs. MD013 fix
2260    // loop), and the integration test covers the MD013 interaction. The
2261    // property tests below pin the *internal* idempotency of MD077's own
2262    // fix, so any future change that introduces oscillation fails fast.
2263
2264    fn assert_idempotent(content: &str) {
2265        let once = fix(content);
2266        let twice = fix(&once);
2267        assert_eq!(once, twice, "MD077 fix was not idempotent on input: {content:?}");
2268    }
2269
2270    fn assert_idempotent_mkdocs(content: &str) {
2271        let once = fix_mkdocs(content);
2272        let twice = fix_mkdocs(&once);
2273        assert_eq!(
2274            once, twice,
2275            "MD077 (MkDocs) fix was not idempotent on input: {content:?}"
2276        );
2277    }
2278
2279    #[test]
2280    fn idempotent_task_list_between_cols() {
2281        assert_idempotent("- [ ] Task\n    continuation\n");
2282    }
2283
2284    #[test]
2285    fn idempotent_task_list_overshoot() {
2286        assert_idempotent("- [ ] Task\n       continuation\n");
2287    }
2288
2289    #[test]
2290    fn idempotent_task_list_under_post_checkbox() {
2291        assert_idempotent("- [ ] Task\n   continuation\n");
2292    }
2293
2294    #[test]
2295    fn idempotent_task_list_near_post_checkbox() {
2296        assert_idempotent("- [ ] Task\n     continuation\n");
2297    }
2298
2299    #[test]
2300    fn idempotent_task_list_tab_overshoot() {
2301        assert_idempotent("- [ ] Task\n\t\twrap\n");
2302    }
2303
2304    #[test]
2305    fn idempotent_task_list_single_tab() {
2306        assert_idempotent("- [ ] Task\n\twrap\n");
2307    }
2308
2309    #[test]
2310    fn idempotent_task_list_ordered_overshoot() {
2311        assert_idempotent("1. [ ] Task\n        continuation\n");
2312    }
2313
2314    #[test]
2315    fn idempotent_task_list_ordered_under() {
2316        assert_idempotent("1. [ ] Task\n    continuation\n");
2317    }
2318
2319    #[test]
2320    fn idempotent_task_list_tie_with_sibling_at_task_col() {
2321        assert_idempotent("- [ ] Task\n      aligned\n    tied\n");
2322    }
2323
2324    #[test]
2325    fn idempotent_task_list_tie_with_sibling_at_content_col() {
2326        assert_idempotent("- [ ] Task\n  aligned\n    tied\n");
2327    }
2328
2329    #[test]
2330    fn idempotent_task_list_mkdocs_unordered_overshoot() {
2331        assert_idempotent_mkdocs("- [ ] Task\n       continuation\n");
2332    }
2333
2334    #[test]
2335    fn idempotent_task_list_mkdocs_unordered_tie() {
2336        assert_idempotent_mkdocs("- [ ] Task\n     continuation\n");
2337    }
2338
2339    #[test]
2340    fn idempotent_task_list_mkdocs_ordered_overshoot() {
2341        assert_idempotent_mkdocs("1. [ ] Task\n        continuation\n");
2342    }
2343
2344    #[test]
2345    fn idempotent_task_list_mkdocs_ordered_between() {
2346        assert_idempotent_mkdocs("1. [ ] Task\n      continuation\n");
2347    }
2348
2349    #[test]
2350    fn idempotent_task_list_reproducer_579() {
2351        // The exact reproducer from issue #579 already has correct indent
2352        // (col 6 = post-checkbox), so idempotency is trivially true. Pin
2353        // it anyway as a smoke test against future regressions.
2354        assert_idempotent(
2355            "- [ ] Lorem ipsum dolor sit amet, consectetur adipiscing\n      tempor incididunt ut labore.\n",
2356        );
2357    }
2358
2359    #[test]
2360    fn idempotent_non_task_list_still_holds() {
2361        // Non-task items never enter the task_col code path; sanity-check
2362        // that idempotency is preserved for them too.
2363        assert_idempotent("1. Item\n    over-indented\n");
2364        assert_idempotent("- Item\n\n continuation\n");
2365    }
2366
2367    // ── Non-task idempotency: loose-mode under-indent ────────────────
2368    //
2369    // When a blank line precedes the continuation (loose mode),
2370    // under-indented content is flagged and fixed up to the content
2371    // column. Idempotency pins that one pass of the fix is sufficient.
2372
2373    #[test]
2374    fn idempotent_non_task_loose_under_indent_ordered() {
2375        // 1. Item → content col 3; "  x" is 2 spaces, under content col.
2376        assert_idempotent("1. Item\n\n  continuation\n");
2377    }
2378
2379    #[test]
2380    fn idempotent_non_task_loose_under_indent_multi_digit() {
2381        // 10. Item → content col 4; single-space continuation needs 4.
2382        assert_idempotent("10. Item\n\n continuation\n");
2383    }
2384
2385    #[test]
2386    fn idempotent_non_task_tight_over_indent_ordered() {
2387        // Tight-mode over-indent: 5 spaces where content col is 3.
2388        assert_idempotent("1. Item\n     over-indented\n");
2389    }
2390
2391    // ── Non-task idempotency: fenced code block compound fix ─────────
2392    //
2393    // A fence opener that needs re-indenting is repaired by the
2394    // compound-fence fix which shifts opener + interior + closer
2395    // together. Idempotency pins that the compound fix settles in one
2396    // pass and does not oscillate between runs.
2397
2398    #[test]
2399    fn idempotent_non_task_fence_ordered_loose() {
2400        // 1. Item → content col 3; fence at col 2 needs to shift to 3.
2401        assert_idempotent("1. Item\n\n  ```rust\n  let x = 1;\n  ```\n");
2402    }
2403
2404    #[test]
2405    fn idempotent_non_task_fence_tilde_under_indent() {
2406        // Tilde fences use the same compound-fix path as backtick fences.
2407        // Interior below the list scope (col 0 here, required col 3) must
2408        // be promoted up in the same pass as the fence delimiters —
2409        // otherwise a second pass would flag the interior individually
2410        // and defeat idempotency.
2411        assert_idempotent("1. Item\n\n  ~~~\nplain text\n  ~~~\n");
2412    }
2413
2414    #[test]
2415    fn idempotent_non_task_fence_interior_above_required() {
2416        // Interior already above the required column must not be pushed
2417        // further up by the compound fix — authored interior indentation
2418        // is preserved when it doesn't threaten fence pairing.
2419        assert_idempotent("1. Item\n\n  ```\n    deeply indented code\n  ```\n");
2420    }
2421
2422    #[test]
2423    fn fence_fix_promotes_interior_below_scope_in_single_pass() {
2424        // Concrete behavioral check, not just idempotency:
2425        // interior at col 0 with opener at col 2, required 3, must land
2426        // at col 3 (same as opener) so fence pairing is preserved.
2427        let content = "1. Item\n\n  ```\ncode\n  ```\n";
2428        let fixed = fix(content);
2429        assert_eq!(fixed, "1. Item\n\n   ```\n   code\n   ```\n");
2430    }
2431
2432    #[test]
2433    fn fence_fix_preserves_interior_above_required() {
2434        // Opener at col 2 → col 3 (required). Interior at col 4 stays at
2435        // col 4 (above required, no need to push it).
2436        let content = "1. Item\n\n  ```\n    code\n  ```\n";
2437        let fixed = fix(content);
2438        assert_eq!(fixed, "1. Item\n\n   ```\n    code\n   ```\n");
2439    }
2440
2441    // ── Non-task idempotency: MkDocs strict-indent ───────────────────
2442    //
2443    // Under MkDocs flavor, continuation requires max(content_col, 4),
2444    // which can force a fix even when CommonMark would accept the
2445    // content. Pin idempotency for the non-task path there too.
2446
2447    #[test]
2448    fn idempotent_non_task_mkdocs_ordered_at_3_spaces() {
2449        // CommonMark-valid (3 spaces) but MkDocs demands 4 → fix runs.
2450        assert_idempotent_mkdocs("1. Item\n\n   continuation\n");
2451    }
2452
2453    #[test]
2454    fn idempotent_non_task_mkdocs_unordered_at_2_spaces() {
2455        // "- Item" → content col 2, but MkDocs raises the floor to 4.
2456        assert_idempotent_mkdocs("- Item\n\n  continuation\n");
2457    }
2458
2459    #[test]
2460    fn idempotent_non_task_mkdocs_fence_compound() {
2461        // MkDocs non-task fence: opener/interior/closer shift together.
2462        assert_idempotent_mkdocs("1. Item\n\n   ```toml\n   k = 1\n   ```\n");
2463    }
2464
2465    // ── style = "aligned" ──────────────────────────────────────────────
2466
2467    #[test]
2468    fn aligned_tight_zero_indent_continuation_flagged() {
2469        // The core #682 case: a tight 0-indent lazy continuation is valid
2470        // CommonMark (and accepted by `any`), but `aligned` requires it to sit
2471        // at the content column.
2472        let content = "- this is a long line\nthat continues on a second line\n";
2473        let warnings = check_aligned(content);
2474        assert_eq!(warnings.len(), 1);
2475        assert_eq!(warnings[0].line, 2);
2476        assert_eq!(
2477            fix_aligned(content),
2478            "- this is a long line\n  that continues on a second line\n"
2479        );
2480    }
2481
2482    #[test]
2483    fn aligned_full_issue_example_made_consistent() {
2484        // The full #682 example: every continuation line is snapped to the
2485        // content column, producing the consistent result the reporter wants.
2486        let content = "- this is a long line\n\
2487                       that continues on a second line\n\
2488                       - this is another long line\n\
2489                       \x20\x20that continues on the next line\n\
2490                       - yet again a long line\n\
2491                       and still inconsistently spaced\n\
2492                       \x20\x20and even worse\n";
2493        let expected = "- this is a long line\n\
2494                        \x20\x20that continues on a second line\n\
2495                        - this is another long line\n\
2496                        \x20\x20that continues on the next line\n\
2497                        - yet again a long line\n\
2498                        \x20\x20and still inconsistently spaced\n\
2499                        \x20\x20and even worse\n";
2500        assert_eq!(fix_aligned(content), expected);
2501        // And the fix is a fixpoint.
2502        assert_eq!(fix_aligned(expected), expected);
2503    }
2504
2505    #[test]
2506    fn aligned_already_aligned_not_flagged() {
2507        let content = "- item\n  continuation at content column\n";
2508        assert!(check_aligned(content).is_empty());
2509    }
2510
2511    #[test]
2512    fn aligned_tight_partial_indent_flagged() {
2513        // 1 space under "- " (content col 2): tight partial under-indent.
2514        let content = "- item\n continuation\n";
2515        let warnings = check_aligned(content);
2516        assert_eq!(warnings.len(), 1);
2517        assert_eq!(fix_aligned(content), "- item\n  continuation\n");
2518    }
2519
2520    #[test]
2521    fn aligned_post_blank_zero_indent_still_new_paragraph() {
2522        // After a blank line, 0-indent content is a NEW paragraph that has left
2523        // the list - aligned mode must not reindent it back into the item.
2524        let content = "- item\n\nnew paragraph\n";
2525        assert!(check_aligned(content).is_empty());
2526        assert_eq!(fix_aligned(content), content);
2527    }
2528
2529    // ── aligned: structural blocks must NOT be pulled into the list ────
2530
2531    #[test]
2532    fn aligned_top_level_blockquote_after_list_untouched() {
2533        // A blockquote tight after a list item is a list-breaking separator,
2534        // not continuation. `in_list_block` is false for it, so aligned mode
2535        // must leave it alone (no reindent into the item).
2536        let content = "- item\n> quote\n";
2537        assert!(check_aligned(content).is_empty());
2538        assert_eq!(fix_aligned(content), content);
2539    }
2540
2541    #[test]
2542    fn aligned_top_level_fence_after_list_untouched() {
2543        let content = "- item\n```\ncode\n```\n";
2544        assert!(check_aligned(content).is_empty());
2545        assert_eq!(fix_aligned(content), content);
2546    }
2547
2548    #[test]
2549    fn aligned_top_level_table_after_list_untouched() {
2550        let content = "- item\n| a | b |\n|---|---|\n| 1 | 2 |\n";
2551        assert!(check_aligned(content).is_empty());
2552        assert_eq!(fix_aligned(content), content);
2553    }
2554
2555    // ── aligned: nested lists ──────────────────────────────────────────
2556
2557    #[test]
2558    fn aligned_nested_tight_lazy_continuation_aligns_to_inner() {
2559        // A tight lazy continuation after a nested item is, per CommonMark,
2560        // continuation of the innermost open item, so it aligns to the inner
2561        // item's content column (4), not the outer's (2). The outer item must
2562        // defer to the inner one.
2563        let content = "- Outer\n  - Inner\ncontinuation\n";
2564        let warnings = check_aligned(content);
2565        assert_eq!(warnings.len(), 1);
2566        assert_eq!(fix_aligned(content), "- Outer\n  - Inner\n    continuation\n");
2567    }
2568
2569    #[test]
2570    fn aligned_nested_continuation_already_aligned_not_flagged() {
2571        let content = "- L1\n  - L2\n    cont of L2 at 4\n";
2572        assert!(check_aligned(content).is_empty());
2573    }
2574
2575    #[test]
2576    fn aligned_nested_idempotent() {
2577        let content = "- Outer\n  - Inner\ncontinuation\n";
2578        let once = fix_aligned(content);
2579        assert_eq!(fix_aligned(&once), once);
2580    }
2581
2582    #[test]
2583    fn aligned_three_level_nesting_aligns_to_innermost() {
2584        // The saw_nested cascade defers through every level, so the tight lazy
2585        // line aligns to L3's content column (6), the innermost open item.
2586        let content = "- L1\n  - L2\n    - L3\ncont\n";
2587        assert_eq!(fix_aligned(content), "- L1\n  - L2\n    - L3\n      cont\n");
2588    }
2589
2590    #[test]
2591    fn aligned_continuation_after_sibling_owned_by_last_item() {
2592        // Tight lazy continuation after the second sibling belongs to that
2593        // sibling (no nesting), so it aligns to its content column (2).
2594        let content = "- a\n- b\nlazy\n";
2595        assert_eq!(fix_aligned(content), "- a\n- b\n  lazy\n");
2596    }
2597
2598    #[test]
2599    fn aligned_multi_digit_ordered_marker_aligns_to_content_column() {
2600        let content = "10. Item\nwrap\n";
2601        assert_eq!(fix_aligned(content), "10. Item\n    wrap\n");
2602    }
2603
2604    #[test]
2605    fn aligned_setext_heading_after_list_left_alone() {
2606        // A setext heading is not prose continuation; aligned must not reindent
2607        // it (the walk terminates on the heading), leaving the document intact.
2608        let content = "- item\nText\n===\n";
2609        assert!(check_aligned(content).is_empty());
2610        assert_eq!(fix_aligned(content), content);
2611    }
2612
2613    #[test]
2614    fn aligned_latent_marker_in_continuation_is_idempotent() {
2615        // Regression (fuzzer-found): a list-marker-looking line ("2. ") that the
2616        // parser absorbs as lazy continuation can be promoted to a real list
2617        // item once an earlier continuation line is reindented, re-attributing
2618        // later lines and breaking single-pass idempotency. Such items are not
2619        // reindented at all.
2620        let content = "# \n- \n``\n2. \n![]()";
2621        let once = fix_aligned(content);
2622        assert_eq!(fix_aligned(&once), once, "fix must be idempotent in one pass");
2623        assert_eq!(once, content, "item with a latent marker is left untouched");
2624    }
2625
2626    #[test]
2627    fn aligned_latent_table_in_continuation_is_idempotent() {
2628        // Regression (fuzzer-found): a table-looking line that the parser
2629        // absorbs as lazy continuation (so it is NOT yet `in_table_block`) can
2630        // be promoted to a real table once an earlier line is reindented. Like
2631        // a latent marker, this re-parses the block, so the item is left alone.
2632        let content = "- \n![`]()\n|  | ` |\n| --- | --- |";
2633        let once = fix_aligned(content);
2634        assert_eq!(fix_aligned(&once), once, "fix must be idempotent in one pass");
2635        assert_eq!(once, content, "item with a latent table is left untouched");
2636    }
2637
2638    #[test]
2639    fn aligned_blockquote_nested_list_not_touched() {
2640        // Lists inside blockquotes are a documented MD077 limitation (the scan
2641        // breaks at the blockquote prefix). Aligned inherits that conservative
2642        // no-false-positive behavior.
2643        let content = "> - item\n> wrap\n";
2644        assert!(check_aligned(content).is_empty());
2645        assert_eq!(fix_aligned(content), content);
2646    }
2647
2648    // ── aligned: GFM task list items ───────────────────────────────────
2649
2650    #[test]
2651    fn aligned_task_post_checkbox_column_accepted() {
2652        // Continuation aligned to the post-checkbox column (content_col + 4 = 6)
2653        // stays valid so aligned mode doesn't fight MD013 reflow.
2654        let content = "- [ ] Task\n      wrap\n";
2655        assert!(check_aligned(content).is_empty());
2656        assert_eq!(fix_aligned(content), content);
2657    }
2658
2659    #[test]
2660    fn aligned_task_under_indent_snaps_to_content_column() {
2661        let content = "- [ ] Task\nwrap\n";
2662        let warnings = check_aligned(content);
2663        assert_eq!(warnings.len(), 1);
2664        assert_eq!(fix_aligned(content), "- [ ] Task\n  wrap\n");
2665    }
2666
2667    // ── aligned: MkDocs flavor ─────────────────────────────────────────
2668
2669    #[test]
2670    fn aligned_mkdocs_tight_under_indent_snaps_to_four() {
2671        // MkDocs raises the required indent to max(content_col, 4) = 4 for "- ".
2672        let content = "- item\nwrap\n";
2673        let warnings = check_aligned_mkdocs(content);
2674        assert_eq!(warnings.len(), 1);
2675        let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
2676        assert_eq!(aligned_rule().fix(&ctx).unwrap(), "- item\n    wrap\n");
2677    }
2678
2679    // ── any (default) regression: tight lazy continuation untouched ────
2680
2681    #[test]
2682    fn any_default_does_not_flag_tight_lazy_continuation() {
2683        // The default style must preserve CommonMark lazy continuation.
2684        let content = "- item\nwrapped at zero indent\n";
2685        assert!(check(content).is_empty());
2686        assert_eq!(fix(content), content);
2687    }
2688
2689    #[test]
2690    fn from_config_aligned_enables_tight_flagging() {
2691        // End-to-end: `[MD077] style = "aligned"` wires through from_config.
2692        let mut config = crate::config::Config::default();
2693        let mut rule_config = crate::config::RuleConfig::default();
2694        rule_config
2695            .values
2696            .insert("style".to_string(), toml::Value::String("aligned".to_string()));
2697        config.rules.insert("MD077".to_string(), rule_config);
2698
2699        let rule = MD077ListContinuationIndent::from_config(&config);
2700        let ctx = LintContext::new("- item\nwrap\n", MarkdownFlavor::Standard, None);
2701        assert_eq!(rule.check(&ctx).unwrap().len(), 1);
2702    }
2703
2704    #[test]
2705    fn from_config_default_is_any() {
2706        // No config -> default `any` -> tight lazy continuation untouched.
2707        let config = crate::config::Config::default();
2708        let rule = MD077ListContinuationIndent::from_config(&config);
2709        let ctx = LintContext::new("- item\nwrap\n", MarkdownFlavor::Standard, None);
2710        assert!(rule.check(&ctx).unwrap().is_empty());
2711    }
2712
2713    #[test]
2714    fn aligned_tight_underindented_fence_inside_item_left_alone() {
2715        // A fenced block is a structural construct; aligned mode does not
2716        // reindent it (the `in_code_block` guard), so a tight under-indented
2717        // fence is a no-op rather than a risky delimiter-only rewrite.
2718        let content = "- item\n ```\n code\n ```\n";
2719        assert!(check_aligned(content).is_empty());
2720        assert_eq!(fix_aligned(content), content);
2721    }
2722
2723    #[test]
2724    fn aligned_task_under_indent_fix_is_idempotent() {
2725        let content = "- [ ] Task\nwrap\n";
2726        let once = fix_aligned(content);
2727        assert_eq!(fix_aligned(&once), once);
2728    }
2729
2730    #[test]
2731    fn aligned_partial_indent_fix_is_idempotent() {
2732        let content = "- item\n continuation\n";
2733        let once = fix_aligned(content);
2734        assert_eq!(fix_aligned(&once), once);
2735    }
2736}