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/// After a blank line inside a list item, continuation content must be indented
12/// to the item's content column (W+N rule). Under the MkDocs flavor, a minimum
13/// of 4 spaces is enforced for ordered list items to satisfy Python-Markdown.
14#[derive(Clone, Default)]
15pub struct MD077ListContinuationIndent;
16
17impl MD077ListContinuationIndent {
18    /// Check if a trimmed line is a block-level construct (not list continuation).
19    fn is_block_level_construct(trimmed: &str) -> bool {
20        // Footnote definition: [^label]:
21        if trimmed.starts_with("[^") && trimmed.contains("]:") {
22            return true;
23        }
24        // Abbreviation definition: *[text]:
25        if trimmed.starts_with("*[") && trimmed.contains("]:") {
26            return true;
27        }
28        // Reference link definition: [label]: url
29        // Must start with [ but not be a regular link, footnote, or abbreviation
30        if trimmed.starts_with('[') && !trimmed.starts_with("[^") && trimmed.contains("]: ") {
31            return true;
32        }
33        false
34    }
35
36    /// Check if a trimmed line is a fenced code block delimiter (opener or closer).
37    fn is_code_fence(trimmed: &str) -> bool {
38        let bytes = trimmed.as_bytes();
39        if bytes.len() < 3 {
40            return false;
41        }
42        let ch = bytes[0];
43        (ch == b'`' || ch == b'~') && bytes[1] == ch && bytes[2] == ch
44    }
45
46    /// Check if a line should be skipped (inside code, HTML, frontmatter, etc.)
47    ///
48    /// Code block *content* is skipped, but fence opener/closer lines are not —
49    /// their indentation matters for list continuation in MkDocs.
50    fn should_skip_line(info: &crate::lint_context::LineInfo, trimmed: &str) -> bool {
51        if info.in_code_block && !Self::is_code_fence(trimmed) {
52            return true;
53        }
54        info.in_front_matter
55            || info.in_html_block
56            || info.in_html_comment
57            || info.in_mkdocstrings
58            || info.in_esm_block
59            || info.in_math_block
60            || info.in_admonition
61            || info.in_content_tab
62            || info.in_pymdown_block
63            || info.in_definition_list
64            || info.in_mkdocs_html_markdown
65            || info.in_kramdown_extension_block
66    }
67}
68
69impl Rule for MD077ListContinuationIndent {
70    fn name(&self) -> &'static str {
71        "MD077"
72    }
73
74    fn description(&self) -> &'static str {
75        "List continuation content indentation"
76    }
77
78    fn check(&self, ctx: &LintContext) -> LintResult {
79        if ctx.content.is_empty() {
80            return Ok(Vec::new());
81        }
82
83        let strict_indent = ctx.flavor.requires_strict_list_indent();
84        let total_lines = ctx.lines.len();
85        let mut warnings = Vec::new();
86        let mut flagged_lines = std::collections::HashSet::new();
87
88        // Collect all list item lines sorted, with their content_column and marker_column.
89        // We need this to compute owned ranges that extend past block.end_line
90        // (the parser excludes under-indented continuation from the block).
91        let mut items: Vec<(usize, usize, usize)> = Vec::new(); // (line_num, marker_col, content_col)
92        for block in &ctx.list_blocks {
93            for &item_line in &block.item_lines {
94                if let Some(info) = ctx.line_info(item_line)
95                    && let Some(ref li) = info.list_item
96                {
97                    items.push((item_line, li.marker_column, li.content_column));
98                }
99            }
100        }
101        items.sort_unstable();
102        items.dedup_by_key(|&mut (ln, _, _)| ln);
103
104        for (item_idx, &(item_line, marker_col, content_col)) in items.iter().enumerate() {
105            let required = if strict_indent { content_col.max(4) } else { content_col };
106
107            // Owned range ends at the line before the next sibling-or-higher
108            // item, or end of document.
109            let range_end = items
110                .iter()
111                .skip(item_idx + 1)
112                .find(|&&(_, mc, _)| mc <= marker_col)
113                .map(|&(ln, _, _)| ln - 1)
114                .unwrap_or(total_lines);
115
116            let mut saw_blank = false;
117
118            for line_num in (item_line + 1)..=range_end {
119                let Some(line_info) = ctx.line_info(line_num) else {
120                    continue;
121                };
122
123                let trimmed = line_info.content(ctx.content).trim_start();
124
125                if Self::should_skip_line(line_info, trimmed) {
126                    continue;
127                }
128
129                if line_info.is_blank {
130                    saw_blank = true;
131                    continue;
132                }
133
134                // Nested list items are not continuation content
135                if line_info.list_item.is_some() {
136                    saw_blank = false;
137                    continue;
138                }
139
140                // Skip headings - they clearly aren't list continuation
141                if line_info.heading.is_some() {
142                    break;
143                }
144
145                // Skip horizontal rules
146                if line_info.is_horizontal_rule {
147                    break;
148                }
149
150                // Skip block-level constructs that aren't list continuation:
151                // reference definitions, footnote definitions, abbreviation definitions
152                if Self::is_block_level_construct(trimmed) {
153                    continue;
154                }
155
156                // Only flag content after a blank line (loose continuation)
157                if !saw_blank {
158                    continue;
159                }
160
161                let actual = line_info.visual_indent;
162
163                // Content at or below the marker column is not continuation —
164                // it starts a new paragraph (top-level) or belongs to a
165                // parent item (nested).
166                if actual <= marker_col {
167                    break;
168                }
169
170                if actual < required && flagged_lines.insert(line_num) {
171                    let line_content = line_info.content(ctx.content);
172
173                    let message = if strict_indent {
174                        format!(
175                            "Content inside list item needs {required} spaces of indentation \
176                             for MkDocs compatibility (found {actual})",
177                        )
178                    } else {
179                        format!(
180                            "Content after blank line in list item needs {required} spaces of \
181                             indentation to remain part of the list (found {actual})",
182                        )
183                    };
184
185                    // Build fix: replace leading whitespace with correct indent
186                    let fix_start = line_info.byte_offset;
187                    let fix_end = fix_start + line_info.indent;
188
189                    warnings.push(LintWarning {
190                        rule_name: Some("MD077".to_string()),
191                        line: line_num,
192                        column: 1,
193                        end_line: line_num,
194                        end_column: line_content.len() + 1,
195                        message,
196                        severity: Severity::Warning,
197                        fix: Some(Fix {
198                            range: fix_start..fix_end,
199                            replacement: " ".repeat(required),
200                        }),
201                    });
202                }
203
204                // Intentionally keep `saw_blank = true` while scanning this
205                // owned item range so that *all* lines in a loose continuation
206                // paragraph are validated/fixed, not just the first line after
207                // the blank.
208            }
209        }
210
211        Ok(warnings)
212    }
213
214    fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
215        let warnings = self.check(ctx)?;
216        if warnings.is_empty() {
217            return Ok(ctx.content.to_string());
218        }
219
220        // Sort fixes by byte position descending to apply from end to start
221        let mut fixes: Vec<Fix> = warnings.into_iter().filter_map(|w| w.fix).collect();
222        fixes.sort_by_key(|f| std::cmp::Reverse(f.range.start));
223
224        let mut content = ctx.content.to_string();
225        for fix in fixes {
226            if fix.range.start <= content.len() && fix.range.end <= content.len() {
227                content.replace_range(fix.range, &fix.replacement);
228            }
229        }
230
231        Ok(content)
232    }
233
234    fn category(&self) -> RuleCategory {
235        RuleCategory::List
236    }
237
238    fn as_any(&self) -> &dyn std::any::Any {
239        self
240    }
241
242    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
243    where
244        Self: Sized,
245    {
246        Box::new(Self)
247    }
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253    use crate::config::MarkdownFlavor;
254
255    fn check(content: &str) -> Vec<LintWarning> {
256        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
257        let rule = MD077ListContinuationIndent;
258        rule.check(&ctx).unwrap()
259    }
260
261    fn check_mkdocs(content: &str) -> Vec<LintWarning> {
262        let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
263        let rule = MD077ListContinuationIndent;
264        rule.check(&ctx).unwrap()
265    }
266
267    fn fix(content: &str) -> String {
268        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
269        let rule = MD077ListContinuationIndent;
270        rule.fix(&ctx).unwrap()
271    }
272
273    fn fix_mkdocs(content: &str) -> String {
274        let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
275        let rule = MD077ListContinuationIndent;
276        rule.fix(&ctx).unwrap()
277    }
278
279    // ── Basic: no blank line (lazy continuation) → no warning ─────────
280
281    #[test]
282    fn lazy_continuation_not_flagged() {
283        // Without a blank line, this is lazy continuation - not our concern
284        let content = "- Item\ncontinuation\n";
285        assert!(check(content).is_empty());
286    }
287
288    // ── Unordered list: correct indent after blank ────────────────────
289
290    #[test]
291    fn unordered_correct_indent_no_warning() {
292        let content = "- Item\n\n  continuation\n";
293        assert!(check(content).is_empty());
294    }
295
296    #[test]
297    fn unordered_partial_indent_warns() {
298        // Content with some indent (above marker column) but less than
299        // content_column is likely an indentation mistake.
300        let content = "- Item\n\n continuation\n";
301        let warnings = check(content);
302        assert_eq!(warnings.len(), 1);
303        assert_eq!(warnings[0].line, 3);
304        assert!(warnings[0].message.contains("2 spaces"));
305        assert!(warnings[0].message.contains("found 1"));
306    }
307
308    #[test]
309    fn unordered_zero_indent_is_new_paragraph() {
310        // Content at 0 indent after a top-level list is a new paragraph, not
311        // under-indented continuation.
312        let content = "- Item\n\ncontinuation\n";
313        assert!(check(content).is_empty());
314    }
315
316    // ── Ordered list: CommonMark W+N ──────────────────────────────────
317
318    #[test]
319    fn ordered_3space_correct_commonmark() {
320        // "1. " is 3 chars, content_column = 3
321        let content = "1. Item\n\n   continuation\n";
322        assert!(check(content).is_empty());
323    }
324
325    #[test]
326    fn ordered_2space_under_indent_commonmark() {
327        let content = "1. Item\n\n  continuation\n";
328        let warnings = check(content);
329        assert_eq!(warnings.len(), 1);
330        assert!(warnings[0].message.contains("3 spaces"));
331        assert!(warnings[0].message.contains("found 2"));
332    }
333
334    // ── Multi-digit ordered markers ───────────────────────────────────
335
336    #[test]
337    fn multi_digit_marker_correct() {
338        // "10. " is 4 chars, content_column = 4
339        let content = "10. Item\n\n    continuation\n";
340        assert!(check(content).is_empty());
341    }
342
343    #[test]
344    fn multi_digit_marker_under_indent() {
345        let content = "10. Item\n\n   continuation\n";
346        let warnings = check(content);
347        assert_eq!(warnings.len(), 1);
348        assert!(warnings[0].message.contains("4 spaces"));
349    }
350
351    // ── MkDocs flavor: 4-space minimum ────────────────────────────────
352
353    #[test]
354    fn mkdocs_3space_ordered_warns() {
355        // In MkDocs mode, 3-space indent on "1. " is not enough
356        let content = "1. Item\n\n   continuation\n";
357        let warnings = check_mkdocs(content);
358        assert_eq!(warnings.len(), 1);
359        assert!(warnings[0].message.contains("4 spaces"));
360        assert!(warnings[0].message.contains("MkDocs"));
361    }
362
363    #[test]
364    fn mkdocs_4space_ordered_no_warning() {
365        let content = "1. Item\n\n    continuation\n";
366        assert!(check_mkdocs(content).is_empty());
367    }
368
369    #[test]
370    fn mkdocs_unordered_2space_ok() {
371        // Unordered "- " has content_column = 2; max(2, 4) = 4 in mkdocs
372        let content = "- Item\n\n    continuation\n";
373        assert!(check_mkdocs(content).is_empty());
374    }
375
376    #[test]
377    fn mkdocs_unordered_2space_warns() {
378        // "- " has content_column 2; MkDocs requires max(2,4) = 4
379        let content = "- Item\n\n  continuation\n";
380        let warnings = check_mkdocs(content);
381        assert_eq!(warnings.len(), 1);
382        assert!(warnings[0].message.contains("4 spaces"));
383    }
384
385    // ── Auto-fix ──────────────────────────────────────────────────────
386
387    #[test]
388    fn fix_unordered_indent() {
389        // Partial indent (above marker column, below content column) gets fixed
390        let content = "- Item\n\n continuation\n";
391        let fixed = fix(content);
392        assert_eq!(fixed, "- Item\n\n  continuation\n");
393    }
394
395    #[test]
396    fn fix_ordered_indent() {
397        let content = "1. Item\n\n continuation\n";
398        let fixed = fix(content);
399        assert_eq!(fixed, "1. Item\n\n   continuation\n");
400    }
401
402    #[test]
403    fn fix_mkdocs_indent() {
404        let content = "1. Item\n\n   continuation\n";
405        let fixed = fix_mkdocs(content);
406        assert_eq!(fixed, "1. Item\n\n    continuation\n");
407    }
408
409    // ── Nested lists: only flag continuation, not sub-items ───────────
410
411    #[test]
412    fn nested_list_items_not_flagged() {
413        let content = "- Parent\n\n  - Child\n";
414        assert!(check(content).is_empty());
415    }
416
417    #[test]
418    fn nested_list_zero_indent_is_new_paragraph() {
419        // Content at 0 indent ends the list, not continuation
420        let content = "- Parent\n  - Child\n\ncontinuation of parent\n";
421        assert!(check(content).is_empty());
422    }
423
424    #[test]
425    fn nested_list_partial_indent_flagged() {
426        // Content with partial indent (above parent marker, below content col)
427        let content = "- Parent\n  - Child\n\n continuation of parent\n";
428        let warnings = check(content);
429        assert_eq!(warnings.len(), 1);
430        assert!(warnings[0].message.contains("2 spaces"));
431    }
432
433    // ── Code blocks inside items ─────────────────────────────────────
434
435    #[test]
436    fn code_block_correctly_indented_no_warning() {
437        // Fence lines and content all at correct indent for "- " (content_column = 2)
438        let content = "- Item\n\n  ```\n  code\n  ```\n";
439        assert!(check(content).is_empty());
440    }
441
442    #[test]
443    fn code_fence_under_indented_warns() {
444        // Fence opener has 1-space indent, but "- " needs 2
445        let content = "- Item\n\n ```\n code\n ```\n";
446        let warnings = check(content);
447        // Fence opener and closer are flagged; content lines are skipped
448        assert_eq!(warnings.len(), 2);
449    }
450
451    #[test]
452    fn code_fence_under_indented_ordered_mkdocs() {
453        // Ordered list in MkDocs: "1. " needs max(3, 4) = 4 spaces
454        // Fence at 3 spaces is correct for CommonMark but wrong for MkDocs
455        let content = "1. Item\n\n   ```toml\n   key = \"value\"\n   ```\n";
456        assert!(check(content).is_empty()); // Standard mode: 3 is fine
457        let warnings = check_mkdocs(content);
458        assert_eq!(warnings.len(), 2); // MkDocs: fence opener + closer both need 4
459        assert!(warnings[0].message.contains("4 spaces"));
460        assert!(warnings[0].message.contains("MkDocs"));
461    }
462
463    #[test]
464    fn code_fence_tilde_under_indented() {
465        let content = "- Item\n\n ~~~\n code\n ~~~\n";
466        let warnings = check(content);
467        assert_eq!(warnings.len(), 2); // Tilde fences also checked
468    }
469
470    // ── Multiple blank lines ──────────────────────────────────────────
471
472    #[test]
473    fn multiple_blank_lines_zero_indent_is_new_paragraph() {
474        // Even with multiple blanks, 0-indent content is a new paragraph
475        let content = "- Item\n\n\ncontinuation\n";
476        assert!(check(content).is_empty());
477    }
478
479    #[test]
480    fn multiple_blank_lines_partial_indent_flags() {
481        let content = "- Item\n\n\n continuation\n";
482        let warnings = check(content);
483        assert_eq!(warnings.len(), 1);
484    }
485
486    // ── Empty items: no continuation to check ─────────────────────────
487
488    #[test]
489    fn empty_item_no_warning() {
490        let content = "- \n- Second\n";
491        assert!(check(content).is_empty());
492    }
493
494    // ── Multiple items, only some under-indented ──────────────────────
495
496    #[test]
497    fn multiple_items_mixed_indent() {
498        let content = "1. First\n\n   correct continuation\n\n2. Second\n\n  wrong continuation\n";
499        let warnings = check(content);
500        assert_eq!(warnings.len(), 1);
501        assert_eq!(warnings[0].line, 7);
502    }
503
504    // ── Task list items ───────────────────────────────────────────────
505
506    #[test]
507    fn task_list_correct_indent() {
508        // "- [ ] " = content_column is typically at col 6
509        let content = "- [ ] Task\n\n      continuation\n";
510        assert!(check(content).is_empty());
511    }
512
513    // ── Frontmatter skipped ───────────────────────────────────────────
514
515    #[test]
516    fn frontmatter_not_flagged() {
517        let content = "---\ntitle: test\n---\n\n- Item\n\n  continuation\n";
518        assert!(check(content).is_empty());
519    }
520
521    // ── Fix produces valid output with multiple fixes ─────────────────
522
523    #[test]
524    fn fix_multiple_items() {
525        let content = "1. First\n\n wrong1\n\n2. Second\n\n wrong2\n";
526        let fixed = fix(content);
527        assert_eq!(fixed, "1. First\n\n   wrong1\n\n2. Second\n\n   wrong2\n");
528    }
529
530    #[test]
531    fn fix_multiline_loose_continuation_all_lines() {
532        let content = "1. Item\n\n  line one\n  line two\n  line three\n";
533        let fixed = fix(content);
534        assert_eq!(fixed, "1. Item\n\n   line one\n   line two\n   line three\n");
535    }
536
537    // ── No false positive when content is after sibling item ──────────
538
539    #[test]
540    fn sibling_item_boundary_respected() {
541        // The "continuation" after a blank belongs to "- Second", not "- First"
542        let content = "- First\n- Second\n\n  continuation\n";
543        assert!(check(content).is_empty());
544    }
545
546    // ── Blockquote-nested lists ────────────────────────────────────────
547
548    #[test]
549    fn blockquote_list_correct_indent_no_warning() {
550        // Lists inside blockquotes: visual_indent includes the blockquote
551        // prefix, so comparisons work on raw line columns.
552        let content = "> - Item\n>\n>   continuation\n";
553        assert!(check(content).is_empty());
554    }
555
556    #[test]
557    fn blockquote_list_under_indent_no_false_positive() {
558        // Under-indented continuation inside a blockquote: visual_indent
559        // starts at 0 (the `>` char) which is <= marker_col, so the scan
560        // breaks and no warning is emitted. This is a known false negative
561        // (not a false positive), which is the safer default.
562        let content = "> - Item\n>\n> continuation\n";
563        assert!(check(content).is_empty());
564    }
565
566    // ── Deep nesting (3+ levels) ──────────────────────────────────────
567
568    #[test]
569    fn deeply_nested_correct_indent() {
570        let content = "- L1\n  - L2\n    - L3\n\n      continuation of L3\n";
571        assert!(check(content).is_empty());
572    }
573
574    #[test]
575    fn deeply_nested_under_indent() {
576        // L3 starts at column 4 with "- " marker, content_column = 6
577        // Continuation with 5 spaces is under-indented for L3.
578        let content = "- L1\n  - L2\n    - L3\n\n     continuation of L3\n";
579        let warnings = check(content);
580        assert_eq!(warnings.len(), 1);
581        assert!(warnings[0].message.contains("6 spaces"));
582        assert!(warnings[0].message.contains("found 5"));
583    }
584
585    // ── Tab indentation ───────────────────────────────────────────────
586
587    #[test]
588    fn tab_indent_correct() {
589        // A tab at the start expands to 4 visual columns, which satisfies
590        // "- " (content_column = 2).
591        let content = "- Item\n\n\tcontinuation\n";
592        assert!(check(content).is_empty());
593    }
594
595    // ── Multiple continuation paragraphs ──────────────────────────────
596
597    #[test]
598    fn multiple_continuations_correct() {
599        let content = "- Item\n\n  para 1\n\n  para 2\n\n  para 3\n";
600        assert!(check(content).is_empty());
601    }
602
603    #[test]
604    fn multiple_continuations_second_under_indent() {
605        // First continuation is correct, second is under-indented
606        let content = "- Item\n\n  para 1\n\n continuation 2\n";
607        let warnings = check(content);
608        assert_eq!(warnings.len(), 1);
609        assert_eq!(warnings[0].line, 5);
610    }
611
612    // ── Ordered list with `)` marker style ────────────────────────────
613
614    #[test]
615    fn ordered_paren_marker_correct() {
616        // "1) " is 3 chars, content_column = 3
617        let content = "1) Item\n\n   continuation\n";
618        assert!(check(content).is_empty());
619    }
620
621    #[test]
622    fn ordered_paren_marker_under_indent() {
623        let content = "1) Item\n\n  continuation\n";
624        let warnings = check(content);
625        assert_eq!(warnings.len(), 1);
626        assert!(warnings[0].message.contains("3 spaces"));
627    }
628
629    // ── Star and plus markers ─────────────────────────────────────────
630
631    #[test]
632    fn star_marker_correct() {
633        let content = "* Item\n\n  continuation\n";
634        assert!(check(content).is_empty());
635    }
636
637    #[test]
638    fn star_marker_under_indent() {
639        let content = "* Item\n\n continuation\n";
640        let warnings = check(content);
641        assert_eq!(warnings.len(), 1);
642    }
643
644    #[test]
645    fn plus_marker_correct() {
646        let content = "+ Item\n\n  continuation\n";
647        assert!(check(content).is_empty());
648    }
649
650    // ── Heading breaks scan ───────────────────────────────────────────
651
652    #[test]
653    fn heading_after_list_no_warning() {
654        let content = "- Item\n\n# Heading\n";
655        assert!(check(content).is_empty());
656    }
657
658    // ── Horizontal rule breaks scan ───────────────────────────────────
659
660    #[test]
661    fn hr_after_list_no_warning() {
662        let content = "- Item\n\n---\n";
663        assert!(check(content).is_empty());
664    }
665
666    // ── Reference link definitions skip ───────────────────────────────
667
668    #[test]
669    fn reference_link_def_not_flagged() {
670        let content = "- Item\n\n [link]: https://example.com\n";
671        assert!(check(content).is_empty());
672    }
673
674    // ── Footnote definitions skip ─────────────────────────────────────
675
676    #[test]
677    fn footnote_def_not_flagged() {
678        let content = "- Item\n\n [^1]: footnote text\n";
679        assert!(check(content).is_empty());
680    }
681
682    // ── Fix preserves correct content ─────────────────────────────────
683
684    #[test]
685    fn fix_deeply_nested() {
686        let content = "- L1\n  - L2\n    - L3\n\n     under-indented\n";
687        let fixed = fix(content);
688        assert_eq!(fixed, "- L1\n  - L2\n    - L3\n\n      under-indented\n");
689    }
690
691    #[test]
692    fn fix_mkdocs_unordered() {
693        // MkDocs: "- " has content_column 2, but MkDocs requires max(2,4) = 4
694        let content = "- Item\n\n  continuation\n";
695        let fixed = fix_mkdocs(content);
696        assert_eq!(fixed, "- Item\n\n    continuation\n");
697    }
698
699    #[test]
700    fn fix_code_fence_indent() {
701        // Fence opener and closer get re-indented; content inside is untouched
702        let content = "- Item\n\n ```\n code\n ```\n";
703        let fixed = fix(content);
704        assert_eq!(fixed, "- Item\n\n  ```\n code\n  ```\n");
705    }
706
707    #[test]
708    fn fix_mkdocs_code_fence_indent() {
709        // MkDocs ordered list: fence at 3 spaces needs 4
710        let content = "1. Item\n\n   ```toml\n   key = \"val\"\n   ```\n";
711        let fixed = fix_mkdocs(content);
712        assert_eq!(fixed, "1. Item\n\n    ```toml\n   key = \"val\"\n    ```\n");
713    }
714
715    // ── Empty document / whitespace-only ──────────────────────────────
716
717    #[test]
718    fn empty_document_no_warning() {
719        assert!(check("").is_empty());
720    }
721
722    #[test]
723    fn whitespace_only_no_warning() {
724        assert!(check("   \n\n  \n").is_empty());
725    }
726
727    // ── No list at all ────────────────────────────────────────────────
728
729    #[test]
730    fn no_list_no_warning() {
731        let content = "# Heading\n\nSome paragraph.\n\nAnother paragraph.\n";
732        assert!(check(content).is_empty());
733    }
734
735    // ── Multi-line continuation (additional coverage) ──────────────
736
737    #[test]
738    fn multiline_continuation_all_lines_flagged() {
739        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";
740        let warnings = check(content);
741        assert_eq!(warnings.len(), 3);
742        assert_eq!(warnings[0].line, 3);
743        assert_eq!(warnings[1].line, 4);
744        assert_eq!(warnings[2].line, 5);
745    }
746
747    #[test]
748    fn multiline_continuation_with_frontmatter_fix() {
749        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";
750        let fixed = fix(content);
751        assert_eq!(
752            fixed,
753            "---\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"
754        );
755    }
756
757    #[test]
758    fn multiline_continuation_correct_indent_no_warning() {
759        let content = "1. Item\n\n   line one\n   line two\n   line three\n";
760        assert!(check(content).is_empty());
761    }
762
763    #[test]
764    fn multiline_continuation_mixed_indent() {
765        let content = "1. Item\n\n   correct\n  wrong\n   correct\n";
766        let warnings = check(content);
767        assert_eq!(warnings.len(), 1);
768        assert_eq!(warnings[0].line, 4);
769    }
770
771    #[test]
772    fn multiline_continuation_unordered() {
773        let content = "- Item\n\n continuation 1\n continuation 2\n continuation 3\n";
774        let warnings = check(content);
775        assert_eq!(warnings.len(), 3);
776        let fixed = fix(content);
777        assert_eq!(
778            fixed,
779            "- Item\n\n  continuation 1\n  continuation 2\n  continuation 3\n"
780        );
781    }
782
783    #[test]
784    fn multiline_continuation_two_items_fix() {
785        let content = "1. First\n\n  cont a\n  cont b\n\n2. Second\n\n  cont c\n  cont d\n";
786        let fixed = fix(content);
787        assert_eq!(
788            fixed,
789            "1. First\n\n   cont a\n   cont b\n\n2. Second\n\n   cont c\n   cont d\n"
790        );
791    }
792
793    #[test]
794    fn multiline_continuation_separated_by_blank() {
795        let content = "1. Item\n\n  para1 line1\n  para1 line2\n\n  para2 line1\n  para2 line2\n";
796        let warnings = check(content);
797        assert_eq!(warnings.len(), 4);
798        let fixed = fix(content);
799        assert_eq!(
800            fixed,
801            "1. Item\n\n   para1 line1\n   para1 line2\n\n   para2 line1\n   para2 line2\n"
802        );
803    }
804}