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