1use std::ops::ControlFlow;
7
8use serde::{Deserialize, Serialize};
9
10use crate::lint_context::{LineInfo, LintContext};
11use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
12use crate::utils::list_indent_shift::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_block_level_construct(trimmed: &str) -> bool {
94 if trimmed.starts_with("[^") && trimmed.contains("]:") {
96 return true;
97 }
98 if trimmed.starts_with("*[") && trimmed.contains("]:") {
100 return true;
101 }
102 if trimmed.starts_with('[') && !trimmed.starts_with("[^") && trimmed.contains("]: ") {
105 return true;
106 }
107 false
108 }
109
110 fn is_code_fence(trimmed: &str) -> bool {
112 let bytes = trimmed.as_bytes();
113 if bytes.len() < 3 {
114 return false;
115 }
116 let ch = bytes[0];
117 (ch == b'`' || ch == b'~') && bytes[1] == ch && bytes[2] == ch
118 }
119
120 fn is_fence_delimiter(ctx: &LintContext, info: &LineInfo, trimmed: &str) -> bool {
123 let line_end = info.byte_offset + info.byte_len;
124 Self::is_code_fence(trimmed)
125 && ctx
126 .code_block_details
127 .iter()
128 .any(|b| b.is_fenced && b.start <= line_end && info.byte_offset < b.end)
129 }
130
131 fn deeper_quote_column(ctx: &LintContext, info: &LineInfo, depth: usize) -> Option<usize> {
134 if info.blockquote.as_ref()?.nesting_level <= depth {
135 return None;
136 }
137 info.content(ctx.content).match_indices('>').nth(depth).map(|(i, _)| i)
140 }
141
142 fn opens_block_when_moved(trimmed: &str, content_col: usize, from: usize, to: usize) -> bool {
152 const MAX_BLOCK_INDENT: usize = 3;
153 from > content_col + MAX_BLOCK_INDENT
154 && to <= content_col + MAX_BLOCK_INDENT
155 && (Self::is_code_fence(trimmed)
156 || trimmed.starts_with('>')
157 || crate::lint_context::is_setext_underline_content(trimmed)
158 || crate::lint_context::is_horizontal_rule_content(trimmed)
159 || crate::utils::skip_context::is_table_line(trimmed))
160 }
161
162 fn starts_with_list_marker(trimmed: &str) -> bool {
166 let bytes = trimmed.as_bytes();
167 match bytes.first() {
168 Some(b'*' | b'-' | b'+') => bytes.get(1).is_some_and(|&b| b == b' ' || b == b'\t'),
169 Some(b'0'..=b'9') => {
170 let rest = trimmed.trim_start_matches(|c: char| c.is_ascii_digit());
171 rest.starts_with(". ") || rest.starts_with(") ")
172 }
173 _ => false,
174 }
175 }
176
177 fn fenced_block_last_line(ctx: &LintContext, info: &LineInfo, trimmed: &str) -> Option<usize> {
182 if !Self::is_code_fence(trimmed) {
183 return None;
184 }
185 let line_end = info.byte_offset + info.byte_len;
186 ctx.code_block_details
187 .iter()
188 .find(|b| b.is_fenced && info.byte_offset <= b.start && b.start <= line_end)
189 .map(|b| ctx.offset_to_line_col(b.end.saturating_sub(1).max(b.start)).0)
190 }
191
192 fn build_compound_fence_fix(
230 ctx: &LintContext,
231 opener_line: usize,
232 closer_line: usize,
233 opener_actual: usize,
234 required: usize,
235 ) -> Option<Fix> {
236 if required <= opener_actual {
237 return None;
238 }
239 let opener_info = ctx.line_info(opener_line)?;
240 let closer_info = ctx.line_info(closer_line)?;
241
242 let fix_start = opener_info.byte_offset;
243 let fix_end = closer_info.byte_offset + closer_info.byte_len;
244
245 let mut replacement = String::new();
246 for i in opener_line..=closer_line {
247 let info = ctx.line_info(i)?;
248 if i > opener_line {
249 replacement.push('\n');
250 }
251 let line = info.content(ctx.content);
252 if info.is_blank {
253 replacement.push_str(line);
255 } else {
256 let new_visual = if i == opener_line || i == closer_line {
257 required
258 } else {
259 (info.visual_indent + (required - opener_actual)).max(required)
260 };
261 for _ in 0..new_visual {
262 replacement.push(' ');
263 }
264 replacement.push_str(&line[info.indent..]);
265 }
266 }
267
268 Some(Fix::new(fix_start..fix_end, replacement))
269 }
270
271 fn walk_item_continuation<F>(
297 ctx: &LintContext,
298 item_line: usize,
299 range_end: usize,
300 marker_col: usize,
301 mut per_line: F,
302 ) where
303 F: FnMut(&ContinuationLine<'_>) -> ControlFlow<()>,
304 {
305 let mut saw_blank = false;
306 let mut saw_nested = false;
307 let mut nested_stack: Vec<(usize, usize)> = Vec::new();
313 let item_info = ctx.line_info(item_line);
314 let item_quotes = item_info
315 .and_then(|i| i.blockquote.as_ref())
316 .map_or(0, |q| q.nesting_level);
317 let content_col = item_info
318 .and_then(|i| i.list_item.as_ref())
319 .map_or(marker_col + 1, |li| li.content_column);
320
321 for line_num in (item_line + 1)..=range_end {
322 let Some(info) = ctx.line_info(line_num) else {
323 continue;
324 };
325
326 if Self::deeper_quote_column(ctx, info, item_quotes).is_some_and(|col| col < content_col) {
330 break;
331 }
332
333 let trimmed = info.content(ctx.content).trim_start();
334
335 if Self::should_skip_line(ctx, info, trimmed) {
336 continue;
337 }
338
339 if info.is_blank {
340 saw_blank = true;
341 continue;
342 }
343
344 if let Some(ref li) = info.list_item {
345 if li.marker_column > marker_col && li.marker_column < content_col {
349 break;
350 }
351 if li.marker_column > marker_col {
352 while nested_stack.last().is_some_and(|&(m, _)| m >= li.marker_column) {
355 nested_stack.pop();
356 }
357 nested_stack.push((li.marker_column, li.content_column));
358 saw_nested = true;
363 } else {
364 nested_stack.clear();
365 }
366 saw_blank = false;
367 continue;
368 }
369
370 if info.heading.is_some() || info.is_setext_heading_text || info.is_horizontal_rule {
374 break;
375 }
376
377 if Self::is_block_level_construct(trimmed) {
378 continue;
379 }
380
381 let col = info.visual_indent;
382
383 if !nested_stack.is_empty() && is_lazy_continuation(ctx, line_num - 1) {
387 continue;
388 }
389
390 while nested_stack.last().is_some_and(|&(_, c)| c > col) {
394 nested_stack.pop();
395 }
396 if !nested_stack.is_empty() {
397 continue;
398 }
399
400 if saw_blank && col <= marker_col {
401 break;
402 }
403
404 let line = ContinuationLine {
405 line_num,
406 info,
407 trimmed,
408 actual: col,
409 saw_blank,
410 saw_nested,
411 };
412 if per_line(&line).is_break() {
413 break;
414 }
415 }
416 }
417
418 fn item_range_has_latent_structure(ctx: &LintContext, item_line: usize, range_end: usize) -> bool {
438 (item_line + 1..=range_end).any(|line_num| {
439 ctx.line_info(line_num).is_some_and(|info| {
440 if info.is_blank || info.list_item.is_some() {
441 return false;
442 }
443 let trimmed = info.content(ctx.content).trim_start();
444 !Self::should_skip_line(ctx, info, trimmed)
445 && (Self::starts_with_list_marker(trimmed)
446 || crate::utils::skip_context::is_table_line(trimmed)
447 || Self::is_latent_setext_underline(ctx, line_num, trimmed))
448 })
449 })
450 }
451
452 fn is_latent_setext_underline(ctx: &LintContext, line_num: usize, trimmed: &str) -> bool {
466 crate::lint_context::is_setext_underline_content(trimmed)
467 && ctx.line_info(line_num - 1).is_some_and(|prev| {
468 prev.is_paragraph_context() && crate::lint_context::is_paragraph_text_line(prev.content(ctx.content))
469 })
470 }
471
472 fn sibling_column_usage(
482 ctx: &LintContext,
483 item_line: usize,
484 range_end: usize,
485 marker_col: usize,
486 content_col: usize,
487 task_col: usize,
488 ) -> (bool, bool) {
489 let mut uses_content = false;
490 let mut uses_task = false;
491
492 Self::walk_item_continuation(ctx, item_line, range_end, marker_col, |line| {
493 if line.actual == content_col {
494 uses_content = true;
495 }
496 if line.actual == task_col {
497 uses_task = true;
498 }
499 if uses_content && uses_task {
500 ControlFlow::Break(())
501 } else {
502 ControlFlow::Continue(())
503 }
504 });
505
506 (uses_content, uses_task)
507 }
508
509 fn compute_fix_target(
515 actual: usize,
516 required: usize,
517 task_col: Option<usize>,
518 uses_content_col: bool,
519 uses_task_col: bool,
520 ) -> usize {
521 let Some(t) = task_col else { return required };
522 match actual.abs_diff(t).cmp(&actual.abs_diff(required)) {
523 std::cmp::Ordering::Less => t,
524 std::cmp::Ordering::Greater => required,
525 std::cmp::Ordering::Equal => match (uses_task_col, uses_content_col) {
526 (true, false) => t,
527 _ => required,
528 },
529 }
530 }
531
532 fn should_skip_line(ctx: &LintContext, info: &LineInfo, trimmed: &str) -> bool {
543 if info.in_code_block && !Self::is_fence_delimiter(ctx, info, trimmed) {
544 return true;
545 }
546 info.in_front_matter
547 || info.in_footnote_definition
548 || info.in_html_block
549 || info.in_html_comment
550 || info.in_mdx_comment
551 || info.in_mkdocstrings
552 || info.in_esm_block
553 || info.in_math_block
554 || info.in_admonition
555 || info.in_content_tab
556 || info.in_pymdown_block
557 || info.in_definition_list
558 || info.in_mkdocs_html_markdown
559 || info.in_kramdown_extension_block
560 }
561
562 fn build_over_indent_warning(
571 ctx: &LintContext,
572 line: &ContinuationLine<'_>,
573 fix_target: usize,
574 message: String,
575 ) -> LintWarning {
576 let line_content = line.info.content(ctx.content);
577 let fix_start = line.info.byte_offset;
578 let fix_end = fix_start + line.info.indent;
579 LintWarning {
580 rule_name: Some("MD077".to_string()),
581 line: line.line_num,
582 column: 1,
583 end_line: line.line_num,
584 end_column: line_content.chars().count() + 1,
585 message,
586 severity: Severity::Warning,
587 fix: Some(Fix::new(fix_start..fix_end, " ".repeat(fix_target))),
588 }
589 }
590
591 fn build_under_indent_warning(
603 ctx: &LintContext,
604 line: &ContinuationLine<'_>,
605 required: usize,
606 message: String,
607 ) -> UnderIndentOutcome {
608 let line_content = line.info.content(ctx.content);
609 let fence_closer = Self::fenced_block_last_line(ctx, line.info, line.trimmed);
610
611 let (fix, warn_end_line, warn_end_column, compound_closer) = if let Some(closer_line) = fence_closer {
612 let fix = Self::build_compound_fence_fix(ctx, line.line_num, closer_line, line.actual, required);
613 let end_column = ctx
614 .line_info(closer_line)
615 .map_or(line_content.chars().count() + 1, |ci| {
616 ci.content(ctx.content).chars().count() + 1
617 });
618 let extra_flag = (closer_line != line.line_num).then_some(closer_line);
619 (fix, closer_line, end_column, extra_flag)
620 } else {
621 let fix_start = line.info.byte_offset;
622 let fix_end = fix_start + line.info.indent;
623 let fix = Some(Fix::new(fix_start..fix_end, " ".repeat(required)));
624 (fix, line.line_num, line_content.chars().count() + 1, None)
625 };
626
627 UnderIndentOutcome {
628 warning: LintWarning {
629 rule_name: Some("MD077".to_string()),
630 line: line.line_num,
631 column: 1,
632 end_line: warn_end_line,
633 end_column: warn_end_column,
634 message,
635 severity: Severity::Warning,
636 fix,
637 },
638 also_flag_line: compound_closer,
639 }
640 }
641}
642
643struct ContinuationLine<'a> {
647 line_num: usize,
648 info: &'a LineInfo,
649 trimmed: &'a str,
650 actual: usize,
651 saw_blank: bool,
652 saw_nested: bool,
656}
657
658struct UnderIndentOutcome {
663 warning: LintWarning,
664 also_flag_line: Option<usize>,
665}
666
667impl Rule for MD077ListContinuationIndent {
668 fn name(&self) -> &'static str {
669 "MD077"
670 }
671
672 fn description(&self) -> &'static str {
673 "List continuation content indentation"
674 }
675
676 fn check(&self, ctx: &LintContext) -> LintResult {
677 if ctx.content.is_empty() {
678 return Ok(Vec::new());
679 }
680
681 let strict_indent = ctx.flavor.requires_strict_list_indent();
682 let total_lines = ctx.lines.len();
683 let mut warnings = Vec::new();
684 let mut flagged_lines = std::collections::HashSet::new();
685
686 let mut items: Vec<(usize, usize, usize, Option<usize>)> = Vec::new();
695 for block in &ctx.list_blocks {
696 for &item_line in &block.item_lines {
697 if let Some(info) = ctx.line_info(item_line)
698 && let Some(ref li) = info.list_item
699 {
700 if info.blockquote.is_some() {
707 continue;
708 }
709 let line = info.content(ctx.content);
710 let task_col = Self::is_task_list_item(line, li.content_column)
711 .then_some(li.content_column + Self::TASK_CHECKBOX_PREFIX_LEN);
712 items.push((item_line, li.marker_column, li.content_column, task_col));
713 }
714 }
715 }
716 items.sort_unstable();
717 items.dedup_by_key(|&mut (ln, _, _, _)| ln);
718
719 let mut range_ends = vec![total_lines; items.len()];
732 let mut stack: Vec<usize> = Vec::new();
733 for i in (0..items.len()).rev() {
734 let marker_col = items[i].1;
735 while let Some(&top) = stack.last() {
736 if items[top].1 > marker_col {
737 stack.pop();
738 } else {
739 break;
740 }
741 }
742 range_ends[i] = stack.last().map_or(total_lines, |&j| items[j].0 - 1);
743 stack.push(i);
744 }
745
746 let scoped: Vec<(usize, usize, usize, Option<usize>, usize, usize)> = items
749 .iter()
750 .enumerate()
751 .map(|(item_idx, &(item_line, marker_col, content_col, task_col))| {
752 let required = match self.config.indent {
760 Some(indent) if strict_indent => (marker_col + indent).max(4),
761 Some(indent) => marker_col + indent,
762 None if strict_indent => content_col.max(4),
763 None => content_col,
764 };
765 (
766 item_line,
767 marker_col,
768 content_col,
769 task_col,
770 required,
771 range_ends[item_idx],
772 )
773 })
774 .collect();
775
776 let prose_candidate_lines: Vec<usize> = (1..=total_lines)
790 .filter(|&line_num| {
791 let Some(info) = ctx.line_info(line_num) else {
792 return false;
793 };
794 let trimmed = info.content(ctx.content).trim_start();
795 !Self::should_skip_line(ctx, info, trimmed)
796 && !info.is_blank
797 && info.list_item.is_none()
798 && info.heading.is_none()
799 && !info.is_setext_heading_text
800 && !info.is_horizontal_rule
801 && !Self::is_block_level_construct(trimmed)
802 })
803 .collect();
804 let range_has_prose_candidate = |after_line: usize, range_end: usize| -> bool {
807 let start = prose_candidate_lines.partition_point(|&l| l <= after_line);
808 prose_candidate_lines.get(start).is_some_and(|&l| l <= range_end)
809 };
810
811 let aligned = self.config.style == ContinuationStyle::Aligned;
838 for &(item_line, marker_col, _content_col, _task_col, required, range_end) in &scoped {
839 if !range_has_prose_candidate(item_line, range_end) {
842 continue;
843 }
844 let has_latent_structure = aligned && Self::item_range_has_latent_structure(ctx, item_line, range_end);
859 let from_configured_indent = self.config.indent.is_some_and(|indent| marker_col + indent == required);
865 Self::walk_item_continuation(ctx, item_line, range_end, marker_col, |line| {
866 let actual = line.actual;
867 let under_indented = actual < required;
868 let loose_escape = line.saw_blank && under_indented;
869 let confirmed_structure = line.info.in_code_block || line.info.blockquote.is_some();
876 let aligned_tight = aligned
877 && !has_latent_structure
878 && !line.saw_blank
879 && !line.saw_nested
880 && under_indented
881 && !confirmed_structure;
882 if (loose_escape || aligned_tight) && flagged_lines.insert(line.line_num) {
883 let message = if line.saw_blank {
884 if from_configured_indent {
885 format!(
886 "Content after blank line in list item needs {required} spaces of \
887 indentation to match the configured indent (found {actual})",
888 )
889 } else if strict_indent {
890 format!(
891 "Content inside list item needs {required} spaces of indentation \
892 for MkDocs compatibility (found {actual})",
893 )
894 } else {
895 format!(
896 "Content after blank line in list item needs {required} spaces of \
897 indentation to remain part of the list (found {actual})",
898 )
899 }
900 } else {
901 format!("Continuation line under-indented (expected {required}, found {actual})")
902 };
903 let outcome = Self::build_under_indent_warning(ctx, line, required, message);
904 if let Some(closer_line) = outcome.also_flag_line {
905 flagged_lines.insert(closer_line);
906 }
907 warnings.push(outcome.warning);
908 }
909 ControlFlow::Continue(())
910 });
911 }
912
913 for &(item_line, marker_col, content_col, task_col, required, range_end) in &scoped {
922 if !range_has_prose_candidate(item_line, range_end) {
924 continue;
925 }
926 let (uses_content_col, uses_task_col) = match task_col {
930 Some(t) => Self::sibling_column_usage(ctx, item_line, range_end, marker_col, content_col, t),
931 None => (false, false),
932 };
933
934 Self::walk_item_continuation(ctx, item_line, range_end, marker_col, |line| {
935 let actual = line.actual;
936 if actual <= required {
937 return ControlFlow::Continue(());
938 }
939 let fix_target = Self::compute_fix_target(actual, required, task_col, uses_content_col, uses_task_col);
940 if !line.info.in_code_block
941 && Some(actual) != task_col
942 && !Self::starts_with_list_marker(line.trimmed)
943 && !Self::opens_block_when_moved(line.trimmed, content_col, actual, fix_target)
944 && flagged_lines.insert(line.line_num)
945 {
946 let message = match task_col {
947 Some(t) => format!(
948 "Continuation line over-indented \
949 (expected {required} or {t}, found {actual})"
950 ),
951 None => {
952 format!("Continuation line over-indented (expected {required}, found {actual})")
953 }
954 };
955 warnings.push(Self::build_over_indent_warning(ctx, line, fix_target, message));
956 }
957 ControlFlow::Continue(())
958 });
959 }
960
961 warnings.sort_by_key(|w| (w.line, w.column));
964
965 Ok(warnings)
966 }
967
968 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
969 let warnings = self.check(ctx)?;
970 let warnings =
971 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
972 if warnings.is_empty() {
973 return Ok(ctx.content.to_string());
974 }
975
976 let mut fixes: Vec<Fix> = warnings.into_iter().filter_map(|w| w.fix).collect();
978 fixes.sort_by_key(|f| std::cmp::Reverse(f.range.start));
979
980 let mut content = ctx.content.to_string();
981 for fix in fixes {
982 if fix.range.start <= content.len() && fix.range.end <= content.len() {
983 content.replace_range(fix.range, &fix.replacement);
984 }
985 }
986
987 Ok(content)
988 }
989
990 fn category(&self) -> RuleCategory {
991 RuleCategory::List
992 }
993
994 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
995 ctx.content.is_empty() || ctx.list_blocks.is_empty()
996 }
997
998 fn as_any(&self) -> &dyn std::any::Any {
999 self
1000 }
1001
1002 crate::impl_rule_config_methods!(MD077Config);
1003}
1004
1005#[cfg(test)]
1006mod tests {
1007 use super::*;
1008 use crate::config::MarkdownFlavor;
1009
1010 fn check(content: &str) -> Vec<LintWarning> {
1011 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1012 let rule = MD077ListContinuationIndent::default();
1013 rule.check(&ctx).unwrap()
1014 }
1015
1016 fn check_mkdocs(content: &str) -> Vec<LintWarning> {
1017 let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
1018 let rule = MD077ListContinuationIndent::default();
1019 rule.check(&ctx).unwrap()
1020 }
1021
1022 fn fix(content: &str) -> String {
1023 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1024 let rule = MD077ListContinuationIndent::default();
1025 rule.fix(&ctx).unwrap()
1026 }
1027
1028 fn fix_mkdocs(content: &str) -> String {
1029 let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
1030 let rule = MD077ListContinuationIndent::default();
1031 rule.fix(&ctx).unwrap()
1032 }
1033
1034 #[test]
1035 fn test_blockquote_line_ends_the_item() {
1036 for content in ["1. item\n>\n continuation text\n", "- item\n> quote\n\n text\n"] {
1039 assert!(check(content).is_empty(), "{content:?}: {:?}", check(content));
1040 assert_eq!(fix(content), content);
1041 }
1042 let inside = "- item\n\n > quote\n\n text\n";
1044 assert_eq!(check(inside).len(), 1);
1045 assert_eq!(fix(inside), "- item\n\n > quote\n\n text\n");
1046 }
1047
1048 #[test]
1049 fn test_fence_after_an_indented_code_block_moves_as_one_block() {
1050 let content = " 2) item\n\n ```\n ```\n code\n ```\n";
1053 let fixed = fix(content);
1054 assert_eq!(fixed, " 2) item\n\n ```\n ```\n code\n ```\n");
1055 assert_eq!(fix(&fixed), fixed);
1056 }
1057
1058 #[test]
1059 fn test_marker_left_of_the_content_column_ends_the_item() {
1060 let content = "* a\n 1. b\n\n text\n";
1063 let warnings = check(content);
1064 assert_eq!(warnings.len(), 1, "{warnings:?}");
1065 let message = &warnings[0].message;
1066 assert!(message.contains("needs 5 spaces"), "{message}");
1067 let fixed = fix(content);
1068 assert_eq!(fixed, "* a\n 1. b\n\n text\n");
1069 assert_eq!(fix(&fixed), fixed);
1070
1071 let outside = "- a\n 1. b\n\n text\n";
1073 assert!(check(outside).is_empty(), "{:?}", check(outside));
1074 }
1075
1076 #[test]
1077 fn test_fence_text_in_indented_code_is_not_a_fence() {
1078 for content in [
1082 " * item\n ```\n ```\n\n ```\n",
1083 " 1. item\n - item\n\n ```\n",
1084 ] {
1085 assert!(check(content).is_empty(), "{content:?}: {:?}", check(content));
1086 assert_eq!(fix(content), content);
1087 }
1088 }
1089
1090 fn aligned_rule() -> MD077ListContinuationIndent {
1091 MD077ListContinuationIndent::new(ContinuationStyle::Aligned)
1092 }
1093
1094 fn check_aligned(content: &str) -> Vec<LintWarning> {
1095 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1096 aligned_rule().check(&ctx).unwrap()
1097 }
1098
1099 fn check_aligned_mkdocs(content: &str) -> Vec<LintWarning> {
1100 let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
1101 aligned_rule().check(&ctx).unwrap()
1102 }
1103
1104 fn fix_aligned(content: &str) -> String {
1105 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1106 aligned_rule().fix(&ctx).unwrap()
1107 }
1108
1109 fn fix_aligned_quarto(content: &str) -> String {
1110 let ctx = LintContext::new(content, MarkdownFlavor::Quarto, None);
1111 aligned_rule().fix(&ctx).unwrap()
1112 }
1113
1114 #[test]
1115 fn html_block_opened_by_an_inline_level_tag_is_left_alone() {
1116 let content = "- item\n\n <math display=\"block\">\n <mi>x</mi>\n </math>\n";
1119 assert!(check(content).is_empty(), "{:?}", check(content));
1120 assert_eq!(fix(content), content);
1121 }
1122
1123 #[test]
1124 fn html_block_in_an_item_is_left_alone_past_a_broken_tag() {
1125 let content = "- item\n\n <table>\n <td>\n <a\n >a</a\n >\n text after\n </td>\n </table>\n";
1128 assert!(check(content).is_empty(), "{:?}", check(content));
1129 assert_eq!(fix(content), content);
1130 }
1131
1132 #[test]
1133 fn lazy_continuation_does_not_close_the_nested_item() {
1134 for content in [
1139 "* a\n\n * b\n lazy\n\n para\n",
1140 "* a\n\n * b\n lazy\n\n ```\n x\n ```\n\n para\n",
1141 "* a\n\n * b\n lazy\n lazy again\n\n para\n",
1142 ] {
1143 assert!(check(content).is_empty(), "{content:?}: {:?}", check(content));
1144 assert_eq!(fix(content), content);
1145 }
1146 }
1147
1148 #[test]
1149 fn paragraph_text_that_would_open_a_block_keeps_its_indent() {
1150 for construct in [
1155 "```",
1156 "```rust",
1157 "~~~",
1158 "> q",
1159 "---",
1160 "===",
1161 "***",
1162 "_ _ _",
1163 "| x | y |\n | - | - |",
1164 ] {
1165 let content = format!("- a\n {construct}\n");
1166 assert!(check(&content).is_empty(), "{content:?}: {:?}", check(&content));
1167 assert_eq!(fix(&content), content);
1168 }
1169 }
1170
1171 #[test]
1172 fn paragraph_text_past_the_threshold_is_still_snapped_back() {
1173 let content = "- a\n text\n";
1174 assert_eq!(check(content).len(), 1);
1175 assert_eq!(fix(content), "- a\n text\n");
1176 let rule = MD077ListContinuationIndent::from_config_struct(MD077Config {
1179 indent: Some(6),
1180 ..Default::default()
1181 });
1182 let content = "- a\n > q\n";
1183 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1184 assert_eq!(rule.fix(&ctx).unwrap(), "- a\n > q\n");
1185 let content = "- a\n\n > q\n";
1188 assert_eq!(check(content).len(), 1);
1189 assert_eq!(fix(content), "- a\n\n > q\n");
1190 }
1191
1192 #[test]
1193 fn over_indent_after_a_lazy_line_is_measured_against_the_nested_item() {
1194 let content = "* a\n\n * b\n lazy\n\n para\n";
1195 let warnings = check(content);
1196 assert_eq!(warnings.len(), 1, "{warnings:?}");
1197 assert_eq!(warnings[0].line, 6);
1198 assert!(
1199 warnings[0].message.contains("expected 4, found 7"),
1200 "{}",
1201 warnings[0].message
1202 );
1203 assert_eq!(fix(content), "* a\n\n * b\n lazy\n\n para\n");
1204 }
1205
1206 #[test]
1207 fn aligned_idempotent_with_latent_marker_behind_unstable_heading() {
1208 let input = "1. \n``\n``\n- \n``";
1217 let once = fix_aligned_quarto(input);
1218 let twice = fix_aligned_quarto(&once);
1219 assert_eq!(once, twice, "MD077 aligned fix must be idempotent (Quarto)");
1220 }
1221
1222 #[test]
1223 fn latent_underline_needs_paragraph_text_above_it() {
1224 for (label, content) in [
1228 ("ATX heading", "- item\nwrap\n# Heading\n===\n"),
1229 ("thematic break", "- item\nwrap\n***\n===\n"),
1230 ("HTML block", "- item\nwrap\n<div>\n===\n"),
1231 ] {
1232 assert_eq!(
1233 check_aligned(content).len(),
1234 1,
1235 "{label}: reindenting cannot make a setext heading here, so the under-indent is reportable"
1236 );
1237 }
1238
1239 assert_eq!(
1243 check_aligned("- item\nwrap\n```\ncode\n```\n===\n").len(),
1244 2,
1245 "a closing fence is not paragraph text, so both under-indents are reportable"
1246 );
1247
1248 for (label, content) in [
1254 ("empty bullet", "- item\n wrap\n > - \n ===\n"),
1255 ("empty ordered item", "- item\n wrap\n > 1. \n ===\n"),
1256 ("empty item in a nested quote", "- item\n wrap\n > > - \n ===\n"),
1257 ] {
1258 assert_eq!(
1259 check_aligned(content).len(),
1260 1,
1261 "{label}: an item holding no text cannot become a heading's text line"
1262 );
1263 }
1264
1265 assert_eq!(
1269 check_aligned("- item\n wrap\n\n ===\n").len(),
1270 2,
1271 "nothing above the underline can become a heading's text line"
1272 );
1273 for (label, content) in [
1276 ("blank line in a quote", "- item\n wrap\n >\n ===\n"),
1277 ("quoted whitespace", "- item\n wrap\n > \n ===\n"),
1278 ] {
1279 let warnings = check_aligned(content);
1280 assert_eq!(warnings.len(), 1, "{label}: {warnings:?}");
1281 assert_eq!(warnings[0].line, 2, "{label}");
1282 }
1283
1284 assert!(
1288 check_aligned("- item\nwrap\ntext\n===\n").is_empty(),
1289 "prose above the underline is latent structure, so the item is left alone"
1290 );
1291 }
1292
1293 #[test]
1294 fn aligned_idempotent_with_lazy_continuation_out_of_a_blockquote() {
1295 let input = "- \n> *\n> a\n``";
1299 let once = fix_aligned(input);
1300 let twice = fix_aligned(&once);
1301 assert_eq!(once, twice, "MD077 aligned fix must be idempotent");
1302 }
1303
1304 #[test]
1307 fn tight_lazy_continuation_zero_indent_not_flagged() {
1308 let content = "- Item\ncontinuation\n";
1310 assert!(check(content).is_empty());
1311 }
1312
1313 #[test]
1314 fn tight_continuation_correct_indent_not_flagged() {
1315 let content = "1. Item\n continuation\n";
1317 assert!(check(content).is_empty());
1318 }
1319
1320 #[test]
1321 fn tight_continuation_over_indented_ordered() {
1322 let content = "1. This is a list item with multiple lines.\n The second line is over-indented.\n";
1324 let warnings = check(content);
1325 assert_eq!(warnings.len(), 1);
1326 assert_eq!(warnings[0].line, 2);
1327 assert!(warnings[0].message.contains("over-indented"));
1328 }
1329
1330 #[test]
1331 fn tight_continuation_over_indented_unordered() {
1332 let content = "- Item\n over-indented\n";
1334 let warnings = check(content);
1335 assert_eq!(warnings.len(), 1);
1336 assert_eq!(warnings[0].line, 2);
1337 }
1338
1339 #[test]
1340 fn tight_continuation_multiple_over_indented_lines() {
1341 let content = "1. Item\n line one\n line two\n line three\n";
1342 let warnings = check(content);
1343 assert_eq!(warnings.len(), 3);
1344 }
1345
1346 #[test]
1347 fn tight_continuation_mixed_correct_and_over() {
1348 let content = "1. Item\n correct\n over-indented\n correct again\n";
1349 let warnings = check(content);
1350 assert_eq!(warnings.len(), 1);
1351 assert_eq!(warnings[0].line, 3);
1352 }
1353
1354 #[test]
1355 fn tight_continuation_nested_over_indented() {
1356 let content = "- L1\n - L2\n over-indented continuation of L2\n";
1358 let warnings = check(content);
1359 assert_eq!(warnings.len(), 1);
1360 assert_eq!(warnings[0].line, 3);
1361 assert!(warnings[0].message.contains("expected 4"));
1363 assert!(warnings[0].message.contains("found 5"));
1364 }
1365
1366 #[test]
1367 fn tight_continuation_nested_correct_indent_not_flagged() {
1368 let content = "- L1\n - L2\n correctly indented continuation of L2\n";
1371 assert!(check(content).is_empty());
1372 }
1373
1374 #[test]
1375 fn fix_tight_continuation_nested_over_indented() {
1376 let content = "- L1\n - L2\n over-indented continuation of L2\n";
1378 let fixed = fix(content);
1379 assert_eq!(fixed, "- L1\n - L2\n over-indented continuation of L2\n");
1380 }
1381
1382 #[test]
1383 fn tight_continuation_under_indented_not_flagged() {
1384 let content = "1. Item\n under-indented\n";
1387 assert!(check(content).is_empty());
1388 }
1389
1390 #[test]
1391 fn tight_continuation_tab_over_indented() {
1392 let content = "- Item\n\tover-indented\n";
1394 let warnings = check(content);
1395 assert_eq!(warnings.len(), 1);
1396 }
1397
1398 #[test]
1399 fn fix_tight_continuation_over_indented_ordered() {
1400 let content = "1. This is a list item with multiple lines.\n The second line is over-indented.\n";
1401 let fixed = fix(content);
1402 assert_eq!(
1403 fixed,
1404 "1. This is a list item with multiple lines.\n The second line is over-indented.\n"
1405 );
1406 }
1407
1408 #[test]
1409 fn fix_tight_continuation_over_indented_unordered() {
1410 let content = "- Item\n over-indented\n";
1411 let fixed = fix(content);
1412 assert_eq!(fixed, "- Item\n over-indented\n");
1413 }
1414
1415 #[test]
1416 fn fix_tight_continuation_multiple_lines() {
1417 let content = "1. Item\n line one\n line two\n";
1418 let fixed = fix(content);
1419 assert_eq!(fixed, "1. Item\n line one\n line two\n");
1420 }
1421
1422 #[test]
1423 fn tight_continuation_mkdocs_4space_ordered_not_flagged() {
1424 let content = "1. Item\n continuation\n";
1427 assert!(check_mkdocs(content).is_empty());
1428 }
1429
1430 #[test]
1431 fn tight_continuation_mkdocs_5space_ordered_flagged() {
1432 let content = "1. Item\n over-indented\n";
1434 let warnings = check_mkdocs(content);
1435 assert_eq!(warnings.len(), 1);
1436 assert!(warnings[0].message.contains("expected 4"));
1437 assert!(warnings[0].message.contains("found 5"));
1438 }
1439
1440 #[test]
1441 fn fix_tight_continuation_mkdocs_over_indented() {
1442 let content = "1. Item\n over-indented\n";
1443 let fixed = fix_mkdocs(content);
1444 assert_eq!(fixed, "1. Item\n over-indented\n");
1445 }
1446
1447 #[test]
1448 fn tight_continuation_deeply_indented_list_markers_not_flagged() {
1449 let content = "* Level 0\n * Level 1\n * Level 2\n";
1452 assert!(check(content).is_empty());
1453 }
1454
1455 #[test]
1456 fn tight_continuation_ordered_marker_not_flagged() {
1457 let content = "- Parent\n 1. Child item\n";
1459 assert!(check(content).is_empty());
1460 }
1461
1462 #[test]
1465 fn unordered_correct_indent_no_warning() {
1466 let content = "- Item\n\n continuation\n";
1467 assert!(check(content).is_empty());
1468 }
1469
1470 #[test]
1471 fn unordered_partial_indent_warns() {
1472 let content = "- Item\n\n continuation\n";
1475 let warnings = check(content);
1476 assert_eq!(warnings.len(), 1);
1477 assert_eq!(warnings[0].line, 3);
1478 assert!(warnings[0].message.contains("2 spaces"));
1479 assert!(warnings[0].message.contains("found 1"));
1480 }
1481
1482 #[test]
1483 fn unordered_zero_indent_is_new_paragraph() {
1484 let content = "- Item\n\ncontinuation\n";
1487 assert!(check(content).is_empty());
1488 }
1489
1490 #[test]
1493 fn ordered_3space_correct_commonmark() {
1494 let content = "1. Item\n\n continuation\n";
1496 assert!(check(content).is_empty());
1497 }
1498
1499 #[test]
1500 fn ordered_2space_under_indent_commonmark() {
1501 let content = "1. Item\n\n continuation\n";
1502 let warnings = check(content);
1503 assert_eq!(warnings.len(), 1);
1504 assert!(warnings[0].message.contains("3 spaces"));
1505 assert!(warnings[0].message.contains("found 2"));
1506 }
1507
1508 #[test]
1511 fn multi_digit_marker_correct() {
1512 let content = "10. Item\n\n continuation\n";
1514 assert!(check(content).is_empty());
1515 }
1516
1517 #[test]
1518 fn multi_digit_marker_under_indent() {
1519 let content = "10. Item\n\n continuation\n";
1520 let warnings = check(content);
1521 assert_eq!(warnings.len(), 1);
1522 assert!(warnings[0].message.contains("4 spaces"));
1523 }
1524
1525 #[test]
1528 fn mkdocs_3space_ordered_warns() {
1529 let content = "1. Item\n\n continuation\n";
1531 let warnings = check_mkdocs(content);
1532 assert_eq!(warnings.len(), 1);
1533 assert!(warnings[0].message.contains("4 spaces"));
1534 assert!(warnings[0].message.contains("MkDocs"));
1535 }
1536
1537 #[test]
1538 fn mkdocs_4space_ordered_no_warning() {
1539 let content = "1. Item\n\n continuation\n";
1540 assert!(check_mkdocs(content).is_empty());
1541 }
1542
1543 #[test]
1544 fn mkdocs_unordered_2space_ok() {
1545 let content = "- Item\n\n continuation\n";
1547 assert!(check_mkdocs(content).is_empty());
1548 }
1549
1550 #[test]
1551 fn mkdocs_unordered_2space_warns() {
1552 let content = "- Item\n\n continuation\n";
1554 let warnings = check_mkdocs(content);
1555 assert_eq!(warnings.len(), 1);
1556 assert!(warnings[0].message.contains("4 spaces"));
1557 }
1558
1559 #[test]
1562 fn fix_unordered_indent() {
1563 let content = "- Item\n\n continuation\n";
1565 let fixed = fix(content);
1566 assert_eq!(fixed, "- Item\n\n continuation\n");
1567 }
1568
1569 #[test]
1570 fn fix_ordered_indent() {
1571 let content = "1. Item\n\n continuation\n";
1572 let fixed = fix(content);
1573 assert_eq!(fixed, "1. Item\n\n continuation\n");
1574 }
1575
1576 #[test]
1577 fn fix_mkdocs_indent() {
1578 let content = "1. Item\n\n continuation\n";
1579 let fixed = fix_mkdocs(content);
1580 assert_eq!(fixed, "1. Item\n\n continuation\n");
1581 }
1582
1583 #[test]
1586 fn nested_list_items_not_flagged() {
1587 let content = "- Parent\n\n - Child\n";
1588 assert!(check(content).is_empty());
1589 }
1590
1591 #[test]
1592 fn nested_list_zero_indent_is_new_paragraph() {
1593 let content = "- Parent\n - Child\n\ncontinuation of parent\n";
1595 assert!(check(content).is_empty());
1596 }
1597
1598 #[test]
1599 fn nested_list_partial_indent_flagged() {
1600 let content = "- Parent\n - Child\n\n continuation of parent\n";
1602 let warnings = check(content);
1603 assert_eq!(warnings.len(), 1);
1604 assert!(warnings[0].message.contains("2 spaces"));
1605 }
1606
1607 #[test]
1610 fn code_block_correctly_indented_no_warning() {
1611 let content = "- Item\n\n ```\n code\n ```\n";
1613 assert!(check(content).is_empty());
1614 }
1615
1616 #[test]
1617 fn code_fence_under_indented_warns() {
1618 let content = "- Item\n\n ```\n code\n ```\n";
1622 let warnings = check(content);
1623 assert_eq!(warnings.len(), 1);
1624 assert_eq!(warnings[0].line, 3);
1625 }
1626
1627 #[test]
1628 fn code_fence_under_indented_ordered_mkdocs() {
1629 let content = "1. Item\n\n ```toml\n key = \"value\"\n ```\n";
1632 assert!(check(content).is_empty()); let warnings = check_mkdocs(content);
1634 assert_eq!(warnings.len(), 1); assert_eq!(warnings[0].line, 3);
1636 assert!(warnings[0].message.contains("4 spaces"));
1637 assert!(warnings[0].message.contains("MkDocs"));
1638 }
1639
1640 #[test]
1641 fn code_fence_tilde_under_indented() {
1642 let content = "- Item\n\n ~~~\n code\n ~~~\n";
1643 let warnings = check(content);
1644 assert_eq!(warnings.len(), 1); assert_eq!(warnings[0].line, 3);
1646 }
1647
1648 #[test]
1651 fn multiple_blank_lines_zero_indent_is_new_paragraph() {
1652 let content = "- Item\n\n\ncontinuation\n";
1654 assert!(check(content).is_empty());
1655 }
1656
1657 #[test]
1658 fn multiple_blank_lines_partial_indent_flags() {
1659 let content = "- Item\n\n\n continuation\n";
1660 let warnings = check(content);
1661 assert_eq!(warnings.len(), 1);
1662 }
1663
1664 #[test]
1667 fn empty_item_no_warning() {
1668 let content = "- \n- Second\n";
1669 assert!(check(content).is_empty());
1670 }
1671
1672 #[test]
1675 fn multiple_items_mixed_indent() {
1676 let content = "1. First\n\n correct continuation\n\n2. Second\n\n wrong continuation\n";
1677 let warnings = check(content);
1678 assert_eq!(warnings.len(), 1);
1679 assert_eq!(warnings[0].line, 7);
1680 }
1681
1682 #[test]
1685 fn task_list_correct_indent() {
1686 let content = "- [ ] Task\n\n continuation\n";
1688 assert!(check(content).is_empty());
1689 }
1690
1691 #[test]
1694 fn frontmatter_not_flagged() {
1695 let content = "---\ntitle: test\n---\n\n- Item\n\n continuation\n";
1696 assert!(check(content).is_empty());
1697 }
1698
1699 #[test]
1702 fn fix_multiple_items() {
1703 let content = "1. First\n\n wrong1\n\n2. Second\n\n wrong2\n";
1704 let fixed = fix(content);
1705 assert_eq!(fixed, "1. First\n\n wrong1\n\n2. Second\n\n wrong2\n");
1706 }
1707
1708 #[test]
1709 fn fix_multiline_loose_continuation_all_lines() {
1710 let content = "1. Item\n\n line one\n line two\n line three\n";
1711 let fixed = fix(content);
1712 assert_eq!(fixed, "1. Item\n\n line one\n line two\n line three\n");
1713 }
1714
1715 #[test]
1716 fn multi_line_setext_heading_after_a_list_is_not_a_continuation() {
1717 let content = "1. Item\n\n First line\n second line\n ===\n";
1721 let warnings = check(content);
1722 assert!(
1723 warnings.is_empty(),
1724 "the text lines of a setext heading are not continuation candidates. Got: {warnings:?}"
1725 );
1726 assert_eq!(fix(content), content);
1727 }
1728
1729 #[test]
1732 fn sibling_item_boundary_respected() {
1733 let content = "- First\n- Second\n\n continuation\n";
1735 assert!(check(content).is_empty());
1736 }
1737
1738 #[test]
1741 fn blockquote_list_correct_indent_no_warning() {
1742 let content = "> - Item\n>\n> continuation\n";
1745 assert!(check(content).is_empty());
1746 }
1747
1748 #[test]
1749 fn blockquote_list_under_indent_no_false_positive() {
1750 let content = "> - Item\n>\n> continuation\n";
1755 assert!(check(content).is_empty());
1756 }
1757
1758 #[test]
1761 fn deep_nesting_correct_indent() {
1762 let content = "- L1\n - L2\n - L3\n\n continuation of L3\n";
1763 assert!(check(content).is_empty());
1764 }
1765
1766 #[test]
1767 fn deep_nesting_under_indent() {
1768 let content = "- L1\n - L2\n - L3\n\n continuation of L3\n";
1771 let warnings = check(content);
1772 assert_eq!(warnings.len(), 1);
1773 assert!(warnings[0].message.contains("6 spaces"));
1774 assert!(warnings[0].message.contains("found 5"));
1775 }
1776
1777 #[test]
1778 fn deep_nesting_middle_level_continuation_bullets() {
1779 let content = "- L1\n - L2\n - L3\n\n continuation of L2\n";
1783 assert!(check(content).is_empty());
1784 }
1785
1786 #[test]
1787 fn deep_nesting_middle_level_continuation_ordered() {
1788 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";
1791 assert!(check(content).is_empty());
1792 }
1793
1794 #[test]
1795 fn deep_nesting_outermost_continuation() {
1796 let content = "- L1\n - L2\n - L3\n\n continuation of L1\n";
1799 assert!(check(content).is_empty());
1800 }
1801
1802 #[test]
1803 fn deep_nesting_between_levels_still_flagged() {
1804 let content = "- L1\n - L2\n - L3\n\n continuation\n";
1807 let warnings = check(content);
1808 assert_eq!(warnings.len(), 1);
1809 assert!(warnings[0].message.contains("4 spaces"));
1810 assert!(warnings[0].message.contains("found 3"));
1811 }
1812
1813 #[test]
1814 fn deep_nesting_beyond_deepest_still_flagged() {
1815 let content = "- L1\n - L2\n - L3\n\n continuation\n";
1817 let warnings = check(content);
1818 assert_eq!(warnings.len(), 1);
1819 assert!(warnings[0].message.contains("over-indented"));
1820 assert!(warnings[0].message.contains("expected 6, found 7"));
1821 }
1822
1823 #[test]
1824 fn four_levels_middle_continuation() {
1825 let content = "- L1\n - L2\n - L3\n - L4\n\n continuation of L2\n";
1828 assert!(check(content).is_empty());
1829 }
1830
1831 #[test]
1832 fn nested_sibling_closes_deeper_level() {
1833 let content = "- L1\n - L2a\n - L3\n - L2b\n\n continuation of L2b\n";
1836 assert!(check(content).is_empty());
1837 }
1838
1839 #[test]
1840 fn deep_nesting_middle_level_continuation_fix_preserved() {
1841 let content = "- L1\n - L2\n - L3\n\n continuation of L2\n";
1843 assert_eq!(fix(content), content);
1844 }
1845
1846 #[test]
1849 fn loose_tab_continuation_over_indented() {
1850 let content = "- Item\n\n\tcontinuation\n";
1855 let warnings = check(content);
1856 assert_eq!(warnings.len(), 1);
1857 assert_eq!(warnings[0].line, 3);
1858 assert_eq!(fix(content), "- Item\n\n continuation\n");
1859 }
1860
1861 #[test]
1864 fn multiple_continuations_correct() {
1865 let content = "- Item\n\n para 1\n\n para 2\n\n para 3\n";
1866 assert!(check(content).is_empty());
1867 }
1868
1869 #[test]
1870 fn multiple_continuations_second_under_indent() {
1871 let content = "- Item\n\n para 1\n\n continuation 2\n";
1873 let warnings = check(content);
1874 assert_eq!(warnings.len(), 1);
1875 assert_eq!(warnings[0].line, 5);
1876 }
1877
1878 #[test]
1881 fn ordered_paren_marker_correct() {
1882 let content = "1) Item\n\n continuation\n";
1884 assert!(check(content).is_empty());
1885 }
1886
1887 #[test]
1888 fn ordered_paren_marker_under_indent() {
1889 let content = "1) Item\n\n continuation\n";
1890 let warnings = check(content);
1891 assert_eq!(warnings.len(), 1);
1892 assert!(warnings[0].message.contains("3 spaces"));
1893 }
1894
1895 #[test]
1898 fn star_marker_correct() {
1899 let content = "* Item\n\n continuation\n";
1900 assert!(check(content).is_empty());
1901 }
1902
1903 #[test]
1904 fn star_marker_under_indent() {
1905 let content = "* Item\n\n continuation\n";
1906 let warnings = check(content);
1907 assert_eq!(warnings.len(), 1);
1908 }
1909
1910 #[test]
1911 fn plus_marker_correct() {
1912 let content = "+ Item\n\n continuation\n";
1913 assert!(check(content).is_empty());
1914 }
1915
1916 #[test]
1919 fn heading_after_list_no_warning() {
1920 let content = "- Item\n\n# Heading\n";
1921 assert!(check(content).is_empty());
1922 }
1923
1924 #[test]
1927 fn hr_after_list_no_warning() {
1928 let content = "- Item\n\n---\n";
1929 assert!(check(content).is_empty());
1930 }
1931
1932 #[test]
1935 fn reference_link_def_not_flagged() {
1936 let content = "- Item\n\n [link]: https://example.com\n";
1937 assert!(check(content).is_empty());
1938 }
1939
1940 #[test]
1943 fn footnote_def_not_flagged() {
1944 let content = "- Item\n\n [^1]: footnote text\n";
1945 assert!(check(content).is_empty());
1946 }
1947
1948 #[test]
1949 fn footnote_multiline_body_after_list_not_flagged() {
1950 let content = "# A list followed by a footnote\n\n\
1954 Here is a paragraph.[^fn]\n\n\
1955 - This is a list.\n\n\
1956 [^fn]:\n\
1957 \x20\x20\x20\x20Here is a footnote that spans multiple lines.\n\
1958 \x20\x20\x20\x20It should thus be indented by at least four spaces.\n";
1959 assert!(check(content).is_empty());
1960 }
1961
1962 #[test]
1963 fn fix_footnote_multiline_body_after_list_is_noop() {
1964 let content = "# A list followed by a footnote\n\n\
1968 Here is a paragraph.[^fn]\n\n\
1969 - This is a list.\n\n\
1970 [^fn]:\n\
1971 \x20\x20\x20\x20Here is a footnote that spans multiple lines.\n\
1972 \x20\x20\x20\x20It should thus be indented by at least four spaces.\n";
1973 assert_eq!(fix(content), content);
1974 }
1975
1976 #[test]
1977 fn footnote_body_indented_past_list_content_col_not_flagged() {
1978 let content = "- Item\n\n[^fn]:\n Body line one.\n Body line two.\n";
1982 assert!(check(content).is_empty());
1983 }
1984
1985 #[test]
1986 fn list_inside_footnote_body_continuation_not_flagged() {
1987 let content = "Text.[^fn]\n\n[^fn]:\n\
1991 \x20\x20\x20\x20- nested item\n\
1992 \x20\x20\x20\x20\x20\x20\x20over-indented continuation\n";
1993 assert!(check(content).is_empty());
1994 }
1995
1996 #[test]
1997 fn footnote_multiline_body_after_list_not_flagged_mkdocs() {
1998 let content = "Here is a paragraph.[^fn]\n\n\
2002 - This is a list.\n\n\
2003 [^fn]:\n\
2004 \x20\x20\x20\x20\x20\x20Footnote body that spans\n\
2005 \x20\x20\x20\x20\x20\x20multiple indented lines.\n";
2006 assert!(check_mkdocs(content).is_empty());
2007 }
2008
2009 #[test]
2012 fn fix_deep_nesting() {
2013 let content = "- L1\n - L2\n - L3\n\n under-indented\n";
2014 let fixed = fix(content);
2015 assert_eq!(fixed, "- L1\n - L2\n - L3\n\n under-indented\n");
2016 }
2017
2018 #[test]
2019 fn fix_mkdocs_unordered() {
2020 let content = "- Item\n\n continuation\n";
2022 let fixed = fix_mkdocs(content);
2023 assert_eq!(fixed, "- Item\n\n continuation\n");
2024 }
2025
2026 #[test]
2027 fn fix_code_fence_indent() {
2028 let content = "- Item\n\n ```\n code\n ```\n";
2031 let fixed = fix(content);
2032 assert_eq!(fixed, "- Item\n\n ```\n code\n ```\n");
2033 }
2034
2035 #[test]
2036 fn fix_mkdocs_code_fence_indent() {
2037 let content = "1. Item\n\n ```toml\n key = \"val\"\n ```\n";
2039 let fixed = fix_mkdocs(content);
2040 assert_eq!(fixed, "1. Item\n\n ```toml\n key = \"val\"\n ```\n");
2041 }
2042
2043 #[test]
2046 fn empty_document_no_warning() {
2047 assert!(check("").is_empty());
2048 }
2049
2050 #[test]
2051 fn whitespace_only_no_warning() {
2052 assert!(check(" \n\n \n").is_empty());
2053 }
2054
2055 #[test]
2058 fn no_list_no_warning() {
2059 let content = "# Heading\n\nSome paragraph.\n\nAnother paragraph.\n";
2060 assert!(check(content).is_empty());
2061 }
2062
2063 #[test]
2066 fn multiline_continuation_all_lines_flagged() {
2067 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";
2068 let warnings = check(content);
2069 assert_eq!(warnings.len(), 3);
2070 assert_eq!(warnings[0].line, 3);
2071 assert_eq!(warnings[1].line, 4);
2072 assert_eq!(warnings[2].line, 5);
2073 }
2074
2075 #[test]
2076 fn multiline_continuation_with_frontmatter_fix() {
2077 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";
2078 let fixed = fix(content);
2079 assert_eq!(
2080 fixed,
2081 "---\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"
2082 );
2083 }
2084
2085 #[test]
2086 fn multiline_continuation_correct_indent_no_warning() {
2087 let content = "1. Item\n\n line one\n line two\n line three\n";
2088 assert!(check(content).is_empty());
2089 }
2090
2091 #[test]
2092 fn multiline_continuation_mixed_indent() {
2093 let content = "1. Item\n\n correct\n wrong\n correct\n";
2094 let warnings = check(content);
2095 assert_eq!(warnings.len(), 1);
2096 assert_eq!(warnings[0].line, 4);
2097 }
2098
2099 #[test]
2100 fn multiline_continuation_unordered() {
2101 let content = "- Item\n\n continuation 1\n continuation 2\n continuation 3\n";
2102 let warnings = check(content);
2103 assert_eq!(warnings.len(), 3);
2104 let fixed = fix(content);
2105 assert_eq!(
2106 fixed,
2107 "- Item\n\n continuation 1\n continuation 2\n continuation 3\n"
2108 );
2109 }
2110
2111 #[test]
2112 fn multiline_continuation_two_items_fix() {
2113 let content = "1. First\n\n cont a\n cont b\n\n2. Second\n\n cont c\n cont d\n";
2114 let fixed = fix(content);
2115 assert_eq!(
2116 fixed,
2117 "1. First\n\n cont a\n cont b\n\n2. Second\n\n cont c\n cont d\n"
2118 );
2119 }
2120
2121 #[test]
2122 fn fence_fix_does_not_break_pairing_for_md031() {
2123 let content = "#### title\n\nabc\n\n\
2130 1. ab\n\n\
2131 \x20\x20`aabbccdd`\n\n\
2132 2. cd\n\n\
2133 \x20\x20`bbcc dd ee`\n\n\
2134 \x20\x20```\n\
2135 \x20\x20abcd\n\
2136 \x20\x20ef gh\n\
2137 \x20\x20```\n\n\
2138 \x20\x20uu\n\n\
2139 \x20\x20```\n\
2140 \x20\x20cdef\n\
2141 \x20\x20gh ij\n\
2142 \x20\x20```\n";
2143 let expected = "#### title\n\nabc\n\n\
2144 1. ab\n\n\
2145 \x20\x20\x20`aabbccdd`\n\n\
2146 2. cd\n\n\
2147 \x20\x20\x20`bbcc dd ee`\n\n\
2148 \x20\x20\x20```\n\
2149 \x20\x20\x20abcd\n\
2150 \x20\x20\x20ef gh\n\
2151 \x20\x20\x20```\n\n\
2152 \x20\x20\x20uu\n\n\
2153 \x20\x20\x20```\n\
2154 \x20\x20\x20cdef\n\
2155 \x20\x20\x20gh ij\n\
2156 \x20\x20\x20```\n";
2157 assert_eq!(fix(content), expected);
2158 }
2159
2160 #[test]
2161 fn multiline_continuation_separated_by_blank() {
2162 let content = "1. Item\n\n para1 line1\n para1 line2\n\n para2 line1\n para2 line2\n";
2163 let warnings = check(content);
2164 assert_eq!(warnings.len(), 4);
2165 let fixed = fix(content);
2166 assert_eq!(
2167 fixed,
2168 "1. Item\n\n para1 line1\n para1 line2\n\n para2 line1\n para2 line2\n"
2169 );
2170 }
2171
2172 #[test]
2173 fn tab_indented_fence_is_normalized_to_spaces() {
2174 let content = "- a\n\n 100. ab\n\n\t```\n\tabcd\n\t```\n";
2182 let expected = "- a\n\n 100. ab\n\n ```\n abcd\n ```\n";
2183 assert_eq!(fix(content), expected);
2184 let code = "100. ab\n\n\t```\n\tabcd\n\t```\n";
2187 assert_eq!(fix(code), code);
2188 }
2189
2190 #[test]
2199 fn loose_continuation_over_indented_flagged() {
2200 let content = "* Item\n\n over-indented\n";
2203 let warnings = check(content);
2204 assert_eq!(warnings.len(), 1);
2205 assert_eq!(warnings[0].line, 3);
2206 assert!(warnings[0].message.contains("over-indented"));
2207 assert!(warnings[0].message.contains("expected 2"));
2208 assert!(warnings[0].message.contains("found 3"));
2209 }
2210
2211 #[test]
2212 fn loose_continuation_over_indented_multiline_mixed() {
2213 let content = "* Item\n\n over one\n correct\n over two\n";
2215 let warnings = check(content);
2216 assert_eq!(warnings.len(), 2);
2217 assert_eq!(warnings[0].line, 3);
2218 assert_eq!(warnings[1].line, 5);
2219 }
2220
2221 #[test]
2222 fn fix_loose_continuation_over_indented() {
2223 let content = "* Item\n\n over one\n correct\n over two\n";
2224 let fixed = fix(content);
2225 assert_eq!(fixed, "* Item\n\n over one\n correct\n over two\n");
2226 }
2227
2228 #[test]
2229 fn fix_tight_and_loose_items_normalized_identically() {
2230 let content = "---\ntitle: Heading\n---\n\nSome introductory text:\n\n\
2233 * 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\
2234 * 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";
2235 let expected = "---\ntitle: Heading\n---\n\nSome introductory text:\n\n\
2236 * 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\
2237 * 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";
2238 assert_eq!(fix(content), expected);
2239 }
2240
2241 #[test]
2242 fn multi_paragraph_item_loose_paragraph_over_indented() {
2243 let content = "* Item.\n tight over\n\n loose over\n";
2246 let warnings = check(content);
2247 assert_eq!(warnings.len(), 2);
2248 assert_eq!(warnings[0].line, 2);
2249 assert_eq!(warnings[1].line, 4);
2250 }
2251
2252 #[test]
2253 fn loose_indented_code_block_not_flagged() {
2254 let content = "- Item\n\n code line\n";
2258 assert!(check(content).is_empty());
2259 }
2260
2261 #[test]
2262 fn mkdocs_loose_over_indented_flagged() {
2263 let content = "1. Item\n\n over\n";
2266 let warnings = check_mkdocs(content);
2267 assert_eq!(warnings.len(), 1);
2268 assert_eq!(warnings[0].line, 3);
2269 assert!(warnings[0].message.contains("over-indented"));
2270 assert!(warnings[0].message.contains("expected 4"));
2271 assert!(warnings[0].message.contains("found 5"));
2272 }
2273
2274 #[test]
2275 fn task_list_loose_over_indented_flagged() {
2276 let content = "- [ ] Task\n\n over\n";
2279 let warnings = check(content);
2280 assert_eq!(warnings.len(), 1);
2281 assert_eq!(warnings[0].line, 3);
2282 }
2283
2284 #[test]
2285 fn loose_over_indent_boundary_below_code_block_threshold_flagged() {
2286 let content = "- Item\n\n over\n";
2291 let warnings = check(content);
2292 assert_eq!(warnings.len(), 1);
2293 assert_eq!(warnings[0].line, 3);
2294 assert!(warnings[0].message.contains("expected 2"));
2295 assert!(warnings[0].message.contains("found 5"));
2296 }
2297
2298 #[test]
2299 fn loose_over_indent_does_not_steal_nested_under_indent() {
2300 let content = "- Outer\n - Inner\n\n continuation\n";
2307 let warnings = check(content);
2308 assert_eq!(warnings.len(), 1);
2309 assert_eq!(warnings[0].line, 4);
2310 assert!(warnings[0].message.contains("4 spaces"));
2311 assert!(warnings[0].message.contains("found 3"));
2312 assert_eq!(fix(content), "- Outer\n - Inner\n\n continuation\n");
2313 }
2314
2315 #[test]
2316 fn loose_over_indent_attributes_to_deepest_enclosing_item() {
2317 let content = "- Outer\n - Inner\n\n continuation\n";
2321 let warnings = check(content);
2322 assert_eq!(warnings.len(), 1);
2323 assert_eq!(warnings[0].line, 4);
2324 assert!(warnings[0].message.contains("expected 4"));
2325 assert!(warnings[0].message.contains("found 5"));
2326 assert_eq!(fix(content), "- Outer\n - Inner\n\n continuation\n");
2327 }
2328
2329 #[test]
2338 fn loose_over_indented_fence_not_flagged() {
2339 let content = "- Item\n\n ```\n code\n ```\n";
2340 assert!(check(content).is_empty());
2341 assert_eq!(fix(content), content);
2342 }
2343
2344 #[test]
2345 fn tight_over_indented_fence_not_flagged() {
2346 let content = "- Item\n ```\n code\n ```\n";
2347 assert!(check(content).is_empty());
2348 assert_eq!(fix(content), content);
2349 }
2350
2351 #[test]
2352 fn over_indented_tilde_fence_not_flagged() {
2353 let content = "- Item\n\n ~~~\n code\n ~~~\n";
2354 assert!(check(content).is_empty());
2355 assert_eq!(fix(content), content);
2356 }
2357
2358 #[test]
2359 fn fence_like_code_content_inside_fenced_block_not_flagged() {
2360 let content = "- Item\n\n ~~~\n ```\n ~~~\n";
2363 assert!(check(content).is_empty());
2364 assert_eq!(fix(content), content);
2365 }
2366
2367 #[test]
2368 fn unterminated_over_indented_fence_not_flagged() {
2369 let content = "- Item\n\n ```\n code1\n code2deeper\n";
2372 assert!(check(content).is_empty());
2373 assert_eq!(fix(content), content);
2374 }
2375
2376 #[test]
2384 fn task_list_tight_continuation_post_checkbox_reproducer_579() {
2385 let content = "- [ ] Lorem ipsum dolor sit amet, consectetur adipiscing\n tempor incididunt ut labore.\n";
2388 assert!(check(content).is_empty());
2389 }
2390
2391 #[test]
2392 fn task_list_tight_continuation_dash_unchecked() {
2393 let content = "- [ ] Task\n continuation\n";
2394 assert!(check(content).is_empty());
2395 }
2396
2397 #[test]
2398 fn task_list_tight_continuation_dash_checked_lower() {
2399 let content = "- [x] Task\n continuation\n";
2400 assert!(check(content).is_empty());
2401 }
2402
2403 #[test]
2404 fn task_list_tight_continuation_dash_checked_upper() {
2405 let content = "- [X] Task\n continuation\n";
2406 assert!(check(content).is_empty());
2407 }
2408
2409 #[test]
2410 fn task_list_tight_continuation_star_marker() {
2411 let content = "* [ ] Task\n continuation\n";
2412 assert!(check(content).is_empty());
2413 }
2414
2415 #[test]
2416 fn task_list_tight_continuation_plus_marker() {
2417 let content = "+ [ ] Task\n continuation\n";
2418 assert!(check(content).is_empty());
2419 }
2420
2421 #[test]
2422 fn task_list_tight_continuation_content_column_still_valid() {
2423 let content = "- [ ] Task\n continuation\n";
2426 assert!(check(content).is_empty());
2427 }
2428
2429 #[test]
2430 fn task_list_tight_continuation_between_columns_still_flagged() {
2431 let content = "- [ ] Task\n continuation\n";
2434 let warnings = check(content);
2435 assert_eq!(warnings.len(), 1);
2436 assert!(warnings[0].message.contains("expected 2 or 6"));
2438 assert!(warnings[0].message.contains("found 4"));
2439 }
2440
2441 #[test]
2442 fn task_list_tight_continuation_overshoot_still_flagged() {
2443 let content = "- [ ] Task\n continuation\n";
2445 let warnings = check(content);
2446 assert_eq!(warnings.len(), 1);
2447 assert!(warnings[0].message.contains("expected 2 or 6"));
2448 assert!(warnings[0].message.contains("found 7"));
2449 }
2450
2451 #[test]
2454 fn fix_task_list_overshoot_snaps_to_task_col() {
2455 let content = "- [ ] Task\n continuation\n";
2459 let fixed = fix(content);
2460 assert_eq!(fixed, "- [ ] Task\n continuation\n");
2461 }
2462
2463 #[test]
2464 fn fix_task_list_col_5_snaps_to_task_col() {
2465 let content = "- [ ] Task\n continuation\n";
2467 let fixed = fix(content);
2468 assert_eq!(fixed, "- [ ] Task\n continuation\n");
2469 }
2470
2471 #[test]
2472 fn fix_task_list_col_3_snaps_to_content_col() {
2473 let content = "- [ ] Task\n continuation\n";
2475 let fixed = fix(content);
2476 assert_eq!(fixed, "- [ ] Task\n continuation\n");
2477 }
2478
2479 #[test]
2480 fn fix_task_list_col_4_ties_to_content_col() {
2481 let content = "- [ ] Task\n continuation\n";
2486 let fixed = fix(content);
2487 assert_eq!(fixed, "- [ ] Task\n continuation\n");
2488 }
2489
2490 #[test]
2491 fn fix_task_list_ordered_overshoot_snaps_to_task_col() {
2492 let content = "1. [ ] Task\n continuation\n";
2495 let fixed = fix(content);
2496 assert_eq!(fixed, "1. [ ] Task\n continuation\n");
2497 }
2498
2499 #[test]
2500 fn fix_task_list_ordered_under_overshoot_snaps_to_content_col() {
2501 let content = "1. [ ] Task\n continuation\n";
2504 let fixed = fix(content);
2505 assert_eq!(fixed, "1. [ ] Task\n continuation\n");
2506 }
2507
2508 #[test]
2509 fn task_list_tight_continuation_ordered_single_digit() {
2510 let content = "1. [ ] Task\n continuation\n";
2512 assert!(check(content).is_empty());
2513 }
2514
2515 #[test]
2516 fn task_list_tight_continuation_ordered_multi_digit() {
2517 let content = "10. [ ] Task\n continuation\n";
2519 assert!(check(content).is_empty());
2520 }
2521
2522 #[test]
2523 fn task_list_tight_continuation_nested_dash() {
2524 let content = "- Parent\n - [ ] Nested task\n continuation\n";
2526 assert!(check(content).is_empty());
2527 }
2528
2529 #[test]
2530 fn task_list_loose_continuation_post_checkbox_column_not_flagged() {
2531 let content = "- [ ] Task\n\n continuation\n";
2536 assert!(check(content).is_empty());
2537 }
2538
2539 #[test]
2540 fn task_list_empty_body_is_not_a_task() {
2541 let content = "- [ ]\n continuation\n";
2547 let warnings = check(content);
2548 assert_eq!(warnings.len(), 1);
2549 assert!(warnings[0].message.contains("found 4"));
2550 }
2551
2552 #[test]
2553 fn task_list_malformed_checkbox_is_not_a_task() {
2554 let content = "- [~] Not a task\n continuation\n";
2556 let warnings = check(content);
2557 assert_eq!(warnings.len(), 1);
2558 }
2559
2560 #[test]
2567 fn task_list_mkdocs_unordered_required_min_valid() {
2568 let content = "- [ ] Task\n continuation\n";
2570 assert!(check_mkdocs(content).is_empty());
2571 }
2572
2573 #[test]
2574 fn task_list_mkdocs_unordered_post_checkbox_valid() {
2575 let content = "- [ ] Task\n continuation\n";
2576 assert!(check_mkdocs(content).is_empty());
2577 }
2578
2579 #[test]
2580 fn task_list_mkdocs_unordered_between_flagged() {
2581 let content = "- [ ] Task\n continuation\n";
2583 let warnings = check_mkdocs(content);
2584 assert_eq!(warnings.len(), 1);
2585 }
2586
2587 #[test]
2588 fn task_list_mkdocs_ordered_both_columns_valid() {
2589 let at_4 = "1. [ ] Task\n continuation\n";
2591 assert!(check_mkdocs(at_4).is_empty());
2592 let at_7 = "1. [ ] Task\n continuation\n";
2593 assert!(check_mkdocs(at_7).is_empty());
2594 }
2595
2596 #[test]
2597 fn task_list_mkdocs_ordered_between_flagged() {
2598 let at_5 = "1. [ ] Task\n continuation\n";
2600 assert_eq!(check_mkdocs(at_5).len(), 1);
2601 let at_6 = "1. [ ] Task\n continuation\n";
2602 assert_eq!(check_mkdocs(at_6).len(), 1);
2603 }
2604
2605 #[test]
2615 fn fix_task_list_tie_sibling_at_task_col_snaps_to_task_col() {
2616 let content = "- [ ] Task\n aligned continuation\n tied continuation\n";
2620 let fixed = fix(content);
2621 assert_eq!(
2622 fixed,
2623 "- [ ] Task\n aligned continuation\n tied continuation\n"
2624 );
2625 }
2626
2627 #[test]
2628 fn fix_task_list_tie_sibling_at_content_col_snaps_to_content_col() {
2629 let content = "- [ ] Task\n aligned continuation\n tied continuation\n";
2632 let fixed = fix(content);
2633 assert_eq!(fixed, "- [ ] Task\n aligned continuation\n tied continuation\n");
2634 }
2635
2636 #[test]
2637 fn fix_task_list_tie_both_siblings_snaps_to_content_col() {
2638 let content = "- [ ] Task\n at content col\n at task col\n tied continuation\n";
2642 let fixed = fix(content);
2643 assert_eq!(
2644 fixed,
2645 "- [ ] Task\n at content col\n at task col\n tied continuation\n"
2646 );
2647 }
2648
2649 #[test]
2650 fn fix_task_list_tie_sees_task_col_through_tight_lazy_continuation() {
2651 let content = concat!("- [ ] Task\n", "lazy\n", " aligned at task col\n", " tied\n",);
2665 let fixed = fix(content);
2666 assert!(
2667 fixed.contains("\n tied\n"),
2668 "tied line should snap to col 6 (task col) because a task-col \
2669 sibling is visible past the tight lazy-continuation line; got:\n{fixed}"
2670 );
2671 }
2672
2673 #[test]
2680 fn task_list_tab_indented_continuation_flagged() {
2681 let content = "- [ ] Task\n\t\twrap\n";
2684 let warnings = check(content);
2685 assert_eq!(warnings.len(), 1);
2686 assert!(warnings[0].message.contains("expected 2 or 6"));
2687 assert!(warnings[0].message.contains("found 8"));
2688 }
2689
2690 #[test]
2691 fn fix_task_list_tab_indented_snaps_to_task_col() {
2692 let content = "- [ ] Task\n\t\twrap\n";
2694 let fixed = fix(content);
2695 assert_eq!(fixed, "- [ ] Task\n wrap\n");
2696 }
2697
2698 #[test]
2699 fn fix_task_list_single_tab_equidistant_snaps_to_content_col() {
2700 let content = "- [ ] Task\n\twrap\n";
2703 let fixed = fix(content);
2704 assert_eq!(fixed, "- [ ] Task\n wrap\n");
2705 }
2706
2707 #[test]
2717 fn task_list_blockquote_post_checkbox_not_flagged() {
2718 let content = "> - [ ] Task\n> continuation\n";
2720 assert!(check(content).is_empty());
2721 }
2722
2723 #[test]
2724 fn task_list_blockquote_between_cols_documented_limitation() {
2725 let content = "> - [ ] Task\n> continuation\n";
2729 assert!(check(content).is_empty());
2730 }
2731
2732 #[test]
2733 fn task_list_blockquote_overshoot_documented_limitation() {
2734 let content = "> - [ ] Task\n> continuation\n";
2736 assert!(check(content).is_empty());
2737 }
2738
2739 #[test]
2746 fn fix_task_list_mkdocs_unordered_overshoot_snaps_to_task_col() {
2747 let content = "- [ ] Task\n continuation\n";
2750 let fixed = fix_mkdocs(content);
2751 assert_eq!(fixed, "- [ ] Task\n continuation\n");
2752 }
2753
2754 #[test]
2755 fn fix_task_list_mkdocs_unordered_tie_snaps_to_required() {
2756 let content = "- [ ] Task\n continuation\n";
2759 let fixed = fix_mkdocs(content);
2760 assert_eq!(fixed, "- [ ] Task\n continuation\n");
2761 }
2762
2763 #[test]
2764 fn fix_task_list_mkdocs_ordered_overshoot_snaps_to_task_col() {
2765 let content = "1. [ ] Task\n continuation\n";
2768 let fixed = fix_mkdocs(content);
2769 assert_eq!(fixed, "1. [ ] Task\n continuation\n");
2770 }
2771
2772 #[test]
2773 fn fix_task_list_mkdocs_ordered_near_required_snaps_to_required() {
2774 let content = "1. [ ] Task\n continuation\n";
2780 let fixed = fix_mkdocs(content);
2781 assert_eq!(fixed, "1. [ ] Task\n continuation\n");
2782 }
2783
2784 #[test]
2785 fn fix_task_list_mkdocs_ordered_between_cols_snaps_to_task_col() {
2786 let content = "1. [ ] Task\n continuation\n";
2789 let fixed = fix_mkdocs(content);
2790 assert_eq!(fixed, "1. [ ] Task\n continuation\n");
2791 }
2792
2793 fn assert_idempotent(content: &str) {
2803 let once = fix(content);
2804 let twice = fix(&once);
2805 assert_eq!(once, twice, "MD077 fix was not idempotent on input: {content:?}");
2806 }
2807
2808 fn assert_idempotent_mkdocs(content: &str) {
2809 let once = fix_mkdocs(content);
2810 let twice = fix_mkdocs(&once);
2811 assert_eq!(
2812 once, twice,
2813 "MD077 (MkDocs) fix was not idempotent on input: {content:?}"
2814 );
2815 }
2816
2817 #[test]
2818 fn idempotent_task_list_between_cols() {
2819 assert_idempotent("- [ ] Task\n continuation\n");
2820 }
2821
2822 #[test]
2823 fn idempotent_task_list_overshoot() {
2824 assert_idempotent("- [ ] Task\n continuation\n");
2825 }
2826
2827 #[test]
2828 fn idempotent_task_list_under_post_checkbox() {
2829 assert_idempotent("- [ ] Task\n continuation\n");
2830 }
2831
2832 #[test]
2833 fn idempotent_task_list_near_post_checkbox() {
2834 assert_idempotent("- [ ] Task\n continuation\n");
2835 }
2836
2837 #[test]
2838 fn idempotent_task_list_tab_overshoot() {
2839 assert_idempotent("- [ ] Task\n\t\twrap\n");
2840 }
2841
2842 #[test]
2843 fn idempotent_task_list_single_tab() {
2844 assert_idempotent("- [ ] Task\n\twrap\n");
2845 }
2846
2847 #[test]
2848 fn idempotent_task_list_ordered_overshoot() {
2849 assert_idempotent("1. [ ] Task\n continuation\n");
2850 }
2851
2852 #[test]
2853 fn idempotent_task_list_ordered_under() {
2854 assert_idempotent("1. [ ] Task\n continuation\n");
2855 }
2856
2857 #[test]
2858 fn idempotent_task_list_tie_with_sibling_at_task_col() {
2859 assert_idempotent("- [ ] Task\n aligned\n tied\n");
2860 }
2861
2862 #[test]
2863 fn idempotent_task_list_tie_with_sibling_at_content_col() {
2864 assert_idempotent("- [ ] Task\n aligned\n tied\n");
2865 }
2866
2867 #[test]
2868 fn idempotent_task_list_mkdocs_unordered_overshoot() {
2869 assert_idempotent_mkdocs("- [ ] Task\n continuation\n");
2870 }
2871
2872 #[test]
2873 fn idempotent_task_list_mkdocs_unordered_tie() {
2874 assert_idempotent_mkdocs("- [ ] Task\n continuation\n");
2875 }
2876
2877 #[test]
2878 fn idempotent_task_list_mkdocs_ordered_overshoot() {
2879 assert_idempotent_mkdocs("1. [ ] Task\n continuation\n");
2880 }
2881
2882 #[test]
2883 fn idempotent_task_list_mkdocs_ordered_between() {
2884 assert_idempotent_mkdocs("1. [ ] Task\n continuation\n");
2885 }
2886
2887 #[test]
2888 fn idempotent_task_list_reproducer_579() {
2889 assert_idempotent(
2893 "- [ ] Lorem ipsum dolor sit amet, consectetur adipiscing\n tempor incididunt ut labore.\n",
2894 );
2895 }
2896
2897 #[test]
2898 fn idempotent_non_task_list_still_holds() {
2899 assert_idempotent("1. Item\n over-indented\n");
2902 assert_idempotent("- Item\n\n continuation\n");
2903 }
2904
2905 #[test]
2912 fn idempotent_non_task_loose_under_indent_ordered() {
2913 assert_idempotent("1. Item\n\n continuation\n");
2915 }
2916
2917 #[test]
2918 fn idempotent_non_task_loose_under_indent_multi_digit() {
2919 assert_idempotent("10. Item\n\n continuation\n");
2921 }
2922
2923 #[test]
2924 fn idempotent_non_task_tight_over_indent_ordered() {
2925 assert_idempotent("1. Item\n over-indented\n");
2927 }
2928
2929 #[test]
2937 fn idempotent_non_task_fence_ordered_loose() {
2938 assert_idempotent("1. Item\n\n ```rust\n let x = 1;\n ```\n");
2940 }
2941
2942 #[test]
2943 fn idempotent_non_task_fence_tilde_under_indent() {
2944 assert_idempotent("1. Item\n\n ~~~\nplain text\n ~~~\n");
2950 }
2951
2952 #[test]
2953 fn idempotent_non_task_fence_interior_above_required() {
2954 assert_idempotent("1. Item\n\n ```\n deeply indented code\n ```\n");
2958 }
2959
2960 #[test]
2961 fn fence_fix_promotes_interior_below_scope_in_single_pass() {
2962 let content = "1. Item\n\n ```\ncode\n ```\n";
2966 let fixed = fix(content);
2967 assert_eq!(fixed, "1. Item\n\n ```\n code\n ```\n");
2968 }
2969
2970 #[test]
2971 fn fence_fix_preserves_interior_offset_from_the_fence() {
2972 let content = "1. Item\n\n ```\n code\n ```\n";
2977 let fixed = fix(content);
2978 assert_eq!(fixed, "1. Item\n\n ```\n code\n ```\n");
2979 }
2980
2981 #[test]
2988 fn idempotent_non_task_mkdocs_ordered_at_3_spaces() {
2989 assert_idempotent_mkdocs("1. Item\n\n continuation\n");
2991 }
2992
2993 #[test]
2994 fn idempotent_non_task_mkdocs_unordered_at_2_spaces() {
2995 assert_idempotent_mkdocs("- Item\n\n continuation\n");
2997 }
2998
2999 #[test]
3000 fn idempotent_non_task_mkdocs_fence_compound() {
3001 assert_idempotent_mkdocs("1. Item\n\n ```toml\n k = 1\n ```\n");
3003 }
3004
3005 #[test]
3008 fn aligned_tight_zero_indent_continuation_flagged() {
3009 let content = "- this is a long line\nthat continues on a second line\n";
3013 let warnings = check_aligned(content);
3014 assert_eq!(warnings.len(), 1);
3015 assert_eq!(warnings[0].line, 2);
3016 assert_eq!(
3017 fix_aligned(content),
3018 "- this is a long line\n that continues on a second line\n"
3019 );
3020 }
3021
3022 #[test]
3023 fn aligned_full_issue_example_made_consistent() {
3024 let content = "- this is a long line\n\
3027 that continues on a second line\n\
3028 - this is another long line\n\
3029 \x20\x20that continues on the next line\n\
3030 - yet again a long line\n\
3031 and still inconsistently spaced\n\
3032 \x20\x20and even worse\n";
3033 let expected = "- this is a long line\n\
3034 \x20\x20that continues on a second line\n\
3035 - this is another long line\n\
3036 \x20\x20that continues on the next line\n\
3037 - yet again a long line\n\
3038 \x20\x20and still inconsistently spaced\n\
3039 \x20\x20and even worse\n";
3040 assert_eq!(fix_aligned(content), expected);
3041 assert_eq!(fix_aligned(expected), expected);
3043 }
3044
3045 #[test]
3046 fn aligned_already_aligned_not_flagged() {
3047 let content = "- item\n continuation at content column\n";
3048 assert!(check_aligned(content).is_empty());
3049 }
3050
3051 #[test]
3052 fn aligned_tight_partial_indent_flagged() {
3053 let content = "- item\n continuation\n";
3055 let warnings = check_aligned(content);
3056 assert_eq!(warnings.len(), 1);
3057 assert_eq!(fix_aligned(content), "- item\n continuation\n");
3058 }
3059
3060 #[test]
3061 fn aligned_post_blank_zero_indent_still_new_paragraph() {
3062 let content = "- item\n\nnew paragraph\n";
3065 assert!(check_aligned(content).is_empty());
3066 assert_eq!(fix_aligned(content), content);
3067 }
3068
3069 #[test]
3072 fn aligned_top_level_blockquote_after_list_untouched() {
3073 let content = "- item\n> quote\n";
3077 assert!(check_aligned(content).is_empty());
3078 assert_eq!(fix_aligned(content), content);
3079 }
3080
3081 #[test]
3082 fn aligned_top_level_fence_after_list_untouched() {
3083 let content = "- item\n```\ncode\n```\n";
3084 assert!(check_aligned(content).is_empty());
3085 assert_eq!(fix_aligned(content), content);
3086 }
3087
3088 #[test]
3089 fn aligned_top_level_table_after_list_untouched() {
3090 let content = "- item\n| a | b |\n|---|---|\n| 1 | 2 |\n";
3091 assert!(check_aligned(content).is_empty());
3092 assert_eq!(fix_aligned(content), content);
3093 }
3094
3095 #[test]
3098 fn aligned_nested_tight_lazy_continuation_aligns_to_inner() {
3099 let content = "- Outer\n - Inner\ncontinuation\n";
3104 let warnings = check_aligned(content);
3105 assert_eq!(warnings.len(), 1);
3106 assert_eq!(fix_aligned(content), "- Outer\n - Inner\n continuation\n");
3107 }
3108
3109 #[test]
3110 fn aligned_nested_continuation_already_aligned_not_flagged() {
3111 let content = "- L1\n - L2\n cont of L2 at 4\n";
3112 assert!(check_aligned(content).is_empty());
3113 }
3114
3115 #[test]
3116 fn aligned_nested_idempotent() {
3117 let content = "- Outer\n - Inner\ncontinuation\n";
3118 let once = fix_aligned(content);
3119 assert_eq!(fix_aligned(&once), once);
3120 }
3121
3122 #[test]
3123 fn aligned_three_level_nesting_aligns_to_innermost() {
3124 let content = "- L1\n - L2\n - L3\ncont\n";
3127 assert_eq!(fix_aligned(content), "- L1\n - L2\n - L3\n cont\n");
3128 }
3129
3130 #[test]
3131 fn aligned_continuation_after_sibling_owned_by_last_item() {
3132 let content = "- a\n- b\nlazy\n";
3135 assert_eq!(fix_aligned(content), "- a\n- b\n lazy\n");
3136 }
3137
3138 #[test]
3139 fn aligned_multi_digit_ordered_marker_aligns_to_content_column() {
3140 let content = "10. Item\nwrap\n";
3141 assert_eq!(fix_aligned(content), "10. Item\n wrap\n");
3142 }
3143
3144 #[test]
3145 fn aligned_latent_setext_underline_is_left_alone() {
3146 let content = "- item\nText\n===\n";
3151 assert!(check_aligned(content).is_empty());
3152 assert_eq!(fix_aligned(content), content);
3153 }
3154
3155 #[test]
3156 fn aligned_reindents_prose_that_only_looks_like_an_underline() {
3157 let content = "- item\nText\n= = =\n";
3160 assert_eq!(fix_aligned(content), "- item\n Text\n = = =\n");
3161 }
3162
3163 #[test]
3164 fn aligned_latent_marker_in_continuation_is_idempotent() {
3165 let content = "# \n- \n``\n2. \n![]()";
3171 let once = fix_aligned(content);
3172 assert_eq!(fix_aligned(&once), once, "fix must be idempotent in one pass");
3173 assert_eq!(once, content, "item with a latent marker is left untouched");
3174 }
3175
3176 #[test]
3177 fn aligned_latent_table_in_continuation_is_idempotent() {
3178 let content = "- \n![`]()\n| | ` |\n| --- | --- |";
3183 let once = fix_aligned(content);
3184 assert_eq!(fix_aligned(&once), once, "fix must be idempotent in one pass");
3185 assert_eq!(once, content, "item with a latent table is left untouched");
3186 }
3187
3188 #[test]
3189 fn aligned_blockquote_nested_list_not_touched() {
3190 let content = "> - item\n> wrap\n";
3194 assert!(check_aligned(content).is_empty());
3195 assert_eq!(fix_aligned(content), content);
3196 }
3197
3198 #[test]
3201 fn aligned_task_post_checkbox_column_accepted() {
3202 let content = "- [ ] Task\n wrap\n";
3205 assert!(check_aligned(content).is_empty());
3206 assert_eq!(fix_aligned(content), content);
3207 }
3208
3209 #[test]
3210 fn aligned_task_under_indent_snaps_to_content_column() {
3211 let content = "- [ ] Task\nwrap\n";
3212 let warnings = check_aligned(content);
3213 assert_eq!(warnings.len(), 1);
3214 assert_eq!(fix_aligned(content), "- [ ] Task\n wrap\n");
3215 }
3216
3217 #[test]
3220 fn aligned_mkdocs_tight_under_indent_snaps_to_four() {
3221 let content = "- item\nwrap\n";
3223 let warnings = check_aligned_mkdocs(content);
3224 assert_eq!(warnings.len(), 1);
3225 let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
3226 assert_eq!(aligned_rule().fix(&ctx).unwrap(), "- item\n wrap\n");
3227 }
3228
3229 #[test]
3232 fn any_default_does_not_flag_tight_lazy_continuation() {
3233 let content = "- item\nwrapped at zero indent\n";
3235 assert!(check(content).is_empty());
3236 assert_eq!(fix(content), content);
3237 }
3238
3239 #[test]
3240 fn from_config_aligned_enables_tight_flagging() {
3241 let mut config = crate::config::Config::default();
3243 let mut rule_config = crate::config::RuleConfig::default();
3244 rule_config
3245 .values
3246 .insert("style".to_string(), toml::Value::String("aligned".to_string()));
3247 config.rules.insert("MD077".to_string(), rule_config);
3248
3249 let rule = MD077ListContinuationIndent::from_config(&config);
3250 let ctx = LintContext::new("- item\nwrap\n", MarkdownFlavor::Standard, None);
3251 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
3252 }
3253
3254 #[test]
3255 fn from_config_default_is_any() {
3256 let config = crate::config::Config::default();
3258 let rule = MD077ListContinuationIndent::from_config(&config);
3259 let ctx = LintContext::new("- item\nwrap\n", MarkdownFlavor::Standard, None);
3260 assert!(rule.check(&ctx).unwrap().is_empty());
3261 }
3262
3263 #[test]
3264 fn from_config_indent_sets_fixed_requirement() {
3265 let mut config = crate::config::Config::default();
3268 let mut rule_config = crate::config::RuleConfig::default();
3269 rule_config.values.insert("indent".to_string(), toml::Value::Integer(4));
3270 config.rules.insert("MD077".to_string(), rule_config);
3271
3272 let rule = MD077ListContinuationIndent::from_config(&config);
3273
3274 let ok_ctx = LintContext::new("- item\n wrap\n", MarkdownFlavor::Standard, None);
3276 assert!(rule.check(&ok_ctx).unwrap().is_empty());
3277 assert_eq!(rule.fix(&ok_ctx).unwrap(), "- item\n wrap\n");
3278
3279 let bad_ctx = LintContext::new("- item\n\n wrap\n", MarkdownFlavor::Standard, None);
3282 let warnings = rule.check(&bad_ctx).unwrap();
3283 assert_eq!(warnings.len(), 1);
3284 assert!(warnings[0].message.contains("needs 4 spaces"));
3285 }
3286
3287 #[test]
3288 fn from_config_indent_applies_per_nested_marker() {
3289 let mut config = crate::config::Config::default();
3292 let mut rule_config = crate::config::RuleConfig::default();
3293 rule_config.values.insert("indent".to_string(), toml::Value::Integer(4));
3294 config.rules.insert("MD077".to_string(), rule_config);
3295
3296 let rule = MD077ListContinuationIndent::from_config(&config);
3297 let ctx = LintContext::new("- a\n - b\n wrap\n", MarkdownFlavor::Standard, None);
3298 let warnings = rule.check(&ctx).unwrap();
3299 assert!(
3300 warnings.is_empty(),
3301 "continuation at 6 spaces should pass: {warnings:?}"
3302 );
3303 }
3304
3305 fn rule_with(settings: &[(&str, toml::Value)]) -> Box<dyn Rule> {
3307 let mut config = crate::config::Config::default();
3308 let mut rule_config = crate::config::RuleConfig::default();
3309 for (key, value) in settings {
3310 rule_config.values.insert((*key).to_string(), value.clone());
3311 }
3312 config.rules.insert("MD077".to_string(), rule_config);
3313 MD077ListContinuationIndent::from_config(&config)
3314 }
3315
3316 #[test]
3317 fn configured_indent_cannot_lower_the_strict_flavor_minimum() {
3318 let rule = rule_with(&[("indent", toml::Value::Integer(2))]);
3322
3323 let two = LintContext::new("- item\n\n wrap\n", MarkdownFlavor::MkDocs, None);
3324 let warnings = rule.check(&two).unwrap();
3325 assert_eq!(warnings.len(), 1, "2 spaces is below the MkDocs minimum: {warnings:?}");
3326 assert!(
3327 warnings[0].message.contains("needs 4 spaces") && warnings[0].message.contains("MkDocs"),
3328 "the requirement comes from MkDocs, so the message must say so: {}",
3329 warnings[0].message
3330 );
3331 assert_eq!(rule.fix(&two).unwrap(), "- item\n\n wrap\n");
3332
3333 let four = LintContext::new("- item\n\n wrap\n", MarkdownFlavor::MkDocs, None);
3335 assert!(rule.check(&four).unwrap().is_empty());
3336
3337 let standard = LintContext::new("- item\n\n wrap\n", MarkdownFlavor::Standard, None);
3341 assert_eq!(rule.check(&standard).unwrap().len(), 1);
3342 assert_eq!(rule.fix(&standard).unwrap(), "- item\n\n wrap\n");
3343 }
3344
3345 #[test]
3346 fn configured_indent_can_raise_the_strict_flavor_minimum() {
3347 let rule = rule_with(&[("indent", toml::Value::Integer(6))]);
3350 let ctx = LintContext::new("- item\n\n wrap\n", MarkdownFlavor::MkDocs, None);
3351 let warnings = rule.check(&ctx).unwrap();
3352 assert_eq!(warnings.len(), 1);
3353 assert!(
3354 warnings[0].message.contains("needs 6 spaces"),
3355 "configured 6 must win over the 4-space floor: {}",
3356 warnings[0].message
3357 );
3358 assert_eq!(rule.fix(&ctx).unwrap(), "- item\n\n wrap\n");
3359 }
3360
3361 #[test]
3362 fn configured_indent_message_does_not_claim_a_structural_consequence() {
3363 let rule = rule_with(&[("indent", toml::Value::Integer(4))]);
3367 let ctx = LintContext::new("- item\n\n wrap\n", MarkdownFlavor::Standard, None);
3368 let warnings = rule.check(&ctx).unwrap();
3369 assert_eq!(warnings.len(), 1);
3370 assert!(
3371 warnings[0].message.contains("match the configured indent"),
3372 "expected the configured-indent wording, got: {}",
3373 warnings[0].message
3374 );
3375 assert!(
3376 !warnings[0].message.contains("remain part of the list"),
3377 "the content does remain part of the list here: {}",
3378 warnings[0].message
3379 );
3380
3381 assert!(check("- item\n\n wrap\n").is_empty());
3384
3385 let escaping = check("- item\n\n wrap\n");
3388 assert_eq!(escaping.len(), 1);
3389 assert!(
3390 escaping[0].message.contains("remain part of the list"),
3391 "unconfigured under-indent keeps its structural message, got: {}",
3392 escaping[0].message
3393 );
3394 }
3395
3396 #[test]
3397 fn configured_indent_leaves_tight_lazy_continuation_to_style() {
3398 let any = rule_with(&[("indent", toml::Value::Integer(4))]);
3402 let ctx = LintContext::new("- item\n wrap\n", MarkdownFlavor::Standard, None);
3403 assert!(
3404 any.check(&ctx).unwrap().is_empty(),
3405 "style = any accepts tight lazy continuation"
3406 );
3407
3408 let aligned = rule_with(&[
3409 ("indent", toml::Value::Integer(4)),
3410 ("style", toml::Value::String("aligned".to_string())),
3411 ]);
3412 let warnings = aligned.check(&ctx).unwrap();
3413 assert_eq!(warnings.len(), 1, "style = aligned raises it: {warnings:?}");
3414 assert!(warnings[0].message.contains("expected 4"));
3415 assert_eq!(aligned.fix(&ctx).unwrap(), "- item\n wrap\n");
3416 }
3417
3418 #[test]
3419 fn aligned_tight_underindented_fence_inside_item_left_alone() {
3420 let content = "- item\n ```\n code\n ```\n";
3424 assert!(check_aligned(content).is_empty());
3425 assert_eq!(fix_aligned(content), content);
3426 }
3427
3428 #[test]
3429 fn aligned_task_under_indent_fix_is_idempotent() {
3430 let content = "- [ ] Task\nwrap\n";
3431 let once = fix_aligned(content);
3432 assert_eq!(fix_aligned(&once), once);
3433 }
3434
3435 #[test]
3436 fn aligned_partial_indent_fix_is_idempotent() {
3437 let content = "- item\n continuation\n";
3438 let once = fix_aligned(content);
3439 assert_eq!(fix_aligned(&once), once);
3440 }
3441
3442 #[test]
3443 fn fence_shift_preserves_one_space_of_interior_nesting() {
3444 let content = "1. Configure:\n\n ```json\n {\n \"a\": 1\n }\n ```\n";
3447 let expected = "1. Configure:\n\n ```json\n {\n \"a\": 1\n }\n ```\n";
3448 assert_eq!(fix(content), expected);
3449 }
3450
3451 #[test]
3452 fn fence_shift_preserves_every_interior_nesting_level() {
3453 let content = "1. Configure:\n\n ```json\n {\n \"a\": {\n \"b\": 1\n }\n }\n ```\n";
3454 let expected = "1. Configure:\n\n ```json\n {\n \"a\": {\n \"b\": 1\n }\n }\n ```\n";
3455 assert_eq!(fix(content), expected);
3456 }
3457
3458 #[test]
3459 fn fence_shift_lifts_interior_below_the_list_scope_all_the_way() {
3460 let content = "1. Configure:\n\n ```json\n{\n ```\n";
3464 let expected = "1. Configure:\n\n ```json\n {\n ```\n";
3465 assert_eq!(fix(content), expected);
3466 assert_eq!(fix(expected), expected, "and the result is stable");
3467 }
3468
3469 #[test]
3470 fn fence_shift_is_idempotent() {
3471 for content in [
3472 "1. Configure:\n\n ```json\n {\n \"a\": 1\n }\n ```\n",
3473 "1. Configure:\n\n ```json\n{\n deep\n }\n ```\n",
3474 "- item\n\n ```\n nested\n ```\n",
3475 ] {
3476 let once = fix(content);
3477 assert_eq!(fix(&once), once, "MD077 fence fix must be idempotent: {content:?}");
3478 }
3479 }
3480
3481 #[test]
3482 fn fence_already_at_the_content_column_is_left_alone() {
3483 let content = "1. Configure:\n\n ```json\n {\n \"a\": 1\n }\n ```\n";
3484 assert!(check(content).is_empty());
3485 assert_eq!(fix(content), content);
3486 }
3487
3488 #[test]
3489 fn over_indented_fence_keeps_its_interior_untouched() {
3490 let content = "1. Configure:\n\n ```json\n {\n \"a\": 1\n }\n ```\n";
3492 assert_eq!(fix(content), content);
3493 }
3494}