rumdl_lib/rules/
md077_list_continuation_indent.rs1use 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 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 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 #[test]
285 fn lazy_continuation_not_flagged() {
286 let content = "- Item\ncontinuation\n";
288 assert!(check(content).is_empty());
289 }
290
291 #[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 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 let content = "- Item\n\ncontinuation\n";
316 assert!(check(content).is_empty());
317 }
318
319 #[test]
322 fn ordered_3space_correct_commonmark() {
323 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 #[test]
340 fn multi_digit_marker_correct() {
341 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 #[test]
357 fn mkdocs_3space_ordered_warns() {
358 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 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 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 #[test]
391 fn fix_unordered_indent() {
392 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 #[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 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 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 #[test]
439 fn code_block_correctly_indented_no_warning() {
440 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 let content = "- Item\n\n ```\n code\n ```\n";
449 let warnings = check(content);
450 assert_eq!(warnings.len(), 2);
452 }
453
454 #[test]
455 fn code_fence_under_indented_ordered_mkdocs() {
456 let content = "1. Item\n\n ```toml\n key = \"value\"\n ```\n";
459 assert!(check(content).is_empty()); let warnings = check_mkdocs(content);
461 assert_eq!(warnings.len(), 2); 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); }
472
473 #[test]
476 fn multiple_blank_lines_zero_indent_is_new_paragraph() {
477 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 #[test]
492 fn empty_item_no_warning() {
493 let content = "- \n- Second\n";
494 assert!(check(content).is_empty());
495 }
496
497 #[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 #[test]
510 fn task_list_correct_indent() {
511 let content = "- [ ] Task\n\n continuation\n";
513 assert!(check(content).is_empty());
514 }
515
516 #[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 #[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 #[test]
536 fn sibling_item_boundary_respected() {
537 let content = "- First\n- Second\n\n continuation\n";
539 assert!(check(content).is_empty());
540 }
541
542 #[test]
545 fn blockquote_list_correct_indent_no_warning() {
546 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 let content = "> - Item\n>\n> continuation\n";
559 assert!(check(content).is_empty());
560 }
561
562 #[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 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 #[test]
584 fn tab_indent_correct() {
585 let content = "- Item\n\n\tcontinuation\n";
588 assert!(check(content).is_empty());
589 }
590
591 #[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 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 #[test]
611 fn ordered_paren_marker_correct() {
612 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 #[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 #[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 #[test]
657 fn hr_after_list_no_warning() {
658 let content = "- Item\n\n---\n";
659 assert!(check(content).is_empty());
660 }
661
662 #[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 #[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 #[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 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 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 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 #[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 #[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}