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