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 let line_end = info.byte_offset + info.byte_len;
163 Self::is_code_fence(trimmed)
164 && ctx
165 .code_block_details
166 .iter()
167 .any(|b| b.is_fenced && b.start <= line_end && info.byte_offset < b.end)
168 }
169
170 fn deeper_quote_column(ctx: &NativeListSource<'_, '_>, info: &LineInfo, depth: usize) -> Option<usize> {
173 if info.blockquote.as_ref()?.nesting_level <= depth {
174 return None;
175 }
176 info.content(ctx.content).match_indices('>').nth(depth).map(|(i, _)| i)
179 }
180
181 fn opens_block_when_moved(trimmed: &str, content_col: usize, from: usize, to: usize) -> bool {
191 const MAX_BLOCK_INDENT: usize = 3;
192 from > content_col + MAX_BLOCK_INDENT
193 && to <= content_col + MAX_BLOCK_INDENT
194 && (Self::is_code_fence(trimmed)
195 || trimmed.starts_with('>')
196 || crate::lint_context::is_setext_underline_content(trimmed)
197 || crate::lint_context::is_horizontal_rule_content(trimmed)
198 || crate::utils::skip_context::is_table_line(trimmed)
199 || Self::is_atx_heading_start(trimmed))
200 }
201
202 fn is_atx_heading_start(trimmed: &str) -> bool {
205 let hashes = trimmed.bytes().take_while(|&b| b == b'#').count();
206 (1..=6).contains(&hashes) && matches!(trimmed.as_bytes().get(hashes), None | Some(b' ' | b'\t'))
207 }
208
209 fn starts_with_list_marker(trimmed: &str) -> bool {
213 let bytes = trimmed.as_bytes();
214 match bytes.first() {
215 Some(b'*' | b'-' | b'+') => bytes.get(1).is_some_and(|&b| b == b' ' || b == b'\t'),
216 Some(b'0'..=b'9') => {
217 let rest = trimmed.trim_start_matches(|c: char| c.is_ascii_digit());
218 rest.starts_with(". ") || rest.starts_with(") ")
219 }
220 _ => false,
221 }
222 }
223
224 fn fenced_block_last_line(ctx: &NativeListSource<'_, '_>, info: &LineInfo, trimmed: &str) -> Option<usize> {
229 if !Self::is_code_fence(trimmed) {
230 return None;
231 }
232 let line_end = info.byte_offset + info.byte_len;
233 ctx.code_block_details
234 .iter()
235 .find(|b| b.is_fenced && info.byte_offset <= b.start && b.start <= line_end)
236 .map(|b| ctx.offset_to_line_col(b.end.saturating_sub(1).max(b.start)).0)
237 }
238
239 fn build_compound_fence_fix(
277 ctx: &NativeListSource<'_, '_>,
278 opener_line: usize,
279 closer_line: usize,
280 opener_actual: usize,
281 required: usize,
282 ) -> Option<Fix> {
283 if required <= opener_actual {
284 return None;
285 }
286 let opener_info = ctx.line_info(opener_line)?;
287 let closer_info = ctx.line_info(closer_line)?;
288
289 let fix_start = opener_info.byte_offset;
290 let fix_end = closer_info.byte_offset + closer_info.byte_len;
291
292 let mut replacement = String::new();
293 for i in opener_line..=closer_line {
294 let info = ctx.line_info(i)?;
295 if i > opener_line {
296 replacement.push('\n');
297 }
298 let line = info.content(ctx.content);
299 if info.is_blank {
300 replacement.push_str(line);
302 } else {
303 let new_visual = if i == opener_line || i == closer_line {
304 required
305 } else {
306 (info.visual_indent + (required - opener_actual)).max(required)
307 };
308 for _ in 0..new_visual {
309 replacement.push(' ');
310 }
311 replacement.push_str(&line[info.indent..]);
312 }
313 }
314
315 Some(Fix::new(fix_start..fix_end, replacement))
316 }
317
318 fn walk_item_continuation<F>(ctx: &NativeListSource<'_, '_>, scope: &ItemScope, mut per_line: F)
344 where
345 F: FnMut(&ContinuationLine<'_>) -> ControlFlow<()>,
346 {
347 let item_line = scope.item_line;
348 let range_end = scope.range_end;
349 let marker_col = scope.marker_col;
350 let content_col = scope.content_col;
351 let mut saw_blank = false;
352 let mut nested_stack: Vec<(usize, usize, bool)> = scope
359 .same_line
360 .iter()
361 .map(|item| (item.marker_col, item.content_col, item.is_empty))
362 .collect();
363 let mut saw_nested = !nested_stack.is_empty();
364 let mut awaiting_content = scope.starts_empty;
368 let item_info = ctx.line_info(item_line);
369 let item_quotes = item_info
370 .and_then(|i| i.blockquote.as_ref())
371 .map_or(0, |q| q.nesting_level);
372
373 for line_num in (item_line + 1)..=range_end {
374 let Some(info) = ctx.line_info(line_num) else {
375 continue;
376 };
377
378 if Self::deeper_quote_column(ctx, info, item_quotes).is_some_and(|col| col < content_col) {
382 break;
383 }
384
385 let raw_line = info.content(ctx.content);
386 let trimmed = raw_line.trim_start();
387 let blank = Self::is_cm_blank(raw_line);
388
389 if awaiting_content {
391 if blank || info.visual_indent < content_col {
392 break;
393 }
394 awaiting_content = false;
395 }
396 if blank {
398 while nested_stack.last().is_some_and(|&(_, _, awaiting)| awaiting) {
399 nested_stack.pop();
400 }
401 } else {
402 while nested_stack
403 .last()
404 .is_some_and(|&(_, content, awaiting)| awaiting && content > info.visual_indent)
405 {
406 nested_stack.pop();
407 }
408 if let Some(top) = nested_stack.last_mut() {
409 top.2 = false;
410 }
411 }
412
413 if Self::should_skip_line(ctx, info, trimmed) {
414 continue;
415 }
416
417 if blank {
418 saw_blank = true;
419 continue;
420 }
421
422 if trimmed.is_empty() {
425 continue;
426 }
427
428 if let Some(ref li) = info.list_item {
429 let fallback = [CommonMarkItem {
432 marker_column: li.marker_column,
433 content_column: li.content_column,
434 is_empty: false,
435 }];
436 let chain: &[CommonMarkItem] = if li.commonmark_items.is_empty() {
437 &fallback
438 } else {
439 &li.commonmark_items
440 };
441 let first = chain[0];
442 if first.marker_column > marker_col && first.marker_column < content_col {
446 break;
447 }
448 if first.marker_column > marker_col {
449 while nested_stack.last().is_some_and(|&(m, _, _)| m >= first.marker_column) {
452 nested_stack.pop();
453 }
454 for item in chain {
455 nested_stack.push((item.marker_column, item.content_column, item.is_empty));
456 }
457 saw_nested = true;
462 } else {
463 nested_stack.clear();
464 }
465 saw_blank = false;
466 continue;
467 }
468
469 if info.heading.is_some() || info.is_setext_heading_text || info.is_horizontal_rule {
473 break;
474 }
475
476 if Self::is_block_level_construct(trimmed) {
477 continue;
478 }
479
480 let col = info.visual_indent;
481
482 if !saw_blank && col < content_col && !is_lazy_continuation(ctx, line_num - 1) {
490 break;
491 }
492
493 if !nested_stack.is_empty() && is_lazy_continuation(ctx, line_num - 1) {
497 continue;
498 }
499
500 while nested_stack.last().is_some_and(|&(_, c, _)| c > col) {
504 nested_stack.pop();
505 }
506 if !nested_stack.is_empty() {
507 continue;
508 }
509
510 if saw_blank && col <= marker_col {
511 break;
512 }
513
514 let line = ContinuationLine {
515 line_num,
516 info,
517 trimmed,
518 actual: col,
519 saw_blank,
520 saw_nested,
521 };
522 if per_line(&line).is_break() {
523 break;
524 }
525 }
526 }
527
528 fn item_range_has_latent_structure(ctx: &NativeListSource<'_, '_>, item_line: usize, range_end: usize) -> bool {
548 (item_line + 1..=range_end).any(|line_num| {
549 ctx.line_info(line_num).is_some_and(|info| {
550 if info.is_blank || info.list_item.is_some() {
551 return false;
552 }
553 let trimmed = info.content(ctx.content).trim_start();
554 !Self::should_skip_line(ctx, info, trimmed)
555 && (Self::starts_with_list_marker(trimmed)
556 || crate::utils::skip_context::is_table_line(trimmed)
557 || Self::is_latent_setext_underline(ctx, line_num, trimmed))
558 })
559 })
560 }
561
562 fn is_latent_setext_underline(ctx: &NativeListSource<'_, '_>, line_num: usize, trimmed: &str) -> bool {
576 crate::lint_context::is_setext_underline_content(trimmed)
577 && ctx.line_info(line_num - 1).is_some_and(|prev| {
578 prev.is_paragraph_context() && crate::lint_context::is_paragraph_text_line(prev.content(ctx.content))
579 })
580 }
581
582 fn sibling_column_usage(ctx: &NativeListSource<'_, '_>, scope: &ItemScope, task_col: usize) -> (bool, bool) {
592 let content_col = scope.content_col;
593 let mut uses_content = false;
594 let mut uses_task = false;
595
596 Self::walk_item_continuation(ctx, scope, |line| {
597 if line.actual == content_col {
598 uses_content = true;
599 }
600 if line.actual == task_col {
601 uses_task = true;
602 }
603 if uses_content && uses_task {
604 ControlFlow::Break(())
605 } else {
606 ControlFlow::Continue(())
607 }
608 });
609
610 (uses_content, uses_task)
611 }
612
613 fn compute_fix_target(
619 actual: usize,
620 required: usize,
621 task_col: Option<usize>,
622 uses_content_col: bool,
623 uses_task_col: bool,
624 ) -> usize {
625 let Some(t) = task_col else { return required };
626 match actual.abs_diff(t).cmp(&actual.abs_diff(required)) {
627 std::cmp::Ordering::Less => t,
628 std::cmp::Ordering::Greater => required,
629 std::cmp::Ordering::Equal => match (uses_task_col, uses_content_col) {
630 (true, false) => t,
631 _ => required,
632 },
633 }
634 }
635
636 fn fence_lines(
638 ctx: &NativeListSource<'_, '_>,
639 line_count: usize,
640 ) -> (std::collections::BTreeSet<usize>, std::collections::BTreeSet<usize>) {
641 let last_line = line_count.saturating_sub(1);
642 let line_of = |offset: usize| ctx.offset_to_line_col(offset).0.saturating_sub(1).min(last_line);
643 let mut openers = std::collections::BTreeSet::new();
644 let mut inside = std::collections::BTreeSet::new();
645 for block in ctx.code_block_details.iter().filter(|b| b.is_fenced) {
646 let first = line_of(block.start);
647 let last = line_of(block.end.saturating_sub(1).max(block.start));
648 openers.insert(first);
649 inside.extend(first + 1..=last);
650 }
651 (openers, inside)
652 }
653
654 fn parser_marker_lines(ctx: &NativeListSource<'_, '_>) -> Option<Vec<bool>> {
656 use crate::utils::python_markdown_lists::{Source, marker_lines};
657 let lines: Vec<&str> = ctx.content.split('\n').collect();
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 let admonition = lines.iter().any(|l| {
674 let t = l.trim_start();
675 t.starts_with("!!! ") || t.starts_with("??? ") || t.starts_with("???+ ")
676 });
677 (!admonition).then_some(markers)
678 }
679
680 fn is_plain_prose(line: &str) -> bool {
683 let t = line.trim();
684 if t.is_empty() {
685 return false;
686 }
687 let first = t.chars().next().unwrap_or(' ');
688 if matches!(first, '>' | '#' | ':' | '|' | '<' | '[' | '=' | '-' | '*' | '_' | '+') {
689 return false;
690 }
691 !t.contains('|') && !t.chars().next().is_some_and(|c| c.is_ascii_digit())
692 }
693
694 fn drop_unsafe_fixes(
707 ctx: &NativeListSource<'_, '_>,
708 warnings: &mut [LintWarning],
709 owners: &std::collections::HashMap<usize, usize>,
710 ) {
711 use crate::utils::python_markdown_lists::{Source, differing_regions, line_regions};
712 use std::collections::BTreeSet;
713
714 let mut live: Vec<usize> = (0..warnings.len())
717 .filter(|&i| warnings[i].fix.is_some() && !ctx.is_rule_disabled("MD077", warnings[i].line))
718 .collect();
719 if live.is_empty() {
720 return;
721 }
722 let blank_front_matter = |lines: &mut Vec<&str>| {
724 for line in lines.iter_mut().take(ctx.front_matter_end_line()) {
725 *line = "";
726 }
727 };
728 let mut lines: Vec<&str> = ctx.content.split('\n').collect();
729 blank_front_matter(&mut lines);
730 let line_of = |offset: usize| {
731 ctx.offset_to_line_col(offset)
732 .0
733 .saturating_sub(1)
734 .min(lines.len().saturating_sub(1))
735 };
736 let (openers, inside) = Self::fence_lines(ctx, lines.len());
737 let before = Source {
738 lines: &lines,
739 fence_openers: &openers,
740 fence_inside: &inside,
741 };
742 let region_of = line_regions(&before);
743 let touched = |fix: &Fix| -> BTreeSet<usize> {
744 (line_of(fix.range.start)..=line_of(fix.range.end.max(fix.range.start)))
745 .map(|l| region_of[l])
746 .collect()
747 };
748
749 const MAX_ROUNDS: usize = 4;
750 for _ in 0..MAX_ROUNDS {
751 let mut ranges: Vec<(usize, &Fix)> = live
752 .iter()
753 .filter_map(|&i| warnings[i].fix.as_ref().map(|f| (i, f)))
754 .collect();
755 ranges.sort_by_key(|(_, f)| std::cmp::Reverse(f.range.start));
756 let mut fixed = ctx.content.to_string();
757 let mut floor = usize::MAX;
758 let mut overlapping = Vec::new();
759 for (i, fix) in ranges {
760 if fix.range.end <= floor && fix.range.end <= fixed.len() {
761 fixed.replace_range(fix.range.clone(), &fix.replacement);
762 floor = fix.range.start;
763 } else {
764 overlapping.push(i);
765 }
766 }
767 if !overlapping.is_empty() {
770 for &i in &overlapping {
771 warnings[i].fix = None;
772 }
773 live.retain(|i| !overlapping.contains(i));
774 if live.is_empty() {
775 return;
776 }
777 continue;
778 }
779 let mut after_lines: Vec<&str> = fixed.split('\n').collect();
780 blank_front_matter(&mut after_lines);
781 let after = Source {
782 lines: &after_lines,
783 fence_openers: &openers,
784 fence_inside: &inside,
785 };
786 let mut owner_of_line: std::collections::HashMap<usize, usize> = std::collections::HashMap::new();
789 for &i in &live {
790 let (Some(fix), Some(&owner)) = (warnings[i].fix.as_ref(), owners.get(&warnings[i].line)) else {
791 continue;
792 };
793 for l in line_of(fix.range.start)..=line_of(fix.range.end.max(fix.range.start)) {
794 owner_of_line.insert(l, owner.saturating_sub(1));
795 }
796 }
797 let bad = match differing_regions(&before, &after, &owner_of_line) {
798 Some(bad) if bad.is_empty() => return,
799 Some(bad) => bad,
800 None => break,
801 };
802 let kept: Vec<usize> = live
803 .iter()
804 .copied()
805 .filter(|&i| warnings[i].fix.as_ref().is_some_and(|f| touched(f).is_disjoint(&bad)))
806 .collect();
807 if kept.len() == live.len() {
808 break;
809 }
810 for &i in live.iter().filter(|i| !kept.contains(i)) {
811 warnings[i].fix = None;
812 }
813 live = kept;
814 if live.is_empty() {
815 return;
816 }
817 }
818 for i in live {
819 warnings[i].fix = None;
820 }
821 }
822
823 fn is_python_markdown_code(ctx: &NativeListSource<'_, '_>, scope: &ItemScope, line: &ContinuationLine<'_>) -> bool {
830 (scope.code_column.is_some_and(|column| line.actual >= column)
833 && ctx
834 .line_info(line.line_num - 1)
835 .is_some_and(|prev| Self::is_cm_blank(prev.content(ctx.content))))
836 || Self::is_python_markdown_nested_content(scope, line)
837 }
838
839 fn is_python_markdown_nested_content(scope: &ItemScope, line: &ContinuationLine<'_>) -> bool {
846 let above = scope
847 .nested
848 .partition_point(|&(item_line, ..)| item_line < line.line_num);
849 above
850 .checked_sub(1)
851 .and_then(|last| scope.nested.get(last))
852 .is_some_and(|&(_, column, closed_at)| line.actual >= column && line.line_num < closed_at)
853 }
854
855 fn should_skip_line(ctx: &NativeListSource<'_, '_>, info: &LineInfo, trimmed: &str) -> bool {
866 if info.in_code_block && !Self::is_fence_delimiter(ctx, info, trimmed) {
867 return true;
868 }
869 let structural = || {
870 info.in_front_matter
871 || info.in_footnote_definition
872 || info.in_html_block
873 || info.in_html_comment
874 || info.in_mdx_comment
875 || info.in_mkdocstrings
876 || info.in_esm_block
877 || info.in_math_block
878 || info.in_admonition
879 || info.in_content_tab
880 || info.in_pymdown_block
881 || info.in_definition_list
882 || info.in_mkdocs_html_markdown
883 || info.in_kramdown_extension_block
884 };
885 let protected = || {
886 ctx.is_byte_offset_in_code_span(info.byte_offset)
887 || ctx.is_in_code_span_byte_with_or_without_math(info.byte_offset)
888 };
889 structural() || protected()
895 }
896
897 fn build_over_indent_warning(
906 ctx: &NativeListSource<'_, '_>,
907 line: &ContinuationLine<'_>,
908 fix_target: usize,
909 message: String,
910 ) -> LintWarning {
911 let line_content = line.info.content(ctx.content);
912 let fix_start = line.info.byte_offset;
913 let fix_end = fix_start + line.info.indent;
914 LintWarning {
915 rule_name: Some("MD077".to_string()),
916 line: line.line_num,
917 column: 1,
918 end_line: line.line_num,
919 end_column: line_content.chars().count() + 1,
920 message,
921 severity: Severity::Warning,
922 fix: Some(Fix::new(fix_start..fix_end, " ".repeat(fix_target))),
923 }
924 }
925
926 fn build_under_indent_warning(
938 ctx: &NativeListSource<'_, '_>,
939 line: &ContinuationLine<'_>,
940 required: usize,
941 message: String,
942 ) -> UnderIndentOutcome {
943 let line_content = line.info.content(ctx.content);
944 let fence_closer = Self::fenced_block_last_line(ctx, line.info, line.trimmed);
945
946 let (fix, warn_end_line, warn_end_column, compound_closer) = if let Some(closer_line) = fence_closer {
947 let fix = Self::build_compound_fence_fix(ctx, line.line_num, closer_line, line.actual, required);
948 let end_column = ctx
949 .line_info(closer_line)
950 .map_or(line_content.chars().count() + 1, |ci| {
951 ci.content(ctx.content).chars().count() + 1
952 });
953 let extra_flag = (closer_line != line.line_num).then_some(closer_line);
954 (fix, closer_line, end_column, extra_flag)
955 } else {
956 let fix_start = line.info.byte_offset;
957 let fix_end = fix_start + line.info.indent;
958 let fix = Some(Fix::new(fix_start..fix_end, " ".repeat(required)));
959 (fix, line.line_num, line_content.chars().count() + 1, None)
960 };
961
962 UnderIndentOutcome {
963 warning: LintWarning {
964 rule_name: Some("MD077".to_string()),
965 line: line.line_num,
966 column: 1,
967 end_line: warn_end_line,
968 end_column: warn_end_column,
969 message,
970 severity: Severity::Warning,
971 fix,
972 },
973 also_flag_line: compound_closer,
974 }
975 }
976}
977
978#[derive(Clone, Copy)]
981struct SameLineItem {
982 marker_col: usize,
983 content_col: usize,
984 is_empty: bool,
986}
987
988struct ItemScope {
990 item_line: usize,
991 marker_col: usize,
992 content_col: usize,
993 task_col: Option<usize>,
995 required: usize,
997 tight_only: bool,
1000 code_column: Option<usize>,
1003 nested: Vec<(usize, usize, usize)>,
1007 range_end: usize,
1009 starts_empty: bool,
1012 same_line: Vec<SameLineItem>,
1014}
1015
1016struct ContinuationLine<'a> {
1020 line_num: usize,
1021 info: &'a LineInfo,
1022 trimmed: &'a str,
1023 actual: usize,
1024 saw_blank: bool,
1025 saw_nested: bool,
1029}
1030
1031struct UnderIndentOutcome {
1036 warning: LintWarning,
1037 also_flag_line: Option<usize>,
1038}
1039
1040impl MD077ListContinuationIndent {
1041 pub(crate) fn apply_native_fixes(content: &str, mut fixes: Vec<Fix>) -> String {
1044 fixes.sort_by_key(|fix| std::cmp::Reverse(fix.range.start));
1045 let mut content = content.to_string();
1046 for fix in fixes {
1047 if fix.range.start <= content.len() && fix.range.end <= content.len() {
1048 content.replace_range(fix.range, &fix.replacement);
1049 }
1050 }
1051 content
1052 }
1053
1054 pub(crate) fn check_native_source(&self, ctx: &NativeListSource<'_, '_>) -> Vec<LintWarning> {
1055 if ctx.content.is_empty() {
1056 return Vec::new();
1057 }
1058
1059 let strict_indent = ctx.flavor.requires_strict_list_indent();
1060 let total_lines = ctx.lines.len();
1061 let mut warnings = Vec::new();
1062 let mut owners: std::collections::HashMap<usize, usize> = std::collections::HashMap::new();
1064 let mut flagged_lines = std::collections::HashSet::new();
1065
1066 let mut items: Vec<(usize, CommonMarkItem, Option<usize>)> = Vec::new();
1075 let mut chains: Vec<(usize, Vec<CommonMarkItem>)> = Vec::new();
1078 let mut block_of_line: std::collections::HashMap<usize, usize> = std::collections::HashMap::new();
1081 for (block_idx, block) in ctx.list_blocks.iter().enumerate() {
1082 for &item_line in &block.item_lines {
1083 block_of_line.insert(item_line, block_idx);
1084 if let Some(info) = ctx.line_info(item_line)
1085 && let Some(ref li) = info.list_item
1086 {
1087 if info.blockquote.is_some() {
1094 continue;
1095 }
1096 if li.commonmark_items.is_empty() {
1100 continue;
1101 }
1102 let line = info.content(ctx.content);
1103 chains.push((item_line, li.commonmark_items.clone()));
1104 for item in &li.commonmark_items {
1105 let task_col = Self::is_task_at_column(line, item.content_column)
1106 .then_some(item.content_column + Self::TASK_CHECKBOX_PREFIX_LEN);
1107 items.push((item_line, *item, task_col));
1108 }
1109 }
1110 }
1111 }
1112
1113 if items.is_empty() {
1115 return warnings;
1116 }
1117
1118 chains.sort_by_key(|&(line, _)| line);
1119 chains.dedup_by_key(|&mut (line, _)| line);
1120 items.sort_unstable_by_key(|&(line, item, _)| (line, item.marker_column));
1121 items.dedup_by_key(|&mut (line, item, _)| (line, item.marker_column));
1122
1123 let mut range_ends = vec![total_lines; items.len()];
1136 let mut stack: Vec<usize> = Vec::new();
1137 for i in (0..items.len()).rev() {
1138 let marker_col = items[i].1.marker_column;
1139 while let Some(&top) = stack.last() {
1140 if items[top].1.marker_column > marker_col {
1141 stack.pop();
1142 } else {
1143 break;
1144 }
1145 }
1146 range_ends[i] = stack.last().map_or(total_lines, |&j| items[j].0 - 1);
1147 stack.push(i);
1148 }
1149
1150 let mut pm_levels: Vec<usize> = Vec::with_capacity(items.len());
1160 let mut pm_items: Vec<bool> = Vec::with_capacity(items.len());
1161 let mut shared_line: Vec<bool> = Vec::with_capacity(items.len());
1162 let parser_markers: Option<Vec<bool>> = strict_indent.then(|| Self::parser_marker_lines(ctx)).flatten();
1166 let mut head_levels: Vec<usize> = Vec::with_capacity(items.len());
1168 let mut open: Vec<usize> = Vec::new();
1169 for (i, &(item_line, item, _)) in items.iter().enumerate() {
1170 let block = block_of_line.get(&item_line);
1171 while open.last().is_some_and(|&top| {
1172 items[top].1.marker_column >= item.marker_column || block_of_line.get(&items[top].0) != block
1173 }) {
1174 open.pop();
1175 }
1176 let (level, is_item) = match open.last() {
1177 Some(&parent) if items[parent].0 == item_line => (pm_levels[parent] + 1, pm_items[parent]),
1178 Some(&parent) => {
1179 let level = item.marker_column / 4;
1180 let after_blank = ctx
1181 .line_info(item_line - 1)
1182 .is_some_and(|prev| Self::is_cm_blank(prev.content(ctx.content)));
1183 let anchor = if pm_items[parent] {
1187 Some(Some(parent))
1188 } else if after_blank {
1189 Some(open.iter().rev().copied().find(|&above| pm_items[above]))
1190 } else {
1191 None
1192 };
1193 let is_item = match anchor {
1194 None => false,
1195 Some(None) => level <= items[parent].1.marker_column / 4,
1198 Some(Some(above)) if shared_line[above] => {
1202 level <= head_levels[above] + 1 || (after_blank && level <= pm_levels[above] + 1)
1203 }
1204 Some(Some(above)) => level <= items[above].1.marker_column / 4 + 1,
1205 };
1206 (level, is_item)
1207 }
1208 None => (item.marker_column / 4, true),
1209 };
1210 let raw = ctx.line_info(item_line).map(|info| info.content(ctx.content));
1213 let bare = item.is_empty && raw.is_some_and(|line| !line.ends_with([' ', '\t']));
1214 let paren = raw.is_some_and(|line| Self::is_paren_marker(line, item.marker_column));
1215 let shared = open.last().filter(|&&parent| items[parent].0 == item_line);
1216 shared_line.push(shared.is_some());
1217 head_levels.push(shared.map_or(level, |&parent| head_levels[parent]));
1218 let in_quote = ctx.line_info(item_line).is_some_and(|info| info.blockquote.is_some());
1219 let read_as_marker = in_quote
1220 || parser_markers
1221 .as_ref()
1222 .is_none_or(|markers| markers.get(item_line - 1).copied().unwrap_or(false));
1223 let is_pm_item = is_item && read_as_marker && !bare && !paren;
1224 pm_levels.push(level);
1225 pm_items.push(is_pm_item);
1226 open.push(i);
1227 }
1228
1229 let prose_candidate_lines: Vec<usize> = (1..=total_lines)
1243 .filter(|&line_num| {
1244 let Some(info) = ctx.line_info(line_num) else {
1245 return false;
1246 };
1247 let trimmed = info.content(ctx.content).trim_start();
1248 !Self::should_skip_line(ctx, info, trimmed)
1249 && !info.is_blank
1250 && info.list_item.is_none()
1251 && info.heading.is_none()
1252 && !info.is_setext_heading_text
1253 && !info.is_horizontal_rule
1254 && !Self::is_block_level_construct(trimmed)
1255 })
1256 .collect();
1257 let range_has_prose_candidate = |after_line: usize, range_end: usize| -> bool {
1260 let start = prose_candidate_lines.partition_point(|&l| l <= after_line);
1261 prose_candidate_lines.get(start).is_some_and(|&l| l <= range_end)
1262 };
1263
1264 let closes: Vec<std::cell::OnceCell<usize>> = items.iter().map(|_| std::cell::OnceCell::new()).collect();
1271 let close_of = |idx: usize| -> usize {
1272 *closes[idx].get_or_init(|| {
1273 let item_line = items[idx].0;
1274 let column = 4 * (pm_levels[idx] + 1);
1275 (item_line + 1..=total_lines)
1276 .find(|&line_num| {
1277 ctx.line_info(line_num).is_some_and(|info| {
1278 info.visual_indent < column && !Self::is_cm_blank(info.content(ctx.content))
1279 })
1280 })
1281 .unwrap_or(total_lines + 1)
1282 })
1283 };
1284 let scoped: Vec<ItemScope> = items
1285 .iter()
1286 .enumerate()
1287 .filter(|&(item_idx, _)| !strict_indent || pm_items[item_idx])
1288 .map(|(item_idx, &(item_line, item, task_col))| {
1289 let marker_col = item.marker_column;
1290 let content_col = item.content_column;
1291 let level_column = 4 * (pm_levels[item_idx] + 1);
1302 let required = match self.config.indent {
1303 Some(indent) if strict_indent => (marker_col + indent).max(level_column),
1304 Some(indent) => marker_col + indent,
1305 None if strict_indent => content_col.max(level_column),
1306 None => content_col,
1307 };
1308 let tight_only = strict_indent && required >= level_column + 4;
1314 let same_line = chains
1316 .binary_search_by_key(&item_line, |(line, _)| *line)
1317 .ok()
1318 .map(|at| &chains[at].1)
1319 .map(|chain| {
1320 chain
1321 .iter()
1322 .filter(|inner| inner.marker_column > marker_col)
1323 .map(|inner| SameLineItem {
1324 marker_col: inner.marker_column,
1325 content_col: inner.content_column,
1326 is_empty: inner.is_empty,
1327 })
1328 .collect()
1329 })
1330 .unwrap_or_default();
1331 ItemScope {
1332 item_line,
1333 marker_col,
1334 content_col,
1335 task_col,
1336 required,
1337 tight_only,
1338 code_column: strict_indent.then_some(level_column + 4),
1339 nested: if strict_indent && range_has_prose_candidate(item_line, range_ends[item_idx]) {
1340 items[item_idx + 1..]
1341 .iter()
1342 .enumerate()
1343 .take_while(|&(_, &(line, _, _))| line <= range_ends[item_idx])
1344 .map(|(offset, &(line, _, _))| (line, item_idx + 1 + offset))
1345 .filter(|&(_, other)| pm_items[other])
1346 .map(|(line, other)| (line, 4 * (pm_levels[other] + 1), close_of(other)))
1347 .collect()
1348 } else {
1349 Vec::new()
1350 },
1351 range_end: range_ends[item_idx],
1352 starts_empty: item.is_empty,
1353 same_line,
1354 }
1355 })
1356 .collect();
1357
1358 let aligned = self.config.style == ContinuationStyle::Aligned;
1385 for scope in &scoped {
1386 let (item_line, marker_col, required, range_end) =
1387 (scope.item_line, scope.marker_col, scope.required, scope.range_end);
1388 if !range_has_prose_candidate(item_line, range_end) {
1391 continue;
1392 }
1393 let has_latent_structure = aligned && Self::item_range_has_latent_structure(ctx, item_line, range_end);
1408 let from_configured_indent = self.config.indent.is_some_and(|indent| marker_col + indent == required);
1414 Self::walk_item_continuation(ctx, scope, |line| {
1415 let actual = line.actual;
1416 if Self::is_python_markdown_code(ctx, scope, line) || (scope.tight_only && line.saw_blank) {
1417 return ControlFlow::Continue(());
1418 }
1419 let under_indented = actual < required;
1420 let loose_escape = line.saw_blank && under_indented;
1421 let confirmed_structure = line.info.in_code_block || line.info.blockquote.is_some();
1428 let aligned_tight = aligned
1429 && !has_latent_structure
1430 && !line.saw_blank
1431 && !line.saw_nested
1432 && under_indented
1433 && !confirmed_structure;
1434 if (loose_escape || aligned_tight) && flagged_lines.insert(line.line_num) {
1435 let message = if line.saw_blank {
1436 if from_configured_indent {
1437 format!(
1438 "Content after blank line in list item needs {required} spaces of \
1439 indentation to match the configured indent (found {actual})",
1440 )
1441 } else if strict_indent {
1442 format!(
1443 "Content inside list item needs {required} spaces of indentation \
1444 for MkDocs compatibility (found {actual})",
1445 )
1446 } else {
1447 format!(
1448 "Content after blank line in list item needs {required} spaces of \
1449 indentation to remain part of the list (found {actual})",
1450 )
1451 }
1452 } else {
1453 format!("Continuation line under-indented (expected {required}, found {actual})")
1454 };
1455 let outcome = Self::build_under_indent_warning(ctx, line, required, message);
1456 if let Some(closer_line) = outcome.also_flag_line {
1457 flagged_lines.insert(closer_line);
1458 }
1459 owners.insert(outcome.warning.line, item_line);
1460 warnings.push(outcome.warning);
1461 }
1462 ControlFlow::Continue(())
1463 });
1464 }
1465
1466 for scope in &scoped {
1475 let (item_line, content_col, task_col, required, range_end) = (
1476 scope.item_line,
1477 scope.content_col,
1478 scope.task_col,
1479 scope.required,
1480 scope.range_end,
1481 );
1482 if !range_has_prose_candidate(item_line, range_end) {
1484 continue;
1485 }
1486 let (uses_content_col, uses_task_col) = match task_col {
1490 Some(t) => Self::sibling_column_usage(ctx, scope, t),
1491 None => (false, false),
1492 };
1493
1494 Self::walk_item_continuation(ctx, scope, |line| {
1495 let actual = line.actual;
1496 if Self::is_python_markdown_code(ctx, scope, line) || (scope.tight_only && line.saw_blank) {
1497 return ControlFlow::Continue(());
1498 }
1499 if actual <= required {
1500 return ControlFlow::Continue(());
1501 }
1502 let fix_target = Self::compute_fix_target(actual, required, task_col, uses_content_col, uses_task_col);
1503 if !line.info.in_code_block
1504 && Some(actual) != task_col
1505 && !Self::starts_with_list_marker(line.trimmed)
1506 && !Self::opens_block_when_moved(line.trimmed, content_col, actual, fix_target)
1507 && flagged_lines.insert(line.line_num)
1508 {
1509 let message = match task_col {
1510 Some(t) => format!(
1511 "Continuation line over-indented \
1512 (expected {required} or {t}, found {actual})"
1513 ),
1514 None => {
1515 format!("Continuation line over-indented (expected {required}, found {actual})")
1516 }
1517 };
1518 let warning = Self::build_over_indent_warning(ctx, line, fix_target, message);
1519 owners.insert(warning.line, item_line);
1520 warnings.push(warning);
1521 }
1522 ControlFlow::Continue(())
1523 });
1524 }
1525
1526 warnings.sort_by_key(|w| (w.line, w.column));
1529
1530 if strict_indent {
1531 Self::drop_unsafe_fixes(ctx, &mut warnings, &owners);
1532 }
1533
1534 warnings
1535 }
1536}
1537
1538impl Rule for MD077ListContinuationIndent {
1539 fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
1540 (self.config == Default::default()).then_some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(77))
1541 }
1542
1543 fn name(&self) -> &'static str {
1544 "MD077"
1545 }
1546
1547 fn description(&self) -> &'static str {
1548 "List continuation content indentation"
1549 }
1550
1551 fn check(&self, ctx: &LintContext) -> LintResult {
1552 Ok(self.check_native_source(&ctx.native_list_source()))
1553 }
1554
1555 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
1556 let warnings = self.check(ctx)?;
1557 let warnings =
1558 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
1559 if warnings.is_empty() {
1560 return Ok(ctx.content.to_string());
1561 }
1562
1563 let fixes: Vec<Fix> = warnings.into_iter().filter_map(|w| w.fix).collect();
1564 Ok(Self::apply_native_fixes(ctx.content, fixes))
1565 }
1566
1567 fn fix_with_diagnostics(&self, ctx: &LintContext, warnings: &[LintWarning]) -> Result<String, LintError> {
1568 let fixes = warnings
1569 .iter()
1570 .filter(|warning| crate::utils::fix_utils::warning_is_enabled(warning, ctx.inline_config(), self.name()))
1571 .filter_map(|warning| warning.fix.clone())
1572 .collect();
1573 Ok(Self::apply_native_fixes(ctx.content, fixes))
1574 }
1575
1576 fn category(&self) -> RuleCategory {
1577 RuleCategory::List
1578 }
1579
1580 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
1581 ctx.content.is_empty() || ctx.list_blocks.is_empty()
1582 }
1583
1584 fn as_any(&self) -> &dyn std::any::Any {
1585 self
1586 }
1587
1588 crate::impl_rule_config_methods!(MD077Config);
1589}
1590
1591#[cfg(test)]
1592mod tests {
1593 use super::*;
1594 use crate::config::MarkdownFlavor;
1595
1596 fn check(content: &str) -> Vec<LintWarning> {
1597 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1598 let rule = MD077ListContinuationIndent::default();
1599 rule.check(&ctx).unwrap()
1600 }
1601
1602 fn check_mkdocs(content: &str) -> Vec<LintWarning> {
1603 let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
1604 let rule = MD077ListContinuationIndent::default();
1605 rule.check(&ctx).unwrap()
1606 }
1607
1608 fn fix(content: &str) -> String {
1609 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1610 let rule = MD077ListContinuationIndent::default();
1611 rule.fix(&ctx).unwrap()
1612 }
1613
1614 fn fix_mkdocs(content: &str) -> String {
1615 let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
1616 let rule = MD077ListContinuationIndent::default();
1617 rule.fix(&ctx).unwrap()
1618 }
1619
1620 #[test]
1621 fn test_blockquote_line_ends_the_item() {
1622 for content in ["1. item\n>\n continuation text\n", "- item\n> quote\n\n text\n"] {
1625 assert!(check(content).is_empty(), "{content:?}: {:?}", check(content));
1626 assert_eq!(fix(content), content);
1627 }
1628 let inside = "- item\n\n > quote\n\n text\n";
1630 assert_eq!(check(inside).len(), 1);
1631 assert_eq!(fix(inside), "- item\n\n > quote\n\n text\n");
1632 }
1633
1634 #[test]
1635 fn test_fence_after_an_indented_code_block_moves_as_one_block() {
1636 let content = " 2) item\n\n ```\n ```\n code\n ```\n";
1639 let fixed = fix(content);
1640 assert_eq!(fixed, " 2) item\n\n ```\n ```\n code\n ```\n");
1641 assert_eq!(fix(&fixed), fixed);
1642 }
1643
1644 #[test]
1645 fn test_marker_left_of_the_content_column_ends_the_item() {
1646 let content = "* a\n 1. b\n\n text\n";
1649 let warnings = check(content);
1650 assert_eq!(warnings.len(), 1, "{warnings:?}");
1651 let message = &warnings[0].message;
1652 assert!(message.contains("needs 5 spaces"), "{message}");
1653 let fixed = fix(content);
1654 assert_eq!(fixed, "* a\n 1. b\n\n text\n");
1655 assert_eq!(fix(&fixed), fixed);
1656
1657 let outside = "- a\n 1. b\n\n text\n";
1659 assert!(check(outside).is_empty(), "{:?}", check(outside));
1660 }
1661
1662 #[test]
1663 fn test_fence_text_in_indented_code_is_not_a_fence() {
1664 for content in [
1668 " * item\n ```\n ```\n\n ```\n",
1669 " 1. item\n - item\n\n ```\n",
1670 ] {
1671 assert!(check(content).is_empty(), "{content:?}: {:?}", check(content));
1672 assert_eq!(fix(content), content);
1673 }
1674 }
1675
1676 fn aligned_rule() -> MD077ListContinuationIndent {
1677 MD077ListContinuationIndent::new(ContinuationStyle::Aligned)
1678 }
1679
1680 fn check_aligned(content: &str) -> Vec<LintWarning> {
1681 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1682 aligned_rule().check(&ctx).unwrap()
1683 }
1684
1685 fn check_aligned_mkdocs(content: &str) -> Vec<LintWarning> {
1686 let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
1687 aligned_rule().check(&ctx).unwrap()
1688 }
1689
1690 fn fix_aligned(content: &str) -> String {
1691 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1692 aligned_rule().fix(&ctx).unwrap()
1693 }
1694
1695 fn fix_aligned_quarto(content: &str) -> String {
1696 let ctx = LintContext::new(content, MarkdownFlavor::Quarto, None);
1697 aligned_rule().fix(&ctx).unwrap()
1698 }
1699
1700 #[test]
1701 fn html_block_opened_by_an_inline_level_tag_is_left_alone() {
1702 let content = "- item\n\n <math display=\"block\">\n <mi>x</mi>\n </math>\n";
1705 assert!(check(content).is_empty(), "{:?}", check(content));
1706 assert_eq!(fix(content), content);
1707 }
1708
1709 #[test]
1710 fn html_block_in_an_item_is_left_alone_past_a_broken_tag() {
1711 let content = "- item\n\n <table>\n <td>\n <a\n >a</a\n >\n text after\n </td>\n </table>\n";
1714 assert!(check(content).is_empty(), "{:?}", check(content));
1715 assert_eq!(fix(content), content);
1716 }
1717
1718 #[test]
1719 fn lazy_continuation_does_not_close_the_nested_item() {
1720 for content in [
1725 "* a\n\n * b\n lazy\n\n para\n",
1726 "* a\n\n * b\n lazy\n\n ```\n x\n ```\n\n para\n",
1727 "* a\n\n * b\n lazy\n lazy again\n\n para\n",
1728 ] {
1729 assert!(check(content).is_empty(), "{content:?}: {:?}", check(content));
1730 assert_eq!(fix(content), content);
1731 }
1732 }
1733
1734 #[test]
1735 fn paragraph_text_that_would_open_a_block_keeps_its_indent() {
1736 for construct in [
1741 "```",
1742 "```rust",
1743 "~~~",
1744 "> q",
1745 "---",
1746 "===",
1747 "***",
1748 "_ _ _",
1749 "| x | y |\n | - | - |",
1750 "# A",
1751 "###### A",
1752 "## A ##",
1753 "#\tA",
1754 "#",
1755 ] {
1756 let content = format!("- a\n {construct}\n");
1757 assert!(check(&content).is_empty(), "{content:?}: {:?}", check(&content));
1758 assert_eq!(fix(&content), content);
1759 }
1760 }
1761
1762 #[test]
1763 fn hash_text_that_is_no_atx_heading_is_still_snapped_back() {
1764 for text in ["#A", "####### A", "#5"] {
1765 let content = format!("- a\n {text}\n");
1766 assert_eq!(check(&content).len(), 1, "{content:?}");
1767 assert_eq!(fix(&content), format!("- a\n {text}\n"));
1768 }
1769 }
1770
1771 #[test]
1772 fn paragraph_text_past_the_threshold_is_still_snapped_back() {
1773 let content = "- a\n text\n";
1774 assert_eq!(check(content).len(), 1);
1775 assert_eq!(fix(content), "- a\n text\n");
1776 let rule = MD077ListContinuationIndent::from_config_struct(MD077Config {
1779 indent: Some(6),
1780 ..Default::default()
1781 });
1782 let content = "- a\n > q\n";
1783 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1784 assert_eq!(rule.fix(&ctx).unwrap(), "- a\n > q\n");
1785 let content = "- a\n\n > q\n";
1788 assert_eq!(check(content).len(), 1);
1789 assert_eq!(fix(content), "- a\n\n > q\n");
1790 }
1791
1792 #[test]
1793 fn over_indent_after_a_lazy_line_is_measured_against_the_nested_item() {
1794 let content = "* a\n\n * b\n lazy\n\n para\n";
1795 let warnings = check(content);
1796 assert_eq!(warnings.len(), 1, "{warnings:?}");
1797 assert_eq!(warnings[0].line, 6);
1798 assert!(
1799 warnings[0].message.contains("expected 4, found 7"),
1800 "{}",
1801 warnings[0].message
1802 );
1803 assert_eq!(fix(content), "* a\n\n * b\n lazy\n\n para\n");
1804 }
1805
1806 #[test]
1807 fn aligned_idempotent_with_latent_marker_behind_unstable_heading() {
1808 let input = "1. \n``\n``\n- \n``";
1817 let once = fix_aligned_quarto(input);
1818 let twice = fix_aligned_quarto(&once);
1819 assert_eq!(once, twice, "MD077 aligned fix must be idempotent (Quarto)");
1820 }
1821
1822 #[test]
1823 fn latent_underline_needs_paragraph_text_above_it() {
1824 for (label, content) in [
1828 ("ATX heading", "- item\nwrap\n# Heading\n===\n"),
1829 ("thematic break", "- item\nwrap\n***\n===\n"),
1830 ("HTML block", "- item\nwrap\n<div>\n===\n"),
1831 ] {
1832 assert_eq!(
1833 check_aligned(content).len(),
1834 1,
1835 "{label}: reindenting cannot make a setext heading here, so the under-indent is reportable"
1836 );
1837 }
1838
1839 let warnings = check_aligned("- item\nwrap\n```\ncode\n```\n===\n");
1845 assert_eq!(
1846 warnings.len(),
1847 1,
1848 "a closing fence is not paragraph text, so it holds nothing back: {warnings:?}"
1849 );
1850 assert_eq!(warnings[0].line, 2);
1851
1852 for (label, content) in [
1858 ("empty bullet", "- item\n wrap\n > - \n ===\n"),
1859 ("empty ordered item", "- item\n wrap\n > 1. \n ===\n"),
1860 ("empty item in a nested quote", "- item\n wrap\n > > - \n ===\n"),
1861 ] {
1862 assert_eq!(
1863 check_aligned(content).len(),
1864 1,
1865 "{label}: an item holding no text cannot become a heading's text line"
1866 );
1867 }
1868
1869 assert_eq!(
1873 check_aligned("- item\n wrap\n\n ===\n").len(),
1874 2,
1875 "nothing above the underline can become a heading's text line"
1876 );
1877 for (label, content) in [
1880 ("blank line in a quote", "- item\n wrap\n >\n ===\n"),
1881 ("quoted whitespace", "- item\n wrap\n > \n ===\n"),
1882 ] {
1883 let warnings = check_aligned(content);
1884 assert_eq!(warnings.len(), 1, "{label}: {warnings:?}");
1885 assert_eq!(warnings[0].line, 2, "{label}");
1886 }
1887
1888 assert!(
1892 check_aligned("- item\nwrap\ntext\n===\n").is_empty(),
1893 "prose above the underline is latent structure, so the item is left alone"
1894 );
1895 }
1896
1897 #[test]
1898 fn aligned_idempotent_with_lazy_continuation_out_of_a_blockquote() {
1899 let input = "- \n> *\n> a\n``";
1903 let once = fix_aligned(input);
1904 let twice = fix_aligned(&once);
1905 assert_eq!(once, twice, "MD077 aligned fix must be idempotent");
1906 }
1907
1908 fn fix_aligned_flavor(content: &str, flavor: MarkdownFlavor) -> String {
1909 let ctx = LintContext::new(content, flavor, None);
1910 aligned_rule().fix(&ctx).unwrap()
1911 }
1912
1913 #[test]
1914 fn aligned_text_after_a_margin_fence_is_not_the_items() {
1915 for input in [
1921 "- A\n```\n\n```\n![`]()\n`",
1922 "- \u{1d400}\n```\n\n```\n*`*\n**`**",
1923 "- A\n```\nx\n```\ntext\nmore\n",
1924 ] {
1925 for flavor in [
1926 MarkdownFlavor::Standard,
1927 MarkdownFlavor::MkDocs,
1928 MarkdownFlavor::Quarto,
1929 MarkdownFlavor::MDX,
1930 ] {
1931 let ctx = LintContext::new(input, flavor, None);
1932 let warnings = aligned_rule().check(&ctx).unwrap();
1933 assert!(warnings.is_empty(), "{flavor:?} {input:?}: {warnings:?}");
1934 assert_eq!(fix_aligned_flavor(input, flavor), input, "{flavor:?} {input:?}");
1935 }
1936 }
1937 }
1938
1939 #[test]
1940 fn aligned_text_after_an_in_item_fence_is_not_the_items() {
1941 let input = "- A\n ```\n x\n ```\ntext\n";
1944 for flavor in [MarkdownFlavor::Standard, MarkdownFlavor::MkDocs] {
1945 assert_eq!(fix_aligned_flavor(input, flavor), input, "{flavor:?}");
1946 }
1947 }
1948
1949 #[test]
1950 fn aligned_still_aligns_the_paragraph_before_a_margin_fence() {
1951 let input = "- A\nb\nc\n```\nx\n```\ntext\n";
1955 assert_eq!(
1956 fix_aligned_flavor(input, MarkdownFlavor::Standard),
1957 "- A\n b\n c\n```\nx\n```\ntext\n"
1958 );
1959 assert_eq!(
1960 fix_aligned_flavor(input, MarkdownFlavor::MkDocs),
1961 "- A\n b\n c\n```\nx\n```\ntext\n"
1962 );
1963 }
1964
1965 #[test]
1966 fn aligned_one_application_is_a_fixed_point_after_a_margin_fence() {
1967 for flavor in [MarkdownFlavor::Standard, MarkdownFlavor::MkDocs, MarkdownFlavor::Quarto] {
1968 for input in [
1969 "- A\n```\n\n```\n![`]()\n`",
1970 "- \u{1d400}\n```\n\n```\n*`*\n**`**",
1971 "- A\nb\n```\n\n```\n![`]()\n`",
1972 ] {
1973 let once = fix_aligned_flavor(input, flavor);
1974 let twice = fix_aligned_flavor(&once, flavor);
1975 assert_eq!(once, twice, "{flavor:?} {input:?}");
1976 }
1977 }
1978 }
1979
1980 #[test]
1983 fn tight_lazy_continuation_zero_indent_not_flagged() {
1984 let content = "- Item\ncontinuation\n";
1986 assert!(check(content).is_empty());
1987 }
1988
1989 #[test]
1990 fn tight_continuation_correct_indent_not_flagged() {
1991 let content = "1. Item\n continuation\n";
1993 assert!(check(content).is_empty());
1994 }
1995
1996 #[test]
1997 fn tight_continuation_over_indented_ordered() {
1998 let content = "1. This is a list item with multiple lines.\n The second line is over-indented.\n";
2000 let warnings = check(content);
2001 assert_eq!(warnings.len(), 1);
2002 assert_eq!(warnings[0].line, 2);
2003 assert!(warnings[0].message.contains("over-indented"));
2004 }
2005
2006 #[test]
2007 fn tight_continuation_over_indented_unordered() {
2008 let content = "- Item\n over-indented\n";
2010 let warnings = check(content);
2011 assert_eq!(warnings.len(), 1);
2012 assert_eq!(warnings[0].line, 2);
2013 }
2014
2015 #[test]
2016 fn tight_continuation_multiple_over_indented_lines() {
2017 let content = "1. Item\n line one\n line two\n line three\n";
2018 let warnings = check(content);
2019 assert_eq!(warnings.len(), 3);
2020 }
2021
2022 #[test]
2023 fn tight_continuation_mixed_correct_and_over() {
2024 let content = "1. Item\n correct\n over-indented\n correct again\n";
2025 let warnings = check(content);
2026 assert_eq!(warnings.len(), 1);
2027 assert_eq!(warnings[0].line, 3);
2028 }
2029
2030 #[test]
2031 fn tight_continuation_nested_over_indented() {
2032 let content = "- L1\n - L2\n over-indented continuation of L2\n";
2034 let warnings = check(content);
2035 assert_eq!(warnings.len(), 1);
2036 assert_eq!(warnings[0].line, 3);
2037 assert!(warnings[0].message.contains("expected 4"));
2039 assert!(warnings[0].message.contains("found 5"));
2040 }
2041
2042 #[test]
2043 fn tight_continuation_nested_correct_indent_not_flagged() {
2044 let content = "- L1\n - L2\n correctly indented continuation of L2\n";
2047 assert!(check(content).is_empty());
2048 }
2049
2050 #[test]
2051 fn fix_tight_continuation_nested_over_indented() {
2052 let content = "- L1\n - L2\n over-indented continuation of L2\n";
2054 let fixed = fix(content);
2055 assert_eq!(fixed, "- L1\n - L2\n over-indented continuation of L2\n");
2056 }
2057
2058 #[test]
2059 fn tight_continuation_under_indented_not_flagged() {
2060 let content = "1. Item\n under-indented\n";
2063 assert!(check(content).is_empty());
2064 }
2065
2066 #[test]
2067 fn tight_continuation_tab_over_indented() {
2068 let content = "- Item\n\tover-indented\n";
2070 let warnings = check(content);
2071 assert_eq!(warnings.len(), 1);
2072 }
2073
2074 #[test]
2075 fn fix_tight_continuation_over_indented_ordered() {
2076 let content = "1. This is a list item with multiple lines.\n The second line is over-indented.\n";
2077 let fixed = fix(content);
2078 assert_eq!(
2079 fixed,
2080 "1. This is a list item with multiple lines.\n The second line is over-indented.\n"
2081 );
2082 }
2083
2084 #[test]
2085 fn fix_tight_continuation_over_indented_unordered() {
2086 let content = "- Item\n over-indented\n";
2087 let fixed = fix(content);
2088 assert_eq!(fixed, "- Item\n over-indented\n");
2089 }
2090
2091 #[test]
2092 fn fix_tight_continuation_multiple_lines() {
2093 let content = "1. Item\n line one\n line two\n";
2094 let fixed = fix(content);
2095 assert_eq!(fixed, "1. Item\n line one\n line two\n");
2096 }
2097
2098 #[test]
2099 fn tight_continuation_mkdocs_4space_ordered_not_flagged() {
2100 let content = "1. Item\n continuation\n";
2103 assert!(check_mkdocs(content).is_empty());
2104 }
2105
2106 #[test]
2107 fn tight_continuation_mkdocs_5space_ordered_flagged() {
2108 let content = "1. Item\n over-indented\n";
2110 let warnings = check_mkdocs(content);
2111 assert_eq!(warnings.len(), 1);
2112 assert!(warnings[0].message.contains("expected 4"));
2113 assert!(warnings[0].message.contains("found 5"));
2114 }
2115
2116 #[test]
2117 fn fix_tight_continuation_mkdocs_over_indented() {
2118 let content = "1. Item\n over-indented\n";
2119 let fixed = fix_mkdocs(content);
2120 assert_eq!(fixed, "1. Item\n over-indented\n");
2121 }
2122
2123 #[test]
2124 fn tight_continuation_deeply_indented_list_markers_not_flagged() {
2125 let content = "* Level 0\n * Level 1\n * Level 2\n";
2128 assert!(check(content).is_empty());
2129 }
2130
2131 #[test]
2132 fn tight_continuation_ordered_marker_not_flagged() {
2133 let content = "- Parent\n 1. Child item\n";
2135 assert!(check(content).is_empty());
2136 }
2137
2138 #[test]
2141 fn unordered_correct_indent_no_warning() {
2142 let content = "- Item\n\n continuation\n";
2143 assert!(check(content).is_empty());
2144 }
2145
2146 #[test]
2147 fn unordered_partial_indent_warns() {
2148 let content = "- Item\n\n continuation\n";
2151 let warnings = check(content);
2152 assert_eq!(warnings.len(), 1);
2153 assert_eq!(warnings[0].line, 3);
2154 assert!(warnings[0].message.contains("2 spaces"));
2155 assert!(warnings[0].message.contains("found 1"));
2156 }
2157
2158 #[test]
2159 fn unordered_zero_indent_is_new_paragraph() {
2160 let content = "- Item\n\ncontinuation\n";
2163 assert!(check(content).is_empty());
2164 }
2165
2166 #[test]
2169 fn ordered_3space_correct_commonmark() {
2170 let content = "1. Item\n\n continuation\n";
2172 assert!(check(content).is_empty());
2173 }
2174
2175 #[test]
2176 fn ordered_2space_under_indent_commonmark() {
2177 let content = "1. Item\n\n continuation\n";
2178 let warnings = check(content);
2179 assert_eq!(warnings.len(), 1);
2180 assert!(warnings[0].message.contains("3 spaces"));
2181 assert!(warnings[0].message.contains("found 2"));
2182 }
2183
2184 #[test]
2187 fn multi_digit_marker_correct() {
2188 let content = "10. Item\n\n continuation\n";
2190 assert!(check(content).is_empty());
2191 }
2192
2193 #[test]
2194 fn multi_digit_marker_under_indent() {
2195 let content = "10. Item\n\n continuation\n";
2196 let warnings = check(content);
2197 assert_eq!(warnings.len(), 1);
2198 assert!(warnings[0].message.contains("4 spaces"));
2199 }
2200
2201 #[test]
2204 fn mkdocs_3space_ordered_warns() {
2205 let content = "1. Item\n\n continuation\n";
2207 let warnings = check_mkdocs(content);
2208 assert_eq!(warnings.len(), 1);
2209 assert!(warnings[0].message.contains("4 spaces"));
2210 assert!(warnings[0].message.contains("MkDocs"));
2211 }
2212
2213 #[test]
2214 fn mkdocs_4space_ordered_no_warning() {
2215 let content = "1. Item\n\n continuation\n";
2216 assert!(check_mkdocs(content).is_empty());
2217 }
2218
2219 #[test]
2220 fn mkdocs_unordered_2space_ok() {
2221 let content = "- Item\n\n continuation\n";
2223 assert!(check_mkdocs(content).is_empty());
2224 }
2225
2226 #[test]
2227 fn mkdocs_unordered_2space_warns() {
2228 let content = "- Item\n\n continuation\n";
2230 let warnings = check_mkdocs(content);
2231 assert_eq!(warnings.len(), 1);
2232 assert!(warnings[0].message.contains("4 spaces"));
2233 }
2234
2235 #[test]
2238 fn fix_unordered_indent() {
2239 let content = "- Item\n\n continuation\n";
2241 let fixed = fix(content);
2242 assert_eq!(fixed, "- Item\n\n continuation\n");
2243 }
2244
2245 #[test]
2246 fn fix_ordered_indent() {
2247 let content = "1. Item\n\n continuation\n";
2248 let fixed = fix(content);
2249 assert_eq!(fixed, "1. Item\n\n continuation\n");
2250 }
2251
2252 #[test]
2253 fn fix_mkdocs_indent() {
2254 let content = "1. Item\n\n continuation\n";
2255 let fixed = fix_mkdocs(content);
2256 assert_eq!(fixed, "1. Item\n\n continuation\n");
2257 assert_eq!(fix_mkdocs(&fixed), fixed);
2258 }
2259
2260 #[test]
2269 fn mkdocs_nested_continuation_at_the_level_column_is_clean() {
2270 for content in [
2271 "- a\n - b\n\n text\n",
2273 "- a\n - b\n - c\n\n text\n",
2275 "1. a\n 1. b\n\n text\n",
2277 "1. a\n - b\n\n text\n",
2278 "- a\n 1. b\n\n text\n",
2279 "- a\n - b\n\n - c\n\n text\n",
2281 "- - a\n\n text\n",
2283 "- - a\n - b\n\n text\n",
2285 "- a\n - b\n\n ```\n code\n ```\n",
2287 ] {
2288 assert!(
2289 check_mkdocs(content).is_empty(),
2290 "{content:?}: {:?}",
2291 check_mkdocs(content)
2292 );
2293 assert_eq!(fix_mkdocs(content), content);
2294 }
2295 }
2296
2297 #[test]
2298 fn mkdocs_nested_continuation_below_the_level_column_moves_to_it() {
2299 for (content, fixed) in [
2302 ("- a\n - b\n\n text\n", "- a\n - b\n\n text\n"),
2303 (
2304 "- a\n - b\n - c\n\n text\n",
2305 "- a\n - b\n - c\n\n text\n",
2306 ),
2307 ("1. a\n 1. b\n\n text\n", "1. a\n 1. b\n\n text\n"),
2308 ("- - a\n\n text\n", "- - a\n\n text\n"),
2309 ] {
2310 let warnings = check_mkdocs(content);
2311 assert_eq!(warnings.len(), 1, "{content:?}: {warnings:?}");
2312 assert!(warnings[0].message.contains("MkDocs"), "{}", warnings[0].message);
2313 assert_eq!(fix_mkdocs(content), fixed);
2314 assert!(check_mkdocs(fixed).is_empty(), "{fixed:?}");
2315 }
2316 }
2317
2318 #[test]
2319 fn mkdocs_nested_over_indent_snaps_to_the_level_column() {
2320 assert_eq!(
2321 fix_mkdocs("- a\n - b\n\n text\n"),
2322 "- a\n - b\n\n text\n"
2323 );
2324 let warnings = check_mkdocs("- a\n - b\n\n text\n");
2325 assert_eq!(warnings.len(), 1);
2326 assert!(
2327 warnings[0].message.contains("expected 8, found 9"),
2328 "{}",
2329 warnings[0].message
2330 );
2331 }
2332
2333 #[test]
2334 fn mkdocs_text_left_of_the_nested_marker_stays_with_the_parent() {
2335 let content = "- a\n - b\n\n text\n";
2338 assert!(check_mkdocs(content).is_empty());
2339 assert_eq!(fix_mkdocs(content), content);
2340 }
2341
2342 #[test]
2343 fn mkdocs_code_at_the_python_markdown_threshold_is_not_reindented() {
2344 let content = "1. a\n - b\n\n text\n";
2347 assert!(check_mkdocs(content).is_empty(), "{:?}", check_mkdocs(content));
2348 assert_eq!(fix_mkdocs(content), content);
2349 let rule = MD077ListContinuationIndent::from_config_struct(MD077Config {
2352 indent: Some(6),
2353 ..Default::default()
2354 });
2355 let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
2356 assert!(rule.check(&ctx).unwrap().is_empty());
2357 assert_eq!(rule.fix(&ctx).unwrap(), content);
2358 }
2359
2360 #[test]
2361 fn mkdocs_over_indented_paragraph_continuation_is_not_code() {
2362 let content = "- a\n\n para\n text\n";
2365 assert_eq!(check_mkdocs(content).len(), 1);
2366 assert_eq!(fix_mkdocs(content), "- a\n\n para\n text\n");
2367 let nested = "- a\n - b\n\n para\n text\n";
2368 assert_eq!(fix_mkdocs(nested), "- a\n - b\n\n para\n text\n");
2369 }
2370
2371 #[test]
2372 fn mkdocs_item_python_markdown_does_not_read_is_not_judged() {
2373 for content in [
2377 "123456. a\n - b\n\n text\n",
2378 "- a\n - b\n\n text\n",
2379 ] {
2380 assert!(
2381 check_mkdocs(content).is_empty(),
2382 "{content:?}: {:?}",
2383 check_mkdocs(content)
2384 );
2385 assert_eq!(fix_mkdocs(content), content);
2386 }
2387 }
2388
2389 #[test]
2390 fn mkdocs_bare_marker_is_not_an_item_for_python_markdown() {
2391 for content in [
2395 "-\n - b\n\n text\n",
2396 "1.\n - b\n\n text\n",
2397 "- a\n -\n\n text\n",
2398 "-\n -\n\n text\n",
2399 ] {
2400 assert!(
2401 check_mkdocs(content).is_empty(),
2402 "{content:?}: {:?}",
2403 check_mkdocs(content)
2404 );
2405 assert_eq!(fix_mkdocs(content), content);
2406 }
2407 for content in ["- \n - b\n\n text\n", "1. \n - b\n\n text\n"] {
2408 assert_eq!(check_mkdocs(content).len(), 1, "{content:?}");
2409 }
2410 }
2411
2412 #[test]
2413 fn mkdocs_bare_marker_does_not_unscope_a_later_list() {
2414 let content = "-\n\n# Heading\n\n - b\n\n text\n";
2417 assert_eq!(check_mkdocs(content).len(), 1, "{:?}", check_mkdocs(content));
2418 assert_eq!(fix_mkdocs(content), "-\n\n# Heading\n\n - b\n\n text\n");
2419 }
2420
2421 #[test]
2422 fn mkdocs_paren_marker_is_not_an_item_for_python_markdown() {
2423 let paragraph = "- a\n 1) b\n\n text\n";
2426 assert!(check_mkdocs(paragraph).is_empty(), "{:?}", check_mkdocs(paragraph));
2427 let code = "- a\n 1) b\n\n text\n";
2428 assert!(check_mkdocs(code).is_empty(), "{:?}", check_mkdocs(code));
2429 }
2430
2431 #[test]
2432 fn mkdocs_item_below_a_same_line_item_needs_the_written_marker_column() {
2433 let content = "1. - a\n - b\n\n text\n";
2437 assert!(check_mkdocs(content).is_empty(), "{:?}", check_mkdocs(content));
2438 }
2439
2440 #[test]
2441 fn mkdocs_list_after_a_blank_line_below_a_text_marker_is_an_item() {
2442 for (content, fixed) in [
2447 ("1) a\n\n - b\n\n text\n", "1) a\n\n - b\n\n text\n"),
2448 ("-\n\n - b\n\n text\n", "-\n\n - b\n\n text\n"),
2449 ] {
2450 assert_eq!(check_mkdocs(content).len(), 1, "{content:?}");
2451 assert_eq!(fix_mkdocs(content), fixed);
2452 assert_eq!(fix_mkdocs(fixed), fixed);
2453 }
2454 for content in ["1) a\n - b\n\n text\n", "-\n - b\n\n text\n"] {
2457 assert!(check_mkdocs(content).is_empty(), "{content:?}");
2458 }
2459 }
2460
2461 #[test]
2462 fn mkdocs_marker_under_a_padded_same_line_item_is_text_until_a_blank_line() {
2463 for content in [
2466 "- - a\n - b\n\n text\n",
2467 "- - a\n - b\n\n text\n",
2468 ] {
2469 assert!(check_mkdocs(content).is_empty(), "{content:?}");
2470 }
2471 let content = "- - a\n\n - b\n\n text\n";
2474 assert!(check_mkdocs(content).is_empty(), "{content:?}");
2475 assert_eq!(check_mkdocs("- - a\n\n - b\n\n text\n").len(), 1);
2476 }
2477
2478 #[test]
2479 fn mkdocs_markers_under_a_same_line_chain_are_text_until_a_blank_line() {
2480 let content = "- - - a\n - - b\n\n text\n";
2483 assert!(check_mkdocs(content).is_empty(), "{:?}", check_mkdocs(content));
2484 let content = "- - - a\n\n - b\n\n text\n";
2486 assert!(check_mkdocs(content).is_empty(), "{:?}", check_mkdocs(content));
2487 }
2488
2489 #[test]
2490 fn mkdocs_list_after_an_unsupported_marker_nests_in_the_enclosing_item() {
2491 let content = "- a\n 1) b\n\n - c\n\n text\n";
2495 assert_eq!(check_mkdocs(content).len(), 1);
2496 assert_eq!(fix_mkdocs(content), "- a\n 1) b\n\n - c\n\n text\n");
2497 let code = "- a\n 1) b\n\n - c\n\n text\n";
2499 assert!(check_mkdocs(code).is_empty(), "{:?}", check_mkdocs(code));
2500 }
2501
2502 #[test]
2503 fn mkdocs_text_at_a_deeper_item_paragraph_column_is_not_claimed_by_the_outer_item() {
2504 let content = "- - - a\n 1. b\n\n text\n";
2509 let warnings = check_mkdocs(content);
2510 assert_eq!(warnings.len(), 1, "{warnings:?}");
2511 assert!(warnings[0].message.contains("needs 9"), "{}", warnings[0].message);
2512 let fixed = fix_mkdocs(content);
2513 assert_eq!(fixed, "- - - a\n 1. b\n\n text\n");
2514 assert!(check_mkdocs(&fixed).is_empty());
2515 }
2516
2517 #[test]
2518 fn mkdocs_nested_item_closed_by_a_shallower_line_no_longer_owns_later_text() {
2519 let content = "- a\n - b\n\n para\n text\n";
2522 let warnings = check_mkdocs(content);
2523 assert_eq!(warnings.len(), 1, "{warnings:?}");
2524 assert_eq!(fix_mkdocs(content), "- a\n - b\n\n para\n text\n");
2525 }
2526
2527 fn assert_mkdocs_warns_without_fix(content: &str) {
2530 let warnings = check_mkdocs(content);
2531 assert!(!warnings.is_empty(), "expected a warning for {content:?}");
2532 assert!(
2533 warnings.iter().all(|w| w.fix.is_none()),
2534 "fix must be withheld for {content:?}: {warnings:?}"
2535 );
2536 assert_eq!(fix_mkdocs(content), content);
2537 }
2538
2539 #[test]
2540 fn mkdocs_fix_does_not_turn_prose_after_a_text_marker_into_code() {
2541 assert_mkdocs_warns_without_fix("- - a\n\n 1) b\n\n text\n");
2544 assert_eq!(fix_mkdocs("- - a\n\n text\n"), "- - a\n\n text\n");
2546 }
2547
2548 #[test]
2549 fn mkdocs_item_after_the_continuation_lines_of_the_item_above_is_an_item() {
2550 assert_eq!(
2554 fix_mkdocs("- a\n continuation\n- b\n\n text\n"),
2555 "- a\n continuation\n- b\n\n text\n"
2556 );
2557 assert_eq!(check_mkdocs("para\n- b\n\n text\n").len(), 0);
2559 assert_eq!(check_mkdocs("# Heading\n- a\n\n text\n").len(), 1);
2562 }
2563
2564 #[test]
2565 fn mkdocs_fix_is_withheld_when_it_would_change_how_later_lines_read() {
2566 assert_mkdocs_warns_without_fix("10. word two\n\n 1.\n\n a\n\n - word two\n");
2569 assert_mkdocs_warns_without_fix(" - - word two\n\n a\n text\n\n para\n");
2570 }
2571
2572 #[test]
2573 fn mkdocs_fix_is_withheld_when_it_would_change_code_text_or_a_quote() {
2574 assert_mkdocs_warns_without_fix("- a\n - b\n\n text\n\n code\n");
2577 assert_mkdocs_warns_without_fix("- a\n - b\n\n text\n > quote\n");
2579 }
2580
2581 #[test]
2582 fn mkdocs_fix_is_withheld_around_a_single_hyphen_underline_and_a_split_code_span() {
2583 assert_mkdocs_warns_without_fix("- a\n - b\n\n text\n - \n");
2585 assert_mkdocs_warns_without_fix("- a\n - b\n\n para\n `one\n two`\n");
2588 assert_mkdocs_warns_without_fix("- a\n - b\n\n `a `` b` c ``x\n d`` e `f `` g`\n");
2591 assert_eq!(
2593 fix_mkdocs("- a\n - b\n\n para `one`\n"),
2594 "- a\n - b\n\n para `one`\n"
2595 );
2596 }
2597
2598 #[test]
2599 fn mkdocs_fix_is_withheld_when_it_would_change_list_kind_or_tab_expansion() {
2600 assert_mkdocs_warns_without_fix("- a\n - b\n\n text\n\n 1. ordered\n");
2603 assert_mkdocs_warns_without_fix("- a\n - b\n\n ```\n a\tb\n ```\n");
2606 }
2607
2608 #[test]
2609 fn mkdocs_fix_is_withheld_for_a_quote_after_an_earlier_quote_and_for_exotic_space() {
2610 assert_mkdocs_warns_without_fix("> intro\n\n- a\n - b\n\n text\n > quote\n");
2613 assert_mkdocs_warns_without_fix("- a\n - b\n\n text\n\n \u{a0}\n");
2616 }
2617
2618 #[test]
2619 fn mkdocs_fix_is_withheld_below_a_whitespace_only_first_line() {
2620 assert_mkdocs_warns_without_fix(" \n- - a\n\n text\n");
2623 assert_eq!(
2625 fix_mkdocs("para\n\n \n- - a\n\n text\n"),
2626 "para\n\n \n- - a\n\n text\n"
2627 );
2628 }
2629
2630 #[test]
2631 fn mkdocs_fix_ignores_front_matter_and_is_withheld_around_abbreviation_definitions() {
2632 assert_eq!(
2635 fix_mkdocs("---\ntitle: Example\n---\n\n- a\n\n text\n"),
2636 "---\ntitle: Example\n---\n\n- a\n\n text\n"
2637 );
2638 assert_mkdocs_warns_without_fix("- a\n - b\n\n *[X]: abbreviation\n ## H\n");
2641 }
2642
2643 #[test]
2644 fn mkdocs_fix_is_judged_on_the_fixes_inline_configuration_leaves() {
2645 let content = "- a\n - b\n\n one <!-- rumdl-disable-line MD077 -->\n\n two\n";
2647 let fixed = fix_mkdocs(content);
2648 assert_eq!(fixed, content);
2649 }
2650
2651 #[test]
2652 fn mkdocs_fix_does_not_pull_a_fence_outside_the_list_into_it() {
2653 assert_eq!(
2656 fix_mkdocs("- a\n\n ```\n code\n ```\n"),
2657 "- a\n\n ```\n code\n ```\n"
2658 );
2659 assert_eq!(
2661 fix_mkdocs("- a\n - b\n\n ```\n code\n ```\n"),
2662 "- a\n - b\n\n ```\n code\n ```\n"
2663 );
2664 }
2665
2666 #[test]
2667 fn mkdocs_fix_is_withheld_around_constructs_the_model_does_not_read() {
2668 for content in [
2673 "- a\n - b\n\n more\n [id]: url\n",
2674 "- a\n - b\n\n ---\n - c\n",
2675 "- a\n - b\n\n ```\n\n - c\n ```\n",
2676 "- a\n - b\n\n text\n ===\n",
2677 "- a\n - b\n\n text\n <!-- x -->\n code\n",
2678 "- a\n - b\n\n ```\n ***\n ```\n",
2679 ] {
2680 assert_eq!(fix_mkdocs(content), content, "{content:?}");
2681 }
2682 }
2683
2684 #[test]
2685 fn mkdocs_fix_is_withheld_for_a_fence_whose_lines_do_not_share_a_column() {
2686 let content = "- a\n - b\n\n ```\n code\n code\n```\n";
2689 assert_eq!(fix_mkdocs(content), content);
2690 assert!(check_mkdocs(content).iter().all(|w| w.fix.is_none()));
2691 }
2692
2693 #[test]
2694 fn mkdocs_warnings_never_carry_overlapping_fixes() {
2695 let content = "- a\n - b\n\n ```\n ```\n ```\n";
2698 let mut ranges: Vec<_> = check_mkdocs(content)
2699 .iter()
2700 .filter_map(|w| w.fix.as_ref().map(|f| f.range.clone()))
2701 .collect();
2702 ranges.sort_by_key(|r| r.start);
2703 for pair in ranges.windows(2) {
2704 assert!(pair[0].end <= pair[1].start, "{ranges:?}");
2705 }
2706 }
2707
2708 #[test]
2709 fn mkdocs_fence_on_the_items_own_line_keeps_the_item() {
2710 let content = "- ```\n code\n ```\n\n text\n";
2713 assert_eq!(check_mkdocs(content).len(), 1, "{:?}", check_mkdocs(content));
2714 }
2715
2716 #[test]
2717 fn mkdocs_wide_marker_still_enforces_tight_continuation() {
2718 assert_eq!(
2721 fix_mkdocs("123456. a\n continuation\n"),
2722 "123456. a\n continuation\n"
2723 );
2724 }
2725
2726 #[test]
2727 fn mkdocs_paren_marker_after_a_tab_is_found_by_column() {
2728 let content = "- a\n\t- x) b\n\n text\n";
2730 assert_eq!(check_mkdocs(content).len(), 1, "{:?}", check_mkdocs(content));
2731 }
2732
2733 #[test]
2734 fn mkdocs_configured_indent_cannot_go_below_the_level_column() {
2735 let rule = MD077ListContinuationIndent::from_config_struct(MD077Config {
2736 indent: Some(2),
2737 ..Default::default()
2738 });
2739 let content = "- a\n - b\n\n text\n";
2740 let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
2741 assert_eq!(rule.fix(&ctx).unwrap(), "- a\n - b\n\n text\n");
2742 let clean = "- a\n - b\n\n text\n";
2743 let ctx = LintContext::new(clean, MarkdownFlavor::MkDocs, None);
2744 assert!(rule.check(&ctx).unwrap().is_empty());
2745 }
2746
2747 #[test]
2748 fn standard_flavor_keeps_the_content_column_for_nested_items() {
2749 let content = "- a\n - b\n\n text\n";
2751 assert!(check(content).is_empty());
2752 assert_eq!(fix("- a\n - b\n\n text\n"), "- a\n - b\n\n text\n");
2753 }
2754
2755 #[test]
2758 fn nested_list_items_not_flagged() {
2759 let content = "- Parent\n\n - Child\n";
2760 assert!(check(content).is_empty());
2761 }
2762
2763 #[test]
2764 fn nested_list_zero_indent_is_new_paragraph() {
2765 let content = "- Parent\n - Child\n\ncontinuation of parent\n";
2767 assert!(check(content).is_empty());
2768 }
2769
2770 #[test]
2771 fn nested_list_partial_indent_flagged() {
2772 let content = "- Parent\n - Child\n\n continuation of parent\n";
2774 let warnings = check(content);
2775 assert_eq!(warnings.len(), 1);
2776 assert!(warnings[0].message.contains("2 spaces"));
2777 }
2778
2779 #[test]
2782 fn code_block_correctly_indented_no_warning() {
2783 let content = "- Item\n\n ```\n code\n ```\n";
2785 assert!(check(content).is_empty());
2786 }
2787
2788 #[test]
2789 fn code_fence_under_indented_warns() {
2790 let content = "- Item\n\n ```\n code\n ```\n";
2794 let warnings = check(content);
2795 assert_eq!(warnings.len(), 1);
2796 assert_eq!(warnings[0].line, 3);
2797 }
2798
2799 #[test]
2800 fn code_fence_under_indented_ordered_mkdocs() {
2801 let content = "1. Item\n\n ```toml\n key = \"value\"\n ```\n";
2804 assert!(check(content).is_empty()); let warnings = check_mkdocs(content);
2806 assert_eq!(warnings.len(), 1); assert_eq!(warnings[0].line, 3);
2808 assert!(warnings[0].message.contains("4 spaces"));
2809 assert!(warnings[0].message.contains("MkDocs"));
2810 }
2811
2812 #[test]
2813 fn code_fence_tilde_under_indented() {
2814 let content = "- Item\n\n ~~~\n code\n ~~~\n";
2815 let warnings = check(content);
2816 assert_eq!(warnings.len(), 1); assert_eq!(warnings[0].line, 3);
2818 }
2819
2820 #[test]
2823 fn multiple_blank_lines_zero_indent_is_new_paragraph() {
2824 let content = "- Item\n\n\ncontinuation\n";
2826 assert!(check(content).is_empty());
2827 }
2828
2829 #[test]
2830 fn multiple_blank_lines_partial_indent_flags() {
2831 let content = "- Item\n\n\n continuation\n";
2832 let warnings = check(content);
2833 assert_eq!(warnings.len(), 1);
2834 }
2835
2836 #[test]
2839 fn empty_item_no_warning() {
2840 let content = "- \n- Second\n";
2841 assert!(check(content).is_empty());
2842 }
2843
2844 #[test]
2847 fn multiple_items_mixed_indent() {
2848 let content = "1. First\n\n correct continuation\n\n2. Second\n\n wrong continuation\n";
2849 let warnings = check(content);
2850 assert_eq!(warnings.len(), 1);
2851 assert_eq!(warnings[0].line, 7);
2852 }
2853
2854 #[test]
2857 fn task_list_correct_indent() {
2858 let content = "- [ ] Task\n\n continuation\n";
2860 assert!(check(content).is_empty());
2861 }
2862
2863 #[test]
2866 fn frontmatter_not_flagged() {
2867 let content = "---\ntitle: test\n---\n\n- Item\n\n continuation\n";
2868 assert!(check(content).is_empty());
2869 }
2870
2871 #[test]
2874 fn fix_multiple_items() {
2875 let content = "1. First\n\n wrong1\n\n2. Second\n\n wrong2\n";
2876 let fixed = fix(content);
2877 assert_eq!(fixed, "1. First\n\n wrong1\n\n2. Second\n\n wrong2\n");
2878 }
2879
2880 #[test]
2881 fn fix_multiline_loose_continuation_all_lines() {
2882 let content = "1. Item\n\n line one\n line two\n line three\n";
2883 let fixed = fix(content);
2884 assert_eq!(fixed, "1. Item\n\n line one\n line two\n line three\n");
2885 }
2886
2887 #[test]
2888 fn multi_line_setext_heading_after_a_list_is_not_a_continuation() {
2889 let content = "1. Item\n\n First line\n second line\n ===\n";
2893 let warnings = check(content);
2894 assert!(
2895 warnings.is_empty(),
2896 "the text lines of a setext heading are not continuation candidates. Got: {warnings:?}"
2897 );
2898 assert_eq!(fix(content), content);
2899 }
2900
2901 #[test]
2904 fn sibling_item_boundary_respected() {
2905 let content = "- First\n- Second\n\n continuation\n";
2907 assert!(check(content).is_empty());
2908 }
2909
2910 #[test]
2913 fn blockquote_list_correct_indent_no_warning() {
2914 let content = "> - Item\n>\n> continuation\n";
2917 assert!(check(content).is_empty());
2918 }
2919
2920 #[test]
2921 fn blockquote_list_under_indent_no_false_positive() {
2922 let content = "> - Item\n>\n> continuation\n";
2927 assert!(check(content).is_empty());
2928 }
2929
2930 #[test]
2933 fn deep_nesting_correct_indent() {
2934 let content = "- L1\n - L2\n - L3\n\n continuation of L3\n";
2935 assert!(check(content).is_empty());
2936 }
2937
2938 #[test]
2939 fn deep_nesting_under_indent() {
2940 let content = "- L1\n - L2\n - L3\n\n continuation of L3\n";
2943 let warnings = check(content);
2944 assert_eq!(warnings.len(), 1);
2945 assert!(warnings[0].message.contains("6 spaces"));
2946 assert!(warnings[0].message.contains("found 5"));
2947 }
2948
2949 #[test]
2950 fn deep_nesting_middle_level_continuation_bullets() {
2951 let content = "- L1\n - L2\n - L3\n\n continuation of L2\n";
2955 assert!(check(content).is_empty());
2956 }
2957
2958 #[test]
2959 fn deep_nesting_middle_level_continuation_ordered() {
2960 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";
2963 assert!(check(content).is_empty());
2964 }
2965
2966 #[test]
2967 fn deep_nesting_outermost_continuation() {
2968 let content = "- L1\n - L2\n - L3\n\n continuation of L1\n";
2971 assert!(check(content).is_empty());
2972 }
2973
2974 #[test]
2975 fn deep_nesting_between_levels_still_flagged() {
2976 let content = "- L1\n - L2\n - L3\n\n continuation\n";
2979 let warnings = check(content);
2980 assert_eq!(warnings.len(), 1);
2981 assert!(warnings[0].message.contains("4 spaces"));
2982 assert!(warnings[0].message.contains("found 3"));
2983 }
2984
2985 #[test]
2986 fn deep_nesting_beyond_deepest_still_flagged() {
2987 let content = "- L1\n - L2\n - L3\n\n continuation\n";
2989 let warnings = check(content);
2990 assert_eq!(warnings.len(), 1);
2991 assert!(warnings[0].message.contains("over-indented"));
2992 assert!(warnings[0].message.contains("expected 6, found 7"));
2993 }
2994
2995 #[test]
2996 fn four_levels_middle_continuation() {
2997 let content = "- L1\n - L2\n - L3\n - L4\n\n continuation of L2\n";
3000 assert!(check(content).is_empty());
3001 }
3002
3003 #[test]
3004 fn nested_sibling_closes_deeper_level() {
3005 let content = "- L1\n - L2a\n - L3\n - L2b\n\n continuation of L2b\n";
3008 assert!(check(content).is_empty());
3009 }
3010
3011 #[test]
3012 fn deep_nesting_middle_level_continuation_fix_preserved() {
3013 let content = "- L1\n - L2\n - L3\n\n continuation of L2\n";
3015 assert_eq!(fix(content), content);
3016 }
3017
3018 #[test]
3021 fn loose_tab_continuation_over_indented() {
3022 let content = "- Item\n\n\tcontinuation\n";
3027 let warnings = check(content);
3028 assert_eq!(warnings.len(), 1);
3029 assert_eq!(warnings[0].line, 3);
3030 assert_eq!(fix(content), "- Item\n\n continuation\n");
3031 }
3032
3033 #[test]
3036 fn multiple_continuations_correct() {
3037 let content = "- Item\n\n para 1\n\n para 2\n\n para 3\n";
3038 assert!(check(content).is_empty());
3039 }
3040
3041 #[test]
3042 fn multiple_continuations_second_under_indent() {
3043 let content = "- Item\n\n para 1\n\n continuation 2\n";
3045 let warnings = check(content);
3046 assert_eq!(warnings.len(), 1);
3047 assert_eq!(warnings[0].line, 5);
3048 }
3049
3050 #[test]
3053 fn ordered_paren_marker_correct() {
3054 let content = "1) Item\n\n continuation\n";
3056 assert!(check(content).is_empty());
3057 }
3058
3059 #[test]
3060 fn ordered_paren_marker_under_indent() {
3061 let content = "1) Item\n\n continuation\n";
3062 let warnings = check(content);
3063 assert_eq!(warnings.len(), 1);
3064 assert!(warnings[0].message.contains("3 spaces"));
3065 }
3066
3067 #[test]
3070 fn star_marker_correct() {
3071 let content = "* Item\n\n continuation\n";
3072 assert!(check(content).is_empty());
3073 }
3074
3075 #[test]
3076 fn star_marker_under_indent() {
3077 let content = "* Item\n\n continuation\n";
3078 let warnings = check(content);
3079 assert_eq!(warnings.len(), 1);
3080 }
3081
3082 #[test]
3083 fn plus_marker_correct() {
3084 let content = "+ Item\n\n continuation\n";
3085 assert!(check(content).is_empty());
3086 }
3087
3088 #[test]
3091 fn heading_after_list_no_warning() {
3092 let content = "- Item\n\n# Heading\n";
3093 assert!(check(content).is_empty());
3094 }
3095
3096 #[test]
3099 fn hr_after_list_no_warning() {
3100 let content = "- Item\n\n---\n";
3101 assert!(check(content).is_empty());
3102 }
3103
3104 #[test]
3107 fn reference_link_def_not_flagged() {
3108 let content = "- Item\n\n [link]: https://example.com\n";
3109 assert!(check(content).is_empty());
3110 }
3111
3112 #[test]
3115 fn footnote_def_not_flagged() {
3116 let content = "- Item\n\n [^1]: footnote text\n";
3117 assert!(check(content).is_empty());
3118 }
3119
3120 #[test]
3121 fn footnote_multiline_body_after_list_not_flagged() {
3122 let content = "# A list followed by a footnote\n\n\
3126 Here is a paragraph.[^fn]\n\n\
3127 - This is a list.\n\n\
3128 [^fn]:\n\
3129 \x20\x20\x20\x20Here is a footnote that spans multiple lines.\n\
3130 \x20\x20\x20\x20It should thus be indented by at least four spaces.\n";
3131 assert!(check(content).is_empty());
3132 }
3133
3134 #[test]
3135 fn fix_footnote_multiline_body_after_list_is_noop() {
3136 let content = "# A list followed by a footnote\n\n\
3140 Here is a paragraph.[^fn]\n\n\
3141 - This is a list.\n\n\
3142 [^fn]:\n\
3143 \x20\x20\x20\x20Here is a footnote that spans multiple lines.\n\
3144 \x20\x20\x20\x20It should thus be indented by at least four spaces.\n";
3145 assert_eq!(fix(content), content);
3146 }
3147
3148 #[test]
3149 fn footnote_body_indented_past_list_content_col_not_flagged() {
3150 let content = "- Item\n\n[^fn]:\n Body line one.\n Body line two.\n";
3154 assert!(check(content).is_empty());
3155 }
3156
3157 #[test]
3158 fn list_inside_footnote_body_continuation_not_flagged() {
3159 let content = "Text.[^fn]\n\n[^fn]:\n\
3163 \x20\x20\x20\x20- nested item\n\
3164 \x20\x20\x20\x20\x20\x20\x20over-indented continuation\n";
3165 assert!(check(content).is_empty());
3166 }
3167
3168 #[test]
3169 fn footnote_multiline_body_after_list_not_flagged_mkdocs() {
3170 let content = "Here is a paragraph.[^fn]\n\n\
3174 - This is a list.\n\n\
3175 [^fn]:\n\
3176 \x20\x20\x20\x20\x20\x20Footnote body that spans\n\
3177 \x20\x20\x20\x20\x20\x20multiple indented lines.\n";
3178 assert!(check_mkdocs(content).is_empty());
3179 }
3180
3181 #[test]
3184 fn fix_deep_nesting() {
3185 let content = "- L1\n - L2\n - L3\n\n under-indented\n";
3186 let fixed = fix(content);
3187 assert_eq!(fixed, "- L1\n - L2\n - L3\n\n under-indented\n");
3188 }
3189
3190 #[test]
3191 fn fix_mkdocs_unordered() {
3192 let content = "- Item\n\n continuation\n";
3194 let fixed = fix_mkdocs(content);
3195 assert_eq!(fixed, "- Item\n\n continuation\n");
3196 assert_eq!(fix_mkdocs(&fixed), fixed);
3197 }
3198
3199 #[test]
3200 fn fix_code_fence_indent() {
3201 let content = "- Item\n\n ```\n code\n ```\n";
3204 let fixed = fix(content);
3205 assert_eq!(fixed, "- Item\n\n ```\n code\n ```\n");
3206 }
3207
3208 #[test]
3209 fn fix_mkdocs_code_fence_indent() {
3210 let content = "1. Item\n\n ```toml\n key = \"val\"\n ```\n";
3212 let fixed = fix_mkdocs(content);
3213 assert_eq!(fixed, "1. Item\n\n ```toml\n key = \"val\"\n ```\n");
3214 assert_eq!(fix_mkdocs(&fixed), fixed);
3215 }
3216
3217 #[test]
3220 fn empty_document_no_warning() {
3221 assert!(check("").is_empty());
3222 }
3223
3224 #[test]
3225 fn whitespace_only_no_warning() {
3226 assert!(check(" \n\n \n").is_empty());
3227 }
3228
3229 #[test]
3232 fn no_list_no_warning() {
3233 let content = "# Heading\n\nSome paragraph.\n\nAnother paragraph.\n";
3234 assert!(check(content).is_empty());
3235 }
3236
3237 #[test]
3240 fn multiline_continuation_all_lines_flagged() {
3241 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";
3242 let warnings = check(content);
3243 assert_eq!(warnings.len(), 3);
3244 assert_eq!(warnings[0].line, 3);
3245 assert_eq!(warnings[1].line, 4);
3246 assert_eq!(warnings[2].line, 5);
3247 }
3248
3249 #[test]
3250 fn multiline_continuation_with_frontmatter_fix() {
3251 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";
3252 let fixed = fix(content);
3253 assert_eq!(
3254 fixed,
3255 "---\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"
3256 );
3257 }
3258
3259 #[test]
3260 fn multiline_continuation_correct_indent_no_warning() {
3261 let content = "1. Item\n\n line one\n line two\n line three\n";
3262 assert!(check(content).is_empty());
3263 }
3264
3265 #[test]
3266 fn multiline_continuation_mixed_indent() {
3267 let content = "1. Item\n\n correct\n wrong\n correct\n";
3268 let warnings = check(content);
3269 assert_eq!(warnings.len(), 1);
3270 assert_eq!(warnings[0].line, 4);
3271 }
3272
3273 #[test]
3274 fn multiline_continuation_unordered() {
3275 let content = "- Item\n\n continuation 1\n continuation 2\n continuation 3\n";
3276 let warnings = check(content);
3277 assert_eq!(warnings.len(), 3);
3278 let fixed = fix(content);
3279 assert_eq!(
3280 fixed,
3281 "- Item\n\n continuation 1\n continuation 2\n continuation 3\n"
3282 );
3283 }
3284
3285 #[test]
3286 fn multiline_continuation_two_items_fix() {
3287 let content = "1. First\n\n cont a\n cont b\n\n2. Second\n\n cont c\n cont d\n";
3288 let fixed = fix(content);
3289 assert_eq!(
3290 fixed,
3291 "1. First\n\n cont a\n cont b\n\n2. Second\n\n cont c\n cont d\n"
3292 );
3293 }
3294
3295 #[test]
3296 fn fence_fix_does_not_break_pairing_for_md031() {
3297 let content = "#### title\n\nabc\n\n\
3304 1. ab\n\n\
3305 \x20\x20`aabbccdd`\n\n\
3306 2. cd\n\n\
3307 \x20\x20`bbcc dd ee`\n\n\
3308 \x20\x20```\n\
3309 \x20\x20abcd\n\
3310 \x20\x20ef gh\n\
3311 \x20\x20```\n\n\
3312 \x20\x20uu\n\n\
3313 \x20\x20```\n\
3314 \x20\x20cdef\n\
3315 \x20\x20gh ij\n\
3316 \x20\x20```\n";
3317 let expected = "#### title\n\nabc\n\n\
3318 1. ab\n\n\
3319 \x20\x20\x20`aabbccdd`\n\n\
3320 2. cd\n\n\
3321 \x20\x20\x20`bbcc dd ee`\n\n\
3322 \x20\x20\x20```\n\
3323 \x20\x20\x20abcd\n\
3324 \x20\x20\x20ef gh\n\
3325 \x20\x20\x20```\n\n\
3326 \x20\x20\x20uu\n\n\
3327 \x20\x20\x20```\n\
3328 \x20\x20\x20cdef\n\
3329 \x20\x20\x20gh ij\n\
3330 \x20\x20\x20```\n";
3331 assert_eq!(fix(content), expected);
3332 }
3333
3334 #[test]
3335 fn multiline_continuation_separated_by_blank() {
3336 let content = "1. Item\n\n para1 line1\n para1 line2\n\n para2 line1\n para2 line2\n";
3337 let warnings = check(content);
3338 assert_eq!(warnings.len(), 4);
3339 let fixed = fix(content);
3340 assert_eq!(
3341 fixed,
3342 "1. Item\n\n para1 line1\n para1 line2\n\n para2 line1\n para2 line2\n"
3343 );
3344 }
3345
3346 #[test]
3347 fn tab_indented_fence_is_normalized_to_spaces() {
3348 let content = "- a\n\n 100. ab\n\n\t```\n\tabcd\n\t```\n";
3356 let expected = "- a\n\n 100. ab\n\n ```\n abcd\n ```\n";
3357 assert_eq!(fix(content), expected);
3358 let code = "100. ab\n\n\t```\n\tabcd\n\t```\n";
3361 assert_eq!(fix(code), code);
3362 }
3363
3364 #[test]
3373 fn loose_continuation_over_indented_flagged() {
3374 let content = "* Item\n\n over-indented\n";
3377 let warnings = check(content);
3378 assert_eq!(warnings.len(), 1);
3379 assert_eq!(warnings[0].line, 3);
3380 assert!(warnings[0].message.contains("over-indented"));
3381 assert!(warnings[0].message.contains("expected 2"));
3382 assert!(warnings[0].message.contains("found 3"));
3383 }
3384
3385 #[test]
3386 fn loose_continuation_over_indented_multiline_mixed() {
3387 let content = "* Item\n\n over one\n correct\n over two\n";
3389 let warnings = check(content);
3390 assert_eq!(warnings.len(), 2);
3391 assert_eq!(warnings[0].line, 3);
3392 assert_eq!(warnings[1].line, 5);
3393 }
3394
3395 #[test]
3396 fn fix_loose_continuation_over_indented() {
3397 let content = "* Item\n\n over one\n correct\n over two\n";
3398 let fixed = fix(content);
3399 assert_eq!(fixed, "* Item\n\n over one\n correct\n over two\n");
3400 }
3401
3402 #[test]
3403 fn fix_tight_and_loose_items_normalized_identically() {
3404 let content = "---\ntitle: Heading\n---\n\nSome introductory text:\n\n\
3407 * 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\
3408 * 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";
3409 let expected = "---\ntitle: Heading\n---\n\nSome introductory text:\n\n\
3410 * 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\
3411 * 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";
3412 assert_eq!(fix(content), expected);
3413 }
3414
3415 #[test]
3416 fn multi_paragraph_item_loose_paragraph_over_indented() {
3417 let content = "* Item.\n tight over\n\n loose over\n";
3420 let warnings = check(content);
3421 assert_eq!(warnings.len(), 2);
3422 assert_eq!(warnings[0].line, 2);
3423 assert_eq!(warnings[1].line, 4);
3424 }
3425
3426 #[test]
3427 fn loose_indented_code_block_not_flagged() {
3428 let content = "- Item\n\n code line\n";
3432 assert!(check(content).is_empty());
3433 }
3434
3435 #[test]
3436 fn mkdocs_loose_over_indented_flagged() {
3437 let content = "1. Item\n\n over\n";
3440 let warnings = check_mkdocs(content);
3441 assert_eq!(warnings.len(), 1);
3442 assert_eq!(warnings[0].line, 3);
3443 assert!(warnings[0].message.contains("over-indented"));
3444 assert!(warnings[0].message.contains("expected 4"));
3445 assert!(warnings[0].message.contains("found 5"));
3446 }
3447
3448 #[test]
3449 fn task_list_loose_over_indented_flagged() {
3450 let content = "- [ ] Task\n\n over\n";
3453 let warnings = check(content);
3454 assert_eq!(warnings.len(), 1);
3455 assert_eq!(warnings[0].line, 3);
3456 }
3457
3458 #[test]
3459 fn loose_over_indent_boundary_below_code_block_threshold_flagged() {
3460 let content = "- Item\n\n over\n";
3465 let warnings = check(content);
3466 assert_eq!(warnings.len(), 1);
3467 assert_eq!(warnings[0].line, 3);
3468 assert!(warnings[0].message.contains("expected 2"));
3469 assert!(warnings[0].message.contains("found 5"));
3470 }
3471
3472 #[test]
3473 fn loose_over_indent_does_not_steal_nested_under_indent() {
3474 let content = "- Outer\n - Inner\n\n continuation\n";
3481 let warnings = check(content);
3482 assert_eq!(warnings.len(), 1);
3483 assert_eq!(warnings[0].line, 4);
3484 assert!(warnings[0].message.contains("4 spaces"));
3485 assert!(warnings[0].message.contains("found 3"));
3486 assert_eq!(fix(content), "- Outer\n - Inner\n\n continuation\n");
3487 }
3488
3489 #[test]
3490 fn loose_over_indent_attributes_to_deepest_enclosing_item() {
3491 let content = "- Outer\n - Inner\n\n continuation\n";
3495 let warnings = check(content);
3496 assert_eq!(warnings.len(), 1);
3497 assert_eq!(warnings[0].line, 4);
3498 assert!(warnings[0].message.contains("expected 4"));
3499 assert!(warnings[0].message.contains("found 5"));
3500 assert_eq!(fix(content), "- Outer\n - Inner\n\n continuation\n");
3501 }
3502
3503 #[test]
3512 fn loose_over_indented_fence_not_flagged() {
3513 let content = "- Item\n\n ```\n code\n ```\n";
3514 assert!(check(content).is_empty());
3515 assert_eq!(fix(content), content);
3516 }
3517
3518 #[test]
3519 fn tight_over_indented_fence_not_flagged() {
3520 let content = "- Item\n ```\n code\n ```\n";
3521 assert!(check(content).is_empty());
3522 assert_eq!(fix(content), content);
3523 }
3524
3525 #[test]
3526 fn over_indented_tilde_fence_not_flagged() {
3527 let content = "- Item\n\n ~~~\n code\n ~~~\n";
3528 assert!(check(content).is_empty());
3529 assert_eq!(fix(content), content);
3530 }
3531
3532 #[test]
3533 fn fence_like_code_content_inside_fenced_block_not_flagged() {
3534 let content = "- Item\n\n ~~~\n ```\n ~~~\n";
3537 assert!(check(content).is_empty());
3538 assert_eq!(fix(content), content);
3539 }
3540
3541 #[test]
3542 fn unterminated_over_indented_fence_not_flagged() {
3543 let content = "- Item\n\n ```\n code1\n code2deeper\n";
3546 assert!(check(content).is_empty());
3547 assert_eq!(fix(content), content);
3548 }
3549
3550 #[test]
3558 fn task_list_tight_continuation_post_checkbox_reproducer_579() {
3559 let content = "- [ ] Lorem ipsum dolor sit amet, consectetur adipiscing\n tempor incididunt ut labore.\n";
3562 assert!(check(content).is_empty());
3563 }
3564
3565 #[test]
3566 fn task_list_tight_continuation_dash_unchecked() {
3567 let content = "- [ ] Task\n continuation\n";
3568 assert!(check(content).is_empty());
3569 }
3570
3571 #[test]
3572 fn task_list_tight_continuation_dash_checked_lower() {
3573 let content = "- [x] Task\n continuation\n";
3574 assert!(check(content).is_empty());
3575 }
3576
3577 #[test]
3578 fn task_list_tight_continuation_dash_checked_upper() {
3579 let content = "- [X] Task\n continuation\n";
3580 assert!(check(content).is_empty());
3581 }
3582
3583 #[test]
3584 fn task_list_tight_continuation_star_marker() {
3585 let content = "* [ ] Task\n continuation\n";
3586 assert!(check(content).is_empty());
3587 }
3588
3589 #[test]
3590 fn task_list_tight_continuation_plus_marker() {
3591 let content = "+ [ ] Task\n continuation\n";
3592 assert!(check(content).is_empty());
3593 }
3594
3595 #[test]
3596 fn task_list_tight_continuation_content_column_still_valid() {
3597 let content = "- [ ] Task\n continuation\n";
3600 assert!(check(content).is_empty());
3601 }
3602
3603 #[test]
3604 fn task_list_tight_continuation_between_columns_still_flagged() {
3605 let content = "- [ ] Task\n continuation\n";
3608 let warnings = check(content);
3609 assert_eq!(warnings.len(), 1);
3610 assert!(warnings[0].message.contains("expected 2 or 6"));
3612 assert!(warnings[0].message.contains("found 4"));
3613 }
3614
3615 #[test]
3616 fn task_list_tight_continuation_overshoot_still_flagged() {
3617 let content = "- [ ] Task\n continuation\n";
3619 let warnings = check(content);
3620 assert_eq!(warnings.len(), 1);
3621 assert!(warnings[0].message.contains("expected 2 or 6"));
3622 assert!(warnings[0].message.contains("found 7"));
3623 }
3624
3625 #[test]
3628 fn fix_task_list_overshoot_snaps_to_task_col() {
3629 let content = "- [ ] Task\n continuation\n";
3633 let fixed = fix(content);
3634 assert_eq!(fixed, "- [ ] Task\n continuation\n");
3635 }
3636
3637 #[test]
3638 fn fix_task_list_col_5_snaps_to_task_col() {
3639 let content = "- [ ] Task\n continuation\n";
3641 let fixed = fix(content);
3642 assert_eq!(fixed, "- [ ] Task\n continuation\n");
3643 }
3644
3645 #[test]
3646 fn fix_task_list_col_3_snaps_to_content_col() {
3647 let content = "- [ ] Task\n continuation\n";
3649 let fixed = fix(content);
3650 assert_eq!(fixed, "- [ ] Task\n continuation\n");
3651 }
3652
3653 #[test]
3654 fn fix_task_list_col_4_ties_to_content_col() {
3655 let content = "- [ ] Task\n continuation\n";
3660 let fixed = fix(content);
3661 assert_eq!(fixed, "- [ ] Task\n continuation\n");
3662 }
3663
3664 #[test]
3665 fn fix_task_list_ordered_overshoot_snaps_to_task_col() {
3666 let content = "1. [ ] Task\n continuation\n";
3669 let fixed = fix(content);
3670 assert_eq!(fixed, "1. [ ] Task\n continuation\n");
3671 }
3672
3673 #[test]
3674 fn fix_task_list_ordered_under_overshoot_snaps_to_content_col() {
3675 let content = "1. [ ] Task\n continuation\n";
3678 let fixed = fix(content);
3679 assert_eq!(fixed, "1. [ ] Task\n continuation\n");
3680 }
3681
3682 #[test]
3683 fn task_list_tight_continuation_ordered_single_digit() {
3684 let content = "1. [ ] Task\n continuation\n";
3686 assert!(check(content).is_empty());
3687 }
3688
3689 #[test]
3690 fn task_list_tight_continuation_ordered_multi_digit() {
3691 let content = "10. [ ] Task\n continuation\n";
3693 assert!(check(content).is_empty());
3694 }
3695
3696 #[test]
3697 fn task_list_tight_continuation_nested_dash() {
3698 let content = "- Parent\n - [ ] Nested task\n continuation\n";
3700 assert!(check(content).is_empty());
3701 }
3702
3703 #[test]
3704 fn task_list_loose_continuation_post_checkbox_column_not_flagged() {
3705 let content = "- [ ] Task\n\n continuation\n";
3710 assert!(check(content).is_empty());
3711 }
3712
3713 #[test]
3714 fn task_list_empty_body_is_not_a_task() {
3715 let content = "- [ ]\n continuation\n";
3721 let warnings = check(content);
3722 assert_eq!(warnings.len(), 1);
3723 assert!(warnings[0].message.contains("found 4"));
3724 }
3725
3726 #[test]
3727 fn task_list_malformed_checkbox_is_not_a_task() {
3728 let content = "- [~] Not a task\n continuation\n";
3730 let warnings = check(content);
3731 assert_eq!(warnings.len(), 1);
3732 }
3733
3734 #[test]
3741 fn task_list_mkdocs_unordered_required_min_valid() {
3742 let content = "- [ ] Task\n continuation\n";
3744 assert!(check_mkdocs(content).is_empty());
3745 }
3746
3747 #[test]
3748 fn task_list_mkdocs_unordered_post_checkbox_valid() {
3749 let content = "- [ ] Task\n continuation\n";
3750 assert!(check_mkdocs(content).is_empty());
3751 }
3752
3753 #[test]
3754 fn task_list_mkdocs_unordered_between_flagged() {
3755 let content = "- [ ] Task\n continuation\n";
3757 let warnings = check_mkdocs(content);
3758 assert_eq!(warnings.len(), 1);
3759 }
3760
3761 #[test]
3762 fn task_list_mkdocs_ordered_both_columns_valid() {
3763 let at_4 = "1. [ ] Task\n continuation\n";
3765 assert!(check_mkdocs(at_4).is_empty());
3766 let at_7 = "1. [ ] Task\n continuation\n";
3767 assert!(check_mkdocs(at_7).is_empty());
3768 }
3769
3770 #[test]
3771 fn task_list_mkdocs_ordered_between_flagged() {
3772 let at_5 = "1. [ ] Task\n continuation\n";
3774 assert_eq!(check_mkdocs(at_5).len(), 1);
3775 let at_6 = "1. [ ] Task\n continuation\n";
3776 assert_eq!(check_mkdocs(at_6).len(), 1);
3777 }
3778
3779 #[test]
3789 fn fix_task_list_tie_sibling_at_task_col_snaps_to_task_col() {
3790 let content = "- [ ] Task\n aligned continuation\n tied continuation\n";
3794 let fixed = fix(content);
3795 assert_eq!(
3796 fixed,
3797 "- [ ] Task\n aligned continuation\n tied continuation\n"
3798 );
3799 }
3800
3801 #[test]
3802 fn fix_task_list_tie_sibling_at_content_col_snaps_to_content_col() {
3803 let content = "- [ ] Task\n aligned continuation\n tied continuation\n";
3806 let fixed = fix(content);
3807 assert_eq!(fixed, "- [ ] Task\n aligned continuation\n tied continuation\n");
3808 }
3809
3810 #[test]
3811 fn fix_task_list_tie_both_siblings_snaps_to_content_col() {
3812 let content = "- [ ] Task\n at content col\n at task col\n tied continuation\n";
3816 let fixed = fix(content);
3817 assert_eq!(
3818 fixed,
3819 "- [ ] Task\n at content col\n at task col\n tied continuation\n"
3820 );
3821 }
3822
3823 #[test]
3824 fn fix_task_list_tie_sees_task_col_through_tight_lazy_continuation() {
3825 let content = concat!("- [ ] Task\n", "lazy\n", " aligned at task col\n", " tied\n",);
3839 let fixed = fix(content);
3840 assert!(
3841 fixed.contains("\n tied\n"),
3842 "tied line should snap to col 6 (task col) because a task-col \
3843 sibling is visible past the tight lazy-continuation line; got:\n{fixed}"
3844 );
3845 }
3846
3847 #[test]
3854 fn task_list_tab_indented_continuation_flagged() {
3855 let content = "- [ ] Task\n\t\twrap\n";
3858 let warnings = check(content);
3859 assert_eq!(warnings.len(), 1);
3860 assert!(warnings[0].message.contains("expected 2 or 6"));
3861 assert!(warnings[0].message.contains("found 8"));
3862 }
3863
3864 #[test]
3865 fn fix_task_list_tab_indented_snaps_to_task_col() {
3866 let content = "- [ ] Task\n\t\twrap\n";
3868 let fixed = fix(content);
3869 assert_eq!(fixed, "- [ ] Task\n wrap\n");
3870 }
3871
3872 #[test]
3873 fn fix_task_list_single_tab_equidistant_snaps_to_content_col() {
3874 let content = "- [ ] Task\n\twrap\n";
3877 let fixed = fix(content);
3878 assert_eq!(fixed, "- [ ] Task\n wrap\n");
3879 }
3880
3881 #[test]
3891 fn task_list_blockquote_post_checkbox_not_flagged() {
3892 let content = "> - [ ] Task\n> continuation\n";
3894 assert!(check(content).is_empty());
3895 }
3896
3897 #[test]
3898 fn task_list_blockquote_between_cols_documented_limitation() {
3899 let content = "> - [ ] Task\n> continuation\n";
3903 assert!(check(content).is_empty());
3904 }
3905
3906 #[test]
3907 fn task_list_blockquote_overshoot_documented_limitation() {
3908 let content = "> - [ ] Task\n> continuation\n";
3910 assert!(check(content).is_empty());
3911 }
3912
3913 #[test]
3920 fn fix_task_list_mkdocs_unordered_overshoot_snaps_to_task_col() {
3921 let content = "- [ ] Task\n continuation\n";
3924 let fixed = fix_mkdocs(content);
3925 assert_eq!(fixed, "- [ ] Task\n continuation\n");
3926 }
3927
3928 #[test]
3929 fn fix_task_list_mkdocs_unordered_tie_snaps_to_required() {
3930 let content = "- [ ] Task\n continuation\n";
3933 let fixed = fix_mkdocs(content);
3934 assert_eq!(fixed, "- [ ] Task\n continuation\n");
3935 }
3936
3937 #[test]
3938 fn fix_task_list_mkdocs_ordered_overshoot_snaps_to_task_col() {
3939 let content = "1. [ ] Task\n continuation\n";
3942 let fixed = fix_mkdocs(content);
3943 assert_eq!(fixed, "1. [ ] Task\n continuation\n");
3944 }
3945
3946 #[test]
3947 fn fix_task_list_mkdocs_ordered_near_required_snaps_to_required() {
3948 let content = "1. [ ] Task\n continuation\n";
3954 let fixed = fix_mkdocs(content);
3955 assert_eq!(fixed, "1. [ ] Task\n continuation\n");
3956 }
3957
3958 #[test]
3959 fn fix_task_list_mkdocs_ordered_between_cols_snaps_to_task_col() {
3960 let content = "1. [ ] Task\n continuation\n";
3963 let fixed = fix_mkdocs(content);
3964 assert_eq!(fixed, "1. [ ] Task\n continuation\n");
3965 }
3966
3967 fn assert_idempotent(content: &str) {
3977 let once = fix(content);
3978 let twice = fix(&once);
3979 assert_eq!(once, twice, "MD077 fix was not idempotent on input: {content:?}");
3980 }
3981
3982 fn assert_idempotent_mkdocs(content: &str) {
3983 let once = fix_mkdocs(content);
3984 let twice = fix_mkdocs(&once);
3985 assert_eq!(
3986 once, twice,
3987 "MD077 (MkDocs) fix was not idempotent on input: {content:?}"
3988 );
3989 }
3990
3991 #[test]
3992 fn idempotent_task_list_between_cols() {
3993 assert_idempotent("- [ ] Task\n continuation\n");
3994 }
3995
3996 #[test]
3997 fn idempotent_task_list_overshoot() {
3998 assert_idempotent("- [ ] Task\n continuation\n");
3999 }
4000
4001 #[test]
4002 fn idempotent_task_list_under_post_checkbox() {
4003 assert_idempotent("- [ ] Task\n continuation\n");
4004 }
4005
4006 #[test]
4007 fn idempotent_task_list_near_post_checkbox() {
4008 assert_idempotent("- [ ] Task\n continuation\n");
4009 }
4010
4011 #[test]
4012 fn idempotent_task_list_tab_overshoot() {
4013 assert_idempotent("- [ ] Task\n\t\twrap\n");
4014 }
4015
4016 #[test]
4017 fn idempotent_task_list_single_tab() {
4018 assert_idempotent("- [ ] Task\n\twrap\n");
4019 }
4020
4021 #[test]
4022 fn idempotent_task_list_ordered_overshoot() {
4023 assert_idempotent("1. [ ] Task\n continuation\n");
4024 }
4025
4026 #[test]
4027 fn idempotent_task_list_ordered_under() {
4028 assert_idempotent("1. [ ] Task\n continuation\n");
4029 }
4030
4031 #[test]
4032 fn idempotent_task_list_tie_with_sibling_at_task_col() {
4033 assert_idempotent("- [ ] Task\n aligned\n tied\n");
4034 }
4035
4036 #[test]
4037 fn idempotent_task_list_tie_with_sibling_at_content_col() {
4038 assert_idempotent("- [ ] Task\n aligned\n tied\n");
4039 }
4040
4041 #[test]
4042 fn idempotent_task_list_mkdocs_unordered_overshoot() {
4043 assert_idempotent_mkdocs("- [ ] Task\n continuation\n");
4044 }
4045
4046 #[test]
4047 fn idempotent_task_list_mkdocs_unordered_tie() {
4048 assert_idempotent_mkdocs("- [ ] Task\n continuation\n");
4049 }
4050
4051 #[test]
4052 fn idempotent_task_list_mkdocs_ordered_overshoot() {
4053 assert_idempotent_mkdocs("1. [ ] Task\n continuation\n");
4054 }
4055
4056 #[test]
4057 fn idempotent_task_list_mkdocs_ordered_between() {
4058 assert_idempotent_mkdocs("1. [ ] Task\n continuation\n");
4059 }
4060
4061 #[test]
4062 fn idempotent_task_list_reproducer_579() {
4063 assert_idempotent(
4067 "- [ ] Lorem ipsum dolor sit amet, consectetur adipiscing\n tempor incididunt ut labore.\n",
4068 );
4069 }
4070
4071 #[test]
4072 fn idempotent_non_task_list_still_holds() {
4073 assert_idempotent("1. Item\n over-indented\n");
4076 assert_idempotent("- Item\n\n continuation\n");
4077 }
4078
4079 #[test]
4086 fn idempotent_non_task_loose_under_indent_ordered() {
4087 assert_idempotent("1. Item\n\n continuation\n");
4089 }
4090
4091 #[test]
4092 fn idempotent_non_task_loose_under_indent_multi_digit() {
4093 assert_idempotent("10. Item\n\n continuation\n");
4095 }
4096
4097 #[test]
4098 fn idempotent_non_task_tight_over_indent_ordered() {
4099 assert_idempotent("1. Item\n over-indented\n");
4101 }
4102
4103 #[test]
4111 fn idempotent_non_task_fence_ordered_loose() {
4112 assert_idempotent("1. Item\n\n ```rust\n let x = 1;\n ```\n");
4114 }
4115
4116 #[test]
4117 fn idempotent_non_task_fence_tilde_under_indent() {
4118 assert_idempotent("1. Item\n\n ~~~\nplain text\n ~~~\n");
4124 }
4125
4126 #[test]
4127 fn idempotent_non_task_fence_interior_above_required() {
4128 assert_idempotent("1. Item\n\n ```\n deeply indented code\n ```\n");
4132 }
4133
4134 #[test]
4135 fn fence_fix_promotes_interior_below_scope_in_single_pass() {
4136 let content = "1. Item\n\n ```\ncode\n ```\n";
4140 let fixed = fix(content);
4141 assert_eq!(fixed, "1. Item\n\n ```\n code\n ```\n");
4142 }
4143
4144 #[test]
4145 fn fence_fix_preserves_interior_offset_from_the_fence() {
4146 let content = "1. Item\n\n ```\n code\n ```\n";
4151 let fixed = fix(content);
4152 assert_eq!(fixed, "1. Item\n\n ```\n code\n ```\n");
4153 }
4154
4155 #[test]
4162 fn idempotent_non_task_mkdocs_ordered_at_3_spaces() {
4163 assert_idempotent_mkdocs("1. Item\n\n continuation\n");
4165 }
4166
4167 #[test]
4168 fn idempotent_non_task_mkdocs_unordered_at_2_spaces() {
4169 assert_idempotent_mkdocs("- Item\n\n continuation\n");
4171 }
4172
4173 #[test]
4174 fn idempotent_non_task_mkdocs_fence_compound() {
4175 assert_idempotent_mkdocs("1. Item\n\n ```toml\n k = 1\n ```\n");
4177 }
4178
4179 #[test]
4182 fn aligned_tight_zero_indent_continuation_flagged() {
4183 let content = "- this is a long line\nthat continues on a second line\n";
4187 let warnings = check_aligned(content);
4188 assert_eq!(warnings.len(), 1);
4189 assert_eq!(warnings[0].line, 2);
4190 assert_eq!(
4191 fix_aligned(content),
4192 "- this is a long line\n that continues on a second line\n"
4193 );
4194 }
4195
4196 #[test]
4197 fn aligned_full_issue_example_made_consistent() {
4198 let content = "- this is a long line\n\
4201 that continues on a second line\n\
4202 - this is another long line\n\
4203 \x20\x20that continues on the next line\n\
4204 - yet again a long line\n\
4205 and still inconsistently spaced\n\
4206 \x20\x20and even worse\n";
4207 let expected = "- this is a long line\n\
4208 \x20\x20that continues on a second line\n\
4209 - this is another long line\n\
4210 \x20\x20that continues on the next line\n\
4211 - yet again a long line\n\
4212 \x20\x20and still inconsistently spaced\n\
4213 \x20\x20and even worse\n";
4214 assert_eq!(fix_aligned(content), expected);
4215 assert_eq!(fix_aligned(expected), expected);
4217 }
4218
4219 #[test]
4220 fn aligned_already_aligned_not_flagged() {
4221 let content = "- item\n continuation at content column\n";
4222 assert!(check_aligned(content).is_empty());
4223 }
4224
4225 #[test]
4226 fn aligned_tight_partial_indent_flagged() {
4227 let content = "- item\n continuation\n";
4229 let warnings = check_aligned(content);
4230 assert_eq!(warnings.len(), 1);
4231 assert_eq!(fix_aligned(content), "- item\n continuation\n");
4232 }
4233
4234 #[test]
4235 fn aligned_post_blank_zero_indent_still_new_paragraph() {
4236 let content = "- item\n\nnew paragraph\n";
4239 assert!(check_aligned(content).is_empty());
4240 assert_eq!(fix_aligned(content), content);
4241 }
4242
4243 #[test]
4246 fn aligned_top_level_blockquote_after_list_untouched() {
4247 let content = "- item\n> quote\n";
4251 assert!(check_aligned(content).is_empty());
4252 assert_eq!(fix_aligned(content), content);
4253 }
4254
4255 #[test]
4256 fn aligned_top_level_fence_after_list_untouched() {
4257 let content = "- item\n```\ncode\n```\n";
4258 assert!(check_aligned(content).is_empty());
4259 assert_eq!(fix_aligned(content), content);
4260 }
4261
4262 #[test]
4263 fn aligned_top_level_table_after_list_untouched() {
4264 let content = "- item\n| a | b |\n|---|---|\n| 1 | 2 |\n";
4265 assert!(check_aligned(content).is_empty());
4266 assert_eq!(fix_aligned(content), content);
4267 }
4268
4269 #[test]
4272 fn aligned_nested_tight_lazy_continuation_aligns_to_inner() {
4273 let content = "- Outer\n - Inner\ncontinuation\n";
4278 let warnings = check_aligned(content);
4279 assert_eq!(warnings.len(), 1);
4280 assert_eq!(fix_aligned(content), "- Outer\n - Inner\n continuation\n");
4281 }
4282
4283 #[test]
4284 fn aligned_nested_continuation_already_aligned_not_flagged() {
4285 let content = "- L1\n - L2\n cont of L2 at 4\n";
4286 assert!(check_aligned(content).is_empty());
4287 }
4288
4289 #[test]
4290 fn aligned_nested_idempotent() {
4291 let content = "- Outer\n - Inner\ncontinuation\n";
4292 let once = fix_aligned(content);
4293 assert_eq!(fix_aligned(&once), once);
4294 }
4295
4296 #[test]
4297 fn aligned_three_level_nesting_aligns_to_innermost() {
4298 let content = "- L1\n - L2\n - L3\ncont\n";
4301 assert_eq!(fix_aligned(content), "- L1\n - L2\n - L3\n cont\n");
4302 }
4303
4304 #[test]
4305 fn aligned_continuation_after_sibling_owned_by_last_item() {
4306 let content = "- a\n- b\nlazy\n";
4309 assert_eq!(fix_aligned(content), "- a\n- b\n lazy\n");
4310 }
4311
4312 #[test]
4313 fn aligned_multi_digit_ordered_marker_aligns_to_content_column() {
4314 let content = "10. Item\nwrap\n";
4315 assert_eq!(fix_aligned(content), "10. Item\n wrap\n");
4316 }
4317
4318 #[test]
4319 fn aligned_latent_setext_underline_is_left_alone() {
4320 let content = "- item\nText\n===\n";
4325 assert!(check_aligned(content).is_empty());
4326 assert_eq!(fix_aligned(content), content);
4327 }
4328
4329 #[test]
4330 fn aligned_reindents_prose_that_only_looks_like_an_underline() {
4331 let content = "- item\nText\n= = =\n";
4334 assert_eq!(fix_aligned(content), "- item\n Text\n = = =\n");
4335 }
4336
4337 #[test]
4338 fn aligned_latent_marker_in_continuation_is_idempotent() {
4339 let content = "# \n- \n``\n2. \n![]()";
4345 let once = fix_aligned(content);
4346 assert_eq!(fix_aligned(&once), once, "fix must be idempotent in one pass");
4347 assert_eq!(once, content, "item with a latent marker is left untouched");
4348 }
4349
4350 #[test]
4351 fn aligned_latent_table_in_continuation_is_idempotent() {
4352 let content = "- \n![`]()\n| | ` |\n| --- | --- |";
4357 let once = fix_aligned(content);
4358 assert_eq!(fix_aligned(&once), once, "fix must be idempotent in one pass");
4359 assert_eq!(once, content, "item with a latent table is left untouched");
4360 }
4361
4362 #[test]
4363 fn aligned_blockquote_nested_list_not_touched() {
4364 let content = "> - item\n> wrap\n";
4368 assert!(check_aligned(content).is_empty());
4369 assert_eq!(fix_aligned(content), content);
4370 }
4371
4372 #[test]
4375 fn aligned_task_post_checkbox_column_accepted() {
4376 let content = "- [ ] Task\n wrap\n";
4379 assert!(check_aligned(content).is_empty());
4380 assert_eq!(fix_aligned(content), content);
4381 }
4382
4383 #[test]
4384 fn aligned_task_under_indent_snaps_to_content_column() {
4385 let content = "- [ ] Task\nwrap\n";
4386 let warnings = check_aligned(content);
4387 assert_eq!(warnings.len(), 1);
4388 assert_eq!(fix_aligned(content), "- [ ] Task\n wrap\n");
4389 }
4390
4391 #[test]
4394 fn aligned_mkdocs_tight_under_indent_snaps_to_four() {
4395 let content = "- item\nwrap\n";
4397 let warnings = check_aligned_mkdocs(content);
4398 assert_eq!(warnings.len(), 1);
4399 let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
4400 assert_eq!(aligned_rule().fix(&ctx).unwrap(), "- item\n wrap\n");
4401 }
4402
4403 #[test]
4406 fn any_default_does_not_flag_tight_lazy_continuation() {
4407 let content = "- item\nwrapped at zero indent\n";
4409 assert!(check(content).is_empty());
4410 assert_eq!(fix(content), content);
4411 }
4412
4413 #[test]
4414 fn from_config_aligned_enables_tight_flagging() {
4415 let mut config = crate::config::Config::default();
4417 let mut rule_config = crate::config::RuleConfig::default();
4418 rule_config
4419 .values
4420 .insert("style".to_string(), toml::Value::String("aligned".to_string()));
4421 config.rules.insert("MD077".to_string(), rule_config);
4422
4423 let rule = MD077ListContinuationIndent::from_config(&config);
4424 let ctx = LintContext::new("- item\nwrap\n", MarkdownFlavor::Standard, None);
4425 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
4426 }
4427
4428 #[test]
4429 fn from_config_default_is_any() {
4430 let config = crate::config::Config::default();
4432 let rule = MD077ListContinuationIndent::from_config(&config);
4433 let ctx = LintContext::new("- item\nwrap\n", MarkdownFlavor::Standard, None);
4434 assert!(rule.check(&ctx).unwrap().is_empty());
4435 }
4436
4437 #[test]
4438 fn from_config_indent_sets_fixed_requirement() {
4439 let mut config = crate::config::Config::default();
4442 let mut rule_config = crate::config::RuleConfig::default();
4443 rule_config.values.insert("indent".to_string(), toml::Value::Integer(4));
4444 config.rules.insert("MD077".to_string(), rule_config);
4445
4446 let rule = MD077ListContinuationIndent::from_config(&config);
4447
4448 let ok_ctx = LintContext::new("- item\n wrap\n", MarkdownFlavor::Standard, None);
4450 assert!(rule.check(&ok_ctx).unwrap().is_empty());
4451 assert_eq!(rule.fix(&ok_ctx).unwrap(), "- item\n wrap\n");
4452
4453 let bad_ctx = LintContext::new("- item\n\n wrap\n", MarkdownFlavor::Standard, None);
4456 let warnings = rule.check(&bad_ctx).unwrap();
4457 assert_eq!(warnings.len(), 1);
4458 assert!(warnings[0].message.contains("needs 4 spaces"));
4459 }
4460
4461 #[test]
4462 fn from_config_indent_applies_per_nested_marker() {
4463 let mut config = crate::config::Config::default();
4466 let mut rule_config = crate::config::RuleConfig::default();
4467 rule_config.values.insert("indent".to_string(), toml::Value::Integer(4));
4468 config.rules.insert("MD077".to_string(), rule_config);
4469
4470 let rule = MD077ListContinuationIndent::from_config(&config);
4471 let ctx = LintContext::new("- a\n - b\n wrap\n", MarkdownFlavor::Standard, None);
4472 let warnings = rule.check(&ctx).unwrap();
4473 assert!(
4474 warnings.is_empty(),
4475 "continuation at 6 spaces should pass: {warnings:?}"
4476 );
4477 }
4478
4479 fn rule_with(settings: &[(&str, toml::Value)]) -> Box<dyn Rule> {
4481 let mut config = crate::config::Config::default();
4482 let mut rule_config = crate::config::RuleConfig::default();
4483 for (key, value) in settings {
4484 rule_config.values.insert((*key).to_string(), value.clone());
4485 }
4486 config.rules.insert("MD077".to_string(), rule_config);
4487 MD077ListContinuationIndent::from_config(&config)
4488 }
4489
4490 #[test]
4491 fn configured_indent_cannot_lower_the_strict_flavor_minimum() {
4492 let rule = rule_with(&[("indent", toml::Value::Integer(2))]);
4496
4497 let two = LintContext::new("- item\n\n wrap\n", MarkdownFlavor::MkDocs, None);
4498 let warnings = rule.check(&two).unwrap();
4499 assert_eq!(warnings.len(), 1, "2 spaces is below the MkDocs minimum: {warnings:?}");
4500 assert!(
4501 warnings[0].message.contains("needs 4 spaces") && warnings[0].message.contains("MkDocs"),
4502 "the requirement comes from MkDocs, so the message must say so: {}",
4503 warnings[0].message
4504 );
4505 assert_eq!(rule.fix(&two).unwrap(), "- item\n\n wrap\n");
4506
4507 let four = LintContext::new("- item\n\n wrap\n", MarkdownFlavor::MkDocs, None);
4509 assert!(rule.check(&four).unwrap().is_empty());
4510
4511 let standard = LintContext::new("- item\n\n wrap\n", MarkdownFlavor::Standard, None);
4515 assert_eq!(rule.check(&standard).unwrap().len(), 1);
4516 assert_eq!(rule.fix(&standard).unwrap(), "- item\n\n wrap\n");
4517 }
4518
4519 #[test]
4520 fn configured_indent_can_raise_the_strict_flavor_minimum() {
4521 let rule = rule_with(&[("indent", toml::Value::Integer(6))]);
4524 let ctx = LintContext::new("- item\n\n wrap\n", MarkdownFlavor::MkDocs, None);
4525 let warnings = rule.check(&ctx).unwrap();
4526 assert_eq!(warnings.len(), 1);
4527 assert!(
4528 warnings[0].message.contains("needs 6 spaces"),
4529 "configured 6 must win over the 4-space floor: {}",
4530 warnings[0].message
4531 );
4532 assert_eq!(rule.fix(&ctx).unwrap(), "- item\n\n wrap\n");
4533 }
4534
4535 #[test]
4536 fn configured_indent_message_does_not_claim_a_structural_consequence() {
4537 let rule = rule_with(&[("indent", toml::Value::Integer(4))]);
4541 let ctx = LintContext::new("- item\n\n wrap\n", MarkdownFlavor::Standard, None);
4542 let warnings = rule.check(&ctx).unwrap();
4543 assert_eq!(warnings.len(), 1);
4544 assert!(
4545 warnings[0].message.contains("match the configured indent"),
4546 "expected the configured-indent wording, got: {}",
4547 warnings[0].message
4548 );
4549 assert!(
4550 !warnings[0].message.contains("remain part of the list"),
4551 "the content does remain part of the list here: {}",
4552 warnings[0].message
4553 );
4554
4555 assert!(check("- item\n\n wrap\n").is_empty());
4558
4559 let escaping = check("- item\n\n wrap\n");
4562 assert_eq!(escaping.len(), 1);
4563 assert!(
4564 escaping[0].message.contains("remain part of the list"),
4565 "unconfigured under-indent keeps its structural message, got: {}",
4566 escaping[0].message
4567 );
4568 }
4569
4570 #[test]
4571 fn configured_indent_leaves_tight_lazy_continuation_to_style() {
4572 let any = rule_with(&[("indent", toml::Value::Integer(4))]);
4576 let ctx = LintContext::new("- item\n wrap\n", MarkdownFlavor::Standard, None);
4577 assert!(
4578 any.check(&ctx).unwrap().is_empty(),
4579 "style = any accepts tight lazy continuation"
4580 );
4581
4582 let aligned = rule_with(&[
4583 ("indent", toml::Value::Integer(4)),
4584 ("style", toml::Value::String("aligned".to_string())),
4585 ]);
4586 let warnings = aligned.check(&ctx).unwrap();
4587 assert_eq!(warnings.len(), 1, "style = aligned raises it: {warnings:?}");
4588 assert!(warnings[0].message.contains("expected 4"));
4589 assert_eq!(aligned.fix(&ctx).unwrap(), "- item\n wrap\n");
4590 }
4591
4592 #[test]
4593 fn aligned_tight_underindented_fence_inside_item_left_alone() {
4594 let content = "- item\n ```\n code\n ```\n";
4598 assert!(check_aligned(content).is_empty());
4599 assert_eq!(fix_aligned(content), content);
4600 }
4601
4602 #[test]
4603 fn aligned_task_under_indent_fix_is_idempotent() {
4604 let content = "- [ ] Task\nwrap\n";
4605 let once = fix_aligned(content);
4606 assert_eq!(fix_aligned(&once), once);
4607 }
4608
4609 #[test]
4610 fn aligned_partial_indent_fix_is_idempotent() {
4611 let content = "- item\n continuation\n";
4612 let once = fix_aligned(content);
4613 assert_eq!(fix_aligned(&once), once);
4614 }
4615
4616 #[test]
4617 fn fence_shift_preserves_one_space_of_interior_nesting() {
4618 let content = "1. Configure:\n\n ```json\n {\n \"a\": 1\n }\n ```\n";
4621 let expected = "1. Configure:\n\n ```json\n {\n \"a\": 1\n }\n ```\n";
4622 assert_eq!(fix(content), expected);
4623 }
4624
4625 #[test]
4626 fn fence_shift_preserves_every_interior_nesting_level() {
4627 let content = "1. Configure:\n\n ```json\n {\n \"a\": {\n \"b\": 1\n }\n }\n ```\n";
4628 let expected = "1. Configure:\n\n ```json\n {\n \"a\": {\n \"b\": 1\n }\n }\n ```\n";
4629 assert_eq!(fix(content), expected);
4630 }
4631
4632 #[test]
4633 fn fence_shift_lifts_interior_below_the_list_scope_all_the_way() {
4634 let content = "1. Configure:\n\n ```json\n{\n ```\n";
4638 let expected = "1. Configure:\n\n ```json\n {\n ```\n";
4639 assert_eq!(fix(content), expected);
4640 assert_eq!(fix(expected), expected, "and the result is stable");
4641 }
4642
4643 #[test]
4644 fn fence_shift_is_idempotent() {
4645 for content in [
4646 "1. Configure:\n\n ```json\n {\n \"a\": 1\n }\n ```\n",
4647 "1. Configure:\n\n ```json\n{\n deep\n }\n ```\n",
4648 "- item\n\n ```\n nested\n ```\n",
4649 ] {
4650 let once = fix(content);
4651 assert_eq!(fix(&once), once, "MD077 fence fix must be idempotent: {content:?}");
4652 }
4653 }
4654
4655 #[test]
4656 fn fence_already_at_the_content_column_is_left_alone() {
4657 let content = "1. Configure:\n\n ```json\n {\n \"a\": 1\n }\n ```\n";
4658 assert!(check(content).is_empty());
4659 assert_eq!(fix(content), content);
4660 }
4661
4662 #[test]
4663 fn over_indented_fence_keeps_its_interior_untouched() {
4664 let content = "1. Configure:\n\n ```json\n {\n \"a\": 1\n }\n ```\n";
4666 assert_eq!(fix(content), content);
4667 }
4668
4669 fn assert_untouched(content: &str) {
4672 assert!(check(content).is_empty(), "{content:?}: {:?}", check(content));
4673 assert_eq!(fix(content), content, "{content:?}");
4674 assert!(
4675 check_aligned(content).is_empty(),
4676 "{content:?}: {:?}",
4677 check_aligned(content)
4678 );
4679 assert_eq!(fix_aligned(content), content, "{content:?} (aligned)");
4680 }
4681
4682 #[test]
4683 fn mkdocs_aligned_fix_settles_when_a_number_follows_re_indented_prose() {
4684 let content = "# \n`?`\n<span>x</span>\n- X\n\n29. y\n*z*";
4687 let once = fix_aligned_flavor(content, MarkdownFlavor::MkDocs);
4688 assert_eq!(
4689 fix_aligned_flavor(&once, MarkdownFlavor::MkDocs),
4690 once,
4691 "{content:?} -> {once:?}"
4692 );
4693 }
4694
4695 fn assert_fixed(content: &str, expected: &str) {
4697 assert_eq!(fix(content), expected, "{content:?}");
4698 assert_eq!(fix(expected), expected, "{content:?} is not idempotent");
4699 }
4700
4701 #[test]
4702 fn bare_marker_content_sits_one_column_past_the_marker() {
4703 for content in [
4704 "-\n a\n",
4705 "+\n a\n",
4706 "*\n a\n b\n",
4707 "1.\n a\n",
4708 "10.\n a\n",
4709 "1)\n a\n",
4710 "- x\n\n -\n a\n",
4711 "-\n foo\n-\n ```\n bar\n ```\n-\n baz\n",
4712 "-\n a\n\n b\n",
4713 ] {
4714 assert_untouched(content);
4715 }
4716 }
4717
4718 #[test]
4719 fn bare_marker_item_followed_by_a_fence_keeps_the_text_after_it() {
4720 assert_untouched("-\n ```\n x\n ```\n\n after\n");
4723 assert_untouched("1.\n ```\n x\n ```\n\n after\n");
4724 }
4725
4726 #[test]
4727 fn bare_marker_over_indent_still_snaps_back() {
4728 assert_fixed("-\n a\n", "-\n a\n");
4729 assert_fixed("1.\n a\n", "1.\n a\n");
4730 assert_untouched("-\n a\n");
4732 }
4733
4734 #[test]
4735 fn bare_marker_item_holds_at_most_one_blank_line_before_its_content() {
4736 for content in ["-\n\n a\n", "-\n\n a\n", "1.\n\n a\n", "-\n a\n"] {
4739 assert_untouched(content);
4740 }
4741 assert_fixed("-\n a\n\n b\n", "-\n a\n\n b\n");
4743 }
4744
4745 #[test]
4746 fn tab_after_the_marker_is_measured_in_columns() {
4747 assert_untouched("-\tx\n\n\ty\n");
4748 assert_untouched("1.\tx\n\n\ty\n");
4749 assert_untouched("-\tx\n\n y\n");
4750 assert_fixed("-\tx\n\n y\n", "-\tx\n\n y\n");
4751 assert_fixed("-\tx\n\n\t y\n", "-\tx\n\n y\n");
4752 }
4753
4754 #[test]
4755 fn indented_code_first_puts_the_content_one_column_past_the_marker() {
4756 assert_untouched("- t\n\n y\n");
4757 assert_untouched("- t\n\n y\n");
4758 assert_untouched("1. t\n\n y\n");
4759 assert_fixed("- t\n\n y\n", "- t\n\n y\n");
4760 assert_fixed("1. t\n\n y\n", "1. t\n\n y\n");
4761 }
4762
4763 #[test]
4764 fn checkbox_text_in_indented_code_is_not_a_task() {
4765 assert_fixed("- [ ] task\n\n y\n", "- [ ] task\n\n y\n");
4767 }
4768
4769 #[test]
4770 fn items_written_on_one_line_each_own_their_continuation() {
4771 for content in [
4772 "- 1. x\n\n y\n",
4773 "1. -\n f\n",
4774 "- 1. ```\n ```\n b\n",
4775 "- - a\n ---\n",
4776 "- - a\n\n b\n",
4777 "- 1. x\n\n y\n",
4778 ] {
4779 assert_untouched(content);
4780 }
4781 assert_fixed("- 1. x\n\n y\n", "- 1. x\n\n y\n");
4782 }
4783
4784 #[test]
4785 fn items_written_on_one_line_keep_their_task_columns() {
4786 for content in ["- - [ ] task\n wrap\n", "- - [ ] task\n wrap\n"] {
4787 assert_untouched(content);
4788 }
4789 assert_untouched("- [ ] t\n\n w\n");
4790 }
4791
4792 #[test]
4793 fn an_empty_inner_item_closes_at_the_blank_line() {
4794 assert_fixed("- -\n\n a\n", "- -\n\n a\n");
4796 assert_untouched("- -\n a\n\n b\n");
4798 }
4799
4800 #[test]
4801 fn text_that_commonmark_does_not_treat_as_a_marker_or_blank_is_content() {
4802 assert_fixed(
4804 "- \u{661}\u{662}\u{663}\u{664}. x\n\n y\n",
4805 "- \u{661}\u{662}\u{663}\u{664}. x\n\n y\n",
4806 );
4807 assert_untouched("- \u{a0}\n a\n\n y\n");
4808 assert_untouched("- -\n \u{a0}\n\n b\n");
4809 }
4810
4811 #[test]
4812 fn intended_reindents_still_happen() {
4813 assert_fixed("- e\n\n t\n", "- e\n\n t\n");
4814 assert_fixed("1. a\n\n b\n", "1. a\n\n b\n");
4815 assert_fixed("- a\n\n b\n", "- a\n\n b\n");
4816 assert_eq!(fix_aligned("- a\n b\n"), "- a\n b\n");
4817 assert_eq!(check("- e\n\n t\n").len(), 1);
4818 }
4819
4820 #[test]
4821 fn whitespace_at_the_start_of_a_line_inside_a_code_span_is_kept() {
4822 assert_untouched("- > para\n `a\n \tb`\n");
4823 assert_untouched("- x\n\n `a\n b`\n");
4824 assert_fixed("- x\n\n `a`\n b\n", "- x\n\n `a`\n b\n");
4826 }
4827}