Skip to main content

rumdl_lib/rules/
md077_list_continuation_indent.rs

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