1use std::ops::ControlFlow;
7
8use serde::{Deserialize, Serialize};
9
10use crate::lint_context::{CommonMarkItem, LineInfo, LintContext, NativeListSource};
11use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
12use crate::utils::list_indent_shift::is_lazy_continuation_source as is_lazy_continuation;
13
14mod md077_config;
15use md077_config::MD077Config;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
19#[serde(rename_all = "kebab-case")]
20pub enum ContinuationStyle {
21 #[default]
25 Any,
26 Aligned,
30}
31
32#[derive(Clone, Default)]
48pub struct MD077ListContinuationIndent {
49 config: MD077Config,
50}
51
52impl MD077ListContinuationIndent {
53 pub fn new(style: ContinuationStyle) -> Self {
56 Self {
57 config: MD077Config { style, indent: None },
58 }
59 }
60
61 pub fn from_config_struct(config: MD077Config) -> Self {
62 Self { config }
63 }
64}
65
66impl MD077ListContinuationIndent {
67 const TASK_CHECKBOX_PREFIX_LEN: usize = 4;
70
71 fn is_task_list_item(line: &str, content_col: usize) -> bool {
87 line.as_bytes()
88 .get(content_col..content_col + Self::TASK_CHECKBOX_PREFIX_LEN)
89 .is_some_and(|window| matches!(window, b"[ ] " | b"[x] " | b"[X] "))
90 }
91
92 fn is_paren_marker(line: &str, marker_column: usize) -> bool {
96 let mut column = 0;
97 for (byte, c) in line.char_indices() {
98 if column >= marker_column {
99 let rest = &line[byte..];
100 return rest.trim_start_matches(|c: char| c.is_ascii_digit()).starts_with(')');
101 }
102 column = if c == '\t' { (column / 4 + 1) * 4 } else { column + 1 };
103 }
104 false
105 }
106
107 fn is_task_at_column(line: &str, content_col: usize) -> bool {
112 let mut column = 0;
113 for (byte, ch) in line.char_indices() {
114 if column == content_col {
115 return Self::is_task_list_item(line, byte);
116 }
117 if column > content_col {
118 return false;
119 }
120 column = crate::lint_context::column_after(column, ch);
121 }
122 false
123 }
124
125 fn is_cm_blank(text: &str) -> bool {
128 text.trim_matches([' ', '\t']).is_empty()
129 }
130
131 fn is_block_level_construct(trimmed: &str) -> bool {
133 if trimmed.starts_with("[^") && trimmed.contains("]:") {
135 return true;
136 }
137 if trimmed.starts_with("*[") && trimmed.contains("]:") {
139 return true;
140 }
141 if trimmed.starts_with('[') && !trimmed.starts_with("[^") && trimmed.contains("]: ") {
144 return true;
145 }
146 false
147 }
148
149 fn is_code_fence(trimmed: &str) -> bool {
151 let bytes = trimmed.as_bytes();
152 if bytes.len() < 3 {
153 return false;
154 }
155 let ch = bytes[0];
156 (ch == b'`' || ch == b'~') && bytes[1] == ch && bytes[2] == ch
157 }
158
159 fn is_fence_delimiter(ctx: &NativeListSource<'_, '_>, info: &LineInfo, trimmed: &str) -> bool {
162 Self::is_code_fence(trimmed) && ctx.fenced_block_overlaps_line(info)
163 }
164
165 fn deeper_quote_column(ctx: &NativeListSource<'_, '_>, info: &LineInfo, depth: usize) -> Option<usize> {
168 if info.blockquote.as_ref()?.nesting_level <= depth {
169 return None;
170 }
171 info.content(ctx.content).match_indices('>').nth(depth).map(|(i, _)| i)
174 }
175
176 fn opens_block_when_moved(trimmed: &str, content_col: usize, from: usize, to: usize) -> bool {
186 const MAX_BLOCK_INDENT: usize = 3;
187 from > content_col + MAX_BLOCK_INDENT
188 && to <= content_col + MAX_BLOCK_INDENT
189 && (Self::is_code_fence(trimmed)
190 || trimmed.starts_with('>')
191 || crate::lint_context::is_setext_underline_content(trimmed)
192 || crate::lint_context::is_horizontal_rule_content(trimmed)
193 || crate::utils::skip_context::is_table_line(trimmed)
194 || Self::is_atx_heading_start(trimmed))
195 }
196
197 fn is_atx_heading_start(trimmed: &str) -> bool {
200 let hashes = trimmed.bytes().take_while(|&b| b == b'#').count();
201 (1..=6).contains(&hashes) && matches!(trimmed.as_bytes().get(hashes), None | Some(b' ' | b'\t'))
202 }
203
204 fn starts_with_list_marker(trimmed: &str) -> bool {
208 let bytes = trimmed.as_bytes();
209 match bytes.first() {
210 Some(b'*' | b'-' | b'+') => bytes.get(1).is_some_and(|&b| b == b' ' || b == b'\t'),
211 Some(b'0'..=b'9') => {
212 let rest = trimmed.trim_start_matches(|c: char| c.is_ascii_digit());
213 rest.starts_with(". ") || rest.starts_with(") ")
214 }
215 _ => false,
216 }
217 }
218
219 fn fenced_block_last_line(ctx: &NativeListSource<'_, '_>, info: &LineInfo, trimmed: &str) -> Option<usize> {
224 if !Self::is_code_fence(trimmed) {
225 return None;
226 }
227 ctx.fenced_block_opened_on_line(info)
228 .map(|b| ctx.line_number_at_offset(b.end.saturating_sub(1).max(b.start)))
229 }
230
231 fn build_compound_fence_fix(
269 ctx: &NativeListSource<'_, '_>,
270 opener_line: usize,
271 closer_line: usize,
272 opener_actual: usize,
273 required: usize,
274 ) -> Option<Fix> {
275 if required <= opener_actual {
276 return None;
277 }
278 let opener_info = ctx.line_info(opener_line)?;
279 let closer_info = ctx.line_info(closer_line)?;
280
281 let fix_start = opener_info.byte_offset;
282 let fix_end = closer_info.byte_offset + closer_info.byte_len;
283
284 let mut replacement = String::new();
285 for i in opener_line..=closer_line {
286 let info = ctx.line_info(i)?;
287 if i > opener_line {
288 replacement.push('\n');
289 }
290 let line = info.content(ctx.content);
291 if info.is_blank {
292 replacement.push_str(line);
294 } else {
295 let new_visual = if i == opener_line || i == closer_line {
296 required
297 } else {
298 (info.visual_indent + (required - opener_actual)).max(required)
299 };
300 for _ in 0..new_visual {
301 replacement.push(' ');
302 }
303 replacement.push_str(&line[info.indent..]);
304 }
305 }
306
307 Some(Fix::new(fix_start..fix_end, replacement))
308 }
309
310 fn walk_item_continuation<F>(ctx: &NativeListSource<'_, '_>, scope: &ItemScope, mut per_line: F)
336 where
337 F: FnMut(&ContinuationLine<'_>) -> ControlFlow<()>,
338 {
339 let item_line = scope.item_line;
340 let range_end = scope.range_end;
341 let marker_col = scope.marker_col;
342 let content_col = scope.content_col;
343 let mut saw_blank = false;
344 let mut nested_stack: Vec<(usize, usize, bool)> = scope
351 .same_line
352 .iter()
353 .map(|item| (item.marker_col, item.content_col, item.is_empty))
354 .collect();
355 let mut saw_nested = !nested_stack.is_empty();
356 let mut awaiting_content = scope.starts_empty;
360 let item_info = ctx.line_info(item_line);
361 let item_quotes = item_info
362 .and_then(|i| i.blockquote.as_ref())
363 .map_or(0, |q| q.nesting_level);
364
365 for line_num in (item_line + 1)..=range_end {
366 let Some(info) = ctx.line_info(line_num) else {
367 continue;
368 };
369
370 if Self::deeper_quote_column(ctx, info, item_quotes).is_some_and(|col| col < content_col) {
374 break;
375 }
376
377 let raw_line = info.content(ctx.content);
378 let trimmed = raw_line.trim_start();
379 let blank = Self::is_cm_blank(raw_line);
380
381 if awaiting_content {
383 if blank || info.visual_indent < content_col {
384 break;
385 }
386 awaiting_content = false;
387 }
388 if blank {
390 while nested_stack.last().is_some_and(|&(_, _, awaiting)| awaiting) {
391 nested_stack.pop();
392 }
393 } else {
394 while nested_stack
395 .last()
396 .is_some_and(|&(_, content, awaiting)| awaiting && content > info.visual_indent)
397 {
398 nested_stack.pop();
399 }
400 if let Some(top) = nested_stack.last_mut() {
401 top.2 = false;
402 }
403 }
404
405 if Self::should_skip_line(ctx, info, trimmed) {
406 continue;
407 }
408
409 if blank {
410 saw_blank = true;
411 continue;
412 }
413
414 if trimmed.is_empty() {
417 continue;
418 }
419
420 if let Some(ref li) = info.list_item {
421 let fallback = [CommonMarkItem {
424 marker_column: li.marker_column,
425 content_column: li.content_column,
426 is_empty: false,
427 }];
428 let chain: &[CommonMarkItem] = if li.commonmark_items.is_empty() {
429 &fallback
430 } else {
431 &li.commonmark_items
432 };
433 let first = chain[0];
434 if first.marker_column > marker_col && first.marker_column < content_col {
438 break;
439 }
440 if first.marker_column > marker_col {
441 while nested_stack.last().is_some_and(|&(m, _, _)| m >= first.marker_column) {
444 nested_stack.pop();
445 }
446 for item in chain {
447 nested_stack.push((item.marker_column, item.content_column, item.is_empty));
448 }
449 saw_nested = true;
454 } else {
455 nested_stack.clear();
456 }
457 saw_blank = false;
458 continue;
459 }
460
461 if info.heading.is_some() || info.is_setext_heading_text || info.is_horizontal_rule {
465 break;
466 }
467
468 if Self::is_block_level_construct(trimmed) {
469 continue;
470 }
471
472 let col = info.visual_indent;
473
474 if !saw_blank && col < content_col && !is_lazy_continuation(ctx, line_num - 1) {
482 break;
483 }
484
485 if !nested_stack.is_empty() && is_lazy_continuation(ctx, line_num - 1) {
489 continue;
490 }
491
492 while nested_stack.last().is_some_and(|&(_, c, _)| c > col) {
496 nested_stack.pop();
497 }
498 if !nested_stack.is_empty() {
499 continue;
500 }
501
502 if saw_blank && col <= marker_col {
503 break;
504 }
505
506 let line = ContinuationLine {
507 line_num,
508 info,
509 trimmed,
510 actual: col,
511 saw_blank,
512 saw_nested,
513 };
514 if per_line(&line).is_break() {
515 break;
516 }
517 }
518 }
519
520 fn item_range_has_latent_structure(ctx: &NativeListSource<'_, '_>, item_line: usize, range_end: usize) -> bool {
540 (item_line + 1..=range_end).any(|line_num| {
541 ctx.line_info(line_num).is_some_and(|info| {
542 if info.is_blank || info.list_item.is_some() {
543 return false;
544 }
545 let trimmed = info.content(ctx.content).trim_start();
546 !Self::should_skip_line(ctx, info, trimmed)
547 && (Self::starts_with_list_marker(trimmed)
548 || crate::utils::skip_context::is_table_line(trimmed)
549 || Self::is_latent_setext_underline(ctx, line_num, trimmed))
550 })
551 })
552 }
553
554 fn is_latent_setext_underline(ctx: &NativeListSource<'_, '_>, line_num: usize, trimmed: &str) -> bool {
568 crate::lint_context::is_setext_underline_content(trimmed)
569 && ctx.line_info(line_num - 1).is_some_and(|prev| {
570 prev.is_paragraph_context() && crate::lint_context::is_paragraph_text_line(prev.content(ctx.content))
571 })
572 }
573
574 fn sibling_column_usage(ctx: &NativeListSource<'_, '_>, scope: &ItemScope, task_col: usize) -> (bool, bool) {
584 let content_col = scope.content_col;
585 let mut uses_content = false;
586 let mut uses_task = false;
587
588 Self::walk_item_continuation(ctx, scope, |line| {
589 if line.actual == content_col {
590 uses_content = true;
591 }
592 if line.actual == task_col {
593 uses_task = true;
594 }
595 if uses_content && uses_task {
596 ControlFlow::Break(())
597 } else {
598 ControlFlow::Continue(())
599 }
600 });
601
602 (uses_content, uses_task)
603 }
604
605 fn compute_fix_target(
611 actual: usize,
612 required: usize,
613 task_col: Option<usize>,
614 uses_content_col: bool,
615 uses_task_col: bool,
616 ) -> usize {
617 let Some(t) = task_col else { return required };
618 match actual.abs_diff(t).cmp(&actual.abs_diff(required)) {
619 std::cmp::Ordering::Less => t,
620 std::cmp::Ordering::Greater => required,
621 std::cmp::Ordering::Equal => match (uses_task_col, uses_content_col) {
622 (true, false) => t,
623 _ => required,
624 },
625 }
626 }
627
628 fn fence_lines(
630 ctx: &NativeListSource<'_, '_>,
631 line_count: usize,
632 ) -> (std::collections::BTreeSet<usize>, std::collections::BTreeSet<usize>) {
633 let last_line = line_count.saturating_sub(1);
634 let line_of = |offset: usize| ctx.offset_to_line_col(offset).0.saturating_sub(1).min(last_line);
635 let mut openers = std::collections::BTreeSet::new();
636 let mut inside = std::collections::BTreeSet::new();
637 for block in ctx.code_block_details.iter().filter(|b| b.is_fenced) {
638 let first = line_of(block.start);
639 let last = line_of(block.end.saturating_sub(1).max(block.start));
640 openers.insert(first);
641 inside.extend(first + 1..=last);
642 }
643 (openers, inside)
644 }
645
646 fn parser_marker_lines(ctx: &NativeListSource<'_, '_>) -> Option<Vec<bool>> {
648 use crate::utils::python_markdown_lists::{Source, marker_lines};
649 let lines: Vec<&str> = ctx.content.split('\n').collect();
650 if lines.iter().any(|line| {
653 let trimmed = line.trim_start();
654 trimmed.starts_with("!!! ") || trimmed.starts_with("??? ") || trimmed.starts_with("???+ ")
655 }) {
656 return None;
657 }
658 let (openers, inside) = Self::fence_lines(ctx, lines.len());
659 let mut markers = marker_lines(&Source {
660 lines: &lines,
661 fence_openers: &openers,
662 fence_inside: &inside,
663 })?;
664 for (idx, marker) in markers.iter_mut().enumerate() {
669 if !*marker && idx > 0 && !Self::is_plain_prose(lines[idx - 1]) {
670 *marker = true;
671 }
672 }
673 Some(markers)
674 }
675
676 fn is_plain_prose(line: &str) -> bool {
679 let t = line.trim();
680 if t.is_empty() {
681 return false;
682 }
683 let first = t.chars().next().unwrap_or(' ');
684 if matches!(first, '>' | '#' | ':' | '|' | '<' | '[' | '=' | '-' | '*' | '_' | '+') {
685 return false;
686 }
687 !t.contains('|') && !t.chars().next().is_some_and(|c| c.is_ascii_digit())
688 }
689
690 fn drop_unsafe_fixes(
703 ctx: &NativeListSource<'_, '_>,
704 warnings: &mut [LintWarning],
705 owners: &std::collections::HashMap<usize, usize>,
706 ) {
707 use crate::utils::python_markdown_lists::{Source, differing_regions, line_regions};
708 use std::collections::BTreeSet;
709
710 let mut live: Vec<usize> = (0..warnings.len())
713 .filter(|&i| warnings[i].fix.is_some() && !ctx.is_rule_disabled("MD077", warnings[i].line))
714 .collect();
715 if live.is_empty() {
716 return;
717 }
718 let blank_front_matter = |lines: &mut Vec<&str>| {
720 for line in lines.iter_mut().take(ctx.front_matter_end_line()) {
721 *line = "";
722 }
723 };
724 let mut lines: Vec<&str> = ctx.content.split('\n').collect();
725 blank_front_matter(&mut lines);
726 let line_of = |offset: usize| {
727 ctx.offset_to_line_col(offset)
728 .0
729 .saturating_sub(1)
730 .min(lines.len().saturating_sub(1))
731 };
732 let (openers, inside) = Self::fence_lines(ctx, lines.len());
733 let before = Source {
734 lines: &lines,
735 fence_openers: &openers,
736 fence_inside: &inside,
737 };
738 let region_of = line_regions(&before);
739 let touched = |fix: &Fix| -> BTreeSet<usize> {
740 (line_of(fix.range.start)..=line_of(fix.range.end.max(fix.range.start)))
741 .map(|l| region_of[l])
742 .collect()
743 };
744
745 const MAX_ROUNDS: usize = 4;
746 for _ in 0..MAX_ROUNDS {
747 let mut ranges: Vec<(usize, &Fix)> = live
748 .iter()
749 .filter_map(|&i| warnings[i].fix.as_ref().map(|f| (i, f)))
750 .collect();
751 ranges.sort_by_key(|(_, f)| std::cmp::Reverse(f.range.start));
752 let mut fixed = ctx.content.to_string();
753 let mut floor = usize::MAX;
754 let mut overlapping = Vec::new();
755 for (i, fix) in ranges {
756 if fix.range.end <= floor && fix.range.end <= fixed.len() {
757 fixed.replace_range(fix.range.clone(), &fix.replacement);
758 floor = fix.range.start;
759 } else {
760 overlapping.push(i);
761 }
762 }
763 if !overlapping.is_empty() {
766 for &i in &overlapping {
767 warnings[i].fix = None;
768 }
769 live.retain(|i| !overlapping.contains(i));
770 if live.is_empty() {
771 return;
772 }
773 continue;
774 }
775 let mut after_lines: Vec<&str> = fixed.split('\n').collect();
776 blank_front_matter(&mut after_lines);
777 let after = Source {
778 lines: &after_lines,
779 fence_openers: &openers,
780 fence_inside: &inside,
781 };
782 let mut owner_of_line: std::collections::HashMap<usize, usize> = std::collections::HashMap::new();
785 for &i in &live {
786 let (Some(fix), Some(&owner)) = (warnings[i].fix.as_ref(), owners.get(&warnings[i].line)) else {
787 continue;
788 };
789 for l in line_of(fix.range.start)..=line_of(fix.range.end.max(fix.range.start)) {
790 owner_of_line.insert(l, owner.saturating_sub(1));
791 }
792 }
793 let bad = match differing_regions(&before, &after, &owner_of_line) {
794 Some(bad) if bad.is_empty() => return,
795 Some(bad) => bad,
796 None => break,
797 };
798 let kept: Vec<usize> = live
799 .iter()
800 .copied()
801 .filter(|&i| warnings[i].fix.as_ref().is_some_and(|f| touched(f).is_disjoint(&bad)))
802 .collect();
803 if kept.len() == live.len() {
804 break;
805 }
806 for &i in live.iter().filter(|i| !kept.contains(i)) {
807 warnings[i].fix = None;
808 }
809 live = kept;
810 if live.is_empty() {
811 return;
812 }
813 }
814 for i in live {
815 warnings[i].fix = None;
816 }
817 }
818
819 fn is_python_markdown_code(ctx: &NativeListSource<'_, '_>, scope: &ItemScope, line: &ContinuationLine<'_>) -> bool {
826 (scope.code_column.is_some_and(|column| line.actual >= column)
829 && ctx
830 .line_info(line.line_num - 1)
831 .is_some_and(|prev| Self::is_cm_blank(prev.content(ctx.content))))
832 || Self::is_python_markdown_nested_content(scope, line)
833 }
834
835 fn is_python_markdown_nested_content(scope: &ItemScope, line: &ContinuationLine<'_>) -> bool {
842 let above = scope
843 .nested
844 .partition_point(|&(item_line, ..)| item_line < line.line_num);
845 above
846 .checked_sub(1)
847 .and_then(|last| scope.nested.get(last))
848 .is_some_and(|&(_, column, closed_at)| line.actual >= column && line.line_num < closed_at)
849 }
850
851 fn should_skip_line(ctx: &NativeListSource<'_, '_>, info: &LineInfo, trimmed: &str) -> bool {
862 if info.in_code_block && !Self::is_fence_delimiter(ctx, info, trimmed) {
863 return true;
864 }
865 let structural = || {
866 info.in_front_matter
867 || info.in_footnote_definition
868 || info.in_html_block
869 || info.in_html_comment
870 || info.in_mdx_comment
871 || info.in_mkdocstrings
872 || info.in_esm_block
873 || info.in_math_block
874 || info.in_admonition
875 || info.in_content_tab
876 || info.in_pymdown_block
877 || info.in_definition_list
878 || info.in_mkdocs_html_markdown
879 || info.in_kramdown_extension_block
880 };
881 let protected = || {
882 ctx.is_byte_offset_in_code_span(info.byte_offset)
883 || ctx.is_in_code_span_byte_with_or_without_math(info.byte_offset)
884 };
885 structural() || protected()
891 }
892
893 fn build_over_indent_warning(
902 ctx: &NativeListSource<'_, '_>,
903 line: &ContinuationLine<'_>,
904 fix_target: usize,
905 message: String,
906 ) -> LintWarning {
907 let line_content = line.info.content(ctx.content);
908 let fix_start = line.info.byte_offset;
909 let fix_end = fix_start + line.info.indent;
910 LintWarning {
911 rule_name: Some("MD077".to_string()),
912 line: line.line_num,
913 column: 1,
914 end_line: line.line_num,
915 end_column: line_content.chars().count() + 1,
916 message,
917 severity: Severity::Warning,
918 fix: Some(Fix::new(fix_start..fix_end, " ".repeat(fix_target))),
919 }
920 }
921
922 fn build_under_indent_warning(
934 ctx: &NativeListSource<'_, '_>,
935 line: &ContinuationLine<'_>,
936 required: usize,
937 message: String,
938 ) -> UnderIndentOutcome {
939 let line_content = line.info.content(ctx.content);
940 let fence_closer = Self::fenced_block_last_line(ctx, line.info, line.trimmed);
941
942 let (fix, warn_end_line, warn_end_column, compound_closer) = if let Some(closer_line) = fence_closer {
943 let fix = Self::build_compound_fence_fix(ctx, line.line_num, closer_line, line.actual, required);
944 let end_column = ctx
945 .line_info(closer_line)
946 .map_or(line_content.chars().count() + 1, |ci| {
947 ci.content(ctx.content).chars().count() + 1
948 });
949 let extra_flag = (closer_line != line.line_num).then_some(closer_line);
950 (fix, closer_line, end_column, extra_flag)
951 } else {
952 let fix_start = line.info.byte_offset;
953 let fix_end = fix_start + line.info.indent;
954 let fix = Some(Fix::new(fix_start..fix_end, " ".repeat(required)));
955 (fix, line.line_num, line_content.chars().count() + 1, None)
956 };
957
958 UnderIndentOutcome {
959 warning: LintWarning {
960 rule_name: Some("MD077".to_string()),
961 line: line.line_num,
962 column: 1,
963 end_line: warn_end_line,
964 end_column: warn_end_column,
965 message,
966 severity: Severity::Warning,
967 fix,
968 },
969 also_flag_line: compound_closer,
970 }
971 }
972}
973
974#[derive(Clone, Copy)]
977struct SameLineItem {
978 marker_col: usize,
979 content_col: usize,
980 is_empty: bool,
982}
983
984struct ItemScope {
986 item_line: usize,
987 marker_col: usize,
988 content_col: usize,
989 task_col: Option<usize>,
991 required: usize,
993 tight_only: bool,
996 code_column: Option<usize>,
999 nested: Vec<(usize, usize, usize)>,
1003 range_end: usize,
1005 starts_empty: bool,
1008 same_line: Vec<SameLineItem>,
1010}
1011
1012struct ContinuationLine<'a> {
1016 line_num: usize,
1017 info: &'a LineInfo,
1018 trimmed: &'a str,
1019 actual: usize,
1020 saw_blank: bool,
1021 saw_nested: bool,
1025}
1026
1027struct UnderIndentOutcome {
1032 warning: LintWarning,
1033 also_flag_line: Option<usize>,
1034}
1035
1036impl MD077ListContinuationIndent {
1037 pub(crate) fn apply_native_fixes(content: &str, mut fixes: Vec<Fix>) -> String {
1040 fixes.sort_by_key(|fix| std::cmp::Reverse(fix.range.start));
1041 let mut content = content.to_string();
1042 for fix in fixes {
1043 if fix.range.start <= content.len() && fix.range.end <= content.len() {
1044 content.replace_range(fix.range, &fix.replacement);
1045 }
1046 }
1047 content
1048 }
1049
1050 pub(crate) fn check_native_source(&self, ctx: &NativeListSource<'_, '_>) -> Vec<LintWarning> {
1051 if ctx.content.is_empty() {
1052 return Vec::new();
1053 }
1054
1055 let strict_indent = ctx.flavor.requires_strict_list_indent();
1056 let total_lines = ctx.lines.len();
1057 let mut warnings = Vec::new();
1058 let mut owners: std::collections::HashMap<usize, usize> = std::collections::HashMap::new();
1060 let mut flagged_lines = std::collections::HashSet::new();
1061
1062 let mut items: Vec<(usize, CommonMarkItem, Option<usize>)> = Vec::new();
1071 let mut chains: Vec<(usize, Vec<CommonMarkItem>)> = Vec::new();
1074 let mut block_of_line: std::collections::HashMap<usize, usize> = std::collections::HashMap::new();
1077 for (block_idx, block) in ctx.list_blocks.iter().enumerate() {
1078 for &item_line in &block.item_lines {
1079 block_of_line.insert(item_line, block_idx);
1080 if let Some(info) = ctx.line_info(item_line)
1081 && let Some(ref li) = info.list_item
1082 {
1083 if info.blockquote.is_some() {
1090 continue;
1091 }
1092 if li.commonmark_items.is_empty() {
1096 continue;
1097 }
1098 let line = info.content(ctx.content);
1099 chains.push((item_line, li.commonmark_items.clone()));
1100 for item in &li.commonmark_items {
1101 let task_col = Self::is_task_at_column(line, item.content_column)
1102 .then_some(item.content_column + Self::TASK_CHECKBOX_PREFIX_LEN);
1103 items.push((item_line, *item, task_col));
1104 }
1105 }
1106 }
1107 }
1108
1109 if items.is_empty() {
1111 return warnings;
1112 }
1113
1114 chains.sort_by_key(|&(line, _)| line);
1115 chains.dedup_by_key(|&mut (line, _)| line);
1116 items.sort_unstable_by_key(|&(line, item, _)| (line, item.marker_column));
1117 items.dedup_by_key(|&mut (line, item, _)| (line, item.marker_column));
1118
1119 let mut range_ends = vec![total_lines; items.len()];
1132 let mut stack: Vec<usize> = Vec::new();
1133 for i in (0..items.len()).rev() {
1134 let marker_col = items[i].1.marker_column;
1135 while let Some(&top) = stack.last() {
1136 if items[top].1.marker_column > marker_col {
1137 stack.pop();
1138 } else {
1139 break;
1140 }
1141 }
1142 range_ends[i] = stack.last().map_or(total_lines, |&j| items[j].0 - 1);
1143 stack.push(i);
1144 }
1145
1146 let mut pm_levels: Vec<usize> = Vec::with_capacity(items.len());
1156 let mut pm_items: Vec<bool> = Vec::with_capacity(items.len());
1157 let mut shared_line: Vec<bool> = Vec::with_capacity(items.len());
1158 let parser_markers: Option<Vec<bool>> = strict_indent.then(|| Self::parser_marker_lines(ctx)).flatten();
1162 let mut head_levels: Vec<usize> = Vec::with_capacity(items.len());
1164 let mut open: Vec<usize> = Vec::new();
1165 for (i, &(item_line, item, _)) in items.iter().enumerate() {
1166 let block = block_of_line.get(&item_line);
1167 while open.last().is_some_and(|&top| {
1168 items[top].1.marker_column >= item.marker_column || block_of_line.get(&items[top].0) != block
1169 }) {
1170 open.pop();
1171 }
1172 let (level, is_item) = match open.last() {
1173 Some(&parent) if items[parent].0 == item_line => (pm_levels[parent] + 1, pm_items[parent]),
1174 Some(&parent) => {
1175 let level = item.marker_column / 4;
1176 let after_blank = ctx
1177 .line_info(item_line - 1)
1178 .is_some_and(|prev| Self::is_cm_blank(prev.content(ctx.content)));
1179 let anchor = if pm_items[parent] {
1183 Some(Some(parent))
1184 } else if after_blank {
1185 Some(open.iter().rev().copied().find(|&above| pm_items[above]))
1186 } else {
1187 None
1188 };
1189 let is_item = match anchor {
1190 None => false,
1191 Some(None) => level <= items[parent].1.marker_column / 4,
1194 Some(Some(above)) if shared_line[above] => {
1198 level <= head_levels[above] + 1 || (after_blank && level <= pm_levels[above] + 1)
1199 }
1200 Some(Some(above)) => level <= items[above].1.marker_column / 4 + 1,
1201 };
1202 (level, is_item)
1203 }
1204 None => (item.marker_column / 4, true),
1205 };
1206 let raw = ctx.line_info(item_line).map(|info| info.content(ctx.content));
1209 let bare = item.is_empty && raw.is_some_and(|line| !line.ends_with([' ', '\t']));
1210 let paren = raw.is_some_and(|line| Self::is_paren_marker(line, item.marker_column));
1211 let shared = open.last().filter(|&&parent| items[parent].0 == item_line);
1212 shared_line.push(shared.is_some());
1213 head_levels.push(shared.map_or(level, |&parent| head_levels[parent]));
1214 let in_quote = ctx.line_info(item_line).is_some_and(|info| info.blockquote.is_some());
1215 let read_as_marker = in_quote
1216 || parser_markers
1217 .as_ref()
1218 .is_none_or(|markers| markers.get(item_line - 1).copied().unwrap_or(false));
1219 let is_pm_item = is_item && read_as_marker && !bare && !paren;
1220 pm_levels.push(level);
1221 pm_items.push(is_pm_item);
1222 open.push(i);
1223 }
1224
1225 let prose_candidate_lines: Vec<usize> = (1..=total_lines)
1239 .filter(|&line_num| {
1240 let Some(info) = ctx.line_info(line_num) else {
1241 return false;
1242 };
1243 let trimmed = info.content(ctx.content).trim_start();
1244 !Self::should_skip_line(ctx, info, trimmed)
1245 && !info.is_blank
1246 && info.list_item.is_none()
1247 && info.heading.is_none()
1248 && !info.is_setext_heading_text
1249 && !info.is_horizontal_rule
1250 && !Self::is_block_level_construct(trimmed)
1251 })
1252 .collect();
1253 let range_has_prose_candidate = |after_line: usize, range_end: usize| -> bool {
1256 let start = prose_candidate_lines.partition_point(|&l| l <= after_line);
1257 prose_candidate_lines.get(start).is_some_and(|&l| l <= range_end)
1258 };
1259
1260 let closes: Vec<std::cell::OnceCell<usize>> = items.iter().map(|_| std::cell::OnceCell::new()).collect();
1267 let close_of = |idx: usize| -> usize {
1268 *closes[idx].get_or_init(|| {
1269 let item_line = items[idx].0;
1270 let column = 4 * (pm_levels[idx] + 1);
1271 (item_line + 1..=total_lines)
1272 .find(|&line_num| {
1273 ctx.line_info(line_num).is_some_and(|info| {
1274 info.visual_indent < column && !Self::is_cm_blank(info.content(ctx.content))
1275 })
1276 })
1277 .unwrap_or(total_lines + 1)
1278 })
1279 };
1280 let scoped: Vec<ItemScope> = items
1281 .iter()
1282 .enumerate()
1283 .filter(|&(item_idx, _)| !strict_indent || pm_items[item_idx])
1284 .map(|(item_idx, &(item_line, item, task_col))| {
1285 let marker_col = item.marker_column;
1286 let content_col = item.content_column;
1287 let level_column = 4 * (pm_levels[item_idx] + 1);
1298 let required = match self.config.indent {
1299 Some(indent) if strict_indent => (marker_col + indent).max(level_column),
1300 Some(indent) => marker_col + indent,
1301 None if strict_indent => content_col.max(level_column),
1302 None => content_col,
1303 };
1304 let tight_only = strict_indent && required >= level_column + 4;
1310 let same_line = chains
1312 .binary_search_by_key(&item_line, |(line, _)| *line)
1313 .ok()
1314 .map(|at| &chains[at].1)
1315 .map(|chain| {
1316 chain
1317 .iter()
1318 .filter(|inner| inner.marker_column > marker_col)
1319 .map(|inner| SameLineItem {
1320 marker_col: inner.marker_column,
1321 content_col: inner.content_column,
1322 is_empty: inner.is_empty,
1323 })
1324 .collect()
1325 })
1326 .unwrap_or_default();
1327 ItemScope {
1328 item_line,
1329 marker_col,
1330 content_col,
1331 task_col,
1332 required,
1333 tight_only,
1334 code_column: strict_indent.then_some(level_column + 4),
1335 nested: if strict_indent && range_has_prose_candidate(item_line, range_ends[item_idx]) {
1336 items[item_idx + 1..]
1337 .iter()
1338 .enumerate()
1339 .take_while(|&(_, &(line, _, _))| line <= range_ends[item_idx])
1340 .map(|(offset, &(line, _, _))| (line, item_idx + 1 + offset))
1341 .filter(|&(_, other)| pm_items[other])
1342 .map(|(line, other)| (line, 4 * (pm_levels[other] + 1), close_of(other)))
1343 .collect()
1344 } else {
1345 Vec::new()
1346 },
1347 range_end: range_ends[item_idx],
1348 starts_empty: item.is_empty,
1349 same_line,
1350 }
1351 })
1352 .collect();
1353
1354 let aligned = self.config.style == ContinuationStyle::Aligned;
1381 for scope in &scoped {
1382 let (item_line, marker_col, required, range_end) =
1383 (scope.item_line, scope.marker_col, scope.required, scope.range_end);
1384 if !range_has_prose_candidate(item_line, range_end) {
1387 continue;
1388 }
1389 let has_latent_structure = aligned && Self::item_range_has_latent_structure(ctx, item_line, range_end);
1404 let from_configured_indent = self.config.indent.is_some_and(|indent| marker_col + indent == required);
1410 Self::walk_item_continuation(ctx, scope, |line| {
1411 let actual = line.actual;
1412 if Self::is_python_markdown_code(ctx, scope, line) || (scope.tight_only && line.saw_blank) {
1413 return ControlFlow::Continue(());
1414 }
1415 let under_indented = actual < required;
1416 let loose_escape = line.saw_blank && under_indented;
1417 let confirmed_structure = line.info.in_code_block || line.info.blockquote.is_some();
1424 let aligned_tight = aligned
1425 && !has_latent_structure
1426 && !line.saw_blank
1427 && !line.saw_nested
1428 && under_indented
1429 && !confirmed_structure;
1430 if (loose_escape || aligned_tight) && flagged_lines.insert(line.line_num) {
1431 let message = if line.saw_blank {
1432 if from_configured_indent {
1433 format!(
1434 "Content after blank line in list item needs {required} spaces of \
1435 indentation to match the configured indent (found {actual})",
1436 )
1437 } else if strict_indent {
1438 format!(
1439 "Content inside list item needs {required} spaces of indentation \
1440 for MkDocs compatibility (found {actual})",
1441 )
1442 } else {
1443 format!(
1444 "Content after blank line in list item needs {required} spaces of \
1445 indentation to remain part of the list (found {actual})",
1446 )
1447 }
1448 } else {
1449 format!("Continuation line under-indented (expected {required}, found {actual})")
1450 };
1451 let outcome = Self::build_under_indent_warning(ctx, line, required, message);
1452 if let Some(closer_line) = outcome.also_flag_line {
1453 flagged_lines.insert(closer_line);
1454 }
1455 owners.insert(outcome.warning.line, item_line);
1456 warnings.push(outcome.warning);
1457 }
1458 ControlFlow::Continue(())
1459 });
1460 }
1461
1462 for scope in &scoped {
1471 let (item_line, content_col, task_col, required, range_end) = (
1472 scope.item_line,
1473 scope.content_col,
1474 scope.task_col,
1475 scope.required,
1476 scope.range_end,
1477 );
1478 if !range_has_prose_candidate(item_line, range_end) {
1480 continue;
1481 }
1482 let (uses_content_col, uses_task_col) = match task_col {
1486 Some(t) => Self::sibling_column_usage(ctx, scope, t),
1487 None => (false, false),
1488 };
1489
1490 Self::walk_item_continuation(ctx, scope, |line| {
1491 let actual = line.actual;
1492 if Self::is_python_markdown_code(ctx, scope, line) || (scope.tight_only && line.saw_blank) {
1493 return ControlFlow::Continue(());
1494 }
1495 if actual <= required {
1496 return ControlFlow::Continue(());
1497 }
1498 let fix_target = Self::compute_fix_target(actual, required, task_col, uses_content_col, uses_task_col);
1499 if !line.info.in_code_block
1500 && Some(actual) != task_col
1501 && !Self::starts_with_list_marker(line.trimmed)
1502 && !Self::opens_block_when_moved(line.trimmed, content_col, actual, fix_target)
1503 && flagged_lines.insert(line.line_num)
1504 {
1505 let message = match task_col {
1506 Some(t) => format!(
1507 "Continuation line over-indented \
1508 (expected {required} or {t}, found {actual})"
1509 ),
1510 None => {
1511 format!("Continuation line over-indented (expected {required}, found {actual})")
1512 }
1513 };
1514 let warning = Self::build_over_indent_warning(ctx, line, fix_target, message);
1515 owners.insert(warning.line, item_line);
1516 warnings.push(warning);
1517 }
1518 ControlFlow::Continue(())
1519 });
1520 }
1521
1522 warnings.sort_by_key(|w| (w.line, w.column));
1525
1526 if strict_indent {
1527 Self::drop_unsafe_fixes(ctx, &mut warnings, &owners);
1528 }
1529
1530 warnings
1531 }
1532}
1533
1534impl Rule for MD077ListContinuationIndent {
1535 fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
1536 (self.config == Default::default()).then_some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(77))
1537 }
1538
1539 fn name(&self) -> &'static str {
1540 "MD077"
1541 }
1542
1543 fn description(&self) -> &'static str {
1544 "List continuation content indentation"
1545 }
1546
1547 fn check(&self, ctx: &LintContext) -> LintResult {
1548 Ok(self.check_native_source(&ctx.native_list_source()))
1549 }
1550
1551 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
1552 let warnings = self.check(ctx)?;
1553 let warnings =
1554 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
1555 if warnings.is_empty() {
1556 return Ok(ctx.content.to_string());
1557 }
1558
1559 let fixes: Vec<Fix> = warnings.into_iter().filter_map(|w| w.fix).collect();
1560 Ok(Self::apply_native_fixes(ctx.content, fixes))
1561 }
1562
1563 fn fix_with_diagnostics(&self, ctx: &LintContext, warnings: &[LintWarning]) -> Result<String, LintError> {
1564 let fixes = warnings
1565 .iter()
1566 .filter(|warning| crate::utils::fix_utils::warning_is_enabled(warning, ctx.inline_config(), self.name()))
1567 .filter_map(|warning| warning.fix.clone())
1568 .collect();
1569 Ok(Self::apply_native_fixes(ctx.content, fixes))
1570 }
1571
1572 fn category(&self) -> RuleCategory {
1573 RuleCategory::List
1574 }
1575
1576 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
1577 ctx.content.is_empty() || ctx.list_blocks.is_empty()
1578 }
1579
1580 fn as_any(&self) -> &dyn std::any::Any {
1581 self
1582 }
1583
1584 crate::impl_rule_config_methods!(MD077Config);
1585}
1586
1587#[cfg(test)]
1588mod tests {
1589 use super::*;
1590 use crate::config::MarkdownFlavor;
1591
1592 fn check(content: &str) -> Vec<LintWarning> {
1593 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1594 let rule = MD077ListContinuationIndent::default();
1595 rule.check(&ctx).unwrap()
1596 }
1597
1598 fn check_mkdocs(content: &str) -> Vec<LintWarning> {
1599 let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
1600 let rule = MD077ListContinuationIndent::default();
1601 rule.check(&ctx).unwrap()
1602 }
1603
1604 fn fix(content: &str) -> String {
1605 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1606 let rule = MD077ListContinuationIndent::default();
1607 rule.fix(&ctx).unwrap()
1608 }
1609
1610 fn fix_mkdocs(content: &str) -> String {
1611 let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
1612 let rule = MD077ListContinuationIndent::default();
1613 rule.fix(&ctx).unwrap()
1614 }
1615
1616 #[test]
1617 fn test_blockquote_line_ends_the_item() {
1618 for content in ["1. item\n>\n continuation text\n", "- item\n> quote\n\n text\n"] {
1621 assert!(check(content).is_empty(), "{content:?}: {:?}", check(content));
1622 assert_eq!(fix(content), content);
1623 }
1624 let inside = "- item\n\n > quote\n\n text\n";
1626 assert_eq!(check(inside).len(), 1);
1627 assert_eq!(fix(inside), "- item\n\n > quote\n\n text\n");
1628 }
1629
1630 #[test]
1631 fn test_fence_after_an_indented_code_block_moves_as_one_block() {
1632 let content = " 2) item\n\n ```\n ```\n code\n ```\n";
1635 let fixed = fix(content);
1636 assert_eq!(fixed, " 2) item\n\n ```\n ```\n code\n ```\n");
1637 assert_eq!(fix(&fixed), fixed);
1638 }
1639
1640 #[test]
1641 fn test_marker_left_of_the_content_column_ends_the_item() {
1642 let content = "* a\n 1. b\n\n text\n";
1645 let warnings = check(content);
1646 assert_eq!(warnings.len(), 1, "{warnings:?}");
1647 let message = &warnings[0].message;
1648 assert!(message.contains("needs 5 spaces"), "{message}");
1649 let fixed = fix(content);
1650 assert_eq!(fixed, "* a\n 1. b\n\n text\n");
1651 assert_eq!(fix(&fixed), fixed);
1652
1653 let outside = "- a\n 1. b\n\n text\n";
1655 assert!(check(outside).is_empty(), "{:?}", check(outside));
1656 }
1657
1658 #[test]
1659 fn test_fence_text_in_indented_code_is_not_a_fence() {
1660 for content in [
1664 " * item\n ```\n ```\n\n ```\n",
1665 " 1. item\n - item\n\n ```\n",
1666 ] {
1667 assert!(check(content).is_empty(), "{content:?}: {:?}", check(content));
1668 assert_eq!(fix(content), content);
1669 }
1670 }
1671
1672 fn aligned_rule() -> MD077ListContinuationIndent {
1673 MD077ListContinuationIndent::new(ContinuationStyle::Aligned)
1674 }
1675
1676 fn check_aligned(content: &str) -> Vec<LintWarning> {
1677 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1678 aligned_rule().check(&ctx).unwrap()
1679 }
1680
1681 fn check_aligned_mkdocs(content: &str) -> Vec<LintWarning> {
1682 let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
1683 aligned_rule().check(&ctx).unwrap()
1684 }
1685
1686 fn fix_aligned(content: &str) -> String {
1687 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1688 aligned_rule().fix(&ctx).unwrap()
1689 }
1690
1691 fn fix_aligned_quarto(content: &str) -> String {
1692 let ctx = LintContext::new(content, MarkdownFlavor::Quarto, None);
1693 aligned_rule().fix(&ctx).unwrap()
1694 }
1695
1696 #[test]
1697 fn html_block_opened_by_an_inline_level_tag_is_left_alone() {
1698 let content = "- item\n\n <math display=\"block\">\n <mi>x</mi>\n </math>\n";
1701 assert!(check(content).is_empty(), "{:?}", check(content));
1702 assert_eq!(fix(content), content);
1703 }
1704
1705 #[test]
1706 fn html_block_in_an_item_is_left_alone_past_a_broken_tag() {
1707 let content = "- item\n\n <table>\n <td>\n <a\n >a</a\n >\n text after\n </td>\n </table>\n";
1710 assert!(check(content).is_empty(), "{:?}", check(content));
1711 assert_eq!(fix(content), content);
1712 }
1713
1714 #[test]
1715 fn lazy_continuation_does_not_close_the_nested_item() {
1716 for content in [
1721 "* a\n\n * b\n lazy\n\n para\n",
1722 "* a\n\n * b\n lazy\n\n ```\n x\n ```\n\n para\n",
1723 "* a\n\n * b\n lazy\n lazy again\n\n para\n",
1724 ] {
1725 assert!(check(content).is_empty(), "{content:?}: {:?}", check(content));
1726 assert_eq!(fix(content), content);
1727 }
1728 }
1729
1730 #[test]
1731 fn paragraph_text_that_would_open_a_block_keeps_its_indent() {
1732 for construct in [
1737 "```",
1738 "```rust",
1739 "~~~",
1740 "> q",
1741 "---",
1742 "===",
1743 "***",
1744 "_ _ _",
1745 "| x | y |\n | - | - |",
1746 "# A",
1747 "###### A",
1748 "## A ##",
1749 "#\tA",
1750 "#",
1751 ] {
1752 let content = format!("- a\n {construct}\n");
1753 assert!(check(&content).is_empty(), "{content:?}: {:?}", check(&content));
1754 assert_eq!(fix(&content), content);
1755 }
1756 }
1757
1758 #[test]
1759 fn hash_text_that_is_no_atx_heading_is_still_snapped_back() {
1760 for text in ["#A", "####### A", "#5"] {
1761 let content = format!("- a\n {text}\n");
1762 assert_eq!(check(&content).len(), 1, "{content:?}");
1763 assert_eq!(fix(&content), format!("- a\n {text}\n"));
1764 }
1765 }
1766
1767 #[test]
1768 fn paragraph_text_past_the_threshold_is_still_snapped_back() {
1769 let content = "- a\n text\n";
1770 assert_eq!(check(content).len(), 1);
1771 assert_eq!(fix(content), "- a\n text\n");
1772 let rule = MD077ListContinuationIndent::from_config_struct(MD077Config {
1775 indent: Some(6),
1776 ..Default::default()
1777 });
1778 let content = "- a\n > q\n";
1779 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1780 assert_eq!(rule.fix(&ctx).unwrap(), "- a\n > q\n");
1781 let content = "- a\n\n > q\n";
1784 assert_eq!(check(content).len(), 1);
1785 assert_eq!(fix(content), "- a\n\n > q\n");
1786 }
1787
1788 #[test]
1789 fn over_indent_after_a_lazy_line_is_measured_against_the_nested_item() {
1790 let content = "* a\n\n * b\n lazy\n\n para\n";
1791 let warnings = check(content);
1792 assert_eq!(warnings.len(), 1, "{warnings:?}");
1793 assert_eq!(warnings[0].line, 6);
1794 assert!(
1795 warnings[0].message.contains("expected 4, found 7"),
1796 "{}",
1797 warnings[0].message
1798 );
1799 assert_eq!(fix(content), "* a\n\n * b\n lazy\n\n para\n");
1800 }
1801
1802 #[test]
1803 fn aligned_idempotent_with_latent_marker_behind_unstable_heading() {
1804 let input = "1. \n``\n``\n- \n``";
1813 let once = fix_aligned_quarto(input);
1814 let twice = fix_aligned_quarto(&once);
1815 assert_eq!(once, twice, "MD077 aligned fix must be idempotent (Quarto)");
1816 }
1817
1818 #[test]
1819 fn latent_underline_needs_paragraph_text_above_it() {
1820 for (label, content) in [
1824 ("ATX heading", "- item\nwrap\n# Heading\n===\n"),
1825 ("thematic break", "- item\nwrap\n***\n===\n"),
1826 ("HTML block", "- item\nwrap\n<div>\n===\n"),
1827 ] {
1828 assert_eq!(
1829 check_aligned(content).len(),
1830 1,
1831 "{label}: reindenting cannot make a setext heading here, so the under-indent is reportable"
1832 );
1833 }
1834
1835 let warnings = check_aligned("- item\nwrap\n```\ncode\n```\n===\n");
1841 assert_eq!(
1842 warnings.len(),
1843 1,
1844 "a closing fence is not paragraph text, so it holds nothing back: {warnings:?}"
1845 );
1846 assert_eq!(warnings[0].line, 2);
1847
1848 for (label, content) in [
1854 ("empty bullet", "- item\n wrap\n > - \n ===\n"),
1855 ("empty ordered item", "- item\n wrap\n > 1. \n ===\n"),
1856 ("empty item in a nested quote", "- item\n wrap\n > > - \n ===\n"),
1857 ] {
1858 assert_eq!(
1859 check_aligned(content).len(),
1860 1,
1861 "{label}: an item holding no text cannot become a heading's text line"
1862 );
1863 }
1864
1865 assert_eq!(
1869 check_aligned("- item\n wrap\n\n ===\n").len(),
1870 2,
1871 "nothing above the underline can become a heading's text line"
1872 );
1873 for (label, content) in [
1876 ("blank line in a quote", "- item\n wrap\n >\n ===\n"),
1877 ("quoted whitespace", "- item\n wrap\n > \n ===\n"),
1878 ] {
1879 let warnings = check_aligned(content);
1880 assert_eq!(warnings.len(), 1, "{label}: {warnings:?}");
1881 assert_eq!(warnings[0].line, 2, "{label}");
1882 }
1883
1884 assert!(
1888 check_aligned("- item\nwrap\ntext\n===\n").is_empty(),
1889 "prose above the underline is latent structure, so the item is left alone"
1890 );
1891 }
1892
1893 #[test]
1894 fn aligned_idempotent_with_lazy_continuation_out_of_a_blockquote() {
1895 let input = "- \n> *\n> a\n``";
1899 let once = fix_aligned(input);
1900 let twice = fix_aligned(&once);
1901 assert_eq!(once, twice, "MD077 aligned fix must be idempotent");
1902 }
1903
1904 fn fix_aligned_flavor(content: &str, flavor: MarkdownFlavor) -> String {
1905 let ctx = LintContext::new(content, flavor, None);
1906 aligned_rule().fix(&ctx).unwrap()
1907 }
1908
1909 #[test]
1910 fn aligned_text_after_a_margin_fence_is_not_the_items() {
1911 for input in [
1917 "- A\n```\n\n```\n![`]()\n`",
1918 "- \u{1d400}\n```\n\n```\n*`*\n**`**",
1919 "- A\n```\nx\n```\ntext\nmore\n",
1920 ] {
1921 for flavor in [
1922 MarkdownFlavor::Standard,
1923 MarkdownFlavor::MkDocs,
1924 MarkdownFlavor::Quarto,
1925 MarkdownFlavor::MDX,
1926 ] {
1927 let ctx = LintContext::new(input, flavor, None);
1928 let warnings = aligned_rule().check(&ctx).unwrap();
1929 assert!(warnings.is_empty(), "{flavor:?} {input:?}: {warnings:?}");
1930 assert_eq!(fix_aligned_flavor(input, flavor), input, "{flavor:?} {input:?}");
1931 }
1932 }
1933 }
1934
1935 #[test]
1936 fn aligned_text_after_an_in_item_fence_is_not_the_items() {
1937 let input = "- A\n ```\n x\n ```\ntext\n";
1940 for flavor in [MarkdownFlavor::Standard, MarkdownFlavor::MkDocs] {
1941 assert_eq!(fix_aligned_flavor(input, flavor), input, "{flavor:?}");
1942 }
1943 }
1944
1945 #[test]
1946 fn aligned_still_aligns_the_paragraph_before_a_margin_fence() {
1947 let input = "- A\nb\nc\n```\nx\n```\ntext\n";
1951 assert_eq!(
1952 fix_aligned_flavor(input, MarkdownFlavor::Standard),
1953 "- A\n b\n c\n```\nx\n```\ntext\n"
1954 );
1955 assert_eq!(
1956 fix_aligned_flavor(input, MarkdownFlavor::MkDocs),
1957 "- A\n b\n c\n```\nx\n```\ntext\n"
1958 );
1959 }
1960
1961 #[test]
1962 fn aligned_one_application_is_a_fixed_point_after_a_margin_fence() {
1963 for flavor in [MarkdownFlavor::Standard, MarkdownFlavor::MkDocs, MarkdownFlavor::Quarto] {
1964 for input in [
1965 "- A\n```\n\n```\n![`]()\n`",
1966 "- \u{1d400}\n```\n\n```\n*`*\n**`**",
1967 "- A\nb\n```\n\n```\n![`]()\n`",
1968 ] {
1969 let once = fix_aligned_flavor(input, flavor);
1970 let twice = fix_aligned_flavor(&once, flavor);
1971 assert_eq!(once, twice, "{flavor:?} {input:?}");
1972 }
1973 }
1974 }
1975
1976 #[test]
1979 fn tight_lazy_continuation_zero_indent_not_flagged() {
1980 let content = "- Item\ncontinuation\n";
1982 assert!(check(content).is_empty());
1983 }
1984
1985 #[test]
1986 fn tight_continuation_correct_indent_not_flagged() {
1987 let content = "1. Item\n continuation\n";
1989 assert!(check(content).is_empty());
1990 }
1991
1992 #[test]
1993 fn tight_continuation_over_indented_ordered() {
1994 let content = "1. This is a list item with multiple lines.\n The second line is over-indented.\n";
1996 let warnings = check(content);
1997 assert_eq!(warnings.len(), 1);
1998 assert_eq!(warnings[0].line, 2);
1999 assert!(warnings[0].message.contains("over-indented"));
2000 }
2001
2002 #[test]
2003 fn tight_continuation_over_indented_unordered() {
2004 let content = "- Item\n over-indented\n";
2006 let warnings = check(content);
2007 assert_eq!(warnings.len(), 1);
2008 assert_eq!(warnings[0].line, 2);
2009 }
2010
2011 #[test]
2012 fn tight_continuation_multiple_over_indented_lines() {
2013 let content = "1. Item\n line one\n line two\n line three\n";
2014 let warnings = check(content);
2015 assert_eq!(warnings.len(), 3);
2016 }
2017
2018 #[test]
2019 fn tight_continuation_mixed_correct_and_over() {
2020 let content = "1. Item\n correct\n over-indented\n correct again\n";
2021 let warnings = check(content);
2022 assert_eq!(warnings.len(), 1);
2023 assert_eq!(warnings[0].line, 3);
2024 }
2025
2026 #[test]
2027 fn tight_continuation_nested_over_indented() {
2028 let content = "- L1\n - L2\n over-indented continuation of L2\n";
2030 let warnings = check(content);
2031 assert_eq!(warnings.len(), 1);
2032 assert_eq!(warnings[0].line, 3);
2033 assert!(warnings[0].message.contains("expected 4"));
2035 assert!(warnings[0].message.contains("found 5"));
2036 }
2037
2038 #[test]
2039 fn tight_continuation_nested_correct_indent_not_flagged() {
2040 let content = "- L1\n - L2\n correctly indented continuation of L2\n";
2043 assert!(check(content).is_empty());
2044 }
2045
2046 #[test]
2047 fn fix_tight_continuation_nested_over_indented() {
2048 let content = "- L1\n - L2\n over-indented continuation of L2\n";
2050 let fixed = fix(content);
2051 assert_eq!(fixed, "- L1\n - L2\n over-indented continuation of L2\n");
2052 }
2053
2054 #[test]
2055 fn tight_continuation_under_indented_not_flagged() {
2056 let content = "1. Item\n under-indented\n";
2059 assert!(check(content).is_empty());
2060 }
2061
2062 #[test]
2063 fn tight_continuation_tab_over_indented() {
2064 let content = "- Item\n\tover-indented\n";
2066 let warnings = check(content);
2067 assert_eq!(warnings.len(), 1);
2068 }
2069
2070 #[test]
2071 fn fix_tight_continuation_over_indented_ordered() {
2072 let content = "1. This is a list item with multiple lines.\n The second line is over-indented.\n";
2073 let fixed = fix(content);
2074 assert_eq!(
2075 fixed,
2076 "1. This is a list item with multiple lines.\n The second line is over-indented.\n"
2077 );
2078 }
2079
2080 #[test]
2081 fn fix_tight_continuation_over_indented_unordered() {
2082 let content = "- Item\n over-indented\n";
2083 let fixed = fix(content);
2084 assert_eq!(fixed, "- Item\n over-indented\n");
2085 }
2086
2087 #[test]
2088 fn fix_tight_continuation_multiple_lines() {
2089 let content = "1. Item\n line one\n line two\n";
2090 let fixed = fix(content);
2091 assert_eq!(fixed, "1. Item\n line one\n line two\n");
2092 }
2093
2094 #[test]
2095 fn tight_continuation_mkdocs_4space_ordered_not_flagged() {
2096 let content = "1. Item\n continuation\n";
2099 assert!(check_mkdocs(content).is_empty());
2100 }
2101
2102 #[test]
2103 fn tight_continuation_mkdocs_5space_ordered_flagged() {
2104 let content = "1. Item\n over-indented\n";
2106 let warnings = check_mkdocs(content);
2107 assert_eq!(warnings.len(), 1);
2108 assert!(warnings[0].message.contains("expected 4"));
2109 assert!(warnings[0].message.contains("found 5"));
2110 }
2111
2112 #[test]
2113 fn fix_tight_continuation_mkdocs_over_indented() {
2114 let content = "1. Item\n over-indented\n";
2115 let fixed = fix_mkdocs(content);
2116 assert_eq!(fixed, "1. Item\n over-indented\n");
2117 }
2118
2119 #[test]
2120 fn tight_continuation_deeply_indented_list_markers_not_flagged() {
2121 let content = "* Level 0\n * Level 1\n * Level 2\n";
2124 assert!(check(content).is_empty());
2125 }
2126
2127 #[test]
2128 fn tight_continuation_ordered_marker_not_flagged() {
2129 let content = "- Parent\n 1. Child item\n";
2131 assert!(check(content).is_empty());
2132 }
2133
2134 #[test]
2137 fn unordered_correct_indent_no_warning() {
2138 let content = "- Item\n\n continuation\n";
2139 assert!(check(content).is_empty());
2140 }
2141
2142 #[test]
2143 fn unordered_partial_indent_warns() {
2144 let content = "- Item\n\n continuation\n";
2147 let warnings = check(content);
2148 assert_eq!(warnings.len(), 1);
2149 assert_eq!(warnings[0].line, 3);
2150 assert!(warnings[0].message.contains("2 spaces"));
2151 assert!(warnings[0].message.contains("found 1"));
2152 }
2153
2154 #[test]
2155 fn unordered_zero_indent_is_new_paragraph() {
2156 let content = "- Item\n\ncontinuation\n";
2159 assert!(check(content).is_empty());
2160 }
2161
2162 #[test]
2165 fn ordered_3space_correct_commonmark() {
2166 let content = "1. Item\n\n continuation\n";
2168 assert!(check(content).is_empty());
2169 }
2170
2171 #[test]
2172 fn ordered_2space_under_indent_commonmark() {
2173 let content = "1. Item\n\n continuation\n";
2174 let warnings = check(content);
2175 assert_eq!(warnings.len(), 1);
2176 assert!(warnings[0].message.contains("3 spaces"));
2177 assert!(warnings[0].message.contains("found 2"));
2178 }
2179
2180 #[test]
2183 fn multi_digit_marker_correct() {
2184 let content = "10. Item\n\n continuation\n";
2186 assert!(check(content).is_empty());
2187 }
2188
2189 #[test]
2190 fn multi_digit_marker_under_indent() {
2191 let content = "10. Item\n\n continuation\n";
2192 let warnings = check(content);
2193 assert_eq!(warnings.len(), 1);
2194 assert!(warnings[0].message.contains("4 spaces"));
2195 }
2196
2197 #[test]
2200 fn mkdocs_3space_ordered_warns() {
2201 let content = "1. Item\n\n continuation\n";
2203 let warnings = check_mkdocs(content);
2204 assert_eq!(warnings.len(), 1);
2205 assert!(warnings[0].message.contains("4 spaces"));
2206 assert!(warnings[0].message.contains("MkDocs"));
2207 }
2208
2209 #[test]
2210 fn mkdocs_4space_ordered_no_warning() {
2211 let content = "1. Item\n\n continuation\n";
2212 assert!(check_mkdocs(content).is_empty());
2213 }
2214
2215 #[test]
2216 fn mkdocs_unordered_2space_ok() {
2217 let content = "- Item\n\n continuation\n";
2219 assert!(check_mkdocs(content).is_empty());
2220 }
2221
2222 #[test]
2223 fn mkdocs_unordered_2space_warns() {
2224 let content = "- Item\n\n continuation\n";
2226 let warnings = check_mkdocs(content);
2227 assert_eq!(warnings.len(), 1);
2228 assert!(warnings[0].message.contains("4 spaces"));
2229 }
2230
2231 #[test]
2234 fn fix_unordered_indent() {
2235 let content = "- Item\n\n continuation\n";
2237 let fixed = fix(content);
2238 assert_eq!(fixed, "- Item\n\n continuation\n");
2239 }
2240
2241 #[test]
2242 fn fix_ordered_indent() {
2243 let content = "1. Item\n\n continuation\n";
2244 let fixed = fix(content);
2245 assert_eq!(fixed, "1. Item\n\n continuation\n");
2246 }
2247
2248 #[test]
2249 fn fix_mkdocs_indent() {
2250 let content = "1. Item\n\n continuation\n";
2251 let fixed = fix_mkdocs(content);
2252 assert_eq!(fixed, "1. Item\n\n continuation\n");
2253 assert_eq!(fix_mkdocs(&fixed), fixed);
2254 }
2255
2256 #[test]
2265 fn mkdocs_nested_continuation_at_the_level_column_is_clean() {
2266 for content in [
2267 "- a\n - b\n\n text\n",
2269 "- a\n - b\n - c\n\n text\n",
2271 "1. a\n 1. b\n\n text\n",
2273 "1. a\n - b\n\n text\n",
2274 "- a\n 1. b\n\n text\n",
2275 "- a\n - b\n\n - c\n\n text\n",
2277 "- - a\n\n text\n",
2279 "- - a\n - b\n\n text\n",
2281 "- a\n - b\n\n ```\n code\n ```\n",
2283 ] {
2284 assert!(
2285 check_mkdocs(content).is_empty(),
2286 "{content:?}: {:?}",
2287 check_mkdocs(content)
2288 );
2289 assert_eq!(fix_mkdocs(content), content);
2290 }
2291 }
2292
2293 #[test]
2294 fn mkdocs_nested_continuation_below_the_level_column_moves_to_it() {
2295 for (content, fixed) in [
2298 ("- a\n - b\n\n text\n", "- a\n - b\n\n text\n"),
2299 (
2300 "- a\n - b\n - c\n\n text\n",
2301 "- a\n - b\n - c\n\n text\n",
2302 ),
2303 ("1. a\n 1. b\n\n text\n", "1. a\n 1. b\n\n text\n"),
2304 ("- - a\n\n text\n", "- - a\n\n text\n"),
2305 ] {
2306 let warnings = check_mkdocs(content);
2307 assert_eq!(warnings.len(), 1, "{content:?}: {warnings:?}");
2308 assert!(warnings[0].message.contains("MkDocs"), "{}", warnings[0].message);
2309 assert_eq!(fix_mkdocs(content), fixed);
2310 assert!(check_mkdocs(fixed).is_empty(), "{fixed:?}");
2311 }
2312 }
2313
2314 #[test]
2315 fn mkdocs_nested_over_indent_snaps_to_the_level_column() {
2316 assert_eq!(
2317 fix_mkdocs("- a\n - b\n\n text\n"),
2318 "- a\n - b\n\n text\n"
2319 );
2320 let warnings = check_mkdocs("- a\n - b\n\n text\n");
2321 assert_eq!(warnings.len(), 1);
2322 assert!(
2323 warnings[0].message.contains("expected 8, found 9"),
2324 "{}",
2325 warnings[0].message
2326 );
2327 }
2328
2329 #[test]
2330 fn mkdocs_text_left_of_the_nested_marker_stays_with_the_parent() {
2331 let content = "- a\n - b\n\n text\n";
2334 assert!(check_mkdocs(content).is_empty());
2335 assert_eq!(fix_mkdocs(content), content);
2336 }
2337
2338 #[test]
2339 fn mkdocs_code_at_the_python_markdown_threshold_is_not_reindented() {
2340 let content = "1. a\n - b\n\n text\n";
2343 assert!(check_mkdocs(content).is_empty(), "{:?}", check_mkdocs(content));
2344 assert_eq!(fix_mkdocs(content), content);
2345 let rule = MD077ListContinuationIndent::from_config_struct(MD077Config {
2348 indent: Some(6),
2349 ..Default::default()
2350 });
2351 let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
2352 assert!(rule.check(&ctx).unwrap().is_empty());
2353 assert_eq!(rule.fix(&ctx).unwrap(), content);
2354 }
2355
2356 #[test]
2357 fn mkdocs_over_indented_paragraph_continuation_is_not_code() {
2358 let content = "- a\n\n para\n text\n";
2361 assert_eq!(check_mkdocs(content).len(), 1);
2362 assert_eq!(fix_mkdocs(content), "- a\n\n para\n text\n");
2363 let nested = "- a\n - b\n\n para\n text\n";
2364 assert_eq!(fix_mkdocs(nested), "- a\n - b\n\n para\n text\n");
2365 }
2366
2367 #[test]
2368 fn mkdocs_item_python_markdown_does_not_read_is_not_judged() {
2369 for content in [
2373 "123456. a\n - b\n\n text\n",
2374 "- a\n - b\n\n text\n",
2375 ] {
2376 assert!(
2377 check_mkdocs(content).is_empty(),
2378 "{content:?}: {:?}",
2379 check_mkdocs(content)
2380 );
2381 assert_eq!(fix_mkdocs(content), content);
2382 }
2383 }
2384
2385 #[test]
2386 fn mkdocs_bare_marker_is_not_an_item_for_python_markdown() {
2387 for content in [
2391 "-\n - b\n\n text\n",
2392 "1.\n - b\n\n text\n",
2393 "- a\n -\n\n text\n",
2394 "-\n -\n\n text\n",
2395 ] {
2396 assert!(
2397 check_mkdocs(content).is_empty(),
2398 "{content:?}: {:?}",
2399 check_mkdocs(content)
2400 );
2401 assert_eq!(fix_mkdocs(content), content);
2402 }
2403 for content in ["- \n - b\n\n text\n", "1. \n - b\n\n text\n"] {
2404 assert_eq!(check_mkdocs(content).len(), 1, "{content:?}");
2405 }
2406 }
2407
2408 #[test]
2409 fn mkdocs_bare_marker_does_not_unscope_a_later_list() {
2410 let content = "-\n\n# Heading\n\n - b\n\n text\n";
2413 assert_eq!(check_mkdocs(content).len(), 1, "{:?}", check_mkdocs(content));
2414 assert_eq!(fix_mkdocs(content), "-\n\n# Heading\n\n - b\n\n text\n");
2415 }
2416
2417 #[test]
2418 fn mkdocs_paren_marker_is_not_an_item_for_python_markdown() {
2419 let paragraph = "- a\n 1) b\n\n text\n";
2422 assert!(check_mkdocs(paragraph).is_empty(), "{:?}", check_mkdocs(paragraph));
2423 let code = "- a\n 1) b\n\n text\n";
2424 assert!(check_mkdocs(code).is_empty(), "{:?}", check_mkdocs(code));
2425 }
2426
2427 #[test]
2428 fn mkdocs_item_below_a_same_line_item_needs_the_written_marker_column() {
2429 let content = "1. - a\n - b\n\n text\n";
2433 assert!(check_mkdocs(content).is_empty(), "{:?}", check_mkdocs(content));
2434 }
2435
2436 #[test]
2437 fn mkdocs_list_after_a_blank_line_below_a_text_marker_is_an_item() {
2438 for (content, fixed) in [
2443 ("1) a\n\n - b\n\n text\n", "1) a\n\n - b\n\n text\n"),
2444 ("-\n\n - b\n\n text\n", "-\n\n - b\n\n text\n"),
2445 ] {
2446 assert_eq!(check_mkdocs(content).len(), 1, "{content:?}");
2447 assert_eq!(fix_mkdocs(content), fixed);
2448 assert_eq!(fix_mkdocs(fixed), fixed);
2449 }
2450 for content in ["1) a\n - b\n\n text\n", "-\n - b\n\n text\n"] {
2453 assert!(check_mkdocs(content).is_empty(), "{content:?}");
2454 }
2455 }
2456
2457 #[test]
2458 fn mkdocs_marker_under_a_padded_same_line_item_is_text_until_a_blank_line() {
2459 for content in [
2462 "- - a\n - b\n\n text\n",
2463 "- - a\n - b\n\n text\n",
2464 ] {
2465 assert!(check_mkdocs(content).is_empty(), "{content:?}");
2466 }
2467 let content = "- - a\n\n - b\n\n text\n";
2470 assert!(check_mkdocs(content).is_empty(), "{content:?}");
2471 assert_eq!(check_mkdocs("- - a\n\n - b\n\n text\n").len(), 1);
2472 }
2473
2474 #[test]
2475 fn mkdocs_markers_under_a_same_line_chain_are_text_until_a_blank_line() {
2476 let content = "- - - a\n - - b\n\n text\n";
2479 assert!(check_mkdocs(content).is_empty(), "{:?}", check_mkdocs(content));
2480 let content = "- - - a\n\n - b\n\n text\n";
2482 assert!(check_mkdocs(content).is_empty(), "{:?}", check_mkdocs(content));
2483 }
2484
2485 #[test]
2486 fn mkdocs_list_after_an_unsupported_marker_nests_in_the_enclosing_item() {
2487 let content = "- a\n 1) b\n\n - c\n\n text\n";
2491 assert_eq!(check_mkdocs(content).len(), 1);
2492 assert_eq!(fix_mkdocs(content), "- a\n 1) b\n\n - c\n\n text\n");
2493 let code = "- a\n 1) b\n\n - c\n\n text\n";
2495 assert!(check_mkdocs(code).is_empty(), "{:?}", check_mkdocs(code));
2496 }
2497
2498 #[test]
2499 fn mkdocs_text_at_a_deeper_item_paragraph_column_is_not_claimed_by_the_outer_item() {
2500 let content = "- - - a\n 1. b\n\n text\n";
2505 let warnings = check_mkdocs(content);
2506 assert_eq!(warnings.len(), 1, "{warnings:?}");
2507 assert!(warnings[0].message.contains("needs 9"), "{}", warnings[0].message);
2508 let fixed = fix_mkdocs(content);
2509 assert_eq!(fixed, "- - - a\n 1. b\n\n text\n");
2510 assert!(check_mkdocs(&fixed).is_empty());
2511 }
2512
2513 #[test]
2514 fn mkdocs_nested_item_closed_by_a_shallower_line_no_longer_owns_later_text() {
2515 let content = "- a\n - b\n\n para\n text\n";
2518 let warnings = check_mkdocs(content);
2519 assert_eq!(warnings.len(), 1, "{warnings:?}");
2520 assert_eq!(fix_mkdocs(content), "- a\n - b\n\n para\n text\n");
2521 }
2522
2523 fn assert_mkdocs_warns_without_fix(content: &str) {
2526 let warnings = check_mkdocs(content);
2527 assert!(!warnings.is_empty(), "expected a warning for {content:?}");
2528 assert!(
2529 warnings.iter().all(|w| w.fix.is_none()),
2530 "fix must be withheld for {content:?}: {warnings:?}"
2531 );
2532 assert_eq!(fix_mkdocs(content), content);
2533 }
2534
2535 #[test]
2536 fn mkdocs_fix_does_not_turn_prose_after_a_text_marker_into_code() {
2537 assert_mkdocs_warns_without_fix("- - a\n\n 1) b\n\n text\n");
2540 assert_eq!(fix_mkdocs("- - a\n\n text\n"), "- - a\n\n text\n");
2542 }
2543
2544 #[test]
2545 fn mkdocs_item_after_the_continuation_lines_of_the_item_above_is_an_item() {
2546 assert_eq!(
2550 fix_mkdocs("- a\n continuation\n- b\n\n text\n"),
2551 "- a\n continuation\n- b\n\n text\n"
2552 );
2553 assert_eq!(check_mkdocs("para\n- b\n\n text\n").len(), 0);
2555 assert_eq!(check_mkdocs("# Heading\n- a\n\n text\n").len(), 1);
2558 }
2559
2560 #[test]
2561 fn mkdocs_fix_is_withheld_when_it_would_change_how_later_lines_read() {
2562 assert_mkdocs_warns_without_fix("10. word two\n\n 1.\n\n a\n\n - word two\n");
2565 assert_mkdocs_warns_without_fix(" - - word two\n\n a\n text\n\n para\n");
2566 }
2567
2568 #[test]
2569 fn mkdocs_fix_is_withheld_when_it_would_change_code_text_or_a_quote() {
2570 assert_mkdocs_warns_without_fix("- a\n - b\n\n text\n\n code\n");
2573 assert_mkdocs_warns_without_fix("- a\n - b\n\n text\n > quote\n");
2575 }
2576
2577 #[test]
2578 fn mkdocs_fix_is_withheld_around_a_single_hyphen_underline_and_a_split_code_span() {
2579 assert_mkdocs_warns_without_fix("- a\n - b\n\n text\n - \n");
2581 assert_mkdocs_warns_without_fix("- a\n - b\n\n para\n `one\n two`\n");
2584 assert_mkdocs_warns_without_fix("- a\n - b\n\n `a `` b` c ``x\n d`` e `f `` g`\n");
2587 assert_eq!(
2589 fix_mkdocs("- a\n - b\n\n para `one`\n"),
2590 "- a\n - b\n\n para `one`\n"
2591 );
2592 }
2593
2594 #[test]
2595 fn mkdocs_fix_is_withheld_when_it_would_change_list_kind_or_tab_expansion() {
2596 assert_mkdocs_warns_without_fix("- a\n - b\n\n text\n\n 1. ordered\n");
2599 assert_mkdocs_warns_without_fix("- a\n - b\n\n ```\n a\tb\n ```\n");
2602 }
2603
2604 #[test]
2605 fn mkdocs_fix_is_withheld_for_a_quote_after_an_earlier_quote_and_for_exotic_space() {
2606 assert_mkdocs_warns_without_fix("> intro\n\n- a\n - b\n\n text\n > quote\n");
2609 assert_mkdocs_warns_without_fix("- a\n - b\n\n text\n\n \u{a0}\n");
2612 }
2613
2614 #[test]
2615 fn mkdocs_fix_is_withheld_below_a_whitespace_only_first_line() {
2616 assert_mkdocs_warns_without_fix(" \n- - a\n\n text\n");
2619 assert_eq!(
2621 fix_mkdocs("para\n\n \n- - a\n\n text\n"),
2622 "para\n\n \n- - a\n\n text\n"
2623 );
2624 }
2625
2626 #[test]
2627 fn mkdocs_fix_ignores_front_matter_and_is_withheld_around_abbreviation_definitions() {
2628 assert_eq!(
2631 fix_mkdocs("---\ntitle: Example\n---\n\n- a\n\n text\n"),
2632 "---\ntitle: Example\n---\n\n- a\n\n text\n"
2633 );
2634 assert_mkdocs_warns_without_fix("- a\n - b\n\n *[X]: abbreviation\n ## H\n");
2637 }
2638
2639 #[test]
2640 fn mkdocs_fix_is_judged_on_the_fixes_inline_configuration_leaves() {
2641 let content = "- a\n - b\n\n one <!-- rumdl-disable-line MD077 -->\n\n two\n";
2643 let fixed = fix_mkdocs(content);
2644 assert_eq!(fixed, content);
2645 }
2646
2647 #[test]
2648 fn mkdocs_fix_does_not_pull_a_fence_outside_the_list_into_it() {
2649 assert_eq!(
2652 fix_mkdocs("- a\n\n ```\n code\n ```\n"),
2653 "- a\n\n ```\n code\n ```\n"
2654 );
2655 assert_eq!(
2657 fix_mkdocs("- a\n - b\n\n ```\n code\n ```\n"),
2658 "- a\n - b\n\n ```\n code\n ```\n"
2659 );
2660 }
2661
2662 #[test]
2663 fn mkdocs_fix_is_withheld_around_constructs_the_model_does_not_read() {
2664 for content in [
2669 "- a\n - b\n\n more\n [id]: url\n",
2670 "- a\n - b\n\n ---\n - c\n",
2671 "- a\n - b\n\n ```\n\n - c\n ```\n",
2672 "- a\n - b\n\n text\n ===\n",
2673 "- a\n - b\n\n text\n <!-- x -->\n code\n",
2674 "- a\n - b\n\n ```\n ***\n ```\n",
2675 ] {
2676 assert_eq!(fix_mkdocs(content), content, "{content:?}");
2677 }
2678 }
2679
2680 #[test]
2681 fn mkdocs_fix_is_withheld_for_a_fence_whose_lines_do_not_share_a_column() {
2682 let content = "- a\n - b\n\n ```\n code\n code\n```\n";
2685 assert_eq!(fix_mkdocs(content), content);
2686 assert!(check_mkdocs(content).iter().all(|w| w.fix.is_none()));
2687 }
2688
2689 #[test]
2690 fn mkdocs_warnings_never_carry_overlapping_fixes() {
2691 let content = "- a\n - b\n\n ```\n ```\n ```\n";
2694 let mut ranges: Vec<_> = check_mkdocs(content)
2695 .iter()
2696 .filter_map(|w| w.fix.as_ref().map(|f| f.range.clone()))
2697 .collect();
2698 ranges.sort_by_key(|r| r.start);
2699 for pair in ranges.windows(2) {
2700 assert!(pair[0].end <= pair[1].start, "{ranges:?}");
2701 }
2702 }
2703
2704 #[test]
2705 fn mkdocs_fence_on_the_items_own_line_keeps_the_item() {
2706 let content = "- ```\n code\n ```\n\n text\n";
2709 assert_eq!(check_mkdocs(content).len(), 1, "{:?}", check_mkdocs(content));
2710 }
2711
2712 #[test]
2713 fn mkdocs_wide_marker_still_enforces_tight_continuation() {
2714 assert_eq!(
2717 fix_mkdocs("123456. a\n continuation\n"),
2718 "123456. a\n continuation\n"
2719 );
2720 }
2721
2722 #[test]
2723 fn mkdocs_paren_marker_after_a_tab_is_found_by_column() {
2724 let content = "- a\n\t- x) b\n\n text\n";
2726 assert_eq!(check_mkdocs(content).len(), 1, "{:?}", check_mkdocs(content));
2727 }
2728
2729 #[test]
2730 fn mkdocs_configured_indent_cannot_go_below_the_level_column() {
2731 let rule = MD077ListContinuationIndent::from_config_struct(MD077Config {
2732 indent: Some(2),
2733 ..Default::default()
2734 });
2735 let content = "- a\n - b\n\n text\n";
2736 let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
2737 assert_eq!(rule.fix(&ctx).unwrap(), "- a\n - b\n\n text\n");
2738 let clean = "- a\n - b\n\n text\n";
2739 let ctx = LintContext::new(clean, MarkdownFlavor::MkDocs, None);
2740 assert!(rule.check(&ctx).unwrap().is_empty());
2741 }
2742
2743 #[test]
2744 fn standard_flavor_keeps_the_content_column_for_nested_items() {
2745 let content = "- a\n - b\n\n text\n";
2747 assert!(check(content).is_empty());
2748 assert_eq!(fix("- a\n - b\n\n text\n"), "- a\n - b\n\n text\n");
2749 }
2750
2751 #[test]
2754 fn nested_list_items_not_flagged() {
2755 let content = "- Parent\n\n - Child\n";
2756 assert!(check(content).is_empty());
2757 }
2758
2759 #[test]
2760 fn nested_list_zero_indent_is_new_paragraph() {
2761 let content = "- Parent\n - Child\n\ncontinuation of parent\n";
2763 assert!(check(content).is_empty());
2764 }
2765
2766 #[test]
2767 fn nested_list_partial_indent_flagged() {
2768 let content = "- Parent\n - Child\n\n continuation of parent\n";
2770 let warnings = check(content);
2771 assert_eq!(warnings.len(), 1);
2772 assert!(warnings[0].message.contains("2 spaces"));
2773 }
2774
2775 #[test]
2778 fn code_block_correctly_indented_no_warning() {
2779 let content = "- Item\n\n ```\n code\n ```\n";
2781 assert!(check(content).is_empty());
2782 }
2783
2784 #[test]
2785 fn code_fence_under_indented_warns() {
2786 let content = "- Item\n\n ```\n code\n ```\n";
2790 let warnings = check(content);
2791 assert_eq!(warnings.len(), 1);
2792 assert_eq!(warnings[0].line, 3);
2793 }
2794
2795 #[test]
2796 fn code_fence_under_indented_ordered_mkdocs() {
2797 let content = "1. Item\n\n ```toml\n key = \"value\"\n ```\n";
2800 assert!(check(content).is_empty()); let warnings = check_mkdocs(content);
2802 assert_eq!(warnings.len(), 1); assert_eq!(warnings[0].line, 3);
2804 assert!(warnings[0].message.contains("4 spaces"));
2805 assert!(warnings[0].message.contains("MkDocs"));
2806 }
2807
2808 #[test]
2809 fn code_fence_tilde_under_indented() {
2810 let content = "- Item\n\n ~~~\n code\n ~~~\n";
2811 let warnings = check(content);
2812 assert_eq!(warnings.len(), 1); assert_eq!(warnings[0].line, 3);
2814 }
2815
2816 #[test]
2819 fn multiple_blank_lines_zero_indent_is_new_paragraph() {
2820 let content = "- Item\n\n\ncontinuation\n";
2822 assert!(check(content).is_empty());
2823 }
2824
2825 #[test]
2826 fn multiple_blank_lines_partial_indent_flags() {
2827 let content = "- Item\n\n\n continuation\n";
2828 let warnings = check(content);
2829 assert_eq!(warnings.len(), 1);
2830 }
2831
2832 #[test]
2835 fn empty_item_no_warning() {
2836 let content = "- \n- Second\n";
2837 assert!(check(content).is_empty());
2838 }
2839
2840 #[test]
2843 fn multiple_items_mixed_indent() {
2844 let content = "1. First\n\n correct continuation\n\n2. Second\n\n wrong continuation\n";
2845 let warnings = check(content);
2846 assert_eq!(warnings.len(), 1);
2847 assert_eq!(warnings[0].line, 7);
2848 }
2849
2850 #[test]
2853 fn task_list_correct_indent() {
2854 let content = "- [ ] Task\n\n continuation\n";
2856 assert!(check(content).is_empty());
2857 }
2858
2859 #[test]
2862 fn frontmatter_not_flagged() {
2863 let content = "---\ntitle: test\n---\n\n- Item\n\n continuation\n";
2864 assert!(check(content).is_empty());
2865 }
2866
2867 #[test]
2870 fn fix_multiple_items() {
2871 let content = "1. First\n\n wrong1\n\n2. Second\n\n wrong2\n";
2872 let fixed = fix(content);
2873 assert_eq!(fixed, "1. First\n\n wrong1\n\n2. Second\n\n wrong2\n");
2874 }
2875
2876 #[test]
2877 fn fix_multiline_loose_continuation_all_lines() {
2878 let content = "1. Item\n\n line one\n line two\n line three\n";
2879 let fixed = fix(content);
2880 assert_eq!(fixed, "1. Item\n\n line one\n line two\n line three\n");
2881 }
2882
2883 #[test]
2884 fn multi_line_setext_heading_after_a_list_is_not_a_continuation() {
2885 let content = "1. Item\n\n First line\n second line\n ===\n";
2889 let warnings = check(content);
2890 assert!(
2891 warnings.is_empty(),
2892 "the text lines of a setext heading are not continuation candidates. Got: {warnings:?}"
2893 );
2894 assert_eq!(fix(content), content);
2895 }
2896
2897 #[test]
2900 fn sibling_item_boundary_respected() {
2901 let content = "- First\n- Second\n\n continuation\n";
2903 assert!(check(content).is_empty());
2904 }
2905
2906 #[test]
2909 fn blockquote_list_correct_indent_no_warning() {
2910 let content = "> - Item\n>\n> continuation\n";
2913 assert!(check(content).is_empty());
2914 }
2915
2916 #[test]
2917 fn blockquote_list_under_indent_no_false_positive() {
2918 let content = "> - Item\n>\n> continuation\n";
2923 assert!(check(content).is_empty());
2924 }
2925
2926 #[test]
2929 fn deep_nesting_correct_indent() {
2930 let content = "- L1\n - L2\n - L3\n\n continuation of L3\n";
2931 assert!(check(content).is_empty());
2932 }
2933
2934 #[test]
2935 fn deep_nesting_under_indent() {
2936 let content = "- L1\n - L2\n - L3\n\n continuation of L3\n";
2939 let warnings = check(content);
2940 assert_eq!(warnings.len(), 1);
2941 assert!(warnings[0].message.contains("6 spaces"));
2942 assert!(warnings[0].message.contains("found 5"));
2943 }
2944
2945 #[test]
2946 fn deep_nesting_middle_level_continuation_bullets() {
2947 let content = "- L1\n - L2\n - L3\n\n continuation of L2\n";
2951 assert!(check(content).is_empty());
2952 }
2953
2954 #[test]
2955 fn deep_nesting_middle_level_continuation_ordered() {
2956 let content = "1. Level 1 item.\n1. Level 1 item:\n 1. Level 2 item.\n 1. Level 2 item.\n 1. Level 2 item:\n - Level 3 item.\n - Level 3 item.\n\n Level 2 list continuation.\n1. Level 1 item.\n";
2959 assert!(check(content).is_empty());
2960 }
2961
2962 #[test]
2963 fn deep_nesting_outermost_continuation() {
2964 let content = "- L1\n - L2\n - L3\n\n continuation of L1\n";
2967 assert!(check(content).is_empty());
2968 }
2969
2970 #[test]
2971 fn deep_nesting_between_levels_still_flagged() {
2972 let content = "- L1\n - L2\n - L3\n\n continuation\n";
2975 let warnings = check(content);
2976 assert_eq!(warnings.len(), 1);
2977 assert!(warnings[0].message.contains("4 spaces"));
2978 assert!(warnings[0].message.contains("found 3"));
2979 }
2980
2981 #[test]
2982 fn deep_nesting_beyond_deepest_still_flagged() {
2983 let content = "- L1\n - L2\n - L3\n\n continuation\n";
2985 let warnings = check(content);
2986 assert_eq!(warnings.len(), 1);
2987 assert!(warnings[0].message.contains("over-indented"));
2988 assert!(warnings[0].message.contains("expected 6, found 7"));
2989 }
2990
2991 #[test]
2992 fn four_levels_middle_continuation() {
2993 let content = "- L1\n - L2\n - L3\n - L4\n\n continuation of L2\n";
2996 assert!(check(content).is_empty());
2997 }
2998
2999 #[test]
3000 fn nested_sibling_closes_deeper_level() {
3001 let content = "- L1\n - L2a\n - L3\n - L2b\n\n continuation of L2b\n";
3004 assert!(check(content).is_empty());
3005 }
3006
3007 #[test]
3008 fn deep_nesting_middle_level_continuation_fix_preserved() {
3009 let content = "- L1\n - L2\n - L3\n\n continuation of L2\n";
3011 assert_eq!(fix(content), content);
3012 }
3013
3014 #[test]
3017 fn loose_tab_continuation_over_indented() {
3018 let content = "- Item\n\n\tcontinuation\n";
3023 let warnings = check(content);
3024 assert_eq!(warnings.len(), 1);
3025 assert_eq!(warnings[0].line, 3);
3026 assert_eq!(fix(content), "- Item\n\n continuation\n");
3027 }
3028
3029 #[test]
3032 fn multiple_continuations_correct() {
3033 let content = "- Item\n\n para 1\n\n para 2\n\n para 3\n";
3034 assert!(check(content).is_empty());
3035 }
3036
3037 #[test]
3038 fn multiple_continuations_second_under_indent() {
3039 let content = "- Item\n\n para 1\n\n continuation 2\n";
3041 let warnings = check(content);
3042 assert_eq!(warnings.len(), 1);
3043 assert_eq!(warnings[0].line, 5);
3044 }
3045
3046 #[test]
3049 fn ordered_paren_marker_correct() {
3050 let content = "1) Item\n\n continuation\n";
3052 assert!(check(content).is_empty());
3053 }
3054
3055 #[test]
3056 fn ordered_paren_marker_under_indent() {
3057 let content = "1) Item\n\n continuation\n";
3058 let warnings = check(content);
3059 assert_eq!(warnings.len(), 1);
3060 assert!(warnings[0].message.contains("3 spaces"));
3061 }
3062
3063 #[test]
3066 fn star_marker_correct() {
3067 let content = "* Item\n\n continuation\n";
3068 assert!(check(content).is_empty());
3069 }
3070
3071 #[test]
3072 fn star_marker_under_indent() {
3073 let content = "* Item\n\n continuation\n";
3074 let warnings = check(content);
3075 assert_eq!(warnings.len(), 1);
3076 }
3077
3078 #[test]
3079 fn plus_marker_correct() {
3080 let content = "+ Item\n\n continuation\n";
3081 assert!(check(content).is_empty());
3082 }
3083
3084 #[test]
3087 fn heading_after_list_no_warning() {
3088 let content = "- Item\n\n# Heading\n";
3089 assert!(check(content).is_empty());
3090 }
3091
3092 #[test]
3095 fn hr_after_list_no_warning() {
3096 let content = "- Item\n\n---\n";
3097 assert!(check(content).is_empty());
3098 }
3099
3100 #[test]
3103 fn reference_link_def_not_flagged() {
3104 let content = "- Item\n\n [link]: https://example.com\n";
3105 assert!(check(content).is_empty());
3106 }
3107
3108 #[test]
3111 fn footnote_def_not_flagged() {
3112 let content = "- Item\n\n [^1]: footnote text\n";
3113 assert!(check(content).is_empty());
3114 }
3115
3116 #[test]
3117 fn footnote_multiline_body_after_list_not_flagged() {
3118 let content = "# A list followed by a footnote\n\n\
3122 Here is a paragraph.[^fn]\n\n\
3123 - This is a list.\n\n\
3124 [^fn]:\n\
3125 \x20\x20\x20\x20Here is a footnote that spans multiple lines.\n\
3126 \x20\x20\x20\x20It should thus be indented by at least four spaces.\n";
3127 assert!(check(content).is_empty());
3128 }
3129
3130 #[test]
3131 fn fix_footnote_multiline_body_after_list_is_noop() {
3132 let content = "# A list followed by a footnote\n\n\
3136 Here is a paragraph.[^fn]\n\n\
3137 - This is a list.\n\n\
3138 [^fn]:\n\
3139 \x20\x20\x20\x20Here is a footnote that spans multiple lines.\n\
3140 \x20\x20\x20\x20It should thus be indented by at least four spaces.\n";
3141 assert_eq!(fix(content), content);
3142 }
3143
3144 #[test]
3145 fn footnote_body_indented_past_list_content_col_not_flagged() {
3146 let content = "- Item\n\n[^fn]:\n Body line one.\n Body line two.\n";
3150 assert!(check(content).is_empty());
3151 }
3152
3153 #[test]
3154 fn list_inside_footnote_body_continuation_not_flagged() {
3155 let content = "Text.[^fn]\n\n[^fn]:\n\
3159 \x20\x20\x20\x20- nested item\n\
3160 \x20\x20\x20\x20\x20\x20\x20over-indented continuation\n";
3161 assert!(check(content).is_empty());
3162 }
3163
3164 #[test]
3165 fn footnote_multiline_body_after_list_not_flagged_mkdocs() {
3166 let content = "Here is a paragraph.[^fn]\n\n\
3170 - This is a list.\n\n\
3171 [^fn]:\n\
3172 \x20\x20\x20\x20\x20\x20Footnote body that spans\n\
3173 \x20\x20\x20\x20\x20\x20multiple indented lines.\n";
3174 assert!(check_mkdocs(content).is_empty());
3175 }
3176
3177 #[test]
3180 fn fix_deep_nesting() {
3181 let content = "- L1\n - L2\n - L3\n\n under-indented\n";
3182 let fixed = fix(content);
3183 assert_eq!(fixed, "- L1\n - L2\n - L3\n\n under-indented\n");
3184 }
3185
3186 #[test]
3187 fn fix_mkdocs_unordered() {
3188 let content = "- Item\n\n continuation\n";
3190 let fixed = fix_mkdocs(content);
3191 assert_eq!(fixed, "- Item\n\n continuation\n");
3192 assert_eq!(fix_mkdocs(&fixed), fixed);
3193 }
3194
3195 #[test]
3196 fn fix_code_fence_indent() {
3197 let content = "- Item\n\n ```\n code\n ```\n";
3200 let fixed = fix(content);
3201 assert_eq!(fixed, "- Item\n\n ```\n code\n ```\n");
3202 }
3203
3204 #[test]
3205 fn fix_mkdocs_code_fence_indent() {
3206 let content = "1. Item\n\n ```toml\n key = \"val\"\n ```\n";
3208 let fixed = fix_mkdocs(content);
3209 assert_eq!(fixed, "1. Item\n\n ```toml\n key = \"val\"\n ```\n");
3210 assert_eq!(fix_mkdocs(&fixed), fixed);
3211 }
3212
3213 #[test]
3216 fn empty_document_no_warning() {
3217 assert!(check("").is_empty());
3218 }
3219
3220 #[test]
3221 fn whitespace_only_no_warning() {
3222 assert!(check(" \n\n \n").is_empty());
3223 }
3224
3225 #[test]
3228 fn no_list_no_warning() {
3229 let content = "# Heading\n\nSome paragraph.\n\nAnother paragraph.\n";
3230 assert!(check(content).is_empty());
3231 }
3232
3233 #[test]
3236 fn multiline_continuation_all_lines_flagged() {
3237 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";
3238 let warnings = check(content);
3239 assert_eq!(warnings.len(), 3);
3240 assert_eq!(warnings[0].line, 3);
3241 assert_eq!(warnings[1].line, 4);
3242 assert_eq!(warnings[2].line, 5);
3243 }
3244
3245 #[test]
3246 fn multiline_continuation_with_frontmatter_fix() {
3247 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";
3248 let fixed = fix(content);
3249 assert_eq!(
3250 fixed,
3251 "---\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"
3252 );
3253 }
3254
3255 #[test]
3256 fn multiline_continuation_correct_indent_no_warning() {
3257 let content = "1. Item\n\n line one\n line two\n line three\n";
3258 assert!(check(content).is_empty());
3259 }
3260
3261 #[test]
3262 fn multiline_continuation_mixed_indent() {
3263 let content = "1. Item\n\n correct\n wrong\n correct\n";
3264 let warnings = check(content);
3265 assert_eq!(warnings.len(), 1);
3266 assert_eq!(warnings[0].line, 4);
3267 }
3268
3269 #[test]
3270 fn multiline_continuation_unordered() {
3271 let content = "- Item\n\n continuation 1\n continuation 2\n continuation 3\n";
3272 let warnings = check(content);
3273 assert_eq!(warnings.len(), 3);
3274 let fixed = fix(content);
3275 assert_eq!(
3276 fixed,
3277 "- Item\n\n continuation 1\n continuation 2\n continuation 3\n"
3278 );
3279 }
3280
3281 #[test]
3282 fn multiline_continuation_two_items_fix() {
3283 let content = "1. First\n\n cont a\n cont b\n\n2. Second\n\n cont c\n cont d\n";
3284 let fixed = fix(content);
3285 assert_eq!(
3286 fixed,
3287 "1. First\n\n cont a\n cont b\n\n2. Second\n\n cont c\n cont d\n"
3288 );
3289 }
3290
3291 #[test]
3292 fn fence_fix_does_not_break_pairing_for_md031() {
3293 let content = "#### title\n\nabc\n\n\
3300 1. ab\n\n\
3301 \x20\x20`aabbccdd`\n\n\
3302 2. cd\n\n\
3303 \x20\x20`bbcc dd ee`\n\n\
3304 \x20\x20```\n\
3305 \x20\x20abcd\n\
3306 \x20\x20ef gh\n\
3307 \x20\x20```\n\n\
3308 \x20\x20uu\n\n\
3309 \x20\x20```\n\
3310 \x20\x20cdef\n\
3311 \x20\x20gh ij\n\
3312 \x20\x20```\n";
3313 let expected = "#### title\n\nabc\n\n\
3314 1. ab\n\n\
3315 \x20\x20\x20`aabbccdd`\n\n\
3316 2. cd\n\n\
3317 \x20\x20\x20`bbcc dd ee`\n\n\
3318 \x20\x20\x20```\n\
3319 \x20\x20\x20abcd\n\
3320 \x20\x20\x20ef gh\n\
3321 \x20\x20\x20```\n\n\
3322 \x20\x20\x20uu\n\n\
3323 \x20\x20\x20```\n\
3324 \x20\x20\x20cdef\n\
3325 \x20\x20\x20gh ij\n\
3326 \x20\x20\x20```\n";
3327 assert_eq!(fix(content), expected);
3328 }
3329
3330 #[test]
3331 fn multiline_continuation_separated_by_blank() {
3332 let content = "1. Item\n\n para1 line1\n para1 line2\n\n para2 line1\n para2 line2\n";
3333 let warnings = check(content);
3334 assert_eq!(warnings.len(), 4);
3335 let fixed = fix(content);
3336 assert_eq!(
3337 fixed,
3338 "1. Item\n\n para1 line1\n para1 line2\n\n para2 line1\n para2 line2\n"
3339 );
3340 }
3341
3342 #[test]
3343 fn tab_indented_fence_is_normalized_to_spaces() {
3344 let content = "- a\n\n 100. ab\n\n\t```\n\tabcd\n\t```\n";
3352 let expected = "- a\n\n 100. ab\n\n ```\n abcd\n ```\n";
3353 assert_eq!(fix(content), expected);
3354 let code = "100. ab\n\n\t```\n\tabcd\n\t```\n";
3357 assert_eq!(fix(code), code);
3358 }
3359
3360 #[test]
3369 fn loose_continuation_over_indented_flagged() {
3370 let content = "* Item\n\n over-indented\n";
3373 let warnings = check(content);
3374 assert_eq!(warnings.len(), 1);
3375 assert_eq!(warnings[0].line, 3);
3376 assert!(warnings[0].message.contains("over-indented"));
3377 assert!(warnings[0].message.contains("expected 2"));
3378 assert!(warnings[0].message.contains("found 3"));
3379 }
3380
3381 #[test]
3382 fn loose_continuation_over_indented_multiline_mixed() {
3383 let content = "* Item\n\n over one\n correct\n over two\n";
3385 let warnings = check(content);
3386 assert_eq!(warnings.len(), 2);
3387 assert_eq!(warnings[0].line, 3);
3388 assert_eq!(warnings[1].line, 5);
3389 }
3390
3391 #[test]
3392 fn fix_loose_continuation_over_indented() {
3393 let content = "* Item\n\n over one\n correct\n over two\n";
3394 let fixed = fix(content);
3395 assert_eq!(fixed, "* Item\n\n over one\n correct\n over two\n");
3396 }
3397
3398 #[test]
3399 fn fix_tight_and_loose_items_normalized_identically() {
3400 let content = "---\ntitle: Heading\n---\n\nSome introductory text:\n\n\
3403 * This is a list item.\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\n\
3404 * 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";
3405 let expected = "---\ntitle: Heading\n---\n\nSome introductory text:\n\n\
3406 * This is a list item.\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\n\
3407 * 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";
3408 assert_eq!(fix(content), expected);
3409 }
3410
3411 #[test]
3412 fn multi_paragraph_item_loose_paragraph_over_indented() {
3413 let content = "* Item.\n tight over\n\n loose over\n";
3416 let warnings = check(content);
3417 assert_eq!(warnings.len(), 2);
3418 assert_eq!(warnings[0].line, 2);
3419 assert_eq!(warnings[1].line, 4);
3420 }
3421
3422 #[test]
3423 fn loose_indented_code_block_not_flagged() {
3424 let content = "- Item\n\n code line\n";
3428 assert!(check(content).is_empty());
3429 }
3430
3431 #[test]
3432 fn mkdocs_loose_over_indented_flagged() {
3433 let content = "1. Item\n\n over\n";
3436 let warnings = check_mkdocs(content);
3437 assert_eq!(warnings.len(), 1);
3438 assert_eq!(warnings[0].line, 3);
3439 assert!(warnings[0].message.contains("over-indented"));
3440 assert!(warnings[0].message.contains("expected 4"));
3441 assert!(warnings[0].message.contains("found 5"));
3442 }
3443
3444 #[test]
3445 fn task_list_loose_over_indented_flagged() {
3446 let content = "- [ ] Task\n\n over\n";
3449 let warnings = check(content);
3450 assert_eq!(warnings.len(), 1);
3451 assert_eq!(warnings[0].line, 3);
3452 }
3453
3454 #[test]
3455 fn loose_over_indent_boundary_below_code_block_threshold_flagged() {
3456 let content = "- Item\n\n over\n";
3461 let warnings = check(content);
3462 assert_eq!(warnings.len(), 1);
3463 assert_eq!(warnings[0].line, 3);
3464 assert!(warnings[0].message.contains("expected 2"));
3465 assert!(warnings[0].message.contains("found 5"));
3466 }
3467
3468 #[test]
3469 fn loose_over_indent_does_not_steal_nested_under_indent() {
3470 let content = "- Outer\n - Inner\n\n continuation\n";
3477 let warnings = check(content);
3478 assert_eq!(warnings.len(), 1);
3479 assert_eq!(warnings[0].line, 4);
3480 assert!(warnings[0].message.contains("4 spaces"));
3481 assert!(warnings[0].message.contains("found 3"));
3482 assert_eq!(fix(content), "- Outer\n - Inner\n\n continuation\n");
3483 }
3484
3485 #[test]
3486 fn loose_over_indent_attributes_to_deepest_enclosing_item() {
3487 let content = "- Outer\n - Inner\n\n continuation\n";
3491 let warnings = check(content);
3492 assert_eq!(warnings.len(), 1);
3493 assert_eq!(warnings[0].line, 4);
3494 assert!(warnings[0].message.contains("expected 4"));
3495 assert!(warnings[0].message.contains("found 5"));
3496 assert_eq!(fix(content), "- Outer\n - Inner\n\n continuation\n");
3497 }
3498
3499 #[test]
3508 fn loose_over_indented_fence_not_flagged() {
3509 let content = "- Item\n\n ```\n code\n ```\n";
3510 assert!(check(content).is_empty());
3511 assert_eq!(fix(content), content);
3512 }
3513
3514 #[test]
3515 fn tight_over_indented_fence_not_flagged() {
3516 let content = "- Item\n ```\n code\n ```\n";
3517 assert!(check(content).is_empty());
3518 assert_eq!(fix(content), content);
3519 }
3520
3521 #[test]
3522 fn over_indented_tilde_fence_not_flagged() {
3523 let content = "- Item\n\n ~~~\n code\n ~~~\n";
3524 assert!(check(content).is_empty());
3525 assert_eq!(fix(content), content);
3526 }
3527
3528 #[test]
3529 fn fence_like_code_content_inside_fenced_block_not_flagged() {
3530 let content = "- Item\n\n ~~~\n ```\n ~~~\n";
3533 assert!(check(content).is_empty());
3534 assert_eq!(fix(content), content);
3535 }
3536
3537 #[test]
3538 fn unterminated_over_indented_fence_not_flagged() {
3539 let content = "- Item\n\n ```\n code1\n code2deeper\n";
3542 assert!(check(content).is_empty());
3543 assert_eq!(fix(content), content);
3544 }
3545
3546 #[test]
3554 fn task_list_tight_continuation_post_checkbox_reproducer_579() {
3555 let content = "- [ ] Lorem ipsum dolor sit amet, consectetur adipiscing\n tempor incididunt ut labore.\n";
3558 assert!(check(content).is_empty());
3559 }
3560
3561 #[test]
3562 fn task_list_tight_continuation_dash_unchecked() {
3563 let content = "- [ ] Task\n continuation\n";
3564 assert!(check(content).is_empty());
3565 }
3566
3567 #[test]
3568 fn task_list_tight_continuation_dash_checked_lower() {
3569 let content = "- [x] Task\n continuation\n";
3570 assert!(check(content).is_empty());
3571 }
3572
3573 #[test]
3574 fn task_list_tight_continuation_dash_checked_upper() {
3575 let content = "- [X] Task\n continuation\n";
3576 assert!(check(content).is_empty());
3577 }
3578
3579 #[test]
3580 fn task_list_tight_continuation_star_marker() {
3581 let content = "* [ ] Task\n continuation\n";
3582 assert!(check(content).is_empty());
3583 }
3584
3585 #[test]
3586 fn task_list_tight_continuation_plus_marker() {
3587 let content = "+ [ ] Task\n continuation\n";
3588 assert!(check(content).is_empty());
3589 }
3590
3591 #[test]
3592 fn task_list_tight_continuation_content_column_still_valid() {
3593 let content = "- [ ] Task\n continuation\n";
3596 assert!(check(content).is_empty());
3597 }
3598
3599 #[test]
3600 fn task_list_tight_continuation_between_columns_still_flagged() {
3601 let content = "- [ ] Task\n continuation\n";
3604 let warnings = check(content);
3605 assert_eq!(warnings.len(), 1);
3606 assert!(warnings[0].message.contains("expected 2 or 6"));
3608 assert!(warnings[0].message.contains("found 4"));
3609 }
3610
3611 #[test]
3612 fn task_list_tight_continuation_overshoot_still_flagged() {
3613 let content = "- [ ] Task\n continuation\n";
3615 let warnings = check(content);
3616 assert_eq!(warnings.len(), 1);
3617 assert!(warnings[0].message.contains("expected 2 or 6"));
3618 assert!(warnings[0].message.contains("found 7"));
3619 }
3620
3621 #[test]
3624 fn fix_task_list_overshoot_snaps_to_task_col() {
3625 let content = "- [ ] Task\n continuation\n";
3629 let fixed = fix(content);
3630 assert_eq!(fixed, "- [ ] Task\n continuation\n");
3631 }
3632
3633 #[test]
3634 fn fix_task_list_col_5_snaps_to_task_col() {
3635 let content = "- [ ] Task\n continuation\n";
3637 let fixed = fix(content);
3638 assert_eq!(fixed, "- [ ] Task\n continuation\n");
3639 }
3640
3641 #[test]
3642 fn fix_task_list_col_3_snaps_to_content_col() {
3643 let content = "- [ ] Task\n continuation\n";
3645 let fixed = fix(content);
3646 assert_eq!(fixed, "- [ ] Task\n continuation\n");
3647 }
3648
3649 #[test]
3650 fn fix_task_list_col_4_ties_to_content_col() {
3651 let content = "- [ ] Task\n continuation\n";
3656 let fixed = fix(content);
3657 assert_eq!(fixed, "- [ ] Task\n continuation\n");
3658 }
3659
3660 #[test]
3661 fn fix_task_list_ordered_overshoot_snaps_to_task_col() {
3662 let content = "1. [ ] Task\n continuation\n";
3665 let fixed = fix(content);
3666 assert_eq!(fixed, "1. [ ] Task\n continuation\n");
3667 }
3668
3669 #[test]
3670 fn fix_task_list_ordered_under_overshoot_snaps_to_content_col() {
3671 let content = "1. [ ] Task\n continuation\n";
3674 let fixed = fix(content);
3675 assert_eq!(fixed, "1. [ ] Task\n continuation\n");
3676 }
3677
3678 #[test]
3679 fn task_list_tight_continuation_ordered_single_digit() {
3680 let content = "1. [ ] Task\n continuation\n";
3682 assert!(check(content).is_empty());
3683 }
3684
3685 #[test]
3686 fn task_list_tight_continuation_ordered_multi_digit() {
3687 let content = "10. [ ] Task\n continuation\n";
3689 assert!(check(content).is_empty());
3690 }
3691
3692 #[test]
3693 fn task_list_tight_continuation_nested_dash() {
3694 let content = "- Parent\n - [ ] Nested task\n continuation\n";
3696 assert!(check(content).is_empty());
3697 }
3698
3699 #[test]
3700 fn task_list_loose_continuation_post_checkbox_column_not_flagged() {
3701 let content = "- [ ] Task\n\n continuation\n";
3706 assert!(check(content).is_empty());
3707 }
3708
3709 #[test]
3710 fn task_list_empty_body_is_not_a_task() {
3711 let content = "- [ ]\n continuation\n";
3717 let warnings = check(content);
3718 assert_eq!(warnings.len(), 1);
3719 assert!(warnings[0].message.contains("found 4"));
3720 }
3721
3722 #[test]
3723 fn task_list_malformed_checkbox_is_not_a_task() {
3724 let content = "- [~] Not a task\n continuation\n";
3726 let warnings = check(content);
3727 assert_eq!(warnings.len(), 1);
3728 }
3729
3730 #[test]
3737 fn task_list_mkdocs_unordered_required_min_valid() {
3738 let content = "- [ ] Task\n continuation\n";
3740 assert!(check_mkdocs(content).is_empty());
3741 }
3742
3743 #[test]
3744 fn task_list_mkdocs_unordered_post_checkbox_valid() {
3745 let content = "- [ ] Task\n continuation\n";
3746 assert!(check_mkdocs(content).is_empty());
3747 }
3748
3749 #[test]
3750 fn task_list_mkdocs_unordered_between_flagged() {
3751 let content = "- [ ] Task\n continuation\n";
3753 let warnings = check_mkdocs(content);
3754 assert_eq!(warnings.len(), 1);
3755 }
3756
3757 #[test]
3758 fn task_list_mkdocs_ordered_both_columns_valid() {
3759 let at_4 = "1. [ ] Task\n continuation\n";
3761 assert!(check_mkdocs(at_4).is_empty());
3762 let at_7 = "1. [ ] Task\n continuation\n";
3763 assert!(check_mkdocs(at_7).is_empty());
3764 }
3765
3766 #[test]
3767 fn task_list_mkdocs_ordered_between_flagged() {
3768 let at_5 = "1. [ ] Task\n continuation\n";
3770 assert_eq!(check_mkdocs(at_5).len(), 1);
3771 let at_6 = "1. [ ] Task\n continuation\n";
3772 assert_eq!(check_mkdocs(at_6).len(), 1);
3773 }
3774
3775 #[test]
3785 fn fix_task_list_tie_sibling_at_task_col_snaps_to_task_col() {
3786 let content = "- [ ] Task\n aligned continuation\n tied continuation\n";
3790 let fixed = fix(content);
3791 assert_eq!(
3792 fixed,
3793 "- [ ] Task\n aligned continuation\n tied continuation\n"
3794 );
3795 }
3796
3797 #[test]
3798 fn fix_task_list_tie_sibling_at_content_col_snaps_to_content_col() {
3799 let content = "- [ ] Task\n aligned continuation\n tied continuation\n";
3802 let fixed = fix(content);
3803 assert_eq!(fixed, "- [ ] Task\n aligned continuation\n tied continuation\n");
3804 }
3805
3806 #[test]
3807 fn fix_task_list_tie_both_siblings_snaps_to_content_col() {
3808 let content = "- [ ] Task\n at content col\n at task col\n tied continuation\n";
3812 let fixed = fix(content);
3813 assert_eq!(
3814 fixed,
3815 "- [ ] Task\n at content col\n at task col\n tied continuation\n"
3816 );
3817 }
3818
3819 #[test]
3820 fn fix_task_list_tie_sees_task_col_through_tight_lazy_continuation() {
3821 let content = concat!("- [ ] Task\n", "lazy\n", " aligned at task col\n", " tied\n",);
3835 let fixed = fix(content);
3836 assert!(
3837 fixed.contains("\n tied\n"),
3838 "tied line should snap to col 6 (task col) because a task-col \
3839 sibling is visible past the tight lazy-continuation line; got:\n{fixed}"
3840 );
3841 }
3842
3843 #[test]
3850 fn task_list_tab_indented_continuation_flagged() {
3851 let content = "- [ ] Task\n\t\twrap\n";
3854 let warnings = check(content);
3855 assert_eq!(warnings.len(), 1);
3856 assert!(warnings[0].message.contains("expected 2 or 6"));
3857 assert!(warnings[0].message.contains("found 8"));
3858 }
3859
3860 #[test]
3861 fn fix_task_list_tab_indented_snaps_to_task_col() {
3862 let content = "- [ ] Task\n\t\twrap\n";
3864 let fixed = fix(content);
3865 assert_eq!(fixed, "- [ ] Task\n wrap\n");
3866 }
3867
3868 #[test]
3869 fn fix_task_list_single_tab_equidistant_snaps_to_content_col() {
3870 let content = "- [ ] Task\n\twrap\n";
3873 let fixed = fix(content);
3874 assert_eq!(fixed, "- [ ] Task\n wrap\n");
3875 }
3876
3877 #[test]
3887 fn task_list_blockquote_post_checkbox_not_flagged() {
3888 let content = "> - [ ] Task\n> continuation\n";
3890 assert!(check(content).is_empty());
3891 }
3892
3893 #[test]
3894 fn task_list_blockquote_between_cols_documented_limitation() {
3895 let content = "> - [ ] Task\n> continuation\n";
3899 assert!(check(content).is_empty());
3900 }
3901
3902 #[test]
3903 fn task_list_blockquote_overshoot_documented_limitation() {
3904 let content = "> - [ ] Task\n> continuation\n";
3906 assert!(check(content).is_empty());
3907 }
3908
3909 #[test]
3916 fn fix_task_list_mkdocs_unordered_overshoot_snaps_to_task_col() {
3917 let content = "- [ ] Task\n continuation\n";
3920 let fixed = fix_mkdocs(content);
3921 assert_eq!(fixed, "- [ ] Task\n continuation\n");
3922 }
3923
3924 #[test]
3925 fn fix_task_list_mkdocs_unordered_tie_snaps_to_required() {
3926 let content = "- [ ] Task\n continuation\n";
3929 let fixed = fix_mkdocs(content);
3930 assert_eq!(fixed, "- [ ] Task\n continuation\n");
3931 }
3932
3933 #[test]
3934 fn fix_task_list_mkdocs_ordered_overshoot_snaps_to_task_col() {
3935 let content = "1. [ ] Task\n continuation\n";
3938 let fixed = fix_mkdocs(content);
3939 assert_eq!(fixed, "1. [ ] Task\n continuation\n");
3940 }
3941
3942 #[test]
3943 fn fix_task_list_mkdocs_ordered_near_required_snaps_to_required() {
3944 let content = "1. [ ] Task\n continuation\n";
3950 let fixed = fix_mkdocs(content);
3951 assert_eq!(fixed, "1. [ ] Task\n continuation\n");
3952 }
3953
3954 #[test]
3955 fn fix_task_list_mkdocs_ordered_between_cols_snaps_to_task_col() {
3956 let content = "1. [ ] Task\n continuation\n";
3959 let fixed = fix_mkdocs(content);
3960 assert_eq!(fixed, "1. [ ] Task\n continuation\n");
3961 }
3962
3963 fn assert_idempotent(content: &str) {
3973 let once = fix(content);
3974 let twice = fix(&once);
3975 assert_eq!(once, twice, "MD077 fix was not idempotent on input: {content:?}");
3976 }
3977
3978 fn assert_idempotent_mkdocs(content: &str) {
3979 let once = fix_mkdocs(content);
3980 let twice = fix_mkdocs(&once);
3981 assert_eq!(
3982 once, twice,
3983 "MD077 (MkDocs) fix was not idempotent on input: {content:?}"
3984 );
3985 }
3986
3987 #[test]
3988 fn idempotent_task_list_between_cols() {
3989 assert_idempotent("- [ ] Task\n continuation\n");
3990 }
3991
3992 #[test]
3993 fn idempotent_task_list_overshoot() {
3994 assert_idempotent("- [ ] Task\n continuation\n");
3995 }
3996
3997 #[test]
3998 fn idempotent_task_list_under_post_checkbox() {
3999 assert_idempotent("- [ ] Task\n continuation\n");
4000 }
4001
4002 #[test]
4003 fn idempotent_task_list_near_post_checkbox() {
4004 assert_idempotent("- [ ] Task\n continuation\n");
4005 }
4006
4007 #[test]
4008 fn idempotent_task_list_tab_overshoot() {
4009 assert_idempotent("- [ ] Task\n\t\twrap\n");
4010 }
4011
4012 #[test]
4013 fn idempotent_task_list_single_tab() {
4014 assert_idempotent("- [ ] Task\n\twrap\n");
4015 }
4016
4017 #[test]
4018 fn idempotent_task_list_ordered_overshoot() {
4019 assert_idempotent("1. [ ] Task\n continuation\n");
4020 }
4021
4022 #[test]
4023 fn idempotent_task_list_ordered_under() {
4024 assert_idempotent("1. [ ] Task\n continuation\n");
4025 }
4026
4027 #[test]
4028 fn idempotent_task_list_tie_with_sibling_at_task_col() {
4029 assert_idempotent("- [ ] Task\n aligned\n tied\n");
4030 }
4031
4032 #[test]
4033 fn idempotent_task_list_tie_with_sibling_at_content_col() {
4034 assert_idempotent("- [ ] Task\n aligned\n tied\n");
4035 }
4036
4037 #[test]
4038 fn idempotent_task_list_mkdocs_unordered_overshoot() {
4039 assert_idempotent_mkdocs("- [ ] Task\n continuation\n");
4040 }
4041
4042 #[test]
4043 fn idempotent_task_list_mkdocs_unordered_tie() {
4044 assert_idempotent_mkdocs("- [ ] Task\n continuation\n");
4045 }
4046
4047 #[test]
4048 fn idempotent_task_list_mkdocs_ordered_overshoot() {
4049 assert_idempotent_mkdocs("1. [ ] Task\n continuation\n");
4050 }
4051
4052 #[test]
4053 fn idempotent_task_list_mkdocs_ordered_between() {
4054 assert_idempotent_mkdocs("1. [ ] Task\n continuation\n");
4055 }
4056
4057 #[test]
4058 fn idempotent_task_list_reproducer_579() {
4059 assert_idempotent(
4063 "- [ ] Lorem ipsum dolor sit amet, consectetur adipiscing\n tempor incididunt ut labore.\n",
4064 );
4065 }
4066
4067 #[test]
4068 fn idempotent_non_task_list_still_holds() {
4069 assert_idempotent("1. Item\n over-indented\n");
4072 assert_idempotent("- Item\n\n continuation\n");
4073 }
4074
4075 #[test]
4082 fn idempotent_non_task_loose_under_indent_ordered() {
4083 assert_idempotent("1. Item\n\n continuation\n");
4085 }
4086
4087 #[test]
4088 fn idempotent_non_task_loose_under_indent_multi_digit() {
4089 assert_idempotent("10. Item\n\n continuation\n");
4091 }
4092
4093 #[test]
4094 fn idempotent_non_task_tight_over_indent_ordered() {
4095 assert_idempotent("1. Item\n over-indented\n");
4097 }
4098
4099 #[test]
4107 fn idempotent_non_task_fence_ordered_loose() {
4108 assert_idempotent("1. Item\n\n ```rust\n let x = 1;\n ```\n");
4110 }
4111
4112 #[test]
4113 fn idempotent_non_task_fence_tilde_under_indent() {
4114 assert_idempotent("1. Item\n\n ~~~\nplain text\n ~~~\n");
4120 }
4121
4122 #[test]
4123 fn idempotent_non_task_fence_interior_above_required() {
4124 assert_idempotent("1. Item\n\n ```\n deeply indented code\n ```\n");
4128 }
4129
4130 #[test]
4131 fn fence_fix_promotes_interior_below_scope_in_single_pass() {
4132 let content = "1. Item\n\n ```\ncode\n ```\n";
4136 let fixed = fix(content);
4137 assert_eq!(fixed, "1. Item\n\n ```\n code\n ```\n");
4138 }
4139
4140 #[test]
4141 fn fence_fix_preserves_interior_offset_from_the_fence() {
4142 let content = "1. Item\n\n ```\n code\n ```\n";
4147 let fixed = fix(content);
4148 assert_eq!(fixed, "1. Item\n\n ```\n code\n ```\n");
4149 }
4150
4151 #[test]
4158 fn idempotent_non_task_mkdocs_ordered_at_3_spaces() {
4159 assert_idempotent_mkdocs("1. Item\n\n continuation\n");
4161 }
4162
4163 #[test]
4164 fn idempotent_non_task_mkdocs_unordered_at_2_spaces() {
4165 assert_idempotent_mkdocs("- Item\n\n continuation\n");
4167 }
4168
4169 #[test]
4170 fn idempotent_non_task_mkdocs_fence_compound() {
4171 assert_idempotent_mkdocs("1. Item\n\n ```toml\n k = 1\n ```\n");
4173 }
4174
4175 #[test]
4178 fn aligned_tight_zero_indent_continuation_flagged() {
4179 let content = "- this is a long line\nthat continues on a second line\n";
4183 let warnings = check_aligned(content);
4184 assert_eq!(warnings.len(), 1);
4185 assert_eq!(warnings[0].line, 2);
4186 assert_eq!(
4187 fix_aligned(content),
4188 "- this is a long line\n that continues on a second line\n"
4189 );
4190 }
4191
4192 #[test]
4193 fn aligned_full_issue_example_made_consistent() {
4194 let content = "- this is a long line\n\
4197 that continues on a second line\n\
4198 - this is another long line\n\
4199 \x20\x20that continues on the next line\n\
4200 - yet again a long line\n\
4201 and still inconsistently spaced\n\
4202 \x20\x20and even worse\n";
4203 let expected = "- this is a long line\n\
4204 \x20\x20that continues on a second line\n\
4205 - this is another long line\n\
4206 \x20\x20that continues on the next line\n\
4207 - yet again a long line\n\
4208 \x20\x20and still inconsistently spaced\n\
4209 \x20\x20and even worse\n";
4210 assert_eq!(fix_aligned(content), expected);
4211 assert_eq!(fix_aligned(expected), expected);
4213 }
4214
4215 #[test]
4216 fn aligned_already_aligned_not_flagged() {
4217 let content = "- item\n continuation at content column\n";
4218 assert!(check_aligned(content).is_empty());
4219 }
4220
4221 #[test]
4222 fn aligned_tight_partial_indent_flagged() {
4223 let content = "- item\n continuation\n";
4225 let warnings = check_aligned(content);
4226 assert_eq!(warnings.len(), 1);
4227 assert_eq!(fix_aligned(content), "- item\n continuation\n");
4228 }
4229
4230 #[test]
4231 fn aligned_post_blank_zero_indent_still_new_paragraph() {
4232 let content = "- item\n\nnew paragraph\n";
4235 assert!(check_aligned(content).is_empty());
4236 assert_eq!(fix_aligned(content), content);
4237 }
4238
4239 #[test]
4242 fn aligned_top_level_blockquote_after_list_untouched() {
4243 let content = "- item\n> quote\n";
4247 assert!(check_aligned(content).is_empty());
4248 assert_eq!(fix_aligned(content), content);
4249 }
4250
4251 #[test]
4252 fn aligned_top_level_fence_after_list_untouched() {
4253 let content = "- item\n```\ncode\n```\n";
4254 assert!(check_aligned(content).is_empty());
4255 assert_eq!(fix_aligned(content), content);
4256 }
4257
4258 #[test]
4259 fn aligned_top_level_table_after_list_untouched() {
4260 let content = "- item\n| a | b |\n|---|---|\n| 1 | 2 |\n";
4261 assert!(check_aligned(content).is_empty());
4262 assert_eq!(fix_aligned(content), content);
4263 }
4264
4265 #[test]
4268 fn aligned_nested_tight_lazy_continuation_aligns_to_inner() {
4269 let content = "- Outer\n - Inner\ncontinuation\n";
4274 let warnings = check_aligned(content);
4275 assert_eq!(warnings.len(), 1);
4276 assert_eq!(fix_aligned(content), "- Outer\n - Inner\n continuation\n");
4277 }
4278
4279 #[test]
4280 fn aligned_nested_continuation_already_aligned_not_flagged() {
4281 let content = "- L1\n - L2\n cont of L2 at 4\n";
4282 assert!(check_aligned(content).is_empty());
4283 }
4284
4285 #[test]
4286 fn aligned_nested_idempotent() {
4287 let content = "- Outer\n - Inner\ncontinuation\n";
4288 let once = fix_aligned(content);
4289 assert_eq!(fix_aligned(&once), once);
4290 }
4291
4292 #[test]
4293 fn aligned_three_level_nesting_aligns_to_innermost() {
4294 let content = "- L1\n - L2\n - L3\ncont\n";
4297 assert_eq!(fix_aligned(content), "- L1\n - L2\n - L3\n cont\n");
4298 }
4299
4300 #[test]
4301 fn aligned_continuation_after_sibling_owned_by_last_item() {
4302 let content = "- a\n- b\nlazy\n";
4305 assert_eq!(fix_aligned(content), "- a\n- b\n lazy\n");
4306 }
4307
4308 #[test]
4309 fn aligned_multi_digit_ordered_marker_aligns_to_content_column() {
4310 let content = "10. Item\nwrap\n";
4311 assert_eq!(fix_aligned(content), "10. Item\n wrap\n");
4312 }
4313
4314 #[test]
4315 fn aligned_latent_setext_underline_is_left_alone() {
4316 let content = "- item\nText\n===\n";
4321 assert!(check_aligned(content).is_empty());
4322 assert_eq!(fix_aligned(content), content);
4323 }
4324
4325 #[test]
4326 fn aligned_reindents_prose_that_only_looks_like_an_underline() {
4327 let content = "- item\nText\n= = =\n";
4330 assert_eq!(fix_aligned(content), "- item\n Text\n = = =\n");
4331 }
4332
4333 #[test]
4334 fn aligned_latent_marker_in_continuation_is_idempotent() {
4335 let content = "# \n- \n``\n2. \n![]()";
4341 let once = fix_aligned(content);
4342 assert_eq!(fix_aligned(&once), once, "fix must be idempotent in one pass");
4343 assert_eq!(once, content, "item with a latent marker is left untouched");
4344 }
4345
4346 #[test]
4347 fn aligned_latent_table_in_continuation_is_idempotent() {
4348 let content = "- \n![`]()\n| | ` |\n| --- | --- |";
4353 let once = fix_aligned(content);
4354 assert_eq!(fix_aligned(&once), once, "fix must be idempotent in one pass");
4355 assert_eq!(once, content, "item with a latent table is left untouched");
4356 }
4357
4358 #[test]
4359 fn aligned_blockquote_nested_list_not_touched() {
4360 let content = "> - item\n> wrap\n";
4364 assert!(check_aligned(content).is_empty());
4365 assert_eq!(fix_aligned(content), content);
4366 }
4367
4368 #[test]
4371 fn aligned_task_post_checkbox_column_accepted() {
4372 let content = "- [ ] Task\n wrap\n";
4375 assert!(check_aligned(content).is_empty());
4376 assert_eq!(fix_aligned(content), content);
4377 }
4378
4379 #[test]
4380 fn aligned_task_under_indent_snaps_to_content_column() {
4381 let content = "- [ ] Task\nwrap\n";
4382 let warnings = check_aligned(content);
4383 assert_eq!(warnings.len(), 1);
4384 assert_eq!(fix_aligned(content), "- [ ] Task\n wrap\n");
4385 }
4386
4387 #[test]
4390 fn aligned_mkdocs_tight_under_indent_snaps_to_four() {
4391 let content = "- item\nwrap\n";
4393 let warnings = check_aligned_mkdocs(content);
4394 assert_eq!(warnings.len(), 1);
4395 let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
4396 assert_eq!(aligned_rule().fix(&ctx).unwrap(), "- item\n wrap\n");
4397 }
4398
4399 #[test]
4402 fn any_default_does_not_flag_tight_lazy_continuation() {
4403 let content = "- item\nwrapped at zero indent\n";
4405 assert!(check(content).is_empty());
4406 assert_eq!(fix(content), content);
4407 }
4408
4409 #[test]
4410 fn from_config_aligned_enables_tight_flagging() {
4411 let mut config = crate::config::Config::default();
4413 let mut rule_config = crate::config::RuleConfig::default();
4414 rule_config
4415 .values
4416 .insert("style".to_string(), toml::Value::String("aligned".to_string()));
4417 config.rules.insert("MD077".to_string(), rule_config);
4418
4419 let rule = MD077ListContinuationIndent::from_config(&config);
4420 let ctx = LintContext::new("- item\nwrap\n", MarkdownFlavor::Standard, None);
4421 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
4422 }
4423
4424 #[test]
4425 fn from_config_default_is_any() {
4426 let config = crate::config::Config::default();
4428 let rule = MD077ListContinuationIndent::from_config(&config);
4429 let ctx = LintContext::new("- item\nwrap\n", MarkdownFlavor::Standard, None);
4430 assert!(rule.check(&ctx).unwrap().is_empty());
4431 }
4432
4433 #[test]
4434 fn from_config_indent_sets_fixed_requirement() {
4435 let mut config = crate::config::Config::default();
4438 let mut rule_config = crate::config::RuleConfig::default();
4439 rule_config.values.insert("indent".to_string(), toml::Value::Integer(4));
4440 config.rules.insert("MD077".to_string(), rule_config);
4441
4442 let rule = MD077ListContinuationIndent::from_config(&config);
4443
4444 let ok_ctx = LintContext::new("- item\n wrap\n", MarkdownFlavor::Standard, None);
4446 assert!(rule.check(&ok_ctx).unwrap().is_empty());
4447 assert_eq!(rule.fix(&ok_ctx).unwrap(), "- item\n wrap\n");
4448
4449 let bad_ctx = LintContext::new("- item\n\n wrap\n", MarkdownFlavor::Standard, None);
4452 let warnings = rule.check(&bad_ctx).unwrap();
4453 assert_eq!(warnings.len(), 1);
4454 assert!(warnings[0].message.contains("needs 4 spaces"));
4455 }
4456
4457 #[test]
4458 fn from_config_indent_applies_per_nested_marker() {
4459 let mut config = crate::config::Config::default();
4462 let mut rule_config = crate::config::RuleConfig::default();
4463 rule_config.values.insert("indent".to_string(), toml::Value::Integer(4));
4464 config.rules.insert("MD077".to_string(), rule_config);
4465
4466 let rule = MD077ListContinuationIndent::from_config(&config);
4467 let ctx = LintContext::new("- a\n - b\n wrap\n", MarkdownFlavor::Standard, None);
4468 let warnings = rule.check(&ctx).unwrap();
4469 assert!(
4470 warnings.is_empty(),
4471 "continuation at 6 spaces should pass: {warnings:?}"
4472 );
4473 }
4474
4475 fn rule_with(settings: &[(&str, toml::Value)]) -> Box<dyn Rule> {
4477 let mut config = crate::config::Config::default();
4478 let mut rule_config = crate::config::RuleConfig::default();
4479 for (key, value) in settings {
4480 rule_config.values.insert((*key).to_string(), value.clone());
4481 }
4482 config.rules.insert("MD077".to_string(), rule_config);
4483 MD077ListContinuationIndent::from_config(&config)
4484 }
4485
4486 #[test]
4487 fn configured_indent_cannot_lower_the_strict_flavor_minimum() {
4488 let rule = rule_with(&[("indent", toml::Value::Integer(2))]);
4492
4493 let two = LintContext::new("- item\n\n wrap\n", MarkdownFlavor::MkDocs, None);
4494 let warnings = rule.check(&two).unwrap();
4495 assert_eq!(warnings.len(), 1, "2 spaces is below the MkDocs minimum: {warnings:?}");
4496 assert!(
4497 warnings[0].message.contains("needs 4 spaces") && warnings[0].message.contains("MkDocs"),
4498 "the requirement comes from MkDocs, so the message must say so: {}",
4499 warnings[0].message
4500 );
4501 assert_eq!(rule.fix(&two).unwrap(), "- item\n\n wrap\n");
4502
4503 let four = LintContext::new("- item\n\n wrap\n", MarkdownFlavor::MkDocs, None);
4505 assert!(rule.check(&four).unwrap().is_empty());
4506
4507 let standard = LintContext::new("- item\n\n wrap\n", MarkdownFlavor::Standard, None);
4511 assert_eq!(rule.check(&standard).unwrap().len(), 1);
4512 assert_eq!(rule.fix(&standard).unwrap(), "- item\n\n wrap\n");
4513 }
4514
4515 #[test]
4516 fn configured_indent_can_raise_the_strict_flavor_minimum() {
4517 let rule = rule_with(&[("indent", toml::Value::Integer(6))]);
4520 let ctx = LintContext::new("- item\n\n wrap\n", MarkdownFlavor::MkDocs, None);
4521 let warnings = rule.check(&ctx).unwrap();
4522 assert_eq!(warnings.len(), 1);
4523 assert!(
4524 warnings[0].message.contains("needs 6 spaces"),
4525 "configured 6 must win over the 4-space floor: {}",
4526 warnings[0].message
4527 );
4528 assert_eq!(rule.fix(&ctx).unwrap(), "- item\n\n wrap\n");
4529 }
4530
4531 #[test]
4532 fn configured_indent_message_does_not_claim_a_structural_consequence() {
4533 let rule = rule_with(&[("indent", toml::Value::Integer(4))]);
4537 let ctx = LintContext::new("- item\n\n wrap\n", MarkdownFlavor::Standard, None);
4538 let warnings = rule.check(&ctx).unwrap();
4539 assert_eq!(warnings.len(), 1);
4540 assert!(
4541 warnings[0].message.contains("match the configured indent"),
4542 "expected the configured-indent wording, got: {}",
4543 warnings[0].message
4544 );
4545 assert!(
4546 !warnings[0].message.contains("remain part of the list"),
4547 "the content does remain part of the list here: {}",
4548 warnings[0].message
4549 );
4550
4551 assert!(check("- item\n\n wrap\n").is_empty());
4554
4555 let escaping = check("- item\n\n wrap\n");
4558 assert_eq!(escaping.len(), 1);
4559 assert!(
4560 escaping[0].message.contains("remain part of the list"),
4561 "unconfigured under-indent keeps its structural message, got: {}",
4562 escaping[0].message
4563 );
4564 }
4565
4566 #[test]
4567 fn configured_indent_leaves_tight_lazy_continuation_to_style() {
4568 let any = rule_with(&[("indent", toml::Value::Integer(4))]);
4572 let ctx = LintContext::new("- item\n wrap\n", MarkdownFlavor::Standard, None);
4573 assert!(
4574 any.check(&ctx).unwrap().is_empty(),
4575 "style = any accepts tight lazy continuation"
4576 );
4577
4578 let aligned = rule_with(&[
4579 ("indent", toml::Value::Integer(4)),
4580 ("style", toml::Value::String("aligned".to_string())),
4581 ]);
4582 let warnings = aligned.check(&ctx).unwrap();
4583 assert_eq!(warnings.len(), 1, "style = aligned raises it: {warnings:?}");
4584 assert!(warnings[0].message.contains("expected 4"));
4585 assert_eq!(aligned.fix(&ctx).unwrap(), "- item\n wrap\n");
4586 }
4587
4588 #[test]
4589 fn aligned_tight_underindented_fence_inside_item_left_alone() {
4590 let content = "- item\n ```\n code\n ```\n";
4594 assert!(check_aligned(content).is_empty());
4595 assert_eq!(fix_aligned(content), content);
4596 }
4597
4598 #[test]
4599 fn aligned_task_under_indent_fix_is_idempotent() {
4600 let content = "- [ ] Task\nwrap\n";
4601 let once = fix_aligned(content);
4602 assert_eq!(fix_aligned(&once), once);
4603 }
4604
4605 #[test]
4606 fn aligned_partial_indent_fix_is_idempotent() {
4607 let content = "- item\n continuation\n";
4608 let once = fix_aligned(content);
4609 assert_eq!(fix_aligned(&once), once);
4610 }
4611
4612 #[test]
4613 fn fence_shift_preserves_one_space_of_interior_nesting() {
4614 let content = "1. Configure:\n\n ```json\n {\n \"a\": 1\n }\n ```\n";
4617 let expected = "1. Configure:\n\n ```json\n {\n \"a\": 1\n }\n ```\n";
4618 assert_eq!(fix(content), expected);
4619 }
4620
4621 #[test]
4622 fn fence_shift_preserves_every_interior_nesting_level() {
4623 let content = "1. Configure:\n\n ```json\n {\n \"a\": {\n \"b\": 1\n }\n }\n ```\n";
4624 let expected = "1. Configure:\n\n ```json\n {\n \"a\": {\n \"b\": 1\n }\n }\n ```\n";
4625 assert_eq!(fix(content), expected);
4626 }
4627
4628 #[test]
4629 fn fence_shift_lifts_interior_below_the_list_scope_all_the_way() {
4630 let content = "1. Configure:\n\n ```json\n{\n ```\n";
4634 let expected = "1. Configure:\n\n ```json\n {\n ```\n";
4635 assert_eq!(fix(content), expected);
4636 assert_eq!(fix(expected), expected, "and the result is stable");
4637 }
4638
4639 #[test]
4640 fn fence_shift_is_idempotent() {
4641 for content in [
4642 "1. Configure:\n\n ```json\n {\n \"a\": 1\n }\n ```\n",
4643 "1. Configure:\n\n ```json\n{\n deep\n }\n ```\n",
4644 "- item\n\n ```\n nested\n ```\n",
4645 ] {
4646 let once = fix(content);
4647 assert_eq!(fix(&once), once, "MD077 fence fix must be idempotent: {content:?}");
4648 }
4649 }
4650
4651 #[test]
4652 fn fence_already_at_the_content_column_is_left_alone() {
4653 let content = "1. Configure:\n\n ```json\n {\n \"a\": 1\n }\n ```\n";
4654 assert!(check(content).is_empty());
4655 assert_eq!(fix(content), content);
4656 }
4657
4658 #[test]
4659 fn over_indented_fence_keeps_its_interior_untouched() {
4660 let content = "1. Configure:\n\n ```json\n {\n \"a\": 1\n }\n ```\n";
4662 assert_eq!(fix(content), content);
4663 }
4664
4665 fn assert_untouched(content: &str) {
4668 assert!(check(content).is_empty(), "{content:?}: {:?}", check(content));
4669 assert_eq!(fix(content), content, "{content:?}");
4670 assert!(
4671 check_aligned(content).is_empty(),
4672 "{content:?}: {:?}",
4673 check_aligned(content)
4674 );
4675 assert_eq!(fix_aligned(content), content, "{content:?} (aligned)");
4676 }
4677
4678 #[test]
4679 fn mkdocs_aligned_fix_settles_when_a_number_follows_re_indented_prose() {
4680 let content = "# \n`?`\n<span>x</span>\n- X\n\n29. y\n*z*";
4683 let once = fix_aligned_flavor(content, MarkdownFlavor::MkDocs);
4684 assert_eq!(
4685 fix_aligned_flavor(&once, MarkdownFlavor::MkDocs),
4686 once,
4687 "{content:?} -> {once:?}"
4688 );
4689 }
4690
4691 fn assert_fixed(content: &str, expected: &str) {
4693 assert_eq!(fix(content), expected, "{content:?}");
4694 assert_eq!(fix(expected), expected, "{content:?} is not idempotent");
4695 }
4696
4697 #[test]
4698 fn bare_marker_content_sits_one_column_past_the_marker() {
4699 for content in [
4700 "-\n a\n",
4701 "+\n a\n",
4702 "*\n a\n b\n",
4703 "1.\n a\n",
4704 "10.\n a\n",
4705 "1)\n a\n",
4706 "- x\n\n -\n a\n",
4707 "-\n foo\n-\n ```\n bar\n ```\n-\n baz\n",
4708 "-\n a\n\n b\n",
4709 ] {
4710 assert_untouched(content);
4711 }
4712 }
4713
4714 #[test]
4715 fn bare_marker_item_followed_by_a_fence_keeps_the_text_after_it() {
4716 assert_untouched("-\n ```\n x\n ```\n\n after\n");
4719 assert_untouched("1.\n ```\n x\n ```\n\n after\n");
4720 }
4721
4722 #[test]
4723 fn bare_marker_over_indent_still_snaps_back() {
4724 assert_fixed("-\n a\n", "-\n a\n");
4725 assert_fixed("1.\n a\n", "1.\n a\n");
4726 assert_untouched("-\n a\n");
4728 }
4729
4730 #[test]
4731 fn bare_marker_item_holds_at_most_one_blank_line_before_its_content() {
4732 for content in ["-\n\n a\n", "-\n\n a\n", "1.\n\n a\n", "-\n a\n"] {
4735 assert_untouched(content);
4736 }
4737 assert_fixed("-\n a\n\n b\n", "-\n a\n\n b\n");
4739 }
4740
4741 #[test]
4742 fn tab_after_the_marker_is_measured_in_columns() {
4743 assert_untouched("-\tx\n\n\ty\n");
4744 assert_untouched("1.\tx\n\n\ty\n");
4745 assert_untouched("-\tx\n\n y\n");
4746 assert_fixed("-\tx\n\n y\n", "-\tx\n\n y\n");
4747 assert_fixed("-\tx\n\n\t y\n", "-\tx\n\n y\n");
4748 }
4749
4750 #[test]
4751 fn indented_code_first_puts_the_content_one_column_past_the_marker() {
4752 assert_untouched("- t\n\n y\n");
4753 assert_untouched("- t\n\n y\n");
4754 assert_untouched("1. t\n\n y\n");
4755 assert_fixed("- t\n\n y\n", "- t\n\n y\n");
4756 assert_fixed("1. t\n\n y\n", "1. t\n\n y\n");
4757 }
4758
4759 #[test]
4760 fn checkbox_text_in_indented_code_is_not_a_task() {
4761 assert_fixed("- [ ] task\n\n y\n", "- [ ] task\n\n y\n");
4763 }
4764
4765 #[test]
4766 fn items_written_on_one_line_each_own_their_continuation() {
4767 for content in [
4768 "- 1. x\n\n y\n",
4769 "1. -\n f\n",
4770 "- 1. ```\n ```\n b\n",
4771 "- - a\n ---\n",
4772 "- - a\n\n b\n",
4773 "- 1. x\n\n y\n",
4774 ] {
4775 assert_untouched(content);
4776 }
4777 assert_fixed("- 1. x\n\n y\n", "- 1. x\n\n y\n");
4778 }
4779
4780 #[test]
4781 fn items_written_on_one_line_keep_their_task_columns() {
4782 for content in ["- - [ ] task\n wrap\n", "- - [ ] task\n wrap\n"] {
4783 assert_untouched(content);
4784 }
4785 assert_untouched("- [ ] t\n\n w\n");
4786 }
4787
4788 #[test]
4789 fn an_empty_inner_item_closes_at_the_blank_line() {
4790 assert_fixed("- -\n\n a\n", "- -\n\n a\n");
4792 assert_untouched("- -\n a\n\n b\n");
4794 }
4795
4796 #[test]
4797 fn text_that_commonmark_does_not_treat_as_a_marker_or_blank_is_content() {
4798 assert_fixed(
4800 "- \u{661}\u{662}\u{663}\u{664}. x\n\n y\n",
4801 "- \u{661}\u{662}\u{663}\u{664}. x\n\n y\n",
4802 );
4803 assert_untouched("- \u{a0}\n a\n\n y\n");
4804 assert_untouched("- -\n \u{a0}\n\n b\n");
4805 }
4806
4807 #[test]
4808 fn intended_reindents_still_happen() {
4809 assert_fixed("- e\n\n t\n", "- e\n\n t\n");
4810 assert_fixed("1. a\n\n b\n", "1. a\n\n b\n");
4811 assert_fixed("- a\n\n b\n", "- a\n\n b\n");
4812 assert_eq!(fix_aligned("- a\n b\n"), "- a\n b\n");
4813 assert_eq!(check("- e\n\n t\n").len(), 1);
4814 }
4815
4816 #[test]
4817 fn whitespace_at_the_start_of_a_line_inside_a_code_span_is_kept() {
4818 assert_untouched("- > para\n `a\n \tb`\n");
4819 assert_untouched("- x\n\n `a\n b`\n");
4820 assert_fixed("- x\n\n `a`\n b\n", "- x\n\n `a`\n b\n");
4822 }
4823}