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                // Reset saw_blank after processing non-blank content.
205                // Exception: code fence lines (opener/closer) are structural
206                // delimiters — the closer inherits the blank-line status from
207                // the opener so both get checked.
208                if !line_info.in_code_block {
209                    saw_blank = false;
210                }
211            }
212        }
213
214        Ok(warnings)
215    }
216
217    fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
218        let warnings = self.check(ctx)?;
219        if warnings.is_empty() {
220            return Ok(ctx.content.to_string());
221        }
222
223        // Sort fixes by byte position descending to apply from end to start
224        let mut fixes: Vec<Fix> = warnings.into_iter().filter_map(|w| w.fix).collect();
225        fixes.sort_by_key(|f| std::cmp::Reverse(f.range.start));
226
227        let mut content = ctx.content.to_string();
228        for fix in fixes {
229            if fix.range.start <= content.len() && fix.range.end <= content.len() {
230                content.replace_range(fix.range, &fix.replacement);
231            }
232        }
233
234        Ok(content)
235    }
236
237    fn category(&self) -> RuleCategory {
238        RuleCategory::List
239    }
240
241    fn as_any(&self) -> &dyn std::any::Any {
242        self
243    }
244
245    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
246    where
247        Self: Sized,
248    {
249        Box::new(Self)
250    }
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256    use crate::config::MarkdownFlavor;
257
258    fn check(content: &str) -> Vec<LintWarning> {
259        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
260        let rule = MD077ListContinuationIndent;
261        rule.check(&ctx).unwrap()
262    }
263
264    fn check_mkdocs(content: &str) -> Vec<LintWarning> {
265        let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
266        let rule = MD077ListContinuationIndent;
267        rule.check(&ctx).unwrap()
268    }
269
270    fn fix(content: &str) -> String {
271        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
272        let rule = MD077ListContinuationIndent;
273        rule.fix(&ctx).unwrap()
274    }
275
276    fn fix_mkdocs(content: &str) -> String {
277        let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
278        let rule = MD077ListContinuationIndent;
279        rule.fix(&ctx).unwrap()
280    }
281
282    // ── Basic: no blank line (lazy continuation) → no warning ─────────
283
284    #[test]
285    fn lazy_continuation_not_flagged() {
286        // Without a blank line, this is lazy continuation - not our concern
287        let content = "- Item\ncontinuation\n";
288        assert!(check(content).is_empty());
289    }
290
291    // ── Unordered list: correct indent after blank ────────────────────
292
293    #[test]
294    fn unordered_correct_indent_no_warning() {
295        let content = "- Item\n\n  continuation\n";
296        assert!(check(content).is_empty());
297    }
298
299    #[test]
300    fn unordered_partial_indent_warns() {
301        // Content with some indent (above marker column) but less than
302        // content_column is likely an indentation mistake.
303        let content = "- Item\n\n continuation\n";
304        let warnings = check(content);
305        assert_eq!(warnings.len(), 1);
306        assert_eq!(warnings[0].line, 3);
307        assert!(warnings[0].message.contains("2 spaces"));
308        assert!(warnings[0].message.contains("found 1"));
309    }
310
311    #[test]
312    fn unordered_zero_indent_is_new_paragraph() {
313        // Content at 0 indent after a top-level list is a new paragraph, not
314        // under-indented continuation.
315        let content = "- Item\n\ncontinuation\n";
316        assert!(check(content).is_empty());
317    }
318
319    // ── Ordered list: CommonMark W+N ──────────────────────────────────
320
321    #[test]
322    fn ordered_3space_correct_commonmark() {
323        // "1. " is 3 chars, content_column = 3
324        let content = "1. Item\n\n   continuation\n";
325        assert!(check(content).is_empty());
326    }
327
328    #[test]
329    fn ordered_2space_under_indent_commonmark() {
330        let content = "1. Item\n\n  continuation\n";
331        let warnings = check(content);
332        assert_eq!(warnings.len(), 1);
333        assert!(warnings[0].message.contains("3 spaces"));
334        assert!(warnings[0].message.contains("found 2"));
335    }
336
337    // ── Multi-digit ordered markers ───────────────────────────────────
338
339    #[test]
340    fn multi_digit_marker_correct() {
341        // "10. " is 4 chars, content_column = 4
342        let content = "10. Item\n\n    continuation\n";
343        assert!(check(content).is_empty());
344    }
345
346    #[test]
347    fn multi_digit_marker_under_indent() {
348        let content = "10. Item\n\n   continuation\n";
349        let warnings = check(content);
350        assert_eq!(warnings.len(), 1);
351        assert!(warnings[0].message.contains("4 spaces"));
352    }
353
354    // ── MkDocs flavor: 4-space minimum ────────────────────────────────
355
356    #[test]
357    fn mkdocs_3space_ordered_warns() {
358        // In MkDocs mode, 3-space indent on "1. " is not enough
359        let content = "1. Item\n\n   continuation\n";
360        let warnings = check_mkdocs(content);
361        assert_eq!(warnings.len(), 1);
362        assert!(warnings[0].message.contains("4 spaces"));
363        assert!(warnings[0].message.contains("MkDocs"));
364    }
365
366    #[test]
367    fn mkdocs_4space_ordered_no_warning() {
368        let content = "1. Item\n\n    continuation\n";
369        assert!(check_mkdocs(content).is_empty());
370    }
371
372    #[test]
373    fn mkdocs_unordered_2space_ok() {
374        // Unordered "- " has content_column = 2; max(2, 4) = 4 in mkdocs
375        let content = "- Item\n\n    continuation\n";
376        assert!(check_mkdocs(content).is_empty());
377    }
378
379    #[test]
380    fn mkdocs_unordered_2space_warns() {
381        // "- " has content_column 2; MkDocs requires max(2,4) = 4
382        let content = "- Item\n\n  continuation\n";
383        let warnings = check_mkdocs(content);
384        assert_eq!(warnings.len(), 1);
385        assert!(warnings[0].message.contains("4 spaces"));
386    }
387
388    // ── Auto-fix ──────────────────────────────────────────────────────
389
390    #[test]
391    fn fix_unordered_indent() {
392        // Partial indent (above marker column, below content column) gets fixed
393        let content = "- Item\n\n continuation\n";
394        let fixed = fix(content);
395        assert_eq!(fixed, "- Item\n\n  continuation\n");
396    }
397
398    #[test]
399    fn fix_ordered_indent() {
400        let content = "1. Item\n\n continuation\n";
401        let fixed = fix(content);
402        assert_eq!(fixed, "1. Item\n\n   continuation\n");
403    }
404
405    #[test]
406    fn fix_mkdocs_indent() {
407        let content = "1. Item\n\n   continuation\n";
408        let fixed = fix_mkdocs(content);
409        assert_eq!(fixed, "1. Item\n\n    continuation\n");
410    }
411
412    // ── Nested lists: only flag continuation, not sub-items ───────────
413
414    #[test]
415    fn nested_list_items_not_flagged() {
416        let content = "- Parent\n\n  - Child\n";
417        assert!(check(content).is_empty());
418    }
419
420    #[test]
421    fn nested_list_zero_indent_is_new_paragraph() {
422        // Content at 0 indent ends the list, not continuation
423        let content = "- Parent\n  - Child\n\ncontinuation of parent\n";
424        assert!(check(content).is_empty());
425    }
426
427    #[test]
428    fn nested_list_partial_indent_flagged() {
429        // Content with partial indent (above parent marker, below content col)
430        let content = "- Parent\n  - Child\n\n continuation of parent\n";
431        let warnings = check(content);
432        assert_eq!(warnings.len(), 1);
433        assert!(warnings[0].message.contains("2 spaces"));
434    }
435
436    // ── Code blocks inside items ─────────────────────────────────────
437
438    #[test]
439    fn code_block_correctly_indented_no_warning() {
440        // Fence lines and content all at correct indent for "- " (content_column = 2)
441        let content = "- Item\n\n  ```\n  code\n  ```\n";
442        assert!(check(content).is_empty());
443    }
444
445    #[test]
446    fn code_fence_under_indented_warns() {
447        // Fence opener has 1-space indent, but "- " needs 2
448        let content = "- Item\n\n ```\n code\n ```\n";
449        let warnings = check(content);
450        // Fence opener and closer are flagged; content lines are skipped
451        assert_eq!(warnings.len(), 2);
452    }
453
454    #[test]
455    fn code_fence_under_indented_ordered_mkdocs() {
456        // Ordered list in MkDocs: "1. " needs max(3, 4) = 4 spaces
457        // Fence at 3 spaces is correct for CommonMark but wrong for MkDocs
458        let content = "1. Item\n\n   ```toml\n   key = \"value\"\n   ```\n";
459        assert!(check(content).is_empty()); // Standard mode: 3 is fine
460        let warnings = check_mkdocs(content);
461        assert_eq!(warnings.len(), 2); // MkDocs: fence opener + closer both need 4
462        assert!(warnings[0].message.contains("4 spaces"));
463        assert!(warnings[0].message.contains("MkDocs"));
464    }
465
466    #[test]
467    fn code_fence_tilde_under_indented() {
468        let content = "- Item\n\n ~~~\n code\n ~~~\n";
469        let warnings = check(content);
470        assert_eq!(warnings.len(), 2); // Tilde fences also checked
471    }
472
473    // ── Multiple blank lines ──────────────────────────────────────────
474
475    #[test]
476    fn multiple_blank_lines_zero_indent_is_new_paragraph() {
477        // Even with multiple blanks, 0-indent content is a new paragraph
478        let content = "- Item\n\n\ncontinuation\n";
479        assert!(check(content).is_empty());
480    }
481
482    #[test]
483    fn multiple_blank_lines_partial_indent_flags() {
484        let content = "- Item\n\n\n continuation\n";
485        let warnings = check(content);
486        assert_eq!(warnings.len(), 1);
487    }
488
489    // ── Empty items: no continuation to check ─────────────────────────
490
491    #[test]
492    fn empty_item_no_warning() {
493        let content = "- \n- Second\n";
494        assert!(check(content).is_empty());
495    }
496
497    // ── Multiple items, only some under-indented ──────────────────────
498
499    #[test]
500    fn multiple_items_mixed_indent() {
501        let content = "1. First\n\n   correct continuation\n\n2. Second\n\n  wrong continuation\n";
502        let warnings = check(content);
503        assert_eq!(warnings.len(), 1);
504        assert_eq!(warnings[0].line, 7);
505    }
506
507    // ── Task list items ───────────────────────────────────────────────
508
509    #[test]
510    fn task_list_correct_indent() {
511        // "- [ ] " = content_column is typically at col 6
512        let content = "- [ ] Task\n\n      continuation\n";
513        assert!(check(content).is_empty());
514    }
515
516    // ── Frontmatter skipped ───────────────────────────────────────────
517
518    #[test]
519    fn frontmatter_not_flagged() {
520        let content = "---\ntitle: test\n---\n\n- Item\n\n  continuation\n";
521        assert!(check(content).is_empty());
522    }
523
524    // ── Fix produces valid output with multiple fixes ─────────────────
525
526    #[test]
527    fn fix_multiple_items() {
528        let content = "1. First\n\n wrong1\n\n2. Second\n\n wrong2\n";
529        let fixed = fix(content);
530        assert_eq!(fixed, "1. First\n\n   wrong1\n\n2. Second\n\n   wrong2\n");
531    }
532
533    // ── No false positive when content is after sibling item ──────────
534
535    #[test]
536    fn sibling_item_boundary_respected() {
537        // The "continuation" after a blank belongs to "- Second", not "- First"
538        let content = "- First\n- Second\n\n  continuation\n";
539        assert!(check(content).is_empty());
540    }
541
542    // ── Blockquote-nested lists ────────────────────────────────────────
543
544    #[test]
545    fn blockquote_list_correct_indent_no_warning() {
546        // Lists inside blockquotes: visual_indent includes the blockquote
547        // prefix, so comparisons work on raw line columns.
548        let content = "> - Item\n>\n>   continuation\n";
549        assert!(check(content).is_empty());
550    }
551
552    #[test]
553    fn blockquote_list_under_indent_no_false_positive() {
554        // Under-indented continuation inside a blockquote: visual_indent
555        // starts at 0 (the `>` char) which is <= marker_col, so the scan
556        // breaks and no warning is emitted. This is a known false negative
557        // (not a false positive), which is the safer default.
558        let content = "> - Item\n>\n> continuation\n";
559        assert!(check(content).is_empty());
560    }
561
562    // ── Deep nesting (3+ levels) ──────────────────────────────────────
563
564    #[test]
565    fn deeply_nested_correct_indent() {
566        let content = "- L1\n  - L2\n    - L3\n\n      continuation of L3\n";
567        assert!(check(content).is_empty());
568    }
569
570    #[test]
571    fn deeply_nested_under_indent() {
572        // L3 starts at column 4 with "- " marker, content_column = 6
573        // Continuation with 5 spaces is under-indented for L3.
574        let content = "- L1\n  - L2\n    - L3\n\n     continuation of L3\n";
575        let warnings = check(content);
576        assert_eq!(warnings.len(), 1);
577        assert!(warnings[0].message.contains("6 spaces"));
578        assert!(warnings[0].message.contains("found 5"));
579    }
580
581    // ── Tab indentation ───────────────────────────────────────────────
582
583    #[test]
584    fn tab_indent_correct() {
585        // A tab at the start expands to 4 visual columns, which satisfies
586        // "- " (content_column = 2).
587        let content = "- Item\n\n\tcontinuation\n";
588        assert!(check(content).is_empty());
589    }
590
591    // ── Multiple continuation paragraphs ──────────────────────────────
592
593    #[test]
594    fn multiple_continuations_correct() {
595        let content = "- Item\n\n  para 1\n\n  para 2\n\n  para 3\n";
596        assert!(check(content).is_empty());
597    }
598
599    #[test]
600    fn multiple_continuations_second_under_indent() {
601        // First continuation is correct, second is under-indented
602        let content = "- Item\n\n  para 1\n\n continuation 2\n";
603        let warnings = check(content);
604        assert_eq!(warnings.len(), 1);
605        assert_eq!(warnings[0].line, 5);
606    }
607
608    // ── Ordered list with `)` marker style ────────────────────────────
609
610    #[test]
611    fn ordered_paren_marker_correct() {
612        // "1) " is 3 chars, content_column = 3
613        let content = "1) Item\n\n   continuation\n";
614        assert!(check(content).is_empty());
615    }
616
617    #[test]
618    fn ordered_paren_marker_under_indent() {
619        let content = "1) Item\n\n  continuation\n";
620        let warnings = check(content);
621        assert_eq!(warnings.len(), 1);
622        assert!(warnings[0].message.contains("3 spaces"));
623    }
624
625    // ── Star and plus markers ─────────────────────────────────────────
626
627    #[test]
628    fn star_marker_correct() {
629        let content = "* Item\n\n  continuation\n";
630        assert!(check(content).is_empty());
631    }
632
633    #[test]
634    fn star_marker_under_indent() {
635        let content = "* Item\n\n continuation\n";
636        let warnings = check(content);
637        assert_eq!(warnings.len(), 1);
638    }
639
640    #[test]
641    fn plus_marker_correct() {
642        let content = "+ Item\n\n  continuation\n";
643        assert!(check(content).is_empty());
644    }
645
646    // ── Heading breaks scan ───────────────────────────────────────────
647
648    #[test]
649    fn heading_after_list_no_warning() {
650        let content = "- Item\n\n# Heading\n";
651        assert!(check(content).is_empty());
652    }
653
654    // ── Horizontal rule breaks scan ───────────────────────────────────
655
656    #[test]
657    fn hr_after_list_no_warning() {
658        let content = "- Item\n\n---\n";
659        assert!(check(content).is_empty());
660    }
661
662    // ── Reference link definitions skip ───────────────────────────────
663
664    #[test]
665    fn reference_link_def_not_flagged() {
666        let content = "- Item\n\n [link]: https://example.com\n";
667        assert!(check(content).is_empty());
668    }
669
670    // ── Footnote definitions skip ─────────────────────────────────────
671
672    #[test]
673    fn footnote_def_not_flagged() {
674        let content = "- Item\n\n [^1]: footnote text\n";
675        assert!(check(content).is_empty());
676    }
677
678    // ── Fix preserves correct content ─────────────────────────────────
679
680    #[test]
681    fn fix_deeply_nested() {
682        let content = "- L1\n  - L2\n    - L3\n\n     under-indented\n";
683        let fixed = fix(content);
684        assert_eq!(fixed, "- L1\n  - L2\n    - L3\n\n      under-indented\n");
685    }
686
687    #[test]
688    fn fix_mkdocs_unordered() {
689        // MkDocs: "- " has content_column 2, but MkDocs requires max(2,4) = 4
690        let content = "- Item\n\n  continuation\n";
691        let fixed = fix_mkdocs(content);
692        assert_eq!(fixed, "- Item\n\n    continuation\n");
693    }
694
695    #[test]
696    fn fix_code_fence_indent() {
697        // Fence opener and closer get re-indented; content inside is untouched
698        let content = "- Item\n\n ```\n code\n ```\n";
699        let fixed = fix(content);
700        assert_eq!(fixed, "- Item\n\n  ```\n code\n  ```\n");
701    }
702
703    #[test]
704    fn fix_mkdocs_code_fence_indent() {
705        // MkDocs ordered list: fence at 3 spaces needs 4
706        let content = "1. Item\n\n   ```toml\n   key = \"val\"\n   ```\n";
707        let fixed = fix_mkdocs(content);
708        assert_eq!(fixed, "1. Item\n\n    ```toml\n   key = \"val\"\n    ```\n");
709    }
710
711    // ── Empty document / whitespace-only ──────────────────────────────
712
713    #[test]
714    fn empty_document_no_warning() {
715        assert!(check("").is_empty());
716    }
717
718    #[test]
719    fn whitespace_only_no_warning() {
720        assert!(check("   \n\n  \n").is_empty());
721    }
722
723    // ── No list at all ────────────────────────────────────────────────
724
725    #[test]
726    fn no_list_no_warning() {
727        let content = "# Heading\n\nSome paragraph.\n\nAnother paragraph.\n";
728        assert!(check(content).is_empty());
729    }
730}