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