1use crate::lint_context::LintContext;
7use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
8
9#[derive(Clone, Default)]
15pub struct MD077ListContinuationIndent;
16
17impl MD077ListContinuationIndent {
18 fn is_block_level_construct(trimmed: &str) -> bool {
20 if trimmed.starts_with("[^") && trimmed.contains("]:") {
22 return true;
23 }
24 if trimmed.starts_with("*[") && trimmed.contains("]:") {
26 return true;
27 }
28 if trimmed.starts_with('[') && !trimmed.starts_with("[^") && trimmed.contains("]: ") {
31 return true;
32 }
33 false
34 }
35
36 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 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 let mut items: Vec<(usize, usize, usize)> = Vec::new(); 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 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 if line_info.list_item.is_some() {
136 saw_blank = false;
137 continue;
138 }
139
140 if line_info.heading.is_some() {
142 break;
143 }
144
145 if line_info.is_horizontal_rule {
147 break;
148 }
149
150 if Self::is_block_level_construct(trimmed) {
153 continue;
154 }
155
156 if !saw_blank {
158 continue;
159 }
160
161 let actual = line_info.visual_indent;
162
163 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 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 }
209 }
210
211 Ok(warnings)
212 }
213
214 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
215 let warnings = self.check(ctx)?;
216 if warnings.is_empty() {
217 return Ok(ctx.content.to_string());
218 }
219
220 let mut fixes: Vec<Fix> = warnings.into_iter().filter_map(|w| w.fix).collect();
222 fixes.sort_by_key(|f| std::cmp::Reverse(f.range.start));
223
224 let mut content = ctx.content.to_string();
225 for fix in fixes {
226 if fix.range.start <= content.len() && fix.range.end <= content.len() {
227 content.replace_range(fix.range, &fix.replacement);
228 }
229 }
230
231 Ok(content)
232 }
233
234 fn category(&self) -> RuleCategory {
235 RuleCategory::List
236 }
237
238 fn as_any(&self) -> &dyn std::any::Any {
239 self
240 }
241
242 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
243 where
244 Self: Sized,
245 {
246 Box::new(Self)
247 }
248}
249
250#[cfg(test)]
251mod tests {
252 use super::*;
253 use crate::config::MarkdownFlavor;
254
255 fn check(content: &str) -> Vec<LintWarning> {
256 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
257 let rule = MD077ListContinuationIndent;
258 rule.check(&ctx).unwrap()
259 }
260
261 fn check_mkdocs(content: &str) -> Vec<LintWarning> {
262 let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
263 let rule = MD077ListContinuationIndent;
264 rule.check(&ctx).unwrap()
265 }
266
267 fn fix(content: &str) -> String {
268 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
269 let rule = MD077ListContinuationIndent;
270 rule.fix(&ctx).unwrap()
271 }
272
273 fn fix_mkdocs(content: &str) -> String {
274 let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
275 let rule = MD077ListContinuationIndent;
276 rule.fix(&ctx).unwrap()
277 }
278
279 #[test]
282 fn lazy_continuation_not_flagged() {
283 let content = "- Item\ncontinuation\n";
285 assert!(check(content).is_empty());
286 }
287
288 #[test]
291 fn unordered_correct_indent_no_warning() {
292 let content = "- Item\n\n continuation\n";
293 assert!(check(content).is_empty());
294 }
295
296 #[test]
297 fn unordered_partial_indent_warns() {
298 let content = "- Item\n\n continuation\n";
301 let warnings = check(content);
302 assert_eq!(warnings.len(), 1);
303 assert_eq!(warnings[0].line, 3);
304 assert!(warnings[0].message.contains("2 spaces"));
305 assert!(warnings[0].message.contains("found 1"));
306 }
307
308 #[test]
309 fn unordered_zero_indent_is_new_paragraph() {
310 let content = "- Item\n\ncontinuation\n";
313 assert!(check(content).is_empty());
314 }
315
316 #[test]
319 fn ordered_3space_correct_commonmark() {
320 let content = "1. Item\n\n continuation\n";
322 assert!(check(content).is_empty());
323 }
324
325 #[test]
326 fn ordered_2space_under_indent_commonmark() {
327 let content = "1. Item\n\n continuation\n";
328 let warnings = check(content);
329 assert_eq!(warnings.len(), 1);
330 assert!(warnings[0].message.contains("3 spaces"));
331 assert!(warnings[0].message.contains("found 2"));
332 }
333
334 #[test]
337 fn multi_digit_marker_correct() {
338 let content = "10. Item\n\n continuation\n";
340 assert!(check(content).is_empty());
341 }
342
343 #[test]
344 fn multi_digit_marker_under_indent() {
345 let content = "10. Item\n\n continuation\n";
346 let warnings = check(content);
347 assert_eq!(warnings.len(), 1);
348 assert!(warnings[0].message.contains("4 spaces"));
349 }
350
351 #[test]
354 fn mkdocs_3space_ordered_warns() {
355 let content = "1. Item\n\n continuation\n";
357 let warnings = check_mkdocs(content);
358 assert_eq!(warnings.len(), 1);
359 assert!(warnings[0].message.contains("4 spaces"));
360 assert!(warnings[0].message.contains("MkDocs"));
361 }
362
363 #[test]
364 fn mkdocs_4space_ordered_no_warning() {
365 let content = "1. Item\n\n continuation\n";
366 assert!(check_mkdocs(content).is_empty());
367 }
368
369 #[test]
370 fn mkdocs_unordered_2space_ok() {
371 let content = "- Item\n\n continuation\n";
373 assert!(check_mkdocs(content).is_empty());
374 }
375
376 #[test]
377 fn mkdocs_unordered_2space_warns() {
378 let content = "- Item\n\n continuation\n";
380 let warnings = check_mkdocs(content);
381 assert_eq!(warnings.len(), 1);
382 assert!(warnings[0].message.contains("4 spaces"));
383 }
384
385 #[test]
388 fn fix_unordered_indent() {
389 let content = "- Item\n\n continuation\n";
391 let fixed = fix(content);
392 assert_eq!(fixed, "- Item\n\n continuation\n");
393 }
394
395 #[test]
396 fn fix_ordered_indent() {
397 let content = "1. Item\n\n continuation\n";
398 let fixed = fix(content);
399 assert_eq!(fixed, "1. Item\n\n continuation\n");
400 }
401
402 #[test]
403 fn fix_mkdocs_indent() {
404 let content = "1. Item\n\n continuation\n";
405 let fixed = fix_mkdocs(content);
406 assert_eq!(fixed, "1. Item\n\n continuation\n");
407 }
408
409 #[test]
412 fn nested_list_items_not_flagged() {
413 let content = "- Parent\n\n - Child\n";
414 assert!(check(content).is_empty());
415 }
416
417 #[test]
418 fn nested_list_zero_indent_is_new_paragraph() {
419 let content = "- Parent\n - Child\n\ncontinuation of parent\n";
421 assert!(check(content).is_empty());
422 }
423
424 #[test]
425 fn nested_list_partial_indent_flagged() {
426 let content = "- Parent\n - Child\n\n continuation of parent\n";
428 let warnings = check(content);
429 assert_eq!(warnings.len(), 1);
430 assert!(warnings[0].message.contains("2 spaces"));
431 }
432
433 #[test]
436 fn code_block_correctly_indented_no_warning() {
437 let content = "- Item\n\n ```\n code\n ```\n";
439 assert!(check(content).is_empty());
440 }
441
442 #[test]
443 fn code_fence_under_indented_warns() {
444 let content = "- Item\n\n ```\n code\n ```\n";
446 let warnings = check(content);
447 assert_eq!(warnings.len(), 2);
449 }
450
451 #[test]
452 fn code_fence_under_indented_ordered_mkdocs() {
453 let content = "1. Item\n\n ```toml\n key = \"value\"\n ```\n";
456 assert!(check(content).is_empty()); let warnings = check_mkdocs(content);
458 assert_eq!(warnings.len(), 2); assert!(warnings[0].message.contains("4 spaces"));
460 assert!(warnings[0].message.contains("MkDocs"));
461 }
462
463 #[test]
464 fn code_fence_tilde_under_indented() {
465 let content = "- Item\n\n ~~~\n code\n ~~~\n";
466 let warnings = check(content);
467 assert_eq!(warnings.len(), 2); }
469
470 #[test]
473 fn multiple_blank_lines_zero_indent_is_new_paragraph() {
474 let content = "- Item\n\n\ncontinuation\n";
476 assert!(check(content).is_empty());
477 }
478
479 #[test]
480 fn multiple_blank_lines_partial_indent_flags() {
481 let content = "- Item\n\n\n continuation\n";
482 let warnings = check(content);
483 assert_eq!(warnings.len(), 1);
484 }
485
486 #[test]
489 fn empty_item_no_warning() {
490 let content = "- \n- Second\n";
491 assert!(check(content).is_empty());
492 }
493
494 #[test]
497 fn multiple_items_mixed_indent() {
498 let content = "1. First\n\n correct continuation\n\n2. Second\n\n wrong continuation\n";
499 let warnings = check(content);
500 assert_eq!(warnings.len(), 1);
501 assert_eq!(warnings[0].line, 7);
502 }
503
504 #[test]
507 fn task_list_correct_indent() {
508 let content = "- [ ] Task\n\n continuation\n";
510 assert!(check(content).is_empty());
511 }
512
513 #[test]
516 fn frontmatter_not_flagged() {
517 let content = "---\ntitle: test\n---\n\n- Item\n\n continuation\n";
518 assert!(check(content).is_empty());
519 }
520
521 #[test]
524 fn fix_multiple_items() {
525 let content = "1. First\n\n wrong1\n\n2. Second\n\n wrong2\n";
526 let fixed = fix(content);
527 assert_eq!(fixed, "1. First\n\n wrong1\n\n2. Second\n\n wrong2\n");
528 }
529
530 #[test]
531 fn fix_multiline_loose_continuation_all_lines() {
532 let content = "1. Item\n\n line one\n line two\n line three\n";
533 let fixed = fix(content);
534 assert_eq!(fixed, "1. Item\n\n line one\n line two\n line three\n");
535 }
536
537 #[test]
540 fn sibling_item_boundary_respected() {
541 let content = "- First\n- Second\n\n continuation\n";
543 assert!(check(content).is_empty());
544 }
545
546 #[test]
549 fn blockquote_list_correct_indent_no_warning() {
550 let content = "> - Item\n>\n> continuation\n";
553 assert!(check(content).is_empty());
554 }
555
556 #[test]
557 fn blockquote_list_under_indent_no_false_positive() {
558 let content = "> - Item\n>\n> continuation\n";
563 assert!(check(content).is_empty());
564 }
565
566 #[test]
569 fn deeply_nested_correct_indent() {
570 let content = "- L1\n - L2\n - L3\n\n continuation of L3\n";
571 assert!(check(content).is_empty());
572 }
573
574 #[test]
575 fn deeply_nested_under_indent() {
576 let content = "- L1\n - L2\n - L3\n\n continuation of L3\n";
579 let warnings = check(content);
580 assert_eq!(warnings.len(), 1);
581 assert!(warnings[0].message.contains("6 spaces"));
582 assert!(warnings[0].message.contains("found 5"));
583 }
584
585 #[test]
588 fn tab_indent_correct() {
589 let content = "- Item\n\n\tcontinuation\n";
592 assert!(check(content).is_empty());
593 }
594
595 #[test]
598 fn multiple_continuations_correct() {
599 let content = "- Item\n\n para 1\n\n para 2\n\n para 3\n";
600 assert!(check(content).is_empty());
601 }
602
603 #[test]
604 fn multiple_continuations_second_under_indent() {
605 let content = "- Item\n\n para 1\n\n continuation 2\n";
607 let warnings = check(content);
608 assert_eq!(warnings.len(), 1);
609 assert_eq!(warnings[0].line, 5);
610 }
611
612 #[test]
615 fn ordered_paren_marker_correct() {
616 let content = "1) Item\n\n continuation\n";
618 assert!(check(content).is_empty());
619 }
620
621 #[test]
622 fn ordered_paren_marker_under_indent() {
623 let content = "1) Item\n\n continuation\n";
624 let warnings = check(content);
625 assert_eq!(warnings.len(), 1);
626 assert!(warnings[0].message.contains("3 spaces"));
627 }
628
629 #[test]
632 fn star_marker_correct() {
633 let content = "* Item\n\n continuation\n";
634 assert!(check(content).is_empty());
635 }
636
637 #[test]
638 fn star_marker_under_indent() {
639 let content = "* Item\n\n continuation\n";
640 let warnings = check(content);
641 assert_eq!(warnings.len(), 1);
642 }
643
644 #[test]
645 fn plus_marker_correct() {
646 let content = "+ Item\n\n continuation\n";
647 assert!(check(content).is_empty());
648 }
649
650 #[test]
653 fn heading_after_list_no_warning() {
654 let content = "- Item\n\n# Heading\n";
655 assert!(check(content).is_empty());
656 }
657
658 #[test]
661 fn hr_after_list_no_warning() {
662 let content = "- Item\n\n---\n";
663 assert!(check(content).is_empty());
664 }
665
666 #[test]
669 fn reference_link_def_not_flagged() {
670 let content = "- Item\n\n [link]: https://example.com\n";
671 assert!(check(content).is_empty());
672 }
673
674 #[test]
677 fn footnote_def_not_flagged() {
678 let content = "- Item\n\n [^1]: footnote text\n";
679 assert!(check(content).is_empty());
680 }
681
682 #[test]
685 fn fix_deeply_nested() {
686 let content = "- L1\n - L2\n - L3\n\n under-indented\n";
687 let fixed = fix(content);
688 assert_eq!(fixed, "- L1\n - L2\n - L3\n\n under-indented\n");
689 }
690
691 #[test]
692 fn fix_mkdocs_unordered() {
693 let content = "- Item\n\n continuation\n";
695 let fixed = fix_mkdocs(content);
696 assert_eq!(fixed, "- Item\n\n continuation\n");
697 }
698
699 #[test]
700 fn fix_code_fence_indent() {
701 let content = "- Item\n\n ```\n code\n ```\n";
703 let fixed = fix(content);
704 assert_eq!(fixed, "- Item\n\n ```\n code\n ```\n");
705 }
706
707 #[test]
708 fn fix_mkdocs_code_fence_indent() {
709 let content = "1. Item\n\n ```toml\n key = \"val\"\n ```\n";
711 let fixed = fix_mkdocs(content);
712 assert_eq!(fixed, "1. Item\n\n ```toml\n key = \"val\"\n ```\n");
713 }
714
715 #[test]
718 fn empty_document_no_warning() {
719 assert!(check("").is_empty());
720 }
721
722 #[test]
723 fn whitespace_only_no_warning() {
724 assert!(check(" \n\n \n").is_empty());
725 }
726
727 #[test]
730 fn no_list_no_warning() {
731 let content = "# Heading\n\nSome paragraph.\n\nAnother paragraph.\n";
732 assert!(check(content).is_empty());
733 }
734
735 #[test]
738 fn multiline_continuation_all_lines_flagged() {
739 let content = "1. This is a list item.\n\n This is continuation text and\n it has multiple lines.\n This is yet another line.\n";
740 let warnings = check(content);
741 assert_eq!(warnings.len(), 3);
742 assert_eq!(warnings[0].line, 3);
743 assert_eq!(warnings[1].line, 4);
744 assert_eq!(warnings[2].line, 5);
745 }
746
747 #[test]
748 fn multiline_continuation_with_frontmatter_fix() {
749 let content = "---\ntitle: Heading\n---\n\nSome introductory text:\n\n1. This is a list item.\n\n This is list continuation text and\n it has multiple lines that aren't indented properly.\n This is yet another line that isn't indented properly.\n1. This is a list item.\n\n This is list continuation text and\n it has multiple lines that aren't indented properly.\n This is yet another line that isn't indented properly.\n";
750 let fixed = fix(content);
751 assert_eq!(
752 fixed,
753 "---\ntitle: Heading\n---\n\nSome introductory text:\n\n1. This is a list item.\n\n This is list continuation text and\n it has multiple lines that aren't indented properly.\n This is yet another line that isn't indented properly.\n1. This is a list item.\n\n This is list continuation text and\n it has multiple lines that aren't indented properly.\n This is yet another line that isn't indented properly.\n"
754 );
755 }
756
757 #[test]
758 fn multiline_continuation_correct_indent_no_warning() {
759 let content = "1. Item\n\n line one\n line two\n line three\n";
760 assert!(check(content).is_empty());
761 }
762
763 #[test]
764 fn multiline_continuation_mixed_indent() {
765 let content = "1. Item\n\n correct\n wrong\n correct\n";
766 let warnings = check(content);
767 assert_eq!(warnings.len(), 1);
768 assert_eq!(warnings[0].line, 4);
769 }
770
771 #[test]
772 fn multiline_continuation_unordered() {
773 let content = "- Item\n\n continuation 1\n continuation 2\n continuation 3\n";
774 let warnings = check(content);
775 assert_eq!(warnings.len(), 3);
776 let fixed = fix(content);
777 assert_eq!(
778 fixed,
779 "- Item\n\n continuation 1\n continuation 2\n continuation 3\n"
780 );
781 }
782
783 #[test]
784 fn multiline_continuation_two_items_fix() {
785 let content = "1. First\n\n cont a\n cont b\n\n2. Second\n\n cont c\n cont d\n";
786 let fixed = fix(content);
787 assert_eq!(
788 fixed,
789 "1. First\n\n cont a\n cont b\n\n2. Second\n\n cont c\n cont d\n"
790 );
791 }
792
793 #[test]
794 fn multiline_continuation_separated_by_blank() {
795 let content = "1. Item\n\n para1 line1\n para1 line2\n\n para2 line1\n para2 line2\n";
796 let warnings = check(content);
797 assert_eq!(warnings.len(), 4);
798 let fixed = fix(content);
799 assert_eq!(
800 fixed,
801 "1. Item\n\n para1 line1\n para1 line2\n\n para2 line1\n para2 line2\n"
802 );
803 }
804}