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