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 {} 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 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 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 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 #[test]
287 fn lazy_continuation_not_flagged() {
288 let content = "- Item\ncontinuation\n";
290 assert!(check(content).is_empty());
291 }
292
293 #[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 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 let content = "- Item\n\ncontinuation\n";
318 assert!(check(content).is_empty());
319 }
320
321 #[test]
324 fn ordered_3space_correct_commonmark() {
325 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 #[test]
342 fn multi_digit_marker_correct() {
343 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 #[test]
359 fn mkdocs_3space_ordered_warns() {
360 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 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 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 #[test]
393 fn fix_unordered_indent() {
394 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 #[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 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 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 #[test]
441 fn code_block_correctly_indented_no_warning() {
442 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 let content = "- Item\n\n ```\n code\n ```\n";
451 let warnings = check(content);
452 assert_eq!(warnings.len(), 2);
454 }
455
456 #[test]
457 fn code_fence_under_indented_ordered_mkdocs() {
458 let content = "1. Item\n\n ```toml\n key = \"value\"\n ```\n";
461 assert!(check(content).is_empty()); let warnings = check_mkdocs(content);
463 assert_eq!(warnings.len(), 2); 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); }
474
475 #[test]
478 fn multiple_blank_lines_zero_indent_is_new_paragraph() {
479 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 #[test]
494 fn empty_item_no_warning() {
495 let content = "- \n- Second\n";
496 assert!(check(content).is_empty());
497 }
498
499 #[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 #[test]
512 fn task_list_correct_indent() {
513 let content = "- [ ] Task\n\n continuation\n";
515 assert!(check(content).is_empty());
516 }
517
518 #[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 #[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 #[test]
538 fn sibling_item_boundary_respected() {
539 let content = "- First\n- Second\n\n continuation\n";
541 assert!(check(content).is_empty());
542 }
543
544 #[test]
547 fn blockquote_list_correct_indent_no_warning() {
548 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 let content = "> - Item\n>\n> continuation\n";
561 assert!(check(content).is_empty());
562 }
563
564 #[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 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 #[test]
586 fn tab_indent_correct() {
587 let content = "- Item\n\n\tcontinuation\n";
590 assert!(check(content).is_empty());
591 }
592
593 #[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 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 #[test]
613 fn ordered_paren_marker_correct() {
614 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 #[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 #[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 #[test]
659 fn hr_after_list_no_warning() {
660 let content = "- Item\n\n---\n";
661 assert!(check(content).is_empty());
662 }
663
664 #[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 #[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 #[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 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 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 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 #[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 #[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}