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};
12
13mod md077_config;
14use md077_config::MD077Config;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
18#[serde(rename_all = "kebab-case")]
19pub enum ContinuationStyle {
20 #[default]
24 Any,
25 Aligned,
29}
30
31#[derive(Clone, Default)]
47pub struct MD077ListContinuationIndent {
48 config: MD077Config,
49}
50
51impl MD077ListContinuationIndent {
52 pub fn new(style: ContinuationStyle) -> Self {
55 Self {
56 config: MD077Config { style, indent: None },
57 }
58 }
59
60 pub fn from_config_struct(config: MD077Config) -> Self {
61 Self { config }
62 }
63}
64
65impl MD077ListContinuationIndent {
66 const TASK_CHECKBOX_PREFIX_LEN: usize = 4;
69
70 fn is_task_list_item(line: &str, content_col: usize) -> bool {
86 line.as_bytes()
87 .get(content_col..content_col + Self::TASK_CHECKBOX_PREFIX_LEN)
88 .is_some_and(|window| matches!(window, b"[ ] " | b"[x] " | b"[X] "))
89 }
90
91 fn is_block_level_construct(trimmed: &str) -> bool {
93 if trimmed.starts_with("[^") && trimmed.contains("]:") {
95 return true;
96 }
97 if trimmed.starts_with("*[") && trimmed.contains("]:") {
99 return true;
100 }
101 if trimmed.starts_with('[') && !trimmed.starts_with("[^") && trimmed.contains("]: ") {
104 return true;
105 }
106 false
107 }
108
109 fn is_code_fence(trimmed: &str) -> bool {
111 let bytes = trimmed.as_bytes();
112 if bytes.len() < 3 {
113 return false;
114 }
115 let ch = bytes[0];
116 (ch == b'`' || ch == b'~') && bytes[1] == ch && bytes[2] == ch
117 }
118
119 fn starts_with_list_marker(trimmed: &str) -> bool {
123 let bytes = trimmed.as_bytes();
124 match bytes.first() {
125 Some(b'*' | b'-' | b'+') => bytes.get(1).is_some_and(|&b| b == b' ' || b == b'\t'),
126 Some(b'0'..=b'9') => {
127 let rest = trimmed.trim_start_matches(|c: char| c.is_ascii_digit());
128 rest.starts_with(". ") || rest.starts_with(") ")
129 }
130 _ => false,
131 }
132 }
133
134 fn find_fence_closer(ctx: &LintContext, opener_line: usize) -> usize {
138 let mut closer_line = opener_line;
139 for peek in (opener_line + 1)..=ctx.lines.len() {
140 let Some(peek_info) = ctx.line_info(peek) else { break };
141 if peek_info.in_code_block {
142 closer_line = peek;
143 } else {
144 break;
145 }
146 }
147 closer_line
148 }
149
150 fn build_compound_fence_fix(
188 ctx: &LintContext,
189 opener_line: usize,
190 closer_line: usize,
191 opener_actual: usize,
192 required: usize,
193 ) -> Option<Fix> {
194 if required <= opener_actual {
195 return None;
196 }
197 let opener_info = ctx.line_info(opener_line)?;
198 let closer_info = ctx.line_info(closer_line)?;
199
200 let fix_start = opener_info.byte_offset;
201 let fix_end = closer_info.byte_offset + closer_info.byte_len;
202
203 let mut replacement = String::new();
204 for i in opener_line..=closer_line {
205 let info = ctx.line_info(i)?;
206 if i > opener_line {
207 replacement.push('\n');
208 }
209 let line = info.content(ctx.content);
210 if info.is_blank {
211 replacement.push_str(line);
213 } else {
214 let new_visual = if i == opener_line || i == closer_line {
215 required
216 } else {
217 (info.visual_indent + (required - opener_actual)).max(required)
218 };
219 for _ in 0..new_visual {
220 replacement.push(' ');
221 }
222 replacement.push_str(&line[info.indent..]);
223 }
224 }
225
226 Some(Fix::new(fix_start..fix_end, replacement))
227 }
228
229 fn walk_item_continuation<F>(
255 ctx: &LintContext,
256 item_line: usize,
257 range_end: usize,
258 marker_col: usize,
259 mut per_line: F,
260 ) where
261 F: FnMut(&ContinuationLine<'_>) -> ControlFlow<()>,
262 {
263 let mut saw_blank = false;
264 let mut saw_nested = false;
265 let mut nested_stack: Vec<(usize, usize)> = Vec::new();
271
272 for line_num in (item_line + 1)..=range_end {
273 let Some(info) = ctx.line_info(line_num) else {
274 continue;
275 };
276
277 let trimmed = info.content(ctx.content).trim_start();
278
279 if Self::should_skip_line(info, trimmed) {
280 continue;
281 }
282
283 if info.is_blank {
284 saw_blank = true;
285 continue;
286 }
287
288 if let Some(ref li) = info.list_item {
289 if li.marker_column > marker_col {
290 while nested_stack.last().is_some_and(|&(m, _)| m >= li.marker_column) {
293 nested_stack.pop();
294 }
295 nested_stack.push((li.marker_column, li.content_column));
296 saw_nested = true;
301 } else {
302 nested_stack.clear();
303 }
304 saw_blank = false;
305 continue;
306 }
307
308 if info.heading.is_some() || info.is_setext_heading_text || info.is_horizontal_rule {
312 break;
313 }
314
315 if Self::is_block_level_construct(trimmed) {
316 continue;
317 }
318
319 let col = info.visual_indent;
320
321 while nested_stack.last().is_some_and(|&(_, c)| c > col) {
325 nested_stack.pop();
326 }
327 if !nested_stack.is_empty() {
328 continue;
329 }
330
331 if saw_blank && col <= marker_col {
332 break;
333 }
334
335 let line = ContinuationLine {
336 line_num,
337 info,
338 trimmed,
339 actual: col,
340 saw_blank,
341 saw_nested,
342 };
343 if per_line(&line).is_break() {
344 break;
345 }
346 }
347 }
348
349 fn item_range_has_latent_structure(ctx: &LintContext, item_line: usize, range_end: usize) -> bool {
369 (item_line + 1..=range_end).any(|line_num| {
370 ctx.line_info(line_num).is_some_and(|info| {
371 if info.is_blank || info.list_item.is_some() {
372 return false;
373 }
374 let trimmed = info.content(ctx.content).trim_start();
375 !Self::should_skip_line(info, trimmed)
376 && (Self::starts_with_list_marker(trimmed)
377 || crate::utils::skip_context::is_table_line(trimmed)
378 || Self::is_latent_setext_underline(ctx, line_num, trimmed))
379 })
380 })
381 }
382
383 fn is_latent_setext_underline(ctx: &LintContext, line_num: usize, trimmed: &str) -> bool {
397 crate::lint_context::is_setext_underline_content(trimmed)
398 && ctx.line_info(line_num - 1).is_some_and(|prev| {
399 prev.is_paragraph_context() && crate::lint_context::is_paragraph_text_line(prev.content(ctx.content))
400 })
401 }
402
403 fn sibling_column_usage(
413 ctx: &LintContext,
414 item_line: usize,
415 range_end: usize,
416 marker_col: usize,
417 content_col: usize,
418 task_col: usize,
419 ) -> (bool, bool) {
420 let mut uses_content = false;
421 let mut uses_task = false;
422
423 Self::walk_item_continuation(ctx, item_line, range_end, marker_col, |line| {
424 if line.actual == content_col {
425 uses_content = true;
426 }
427 if line.actual == task_col {
428 uses_task = true;
429 }
430 if uses_content && uses_task {
431 ControlFlow::Break(())
432 } else {
433 ControlFlow::Continue(())
434 }
435 });
436
437 (uses_content, uses_task)
438 }
439
440 fn compute_fix_target(
446 actual: usize,
447 required: usize,
448 task_col: Option<usize>,
449 uses_content_col: bool,
450 uses_task_col: bool,
451 ) -> usize {
452 let Some(t) = task_col else { return required };
453 match actual.abs_diff(t).cmp(&actual.abs_diff(required)) {
454 std::cmp::Ordering::Less => t,
455 std::cmp::Ordering::Greater => required,
456 std::cmp::Ordering::Equal => match (uses_task_col, uses_content_col) {
457 (true, false) => t,
458 _ => required,
459 },
460 }
461 }
462
463 fn should_skip_line(info: &crate::lint_context::LineInfo, trimmed: &str) -> bool {
474 if info.in_code_block && !Self::is_code_fence(trimmed) {
475 return true;
476 }
477 info.in_front_matter
478 || info.in_footnote_definition
479 || info.in_html_block
480 || info.in_html_comment
481 || info.in_mdx_comment
482 || info.in_mkdocstrings
483 || info.in_esm_block
484 || info.in_math_block
485 || info.in_admonition
486 || info.in_content_tab
487 || info.in_pymdown_block
488 || info.in_definition_list
489 || info.in_mkdocs_html_markdown
490 || info.in_kramdown_extension_block
491 }
492
493 fn build_over_indent_warning(
502 ctx: &LintContext,
503 line: &ContinuationLine<'_>,
504 fix_target: usize,
505 message: String,
506 ) -> LintWarning {
507 let line_content = line.info.content(ctx.content);
508 let fix_start = line.info.byte_offset;
509 let fix_end = fix_start + line.info.indent;
510 LintWarning {
511 rule_name: Some("MD077".to_string()),
512 line: line.line_num,
513 column: 1,
514 end_line: line.line_num,
515 end_column: line_content.chars().count() + 1,
516 message,
517 severity: Severity::Warning,
518 fix: Some(Fix::new(fix_start..fix_end, " ".repeat(fix_target))),
519 }
520 }
521
522 fn build_under_indent_warning(
534 ctx: &LintContext,
535 line: &ContinuationLine<'_>,
536 required: usize,
537 message: String,
538 ) -> UnderIndentOutcome {
539 let line_content = line.info.content(ctx.content);
540 let is_fence_opener = line.info.in_code_block
541 && Self::is_code_fence(line.trimmed)
542 && ctx.line_info(line.line_num - 1).is_none_or(|p| !p.in_code_block);
543
544 let (fix, warn_end_line, warn_end_column, compound_closer) = if is_fence_opener {
545 let closer_line = Self::find_fence_closer(ctx, line.line_num);
546 let fix = Self::build_compound_fence_fix(ctx, line.line_num, closer_line, line.actual, required);
547 let end_column = ctx
548 .line_info(closer_line)
549 .map_or(line_content.chars().count() + 1, |ci| {
550 ci.content(ctx.content).chars().count() + 1
551 });
552 let extra_flag = (closer_line != line.line_num).then_some(closer_line);
553 (fix, closer_line, end_column, extra_flag)
554 } else {
555 let fix_start = line.info.byte_offset;
556 let fix_end = fix_start + line.info.indent;
557 let fix = Some(Fix::new(fix_start..fix_end, " ".repeat(required)));
558 (fix, line.line_num, line_content.chars().count() + 1, None)
559 };
560
561 UnderIndentOutcome {
562 warning: LintWarning {
563 rule_name: Some("MD077".to_string()),
564 line: line.line_num,
565 column: 1,
566 end_line: warn_end_line,
567 end_column: warn_end_column,
568 message,
569 severity: Severity::Warning,
570 fix,
571 },
572 also_flag_line: compound_closer,
573 }
574 }
575}
576
577struct ContinuationLine<'a> {
581 line_num: usize,
582 info: &'a LineInfo,
583 trimmed: &'a str,
584 actual: usize,
585 saw_blank: bool,
586 saw_nested: bool,
590}
591
592struct UnderIndentOutcome {
597 warning: LintWarning,
598 also_flag_line: Option<usize>,
599}
600
601impl Rule for MD077ListContinuationIndent {
602 fn name(&self) -> &'static str {
603 "MD077"
604 }
605
606 fn description(&self) -> &'static str {
607 "List continuation content indentation"
608 }
609
610 fn check(&self, ctx: &LintContext) -> LintResult {
611 if ctx.content.is_empty() {
612 return Ok(Vec::new());
613 }
614
615 let strict_indent = ctx.flavor.requires_strict_list_indent();
616 let total_lines = ctx.lines.len();
617 let mut warnings = Vec::new();
618 let mut flagged_lines = std::collections::HashSet::new();
619
620 let mut items: Vec<(usize, usize, usize, Option<usize>)> = Vec::new();
629 for block in &ctx.list_blocks {
630 for &item_line in &block.item_lines {
631 if let Some(info) = ctx.line_info(item_line)
632 && let Some(ref li) = info.list_item
633 {
634 if info.blockquote.is_some() {
641 continue;
642 }
643 let line = info.content(ctx.content);
644 let task_col = Self::is_task_list_item(line, li.content_column)
645 .then_some(li.content_column + Self::TASK_CHECKBOX_PREFIX_LEN);
646 items.push((item_line, li.marker_column, li.content_column, task_col));
647 }
648 }
649 }
650 items.sort_unstable();
651 items.dedup_by_key(|&mut (ln, _, _, _)| ln);
652
653 let mut range_ends = vec![total_lines; items.len()];
666 let mut stack: Vec<usize> = Vec::new();
667 for i in (0..items.len()).rev() {
668 let marker_col = items[i].1;
669 while let Some(&top) = stack.last() {
670 if items[top].1 > marker_col {
671 stack.pop();
672 } else {
673 break;
674 }
675 }
676 range_ends[i] = stack.last().map_or(total_lines, |&j| items[j].0 - 1);
677 stack.push(i);
678 }
679
680 let scoped: Vec<(usize, usize, usize, Option<usize>, usize, usize)> = items
683 .iter()
684 .enumerate()
685 .map(|(item_idx, &(item_line, marker_col, content_col, task_col))| {
686 let required = match self.config.indent {
694 Some(indent) if strict_indent => (marker_col + indent).max(4),
695 Some(indent) => marker_col + indent,
696 None if strict_indent => content_col.max(4),
697 None => content_col,
698 };
699 (
700 item_line,
701 marker_col,
702 content_col,
703 task_col,
704 required,
705 range_ends[item_idx],
706 )
707 })
708 .collect();
709
710 let prose_candidate_lines: Vec<usize> = (1..=total_lines)
724 .filter(|&line_num| {
725 let Some(info) = ctx.line_info(line_num) else {
726 return false;
727 };
728 let trimmed = info.content(ctx.content).trim_start();
729 !Self::should_skip_line(info, trimmed)
730 && !info.is_blank
731 && info.list_item.is_none()
732 && info.heading.is_none()
733 && !info.is_setext_heading_text
734 && !info.is_horizontal_rule
735 && !Self::is_block_level_construct(trimmed)
736 })
737 .collect();
738 let range_has_prose_candidate = |after_line: usize, range_end: usize| -> bool {
741 let start = prose_candidate_lines.partition_point(|&l| l <= after_line);
742 prose_candidate_lines.get(start).is_some_and(|&l| l <= range_end)
743 };
744
745 let aligned = self.config.style == ContinuationStyle::Aligned;
772 for &(item_line, marker_col, _content_col, _task_col, required, range_end) in &scoped {
773 if !range_has_prose_candidate(item_line, range_end) {
776 continue;
777 }
778 let has_latent_structure = aligned && Self::item_range_has_latent_structure(ctx, item_line, range_end);
793 let from_configured_indent = self.config.indent.is_some_and(|indent| marker_col + indent == required);
799 Self::walk_item_continuation(ctx, item_line, range_end, marker_col, |line| {
800 let actual = line.actual;
801 let under_indented = actual < required;
802 let loose_escape = line.saw_blank && under_indented;
803 let confirmed_structure = line.info.in_code_block || line.info.blockquote.is_some();
810 let aligned_tight = aligned
811 && !has_latent_structure
812 && !line.saw_blank
813 && !line.saw_nested
814 && under_indented
815 && !confirmed_structure;
816 if (loose_escape || aligned_tight) && flagged_lines.insert(line.line_num) {
817 let message = if line.saw_blank {
818 if from_configured_indent {
819 format!(
820 "Content after blank line in list item needs {required} spaces of \
821 indentation to match the configured indent (found {actual})",
822 )
823 } else if strict_indent {
824 format!(
825 "Content inside list item needs {required} spaces of indentation \
826 for MkDocs compatibility (found {actual})",
827 )
828 } else {
829 format!(
830 "Content after blank line in list item needs {required} spaces of \
831 indentation to remain part of the list (found {actual})",
832 )
833 }
834 } else {
835 format!("Continuation line under-indented (expected {required}, found {actual})")
836 };
837 let outcome = Self::build_under_indent_warning(ctx, line, required, message);
838 if let Some(closer_line) = outcome.also_flag_line {
839 flagged_lines.insert(closer_line);
840 }
841 warnings.push(outcome.warning);
842 }
843 ControlFlow::Continue(())
844 });
845 }
846
847 for &(item_line, marker_col, content_col, task_col, required, range_end) in &scoped {
856 if !range_has_prose_candidate(item_line, range_end) {
858 continue;
859 }
860 let (uses_content_col, uses_task_col) = match task_col {
864 Some(t) => Self::sibling_column_usage(ctx, item_line, range_end, marker_col, content_col, t),
865 None => (false, false),
866 };
867
868 Self::walk_item_continuation(ctx, item_line, range_end, marker_col, |line| {
869 let actual = line.actual;
870 if actual > required
871 && !line.info.in_code_block
872 && Some(actual) != task_col
873 && !Self::starts_with_list_marker(line.trimmed)
874 && flagged_lines.insert(line.line_num)
875 {
876 let fix_target =
877 Self::compute_fix_target(actual, required, task_col, uses_content_col, uses_task_col);
878 let message = match task_col {
879 Some(t) => format!(
880 "Continuation line over-indented \
881 (expected {required} or {t}, found {actual})"
882 ),
883 None => {
884 format!("Continuation line over-indented (expected {required}, found {actual})")
885 }
886 };
887 warnings.push(Self::build_over_indent_warning(ctx, line, fix_target, message));
888 }
889 ControlFlow::Continue(())
890 });
891 }
892
893 warnings.sort_by_key(|w| (w.line, w.column));
896
897 Ok(warnings)
898 }
899
900 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
901 let warnings = self.check(ctx)?;
902 let warnings =
903 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
904 if warnings.is_empty() {
905 return Ok(ctx.content.to_string());
906 }
907
908 let mut fixes: Vec<Fix> = warnings.into_iter().filter_map(|w| w.fix).collect();
910 fixes.sort_by_key(|f| std::cmp::Reverse(f.range.start));
911
912 let mut content = ctx.content.to_string();
913 for fix in fixes {
914 if fix.range.start <= content.len() && fix.range.end <= content.len() {
915 content.replace_range(fix.range, &fix.replacement);
916 }
917 }
918
919 Ok(content)
920 }
921
922 fn category(&self) -> RuleCategory {
923 RuleCategory::List
924 }
925
926 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
927 ctx.content.is_empty() || ctx.list_blocks.is_empty()
928 }
929
930 fn as_any(&self) -> &dyn std::any::Any {
931 self
932 }
933
934 crate::impl_rule_config_methods!(MD077Config);
935}
936
937#[cfg(test)]
938mod tests {
939 use super::*;
940 use crate::config::MarkdownFlavor;
941
942 fn check(content: &str) -> Vec<LintWarning> {
943 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
944 let rule = MD077ListContinuationIndent::default();
945 rule.check(&ctx).unwrap()
946 }
947
948 fn check_mkdocs(content: &str) -> Vec<LintWarning> {
949 let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
950 let rule = MD077ListContinuationIndent::default();
951 rule.check(&ctx).unwrap()
952 }
953
954 fn fix(content: &str) -> String {
955 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
956 let rule = MD077ListContinuationIndent::default();
957 rule.fix(&ctx).unwrap()
958 }
959
960 fn fix_mkdocs(content: &str) -> String {
961 let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
962 let rule = MD077ListContinuationIndent::default();
963 rule.fix(&ctx).unwrap()
964 }
965
966 fn aligned_rule() -> MD077ListContinuationIndent {
967 MD077ListContinuationIndent::new(ContinuationStyle::Aligned)
968 }
969
970 fn check_aligned(content: &str) -> Vec<LintWarning> {
971 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
972 aligned_rule().check(&ctx).unwrap()
973 }
974
975 fn check_aligned_mkdocs(content: &str) -> Vec<LintWarning> {
976 let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
977 aligned_rule().check(&ctx).unwrap()
978 }
979
980 fn fix_aligned(content: &str) -> String {
981 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
982 aligned_rule().fix(&ctx).unwrap()
983 }
984
985 fn fix_aligned_quarto(content: &str) -> String {
986 let ctx = LintContext::new(content, MarkdownFlavor::Quarto, None);
987 aligned_rule().fix(&ctx).unwrap()
988 }
989
990 #[test]
991 fn aligned_idempotent_with_latent_marker_behind_unstable_heading() {
992 let input = "1. \n``\n``\n- \n``";
1001 let once = fix_aligned_quarto(input);
1002 let twice = fix_aligned_quarto(&once);
1003 assert_eq!(once, twice, "MD077 aligned fix must be idempotent (Quarto)");
1004 }
1005
1006 #[test]
1007 fn latent_underline_needs_paragraph_text_above_it() {
1008 for (label, content) in [
1012 ("ATX heading", "- item\nwrap\n# Heading\n===\n"),
1013 ("thematic break", "- item\nwrap\n***\n===\n"),
1014 ("HTML block", "- item\nwrap\n<div>\n===\n"),
1015 ] {
1016 assert_eq!(
1017 check_aligned(content).len(),
1018 1,
1019 "{label}: reindenting cannot make a setext heading here, so the under-indent is reportable"
1020 );
1021 }
1022
1023 assert_eq!(
1027 check_aligned("- item\nwrap\n```\ncode\n```\n===\n").len(),
1028 2,
1029 "a closing fence is not paragraph text, so both under-indents are reportable"
1030 );
1031
1032 for (label, content) in [
1038 ("empty bullet", "- item\n wrap\n > - \n ===\n"),
1039 ("empty ordered item", "- item\n wrap\n > 1. \n ===\n"),
1040 ("empty item in a nested quote", "- item\n wrap\n > > - \n ===\n"),
1041 ] {
1042 assert_eq!(
1043 check_aligned(content).len(),
1044 1,
1045 "{label}: an item holding no text cannot become a heading's text line"
1046 );
1047 }
1048
1049 for (label, content) in [
1053 ("bare blank line", "- item\n wrap\n\n ===\n"),
1054 ("blank line in a quote", "- item\n wrap\n >\n ===\n"),
1055 ("quoted whitespace", "- item\n wrap\n > \n ===\n"),
1056 ] {
1057 assert_eq!(
1058 check_aligned(content).len(),
1059 2,
1060 "{label}: nothing above the underline can become a heading's text line"
1061 );
1062 }
1063
1064 assert!(
1068 check_aligned("- item\nwrap\ntext\n===\n").is_empty(),
1069 "prose above the underline is latent structure, so the item is left alone"
1070 );
1071 }
1072
1073 #[test]
1074 fn aligned_idempotent_with_lazy_continuation_out_of_a_blockquote() {
1075 let input = "- \n> *\n> a\n``";
1079 let once = fix_aligned(input);
1080 let twice = fix_aligned(&once);
1081 assert_eq!(once, twice, "MD077 aligned fix must be idempotent");
1082 }
1083
1084 #[test]
1087 fn tight_lazy_continuation_zero_indent_not_flagged() {
1088 let content = "- Item\ncontinuation\n";
1090 assert!(check(content).is_empty());
1091 }
1092
1093 #[test]
1094 fn tight_continuation_correct_indent_not_flagged() {
1095 let content = "1. Item\n continuation\n";
1097 assert!(check(content).is_empty());
1098 }
1099
1100 #[test]
1101 fn tight_continuation_over_indented_ordered() {
1102 let content = "1. This is a list item with multiple lines.\n The second line is over-indented.\n";
1104 let warnings = check(content);
1105 assert_eq!(warnings.len(), 1);
1106 assert_eq!(warnings[0].line, 2);
1107 assert!(warnings[0].message.contains("over-indented"));
1108 }
1109
1110 #[test]
1111 fn tight_continuation_over_indented_unordered() {
1112 let content = "- Item\n over-indented\n";
1114 let warnings = check(content);
1115 assert_eq!(warnings.len(), 1);
1116 assert_eq!(warnings[0].line, 2);
1117 }
1118
1119 #[test]
1120 fn tight_continuation_multiple_over_indented_lines() {
1121 let content = "1. Item\n line one\n line two\n line three\n";
1122 let warnings = check(content);
1123 assert_eq!(warnings.len(), 3);
1124 }
1125
1126 #[test]
1127 fn tight_continuation_mixed_correct_and_over() {
1128 let content = "1. Item\n correct\n over-indented\n correct again\n";
1129 let warnings = check(content);
1130 assert_eq!(warnings.len(), 1);
1131 assert_eq!(warnings[0].line, 3);
1132 }
1133
1134 #[test]
1135 fn tight_continuation_nested_over_indented() {
1136 let content = "- L1\n - L2\n over-indented continuation of L2\n";
1138 let warnings = check(content);
1139 assert_eq!(warnings.len(), 1);
1140 assert_eq!(warnings[0].line, 3);
1141 assert!(warnings[0].message.contains("expected 4"));
1143 assert!(warnings[0].message.contains("found 5"));
1144 }
1145
1146 #[test]
1147 fn tight_continuation_nested_correct_indent_not_flagged() {
1148 let content = "- L1\n - L2\n correctly indented continuation of L2\n";
1151 assert!(check(content).is_empty());
1152 }
1153
1154 #[test]
1155 fn fix_tight_continuation_nested_over_indented() {
1156 let content = "- L1\n - L2\n over-indented continuation of L2\n";
1158 let fixed = fix(content);
1159 assert_eq!(fixed, "- L1\n - L2\n over-indented continuation of L2\n");
1160 }
1161
1162 #[test]
1163 fn tight_continuation_under_indented_not_flagged() {
1164 let content = "1. Item\n under-indented\n";
1167 assert!(check(content).is_empty());
1168 }
1169
1170 #[test]
1171 fn tight_continuation_tab_over_indented() {
1172 let content = "- Item\n\tover-indented\n";
1174 let warnings = check(content);
1175 assert_eq!(warnings.len(), 1);
1176 }
1177
1178 #[test]
1179 fn fix_tight_continuation_over_indented_ordered() {
1180 let content = "1. This is a list item with multiple lines.\n The second line is over-indented.\n";
1181 let fixed = fix(content);
1182 assert_eq!(
1183 fixed,
1184 "1. This is a list item with multiple lines.\n The second line is over-indented.\n"
1185 );
1186 }
1187
1188 #[test]
1189 fn fix_tight_continuation_over_indented_unordered() {
1190 let content = "- Item\n over-indented\n";
1191 let fixed = fix(content);
1192 assert_eq!(fixed, "- Item\n over-indented\n");
1193 }
1194
1195 #[test]
1196 fn fix_tight_continuation_multiple_lines() {
1197 let content = "1. Item\n line one\n line two\n";
1198 let fixed = fix(content);
1199 assert_eq!(fixed, "1. Item\n line one\n line two\n");
1200 }
1201
1202 #[test]
1203 fn tight_continuation_mkdocs_4space_ordered_not_flagged() {
1204 let content = "1. Item\n continuation\n";
1207 assert!(check_mkdocs(content).is_empty());
1208 }
1209
1210 #[test]
1211 fn tight_continuation_mkdocs_5space_ordered_flagged() {
1212 let content = "1. Item\n over-indented\n";
1214 let warnings = check_mkdocs(content);
1215 assert_eq!(warnings.len(), 1);
1216 assert!(warnings[0].message.contains("expected 4"));
1217 assert!(warnings[0].message.contains("found 5"));
1218 }
1219
1220 #[test]
1221 fn fix_tight_continuation_mkdocs_over_indented() {
1222 let content = "1. Item\n over-indented\n";
1223 let fixed = fix_mkdocs(content);
1224 assert_eq!(fixed, "1. Item\n over-indented\n");
1225 }
1226
1227 #[test]
1228 fn tight_continuation_deeply_indented_list_markers_not_flagged() {
1229 let content = "* Level 0\n * Level 1\n * Level 2\n";
1232 assert!(check(content).is_empty());
1233 }
1234
1235 #[test]
1236 fn tight_continuation_ordered_marker_not_flagged() {
1237 let content = "- Parent\n 1. Child item\n";
1239 assert!(check(content).is_empty());
1240 }
1241
1242 #[test]
1245 fn unordered_correct_indent_no_warning() {
1246 let content = "- Item\n\n continuation\n";
1247 assert!(check(content).is_empty());
1248 }
1249
1250 #[test]
1251 fn unordered_partial_indent_warns() {
1252 let content = "- Item\n\n continuation\n";
1255 let warnings = check(content);
1256 assert_eq!(warnings.len(), 1);
1257 assert_eq!(warnings[0].line, 3);
1258 assert!(warnings[0].message.contains("2 spaces"));
1259 assert!(warnings[0].message.contains("found 1"));
1260 }
1261
1262 #[test]
1263 fn unordered_zero_indent_is_new_paragraph() {
1264 let content = "- Item\n\ncontinuation\n";
1267 assert!(check(content).is_empty());
1268 }
1269
1270 #[test]
1273 fn ordered_3space_correct_commonmark() {
1274 let content = "1. Item\n\n continuation\n";
1276 assert!(check(content).is_empty());
1277 }
1278
1279 #[test]
1280 fn ordered_2space_under_indent_commonmark() {
1281 let content = "1. Item\n\n continuation\n";
1282 let warnings = check(content);
1283 assert_eq!(warnings.len(), 1);
1284 assert!(warnings[0].message.contains("3 spaces"));
1285 assert!(warnings[0].message.contains("found 2"));
1286 }
1287
1288 #[test]
1291 fn multi_digit_marker_correct() {
1292 let content = "10. Item\n\n continuation\n";
1294 assert!(check(content).is_empty());
1295 }
1296
1297 #[test]
1298 fn multi_digit_marker_under_indent() {
1299 let content = "10. Item\n\n continuation\n";
1300 let warnings = check(content);
1301 assert_eq!(warnings.len(), 1);
1302 assert!(warnings[0].message.contains("4 spaces"));
1303 }
1304
1305 #[test]
1308 fn mkdocs_3space_ordered_warns() {
1309 let content = "1. Item\n\n continuation\n";
1311 let warnings = check_mkdocs(content);
1312 assert_eq!(warnings.len(), 1);
1313 assert!(warnings[0].message.contains("4 spaces"));
1314 assert!(warnings[0].message.contains("MkDocs"));
1315 }
1316
1317 #[test]
1318 fn mkdocs_4space_ordered_no_warning() {
1319 let content = "1. Item\n\n continuation\n";
1320 assert!(check_mkdocs(content).is_empty());
1321 }
1322
1323 #[test]
1324 fn mkdocs_unordered_2space_ok() {
1325 let content = "- Item\n\n continuation\n";
1327 assert!(check_mkdocs(content).is_empty());
1328 }
1329
1330 #[test]
1331 fn mkdocs_unordered_2space_warns() {
1332 let content = "- Item\n\n continuation\n";
1334 let warnings = check_mkdocs(content);
1335 assert_eq!(warnings.len(), 1);
1336 assert!(warnings[0].message.contains("4 spaces"));
1337 }
1338
1339 #[test]
1342 fn fix_unordered_indent() {
1343 let content = "- Item\n\n continuation\n";
1345 let fixed = fix(content);
1346 assert_eq!(fixed, "- Item\n\n continuation\n");
1347 }
1348
1349 #[test]
1350 fn fix_ordered_indent() {
1351 let content = "1. Item\n\n continuation\n";
1352 let fixed = fix(content);
1353 assert_eq!(fixed, "1. Item\n\n continuation\n");
1354 }
1355
1356 #[test]
1357 fn fix_mkdocs_indent() {
1358 let content = "1. Item\n\n continuation\n";
1359 let fixed = fix_mkdocs(content);
1360 assert_eq!(fixed, "1. Item\n\n continuation\n");
1361 }
1362
1363 #[test]
1366 fn nested_list_items_not_flagged() {
1367 let content = "- Parent\n\n - Child\n";
1368 assert!(check(content).is_empty());
1369 }
1370
1371 #[test]
1372 fn nested_list_zero_indent_is_new_paragraph() {
1373 let content = "- Parent\n - Child\n\ncontinuation of parent\n";
1375 assert!(check(content).is_empty());
1376 }
1377
1378 #[test]
1379 fn nested_list_partial_indent_flagged() {
1380 let content = "- Parent\n - Child\n\n continuation of parent\n";
1382 let warnings = check(content);
1383 assert_eq!(warnings.len(), 1);
1384 assert!(warnings[0].message.contains("2 spaces"));
1385 }
1386
1387 #[test]
1390 fn code_block_correctly_indented_no_warning() {
1391 let content = "- Item\n\n ```\n code\n ```\n";
1393 assert!(check(content).is_empty());
1394 }
1395
1396 #[test]
1397 fn code_fence_under_indented_warns() {
1398 let content = "- Item\n\n ```\n code\n ```\n";
1402 let warnings = check(content);
1403 assert_eq!(warnings.len(), 1);
1404 assert_eq!(warnings[0].line, 3);
1405 }
1406
1407 #[test]
1408 fn code_fence_under_indented_ordered_mkdocs() {
1409 let content = "1. Item\n\n ```toml\n key = \"value\"\n ```\n";
1412 assert!(check(content).is_empty()); let warnings = check_mkdocs(content);
1414 assert_eq!(warnings.len(), 1); assert_eq!(warnings[0].line, 3);
1416 assert!(warnings[0].message.contains("4 spaces"));
1417 assert!(warnings[0].message.contains("MkDocs"));
1418 }
1419
1420 #[test]
1421 fn code_fence_tilde_under_indented() {
1422 let content = "- Item\n\n ~~~\n code\n ~~~\n";
1423 let warnings = check(content);
1424 assert_eq!(warnings.len(), 1); assert_eq!(warnings[0].line, 3);
1426 }
1427
1428 #[test]
1431 fn multiple_blank_lines_zero_indent_is_new_paragraph() {
1432 let content = "- Item\n\n\ncontinuation\n";
1434 assert!(check(content).is_empty());
1435 }
1436
1437 #[test]
1438 fn multiple_blank_lines_partial_indent_flags() {
1439 let content = "- Item\n\n\n continuation\n";
1440 let warnings = check(content);
1441 assert_eq!(warnings.len(), 1);
1442 }
1443
1444 #[test]
1447 fn empty_item_no_warning() {
1448 let content = "- \n- Second\n";
1449 assert!(check(content).is_empty());
1450 }
1451
1452 #[test]
1455 fn multiple_items_mixed_indent() {
1456 let content = "1. First\n\n correct continuation\n\n2. Second\n\n wrong continuation\n";
1457 let warnings = check(content);
1458 assert_eq!(warnings.len(), 1);
1459 assert_eq!(warnings[0].line, 7);
1460 }
1461
1462 #[test]
1465 fn task_list_correct_indent() {
1466 let content = "- [ ] Task\n\n continuation\n";
1468 assert!(check(content).is_empty());
1469 }
1470
1471 #[test]
1474 fn frontmatter_not_flagged() {
1475 let content = "---\ntitle: test\n---\n\n- Item\n\n continuation\n";
1476 assert!(check(content).is_empty());
1477 }
1478
1479 #[test]
1482 fn fix_multiple_items() {
1483 let content = "1. First\n\n wrong1\n\n2. Second\n\n wrong2\n";
1484 let fixed = fix(content);
1485 assert_eq!(fixed, "1. First\n\n wrong1\n\n2. Second\n\n wrong2\n");
1486 }
1487
1488 #[test]
1489 fn fix_multiline_loose_continuation_all_lines() {
1490 let content = "1. Item\n\n line one\n line two\n line three\n";
1491 let fixed = fix(content);
1492 assert_eq!(fixed, "1. Item\n\n line one\n line two\n line three\n");
1493 }
1494
1495 #[test]
1496 fn multi_line_setext_heading_after_a_list_is_not_a_continuation() {
1497 let content = "1. Item\n\n First line\n second line\n ===\n";
1501 let warnings = check(content);
1502 assert!(
1503 warnings.is_empty(),
1504 "the text lines of a setext heading are not continuation candidates. Got: {warnings:?}"
1505 );
1506 assert_eq!(fix(content), content);
1507 }
1508
1509 #[test]
1512 fn sibling_item_boundary_respected() {
1513 let content = "- First\n- Second\n\n continuation\n";
1515 assert!(check(content).is_empty());
1516 }
1517
1518 #[test]
1521 fn blockquote_list_correct_indent_no_warning() {
1522 let content = "> - Item\n>\n> continuation\n";
1525 assert!(check(content).is_empty());
1526 }
1527
1528 #[test]
1529 fn blockquote_list_under_indent_no_false_positive() {
1530 let content = "> - Item\n>\n> continuation\n";
1535 assert!(check(content).is_empty());
1536 }
1537
1538 #[test]
1541 fn deep_nesting_correct_indent() {
1542 let content = "- L1\n - L2\n - L3\n\n continuation of L3\n";
1543 assert!(check(content).is_empty());
1544 }
1545
1546 #[test]
1547 fn deep_nesting_under_indent() {
1548 let content = "- L1\n - L2\n - L3\n\n continuation of L3\n";
1551 let warnings = check(content);
1552 assert_eq!(warnings.len(), 1);
1553 assert!(warnings[0].message.contains("6 spaces"));
1554 assert!(warnings[0].message.contains("found 5"));
1555 }
1556
1557 #[test]
1558 fn deep_nesting_middle_level_continuation_bullets() {
1559 let content = "- L1\n - L2\n - L3\n\n continuation of L2\n";
1563 assert!(check(content).is_empty());
1564 }
1565
1566 #[test]
1567 fn deep_nesting_middle_level_continuation_ordered() {
1568 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";
1571 assert!(check(content).is_empty());
1572 }
1573
1574 #[test]
1575 fn deep_nesting_outermost_continuation() {
1576 let content = "- L1\n - L2\n - L3\n\n continuation of L1\n";
1579 assert!(check(content).is_empty());
1580 }
1581
1582 #[test]
1583 fn deep_nesting_between_levels_still_flagged() {
1584 let content = "- L1\n - L2\n - L3\n\n continuation\n";
1587 let warnings = check(content);
1588 assert_eq!(warnings.len(), 1);
1589 assert!(warnings[0].message.contains("4 spaces"));
1590 assert!(warnings[0].message.contains("found 3"));
1591 }
1592
1593 #[test]
1594 fn deep_nesting_beyond_deepest_still_flagged() {
1595 let content = "- L1\n - L2\n - L3\n\n continuation\n";
1597 let warnings = check(content);
1598 assert_eq!(warnings.len(), 1);
1599 assert!(warnings[0].message.contains("over-indented"));
1600 assert!(warnings[0].message.contains("expected 6, found 7"));
1601 }
1602
1603 #[test]
1604 fn four_levels_middle_continuation() {
1605 let content = "- L1\n - L2\n - L3\n - L4\n\n continuation of L2\n";
1608 assert!(check(content).is_empty());
1609 }
1610
1611 #[test]
1612 fn nested_sibling_closes_deeper_level() {
1613 let content = "- L1\n - L2a\n - L3\n - L2b\n\n continuation of L2b\n";
1616 assert!(check(content).is_empty());
1617 }
1618
1619 #[test]
1620 fn deep_nesting_middle_level_continuation_fix_preserved() {
1621 let content = "- L1\n - L2\n - L3\n\n continuation of L2\n";
1623 assert_eq!(fix(content), content);
1624 }
1625
1626 #[test]
1629 fn loose_tab_continuation_over_indented() {
1630 let content = "- Item\n\n\tcontinuation\n";
1635 let warnings = check(content);
1636 assert_eq!(warnings.len(), 1);
1637 assert_eq!(warnings[0].line, 3);
1638 assert_eq!(fix(content), "- Item\n\n continuation\n");
1639 }
1640
1641 #[test]
1644 fn multiple_continuations_correct() {
1645 let content = "- Item\n\n para 1\n\n para 2\n\n para 3\n";
1646 assert!(check(content).is_empty());
1647 }
1648
1649 #[test]
1650 fn multiple_continuations_second_under_indent() {
1651 let content = "- Item\n\n para 1\n\n continuation 2\n";
1653 let warnings = check(content);
1654 assert_eq!(warnings.len(), 1);
1655 assert_eq!(warnings[0].line, 5);
1656 }
1657
1658 #[test]
1661 fn ordered_paren_marker_correct() {
1662 let content = "1) Item\n\n continuation\n";
1664 assert!(check(content).is_empty());
1665 }
1666
1667 #[test]
1668 fn ordered_paren_marker_under_indent() {
1669 let content = "1) Item\n\n continuation\n";
1670 let warnings = check(content);
1671 assert_eq!(warnings.len(), 1);
1672 assert!(warnings[0].message.contains("3 spaces"));
1673 }
1674
1675 #[test]
1678 fn star_marker_correct() {
1679 let content = "* Item\n\n continuation\n";
1680 assert!(check(content).is_empty());
1681 }
1682
1683 #[test]
1684 fn star_marker_under_indent() {
1685 let content = "* Item\n\n continuation\n";
1686 let warnings = check(content);
1687 assert_eq!(warnings.len(), 1);
1688 }
1689
1690 #[test]
1691 fn plus_marker_correct() {
1692 let content = "+ Item\n\n continuation\n";
1693 assert!(check(content).is_empty());
1694 }
1695
1696 #[test]
1699 fn heading_after_list_no_warning() {
1700 let content = "- Item\n\n# Heading\n";
1701 assert!(check(content).is_empty());
1702 }
1703
1704 #[test]
1707 fn hr_after_list_no_warning() {
1708 let content = "- Item\n\n---\n";
1709 assert!(check(content).is_empty());
1710 }
1711
1712 #[test]
1715 fn reference_link_def_not_flagged() {
1716 let content = "- Item\n\n [link]: https://example.com\n";
1717 assert!(check(content).is_empty());
1718 }
1719
1720 #[test]
1723 fn footnote_def_not_flagged() {
1724 let content = "- Item\n\n [^1]: footnote text\n";
1725 assert!(check(content).is_empty());
1726 }
1727
1728 #[test]
1729 fn footnote_multiline_body_after_list_not_flagged() {
1730 let content = "# A list followed by a footnote\n\n\
1734 Here is a paragraph.[^fn]\n\n\
1735 - This is a list.\n\n\
1736 [^fn]:\n\
1737 \x20\x20\x20\x20Here is a footnote that spans multiple lines.\n\
1738 \x20\x20\x20\x20It should thus be indented by at least four spaces.\n";
1739 assert!(check(content).is_empty());
1740 }
1741
1742 #[test]
1743 fn fix_footnote_multiline_body_after_list_is_noop() {
1744 let content = "# A list followed by a footnote\n\n\
1748 Here is a paragraph.[^fn]\n\n\
1749 - This is a list.\n\n\
1750 [^fn]:\n\
1751 \x20\x20\x20\x20Here is a footnote that spans multiple lines.\n\
1752 \x20\x20\x20\x20It should thus be indented by at least four spaces.\n";
1753 assert_eq!(fix(content), content);
1754 }
1755
1756 #[test]
1757 fn footnote_body_indented_past_list_content_col_not_flagged() {
1758 let content = "- Item\n\n[^fn]:\n Body line one.\n Body line two.\n";
1762 assert!(check(content).is_empty());
1763 }
1764
1765 #[test]
1766 fn list_inside_footnote_body_continuation_not_flagged() {
1767 let content = "Text.[^fn]\n\n[^fn]:\n\
1771 \x20\x20\x20\x20- nested item\n\
1772 \x20\x20\x20\x20\x20\x20\x20over-indented continuation\n";
1773 assert!(check(content).is_empty());
1774 }
1775
1776 #[test]
1777 fn footnote_multiline_body_after_list_not_flagged_mkdocs() {
1778 let content = "Here is a paragraph.[^fn]\n\n\
1782 - This is a list.\n\n\
1783 [^fn]:\n\
1784 \x20\x20\x20\x20\x20\x20Footnote body that spans\n\
1785 \x20\x20\x20\x20\x20\x20multiple indented lines.\n";
1786 assert!(check_mkdocs(content).is_empty());
1787 }
1788
1789 #[test]
1792 fn fix_deep_nesting() {
1793 let content = "- L1\n - L2\n - L3\n\n under-indented\n";
1794 let fixed = fix(content);
1795 assert_eq!(fixed, "- L1\n - L2\n - L3\n\n under-indented\n");
1796 }
1797
1798 #[test]
1799 fn fix_mkdocs_unordered() {
1800 let content = "- Item\n\n continuation\n";
1802 let fixed = fix_mkdocs(content);
1803 assert_eq!(fixed, "- Item\n\n continuation\n");
1804 }
1805
1806 #[test]
1807 fn fix_code_fence_indent() {
1808 let content = "- Item\n\n ```\n code\n ```\n";
1811 let fixed = fix(content);
1812 assert_eq!(fixed, "- Item\n\n ```\n code\n ```\n");
1813 }
1814
1815 #[test]
1816 fn fix_mkdocs_code_fence_indent() {
1817 let content = "1. Item\n\n ```toml\n key = \"val\"\n ```\n";
1819 let fixed = fix_mkdocs(content);
1820 assert_eq!(fixed, "1. Item\n\n ```toml\n key = \"val\"\n ```\n");
1821 }
1822
1823 #[test]
1826 fn empty_document_no_warning() {
1827 assert!(check("").is_empty());
1828 }
1829
1830 #[test]
1831 fn whitespace_only_no_warning() {
1832 assert!(check(" \n\n \n").is_empty());
1833 }
1834
1835 #[test]
1838 fn no_list_no_warning() {
1839 let content = "# Heading\n\nSome paragraph.\n\nAnother paragraph.\n";
1840 assert!(check(content).is_empty());
1841 }
1842
1843 #[test]
1846 fn multiline_continuation_all_lines_flagged() {
1847 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";
1848 let warnings = check(content);
1849 assert_eq!(warnings.len(), 3);
1850 assert_eq!(warnings[0].line, 3);
1851 assert_eq!(warnings[1].line, 4);
1852 assert_eq!(warnings[2].line, 5);
1853 }
1854
1855 #[test]
1856 fn multiline_continuation_with_frontmatter_fix() {
1857 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";
1858 let fixed = fix(content);
1859 assert_eq!(
1860 fixed,
1861 "---\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"
1862 );
1863 }
1864
1865 #[test]
1866 fn multiline_continuation_correct_indent_no_warning() {
1867 let content = "1. Item\n\n line one\n line two\n line three\n";
1868 assert!(check(content).is_empty());
1869 }
1870
1871 #[test]
1872 fn multiline_continuation_mixed_indent() {
1873 let content = "1. Item\n\n correct\n wrong\n correct\n";
1874 let warnings = check(content);
1875 assert_eq!(warnings.len(), 1);
1876 assert_eq!(warnings[0].line, 4);
1877 }
1878
1879 #[test]
1880 fn multiline_continuation_unordered() {
1881 let content = "- Item\n\n continuation 1\n continuation 2\n continuation 3\n";
1882 let warnings = check(content);
1883 assert_eq!(warnings.len(), 3);
1884 let fixed = fix(content);
1885 assert_eq!(
1886 fixed,
1887 "- Item\n\n continuation 1\n continuation 2\n continuation 3\n"
1888 );
1889 }
1890
1891 #[test]
1892 fn multiline_continuation_two_items_fix() {
1893 let content = "1. First\n\n cont a\n cont b\n\n2. Second\n\n cont c\n cont d\n";
1894 let fixed = fix(content);
1895 assert_eq!(
1896 fixed,
1897 "1. First\n\n cont a\n cont b\n\n2. Second\n\n cont c\n cont d\n"
1898 );
1899 }
1900
1901 #[test]
1902 fn fence_fix_does_not_break_pairing_for_md031() {
1903 let content = "#### title\n\nabc\n\n\
1910 1. ab\n\n\
1911 \x20\x20`aabbccdd`\n\n\
1912 2. cd\n\n\
1913 \x20\x20`bbcc dd ee`\n\n\
1914 \x20\x20```\n\
1915 \x20\x20abcd\n\
1916 \x20\x20ef gh\n\
1917 \x20\x20```\n\n\
1918 \x20\x20uu\n\n\
1919 \x20\x20```\n\
1920 \x20\x20cdef\n\
1921 \x20\x20gh ij\n\
1922 \x20\x20```\n";
1923 let expected = "#### title\n\nabc\n\n\
1924 1. ab\n\n\
1925 \x20\x20\x20`aabbccdd`\n\n\
1926 2. cd\n\n\
1927 \x20\x20\x20`bbcc dd ee`\n\n\
1928 \x20\x20\x20```\n\
1929 \x20\x20\x20abcd\n\
1930 \x20\x20\x20ef gh\n\
1931 \x20\x20\x20```\n\n\
1932 \x20\x20\x20uu\n\n\
1933 \x20\x20\x20```\n\
1934 \x20\x20\x20cdef\n\
1935 \x20\x20\x20gh ij\n\
1936 \x20\x20\x20```\n";
1937 assert_eq!(fix(content), expected);
1938 }
1939
1940 #[test]
1941 fn multiline_continuation_separated_by_blank() {
1942 let content = "1. Item\n\n para1 line1\n para1 line2\n\n para2 line1\n para2 line2\n";
1943 let warnings = check(content);
1944 assert_eq!(warnings.len(), 4);
1945 let fixed = fix(content);
1946 assert_eq!(
1947 fixed,
1948 "1. Item\n\n para1 line1\n para1 line2\n\n para2 line1\n para2 line2\n"
1949 );
1950 }
1951
1952 #[test]
1953 fn tab_indented_fence_is_normalized_to_spaces() {
1954 let content = "100. ab\n\n\t```\n\tabcd\n\t```\n";
1962 let expected = "100. ab\n\n ```\n abcd\n ```\n";
1963 assert_eq!(fix(content), expected);
1964 }
1965
1966 #[test]
1975 fn loose_continuation_over_indented_flagged() {
1976 let content = "* Item\n\n over-indented\n";
1979 let warnings = check(content);
1980 assert_eq!(warnings.len(), 1);
1981 assert_eq!(warnings[0].line, 3);
1982 assert!(warnings[0].message.contains("over-indented"));
1983 assert!(warnings[0].message.contains("expected 2"));
1984 assert!(warnings[0].message.contains("found 3"));
1985 }
1986
1987 #[test]
1988 fn loose_continuation_over_indented_multiline_mixed() {
1989 let content = "* Item\n\n over one\n correct\n over two\n";
1991 let warnings = check(content);
1992 assert_eq!(warnings.len(), 2);
1993 assert_eq!(warnings[0].line, 3);
1994 assert_eq!(warnings[1].line, 5);
1995 }
1996
1997 #[test]
1998 fn fix_loose_continuation_over_indented() {
1999 let content = "* Item\n\n over one\n correct\n over two\n";
2000 let fixed = fix(content);
2001 assert_eq!(fixed, "* Item\n\n over one\n correct\n over two\n");
2002 }
2003
2004 #[test]
2005 fn fix_tight_and_loose_items_normalized_identically() {
2006 let content = "---\ntitle: Heading\n---\n\nSome introductory text:\n\n\
2009 * 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\
2010 * 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";
2011 let expected = "---\ntitle: Heading\n---\n\nSome introductory text:\n\n\
2012 * 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\
2013 * 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";
2014 assert_eq!(fix(content), expected);
2015 }
2016
2017 #[test]
2018 fn multi_paragraph_item_loose_paragraph_over_indented() {
2019 let content = "* Item.\n tight over\n\n loose over\n";
2022 let warnings = check(content);
2023 assert_eq!(warnings.len(), 2);
2024 assert_eq!(warnings[0].line, 2);
2025 assert_eq!(warnings[1].line, 4);
2026 }
2027
2028 #[test]
2029 fn loose_indented_code_block_not_flagged() {
2030 let content = "- Item\n\n code line\n";
2034 assert!(check(content).is_empty());
2035 }
2036
2037 #[test]
2038 fn mkdocs_loose_over_indented_flagged() {
2039 let content = "1. Item\n\n over\n";
2042 let warnings = check_mkdocs(content);
2043 assert_eq!(warnings.len(), 1);
2044 assert_eq!(warnings[0].line, 3);
2045 assert!(warnings[0].message.contains("over-indented"));
2046 assert!(warnings[0].message.contains("expected 4"));
2047 assert!(warnings[0].message.contains("found 5"));
2048 }
2049
2050 #[test]
2051 fn task_list_loose_over_indented_flagged() {
2052 let content = "- [ ] Task\n\n over\n";
2055 let warnings = check(content);
2056 assert_eq!(warnings.len(), 1);
2057 assert_eq!(warnings[0].line, 3);
2058 }
2059
2060 #[test]
2061 fn loose_over_indent_boundary_below_code_block_threshold_flagged() {
2062 let content = "- Item\n\n over\n";
2067 let warnings = check(content);
2068 assert_eq!(warnings.len(), 1);
2069 assert_eq!(warnings[0].line, 3);
2070 assert!(warnings[0].message.contains("expected 2"));
2071 assert!(warnings[0].message.contains("found 5"));
2072 }
2073
2074 #[test]
2075 fn loose_over_indent_does_not_steal_nested_under_indent() {
2076 let content = "- Outer\n - Inner\n\n continuation\n";
2083 let warnings = check(content);
2084 assert_eq!(warnings.len(), 1);
2085 assert_eq!(warnings[0].line, 4);
2086 assert!(warnings[0].message.contains("4 spaces"));
2087 assert!(warnings[0].message.contains("found 3"));
2088 assert_eq!(fix(content), "- Outer\n - Inner\n\n continuation\n");
2089 }
2090
2091 #[test]
2092 fn loose_over_indent_attributes_to_deepest_enclosing_item() {
2093 let content = "- Outer\n - Inner\n\n continuation\n";
2097 let warnings = check(content);
2098 assert_eq!(warnings.len(), 1);
2099 assert_eq!(warnings[0].line, 4);
2100 assert!(warnings[0].message.contains("expected 4"));
2101 assert!(warnings[0].message.contains("found 5"));
2102 assert_eq!(fix(content), "- Outer\n - Inner\n\n continuation\n");
2103 }
2104
2105 #[test]
2114 fn loose_over_indented_fence_not_flagged() {
2115 let content = "- Item\n\n ```\n code\n ```\n";
2116 assert!(check(content).is_empty());
2117 assert_eq!(fix(content), content);
2118 }
2119
2120 #[test]
2121 fn tight_over_indented_fence_not_flagged() {
2122 let content = "- Item\n ```\n code\n ```\n";
2123 assert!(check(content).is_empty());
2124 assert_eq!(fix(content), content);
2125 }
2126
2127 #[test]
2128 fn over_indented_tilde_fence_not_flagged() {
2129 let content = "- Item\n\n ~~~\n code\n ~~~\n";
2130 assert!(check(content).is_empty());
2131 assert_eq!(fix(content), content);
2132 }
2133
2134 #[test]
2135 fn fence_like_code_content_inside_fenced_block_not_flagged() {
2136 let content = "- Item\n\n ~~~\n ```\n ~~~\n";
2139 assert!(check(content).is_empty());
2140 assert_eq!(fix(content), content);
2141 }
2142
2143 #[test]
2144 fn unterminated_over_indented_fence_not_flagged() {
2145 let content = "- Item\n\n ```\n code1\n code2deeper\n";
2148 assert!(check(content).is_empty());
2149 assert_eq!(fix(content), content);
2150 }
2151
2152 #[test]
2160 fn task_list_tight_continuation_post_checkbox_reproducer_579() {
2161 let content = "- [ ] Lorem ipsum dolor sit amet, consectetur adipiscing\n tempor incididunt ut labore.\n";
2164 assert!(check(content).is_empty());
2165 }
2166
2167 #[test]
2168 fn task_list_tight_continuation_dash_unchecked() {
2169 let content = "- [ ] Task\n continuation\n";
2170 assert!(check(content).is_empty());
2171 }
2172
2173 #[test]
2174 fn task_list_tight_continuation_dash_checked_lower() {
2175 let content = "- [x] Task\n continuation\n";
2176 assert!(check(content).is_empty());
2177 }
2178
2179 #[test]
2180 fn task_list_tight_continuation_dash_checked_upper() {
2181 let content = "- [X] Task\n continuation\n";
2182 assert!(check(content).is_empty());
2183 }
2184
2185 #[test]
2186 fn task_list_tight_continuation_star_marker() {
2187 let content = "* [ ] Task\n continuation\n";
2188 assert!(check(content).is_empty());
2189 }
2190
2191 #[test]
2192 fn task_list_tight_continuation_plus_marker() {
2193 let content = "+ [ ] Task\n continuation\n";
2194 assert!(check(content).is_empty());
2195 }
2196
2197 #[test]
2198 fn task_list_tight_continuation_content_column_still_valid() {
2199 let content = "- [ ] Task\n continuation\n";
2202 assert!(check(content).is_empty());
2203 }
2204
2205 #[test]
2206 fn task_list_tight_continuation_between_columns_still_flagged() {
2207 let content = "- [ ] Task\n continuation\n";
2210 let warnings = check(content);
2211 assert_eq!(warnings.len(), 1);
2212 assert!(warnings[0].message.contains("expected 2 or 6"));
2214 assert!(warnings[0].message.contains("found 4"));
2215 }
2216
2217 #[test]
2218 fn task_list_tight_continuation_overshoot_still_flagged() {
2219 let content = "- [ ] Task\n continuation\n";
2221 let warnings = check(content);
2222 assert_eq!(warnings.len(), 1);
2223 assert!(warnings[0].message.contains("expected 2 or 6"));
2224 assert!(warnings[0].message.contains("found 7"));
2225 }
2226
2227 #[test]
2230 fn fix_task_list_overshoot_snaps_to_task_col() {
2231 let content = "- [ ] Task\n continuation\n";
2235 let fixed = fix(content);
2236 assert_eq!(fixed, "- [ ] Task\n continuation\n");
2237 }
2238
2239 #[test]
2240 fn fix_task_list_col_5_snaps_to_task_col() {
2241 let content = "- [ ] Task\n continuation\n";
2243 let fixed = fix(content);
2244 assert_eq!(fixed, "- [ ] Task\n continuation\n");
2245 }
2246
2247 #[test]
2248 fn fix_task_list_col_3_snaps_to_content_col() {
2249 let content = "- [ ] Task\n continuation\n";
2251 let fixed = fix(content);
2252 assert_eq!(fixed, "- [ ] Task\n continuation\n");
2253 }
2254
2255 #[test]
2256 fn fix_task_list_col_4_ties_to_content_col() {
2257 let content = "- [ ] Task\n continuation\n";
2262 let fixed = fix(content);
2263 assert_eq!(fixed, "- [ ] Task\n continuation\n");
2264 }
2265
2266 #[test]
2267 fn fix_task_list_ordered_overshoot_snaps_to_task_col() {
2268 let content = "1. [ ] Task\n continuation\n";
2271 let fixed = fix(content);
2272 assert_eq!(fixed, "1. [ ] Task\n continuation\n");
2273 }
2274
2275 #[test]
2276 fn fix_task_list_ordered_under_overshoot_snaps_to_content_col() {
2277 let content = "1. [ ] Task\n continuation\n";
2280 let fixed = fix(content);
2281 assert_eq!(fixed, "1. [ ] Task\n continuation\n");
2282 }
2283
2284 #[test]
2285 fn task_list_tight_continuation_ordered_single_digit() {
2286 let content = "1. [ ] Task\n continuation\n";
2288 assert!(check(content).is_empty());
2289 }
2290
2291 #[test]
2292 fn task_list_tight_continuation_ordered_multi_digit() {
2293 let content = "10. [ ] Task\n continuation\n";
2295 assert!(check(content).is_empty());
2296 }
2297
2298 #[test]
2299 fn task_list_tight_continuation_nested_dash() {
2300 let content = "- Parent\n - [ ] Nested task\n continuation\n";
2302 assert!(check(content).is_empty());
2303 }
2304
2305 #[test]
2306 fn task_list_loose_continuation_post_checkbox_column_not_flagged() {
2307 let content = "- [ ] Task\n\n continuation\n";
2312 assert!(check(content).is_empty());
2313 }
2314
2315 #[test]
2316 fn task_list_empty_body_is_not_a_task() {
2317 let content = "- [ ]\n continuation\n";
2323 let warnings = check(content);
2324 assert_eq!(warnings.len(), 1);
2325 assert!(warnings[0].message.contains("found 4"));
2326 }
2327
2328 #[test]
2329 fn task_list_malformed_checkbox_is_not_a_task() {
2330 let content = "- [~] Not a task\n continuation\n";
2332 let warnings = check(content);
2333 assert_eq!(warnings.len(), 1);
2334 }
2335
2336 #[test]
2343 fn task_list_mkdocs_unordered_required_min_valid() {
2344 let content = "- [ ] Task\n continuation\n";
2346 assert!(check_mkdocs(content).is_empty());
2347 }
2348
2349 #[test]
2350 fn task_list_mkdocs_unordered_post_checkbox_valid() {
2351 let content = "- [ ] Task\n continuation\n";
2352 assert!(check_mkdocs(content).is_empty());
2353 }
2354
2355 #[test]
2356 fn task_list_mkdocs_unordered_between_flagged() {
2357 let content = "- [ ] Task\n continuation\n";
2359 let warnings = check_mkdocs(content);
2360 assert_eq!(warnings.len(), 1);
2361 }
2362
2363 #[test]
2364 fn task_list_mkdocs_ordered_both_columns_valid() {
2365 let at_4 = "1. [ ] Task\n continuation\n";
2367 assert!(check_mkdocs(at_4).is_empty());
2368 let at_7 = "1. [ ] Task\n continuation\n";
2369 assert!(check_mkdocs(at_7).is_empty());
2370 }
2371
2372 #[test]
2373 fn task_list_mkdocs_ordered_between_flagged() {
2374 let at_5 = "1. [ ] Task\n continuation\n";
2376 assert_eq!(check_mkdocs(at_5).len(), 1);
2377 let at_6 = "1. [ ] Task\n continuation\n";
2378 assert_eq!(check_mkdocs(at_6).len(), 1);
2379 }
2380
2381 #[test]
2391 fn fix_task_list_tie_sibling_at_task_col_snaps_to_task_col() {
2392 let content = "- [ ] Task\n aligned continuation\n tied continuation\n";
2396 let fixed = fix(content);
2397 assert_eq!(
2398 fixed,
2399 "- [ ] Task\n aligned continuation\n tied continuation\n"
2400 );
2401 }
2402
2403 #[test]
2404 fn fix_task_list_tie_sibling_at_content_col_snaps_to_content_col() {
2405 let content = "- [ ] Task\n aligned continuation\n tied continuation\n";
2408 let fixed = fix(content);
2409 assert_eq!(fixed, "- [ ] Task\n aligned continuation\n tied continuation\n");
2410 }
2411
2412 #[test]
2413 fn fix_task_list_tie_both_siblings_snaps_to_content_col() {
2414 let content = "- [ ] Task\n at content col\n at task col\n tied continuation\n";
2418 let fixed = fix(content);
2419 assert_eq!(
2420 fixed,
2421 "- [ ] Task\n at content col\n at task col\n tied continuation\n"
2422 );
2423 }
2424
2425 #[test]
2426 fn fix_task_list_tie_sees_task_col_through_tight_lazy_continuation() {
2427 let content = concat!("- [ ] Task\n", "lazy\n", " aligned at task col\n", " tied\n",);
2441 let fixed = fix(content);
2442 assert!(
2443 fixed.contains("\n tied\n"),
2444 "tied line should snap to col 6 (task col) because a task-col \
2445 sibling is visible past the tight lazy-continuation line; got:\n{fixed}"
2446 );
2447 }
2448
2449 #[test]
2456 fn task_list_tab_indented_continuation_flagged() {
2457 let content = "- [ ] Task\n\t\twrap\n";
2460 let warnings = check(content);
2461 assert_eq!(warnings.len(), 1);
2462 assert!(warnings[0].message.contains("expected 2 or 6"));
2463 assert!(warnings[0].message.contains("found 8"));
2464 }
2465
2466 #[test]
2467 fn fix_task_list_tab_indented_snaps_to_task_col() {
2468 let content = "- [ ] Task\n\t\twrap\n";
2470 let fixed = fix(content);
2471 assert_eq!(fixed, "- [ ] Task\n wrap\n");
2472 }
2473
2474 #[test]
2475 fn fix_task_list_single_tab_equidistant_snaps_to_content_col() {
2476 let content = "- [ ] Task\n\twrap\n";
2479 let fixed = fix(content);
2480 assert_eq!(fixed, "- [ ] Task\n wrap\n");
2481 }
2482
2483 #[test]
2493 fn task_list_blockquote_post_checkbox_not_flagged() {
2494 let content = "> - [ ] Task\n> continuation\n";
2496 assert!(check(content).is_empty());
2497 }
2498
2499 #[test]
2500 fn task_list_blockquote_between_cols_documented_limitation() {
2501 let content = "> - [ ] Task\n> continuation\n";
2505 assert!(check(content).is_empty());
2506 }
2507
2508 #[test]
2509 fn task_list_blockquote_overshoot_documented_limitation() {
2510 let content = "> - [ ] Task\n> continuation\n";
2512 assert!(check(content).is_empty());
2513 }
2514
2515 #[test]
2522 fn fix_task_list_mkdocs_unordered_overshoot_snaps_to_task_col() {
2523 let content = "- [ ] Task\n continuation\n";
2526 let fixed = fix_mkdocs(content);
2527 assert_eq!(fixed, "- [ ] Task\n continuation\n");
2528 }
2529
2530 #[test]
2531 fn fix_task_list_mkdocs_unordered_tie_snaps_to_required() {
2532 let content = "- [ ] Task\n continuation\n";
2535 let fixed = fix_mkdocs(content);
2536 assert_eq!(fixed, "- [ ] Task\n continuation\n");
2537 }
2538
2539 #[test]
2540 fn fix_task_list_mkdocs_ordered_overshoot_snaps_to_task_col() {
2541 let content = "1. [ ] Task\n continuation\n";
2544 let fixed = fix_mkdocs(content);
2545 assert_eq!(fixed, "1. [ ] Task\n continuation\n");
2546 }
2547
2548 #[test]
2549 fn fix_task_list_mkdocs_ordered_near_required_snaps_to_required() {
2550 let content = "1. [ ] Task\n continuation\n";
2556 let fixed = fix_mkdocs(content);
2557 assert_eq!(fixed, "1. [ ] Task\n continuation\n");
2558 }
2559
2560 #[test]
2561 fn fix_task_list_mkdocs_ordered_between_cols_snaps_to_task_col() {
2562 let content = "1. [ ] Task\n continuation\n";
2565 let fixed = fix_mkdocs(content);
2566 assert_eq!(fixed, "1. [ ] Task\n continuation\n");
2567 }
2568
2569 fn assert_idempotent(content: &str) {
2579 let once = fix(content);
2580 let twice = fix(&once);
2581 assert_eq!(once, twice, "MD077 fix was not idempotent on input: {content:?}");
2582 }
2583
2584 fn assert_idempotent_mkdocs(content: &str) {
2585 let once = fix_mkdocs(content);
2586 let twice = fix_mkdocs(&once);
2587 assert_eq!(
2588 once, twice,
2589 "MD077 (MkDocs) fix was not idempotent on input: {content:?}"
2590 );
2591 }
2592
2593 #[test]
2594 fn idempotent_task_list_between_cols() {
2595 assert_idempotent("- [ ] Task\n continuation\n");
2596 }
2597
2598 #[test]
2599 fn idempotent_task_list_overshoot() {
2600 assert_idempotent("- [ ] Task\n continuation\n");
2601 }
2602
2603 #[test]
2604 fn idempotent_task_list_under_post_checkbox() {
2605 assert_idempotent("- [ ] Task\n continuation\n");
2606 }
2607
2608 #[test]
2609 fn idempotent_task_list_near_post_checkbox() {
2610 assert_idempotent("- [ ] Task\n continuation\n");
2611 }
2612
2613 #[test]
2614 fn idempotent_task_list_tab_overshoot() {
2615 assert_idempotent("- [ ] Task\n\t\twrap\n");
2616 }
2617
2618 #[test]
2619 fn idempotent_task_list_single_tab() {
2620 assert_idempotent("- [ ] Task\n\twrap\n");
2621 }
2622
2623 #[test]
2624 fn idempotent_task_list_ordered_overshoot() {
2625 assert_idempotent("1. [ ] Task\n continuation\n");
2626 }
2627
2628 #[test]
2629 fn idempotent_task_list_ordered_under() {
2630 assert_idempotent("1. [ ] Task\n continuation\n");
2631 }
2632
2633 #[test]
2634 fn idempotent_task_list_tie_with_sibling_at_task_col() {
2635 assert_idempotent("- [ ] Task\n aligned\n tied\n");
2636 }
2637
2638 #[test]
2639 fn idempotent_task_list_tie_with_sibling_at_content_col() {
2640 assert_idempotent("- [ ] Task\n aligned\n tied\n");
2641 }
2642
2643 #[test]
2644 fn idempotent_task_list_mkdocs_unordered_overshoot() {
2645 assert_idempotent_mkdocs("- [ ] Task\n continuation\n");
2646 }
2647
2648 #[test]
2649 fn idempotent_task_list_mkdocs_unordered_tie() {
2650 assert_idempotent_mkdocs("- [ ] Task\n continuation\n");
2651 }
2652
2653 #[test]
2654 fn idempotent_task_list_mkdocs_ordered_overshoot() {
2655 assert_idempotent_mkdocs("1. [ ] Task\n continuation\n");
2656 }
2657
2658 #[test]
2659 fn idempotent_task_list_mkdocs_ordered_between() {
2660 assert_idempotent_mkdocs("1. [ ] Task\n continuation\n");
2661 }
2662
2663 #[test]
2664 fn idempotent_task_list_reproducer_579() {
2665 assert_idempotent(
2669 "- [ ] Lorem ipsum dolor sit amet, consectetur adipiscing\n tempor incididunt ut labore.\n",
2670 );
2671 }
2672
2673 #[test]
2674 fn idempotent_non_task_list_still_holds() {
2675 assert_idempotent("1. Item\n over-indented\n");
2678 assert_idempotent("- Item\n\n continuation\n");
2679 }
2680
2681 #[test]
2688 fn idempotent_non_task_loose_under_indent_ordered() {
2689 assert_idempotent("1. Item\n\n continuation\n");
2691 }
2692
2693 #[test]
2694 fn idempotent_non_task_loose_under_indent_multi_digit() {
2695 assert_idempotent("10. Item\n\n continuation\n");
2697 }
2698
2699 #[test]
2700 fn idempotent_non_task_tight_over_indent_ordered() {
2701 assert_idempotent("1. Item\n over-indented\n");
2703 }
2704
2705 #[test]
2713 fn idempotent_non_task_fence_ordered_loose() {
2714 assert_idempotent("1. Item\n\n ```rust\n let x = 1;\n ```\n");
2716 }
2717
2718 #[test]
2719 fn idempotent_non_task_fence_tilde_under_indent() {
2720 assert_idempotent("1. Item\n\n ~~~\nplain text\n ~~~\n");
2726 }
2727
2728 #[test]
2729 fn idempotent_non_task_fence_interior_above_required() {
2730 assert_idempotent("1. Item\n\n ```\n deeply indented code\n ```\n");
2734 }
2735
2736 #[test]
2737 fn fence_fix_promotes_interior_below_scope_in_single_pass() {
2738 let content = "1. Item\n\n ```\ncode\n ```\n";
2742 let fixed = fix(content);
2743 assert_eq!(fixed, "1. Item\n\n ```\n code\n ```\n");
2744 }
2745
2746 #[test]
2747 fn fence_fix_preserves_interior_offset_from_the_fence() {
2748 let content = "1. Item\n\n ```\n code\n ```\n";
2753 let fixed = fix(content);
2754 assert_eq!(fixed, "1. Item\n\n ```\n code\n ```\n");
2755 }
2756
2757 #[test]
2764 fn idempotent_non_task_mkdocs_ordered_at_3_spaces() {
2765 assert_idempotent_mkdocs("1. Item\n\n continuation\n");
2767 }
2768
2769 #[test]
2770 fn idempotent_non_task_mkdocs_unordered_at_2_spaces() {
2771 assert_idempotent_mkdocs("- Item\n\n continuation\n");
2773 }
2774
2775 #[test]
2776 fn idempotent_non_task_mkdocs_fence_compound() {
2777 assert_idempotent_mkdocs("1. Item\n\n ```toml\n k = 1\n ```\n");
2779 }
2780
2781 #[test]
2784 fn aligned_tight_zero_indent_continuation_flagged() {
2785 let content = "- this is a long line\nthat continues on a second line\n";
2789 let warnings = check_aligned(content);
2790 assert_eq!(warnings.len(), 1);
2791 assert_eq!(warnings[0].line, 2);
2792 assert_eq!(
2793 fix_aligned(content),
2794 "- this is a long line\n that continues on a second line\n"
2795 );
2796 }
2797
2798 #[test]
2799 fn aligned_full_issue_example_made_consistent() {
2800 let content = "- this is a long line\n\
2803 that continues on a second line\n\
2804 - this is another long line\n\
2805 \x20\x20that continues on the next line\n\
2806 - yet again a long line\n\
2807 and still inconsistently spaced\n\
2808 \x20\x20and even worse\n";
2809 let expected = "- this is a long line\n\
2810 \x20\x20that continues on a second line\n\
2811 - this is another long line\n\
2812 \x20\x20that continues on the next line\n\
2813 - yet again a long line\n\
2814 \x20\x20and still inconsistently spaced\n\
2815 \x20\x20and even worse\n";
2816 assert_eq!(fix_aligned(content), expected);
2817 assert_eq!(fix_aligned(expected), expected);
2819 }
2820
2821 #[test]
2822 fn aligned_already_aligned_not_flagged() {
2823 let content = "- item\n continuation at content column\n";
2824 assert!(check_aligned(content).is_empty());
2825 }
2826
2827 #[test]
2828 fn aligned_tight_partial_indent_flagged() {
2829 let content = "- item\n continuation\n";
2831 let warnings = check_aligned(content);
2832 assert_eq!(warnings.len(), 1);
2833 assert_eq!(fix_aligned(content), "- item\n continuation\n");
2834 }
2835
2836 #[test]
2837 fn aligned_post_blank_zero_indent_still_new_paragraph() {
2838 let content = "- item\n\nnew paragraph\n";
2841 assert!(check_aligned(content).is_empty());
2842 assert_eq!(fix_aligned(content), content);
2843 }
2844
2845 #[test]
2848 fn aligned_top_level_blockquote_after_list_untouched() {
2849 let content = "- item\n> quote\n";
2853 assert!(check_aligned(content).is_empty());
2854 assert_eq!(fix_aligned(content), content);
2855 }
2856
2857 #[test]
2858 fn aligned_top_level_fence_after_list_untouched() {
2859 let content = "- item\n```\ncode\n```\n";
2860 assert!(check_aligned(content).is_empty());
2861 assert_eq!(fix_aligned(content), content);
2862 }
2863
2864 #[test]
2865 fn aligned_top_level_table_after_list_untouched() {
2866 let content = "- item\n| a | b |\n|---|---|\n| 1 | 2 |\n";
2867 assert!(check_aligned(content).is_empty());
2868 assert_eq!(fix_aligned(content), content);
2869 }
2870
2871 #[test]
2874 fn aligned_nested_tight_lazy_continuation_aligns_to_inner() {
2875 let content = "- Outer\n - Inner\ncontinuation\n";
2880 let warnings = check_aligned(content);
2881 assert_eq!(warnings.len(), 1);
2882 assert_eq!(fix_aligned(content), "- Outer\n - Inner\n continuation\n");
2883 }
2884
2885 #[test]
2886 fn aligned_nested_continuation_already_aligned_not_flagged() {
2887 let content = "- L1\n - L2\n cont of L2 at 4\n";
2888 assert!(check_aligned(content).is_empty());
2889 }
2890
2891 #[test]
2892 fn aligned_nested_idempotent() {
2893 let content = "- Outer\n - Inner\ncontinuation\n";
2894 let once = fix_aligned(content);
2895 assert_eq!(fix_aligned(&once), once);
2896 }
2897
2898 #[test]
2899 fn aligned_three_level_nesting_aligns_to_innermost() {
2900 let content = "- L1\n - L2\n - L3\ncont\n";
2903 assert_eq!(fix_aligned(content), "- L1\n - L2\n - L3\n cont\n");
2904 }
2905
2906 #[test]
2907 fn aligned_continuation_after_sibling_owned_by_last_item() {
2908 let content = "- a\n- b\nlazy\n";
2911 assert_eq!(fix_aligned(content), "- a\n- b\n lazy\n");
2912 }
2913
2914 #[test]
2915 fn aligned_multi_digit_ordered_marker_aligns_to_content_column() {
2916 let content = "10. Item\nwrap\n";
2917 assert_eq!(fix_aligned(content), "10. Item\n wrap\n");
2918 }
2919
2920 #[test]
2921 fn aligned_latent_setext_underline_is_left_alone() {
2922 let content = "- item\nText\n===\n";
2927 assert!(check_aligned(content).is_empty());
2928 assert_eq!(fix_aligned(content), content);
2929 }
2930
2931 #[test]
2932 fn aligned_reindents_prose_that_only_looks_like_an_underline() {
2933 let content = "- item\nText\n= = =\n";
2936 assert_eq!(fix_aligned(content), "- item\n Text\n = = =\n");
2937 }
2938
2939 #[test]
2940 fn aligned_latent_marker_in_continuation_is_idempotent() {
2941 let content = "# \n- \n``\n2. \n![]()";
2947 let once = fix_aligned(content);
2948 assert_eq!(fix_aligned(&once), once, "fix must be idempotent in one pass");
2949 assert_eq!(once, content, "item with a latent marker is left untouched");
2950 }
2951
2952 #[test]
2953 fn aligned_latent_table_in_continuation_is_idempotent() {
2954 let content = "- \n![`]()\n| | ` |\n| --- | --- |";
2959 let once = fix_aligned(content);
2960 assert_eq!(fix_aligned(&once), once, "fix must be idempotent in one pass");
2961 assert_eq!(once, content, "item with a latent table is left untouched");
2962 }
2963
2964 #[test]
2965 fn aligned_blockquote_nested_list_not_touched() {
2966 let content = "> - item\n> wrap\n";
2970 assert!(check_aligned(content).is_empty());
2971 assert_eq!(fix_aligned(content), content);
2972 }
2973
2974 #[test]
2977 fn aligned_task_post_checkbox_column_accepted() {
2978 let content = "- [ ] Task\n wrap\n";
2981 assert!(check_aligned(content).is_empty());
2982 assert_eq!(fix_aligned(content), content);
2983 }
2984
2985 #[test]
2986 fn aligned_task_under_indent_snaps_to_content_column() {
2987 let content = "- [ ] Task\nwrap\n";
2988 let warnings = check_aligned(content);
2989 assert_eq!(warnings.len(), 1);
2990 assert_eq!(fix_aligned(content), "- [ ] Task\n wrap\n");
2991 }
2992
2993 #[test]
2996 fn aligned_mkdocs_tight_under_indent_snaps_to_four() {
2997 let content = "- item\nwrap\n";
2999 let warnings = check_aligned_mkdocs(content);
3000 assert_eq!(warnings.len(), 1);
3001 let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
3002 assert_eq!(aligned_rule().fix(&ctx).unwrap(), "- item\n wrap\n");
3003 }
3004
3005 #[test]
3008 fn any_default_does_not_flag_tight_lazy_continuation() {
3009 let content = "- item\nwrapped at zero indent\n";
3011 assert!(check(content).is_empty());
3012 assert_eq!(fix(content), content);
3013 }
3014
3015 #[test]
3016 fn from_config_aligned_enables_tight_flagging() {
3017 let mut config = crate::config::Config::default();
3019 let mut rule_config = crate::config::RuleConfig::default();
3020 rule_config
3021 .values
3022 .insert("style".to_string(), toml::Value::String("aligned".to_string()));
3023 config.rules.insert("MD077".to_string(), rule_config);
3024
3025 let rule = MD077ListContinuationIndent::from_config(&config);
3026 let ctx = LintContext::new("- item\nwrap\n", MarkdownFlavor::Standard, None);
3027 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
3028 }
3029
3030 #[test]
3031 fn from_config_default_is_any() {
3032 let config = crate::config::Config::default();
3034 let rule = MD077ListContinuationIndent::from_config(&config);
3035 let ctx = LintContext::new("- item\nwrap\n", MarkdownFlavor::Standard, None);
3036 assert!(rule.check(&ctx).unwrap().is_empty());
3037 }
3038
3039 #[test]
3040 fn from_config_indent_sets_fixed_requirement() {
3041 let mut config = crate::config::Config::default();
3044 let mut rule_config = crate::config::RuleConfig::default();
3045 rule_config.values.insert("indent".to_string(), toml::Value::Integer(4));
3046 config.rules.insert("MD077".to_string(), rule_config);
3047
3048 let rule = MD077ListContinuationIndent::from_config(&config);
3049
3050 let ok_ctx = LintContext::new("- item\n wrap\n", MarkdownFlavor::Standard, None);
3052 assert!(rule.check(&ok_ctx).unwrap().is_empty());
3053 assert_eq!(rule.fix(&ok_ctx).unwrap(), "- item\n wrap\n");
3054
3055 let bad_ctx = LintContext::new("- item\n\n wrap\n", MarkdownFlavor::Standard, None);
3058 let warnings = rule.check(&bad_ctx).unwrap();
3059 assert_eq!(warnings.len(), 1);
3060 assert!(warnings[0].message.contains("needs 4 spaces"));
3061 }
3062
3063 #[test]
3064 fn from_config_indent_applies_per_nested_marker() {
3065 let mut config = crate::config::Config::default();
3068 let mut rule_config = crate::config::RuleConfig::default();
3069 rule_config.values.insert("indent".to_string(), toml::Value::Integer(4));
3070 config.rules.insert("MD077".to_string(), rule_config);
3071
3072 let rule = MD077ListContinuationIndent::from_config(&config);
3073 let ctx = LintContext::new("- a\n - b\n wrap\n", MarkdownFlavor::Standard, None);
3074 let warnings = rule.check(&ctx).unwrap();
3075 assert!(
3076 warnings.is_empty(),
3077 "continuation at 6 spaces should pass: {warnings:?}"
3078 );
3079 }
3080
3081 fn rule_with(settings: &[(&str, toml::Value)]) -> Box<dyn Rule> {
3083 let mut config = crate::config::Config::default();
3084 let mut rule_config = crate::config::RuleConfig::default();
3085 for (key, value) in settings {
3086 rule_config.values.insert((*key).to_string(), value.clone());
3087 }
3088 config.rules.insert("MD077".to_string(), rule_config);
3089 MD077ListContinuationIndent::from_config(&config)
3090 }
3091
3092 #[test]
3093 fn configured_indent_cannot_lower_the_strict_flavor_minimum() {
3094 let rule = rule_with(&[("indent", toml::Value::Integer(2))]);
3098
3099 let two = LintContext::new("- item\n\n wrap\n", MarkdownFlavor::MkDocs, None);
3100 let warnings = rule.check(&two).unwrap();
3101 assert_eq!(warnings.len(), 1, "2 spaces is below the MkDocs minimum: {warnings:?}");
3102 assert!(
3103 warnings[0].message.contains("needs 4 spaces") && warnings[0].message.contains("MkDocs"),
3104 "the requirement comes from MkDocs, so the message must say so: {}",
3105 warnings[0].message
3106 );
3107 assert_eq!(rule.fix(&two).unwrap(), "- item\n\n wrap\n");
3108
3109 let four = LintContext::new("- item\n\n wrap\n", MarkdownFlavor::MkDocs, None);
3111 assert!(rule.check(&four).unwrap().is_empty());
3112
3113 let standard = LintContext::new("- item\n\n wrap\n", MarkdownFlavor::Standard, None);
3117 assert_eq!(rule.check(&standard).unwrap().len(), 1);
3118 assert_eq!(rule.fix(&standard).unwrap(), "- item\n\n wrap\n");
3119 }
3120
3121 #[test]
3122 fn configured_indent_can_raise_the_strict_flavor_minimum() {
3123 let rule = rule_with(&[("indent", toml::Value::Integer(6))]);
3126 let ctx = LintContext::new("- item\n\n wrap\n", MarkdownFlavor::MkDocs, None);
3127 let warnings = rule.check(&ctx).unwrap();
3128 assert_eq!(warnings.len(), 1);
3129 assert!(
3130 warnings[0].message.contains("needs 6 spaces"),
3131 "configured 6 must win over the 4-space floor: {}",
3132 warnings[0].message
3133 );
3134 assert_eq!(rule.fix(&ctx).unwrap(), "- item\n\n wrap\n");
3135 }
3136
3137 #[test]
3138 fn configured_indent_message_does_not_claim_a_structural_consequence() {
3139 let rule = rule_with(&[("indent", toml::Value::Integer(4))]);
3143 let ctx = LintContext::new("- item\n\n wrap\n", MarkdownFlavor::Standard, None);
3144 let warnings = rule.check(&ctx).unwrap();
3145 assert_eq!(warnings.len(), 1);
3146 assert!(
3147 warnings[0].message.contains("match the configured indent"),
3148 "expected the configured-indent wording, got: {}",
3149 warnings[0].message
3150 );
3151 assert!(
3152 !warnings[0].message.contains("remain part of the list"),
3153 "the content does remain part of the list here: {}",
3154 warnings[0].message
3155 );
3156
3157 assert!(check("- item\n\n wrap\n").is_empty());
3160
3161 let escaping = check("- item\n\n wrap\n");
3164 assert_eq!(escaping.len(), 1);
3165 assert!(
3166 escaping[0].message.contains("remain part of the list"),
3167 "unconfigured under-indent keeps its structural message, got: {}",
3168 escaping[0].message
3169 );
3170 }
3171
3172 #[test]
3173 fn configured_indent_leaves_tight_lazy_continuation_to_style() {
3174 let any = rule_with(&[("indent", toml::Value::Integer(4))]);
3178 let ctx = LintContext::new("- item\n wrap\n", MarkdownFlavor::Standard, None);
3179 assert!(
3180 any.check(&ctx).unwrap().is_empty(),
3181 "style = any accepts tight lazy continuation"
3182 );
3183
3184 let aligned = rule_with(&[
3185 ("indent", toml::Value::Integer(4)),
3186 ("style", toml::Value::String("aligned".to_string())),
3187 ]);
3188 let warnings = aligned.check(&ctx).unwrap();
3189 assert_eq!(warnings.len(), 1, "style = aligned raises it: {warnings:?}");
3190 assert!(warnings[0].message.contains("expected 4"));
3191 assert_eq!(aligned.fix(&ctx).unwrap(), "- item\n wrap\n");
3192 }
3193
3194 #[test]
3195 fn aligned_tight_underindented_fence_inside_item_left_alone() {
3196 let content = "- item\n ```\n code\n ```\n";
3200 assert!(check_aligned(content).is_empty());
3201 assert_eq!(fix_aligned(content), content);
3202 }
3203
3204 #[test]
3205 fn aligned_task_under_indent_fix_is_idempotent() {
3206 let content = "- [ ] Task\nwrap\n";
3207 let once = fix_aligned(content);
3208 assert_eq!(fix_aligned(&once), once);
3209 }
3210
3211 #[test]
3212 fn aligned_partial_indent_fix_is_idempotent() {
3213 let content = "- item\n continuation\n";
3214 let once = fix_aligned(content);
3215 assert_eq!(fix_aligned(&once), once);
3216 }
3217
3218 #[test]
3219 fn fence_shift_preserves_one_space_of_interior_nesting() {
3220 let content = "1. Configure:\n\n ```json\n {\n \"a\": 1\n }\n ```\n";
3223 let expected = "1. Configure:\n\n ```json\n {\n \"a\": 1\n }\n ```\n";
3224 assert_eq!(fix(content), expected);
3225 }
3226
3227 #[test]
3228 fn fence_shift_preserves_every_interior_nesting_level() {
3229 let content = "1. Configure:\n\n ```json\n {\n \"a\": {\n \"b\": 1\n }\n }\n ```\n";
3230 let expected = "1. Configure:\n\n ```json\n {\n \"a\": {\n \"b\": 1\n }\n }\n ```\n";
3231 assert_eq!(fix(content), expected);
3232 }
3233
3234 #[test]
3235 fn fence_shift_lifts_interior_below_the_list_scope_all_the_way() {
3236 let content = "1. Configure:\n\n ```json\n{\n ```\n";
3240 let expected = "1. Configure:\n\n ```json\n {\n ```\n";
3241 assert_eq!(fix(content), expected);
3242 assert_eq!(fix(expected), expected, "and the result is stable");
3243 }
3244
3245 #[test]
3246 fn fence_shift_is_idempotent() {
3247 for content in [
3248 "1. Configure:\n\n ```json\n {\n \"a\": 1\n }\n ```\n",
3249 "1. Configure:\n\n ```json\n{\n deep\n }\n ```\n",
3250 "- item\n\n ```\n nested\n ```\n",
3251 ] {
3252 let once = fix(content);
3253 assert_eq!(fix(&once), once, "MD077 fence fix must be idempotent: {content:?}");
3254 }
3255 }
3256
3257 #[test]
3258 fn fence_already_at_the_content_column_is_left_alone() {
3259 let content = "1. Configure:\n\n ```json\n {\n \"a\": 1\n }\n ```\n";
3260 assert!(check(content).is_empty());
3261 assert_eq!(fix(content), content);
3262 }
3263
3264 #[test]
3265 fn over_indented_fence_keeps_its_interior_untouched() {
3266 let content = "1. Configure:\n\n ```json\n {\n \"a\": 1\n }\n ```\n";
3268 assert_eq!(fix(content), content);
3269 }
3270}