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 crate::lint_context::{LineInfo, LintContext};
9use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
10
11/// Rule MD077: List continuation content indentation
12///
13/// Checks two cases:
14/// - **Loose continuation** (after a blank line): content must be indented to the
15///   item's content column (W+N rule), or it falls out of the list.
16/// - **Tight continuation** (no blank line): content must not be over-indented
17///   beyond the item's content column.
18///
19/// Under the MkDocs flavor, a minimum of 4 spaces is enforced for ordered list
20/// items to satisfy Python-Markdown.
21#[derive(Clone, Default)]
22pub struct MD077ListContinuationIndent;
23
24impl MD077ListContinuationIndent {
25    /// Width of a GFM task checkbox prefix including its trailing space:
26    /// `[ ] `, `[x] `, or `[X] ` — always exactly 4 bytes.
27    const TASK_CHECKBOX_PREFIX_LEN: usize = 4;
28
29    /// Returns true if the item line starts a GFM task list item, i.e. its
30    /// content column begins with `[ ] `, `[x] `, or `[X] `. The trailing
31    /// space is part of the match — `- [ ]` with no body is an empty list
32    /// item, not a task.
33    ///
34    /// Task items have a second, conventionally-accepted continuation column
35    /// at `content_col + 4` (aligned after the checkbox). MD013 reflow
36    /// produces this column for wrapped task lines, so MD077 has to accept
37    /// it to avoid a fix loop with MD013.
38    ///
39    /// `content_col` is a byte offset into `line`, not a visual column. The
40    /// CommonMark list parser produces byte-offset content columns, and the
41    /// checkbox prefix `[ ] ` is pure ASCII, so this byte-level comparison
42    /// is correct. Leading indent mixing tabs and spaces is irrelevant here
43    /// because `content_col` already points past any leading whitespace.
44    fn is_task_list_item(line: &str, content_col: usize) -> bool {
45        line.as_bytes()
46            .get(content_col..content_col + Self::TASK_CHECKBOX_PREFIX_LEN)
47            .is_some_and(|window| matches!(window, b"[ ] " | b"[x] " | b"[X] "))
48    }
49
50    /// Check if a trimmed line is a block-level construct (not list continuation).
51    fn is_block_level_construct(trimmed: &str) -> bool {
52        // Footnote definition: [^label]:
53        if trimmed.starts_with("[^") && trimmed.contains("]:") {
54            return true;
55        }
56        // Abbreviation definition: *[text]:
57        if trimmed.starts_with("*[") && trimmed.contains("]:") {
58            return true;
59        }
60        // Reference link definition: [label]: url
61        // Must start with [ but not be a regular link, footnote, or abbreviation
62        if trimmed.starts_with('[') && !trimmed.starts_with("[^") && trimmed.contains("]: ") {
63            return true;
64        }
65        false
66    }
67
68    /// Check if a trimmed line is a fenced code block delimiter (opener or closer).
69    fn is_code_fence(trimmed: &str) -> bool {
70        let bytes = trimmed.as_bytes();
71        if bytes.len() < 3 {
72            return false;
73        }
74        let ch = bytes[0];
75        (ch == b'`' || ch == b'~') && bytes[1] == ch && bytes[2] == ch
76    }
77
78    /// Check if a trimmed line starts with a list marker (*, -, +, or ordered).
79    /// Used to avoid flagging deeply indented list items that the parser doesn't
80    /// recognize as list items (e.g., with indent=8 configured in MD007).
81    fn starts_with_list_marker(trimmed: &str) -> bool {
82        let bytes = trimmed.as_bytes();
83        match bytes.first() {
84            Some(b'*' | b'-' | b'+') => bytes.get(1).is_some_and(|&b| b == b' ' || b == b'\t'),
85            Some(b'0'..=b'9') => {
86                let rest = trimmed.trim_start_matches(|c: char| c.is_ascii_digit());
87                rest.starts_with(". ") || rest.starts_with(") ")
88            }
89            _ => false,
90        }
91    }
92
93    /// Given the line number of a fenced code block opener, walk forward and
94    /// return the line number of the matching closer. Returns the opener itself
95    /// if no following line is in the code block (degenerate single-line block).
96    fn find_fence_closer(ctx: &LintContext, opener_line: usize) -> usize {
97        let mut closer_line = opener_line;
98        for peek in (opener_line + 1)..=ctx.lines.len() {
99            let Some(peek_info) = ctx.line_info(peek) else { break };
100            if peek_info.in_code_block {
101                closer_line = peek;
102            } else {
103                break;
104            }
105        }
106        closer_line
107    }
108
109    /// Build an atomic fix that reindents a fenced code block from its opener
110    /// through its matching closer.
111    ///
112    /// - **Opener and closer** are moved to `required` (the list item's
113    ///   content column, which is what MD077 actually flagged).
114    /// - **Interior lines** are *promoted* to `required` only if they sit
115    ///   below it; interior content at or above `required` is left at its
116    ///   original column. This preserves authored interior indentation when
117    ///   possible while guaranteeing fence pairing: every non-blank line in
118    ///   the block ends at column ≥ `required`, so the block stays inside
119    ///   the list item's scope after the fix.
120    ///
121    /// Why a compound fix rather than three independent fixes? MD077 and
122    /// MD031 run in the same iterative fix loop. If we only moved the
123    /// delimiters, an intermediate state would have mismatched
124    /// opener/closer indentation and MD031 would misread the block as
125    /// unpaired, injecting stray blank lines (issue #574).
126    ///
127    /// Why `max(interior, required)` instead of `interior + delta`? The
128    /// delta-shift version was not idempotent: if interior started below
129    /// the list scope (e.g., col 0 under an opener at col 2 that needs to
130    /// move to col 3), delta-shift landed interior at col 1 — still below
131    /// the list scope — and the next MD077 pass would re-flag it
132    /// individually and snap it to `required`. The promote-up rule reaches
133    /// that end state in a single pass.
134    ///
135    /// Leading tabs are normalized to spaces: CommonMark expands a tab to
136    /// the next column that's a multiple of 4, so simply prepending spaces
137    /// before a tab would let the tab snap back and cancel the shift. We
138    /// replace the whole leading-whitespace byte range with spaces.
139    fn build_compound_fence_fix(
140        ctx: &LintContext,
141        opener_line: usize,
142        closer_line: usize,
143        opener_actual: usize,
144        required: usize,
145    ) -> Option<Fix> {
146        if required <= opener_actual {
147            return None;
148        }
149        let opener_info = ctx.line_info(opener_line)?;
150        let closer_info = ctx.line_info(closer_line)?;
151
152        let fix_start = opener_info.byte_offset;
153        let fix_end = closer_info.byte_offset + closer_info.byte_len;
154
155        let mut replacement = String::new();
156        for i in opener_line..=closer_line {
157            let info = ctx.line_info(i)?;
158            if i > opener_line {
159                replacement.push('\n');
160            }
161            let line = info.content(ctx.content);
162            if info.is_blank {
163                // Blank lines have no content to shift; preserve verbatim.
164                replacement.push_str(line);
165            } else {
166                let new_visual = if i == opener_line || i == closer_line {
167                    required
168                } else {
169                    info.visual_indent.max(required)
170                };
171                for _ in 0..new_visual {
172                    replacement.push(' ');
173                }
174                replacement.push_str(&line[info.indent..]);
175            }
176        }
177
178        Some(Fix {
179            range: fix_start..fix_end,
180            replacement,
181        })
182    }
183
184    /// Walk the continuation lines owned by a single list item, invoking
185    /// `per_line` for each *in-scope, non-blank, non-nested, non-skipped*
186    /// line with its pre-computed visual column and loose/tight state.
187    ///
188    /// This is the **single source of truth** for MD077's item-scope
189    /// traversal: both the sibling-column pre-pass and the main check loop
190    /// route through this method so their termination semantics cannot
191    /// drift. The callback sees only lines the rule actually needs to
192    /// reason about; it can return `ControlFlow::Break` for early exit.
193    ///
194    /// Termination conditions (applied before the callback fires):
195    /// - Headings and horizontal rules end the item unconditionally.
196    /// - After a blank line, content at or below the marker column has
197    ///   escaped the item; further lines are not delivered.
198    ///
199    /// Skipped silently (do not fire the callback):
200    /// - Blank lines (toggle `saw_blank`).
201    /// - Nested list items (reset `saw_blank`, track their content column).
202    /// - Lines inside a nested item's scope (`col >= nested_content_col`).
203    /// - Reference/footnote/abbreviation definitions and similar block
204    ///   constructs that aren't list continuation.
205    /// - Lines that `should_skip_line` rejects (code-block interior etc.).
206    fn walk_item_continuation<F>(
207        ctx: &LintContext,
208        item_line: usize,
209        range_end: usize,
210        marker_col: usize,
211        mut per_line: F,
212    ) where
213        F: FnMut(&ContinuationLine<'_>) -> ControlFlow<()>,
214    {
215        let mut saw_blank = false;
216        let mut nested_content_col: Option<usize> = None;
217
218        for line_num in (item_line + 1)..=range_end {
219            let Some(info) = ctx.line_info(line_num) else {
220                continue;
221            };
222
223            let trimmed = info.content(ctx.content).trim_start();
224
225            if Self::should_skip_line(info, trimmed) {
226                continue;
227            }
228
229            if info.is_blank {
230                saw_blank = true;
231                continue;
232            }
233
234            if let Some(ref li) = info.list_item {
235                nested_content_col = (li.marker_column > marker_col).then_some(li.content_column);
236                saw_blank = false;
237                continue;
238            }
239
240            if info.heading.is_some() || info.is_horizontal_rule {
241                break;
242            }
243
244            if Self::is_block_level_construct(trimmed) {
245                continue;
246            }
247
248            let col = info.visual_indent;
249
250            if let Some(ncc) = nested_content_col {
251                if col >= ncc {
252                    continue;
253                }
254                nested_content_col = None;
255            }
256
257            if saw_blank && col <= marker_col {
258                break;
259            }
260
261            let line = ContinuationLine {
262                line_num,
263                info,
264                trimmed,
265                actual: col,
266                saw_blank,
267            };
268            if per_line(&line).is_break() {
269                break;
270            }
271        }
272    }
273
274    /// Scan an item's owned range and report whether any *other* continuation
275    /// line in the item uses the content column or the post-checkbox column.
276    ///
277    /// Used exclusively for tie-breaking the auto-fix target when an
278    /// over-indented line is exactly equidistant from `content_col` and
279    /// `task_col`. In that case the author's intent is ambiguous, so we
280    /// snap to whichever valid column *they're already using* elsewhere in
281    /// the same item. When neither or both columns are in use, the caller
282    /// falls back to a canonical default.
283    fn sibling_column_usage(
284        ctx: &LintContext,
285        item_line: usize,
286        range_end: usize,
287        marker_col: usize,
288        content_col: usize,
289        task_col: usize,
290    ) -> (bool, bool) {
291        let mut uses_content = false;
292        let mut uses_task = false;
293
294        Self::walk_item_continuation(ctx, item_line, range_end, marker_col, |line| {
295            if line.actual == content_col {
296                uses_content = true;
297            }
298            if line.actual == task_col {
299                uses_task = true;
300            }
301            if uses_content && uses_task {
302                ControlFlow::Break(())
303            } else {
304                ControlFlow::Continue(())
305            }
306        });
307
308        (uses_content, uses_task)
309    }
310
311    /// Compute the auto-fix target for an over-indented continuation line.
312    /// Snaps to the nearer of the two valid columns (content_col / task_col)
313    /// for task items, and on an exact tie uses sibling-column context to
314    /// pick whichever column the author is already using elsewhere in this
315    /// item. Non-task items always snap to `required`.
316    fn compute_fix_target(
317        actual: usize,
318        required: usize,
319        task_col: Option<usize>,
320        uses_content_col: bool,
321        uses_task_col: bool,
322    ) -> usize {
323        let Some(t) = task_col else { return required };
324        match actual.abs_diff(t).cmp(&actual.abs_diff(required)) {
325            std::cmp::Ordering::Less => t,
326            std::cmp::Ordering::Greater => required,
327            std::cmp::Ordering::Equal => match (uses_task_col, uses_content_col) {
328                (true, false) => t,
329                _ => required,
330            },
331        }
332    }
333
334    /// Check if a line should be skipped (inside code, HTML, frontmatter, etc.)
335    ///
336    /// Code block *content* is skipped, but fence opener/closer lines are not —
337    /// their indentation matters for list continuation in MkDocs.
338    fn should_skip_line(info: &crate::lint_context::LineInfo, trimmed: &str) -> bool {
339        if info.in_code_block && !Self::is_code_fence(trimmed) {
340            return true;
341        }
342        info.in_front_matter
343            || info.in_html_block
344            || info.in_html_comment
345            || info.in_mdx_comment
346            || info.in_mkdocstrings
347            || info.in_esm_block
348            || info.in_math_block
349            || info.in_admonition
350            || info.in_content_tab
351            || info.in_pymdown_block
352            || info.in_definition_list
353            || info.in_mkdocs_html_markdown
354            || info.in_kramdown_extension_block
355    }
356
357    /// Build the warning for a tight-mode over-indented continuation line.
358    fn build_over_indent_warning(
359        ctx: &LintContext,
360        line: &ContinuationLine<'_>,
361        fix_target: usize,
362        message: String,
363    ) -> LintWarning {
364        let line_content = line.info.content(ctx.content);
365        let fix_start = line.info.byte_offset;
366        let fix_end = fix_start + line.info.indent;
367        LintWarning {
368            rule_name: Some("MD077".to_string()),
369            line: line.line_num,
370            column: 1,
371            end_line: line.line_num,
372            end_column: line_content.len() + 1,
373            message,
374            severity: Severity::Warning,
375            fix: Some(Fix {
376                range: fix_start..fix_end,
377                replacement: " ".repeat(fix_target),
378            }),
379        }
380    }
381
382    /// Build the warning for a loose-mode under-indented continuation line.
383    /// When the line is the opener of a fenced code block, emit a compound
384    /// fix that reindents opener + interior + closer atomically so MD031
385    /// doesn't see a transiently-broken fence pair (see #574).
386    ///
387    /// Returns the warning plus, when the fix is compound, the closer
388    /// line number so the caller can mark it flagged (preventing the
389    /// main loop from double-flagging the closer as its own under-indent
390    /// case). Keeping the "also flag this line" signal out of band keeps
391    /// this function pure — it reads from `ctx` only and returns a
392    /// plain value.
393    fn build_under_indent_warning(
394        ctx: &LintContext,
395        line: &ContinuationLine<'_>,
396        required: usize,
397        message: String,
398    ) -> UnderIndentOutcome {
399        let line_content = line.info.content(ctx.content);
400        let is_fence_opener = line.info.in_code_block
401            && Self::is_code_fence(line.trimmed)
402            && ctx.line_info(line.line_num - 1).is_none_or(|p| !p.in_code_block);
403
404        let (fix, warn_end_line, warn_end_column, compound_closer) = if is_fence_opener {
405            let closer_line = Self::find_fence_closer(ctx, line.line_num);
406            let fix = Self::build_compound_fence_fix(ctx, line.line_num, closer_line, line.actual, required);
407            let end_column = ctx
408                .line_info(closer_line)
409                .map_or(line_content.len() + 1, |ci| ci.content(ctx.content).len() + 1);
410            let extra_flag = (closer_line != line.line_num).then_some(closer_line);
411            (fix, closer_line, end_column, extra_flag)
412        } else {
413            let fix_start = line.info.byte_offset;
414            let fix_end = fix_start + line.info.indent;
415            let fix = Some(Fix {
416                range: fix_start..fix_end,
417                replacement: " ".repeat(required),
418            });
419            (fix, line.line_num, line_content.len() + 1, None)
420        };
421
422        UnderIndentOutcome {
423            warning: LintWarning {
424                rule_name: Some("MD077".to_string()),
425                line: line.line_num,
426                column: 1,
427                end_line: warn_end_line,
428                end_column: warn_end_column,
429                message,
430                severity: Severity::Warning,
431                fix,
432            },
433            also_flag_line: compound_closer,
434        }
435    }
436}
437
438/// A continuation line yielded by `walk_item_continuation`. Bundles the
439/// per-line facts both checker branches need so helper functions don't
440/// balloon their argument lists.
441struct ContinuationLine<'a> {
442    line_num: usize,
443    info: &'a LineInfo,
444    trimmed: &'a str,
445    actual: usize,
446    saw_blank: bool,
447}
448
449/// Result of `build_under_indent_warning`. Carries both the warning and,
450/// when the fix is compound (fence opener → promote-to-required over the
451/// whole block), the closer line so the caller can record it as already
452/// handled. This keeps the warning builder free of external mutation.
453struct UnderIndentOutcome {
454    warning: LintWarning,
455    also_flag_line: Option<usize>,
456}
457
458impl Rule for MD077ListContinuationIndent {
459    fn name(&self) -> &'static str {
460        "MD077"
461    }
462
463    fn description(&self) -> &'static str {
464        "List continuation content indentation"
465    }
466
467    fn check(&self, ctx: &LintContext) -> LintResult {
468        if ctx.content.is_empty() {
469            return Ok(Vec::new());
470        }
471
472        let strict_indent = ctx.flavor.requires_strict_list_indent();
473        let total_lines = ctx.lines.len();
474        let mut warnings = Vec::new();
475        let mut flagged_lines = std::collections::HashSet::new();
476
477        // Collect all list item lines sorted, with their content_column,
478        // marker_column, and — if the item is a GFM task — its post-checkbox
479        // column. Precomputing task_col here (instead of re-reading line_info
480        // inside the hot inner loop) keeps the per-item cost O(1).
481        //
482        // We need the owned range to extend past block.end_line because the
483        // parser excludes under-indented continuation from the block, and
484        // MD077 specifically has to evaluate those escaped lines.
485        let mut items: Vec<(usize, usize, usize, Option<usize>)> = Vec::new();
486        for block in &ctx.list_blocks {
487            for &item_line in &block.item_lines {
488                if let Some(info) = ctx.line_info(item_line)
489                    && let Some(ref li) = info.list_item
490                {
491                    let line = info.content(ctx.content);
492                    let task_col = Self::is_task_list_item(line, li.content_column)
493                        .then_some(li.content_column + Self::TASK_CHECKBOX_PREFIX_LEN);
494                    items.push((item_line, li.marker_column, li.content_column, task_col));
495                }
496            }
497        }
498        items.sort_unstable();
499        items.dedup_by_key(|&mut (ln, _, _, _)| ln);
500
501        for (item_idx, &(item_line, marker_col, content_col, task_col)) in items.iter().enumerate() {
502            let required = if strict_indent { content_col.max(4) } else { content_col };
503
504            // Owned range ends at the line before the next sibling-or-higher
505            // item, or end of document.
506            let range_end = items
507                .iter()
508                .skip(item_idx + 1)
509                .find(|&&(_, mc, _, _)| mc <= marker_col)
510                .map_or(total_lines, |&(ln, _, _, _)| ln - 1);
511
512            // For task items, gather sibling-column usage once per item so
513            // the auto-fix can tie-break equidistant over-indents toward
514            // whichever valid column the author is already using.
515            let (uses_content_col, uses_task_col) = match task_col {
516                Some(t) => Self::sibling_column_usage(ctx, item_line, range_end, marker_col, content_col, t),
517                None => (false, false),
518            };
519
520            Self::walk_item_continuation(ctx, item_line, range_end, marker_col, |line| {
521                let actual = line.actual;
522                if !line.saw_blank {
523                    // Tight continuation: flag over-indented lines.
524                    if actual > required
525                        && Some(actual) != task_col
526                        && !Self::starts_with_list_marker(line.trimmed)
527                        && flagged_lines.insert(line.line_num)
528                    {
529                        let fix_target =
530                            Self::compute_fix_target(actual, required, task_col, uses_content_col, uses_task_col);
531                        let message = match task_col {
532                            Some(t) => format!(
533                                "Continuation line over-indented \
534                                 (expected {required} or {t}, found {actual})"
535                            ),
536                            None => {
537                                format!("Continuation line over-indented (expected {required}, found {actual})")
538                            }
539                        };
540                        warnings.push(Self::build_over_indent_warning(ctx, line, fix_target, message));
541                    }
542                } else if actual < required && flagged_lines.insert(line.line_num) {
543                    // Loose continuation: flag under-indented lines.
544                    let message = if strict_indent {
545                        format!(
546                            "Content inside list item needs {required} spaces of indentation \
547                             for MkDocs compatibility (found {actual})",
548                        )
549                    } else {
550                        format!(
551                            "Content after blank line in list item needs {required} spaces of \
552                             indentation to remain part of the list (found {actual})",
553                        )
554                    };
555                    let outcome = Self::build_under_indent_warning(ctx, line, required, message);
556                    if let Some(closer_line) = outcome.also_flag_line {
557                        flagged_lines.insert(closer_line);
558                    }
559                    warnings.push(outcome.warning);
560                }
561                ControlFlow::Continue(())
562            });
563        }
564
565        Ok(warnings)
566    }
567
568    fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
569        let warnings = self.check(ctx)?;
570        let warnings =
571            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
572        if warnings.is_empty() {
573            return Ok(ctx.content.to_string());
574        }
575
576        // Sort fixes by byte position descending to apply from end to start
577        let mut fixes: Vec<Fix> = warnings.into_iter().filter_map(|w| w.fix).collect();
578        fixes.sort_by_key(|f| std::cmp::Reverse(f.range.start));
579
580        let mut content = ctx.content.to_string();
581        for fix in fixes {
582            if fix.range.start <= content.len() && fix.range.end <= content.len() {
583                content.replace_range(fix.range, &fix.replacement);
584            }
585        }
586
587        Ok(content)
588    }
589
590    fn category(&self) -> RuleCategory {
591        RuleCategory::List
592    }
593
594    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
595        ctx.content.is_empty() || ctx.list_blocks.is_empty()
596    }
597
598    fn as_any(&self) -> &dyn std::any::Any {
599        self
600    }
601
602    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
603    where
604        Self: Sized,
605    {
606        Box::new(Self)
607    }
608}
609
610#[cfg(test)]
611mod tests {
612    use super::*;
613    use crate::config::MarkdownFlavor;
614
615    fn check(content: &str) -> Vec<LintWarning> {
616        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
617        let rule = MD077ListContinuationIndent;
618        rule.check(&ctx).unwrap()
619    }
620
621    fn check_mkdocs(content: &str) -> Vec<LintWarning> {
622        let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
623        let rule = MD077ListContinuationIndent;
624        rule.check(&ctx).unwrap()
625    }
626
627    fn fix(content: &str) -> String {
628        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
629        let rule = MD077ListContinuationIndent;
630        rule.fix(&ctx).unwrap()
631    }
632
633    fn fix_mkdocs(content: &str) -> String {
634        let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
635        let rule = MD077ListContinuationIndent;
636        rule.fix(&ctx).unwrap()
637    }
638
639    // ── Tight continuation (no blank line) ─────────────────────────────
640
641    #[test]
642    fn tight_lazy_continuation_zero_indent_not_flagged() {
643        // Zero-indent lazy continuation is valid CommonMark
644        let content = "- Item\ncontinuation\n";
645        assert!(check(content).is_empty());
646    }
647
648    #[test]
649    fn tight_continuation_correct_indent_not_flagged() {
650        // Correctly indented tight continuation (aligns with content column)
651        let content = "1. Item\n   continuation\n";
652        assert!(check(content).is_empty());
653    }
654
655    #[test]
656    fn tight_continuation_over_indented_ordered() {
657        // "1. " = 3 chars, but continuation has 4 spaces
658        let content = "1. This is a list item with multiple lines.\n    The second line is over-indented.\n";
659        let warnings = check(content);
660        assert_eq!(warnings.len(), 1);
661        assert_eq!(warnings[0].line, 2);
662        assert!(warnings[0].message.contains("over-indented"));
663    }
664
665    #[test]
666    fn tight_continuation_over_indented_unordered() {
667        // "- " = 2 chars, but continuation has 3 spaces
668        let content = "- Item\n   over-indented\n";
669        let warnings = check(content);
670        assert_eq!(warnings.len(), 1);
671        assert_eq!(warnings[0].line, 2);
672    }
673
674    #[test]
675    fn tight_continuation_multiple_over_indented_lines() {
676        let content = "1. Item\n    line one\n    line two\n    line three\n";
677        let warnings = check(content);
678        assert_eq!(warnings.len(), 3);
679    }
680
681    #[test]
682    fn tight_continuation_mixed_correct_and_over() {
683        let content = "1. Item\n   correct\n    over-indented\n   correct again\n";
684        let warnings = check(content);
685        assert_eq!(warnings.len(), 1);
686        assert_eq!(warnings[0].line, 3);
687    }
688
689    #[test]
690    fn tight_continuation_nested_over_indented() {
691        // L2 "- " at column 2, content_column = 4. Continuation at 5 is over-indented for L2.
692        let content = "- L1\n  - L2\n     over-indented continuation of L2\n";
693        let warnings = check(content);
694        assert_eq!(warnings.len(), 1);
695        assert_eq!(warnings[0].line, 3);
696        // Must report expected=4 (L2's content_col), not expected=2 (L1's)
697        assert!(warnings[0].message.contains("expected 4"));
698        assert!(warnings[0].message.contains("found 5"));
699    }
700
701    #[test]
702    fn tight_continuation_nested_correct_indent_not_flagged() {
703        // Continuation at 4 spaces is correct for L2 (content_col=4). Must NOT be
704        // flagged as over-indented relative to L1 (content_col=2).
705        let content = "- L1\n  - L2\n    correctly indented continuation of L2\n";
706        assert!(check(content).is_empty());
707    }
708
709    #[test]
710    fn fix_tight_continuation_nested_over_indented() {
711        // Fix should reduce to 4 spaces (L2's content_col), not 2 (L1's)
712        let content = "- L1\n  - L2\n     over-indented continuation of L2\n";
713        let fixed = fix(content);
714        assert_eq!(fixed, "- L1\n  - L2\n    over-indented continuation of L2\n");
715    }
716
717    #[test]
718    fn tight_continuation_under_indented_not_flagged() {
719        // 2 spaces instead of 3 for "1. " — under-indented, not over-indented.
720        // Valid lazy continuation in CommonMark, so not flagged.
721        let content = "1. Item\n  under-indented\n";
722        assert!(check(content).is_empty());
723    }
724
725    #[test]
726    fn tight_continuation_tab_over_indented() {
727        // A tab expands to 4 visual columns, which exceeds content_col=2 for "- "
728        let content = "- Item\n\tover-indented\n";
729        let warnings = check(content);
730        assert_eq!(warnings.len(), 1);
731    }
732
733    #[test]
734    fn fix_tight_continuation_over_indented_ordered() {
735        let content = "1. This is a list item with multiple lines.\n    The second line is over-indented.\n";
736        let fixed = fix(content);
737        assert_eq!(
738            fixed,
739            "1. This is a list item with multiple lines.\n   The second line is over-indented.\n"
740        );
741    }
742
743    #[test]
744    fn fix_tight_continuation_over_indented_unordered() {
745        let content = "- Item\n   over-indented\n";
746        let fixed = fix(content);
747        assert_eq!(fixed, "- Item\n  over-indented\n");
748    }
749
750    #[test]
751    fn fix_tight_continuation_multiple_lines() {
752        let content = "1. Item\n    line one\n    line two\n";
753        let fixed = fix(content);
754        assert_eq!(fixed, "1. Item\n   line one\n   line two\n");
755    }
756
757    #[test]
758    fn tight_continuation_mkdocs_4space_ordered_not_flagged() {
759        // MkDocs requires max(3, 4) = 4 spaces for "1. " items.
760        // 4-space tight continuation is correct, not over-indented.
761        let content = "1. Item\n    continuation\n";
762        assert!(check_mkdocs(content).is_empty());
763    }
764
765    #[test]
766    fn tight_continuation_mkdocs_5space_ordered_flagged() {
767        // 5 spaces exceeds the MkDocs required indent of 4
768        let content = "1. Item\n     over-indented\n";
769        let warnings = check_mkdocs(content);
770        assert_eq!(warnings.len(), 1);
771        assert!(warnings[0].message.contains("expected 4"));
772        assert!(warnings[0].message.contains("found 5"));
773    }
774
775    #[test]
776    fn fix_tight_continuation_mkdocs_over_indented() {
777        let content = "1. Item\n     over-indented\n";
778        let fixed = fix_mkdocs(content);
779        assert_eq!(fixed, "1. Item\n    over-indented\n");
780    }
781
782    #[test]
783    fn tight_continuation_deeply_indented_list_markers_not_flagged() {
784        // Deeply indented list markers (e.g., indent=8 in MD007) may not be
785        // recognized as list items by the parser. MD077 must not flag them.
786        let content = "* Level 0\n        * Level 1\n                * Level 2\n";
787        assert!(check(content).is_empty());
788    }
789
790    #[test]
791    fn tight_continuation_ordered_marker_not_flagged() {
792        // Indented ordered list marker should not be flagged
793        let content = "- Parent\n      1. Child item\n";
794        assert!(check(content).is_empty());
795    }
796
797    // ── Unordered list: correct indent after blank ────────────────────
798
799    #[test]
800    fn unordered_correct_indent_no_warning() {
801        let content = "- Item\n\n  continuation\n";
802        assert!(check(content).is_empty());
803    }
804
805    #[test]
806    fn unordered_partial_indent_warns() {
807        // Content with some indent (above marker column) but less than
808        // content_column is likely an indentation mistake.
809        let content = "- Item\n\n continuation\n";
810        let warnings = check(content);
811        assert_eq!(warnings.len(), 1);
812        assert_eq!(warnings[0].line, 3);
813        assert!(warnings[0].message.contains("2 spaces"));
814        assert!(warnings[0].message.contains("found 1"));
815    }
816
817    #[test]
818    fn unordered_zero_indent_is_new_paragraph() {
819        // Content at 0 indent after a top-level list is a new paragraph, not
820        // under-indented continuation.
821        let content = "- Item\n\ncontinuation\n";
822        assert!(check(content).is_empty());
823    }
824
825    // ── Ordered list: CommonMark W+N ──────────────────────────────────
826
827    #[test]
828    fn ordered_3space_correct_commonmark() {
829        // "1. " is 3 chars, content_column = 3
830        let content = "1. Item\n\n   continuation\n";
831        assert!(check(content).is_empty());
832    }
833
834    #[test]
835    fn ordered_2space_under_indent_commonmark() {
836        let content = "1. Item\n\n  continuation\n";
837        let warnings = check(content);
838        assert_eq!(warnings.len(), 1);
839        assert!(warnings[0].message.contains("3 spaces"));
840        assert!(warnings[0].message.contains("found 2"));
841    }
842
843    // ── Multi-digit ordered markers ───────────────────────────────────
844
845    #[test]
846    fn multi_digit_marker_correct() {
847        // "10. " is 4 chars, content_column = 4
848        let content = "10. Item\n\n    continuation\n";
849        assert!(check(content).is_empty());
850    }
851
852    #[test]
853    fn multi_digit_marker_under_indent() {
854        let content = "10. Item\n\n   continuation\n";
855        let warnings = check(content);
856        assert_eq!(warnings.len(), 1);
857        assert!(warnings[0].message.contains("4 spaces"));
858    }
859
860    // ── MkDocs flavor: 4-space minimum ────────────────────────────────
861
862    #[test]
863    fn mkdocs_3space_ordered_warns() {
864        // In MkDocs mode, 3-space indent on "1. " is not enough
865        let content = "1. Item\n\n   continuation\n";
866        let warnings = check_mkdocs(content);
867        assert_eq!(warnings.len(), 1);
868        assert!(warnings[0].message.contains("4 spaces"));
869        assert!(warnings[0].message.contains("MkDocs"));
870    }
871
872    #[test]
873    fn mkdocs_4space_ordered_no_warning() {
874        let content = "1. Item\n\n    continuation\n";
875        assert!(check_mkdocs(content).is_empty());
876    }
877
878    #[test]
879    fn mkdocs_unordered_2space_ok() {
880        // Unordered "- " has content_column = 2; max(2, 4) = 4 in mkdocs
881        let content = "- Item\n\n    continuation\n";
882        assert!(check_mkdocs(content).is_empty());
883    }
884
885    #[test]
886    fn mkdocs_unordered_2space_warns() {
887        // "- " has content_column 2; MkDocs requires max(2,4) = 4
888        let content = "- Item\n\n  continuation\n";
889        let warnings = check_mkdocs(content);
890        assert_eq!(warnings.len(), 1);
891        assert!(warnings[0].message.contains("4 spaces"));
892    }
893
894    // ── Auto-fix ──────────────────────────────────────────────────────
895
896    #[test]
897    fn fix_unordered_indent() {
898        // Partial indent (above marker column, below content column) gets fixed
899        let content = "- Item\n\n continuation\n";
900        let fixed = fix(content);
901        assert_eq!(fixed, "- Item\n\n  continuation\n");
902    }
903
904    #[test]
905    fn fix_ordered_indent() {
906        let content = "1. Item\n\n continuation\n";
907        let fixed = fix(content);
908        assert_eq!(fixed, "1. Item\n\n   continuation\n");
909    }
910
911    #[test]
912    fn fix_mkdocs_indent() {
913        let content = "1. Item\n\n   continuation\n";
914        let fixed = fix_mkdocs(content);
915        assert_eq!(fixed, "1. Item\n\n    continuation\n");
916    }
917
918    // ── Nested lists: only flag continuation, not sub-items ───────────
919
920    #[test]
921    fn nested_list_items_not_flagged() {
922        let content = "- Parent\n\n  - Child\n";
923        assert!(check(content).is_empty());
924    }
925
926    #[test]
927    fn nested_list_zero_indent_is_new_paragraph() {
928        // Content at 0 indent ends the list, not continuation
929        let content = "- Parent\n  - Child\n\ncontinuation of parent\n";
930        assert!(check(content).is_empty());
931    }
932
933    #[test]
934    fn nested_list_partial_indent_flagged() {
935        // Content with partial indent (above parent marker, below content col)
936        let content = "- Parent\n  - Child\n\n continuation of parent\n";
937        let warnings = check(content);
938        assert_eq!(warnings.len(), 1);
939        assert!(warnings[0].message.contains("2 spaces"));
940    }
941
942    // ── Code blocks inside items ─────────────────────────────────────
943
944    #[test]
945    fn code_block_correctly_indented_no_warning() {
946        // Fence lines and content all at correct indent for "- " (content_column = 2)
947        let content = "- Item\n\n  ```\n  code\n  ```\n";
948        assert!(check(content).is_empty());
949    }
950
951    #[test]
952    fn code_fence_under_indented_warns() {
953        // Fence opener has 1-space indent, but "- " needs 2.
954        // Only the opener is flagged — its compound fix also covers the
955        // interior content and the matching closer (see issue #574).
956        let content = "- Item\n\n ```\n code\n ```\n";
957        let warnings = check(content);
958        assert_eq!(warnings.len(), 1);
959        assert_eq!(warnings[0].line, 3);
960    }
961
962    #[test]
963    fn code_fence_under_indented_ordered_mkdocs() {
964        // Ordered list in MkDocs: "1. " needs max(3, 4) = 4 spaces
965        // Fence at 3 spaces is correct for CommonMark but wrong for MkDocs
966        let content = "1. Item\n\n   ```toml\n   key = \"value\"\n   ```\n";
967        assert!(check(content).is_empty()); // Standard mode: 3 is fine
968        let warnings = check_mkdocs(content);
969        assert_eq!(warnings.len(), 1); // MkDocs: opener's compound fix covers the whole block
970        assert_eq!(warnings[0].line, 3);
971        assert!(warnings[0].message.contains("4 spaces"));
972        assert!(warnings[0].message.contains("MkDocs"));
973    }
974
975    #[test]
976    fn code_fence_tilde_under_indented() {
977        let content = "- Item\n\n ~~~\n code\n ~~~\n";
978        let warnings = check(content);
979        assert_eq!(warnings.len(), 1); // Tilde fences: single compound-fix warning on opener
980        assert_eq!(warnings[0].line, 3);
981    }
982
983    // ── Multiple blank lines ──────────────────────────────────────────
984
985    #[test]
986    fn multiple_blank_lines_zero_indent_is_new_paragraph() {
987        // Even with multiple blanks, 0-indent content is a new paragraph
988        let content = "- Item\n\n\ncontinuation\n";
989        assert!(check(content).is_empty());
990    }
991
992    #[test]
993    fn multiple_blank_lines_partial_indent_flags() {
994        let content = "- Item\n\n\n continuation\n";
995        let warnings = check(content);
996        assert_eq!(warnings.len(), 1);
997    }
998
999    // ── Empty items: no continuation to check ─────────────────────────
1000
1001    #[test]
1002    fn empty_item_no_warning() {
1003        let content = "- \n- Second\n";
1004        assert!(check(content).is_empty());
1005    }
1006
1007    // ── Multiple items, only some under-indented ──────────────────────
1008
1009    #[test]
1010    fn multiple_items_mixed_indent() {
1011        let content = "1. First\n\n   correct continuation\n\n2. Second\n\n  wrong continuation\n";
1012        let warnings = check(content);
1013        assert_eq!(warnings.len(), 1);
1014        assert_eq!(warnings[0].line, 7);
1015    }
1016
1017    // ── Task list items ───────────────────────────────────────────────
1018
1019    #[test]
1020    fn task_list_correct_indent() {
1021        // "- [ ] " = content_column is typically at col 6
1022        let content = "- [ ] Task\n\n      continuation\n";
1023        assert!(check(content).is_empty());
1024    }
1025
1026    // ── Frontmatter skipped ───────────────────────────────────────────
1027
1028    #[test]
1029    fn frontmatter_not_flagged() {
1030        let content = "---\ntitle: test\n---\n\n- Item\n\n  continuation\n";
1031        assert!(check(content).is_empty());
1032    }
1033
1034    // ── Fix produces valid output with multiple fixes ─────────────────
1035
1036    #[test]
1037    fn fix_multiple_items() {
1038        let content = "1. First\n\n wrong1\n\n2. Second\n\n wrong2\n";
1039        let fixed = fix(content);
1040        assert_eq!(fixed, "1. First\n\n   wrong1\n\n2. Second\n\n   wrong2\n");
1041    }
1042
1043    #[test]
1044    fn fix_multiline_loose_continuation_all_lines() {
1045        let content = "1. Item\n\n  line one\n  line two\n  line three\n";
1046        let fixed = fix(content);
1047        assert_eq!(fixed, "1. Item\n\n   line one\n   line two\n   line three\n");
1048    }
1049
1050    // ── No false positive when content is after sibling item ──────────
1051
1052    #[test]
1053    fn sibling_item_boundary_respected() {
1054        // The "continuation" after a blank belongs to "- Second", not "- First"
1055        let content = "- First\n- Second\n\n  continuation\n";
1056        assert!(check(content).is_empty());
1057    }
1058
1059    // ── Blockquote-nested lists ────────────────────────────────────────
1060
1061    #[test]
1062    fn blockquote_list_correct_indent_no_warning() {
1063        // Lists inside blockquotes: visual_indent includes the blockquote
1064        // prefix, so comparisons work on raw line columns.
1065        let content = "> - Item\n>\n>   continuation\n";
1066        assert!(check(content).is_empty());
1067    }
1068
1069    #[test]
1070    fn blockquote_list_under_indent_no_false_positive() {
1071        // Under-indented continuation inside a blockquote: visual_indent
1072        // starts at 0 (the `>` char) which is <= marker_col, so the scan
1073        // breaks and no warning is emitted. This is a known false negative
1074        // (not a false positive), which is the safer default.
1075        let content = "> - Item\n>\n> continuation\n";
1076        assert!(check(content).is_empty());
1077    }
1078
1079    // ── Deep nesting (3+ levels) ──────────────────────────────────────
1080
1081    #[test]
1082    fn deeply_nested_correct_indent() {
1083        let content = "- L1\n  - L2\n    - L3\n\n      continuation of L3\n";
1084        assert!(check(content).is_empty());
1085    }
1086
1087    #[test]
1088    fn deeply_nested_under_indent() {
1089        // L3 starts at column 4 with "- " marker, content_column = 6
1090        // Continuation with 5 spaces is under-indented for L3.
1091        let content = "- L1\n  - L2\n    - L3\n\n     continuation of L3\n";
1092        let warnings = check(content);
1093        assert_eq!(warnings.len(), 1);
1094        assert!(warnings[0].message.contains("6 spaces"));
1095        assert!(warnings[0].message.contains("found 5"));
1096    }
1097
1098    // ── Tab indentation ───────────────────────────────────────────────
1099
1100    #[test]
1101    fn tab_indent_correct() {
1102        // A tab at the start expands to 4 visual columns, which satisfies
1103        // "- " (content_column = 2).
1104        let content = "- Item\n\n\tcontinuation\n";
1105        assert!(check(content).is_empty());
1106    }
1107
1108    // ── Multiple continuation paragraphs ──────────────────────────────
1109
1110    #[test]
1111    fn multiple_continuations_correct() {
1112        let content = "- Item\n\n  para 1\n\n  para 2\n\n  para 3\n";
1113        assert!(check(content).is_empty());
1114    }
1115
1116    #[test]
1117    fn multiple_continuations_second_under_indent() {
1118        // First continuation is correct, second is under-indented
1119        let content = "- Item\n\n  para 1\n\n continuation 2\n";
1120        let warnings = check(content);
1121        assert_eq!(warnings.len(), 1);
1122        assert_eq!(warnings[0].line, 5);
1123    }
1124
1125    // ── Ordered list with `)` marker style ────────────────────────────
1126
1127    #[test]
1128    fn ordered_paren_marker_correct() {
1129        // "1) " is 3 chars, content_column = 3
1130        let content = "1) Item\n\n   continuation\n";
1131        assert!(check(content).is_empty());
1132    }
1133
1134    #[test]
1135    fn ordered_paren_marker_under_indent() {
1136        let content = "1) Item\n\n  continuation\n";
1137        let warnings = check(content);
1138        assert_eq!(warnings.len(), 1);
1139        assert!(warnings[0].message.contains("3 spaces"));
1140    }
1141
1142    // ── Star and plus markers ─────────────────────────────────────────
1143
1144    #[test]
1145    fn star_marker_correct() {
1146        let content = "* Item\n\n  continuation\n";
1147        assert!(check(content).is_empty());
1148    }
1149
1150    #[test]
1151    fn star_marker_under_indent() {
1152        let content = "* Item\n\n continuation\n";
1153        let warnings = check(content);
1154        assert_eq!(warnings.len(), 1);
1155    }
1156
1157    #[test]
1158    fn plus_marker_correct() {
1159        let content = "+ Item\n\n  continuation\n";
1160        assert!(check(content).is_empty());
1161    }
1162
1163    // ── Heading breaks scan ───────────────────────────────────────────
1164
1165    #[test]
1166    fn heading_after_list_no_warning() {
1167        let content = "- Item\n\n# Heading\n";
1168        assert!(check(content).is_empty());
1169    }
1170
1171    // ── Horizontal rule breaks scan ───────────────────────────────────
1172
1173    #[test]
1174    fn hr_after_list_no_warning() {
1175        let content = "- Item\n\n---\n";
1176        assert!(check(content).is_empty());
1177    }
1178
1179    // ── Reference link definitions skip ───────────────────────────────
1180
1181    #[test]
1182    fn reference_link_def_not_flagged() {
1183        let content = "- Item\n\n [link]: https://example.com\n";
1184        assert!(check(content).is_empty());
1185    }
1186
1187    // ── Footnote definitions skip ─────────────────────────────────────
1188
1189    #[test]
1190    fn footnote_def_not_flagged() {
1191        let content = "- Item\n\n [^1]: footnote text\n";
1192        assert!(check(content).is_empty());
1193    }
1194
1195    // ── Fix preserves correct content ─────────────────────────────────
1196
1197    #[test]
1198    fn fix_deeply_nested() {
1199        let content = "- L1\n  - L2\n    - L3\n\n     under-indented\n";
1200        let fixed = fix(content);
1201        assert_eq!(fixed, "- L1\n  - L2\n    - L3\n\n      under-indented\n");
1202    }
1203
1204    #[test]
1205    fn fix_mkdocs_unordered() {
1206        // MkDocs: "- " has content_column 2, but MkDocs requires max(2,4) = 4
1207        let content = "- Item\n\n  continuation\n";
1208        let fixed = fix_mkdocs(content);
1209        assert_eq!(fixed, "- Item\n\n    continuation\n");
1210    }
1211
1212    #[test]
1213    fn fix_code_fence_indent() {
1214        // Fence opener, interior, and closer all shift by the same delta so
1215        // the parser keeps pairing the fences and MD031 doesn't misfire.
1216        let content = "- Item\n\n ```\n code\n ```\n";
1217        let fixed = fix(content);
1218        assert_eq!(fixed, "- Item\n\n  ```\n  code\n  ```\n");
1219    }
1220
1221    #[test]
1222    fn fix_mkdocs_code_fence_indent() {
1223        // MkDocs ordered list: fence at 3 spaces needs 4; interior shifts too
1224        let content = "1. Item\n\n   ```toml\n   key = \"val\"\n   ```\n";
1225        let fixed = fix_mkdocs(content);
1226        assert_eq!(fixed, "1. Item\n\n    ```toml\n    key = \"val\"\n    ```\n");
1227    }
1228
1229    // ── Empty document / whitespace-only ──────────────────────────────
1230
1231    #[test]
1232    fn empty_document_no_warning() {
1233        assert!(check("").is_empty());
1234    }
1235
1236    #[test]
1237    fn whitespace_only_no_warning() {
1238        assert!(check("   \n\n  \n").is_empty());
1239    }
1240
1241    // ── No list at all ────────────────────────────────────────────────
1242
1243    #[test]
1244    fn no_list_no_warning() {
1245        let content = "# Heading\n\nSome paragraph.\n\nAnother paragraph.\n";
1246        assert!(check(content).is_empty());
1247    }
1248
1249    // ── Multi-line continuation (additional coverage) ──────────────
1250
1251    #[test]
1252    fn multiline_continuation_all_lines_flagged() {
1253        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";
1254        let warnings = check(content);
1255        assert_eq!(warnings.len(), 3);
1256        assert_eq!(warnings[0].line, 3);
1257        assert_eq!(warnings[1].line, 4);
1258        assert_eq!(warnings[2].line, 5);
1259    }
1260
1261    #[test]
1262    fn multiline_continuation_with_frontmatter_fix() {
1263        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";
1264        let fixed = fix(content);
1265        assert_eq!(
1266            fixed,
1267            "---\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"
1268        );
1269    }
1270
1271    #[test]
1272    fn multiline_continuation_correct_indent_no_warning() {
1273        let content = "1. Item\n\n   line one\n   line two\n   line three\n";
1274        assert!(check(content).is_empty());
1275    }
1276
1277    #[test]
1278    fn multiline_continuation_mixed_indent() {
1279        let content = "1. Item\n\n   correct\n  wrong\n   correct\n";
1280        let warnings = check(content);
1281        assert_eq!(warnings.len(), 1);
1282        assert_eq!(warnings[0].line, 4);
1283    }
1284
1285    #[test]
1286    fn multiline_continuation_unordered() {
1287        let content = "- Item\n\n continuation 1\n continuation 2\n continuation 3\n";
1288        let warnings = check(content);
1289        assert_eq!(warnings.len(), 3);
1290        let fixed = fix(content);
1291        assert_eq!(
1292            fixed,
1293            "- Item\n\n  continuation 1\n  continuation 2\n  continuation 3\n"
1294        );
1295    }
1296
1297    #[test]
1298    fn multiline_continuation_two_items_fix() {
1299        let content = "1. First\n\n  cont a\n  cont b\n\n2. Second\n\n  cont c\n  cont d\n";
1300        let fixed = fix(content);
1301        assert_eq!(
1302            fixed,
1303            "1. First\n\n   cont a\n   cont b\n\n2. Second\n\n   cont c\n   cont d\n"
1304        );
1305    }
1306
1307    #[test]
1308    fn fence_fix_does_not_break_pairing_for_md031() {
1309        // Regression for issue #574: previously MD077 only reindented the
1310        // fence delimiter lines while leaving the code block's interior at
1311        // the old indent. Between iterations of the fix loop the parser
1312        // saw an opener-closer mismatch, and MD031 then injected stray
1313        // blank lines at the fence boundaries. MD077's compound fix must
1314        // now rewrite the whole block atomically so the fences stay paired.
1315        let content = "#### title\n\nabc\n\n\
1316                       1. ab\n\n\
1317                       \x20\x20`aabbccdd`\n\n\
1318                       2. cd\n\n\
1319                       \x20\x20`bbcc dd ee`\n\n\
1320                       \x20\x20```\n\
1321                       \x20\x20abcd\n\
1322                       \x20\x20ef gh\n\
1323                       \x20\x20```\n\n\
1324                       \x20\x20uu\n\n\
1325                       \x20\x20```\n\
1326                       \x20\x20cdef\n\
1327                       \x20\x20gh ij\n\
1328                       \x20\x20```\n";
1329        let expected = "#### title\n\nabc\n\n\
1330                        1. ab\n\n\
1331                        \x20\x20\x20`aabbccdd`\n\n\
1332                        2. cd\n\n\
1333                        \x20\x20\x20`bbcc dd ee`\n\n\
1334                        \x20\x20\x20```\n\
1335                        \x20\x20\x20abcd\n\
1336                        \x20\x20\x20ef gh\n\
1337                        \x20\x20\x20```\n\n\
1338                        \x20\x20\x20uu\n\n\
1339                        \x20\x20\x20```\n\
1340                        \x20\x20\x20cdef\n\
1341                        \x20\x20\x20gh ij\n\
1342                        \x20\x20\x20```\n";
1343        assert_eq!(fix(content), expected);
1344    }
1345
1346    #[test]
1347    fn multiline_continuation_separated_by_blank() {
1348        let content = "1. Item\n\n  para1 line1\n  para1 line2\n\n  para2 line1\n  para2 line2\n";
1349        let warnings = check(content);
1350        assert_eq!(warnings.len(), 4);
1351        let fixed = fix(content);
1352        assert_eq!(
1353            fixed,
1354            "1. Item\n\n   para1 line1\n   para1 line2\n\n   para2 line1\n   para2 line2\n"
1355        );
1356    }
1357
1358    #[test]
1359    fn tab_indented_fence_is_normalized_to_spaces() {
1360        // Leading tabs expand to the next multiple-of-4 column under
1361        // CommonMark, so simply prepending spaces before a tab would
1362        // silently no-op (the tab snaps back to column 4). The compound
1363        // fence fix must replace the leading whitespace with a fresh
1364        // (visual_indent + delta) run of spaces. A `100. ` item has
1365        // content_column = 5, so a tab-indented fence (visual col 4) is
1366        // under-indented by 1 and must end up at 5 spaces after the fix.
1367        let content = "100. ab\n\n\t```\n\tabcd\n\t```\n";
1368        let expected = "100. ab\n\n     ```\n     abcd\n     ```\n";
1369        assert_eq!(fix(content), expected);
1370    }
1371
1372    // ── GFM task list items: post-checkbox continuation column ───────
1373    //
1374    // MD013's reflow indents wrapped task-list lines at `content_col + 4`
1375    // (the column after the checkbox). MD077 must accept that column for
1376    // both tight and loose continuation, for every marker flavour, so the
1377    // two rules don't fight over well-formed task items (issue #579).
1378
1379    #[test]
1380    fn task_list_tight_continuation_post_checkbox_reproducer_579() {
1381        // Exact reproducer from the bug report: content wraps to the
1382        // post-checkbox column (6) with no blank line.
1383        let content = "- [ ] Lorem ipsum dolor sit amet, consectetur adipiscing\n      tempor incididunt ut labore.\n";
1384        assert!(check(content).is_empty());
1385    }
1386
1387    #[test]
1388    fn task_list_tight_continuation_dash_unchecked() {
1389        let content = "- [ ] Task\n      continuation\n";
1390        assert!(check(content).is_empty());
1391    }
1392
1393    #[test]
1394    fn task_list_tight_continuation_dash_checked_lower() {
1395        let content = "- [x] Task\n      continuation\n";
1396        assert!(check(content).is_empty());
1397    }
1398
1399    #[test]
1400    fn task_list_tight_continuation_dash_checked_upper() {
1401        let content = "- [X] Task\n      continuation\n";
1402        assert!(check(content).is_empty());
1403    }
1404
1405    #[test]
1406    fn task_list_tight_continuation_star_marker() {
1407        let content = "* [ ] Task\n      continuation\n";
1408        assert!(check(content).is_empty());
1409    }
1410
1411    #[test]
1412    fn task_list_tight_continuation_plus_marker() {
1413        let content = "+ [ ] Task\n      continuation\n";
1414        assert!(check(content).is_empty());
1415    }
1416
1417    #[test]
1418    fn task_list_tight_continuation_content_column_still_valid() {
1419        // Column 2 is the CommonMark-canonical indent for "- " and remains
1420        // valid for task items too.
1421        let content = "- [ ] Task\n  continuation\n";
1422        assert!(check(content).is_empty());
1423    }
1424
1425    #[test]
1426    fn task_list_tight_continuation_between_columns_still_flagged() {
1427        // Column 4 matches neither content_col (2) nor post-checkbox (6).
1428        // A genuine indentation mistake — must remain flagged.
1429        let content = "- [ ] Task\n    continuation\n";
1430        let warnings = check(content);
1431        assert_eq!(warnings.len(), 1);
1432        // Task items advertise both valid columns to the user.
1433        assert!(warnings[0].message.contains("expected 2 or 6"));
1434        assert!(warnings[0].message.contains("found 4"));
1435    }
1436
1437    #[test]
1438    fn task_list_tight_continuation_overshoot_still_flagged() {
1439        // Column 7 overshoots the post-checkbox column. Genuine mistake.
1440        let content = "- [ ] Task\n       continuation\n";
1441        let warnings = check(content);
1442        assert_eq!(warnings.len(), 1);
1443        assert!(warnings[0].message.contains("expected 2 or 6"));
1444        assert!(warnings[0].message.contains("found 7"));
1445    }
1446
1447    // ── Task-list fix output: snap to nearer valid column ────────────
1448
1449    #[test]
1450    fn fix_task_list_overshoot_snaps_to_task_col() {
1451        // Col 7 is 1 away from post-checkbox (6), 5 away from content (2).
1452        // Snap to 6 — the author's intent was almost certainly the
1453        // post-checkbox alignment, not the content column.
1454        let content = "- [ ] Task\n       continuation\n";
1455        let fixed = fix(content);
1456        assert_eq!(fixed, "- [ ] Task\n      continuation\n");
1457    }
1458
1459    #[test]
1460    fn fix_task_list_col_5_snaps_to_task_col() {
1461        // Col 5 is 1 away from post-checkbox (6), 3 away from content (2).
1462        let content = "- [ ] Task\n     continuation\n";
1463        let fixed = fix(content);
1464        assert_eq!(fixed, "- [ ] Task\n      continuation\n");
1465    }
1466
1467    #[test]
1468    fn fix_task_list_col_3_snaps_to_content_col() {
1469        // Col 3 is 1 away from content (2), 3 away from post-checkbox (6).
1470        let content = "- [ ] Task\n   continuation\n";
1471        let fixed = fix(content);
1472        assert_eq!(fixed, "- [ ] Task\n  continuation\n");
1473    }
1474
1475    #[test]
1476    fn fix_task_list_col_4_ties_to_content_col() {
1477        // Col 4 is equidistant (±2) from both columns. Tie breaks to the
1478        // CommonMark-canonical content column — that's the default indent
1479        // MD077 would produce for a non-task item, so prefer it when the
1480        // author's intent is ambiguous.
1481        let content = "- [ ] Task\n    continuation\n";
1482        let fixed = fix(content);
1483        assert_eq!(fixed, "- [ ] Task\n  continuation\n");
1484    }
1485
1486    #[test]
1487    fn fix_task_list_ordered_overshoot_snaps_to_task_col() {
1488        // "1. [ ] " → content_col = 3, post-checkbox = 7.
1489        // Col 8 is nearer to 7.
1490        let content = "1. [ ] Task\n        continuation\n";
1491        let fixed = fix(content);
1492        assert_eq!(fixed, "1. [ ] Task\n       continuation\n");
1493    }
1494
1495    #[test]
1496    fn fix_task_list_ordered_under_overshoot_snaps_to_content_col() {
1497        // "1. [ ] " → content_col = 3, post-checkbox = 7.
1498        // Col 4 is nearer to 3.
1499        let content = "1. [ ] Task\n    continuation\n";
1500        let fixed = fix(content);
1501        assert_eq!(fixed, "1. [ ] Task\n   continuation\n");
1502    }
1503
1504    #[test]
1505    fn task_list_tight_continuation_ordered_single_digit() {
1506        // "1. [ ] " → content_col = 3, post-checkbox = 7
1507        let content = "1. [ ] Task\n       continuation\n";
1508        assert!(check(content).is_empty());
1509    }
1510
1511    #[test]
1512    fn task_list_tight_continuation_ordered_multi_digit() {
1513        // "10. [ ] " → content_col = 4, post-checkbox = 8
1514        let content = "10. [ ] Task\n        continuation\n";
1515        assert!(check(content).is_empty());
1516    }
1517
1518    #[test]
1519    fn task_list_tight_continuation_nested_dash() {
1520        // Nested "  - [ ] " at marker_col=2 → content_col=4, post-checkbox=8
1521        let content = "- Parent\n  - [ ] Nested task\n        continuation\n";
1522        assert!(check(content).is_empty());
1523    }
1524
1525    #[test]
1526    fn task_list_loose_continuation_post_checkbox_column_not_flagged() {
1527        // Loose continuation (blank line) at col 6 is also valid. This
1528        // already passed before the fix, but pin the intent: the 6-space
1529        // indent is accepted because it's the task-alignment column, not
1530        // because the under-indent check happens to let it through.
1531        let content = "- [ ] Task\n\n      continuation\n";
1532        assert!(check(content).is_empty());
1533    }
1534
1535    #[test]
1536    fn task_list_empty_body_is_not_a_task() {
1537        // "- [ ]" with nothing after is an empty regular list item, not a
1538        // task. Column 4 continuation has no task alignment to justify it
1539        // and must still be flagged as over-indented. (Col 6 would turn
1540        // the continuation into an indented code block inside the item,
1541        // which is a different code path.)
1542        let content = "- [ ]\n    continuation\n";
1543        let warnings = check(content);
1544        assert_eq!(warnings.len(), 1);
1545        assert!(warnings[0].message.contains("found 4"));
1546    }
1547
1548    #[test]
1549    fn task_list_malformed_checkbox_is_not_a_task() {
1550        // `[~] ` is not a GFM checkbox; only `[ ] `, `[x] `, `[X] ` count.
1551        let content = "- [~] Not a task\n      continuation\n";
1552        let warnings = check(content);
1553        assert_eq!(warnings.len(), 1);
1554    }
1555
1556    // ── MkDocs flavor × task checkbox ─────────────────────────────────
1557    //
1558    // MkDocs strict-indent and task alignment interact: required_min is
1559    // max(content_col, 4), and post-checkbox is content_col + 4. Both are
1560    // independently valid; values between them are flagged.
1561
1562    #[test]
1563    fn task_list_mkdocs_unordered_required_min_valid() {
1564        // "- [ ]" MkDocs: required_min = max(2, 4) = 4, post-checkbox = 6.
1565        let content = "- [ ] Task\n    continuation\n";
1566        assert!(check_mkdocs(content).is_empty());
1567    }
1568
1569    #[test]
1570    fn task_list_mkdocs_unordered_post_checkbox_valid() {
1571        let content = "- [ ] Task\n      continuation\n";
1572        assert!(check_mkdocs(content).is_empty());
1573    }
1574
1575    #[test]
1576    fn task_list_mkdocs_unordered_between_flagged() {
1577        // Column 5 is between required_min=4 and post-checkbox=6.
1578        let content = "- [ ] Task\n     continuation\n";
1579        let warnings = check_mkdocs(content);
1580        assert_eq!(warnings.len(), 1);
1581    }
1582
1583    #[test]
1584    fn task_list_mkdocs_ordered_both_columns_valid() {
1585        // "1. [ ]" MkDocs: required_min = max(3, 4) = 4, post-checkbox = 7.
1586        let at_4 = "1. [ ] Task\n    continuation\n";
1587        assert!(check_mkdocs(at_4).is_empty());
1588        let at_7 = "1. [ ] Task\n       continuation\n";
1589        assert!(check_mkdocs(at_7).is_empty());
1590    }
1591
1592    #[test]
1593    fn task_list_mkdocs_ordered_between_flagged() {
1594        // Column 5 and 6 are between required_min=4 and post-checkbox=7.
1595        let at_5 = "1. [ ] Task\n     continuation\n";
1596        assert_eq!(check_mkdocs(at_5).len(), 1);
1597        let at_6 = "1. [ ] Task\n      continuation\n";
1598        assert_eq!(check_mkdocs(at_6).len(), 1);
1599    }
1600
1601    // ── Context-aware tie-break ──────────────────────────────────────
1602    //
1603    // When a flagged line is exactly equidistant from `content_col` and
1604    // `task_col`, the author's intent is ambiguous. Before picking a
1605    // canonical default, look at whether other continuation lines in the
1606    // same item already use one of the valid columns — if so, snap to the
1607    // column they're using so the fix preserves the author's visible
1608    // convention.
1609
1610    #[test]
1611    fn fix_task_list_tie_sibling_at_task_col_snaps_to_task_col() {
1612        // Col 4 is equidistant from content_col (2) and task_col (6).
1613        // A valid sibling at col 6 proves the author is aligning under the
1614        // checkbox, so the tie resolves to col 6.
1615        let content = "- [ ] Task\n      aligned continuation\n    tied continuation\n";
1616        let fixed = fix(content);
1617        assert_eq!(
1618            fixed,
1619            "- [ ] Task\n      aligned continuation\n      tied continuation\n"
1620        );
1621    }
1622
1623    #[test]
1624    fn fix_task_list_tie_sibling_at_content_col_snaps_to_content_col() {
1625        // Valid sibling at col 2 proves the author is aligning to the
1626        // content column, so the col-4 tie resolves to col 2.
1627        let content = "- [ ] Task\n  aligned continuation\n    tied continuation\n";
1628        let fixed = fix(content);
1629        assert_eq!(fixed, "- [ ] Task\n  aligned continuation\n  tied continuation\n");
1630    }
1631
1632    #[test]
1633    fn fix_task_list_tie_both_siblings_snaps_to_content_col() {
1634        // When siblings exist at both valid columns, the author's pattern
1635        // is self-contradictory. Fall back to the CommonMark-canonical
1636        // content column.
1637        let content = "- [ ] Task\n  at content col\n      at task col\n    tied continuation\n";
1638        let fixed = fix(content);
1639        assert_eq!(
1640            fixed,
1641            "- [ ] Task\n  at content col\n      at task col\n  tied continuation\n"
1642        );
1643    }
1644
1645    #[test]
1646    fn fix_task_list_tie_sees_task_col_through_tight_lazy_continuation() {
1647        // CommonMark allows tight lazy continuation at col ≤ marker_col
1648        // (zero-indent continuation) inside a list item. The pre-pass
1649        // must MIRROR the main check loop's termination semantics: in
1650        // tight mode (no preceding blank) col ≤ marker_col is NOT a
1651        // termination signal — the lazy line still belongs to the item.
1652        //
1653        // This test pins that mirroring: a `lazy` line at col 0 is
1654        // followed by a legitimate task-col sibling at col 6, then a
1655        // tied col-4 line. If the pre-pass terminated eagerly at the
1656        // lazy line, the task-col sibling would be missed and the tied
1657        // line would fall back to content column. With correct
1658        // mirroring, the task-col sibling is seen and the tie resolves
1659        // to col 6.
1660        let content = concat!("- [ ] Task\n", "lazy\n", "      aligned at task col\n", "    tied\n",);
1661        let fixed = fix(content);
1662        assert!(
1663            fixed.contains("\n      tied\n"),
1664            "tied line should snap to col 6 (task col) because a task-col \
1665             sibling is visible past the tight lazy-continuation line; got:\n{fixed}"
1666        );
1667    }
1668
1669    // ── Tab-indented task continuation ───────────────────────────────
1670    //
1671    // Leading tabs expand to the next column that's a multiple of 4 under
1672    // CommonMark. The fix replaces the leading whitespace bytes wholesale,
1673    // turning tabs into space-indented output.
1674
1675    #[test]
1676    fn task_list_tab_indented_continuation_flagged() {
1677        // Two tabs → visual col 8, which overshoots both valid columns
1678        // for `- [ ] ` (content_col=2, task_col=6).
1679        let content = "- [ ] Task\n\t\twrap\n";
1680        let warnings = check(content);
1681        assert_eq!(warnings.len(), 1);
1682        assert!(warnings[0].message.contains("expected 2 or 6"));
1683        assert!(warnings[0].message.contains("found 8"));
1684    }
1685
1686    #[test]
1687    fn fix_task_list_tab_indented_snaps_to_task_col() {
1688        // abs_diff(8, 6) = 2 < abs_diff(8, 2) = 6 → snap to task_col (6).
1689        let content = "- [ ] Task\n\t\twrap\n";
1690        let fixed = fix(content);
1691        assert_eq!(fixed, "- [ ] Task\n      wrap\n");
1692    }
1693
1694    #[test]
1695    fn fix_task_list_single_tab_equidistant_snaps_to_content_col() {
1696        // One tab → visual col 4, equidistant from content_col (2) and
1697        // task_col (6). No siblings → tie-break to content_col.
1698        let content = "- [ ] Task\n\twrap\n";
1699        let fixed = fix(content);
1700        assert_eq!(fixed, "- [ ] Task\n  wrap\n");
1701    }
1702
1703    // ── Blockquote × task-list ───────────────────────────────────────
1704    //
1705    // Blockquote-nested lists are a known limitation on MD077: the list
1706    // parser doesn't always expose them with the same column semantics as
1707    // top-level lists, and the rule prefers a false-negative default to
1708    // avoid spurious warnings inside blockquotes (see
1709    // `blockquote_list_under_indent_no_false_positive`). These tests pin
1710    // the current behavior so any future change is intentional.
1711
1712    #[test]
1713    fn task_list_blockquote_post_checkbox_not_flagged() {
1714        // Post-checkbox alignment inside a blockquote — accepted as valid.
1715        let content = "> - [ ] Task\n>       continuation\n";
1716        assert!(check(content).is_empty());
1717    }
1718
1719    #[test]
1720    fn task_list_blockquote_between_cols_documented_limitation() {
1721        // Col-4-equivalent inside a blockquote is silently accepted — a
1722        // known MD077 limitation on blockquote-nested lists, not a task-
1723        // list-specific choice. Pinning the current behavior.
1724        let content = "> - [ ] Task\n>     continuation\n";
1725        assert!(check(content).is_empty());
1726    }
1727
1728    #[test]
1729    fn task_list_blockquote_overshoot_documented_limitation() {
1730        // Overshoot inside a blockquote — same known limitation.
1731        let content = "> - [ ] Task\n>        continuation\n";
1732        assert!(check(content).is_empty());
1733    }
1734
1735    // ── MkDocs × task × fix output ───────────────────────────────────
1736    //
1737    // MkDocs strict-indent raises `required` to max(content_col, 4) while
1738    // task_col stays at content_col + 4. The snap logic operates on the
1739    // raised required, not on the underlying content_col.
1740
1741    #[test]
1742    fn fix_task_list_mkdocs_unordered_overshoot_snaps_to_task_col() {
1743        // `- [ ]` MkDocs: required=4, task_col=6. Col 7 → abs_diff(7,6)=1
1744        // < abs_diff(7,4)=3. Snap to task_col.
1745        let content = "- [ ] Task\n       continuation\n";
1746        let fixed = fix_mkdocs(content);
1747        assert_eq!(fixed, "- [ ] Task\n      continuation\n");
1748    }
1749
1750    #[test]
1751    fn fix_task_list_mkdocs_unordered_tie_snaps_to_required() {
1752        // `- [ ]` MkDocs: required=4, task_col=6. Col 5 → abs_diff(5,6)=1
1753        // == abs_diff(5,4)=1. Tie with no siblings → required (4).
1754        let content = "- [ ] Task\n     continuation\n";
1755        let fixed = fix_mkdocs(content);
1756        assert_eq!(fixed, "- [ ] Task\n    continuation\n");
1757    }
1758
1759    #[test]
1760    fn fix_task_list_mkdocs_ordered_overshoot_snaps_to_task_col() {
1761        // `1. [ ]` MkDocs: required=4, task_col=7. Col 8 → abs_diff(8,7)=1
1762        // < abs_diff(8,4)=4. Snap to task_col.
1763        let content = "1. [ ] Task\n        continuation\n";
1764        let fixed = fix_mkdocs(content);
1765        assert_eq!(fixed, "1. [ ] Task\n       continuation\n");
1766    }
1767
1768    #[test]
1769    fn fix_task_list_mkdocs_ordered_near_required_snaps_to_required() {
1770        // `1. [ ]` MkDocs: required=4, task_col=7. Col 5 → abs_diff(5,7)=2
1771        // > abs_diff(5,4)=1. Snap to required (4). `1. [ ] Task\n     wrap`
1772        // has actual=5 which is over `required=4` so it's flagged in
1773        // strict mode, while in standard mode it falls under the lazy-
1774        // continuation window and isn't flagged at all.
1775        let content = "1. [ ] Task\n     continuation\n";
1776        let fixed = fix_mkdocs(content);
1777        assert_eq!(fixed, "1. [ ] Task\n    continuation\n");
1778    }
1779
1780    #[test]
1781    fn fix_task_list_mkdocs_ordered_between_cols_snaps_to_task_col() {
1782        // `1. [ ]` MkDocs: required=4, task_col=7. Col 6 → abs_diff(6,7)=1
1783        // < abs_diff(6,4)=2. Snap to task_col (7).
1784        let content = "1. [ ] Task\n      continuation\n";
1785        let fixed = fix_mkdocs(content);
1786        assert_eq!(fixed, "1. [ ] Task\n       continuation\n");
1787    }
1788
1789    // ── Fix idempotency (property test) ──────────────────────────────
1790    //
1791    // A fix pass on already-fixed content must produce the same content
1792    // — otherwise MD077 would oscillate on repeated invocations. This is
1793    // the core property that issue #579 was about (MD077 vs. MD013 fix
1794    // loop), and the integration test covers the MD013 interaction. The
1795    // property tests below pin the *internal* idempotency of MD077's own
1796    // fix, so any future change that introduces oscillation fails fast.
1797
1798    fn assert_idempotent(content: &str) {
1799        let once = fix(content);
1800        let twice = fix(&once);
1801        assert_eq!(once, twice, "MD077 fix was not idempotent on input: {content:?}");
1802    }
1803
1804    fn assert_idempotent_mkdocs(content: &str) {
1805        let once = fix_mkdocs(content);
1806        let twice = fix_mkdocs(&once);
1807        assert_eq!(
1808            once, twice,
1809            "MD077 (MkDocs) fix was not idempotent on input: {content:?}"
1810        );
1811    }
1812
1813    #[test]
1814    fn idempotent_task_list_between_cols() {
1815        assert_idempotent("- [ ] Task\n    continuation\n");
1816    }
1817
1818    #[test]
1819    fn idempotent_task_list_overshoot() {
1820        assert_idempotent("- [ ] Task\n       continuation\n");
1821    }
1822
1823    #[test]
1824    fn idempotent_task_list_under_post_checkbox() {
1825        assert_idempotent("- [ ] Task\n   continuation\n");
1826    }
1827
1828    #[test]
1829    fn idempotent_task_list_near_post_checkbox() {
1830        assert_idempotent("- [ ] Task\n     continuation\n");
1831    }
1832
1833    #[test]
1834    fn idempotent_task_list_tab_overshoot() {
1835        assert_idempotent("- [ ] Task\n\t\twrap\n");
1836    }
1837
1838    #[test]
1839    fn idempotent_task_list_single_tab() {
1840        assert_idempotent("- [ ] Task\n\twrap\n");
1841    }
1842
1843    #[test]
1844    fn idempotent_task_list_ordered_overshoot() {
1845        assert_idempotent("1. [ ] Task\n        continuation\n");
1846    }
1847
1848    #[test]
1849    fn idempotent_task_list_ordered_under() {
1850        assert_idempotent("1. [ ] Task\n    continuation\n");
1851    }
1852
1853    #[test]
1854    fn idempotent_task_list_tie_with_sibling_at_task_col() {
1855        assert_idempotent("- [ ] Task\n      aligned\n    tied\n");
1856    }
1857
1858    #[test]
1859    fn idempotent_task_list_tie_with_sibling_at_content_col() {
1860        assert_idempotent("- [ ] Task\n  aligned\n    tied\n");
1861    }
1862
1863    #[test]
1864    fn idempotent_task_list_mkdocs_unordered_overshoot() {
1865        assert_idempotent_mkdocs("- [ ] Task\n       continuation\n");
1866    }
1867
1868    #[test]
1869    fn idempotent_task_list_mkdocs_unordered_tie() {
1870        assert_idempotent_mkdocs("- [ ] Task\n     continuation\n");
1871    }
1872
1873    #[test]
1874    fn idempotent_task_list_mkdocs_ordered_overshoot() {
1875        assert_idempotent_mkdocs("1. [ ] Task\n        continuation\n");
1876    }
1877
1878    #[test]
1879    fn idempotent_task_list_mkdocs_ordered_between() {
1880        assert_idempotent_mkdocs("1. [ ] Task\n      continuation\n");
1881    }
1882
1883    #[test]
1884    fn idempotent_task_list_reproducer_579() {
1885        // The exact reproducer from issue #579 already has correct indent
1886        // (col 6 = post-checkbox), so idempotency is trivially true. Pin
1887        // it anyway as a smoke test against future regressions.
1888        assert_idempotent(
1889            "- [ ] Lorem ipsum dolor sit amet, consectetur adipiscing\n      tempor incididunt ut labore.\n",
1890        );
1891    }
1892
1893    #[test]
1894    fn idempotent_non_task_list_still_holds() {
1895        // Non-task items never enter the task_col code path; sanity-check
1896        // that idempotency is preserved for them too.
1897        assert_idempotent("1. Item\n    over-indented\n");
1898        assert_idempotent("- Item\n\n continuation\n");
1899    }
1900
1901    // ── Non-task idempotency: loose-mode under-indent ────────────────
1902    //
1903    // When a blank line precedes the continuation (loose mode),
1904    // under-indented content is flagged and fixed up to the content
1905    // column. Idempotency pins that one pass of the fix is sufficient.
1906
1907    #[test]
1908    fn idempotent_non_task_loose_under_indent_ordered() {
1909        // 1. Item → content col 3; "  x" is 2 spaces, under content col.
1910        assert_idempotent("1. Item\n\n  continuation\n");
1911    }
1912
1913    #[test]
1914    fn idempotent_non_task_loose_under_indent_multi_digit() {
1915        // 10. Item → content col 4; single-space continuation needs 4.
1916        assert_idempotent("10. Item\n\n continuation\n");
1917    }
1918
1919    #[test]
1920    fn idempotent_non_task_tight_over_indent_ordered() {
1921        // Tight-mode over-indent: 5 spaces where content col is 3.
1922        assert_idempotent("1. Item\n     over-indented\n");
1923    }
1924
1925    // ── Non-task idempotency: fenced code block compound fix ─────────
1926    //
1927    // A fence opener that needs re-indenting is repaired by the
1928    // compound-fence fix which shifts opener + interior + closer
1929    // together. Idempotency pins that the compound fix settles in one
1930    // pass and does not oscillate between runs.
1931
1932    #[test]
1933    fn idempotent_non_task_fence_ordered_loose() {
1934        // 1. Item → content col 3; fence at col 2 needs to shift to 3.
1935        assert_idempotent("1. Item\n\n  ```rust\n  let x = 1;\n  ```\n");
1936    }
1937
1938    #[test]
1939    fn idempotent_non_task_fence_tilde_under_indent() {
1940        // Tilde fences use the same compound-fix path as backtick fences.
1941        // Interior below the list scope (col 0 here, required col 3) must
1942        // be promoted up in the same pass as the fence delimiters —
1943        // otherwise a second pass would flag the interior individually
1944        // and defeat idempotency.
1945        assert_idempotent("1. Item\n\n  ~~~\nplain text\n  ~~~\n");
1946    }
1947
1948    #[test]
1949    fn idempotent_non_task_fence_interior_above_required() {
1950        // Interior already above the required column must not be pushed
1951        // further up by the compound fix — authored interior indentation
1952        // is preserved when it doesn't threaten fence pairing.
1953        assert_idempotent("1. Item\n\n  ```\n    deeply indented code\n  ```\n");
1954    }
1955
1956    #[test]
1957    fn fence_fix_promotes_interior_below_scope_in_single_pass() {
1958        // Concrete behavioral check, not just idempotency:
1959        // interior at col 0 with opener at col 2, required 3, must land
1960        // at col 3 (same as opener) so fence pairing is preserved.
1961        let content = "1. Item\n\n  ```\ncode\n  ```\n";
1962        let fixed = fix(content);
1963        assert_eq!(fixed, "1. Item\n\n   ```\n   code\n   ```\n");
1964    }
1965
1966    #[test]
1967    fn fence_fix_preserves_interior_above_required() {
1968        // Opener at col 2 → col 3 (required). Interior at col 4 stays at
1969        // col 4 (above required, no need to push it).
1970        let content = "1. Item\n\n  ```\n    code\n  ```\n";
1971        let fixed = fix(content);
1972        assert_eq!(fixed, "1. Item\n\n   ```\n    code\n   ```\n");
1973    }
1974
1975    // ── Non-task idempotency: MkDocs strict-indent ───────────────────
1976    //
1977    // Under MkDocs flavor, continuation requires max(content_col, 4),
1978    // which can force a fix even when CommonMark would accept the
1979    // content. Pin idempotency for the non-task path there too.
1980
1981    #[test]
1982    fn idempotent_non_task_mkdocs_ordered_at_3_spaces() {
1983        // CommonMark-valid (3 spaces) but MkDocs demands 4 → fix runs.
1984        assert_idempotent_mkdocs("1. Item\n\n   continuation\n");
1985    }
1986
1987    #[test]
1988    fn idempotent_non_task_mkdocs_unordered_at_2_spaces() {
1989        // "- Item" → content col 2, but MkDocs raises the floor to 4.
1990        assert_idempotent_mkdocs("- Item\n\n  continuation\n");
1991    }
1992
1993    #[test]
1994    fn idempotent_non_task_mkdocs_fence_compound() {
1995        // MkDocs non-task fence: opener/interior/closer shift together.
1996        assert_idempotent_mkdocs("1. Item\n\n   ```toml\n   k = 1\n   ```\n");
1997    }
1998}