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