1use crate::lint_context::LintContext;
7use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
8
9#[derive(Clone, Default)]
20pub struct MD077ListContinuationIndent;
21
22impl MD077ListContinuationIndent {
23 fn is_block_level_construct(trimmed: &str) -> bool {
25 if trimmed.starts_with("[^") && trimmed.contains("]:") {
27 return true;
28 }
29 if trimmed.starts_with("*[") && trimmed.contains("]:") {
31 return true;
32 }
33 if trimmed.starts_with('[') && !trimmed.starts_with("[^") && trimmed.contains("]: ") {
36 return true;
37 }
38 false
39 }
40
41 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 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 fn find_fence_closer(ctx: &LintContext, opener_line: usize) -> usize {
70 let mut closer_line = opener_line;
71 for peek in (opener_line + 1)..=ctx.lines.len() {
72 let Some(peek_info) = ctx.line_info(peek) else { break };
73 if peek_info.in_code_block {
74 closer_line = peek;
75 } else {
76 break;
77 }
78 }
79 closer_line
80 }
81
82 fn build_compound_fence_fix(
96 ctx: &LintContext,
97 opener_line: usize,
98 closer_line: usize,
99 opener_actual: usize,
100 required: usize,
101 ) -> Option<Fix> {
102 let opener_info = ctx.line_info(opener_line)?;
103 let closer_info = ctx.line_info(closer_line)?;
104 let delta = required.saturating_sub(opener_actual);
105 if delta == 0 {
106 return None;
107 }
108
109 let fix_start = opener_info.byte_offset;
110 let fix_end = closer_info.byte_offset + closer_info.byte_len;
111
112 let mut replacement = String::new();
113 for i in opener_line..=closer_line {
114 let info = ctx.line_info(i)?;
115 if i > opener_line {
116 replacement.push('\n');
117 }
118 let line = info.content(ctx.content);
119 if info.is_blank {
120 replacement.push_str(line);
122 } else {
123 let new_visual = info.visual_indent + delta;
124 for _ in 0..new_visual {
125 replacement.push(' ');
126 }
127 replacement.push_str(&line[info.indent..]);
128 }
129 }
130
131 Some(Fix {
132 range: fix_start..fix_end,
133 replacement,
134 })
135 }
136
137 fn should_skip_line(info: &crate::lint_context::LineInfo, trimmed: &str) -> bool {
142 if info.in_code_block && !Self::is_code_fence(trimmed) {
143 return true;
144 }
145 info.in_front_matter
146 || info.in_html_block
147 || info.in_html_comment
148 || info.in_mdx_comment
149 || info.in_mkdocstrings
150 || info.in_esm_block
151 || info.in_math_block
152 || info.in_admonition
153 || info.in_content_tab
154 || info.in_pymdown_block
155 || info.in_definition_list
156 || info.in_mkdocs_html_markdown
157 || info.in_kramdown_extension_block
158 }
159}
160
161impl Rule for MD077ListContinuationIndent {
162 fn name(&self) -> &'static str {
163 "MD077"
164 }
165
166 fn description(&self) -> &'static str {
167 "List continuation content indentation"
168 }
169
170 fn check(&self, ctx: &LintContext) -> LintResult {
171 if ctx.content.is_empty() {
172 return Ok(Vec::new());
173 }
174
175 let strict_indent = ctx.flavor.requires_strict_list_indent();
176 let total_lines = ctx.lines.len();
177 let mut warnings = Vec::new();
178 let mut flagged_lines = std::collections::HashSet::new();
179
180 let mut items: Vec<(usize, usize, usize)> = Vec::new(); for block in &ctx.list_blocks {
185 for &item_line in &block.item_lines {
186 if let Some(info) = ctx.line_info(item_line)
187 && let Some(ref li) = info.list_item
188 {
189 items.push((item_line, li.marker_column, li.content_column));
190 }
191 }
192 }
193 items.sort_unstable();
194 items.dedup_by_key(|&mut (ln, _, _)| ln);
195
196 for (item_idx, &(item_line, marker_col, content_col)) in items.iter().enumerate() {
197 let required = if strict_indent { content_col.max(4) } else { content_col };
198
199 let range_end = items
202 .iter()
203 .skip(item_idx + 1)
204 .find(|&&(_, mc, _)| mc <= marker_col)
205 .map_or(total_lines, |&(ln, _, _)| ln - 1);
206
207 let mut saw_blank = false;
208 let mut nested_content_col: Option<usize> = None;
211
212 for line_num in (item_line + 1)..=range_end {
213 let Some(line_info) = ctx.line_info(line_num) else {
214 continue;
215 };
216
217 let trimmed = line_info.content(ctx.content).trim_start();
218
219 if Self::should_skip_line(line_info, trimmed) {
220 continue;
221 }
222
223 if line_info.is_blank {
224 saw_blank = true;
225 continue;
226 }
227
228 if let Some(ref li) = line_info.list_item {
230 if li.marker_column > marker_col {
231 nested_content_col = Some(li.content_column);
232 } else {
233 nested_content_col = None;
234 }
235 saw_blank = false;
236 continue;
237 }
238
239 if line_info.heading.is_some() {
241 break;
242 }
243
244 if line_info.is_horizontal_rule {
246 break;
247 }
248
249 if Self::is_block_level_construct(trimmed) {
252 continue;
253 }
254
255 let actual = line_info.visual_indent;
256
257 if let Some(ncc) = nested_content_col {
260 if actual >= ncc {
261 continue;
262 }
263 nested_content_col = None;
264 }
265
266 if !saw_blank {
268 if actual > required && !Self::starts_with_list_marker(trimmed) && flagged_lines.insert(line_num) {
269 let line_content = line_info.content(ctx.content);
270 let fix_start = line_info.byte_offset;
271 let fix_end = fix_start + line_info.indent;
272
273 warnings.push(LintWarning {
274 rule_name: Some("MD077".to_string()),
275 line: line_num,
276 column: 1,
277 end_line: line_num,
278 end_column: line_content.len() + 1,
279 message: format!("Continuation line over-indented (expected {required}, found {actual})",),
280 severity: Severity::Warning,
281 fix: Some(Fix {
282 range: fix_start..fix_end,
283 replacement: " ".repeat(required),
284 }),
285 });
286 }
287 continue;
288 }
289
290 if actual <= marker_col {
294 break;
295 }
296
297 if actual < required && flagged_lines.insert(line_num) {
298 let line_content = line_info.content(ctx.content);
299
300 let message = if strict_indent {
301 format!(
302 "Content inside list item needs {required} spaces of indentation \
303 for MkDocs compatibility (found {actual})",
304 )
305 } else {
306 format!(
307 "Content after blank line in list item needs {required} spaces of \
308 indentation to remain part of the list (found {actual})",
309 )
310 };
311
312 let is_fence_opener = line_info.in_code_block
320 && Self::is_code_fence(trimmed)
321 && ctx.line_info(line_num - 1).is_none_or(|p| !p.in_code_block);
322
323 let (fix, warn_end_line, warn_end_column) = if is_fence_opener {
324 let closer_line = Self::find_fence_closer(ctx, line_num);
325 if closer_line != line_num {
326 flagged_lines.insert(closer_line);
327 }
328 let fix = Self::build_compound_fence_fix(ctx, line_num, closer_line, actual, required);
329 let end_line = closer_line;
330 let end_column = ctx
331 .line_info(closer_line)
332 .map_or(line_content.len() + 1, |ci| ci.content(ctx.content).len() + 1);
333 (fix, end_line, end_column)
334 } else {
335 let fix_start = line_info.byte_offset;
336 let fix_end = fix_start + line_info.indent;
337 let fix = Some(Fix {
338 range: fix_start..fix_end,
339 replacement: " ".repeat(required),
340 });
341 (fix, line_num, line_content.len() + 1)
342 };
343
344 warnings.push(LintWarning {
345 rule_name: Some("MD077".to_string()),
346 line: line_num,
347 column: 1,
348 end_line: warn_end_line,
349 end_column: warn_end_column,
350 message,
351 severity: Severity::Warning,
352 fix,
353 });
354 }
355
356 }
361 }
362
363 Ok(warnings)
364 }
365
366 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
367 let warnings = self.check(ctx)?;
368 let warnings =
369 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
370 if warnings.is_empty() {
371 return Ok(ctx.content.to_string());
372 }
373
374 let mut fixes: Vec<Fix> = warnings.into_iter().filter_map(|w| w.fix).collect();
376 fixes.sort_by_key(|f| std::cmp::Reverse(f.range.start));
377
378 let mut content = ctx.content.to_string();
379 for fix in fixes {
380 if fix.range.start <= content.len() && fix.range.end <= content.len() {
381 content.replace_range(fix.range, &fix.replacement);
382 }
383 }
384
385 Ok(content)
386 }
387
388 fn category(&self) -> RuleCategory {
389 RuleCategory::List
390 }
391
392 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
393 ctx.content.is_empty() || ctx.list_blocks.is_empty()
394 }
395
396 fn as_any(&self) -> &dyn std::any::Any {
397 self
398 }
399
400 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
401 where
402 Self: Sized,
403 {
404 Box::new(Self)
405 }
406}
407
408#[cfg(test)]
409mod tests {
410 use super::*;
411 use crate::config::MarkdownFlavor;
412
413 fn check(content: &str) -> Vec<LintWarning> {
414 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
415 let rule = MD077ListContinuationIndent;
416 rule.check(&ctx).unwrap()
417 }
418
419 fn check_mkdocs(content: &str) -> Vec<LintWarning> {
420 let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
421 let rule = MD077ListContinuationIndent;
422 rule.check(&ctx).unwrap()
423 }
424
425 fn fix(content: &str) -> String {
426 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
427 let rule = MD077ListContinuationIndent;
428 rule.fix(&ctx).unwrap()
429 }
430
431 fn fix_mkdocs(content: &str) -> String {
432 let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
433 let rule = MD077ListContinuationIndent;
434 rule.fix(&ctx).unwrap()
435 }
436
437 #[test]
440 fn tight_lazy_continuation_zero_indent_not_flagged() {
441 let content = "- Item\ncontinuation\n";
443 assert!(check(content).is_empty());
444 }
445
446 #[test]
447 fn tight_continuation_correct_indent_not_flagged() {
448 let content = "1. Item\n continuation\n";
450 assert!(check(content).is_empty());
451 }
452
453 #[test]
454 fn tight_continuation_over_indented_ordered() {
455 let content = "1. This is a list item with multiple lines.\n The second line is over-indented.\n";
457 let warnings = check(content);
458 assert_eq!(warnings.len(), 1);
459 assert_eq!(warnings[0].line, 2);
460 assert!(warnings[0].message.contains("over-indented"));
461 }
462
463 #[test]
464 fn tight_continuation_over_indented_unordered() {
465 let content = "- Item\n over-indented\n";
467 let warnings = check(content);
468 assert_eq!(warnings.len(), 1);
469 assert_eq!(warnings[0].line, 2);
470 }
471
472 #[test]
473 fn tight_continuation_multiple_over_indented_lines() {
474 let content = "1. Item\n line one\n line two\n line three\n";
475 let warnings = check(content);
476 assert_eq!(warnings.len(), 3);
477 }
478
479 #[test]
480 fn tight_continuation_mixed_correct_and_over() {
481 let content = "1. Item\n correct\n over-indented\n correct again\n";
482 let warnings = check(content);
483 assert_eq!(warnings.len(), 1);
484 assert_eq!(warnings[0].line, 3);
485 }
486
487 #[test]
488 fn tight_continuation_nested_over_indented() {
489 let content = "- L1\n - L2\n over-indented continuation of L2\n";
491 let warnings = check(content);
492 assert_eq!(warnings.len(), 1);
493 assert_eq!(warnings[0].line, 3);
494 assert!(warnings[0].message.contains("expected 4"));
496 assert!(warnings[0].message.contains("found 5"));
497 }
498
499 #[test]
500 fn tight_continuation_nested_correct_indent_not_flagged() {
501 let content = "- L1\n - L2\n correctly indented continuation of L2\n";
504 assert!(check(content).is_empty());
505 }
506
507 #[test]
508 fn fix_tight_continuation_nested_over_indented() {
509 let content = "- L1\n - L2\n over-indented continuation of L2\n";
511 let fixed = fix(content);
512 assert_eq!(fixed, "- L1\n - L2\n over-indented continuation of L2\n");
513 }
514
515 #[test]
516 fn tight_continuation_under_indented_not_flagged() {
517 let content = "1. Item\n under-indented\n";
520 assert!(check(content).is_empty());
521 }
522
523 #[test]
524 fn tight_continuation_tab_over_indented() {
525 let content = "- Item\n\tover-indented\n";
527 let warnings = check(content);
528 assert_eq!(warnings.len(), 1);
529 }
530
531 #[test]
532 fn fix_tight_continuation_over_indented_ordered() {
533 let content = "1. This is a list item with multiple lines.\n The second line is over-indented.\n";
534 let fixed = fix(content);
535 assert_eq!(
536 fixed,
537 "1. This is a list item with multiple lines.\n The second line is over-indented.\n"
538 );
539 }
540
541 #[test]
542 fn fix_tight_continuation_over_indented_unordered() {
543 let content = "- Item\n over-indented\n";
544 let fixed = fix(content);
545 assert_eq!(fixed, "- Item\n over-indented\n");
546 }
547
548 #[test]
549 fn fix_tight_continuation_multiple_lines() {
550 let content = "1. Item\n line one\n line two\n";
551 let fixed = fix(content);
552 assert_eq!(fixed, "1. Item\n line one\n line two\n");
553 }
554
555 #[test]
556 fn tight_continuation_mkdocs_4space_ordered_not_flagged() {
557 let content = "1. Item\n continuation\n";
560 assert!(check_mkdocs(content).is_empty());
561 }
562
563 #[test]
564 fn tight_continuation_mkdocs_5space_ordered_flagged() {
565 let content = "1. Item\n over-indented\n";
567 let warnings = check_mkdocs(content);
568 assert_eq!(warnings.len(), 1);
569 assert!(warnings[0].message.contains("expected 4"));
570 assert!(warnings[0].message.contains("found 5"));
571 }
572
573 #[test]
574 fn fix_tight_continuation_mkdocs_over_indented() {
575 let content = "1. Item\n over-indented\n";
576 let fixed = fix_mkdocs(content);
577 assert_eq!(fixed, "1. Item\n over-indented\n");
578 }
579
580 #[test]
581 fn tight_continuation_deeply_indented_list_markers_not_flagged() {
582 let content = "* Level 0\n * Level 1\n * Level 2\n";
585 assert!(check(content).is_empty());
586 }
587
588 #[test]
589 fn tight_continuation_ordered_marker_not_flagged() {
590 let content = "- Parent\n 1. Child item\n";
592 assert!(check(content).is_empty());
593 }
594
595 #[test]
598 fn unordered_correct_indent_no_warning() {
599 let content = "- Item\n\n continuation\n";
600 assert!(check(content).is_empty());
601 }
602
603 #[test]
604 fn unordered_partial_indent_warns() {
605 let content = "- Item\n\n continuation\n";
608 let warnings = check(content);
609 assert_eq!(warnings.len(), 1);
610 assert_eq!(warnings[0].line, 3);
611 assert!(warnings[0].message.contains("2 spaces"));
612 assert!(warnings[0].message.contains("found 1"));
613 }
614
615 #[test]
616 fn unordered_zero_indent_is_new_paragraph() {
617 let content = "- Item\n\ncontinuation\n";
620 assert!(check(content).is_empty());
621 }
622
623 #[test]
626 fn ordered_3space_correct_commonmark() {
627 let content = "1. Item\n\n continuation\n";
629 assert!(check(content).is_empty());
630 }
631
632 #[test]
633 fn ordered_2space_under_indent_commonmark() {
634 let content = "1. Item\n\n continuation\n";
635 let warnings = check(content);
636 assert_eq!(warnings.len(), 1);
637 assert!(warnings[0].message.contains("3 spaces"));
638 assert!(warnings[0].message.contains("found 2"));
639 }
640
641 #[test]
644 fn multi_digit_marker_correct() {
645 let content = "10. Item\n\n continuation\n";
647 assert!(check(content).is_empty());
648 }
649
650 #[test]
651 fn multi_digit_marker_under_indent() {
652 let content = "10. Item\n\n continuation\n";
653 let warnings = check(content);
654 assert_eq!(warnings.len(), 1);
655 assert!(warnings[0].message.contains("4 spaces"));
656 }
657
658 #[test]
661 fn mkdocs_3space_ordered_warns() {
662 let content = "1. Item\n\n continuation\n";
664 let warnings = check_mkdocs(content);
665 assert_eq!(warnings.len(), 1);
666 assert!(warnings[0].message.contains("4 spaces"));
667 assert!(warnings[0].message.contains("MkDocs"));
668 }
669
670 #[test]
671 fn mkdocs_4space_ordered_no_warning() {
672 let content = "1. Item\n\n continuation\n";
673 assert!(check_mkdocs(content).is_empty());
674 }
675
676 #[test]
677 fn mkdocs_unordered_2space_ok() {
678 let content = "- Item\n\n continuation\n";
680 assert!(check_mkdocs(content).is_empty());
681 }
682
683 #[test]
684 fn mkdocs_unordered_2space_warns() {
685 let content = "- Item\n\n continuation\n";
687 let warnings = check_mkdocs(content);
688 assert_eq!(warnings.len(), 1);
689 assert!(warnings[0].message.contains("4 spaces"));
690 }
691
692 #[test]
695 fn fix_unordered_indent() {
696 let content = "- Item\n\n continuation\n";
698 let fixed = fix(content);
699 assert_eq!(fixed, "- Item\n\n continuation\n");
700 }
701
702 #[test]
703 fn fix_ordered_indent() {
704 let content = "1. Item\n\n continuation\n";
705 let fixed = fix(content);
706 assert_eq!(fixed, "1. Item\n\n continuation\n");
707 }
708
709 #[test]
710 fn fix_mkdocs_indent() {
711 let content = "1. Item\n\n continuation\n";
712 let fixed = fix_mkdocs(content);
713 assert_eq!(fixed, "1. Item\n\n continuation\n");
714 }
715
716 #[test]
719 fn nested_list_items_not_flagged() {
720 let content = "- Parent\n\n - Child\n";
721 assert!(check(content).is_empty());
722 }
723
724 #[test]
725 fn nested_list_zero_indent_is_new_paragraph() {
726 let content = "- Parent\n - Child\n\ncontinuation of parent\n";
728 assert!(check(content).is_empty());
729 }
730
731 #[test]
732 fn nested_list_partial_indent_flagged() {
733 let content = "- Parent\n - Child\n\n continuation of parent\n";
735 let warnings = check(content);
736 assert_eq!(warnings.len(), 1);
737 assert!(warnings[0].message.contains("2 spaces"));
738 }
739
740 #[test]
743 fn code_block_correctly_indented_no_warning() {
744 let content = "- Item\n\n ```\n code\n ```\n";
746 assert!(check(content).is_empty());
747 }
748
749 #[test]
750 fn code_fence_under_indented_warns() {
751 let content = "- Item\n\n ```\n code\n ```\n";
755 let warnings = check(content);
756 assert_eq!(warnings.len(), 1);
757 assert_eq!(warnings[0].line, 3);
758 }
759
760 #[test]
761 fn code_fence_under_indented_ordered_mkdocs() {
762 let content = "1. Item\n\n ```toml\n key = \"value\"\n ```\n";
765 assert!(check(content).is_empty()); let warnings = check_mkdocs(content);
767 assert_eq!(warnings.len(), 1); assert_eq!(warnings[0].line, 3);
769 assert!(warnings[0].message.contains("4 spaces"));
770 assert!(warnings[0].message.contains("MkDocs"));
771 }
772
773 #[test]
774 fn code_fence_tilde_under_indented() {
775 let content = "- Item\n\n ~~~\n code\n ~~~\n";
776 let warnings = check(content);
777 assert_eq!(warnings.len(), 1); assert_eq!(warnings[0].line, 3);
779 }
780
781 #[test]
784 fn multiple_blank_lines_zero_indent_is_new_paragraph() {
785 let content = "- Item\n\n\ncontinuation\n";
787 assert!(check(content).is_empty());
788 }
789
790 #[test]
791 fn multiple_blank_lines_partial_indent_flags() {
792 let content = "- Item\n\n\n continuation\n";
793 let warnings = check(content);
794 assert_eq!(warnings.len(), 1);
795 }
796
797 #[test]
800 fn empty_item_no_warning() {
801 let content = "- \n- Second\n";
802 assert!(check(content).is_empty());
803 }
804
805 #[test]
808 fn multiple_items_mixed_indent() {
809 let content = "1. First\n\n correct continuation\n\n2. Second\n\n wrong continuation\n";
810 let warnings = check(content);
811 assert_eq!(warnings.len(), 1);
812 assert_eq!(warnings[0].line, 7);
813 }
814
815 #[test]
818 fn task_list_correct_indent() {
819 let content = "- [ ] Task\n\n continuation\n";
821 assert!(check(content).is_empty());
822 }
823
824 #[test]
827 fn frontmatter_not_flagged() {
828 let content = "---\ntitle: test\n---\n\n- Item\n\n continuation\n";
829 assert!(check(content).is_empty());
830 }
831
832 #[test]
835 fn fix_multiple_items() {
836 let content = "1. First\n\n wrong1\n\n2. Second\n\n wrong2\n";
837 let fixed = fix(content);
838 assert_eq!(fixed, "1. First\n\n wrong1\n\n2. Second\n\n wrong2\n");
839 }
840
841 #[test]
842 fn fix_multiline_loose_continuation_all_lines() {
843 let content = "1. Item\n\n line one\n line two\n line three\n";
844 let fixed = fix(content);
845 assert_eq!(fixed, "1. Item\n\n line one\n line two\n line three\n");
846 }
847
848 #[test]
851 fn sibling_item_boundary_respected() {
852 let content = "- First\n- Second\n\n continuation\n";
854 assert!(check(content).is_empty());
855 }
856
857 #[test]
860 fn blockquote_list_correct_indent_no_warning() {
861 let content = "> - Item\n>\n> continuation\n";
864 assert!(check(content).is_empty());
865 }
866
867 #[test]
868 fn blockquote_list_under_indent_no_false_positive() {
869 let content = "> - Item\n>\n> continuation\n";
874 assert!(check(content).is_empty());
875 }
876
877 #[test]
880 fn deeply_nested_correct_indent() {
881 let content = "- L1\n - L2\n - L3\n\n continuation of L3\n";
882 assert!(check(content).is_empty());
883 }
884
885 #[test]
886 fn deeply_nested_under_indent() {
887 let content = "- L1\n - L2\n - L3\n\n continuation of L3\n";
890 let warnings = check(content);
891 assert_eq!(warnings.len(), 1);
892 assert!(warnings[0].message.contains("6 spaces"));
893 assert!(warnings[0].message.contains("found 5"));
894 }
895
896 #[test]
899 fn tab_indent_correct() {
900 let content = "- Item\n\n\tcontinuation\n";
903 assert!(check(content).is_empty());
904 }
905
906 #[test]
909 fn multiple_continuations_correct() {
910 let content = "- Item\n\n para 1\n\n para 2\n\n para 3\n";
911 assert!(check(content).is_empty());
912 }
913
914 #[test]
915 fn multiple_continuations_second_under_indent() {
916 let content = "- Item\n\n para 1\n\n continuation 2\n";
918 let warnings = check(content);
919 assert_eq!(warnings.len(), 1);
920 assert_eq!(warnings[0].line, 5);
921 }
922
923 #[test]
926 fn ordered_paren_marker_correct() {
927 let content = "1) Item\n\n continuation\n";
929 assert!(check(content).is_empty());
930 }
931
932 #[test]
933 fn ordered_paren_marker_under_indent() {
934 let content = "1) Item\n\n continuation\n";
935 let warnings = check(content);
936 assert_eq!(warnings.len(), 1);
937 assert!(warnings[0].message.contains("3 spaces"));
938 }
939
940 #[test]
943 fn star_marker_correct() {
944 let content = "* Item\n\n continuation\n";
945 assert!(check(content).is_empty());
946 }
947
948 #[test]
949 fn star_marker_under_indent() {
950 let content = "* Item\n\n continuation\n";
951 let warnings = check(content);
952 assert_eq!(warnings.len(), 1);
953 }
954
955 #[test]
956 fn plus_marker_correct() {
957 let content = "+ Item\n\n continuation\n";
958 assert!(check(content).is_empty());
959 }
960
961 #[test]
964 fn heading_after_list_no_warning() {
965 let content = "- Item\n\n# Heading\n";
966 assert!(check(content).is_empty());
967 }
968
969 #[test]
972 fn hr_after_list_no_warning() {
973 let content = "- Item\n\n---\n";
974 assert!(check(content).is_empty());
975 }
976
977 #[test]
980 fn reference_link_def_not_flagged() {
981 let content = "- Item\n\n [link]: https://example.com\n";
982 assert!(check(content).is_empty());
983 }
984
985 #[test]
988 fn footnote_def_not_flagged() {
989 let content = "- Item\n\n [^1]: footnote text\n";
990 assert!(check(content).is_empty());
991 }
992
993 #[test]
996 fn fix_deeply_nested() {
997 let content = "- L1\n - L2\n - L3\n\n under-indented\n";
998 let fixed = fix(content);
999 assert_eq!(fixed, "- L1\n - L2\n - L3\n\n under-indented\n");
1000 }
1001
1002 #[test]
1003 fn fix_mkdocs_unordered() {
1004 let content = "- Item\n\n continuation\n";
1006 let fixed = fix_mkdocs(content);
1007 assert_eq!(fixed, "- Item\n\n continuation\n");
1008 }
1009
1010 #[test]
1011 fn fix_code_fence_indent() {
1012 let content = "- Item\n\n ```\n code\n ```\n";
1015 let fixed = fix(content);
1016 assert_eq!(fixed, "- Item\n\n ```\n code\n ```\n");
1017 }
1018
1019 #[test]
1020 fn fix_mkdocs_code_fence_indent() {
1021 let content = "1. Item\n\n ```toml\n key = \"val\"\n ```\n";
1023 let fixed = fix_mkdocs(content);
1024 assert_eq!(fixed, "1. Item\n\n ```toml\n key = \"val\"\n ```\n");
1025 }
1026
1027 #[test]
1030 fn empty_document_no_warning() {
1031 assert!(check("").is_empty());
1032 }
1033
1034 #[test]
1035 fn whitespace_only_no_warning() {
1036 assert!(check(" \n\n \n").is_empty());
1037 }
1038
1039 #[test]
1042 fn no_list_no_warning() {
1043 let content = "# Heading\n\nSome paragraph.\n\nAnother paragraph.\n";
1044 assert!(check(content).is_empty());
1045 }
1046
1047 #[test]
1050 fn multiline_continuation_all_lines_flagged() {
1051 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";
1052 let warnings = check(content);
1053 assert_eq!(warnings.len(), 3);
1054 assert_eq!(warnings[0].line, 3);
1055 assert_eq!(warnings[1].line, 4);
1056 assert_eq!(warnings[2].line, 5);
1057 }
1058
1059 #[test]
1060 fn multiline_continuation_with_frontmatter_fix() {
1061 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";
1062 let fixed = fix(content);
1063 assert_eq!(
1064 fixed,
1065 "---\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"
1066 );
1067 }
1068
1069 #[test]
1070 fn multiline_continuation_correct_indent_no_warning() {
1071 let content = "1. Item\n\n line one\n line two\n line three\n";
1072 assert!(check(content).is_empty());
1073 }
1074
1075 #[test]
1076 fn multiline_continuation_mixed_indent() {
1077 let content = "1. Item\n\n correct\n wrong\n correct\n";
1078 let warnings = check(content);
1079 assert_eq!(warnings.len(), 1);
1080 assert_eq!(warnings[0].line, 4);
1081 }
1082
1083 #[test]
1084 fn multiline_continuation_unordered() {
1085 let content = "- Item\n\n continuation 1\n continuation 2\n continuation 3\n";
1086 let warnings = check(content);
1087 assert_eq!(warnings.len(), 3);
1088 let fixed = fix(content);
1089 assert_eq!(
1090 fixed,
1091 "- Item\n\n continuation 1\n continuation 2\n continuation 3\n"
1092 );
1093 }
1094
1095 #[test]
1096 fn multiline_continuation_two_items_fix() {
1097 let content = "1. First\n\n cont a\n cont b\n\n2. Second\n\n cont c\n cont d\n";
1098 let fixed = fix(content);
1099 assert_eq!(
1100 fixed,
1101 "1. First\n\n cont a\n cont b\n\n2. Second\n\n cont c\n cont d\n"
1102 );
1103 }
1104
1105 #[test]
1106 fn fence_fix_does_not_break_pairing_for_md031() {
1107 let content = "#### title\n\nabc\n\n\
1114 1. ab\n\n\
1115 \x20\x20`aabbccdd`\n\n\
1116 2. cd\n\n\
1117 \x20\x20`bbcc dd ee`\n\n\
1118 \x20\x20```\n\
1119 \x20\x20abcd\n\
1120 \x20\x20ef gh\n\
1121 \x20\x20```\n\n\
1122 \x20\x20uu\n\n\
1123 \x20\x20```\n\
1124 \x20\x20cdef\n\
1125 \x20\x20gh ij\n\
1126 \x20\x20```\n";
1127 let expected = "#### title\n\nabc\n\n\
1128 1. ab\n\n\
1129 \x20\x20\x20`aabbccdd`\n\n\
1130 2. cd\n\n\
1131 \x20\x20\x20`bbcc dd ee`\n\n\
1132 \x20\x20\x20```\n\
1133 \x20\x20\x20abcd\n\
1134 \x20\x20\x20ef gh\n\
1135 \x20\x20\x20```\n\n\
1136 \x20\x20\x20uu\n\n\
1137 \x20\x20\x20```\n\
1138 \x20\x20\x20cdef\n\
1139 \x20\x20\x20gh ij\n\
1140 \x20\x20\x20```\n";
1141 assert_eq!(fix(content), expected);
1142 }
1143
1144 #[test]
1145 fn multiline_continuation_separated_by_blank() {
1146 let content = "1. Item\n\n para1 line1\n para1 line2\n\n para2 line1\n para2 line2\n";
1147 let warnings = check(content);
1148 assert_eq!(warnings.len(), 4);
1149 let fixed = fix(content);
1150 assert_eq!(
1151 fixed,
1152 "1. Item\n\n para1 line1\n para1 line2\n\n para2 line1\n para2 line2\n"
1153 );
1154 }
1155
1156 #[test]
1157 fn tab_indented_fence_is_normalized_to_spaces() {
1158 let content = "100. ab\n\n\t```\n\tabcd\n\t```\n";
1166 let expected = "100. ab\n\n ```\n abcd\n ```\n";
1167 assert_eq!(fix(content), expected);
1168 }
1169}