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