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