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 crate::lint_context::LintContext;
7use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
8
9/// Rule MD077: List continuation content indentation
10///
11/// Checks two cases:
12/// - **Loose continuation** (after a blank line): content must be indented to the
13///   item's content column (W+N rule), or it falls out of the list.
14/// - **Tight continuation** (no blank line): content must not be over-indented
15///   beyond the item's content column.
16///
17/// Under the MkDocs flavor, a minimum of 4 spaces is enforced for ordered list
18/// items to satisfy Python-Markdown.
19#[derive(Clone, Default)]
20pub struct MD077ListContinuationIndent;
21
22impl MD077ListContinuationIndent {
23    /// Check if a trimmed line is a block-level construct (not list continuation).
24    fn is_block_level_construct(trimmed: &str) -> bool {
25        // Footnote definition: [^label]:
26        if trimmed.starts_with("[^") && trimmed.contains("]:") {
27            return true;
28        }
29        // Abbreviation definition: *[text]:
30        if trimmed.starts_with("*[") && trimmed.contains("]:") {
31            return true;
32        }
33        // Reference link definition: [label]: url
34        // Must start with [ but not be a regular link, footnote, or abbreviation
35        if trimmed.starts_with('[') && !trimmed.starts_with("[^") && trimmed.contains("]: ") {
36            return true;
37        }
38        false
39    }
40
41    /// Check if a trimmed line is a fenced code block delimiter (opener or closer).
42    fn is_code_fence(trimmed: &str) -> bool {
43        let bytes = trimmed.as_bytes();
44        if bytes.len() < 3 {
45            return false;
46        }
47        let ch = bytes[0];
48        (ch == b'`' || ch == b'~') && bytes[1] == ch && bytes[2] == ch
49    }
50
51    /// Check if a trimmed line starts with a list marker (*, -, +, or ordered).
52    /// Used to avoid flagging deeply indented list items that the parser doesn't
53    /// recognize as list items (e.g., with indent=8 configured in MD007).
54    fn starts_with_list_marker(trimmed: &str) -> bool {
55        let bytes = trimmed.as_bytes();
56        match bytes.first() {
57            Some(b'*' | b'-' | b'+') => bytes.get(1).is_some_and(|&b| b == b' ' || b == b'\t'),
58            Some(b'0'..=b'9') => {
59                let rest = trimmed.trim_start_matches(|c: char| c.is_ascii_digit());
60                rest.starts_with(". ") || rest.starts_with(") ")
61            }
62            _ => false,
63        }
64    }
65
66    /// Given the line number of a fenced code block opener, walk forward and
67    /// return the line number of the matching closer. Returns the opener itself
68    /// if no following line is in the code block (degenerate single-line block).
69    fn find_fence_closer(ctx: &LintContext, opener_line: usize) -> usize {
70        let mut closer_line = opener_line;
71        for peek in (opener_line + 1)..=ctx.lines.len() {
72            let Some(peek_info) = ctx.line_info(peek) else { break };
73            if peek_info.in_code_block {
74                closer_line = peek;
75            } else {
76                break;
77            }
78        }
79        closer_line
80    }
81
82    /// Build an atomic fix that reindents a fenced code block from its opener
83    /// through its matching closer, shifting every non-blank line's leading
84    /// whitespace by the same visual delta. Preserves relative indentation of
85    /// code content inside the block so the parser keeps pairing the fences.
86    ///
87    /// Leading tabs are normalized to spaces: CommonMark expands a tab to the
88    /// next column that's a multiple of 4, so simply prepending spaces before
89    /// a tab would let the tab snap back and cancel the shift. We replace the
90    /// whole leading-whitespace byte range with `(visual_indent + delta)`
91    /// spaces instead.
92    ///
93    /// This prevents other rules (notably MD031) from seeing a transiently
94    /// broken fence pair between iterations of the fix loop (see issue #574).
95    fn build_compound_fence_fix(
96        ctx: &LintContext,
97        opener_line: usize,
98        closer_line: usize,
99        opener_actual: usize,
100        required: usize,
101    ) -> Option<Fix> {
102        let opener_info = ctx.line_info(opener_line)?;
103        let closer_info = ctx.line_info(closer_line)?;
104        let delta = required.saturating_sub(opener_actual);
105        if delta == 0 {
106            return None;
107        }
108
109        let fix_start = opener_info.byte_offset;
110        let fix_end = closer_info.byte_offset + closer_info.byte_len;
111
112        let mut replacement = String::new();
113        for i in opener_line..=closer_line {
114            let info = ctx.line_info(i)?;
115            if i > opener_line {
116                replacement.push('\n');
117            }
118            let line = info.content(ctx.content);
119            if info.is_blank {
120                // Blank lines have no content to shift; preserve verbatim.
121                replacement.push_str(line);
122            } else {
123                let new_visual = info.visual_indent + delta;
124                for _ in 0..new_visual {
125                    replacement.push(' ');
126                }
127                replacement.push_str(&line[info.indent..]);
128            }
129        }
130
131        Some(Fix {
132            range: fix_start..fix_end,
133            replacement,
134        })
135    }
136
137    /// Check if a line should be skipped (inside code, HTML, frontmatter, etc.)
138    ///
139    /// Code block *content* is skipped, but fence opener/closer lines are not —
140    /// their indentation matters for list continuation in MkDocs.
141    fn should_skip_line(info: &crate::lint_context::LineInfo, trimmed: &str) -> bool {
142        if info.in_code_block && !Self::is_code_fence(trimmed) {
143            return true;
144        }
145        info.in_front_matter
146            || info.in_html_block
147            || info.in_html_comment
148            || info.in_mdx_comment
149            || info.in_mkdocstrings
150            || info.in_esm_block
151            || info.in_math_block
152            || info.in_admonition
153            || info.in_content_tab
154            || info.in_pymdown_block
155            || info.in_definition_list
156            || info.in_mkdocs_html_markdown
157            || info.in_kramdown_extension_block
158    }
159}
160
161impl Rule for MD077ListContinuationIndent {
162    fn name(&self) -> &'static str {
163        "MD077"
164    }
165
166    fn description(&self) -> &'static str {
167        "List continuation content indentation"
168    }
169
170    fn check(&self, ctx: &LintContext) -> LintResult {
171        if ctx.content.is_empty() {
172            return Ok(Vec::new());
173        }
174
175        let strict_indent = ctx.flavor.requires_strict_list_indent();
176        let total_lines = ctx.lines.len();
177        let mut warnings = Vec::new();
178        let mut flagged_lines = std::collections::HashSet::new();
179
180        // Collect all list item lines sorted, with their content_column and marker_column.
181        // We need this to compute owned ranges that extend past block.end_line
182        // (the parser excludes under-indented continuation from the block).
183        let mut items: Vec<(usize, usize, usize)> = Vec::new(); // (line_num, marker_col, content_col)
184        for block in &ctx.list_blocks {
185            for &item_line in &block.item_lines {
186                if let Some(info) = ctx.line_info(item_line)
187                    && let Some(ref li) = info.list_item
188                {
189                    items.push((item_line, li.marker_column, li.content_column));
190                }
191            }
192        }
193        items.sort_unstable();
194        items.dedup_by_key(|&mut (ln, _, _)| ln);
195
196        for (item_idx, &(item_line, marker_col, content_col)) in items.iter().enumerate() {
197            let required = if strict_indent { content_col.max(4) } else { content_col };
198
199            // Owned range ends at the line before the next sibling-or-higher
200            // item, or end of document.
201            let range_end = items
202                .iter()
203                .skip(item_idx + 1)
204                .find(|&&(_, mc, _)| mc <= marker_col)
205                .map_or(total_lines, |&(ln, _, _)| ln - 1);
206
207            let mut saw_blank = false;
208            // Track nested child items so we don't check their continuation
209            // lines against the parent's content column.
210            let mut nested_content_col: Option<usize> = None;
211
212            for line_num in (item_line + 1)..=range_end {
213                let Some(line_info) = ctx.line_info(line_num) else {
214                    continue;
215                };
216
217                let trimmed = line_info.content(ctx.content).trim_start();
218
219                if Self::should_skip_line(line_info, trimmed) {
220                    continue;
221                }
222
223                if line_info.is_blank {
224                    saw_blank = true;
225                    continue;
226                }
227
228                // Nested list items are not continuation content
229                if let Some(ref li) = line_info.list_item {
230                    if li.marker_column > marker_col {
231                        nested_content_col = Some(li.content_column);
232                    } else {
233                        nested_content_col = None;
234                    }
235                    saw_blank = false;
236                    continue;
237                }
238
239                // Skip headings - they clearly aren't list continuation
240                if line_info.heading.is_some() {
241                    break;
242                }
243
244                // Skip horizontal rules
245                if line_info.is_horizontal_rule {
246                    break;
247                }
248
249                // Skip block-level constructs that aren't list continuation:
250                // reference definitions, footnote definitions, abbreviation definitions
251                if Self::is_block_level_construct(trimmed) {
252                    continue;
253                }
254
255                let actual = line_info.visual_indent;
256
257                // Lines belonging to a nested item's scope are handled by
258                // that item's own iteration — skip them here.
259                if let Some(ncc) = nested_content_col {
260                    if actual >= ncc {
261                        continue;
262                    }
263                    nested_content_col = None;
264                }
265
266                // Tight continuation (no blank line): flag over-indented lines
267                if !saw_blank {
268                    if actual > required && !Self::starts_with_list_marker(trimmed) && flagged_lines.insert(line_num) {
269                        let line_content = line_info.content(ctx.content);
270                        let fix_start = line_info.byte_offset;
271                        let fix_end = fix_start + line_info.indent;
272
273                        warnings.push(LintWarning {
274                            rule_name: Some("MD077".to_string()),
275                            line: line_num,
276                            column: 1,
277                            end_line: line_num,
278                            end_column: line_content.len() + 1,
279                            message: format!("Continuation line over-indented (expected {required}, found {actual})",),
280                            severity: Severity::Warning,
281                            fix: Some(Fix {
282                                range: fix_start..fix_end,
283                                replacement: " ".repeat(required),
284                            }),
285                        });
286                    }
287                    continue;
288                }
289
290                // Content at or below the marker column is not continuation —
291                // it starts a new paragraph (top-level) or belongs to a
292                // parent item (nested).
293                if actual <= marker_col {
294                    break;
295                }
296
297                if actual < required && flagged_lines.insert(line_num) {
298                    let line_content = line_info.content(ctx.content);
299
300                    let message = if strict_indent {
301                        format!(
302                            "Content inside list item needs {required} spaces of indentation \
303                             for MkDocs compatibility (found {actual})",
304                        )
305                    } else {
306                        format!(
307                            "Content after blank line in list item needs {required} spaces of \
308                             indentation to remain part of the list (found {actual})",
309                        )
310                    };
311
312                    // If this is the opener of a fenced code block, emit a
313                    // compound fix that reindents the whole block (opener +
314                    // interior + closer). Fixing only the fence lines while
315                    // leaving interior content at the old indent creates a
316                    // transiently-broken fence pair that MD031 (which runs in
317                    // the same iterative-fix loop) misreads as orphan fences
318                    // and "repairs" by inserting stray blank lines.
319                    let is_fence_opener = line_info.in_code_block
320                        && Self::is_code_fence(trimmed)
321                        && ctx.line_info(line_num - 1).is_none_or(|p| !p.in_code_block);
322
323                    let (fix, warn_end_line, warn_end_column) = if is_fence_opener {
324                        let closer_line = Self::find_fence_closer(ctx, line_num);
325                        if closer_line != line_num {
326                            flagged_lines.insert(closer_line);
327                        }
328                        let fix = Self::build_compound_fence_fix(ctx, line_num, closer_line, actual, required);
329                        let end_line = closer_line;
330                        let end_column = ctx
331                            .line_info(closer_line)
332                            .map_or(line_content.len() + 1, |ci| ci.content(ctx.content).len() + 1);
333                        (fix, end_line, end_column)
334                    } else {
335                        let fix_start = line_info.byte_offset;
336                        let fix_end = fix_start + line_info.indent;
337                        let fix = Some(Fix {
338                            range: fix_start..fix_end,
339                            replacement: " ".repeat(required),
340                        });
341                        (fix, line_num, line_content.len() + 1)
342                    };
343
344                    warnings.push(LintWarning {
345                        rule_name: Some("MD077".to_string()),
346                        line: line_num,
347                        column: 1,
348                        end_line: warn_end_line,
349                        end_column: warn_end_column,
350                        message,
351                        severity: Severity::Warning,
352                        fix,
353                    });
354                }
355
356                // Intentionally keep `saw_blank = true` while scanning this
357                // owned item range so that *all* lines in a loose continuation
358                // paragraph are validated/fixed, not just the first line after
359                // the blank.
360            }
361        }
362
363        Ok(warnings)
364    }
365
366    fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
367        let warnings = self.check(ctx)?;
368        let warnings =
369            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
370        if warnings.is_empty() {
371            return Ok(ctx.content.to_string());
372        }
373
374        // Sort fixes by byte position descending to apply from end to start
375        let mut fixes: Vec<Fix> = warnings.into_iter().filter_map(|w| w.fix).collect();
376        fixes.sort_by_key(|f| std::cmp::Reverse(f.range.start));
377
378        let mut content = ctx.content.to_string();
379        for fix in fixes {
380            if fix.range.start <= content.len() && fix.range.end <= content.len() {
381                content.replace_range(fix.range, &fix.replacement);
382            }
383        }
384
385        Ok(content)
386    }
387
388    fn category(&self) -> RuleCategory {
389        RuleCategory::List
390    }
391
392    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
393        ctx.content.is_empty() || ctx.list_blocks.is_empty()
394    }
395
396    fn as_any(&self) -> &dyn std::any::Any {
397        self
398    }
399
400    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
401    where
402        Self: Sized,
403    {
404        Box::new(Self)
405    }
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411    use crate::config::MarkdownFlavor;
412
413    fn check(content: &str) -> Vec<LintWarning> {
414        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
415        let rule = MD077ListContinuationIndent;
416        rule.check(&ctx).unwrap()
417    }
418
419    fn check_mkdocs(content: &str) -> Vec<LintWarning> {
420        let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
421        let rule = MD077ListContinuationIndent;
422        rule.check(&ctx).unwrap()
423    }
424
425    fn fix(content: &str) -> String {
426        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
427        let rule = MD077ListContinuationIndent;
428        rule.fix(&ctx).unwrap()
429    }
430
431    fn fix_mkdocs(content: &str) -> String {
432        let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
433        let rule = MD077ListContinuationIndent;
434        rule.fix(&ctx).unwrap()
435    }
436
437    // ── Tight continuation (no blank line) ─────────────────────────────
438
439    #[test]
440    fn tight_lazy_continuation_zero_indent_not_flagged() {
441        // Zero-indent lazy continuation is valid CommonMark
442        let content = "- Item\ncontinuation\n";
443        assert!(check(content).is_empty());
444    }
445
446    #[test]
447    fn tight_continuation_correct_indent_not_flagged() {
448        // Correctly indented tight continuation (aligns with content column)
449        let content = "1. Item\n   continuation\n";
450        assert!(check(content).is_empty());
451    }
452
453    #[test]
454    fn tight_continuation_over_indented_ordered() {
455        // "1. " = 3 chars, but continuation has 4 spaces
456        let content = "1. This is a list item with multiple lines.\n    The second line is over-indented.\n";
457        let warnings = check(content);
458        assert_eq!(warnings.len(), 1);
459        assert_eq!(warnings[0].line, 2);
460        assert!(warnings[0].message.contains("over-indented"));
461    }
462
463    #[test]
464    fn tight_continuation_over_indented_unordered() {
465        // "- " = 2 chars, but continuation has 3 spaces
466        let content = "- Item\n   over-indented\n";
467        let warnings = check(content);
468        assert_eq!(warnings.len(), 1);
469        assert_eq!(warnings[0].line, 2);
470    }
471
472    #[test]
473    fn tight_continuation_multiple_over_indented_lines() {
474        let content = "1. Item\n    line one\n    line two\n    line three\n";
475        let warnings = check(content);
476        assert_eq!(warnings.len(), 3);
477    }
478
479    #[test]
480    fn tight_continuation_mixed_correct_and_over() {
481        let content = "1. Item\n   correct\n    over-indented\n   correct again\n";
482        let warnings = check(content);
483        assert_eq!(warnings.len(), 1);
484        assert_eq!(warnings[0].line, 3);
485    }
486
487    #[test]
488    fn tight_continuation_nested_over_indented() {
489        // L2 "- " at column 2, content_column = 4. Continuation at 5 is over-indented for L2.
490        let content = "- L1\n  - L2\n     over-indented continuation of L2\n";
491        let warnings = check(content);
492        assert_eq!(warnings.len(), 1);
493        assert_eq!(warnings[0].line, 3);
494        // Must report expected=4 (L2's content_col), not expected=2 (L1's)
495        assert!(warnings[0].message.contains("expected 4"));
496        assert!(warnings[0].message.contains("found 5"));
497    }
498
499    #[test]
500    fn tight_continuation_nested_correct_indent_not_flagged() {
501        // Continuation at 4 spaces is correct for L2 (content_col=4). Must NOT be
502        // flagged as over-indented relative to L1 (content_col=2).
503        let content = "- L1\n  - L2\n    correctly indented continuation of L2\n";
504        assert!(check(content).is_empty());
505    }
506
507    #[test]
508    fn fix_tight_continuation_nested_over_indented() {
509        // Fix should reduce to 4 spaces (L2's content_col), not 2 (L1's)
510        let content = "- L1\n  - L2\n     over-indented continuation of L2\n";
511        let fixed = fix(content);
512        assert_eq!(fixed, "- L1\n  - L2\n    over-indented continuation of L2\n");
513    }
514
515    #[test]
516    fn tight_continuation_under_indented_not_flagged() {
517        // 2 spaces instead of 3 for "1. " — under-indented, not over-indented.
518        // Valid lazy continuation in CommonMark, so not flagged.
519        let content = "1. Item\n  under-indented\n";
520        assert!(check(content).is_empty());
521    }
522
523    #[test]
524    fn tight_continuation_tab_over_indented() {
525        // A tab expands to 4 visual columns, which exceeds content_col=2 for "- "
526        let content = "- Item\n\tover-indented\n";
527        let warnings = check(content);
528        assert_eq!(warnings.len(), 1);
529    }
530
531    #[test]
532    fn fix_tight_continuation_over_indented_ordered() {
533        let content = "1. This is a list item with multiple lines.\n    The second line is over-indented.\n";
534        let fixed = fix(content);
535        assert_eq!(
536            fixed,
537            "1. This is a list item with multiple lines.\n   The second line is over-indented.\n"
538        );
539    }
540
541    #[test]
542    fn fix_tight_continuation_over_indented_unordered() {
543        let content = "- Item\n   over-indented\n";
544        let fixed = fix(content);
545        assert_eq!(fixed, "- Item\n  over-indented\n");
546    }
547
548    #[test]
549    fn fix_tight_continuation_multiple_lines() {
550        let content = "1. Item\n    line one\n    line two\n";
551        let fixed = fix(content);
552        assert_eq!(fixed, "1. Item\n   line one\n   line two\n");
553    }
554
555    #[test]
556    fn tight_continuation_mkdocs_4space_ordered_not_flagged() {
557        // MkDocs requires max(3, 4) = 4 spaces for "1. " items.
558        // 4-space tight continuation is correct, not over-indented.
559        let content = "1. Item\n    continuation\n";
560        assert!(check_mkdocs(content).is_empty());
561    }
562
563    #[test]
564    fn tight_continuation_mkdocs_5space_ordered_flagged() {
565        // 5 spaces exceeds the MkDocs required indent of 4
566        let content = "1. Item\n     over-indented\n";
567        let warnings = check_mkdocs(content);
568        assert_eq!(warnings.len(), 1);
569        assert!(warnings[0].message.contains("expected 4"));
570        assert!(warnings[0].message.contains("found 5"));
571    }
572
573    #[test]
574    fn fix_tight_continuation_mkdocs_over_indented() {
575        let content = "1. Item\n     over-indented\n";
576        let fixed = fix_mkdocs(content);
577        assert_eq!(fixed, "1. Item\n    over-indented\n");
578    }
579
580    #[test]
581    fn tight_continuation_deeply_indented_list_markers_not_flagged() {
582        // Deeply indented list markers (e.g., indent=8 in MD007) may not be
583        // recognized as list items by the parser. MD077 must not flag them.
584        let content = "* Level 0\n        * Level 1\n                * Level 2\n";
585        assert!(check(content).is_empty());
586    }
587
588    #[test]
589    fn tight_continuation_ordered_marker_not_flagged() {
590        // Indented ordered list marker should not be flagged
591        let content = "- Parent\n      1. Child item\n";
592        assert!(check(content).is_empty());
593    }
594
595    // ── Unordered list: correct indent after blank ────────────────────
596
597    #[test]
598    fn unordered_correct_indent_no_warning() {
599        let content = "- Item\n\n  continuation\n";
600        assert!(check(content).is_empty());
601    }
602
603    #[test]
604    fn unordered_partial_indent_warns() {
605        // Content with some indent (above marker column) but less than
606        // content_column is likely an indentation mistake.
607        let content = "- Item\n\n continuation\n";
608        let warnings = check(content);
609        assert_eq!(warnings.len(), 1);
610        assert_eq!(warnings[0].line, 3);
611        assert!(warnings[0].message.contains("2 spaces"));
612        assert!(warnings[0].message.contains("found 1"));
613    }
614
615    #[test]
616    fn unordered_zero_indent_is_new_paragraph() {
617        // Content at 0 indent after a top-level list is a new paragraph, not
618        // under-indented continuation.
619        let content = "- Item\n\ncontinuation\n";
620        assert!(check(content).is_empty());
621    }
622
623    // ── Ordered list: CommonMark W+N ──────────────────────────────────
624
625    #[test]
626    fn ordered_3space_correct_commonmark() {
627        // "1. " is 3 chars, content_column = 3
628        let content = "1. Item\n\n   continuation\n";
629        assert!(check(content).is_empty());
630    }
631
632    #[test]
633    fn ordered_2space_under_indent_commonmark() {
634        let content = "1. Item\n\n  continuation\n";
635        let warnings = check(content);
636        assert_eq!(warnings.len(), 1);
637        assert!(warnings[0].message.contains("3 spaces"));
638        assert!(warnings[0].message.contains("found 2"));
639    }
640
641    // ── Multi-digit ordered markers ───────────────────────────────────
642
643    #[test]
644    fn multi_digit_marker_correct() {
645        // "10. " is 4 chars, content_column = 4
646        let content = "10. Item\n\n    continuation\n";
647        assert!(check(content).is_empty());
648    }
649
650    #[test]
651    fn multi_digit_marker_under_indent() {
652        let content = "10. Item\n\n   continuation\n";
653        let warnings = check(content);
654        assert_eq!(warnings.len(), 1);
655        assert!(warnings[0].message.contains("4 spaces"));
656    }
657
658    // ── MkDocs flavor: 4-space minimum ────────────────────────────────
659
660    #[test]
661    fn mkdocs_3space_ordered_warns() {
662        // In MkDocs mode, 3-space indent on "1. " is not enough
663        let content = "1. Item\n\n   continuation\n";
664        let warnings = check_mkdocs(content);
665        assert_eq!(warnings.len(), 1);
666        assert!(warnings[0].message.contains("4 spaces"));
667        assert!(warnings[0].message.contains("MkDocs"));
668    }
669
670    #[test]
671    fn mkdocs_4space_ordered_no_warning() {
672        let content = "1. Item\n\n    continuation\n";
673        assert!(check_mkdocs(content).is_empty());
674    }
675
676    #[test]
677    fn mkdocs_unordered_2space_ok() {
678        // Unordered "- " has content_column = 2; max(2, 4) = 4 in mkdocs
679        let content = "- Item\n\n    continuation\n";
680        assert!(check_mkdocs(content).is_empty());
681    }
682
683    #[test]
684    fn mkdocs_unordered_2space_warns() {
685        // "- " has content_column 2; MkDocs requires max(2,4) = 4
686        let content = "- Item\n\n  continuation\n";
687        let warnings = check_mkdocs(content);
688        assert_eq!(warnings.len(), 1);
689        assert!(warnings[0].message.contains("4 spaces"));
690    }
691
692    // ── Auto-fix ──────────────────────────────────────────────────────
693
694    #[test]
695    fn fix_unordered_indent() {
696        // Partial indent (above marker column, below content column) gets fixed
697        let content = "- Item\n\n continuation\n";
698        let fixed = fix(content);
699        assert_eq!(fixed, "- Item\n\n  continuation\n");
700    }
701
702    #[test]
703    fn fix_ordered_indent() {
704        let content = "1. Item\n\n continuation\n";
705        let fixed = fix(content);
706        assert_eq!(fixed, "1. Item\n\n   continuation\n");
707    }
708
709    #[test]
710    fn fix_mkdocs_indent() {
711        let content = "1. Item\n\n   continuation\n";
712        let fixed = fix_mkdocs(content);
713        assert_eq!(fixed, "1. Item\n\n    continuation\n");
714    }
715
716    // ── Nested lists: only flag continuation, not sub-items ───────────
717
718    #[test]
719    fn nested_list_items_not_flagged() {
720        let content = "- Parent\n\n  - Child\n";
721        assert!(check(content).is_empty());
722    }
723
724    #[test]
725    fn nested_list_zero_indent_is_new_paragraph() {
726        // Content at 0 indent ends the list, not continuation
727        let content = "- Parent\n  - Child\n\ncontinuation of parent\n";
728        assert!(check(content).is_empty());
729    }
730
731    #[test]
732    fn nested_list_partial_indent_flagged() {
733        // Content with partial indent (above parent marker, below content col)
734        let content = "- Parent\n  - Child\n\n continuation of parent\n";
735        let warnings = check(content);
736        assert_eq!(warnings.len(), 1);
737        assert!(warnings[0].message.contains("2 spaces"));
738    }
739
740    // ── Code blocks inside items ─────────────────────────────────────
741
742    #[test]
743    fn code_block_correctly_indented_no_warning() {
744        // Fence lines and content all at correct indent for "- " (content_column = 2)
745        let content = "- Item\n\n  ```\n  code\n  ```\n";
746        assert!(check(content).is_empty());
747    }
748
749    #[test]
750    fn code_fence_under_indented_warns() {
751        // Fence opener has 1-space indent, but "- " needs 2.
752        // Only the opener is flagged — its compound fix also covers the
753        // interior content and the matching closer (see issue #574).
754        let content = "- Item\n\n ```\n code\n ```\n";
755        let warnings = check(content);
756        assert_eq!(warnings.len(), 1);
757        assert_eq!(warnings[0].line, 3);
758    }
759
760    #[test]
761    fn code_fence_under_indented_ordered_mkdocs() {
762        // Ordered list in MkDocs: "1. " needs max(3, 4) = 4 spaces
763        // Fence at 3 spaces is correct for CommonMark but wrong for MkDocs
764        let content = "1. Item\n\n   ```toml\n   key = \"value\"\n   ```\n";
765        assert!(check(content).is_empty()); // Standard mode: 3 is fine
766        let warnings = check_mkdocs(content);
767        assert_eq!(warnings.len(), 1); // MkDocs: opener's compound fix covers the whole block
768        assert_eq!(warnings[0].line, 3);
769        assert!(warnings[0].message.contains("4 spaces"));
770        assert!(warnings[0].message.contains("MkDocs"));
771    }
772
773    #[test]
774    fn code_fence_tilde_under_indented() {
775        let content = "- Item\n\n ~~~\n code\n ~~~\n";
776        let warnings = check(content);
777        assert_eq!(warnings.len(), 1); // Tilde fences: single compound-fix warning on opener
778        assert_eq!(warnings[0].line, 3);
779    }
780
781    // ── Multiple blank lines ──────────────────────────────────────────
782
783    #[test]
784    fn multiple_blank_lines_zero_indent_is_new_paragraph() {
785        // Even with multiple blanks, 0-indent content is a new paragraph
786        let content = "- Item\n\n\ncontinuation\n";
787        assert!(check(content).is_empty());
788    }
789
790    #[test]
791    fn multiple_blank_lines_partial_indent_flags() {
792        let content = "- Item\n\n\n continuation\n";
793        let warnings = check(content);
794        assert_eq!(warnings.len(), 1);
795    }
796
797    // ── Empty items: no continuation to check ─────────────────────────
798
799    #[test]
800    fn empty_item_no_warning() {
801        let content = "- \n- Second\n";
802        assert!(check(content).is_empty());
803    }
804
805    // ── Multiple items, only some under-indented ──────────────────────
806
807    #[test]
808    fn multiple_items_mixed_indent() {
809        let content = "1. First\n\n   correct continuation\n\n2. Second\n\n  wrong continuation\n";
810        let warnings = check(content);
811        assert_eq!(warnings.len(), 1);
812        assert_eq!(warnings[0].line, 7);
813    }
814
815    // ── Task list items ───────────────────────────────────────────────
816
817    #[test]
818    fn task_list_correct_indent() {
819        // "- [ ] " = content_column is typically at col 6
820        let content = "- [ ] Task\n\n      continuation\n";
821        assert!(check(content).is_empty());
822    }
823
824    // ── Frontmatter skipped ───────────────────────────────────────────
825
826    #[test]
827    fn frontmatter_not_flagged() {
828        let content = "---\ntitle: test\n---\n\n- Item\n\n  continuation\n";
829        assert!(check(content).is_empty());
830    }
831
832    // ── Fix produces valid output with multiple fixes ─────────────────
833
834    #[test]
835    fn fix_multiple_items() {
836        let content = "1. First\n\n wrong1\n\n2. Second\n\n wrong2\n";
837        let fixed = fix(content);
838        assert_eq!(fixed, "1. First\n\n   wrong1\n\n2. Second\n\n   wrong2\n");
839    }
840
841    #[test]
842    fn fix_multiline_loose_continuation_all_lines() {
843        let content = "1. Item\n\n  line one\n  line two\n  line three\n";
844        let fixed = fix(content);
845        assert_eq!(fixed, "1. Item\n\n   line one\n   line two\n   line three\n");
846    }
847
848    // ── No false positive when content is after sibling item ──────────
849
850    #[test]
851    fn sibling_item_boundary_respected() {
852        // The "continuation" after a blank belongs to "- Second", not "- First"
853        let content = "- First\n- Second\n\n  continuation\n";
854        assert!(check(content).is_empty());
855    }
856
857    // ── Blockquote-nested lists ────────────────────────────────────────
858
859    #[test]
860    fn blockquote_list_correct_indent_no_warning() {
861        // Lists inside blockquotes: visual_indent includes the blockquote
862        // prefix, so comparisons work on raw line columns.
863        let content = "> - Item\n>\n>   continuation\n";
864        assert!(check(content).is_empty());
865    }
866
867    #[test]
868    fn blockquote_list_under_indent_no_false_positive() {
869        // Under-indented continuation inside a blockquote: visual_indent
870        // starts at 0 (the `>` char) which is <= marker_col, so the scan
871        // breaks and no warning is emitted. This is a known false negative
872        // (not a false positive), which is the safer default.
873        let content = "> - Item\n>\n> continuation\n";
874        assert!(check(content).is_empty());
875    }
876
877    // ── Deep nesting (3+ levels) ──────────────────────────────────────
878
879    #[test]
880    fn deeply_nested_correct_indent() {
881        let content = "- L1\n  - L2\n    - L3\n\n      continuation of L3\n";
882        assert!(check(content).is_empty());
883    }
884
885    #[test]
886    fn deeply_nested_under_indent() {
887        // L3 starts at column 4 with "- " marker, content_column = 6
888        // Continuation with 5 spaces is under-indented for L3.
889        let content = "- L1\n  - L2\n    - L3\n\n     continuation of L3\n";
890        let warnings = check(content);
891        assert_eq!(warnings.len(), 1);
892        assert!(warnings[0].message.contains("6 spaces"));
893        assert!(warnings[0].message.contains("found 5"));
894    }
895
896    // ── Tab indentation ───────────────────────────────────────────────
897
898    #[test]
899    fn tab_indent_correct() {
900        // A tab at the start expands to 4 visual columns, which satisfies
901        // "- " (content_column = 2).
902        let content = "- Item\n\n\tcontinuation\n";
903        assert!(check(content).is_empty());
904    }
905
906    // ── Multiple continuation paragraphs ──────────────────────────────
907
908    #[test]
909    fn multiple_continuations_correct() {
910        let content = "- Item\n\n  para 1\n\n  para 2\n\n  para 3\n";
911        assert!(check(content).is_empty());
912    }
913
914    #[test]
915    fn multiple_continuations_second_under_indent() {
916        // First continuation is correct, second is under-indented
917        let content = "- Item\n\n  para 1\n\n continuation 2\n";
918        let warnings = check(content);
919        assert_eq!(warnings.len(), 1);
920        assert_eq!(warnings[0].line, 5);
921    }
922
923    // ── Ordered list with `)` marker style ────────────────────────────
924
925    #[test]
926    fn ordered_paren_marker_correct() {
927        // "1) " is 3 chars, content_column = 3
928        let content = "1) Item\n\n   continuation\n";
929        assert!(check(content).is_empty());
930    }
931
932    #[test]
933    fn ordered_paren_marker_under_indent() {
934        let content = "1) Item\n\n  continuation\n";
935        let warnings = check(content);
936        assert_eq!(warnings.len(), 1);
937        assert!(warnings[0].message.contains("3 spaces"));
938    }
939
940    // ── Star and plus markers ─────────────────────────────────────────
941
942    #[test]
943    fn star_marker_correct() {
944        let content = "* Item\n\n  continuation\n";
945        assert!(check(content).is_empty());
946    }
947
948    #[test]
949    fn star_marker_under_indent() {
950        let content = "* Item\n\n continuation\n";
951        let warnings = check(content);
952        assert_eq!(warnings.len(), 1);
953    }
954
955    #[test]
956    fn plus_marker_correct() {
957        let content = "+ Item\n\n  continuation\n";
958        assert!(check(content).is_empty());
959    }
960
961    // ── Heading breaks scan ───────────────────────────────────────────
962
963    #[test]
964    fn heading_after_list_no_warning() {
965        let content = "- Item\n\n# Heading\n";
966        assert!(check(content).is_empty());
967    }
968
969    // ── Horizontal rule breaks scan ───────────────────────────────────
970
971    #[test]
972    fn hr_after_list_no_warning() {
973        let content = "- Item\n\n---\n";
974        assert!(check(content).is_empty());
975    }
976
977    // ── Reference link definitions skip ───────────────────────────────
978
979    #[test]
980    fn reference_link_def_not_flagged() {
981        let content = "- Item\n\n [link]: https://example.com\n";
982        assert!(check(content).is_empty());
983    }
984
985    // ── Footnote definitions skip ─────────────────────────────────────
986
987    #[test]
988    fn footnote_def_not_flagged() {
989        let content = "- Item\n\n [^1]: footnote text\n";
990        assert!(check(content).is_empty());
991    }
992
993    // ── Fix preserves correct content ─────────────────────────────────
994
995    #[test]
996    fn fix_deeply_nested() {
997        let content = "- L1\n  - L2\n    - L3\n\n     under-indented\n";
998        let fixed = fix(content);
999        assert_eq!(fixed, "- L1\n  - L2\n    - L3\n\n      under-indented\n");
1000    }
1001
1002    #[test]
1003    fn fix_mkdocs_unordered() {
1004        // MkDocs: "- " has content_column 2, but MkDocs requires max(2,4) = 4
1005        let content = "- Item\n\n  continuation\n";
1006        let fixed = fix_mkdocs(content);
1007        assert_eq!(fixed, "- Item\n\n    continuation\n");
1008    }
1009
1010    #[test]
1011    fn fix_code_fence_indent() {
1012        // Fence opener, interior, and closer all shift by the same delta so
1013        // the parser keeps pairing the fences and MD031 doesn't misfire.
1014        let content = "- Item\n\n ```\n code\n ```\n";
1015        let fixed = fix(content);
1016        assert_eq!(fixed, "- Item\n\n  ```\n  code\n  ```\n");
1017    }
1018
1019    #[test]
1020    fn fix_mkdocs_code_fence_indent() {
1021        // MkDocs ordered list: fence at 3 spaces needs 4; interior shifts too
1022        let content = "1. Item\n\n   ```toml\n   key = \"val\"\n   ```\n";
1023        let fixed = fix_mkdocs(content);
1024        assert_eq!(fixed, "1. Item\n\n    ```toml\n    key = \"val\"\n    ```\n");
1025    }
1026
1027    // ── Empty document / whitespace-only ──────────────────────────────
1028
1029    #[test]
1030    fn empty_document_no_warning() {
1031        assert!(check("").is_empty());
1032    }
1033
1034    #[test]
1035    fn whitespace_only_no_warning() {
1036        assert!(check("   \n\n  \n").is_empty());
1037    }
1038
1039    // ── No list at all ────────────────────────────────────────────────
1040
1041    #[test]
1042    fn no_list_no_warning() {
1043        let content = "# Heading\n\nSome paragraph.\n\nAnother paragraph.\n";
1044        assert!(check(content).is_empty());
1045    }
1046
1047    // ── Multi-line continuation (additional coverage) ──────────────
1048
1049    #[test]
1050    fn multiline_continuation_all_lines_flagged() {
1051        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";
1052        let warnings = check(content);
1053        assert_eq!(warnings.len(), 3);
1054        assert_eq!(warnings[0].line, 3);
1055        assert_eq!(warnings[1].line, 4);
1056        assert_eq!(warnings[2].line, 5);
1057    }
1058
1059    #[test]
1060    fn multiline_continuation_with_frontmatter_fix() {
1061        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";
1062        let fixed = fix(content);
1063        assert_eq!(
1064            fixed,
1065            "---\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"
1066        );
1067    }
1068
1069    #[test]
1070    fn multiline_continuation_correct_indent_no_warning() {
1071        let content = "1. Item\n\n   line one\n   line two\n   line three\n";
1072        assert!(check(content).is_empty());
1073    }
1074
1075    #[test]
1076    fn multiline_continuation_mixed_indent() {
1077        let content = "1. Item\n\n   correct\n  wrong\n   correct\n";
1078        let warnings = check(content);
1079        assert_eq!(warnings.len(), 1);
1080        assert_eq!(warnings[0].line, 4);
1081    }
1082
1083    #[test]
1084    fn multiline_continuation_unordered() {
1085        let content = "- Item\n\n continuation 1\n continuation 2\n continuation 3\n";
1086        let warnings = check(content);
1087        assert_eq!(warnings.len(), 3);
1088        let fixed = fix(content);
1089        assert_eq!(
1090            fixed,
1091            "- Item\n\n  continuation 1\n  continuation 2\n  continuation 3\n"
1092        );
1093    }
1094
1095    #[test]
1096    fn multiline_continuation_two_items_fix() {
1097        let content = "1. First\n\n  cont a\n  cont b\n\n2. Second\n\n  cont c\n  cont d\n";
1098        let fixed = fix(content);
1099        assert_eq!(
1100            fixed,
1101            "1. First\n\n   cont a\n   cont b\n\n2. Second\n\n   cont c\n   cont d\n"
1102        );
1103    }
1104
1105    #[test]
1106    fn fence_fix_does_not_break_pairing_for_md031() {
1107        // Regression for issue #574: previously MD077 only reindented the
1108        // fence delimiter lines while leaving the code block's interior at
1109        // the old indent. Between iterations of the fix loop the parser
1110        // saw an opener-closer mismatch, and MD031 then injected stray
1111        // blank lines at the fence boundaries. MD077's compound fix must
1112        // now rewrite the whole block atomically so the fences stay paired.
1113        let content = "#### title\n\nabc\n\n\
1114                       1. ab\n\n\
1115                       \x20\x20`aabbccdd`\n\n\
1116                       2. cd\n\n\
1117                       \x20\x20`bbcc dd ee`\n\n\
1118                       \x20\x20```\n\
1119                       \x20\x20abcd\n\
1120                       \x20\x20ef gh\n\
1121                       \x20\x20```\n\n\
1122                       \x20\x20uu\n\n\
1123                       \x20\x20```\n\
1124                       \x20\x20cdef\n\
1125                       \x20\x20gh ij\n\
1126                       \x20\x20```\n";
1127        let expected = "#### title\n\nabc\n\n\
1128                        1. ab\n\n\
1129                        \x20\x20\x20`aabbccdd`\n\n\
1130                        2. cd\n\n\
1131                        \x20\x20\x20`bbcc dd ee`\n\n\
1132                        \x20\x20\x20```\n\
1133                        \x20\x20\x20abcd\n\
1134                        \x20\x20\x20ef gh\n\
1135                        \x20\x20\x20```\n\n\
1136                        \x20\x20\x20uu\n\n\
1137                        \x20\x20\x20```\n\
1138                        \x20\x20\x20cdef\n\
1139                        \x20\x20\x20gh ij\n\
1140                        \x20\x20\x20```\n";
1141        assert_eq!(fix(content), expected);
1142    }
1143
1144    #[test]
1145    fn multiline_continuation_separated_by_blank() {
1146        let content = "1. Item\n\n  para1 line1\n  para1 line2\n\n  para2 line1\n  para2 line2\n";
1147        let warnings = check(content);
1148        assert_eq!(warnings.len(), 4);
1149        let fixed = fix(content);
1150        assert_eq!(
1151            fixed,
1152            "1. Item\n\n   para1 line1\n   para1 line2\n\n   para2 line1\n   para2 line2\n"
1153        );
1154    }
1155
1156    #[test]
1157    fn tab_indented_fence_is_normalized_to_spaces() {
1158        // Leading tabs expand to the next multiple-of-4 column under
1159        // CommonMark, so simply prepending spaces before a tab would
1160        // silently no-op (the tab snaps back to column 4). The compound
1161        // fence fix must replace the leading whitespace with a fresh
1162        // (visual_indent + delta) run of spaces. A `100. ` item has
1163        // content_column = 5, so a tab-indented fence (visual col 4) is
1164        // under-indented by 1 and must end up at 5 spaces after the fix.
1165        let content = "100. ab\n\n\t```\n\tabcd\n\t```\n";
1166        let expected = "100. ab\n\n     ```\n     abcd\n     ```\n";
1167        assert_eq!(fix(content), expected);
1168    }
1169}