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(
185 ctx: &LintContext,
186 opener_line: usize,
187 closer_line: usize,
188 opener_actual: usize,
189 required: usize,
190 ) -> Option<Fix> {
191 if required <= opener_actual {
192 return None;
193 }
194 let opener_info = ctx.line_info(opener_line)?;
195 let closer_info = ctx.line_info(closer_line)?;
196
197 let fix_start = opener_info.byte_offset;
198 let fix_end = closer_info.byte_offset + closer_info.byte_len;
199
200 let mut replacement = String::new();
201 for i in opener_line..=closer_line {
202 let info = ctx.line_info(i)?;
203 if i > opener_line {
204 replacement.push('\n');
205 }
206 let line = info.content(ctx.content);
207 if info.is_blank {
208 replacement.push_str(line);
210 } else {
211 let new_visual = if i == opener_line || i == closer_line {
212 required
213 } else {
214 info.visual_indent.max(required)
215 };
216 for _ in 0..new_visual {
217 replacement.push(' ');
218 }
219 replacement.push_str(&line[info.indent..]);
220 }
221 }
222
223 Some(Fix::new(fix_start..fix_end, replacement))
224 }
225
226 fn walk_item_continuation<F>(
252 ctx: &LintContext,
253 item_line: usize,
254 range_end: usize,
255 marker_col: usize,
256 mut per_line: F,
257 ) where
258 F: FnMut(&ContinuationLine<'_>) -> ControlFlow<()>,
259 {
260 let mut saw_blank = false;
261 let mut saw_nested = false;
262 let mut nested_stack: Vec<(usize, usize)> = Vec::new();
268
269 for line_num in (item_line + 1)..=range_end {
270 let Some(info) = ctx.line_info(line_num) else {
271 continue;
272 };
273
274 let trimmed = info.content(ctx.content).trim_start();
275
276 if Self::should_skip_line(info, trimmed) {
277 continue;
278 }
279
280 if info.is_blank {
281 saw_blank = true;
282 continue;
283 }
284
285 if let Some(ref li) = info.list_item {
286 if li.marker_column > marker_col {
287 while nested_stack.last().is_some_and(|&(m, _)| m >= li.marker_column) {
290 nested_stack.pop();
291 }
292 nested_stack.push((li.marker_column, li.content_column));
293 saw_nested = true;
298 } else {
299 nested_stack.clear();
300 }
301 saw_blank = false;
302 continue;
303 }
304
305 if info.heading.is_some() || info.is_horizontal_rule {
306 break;
307 }
308
309 if Self::is_block_level_construct(trimmed) {
310 continue;
311 }
312
313 let col = info.visual_indent;
314
315 while nested_stack.last().is_some_and(|&(_, c)| c > col) {
319 nested_stack.pop();
320 }
321 if !nested_stack.is_empty() {
322 continue;
323 }
324
325 if saw_blank && col <= marker_col {
326 break;
327 }
328
329 let line = ContinuationLine {
330 line_num,
331 info,
332 trimmed,
333 actual: col,
334 saw_blank,
335 saw_nested,
336 };
337 if per_line(&line).is_break() {
338 break;
339 }
340 }
341 }
342
343 fn item_range_has_latent_structure(ctx: &LintContext, item_line: usize, range_end: usize) -> bool {
363 (item_line + 1..=range_end).any(|line_num| {
364 ctx.line_info(line_num).is_some_and(|info| {
365 if info.is_blank || info.list_item.is_some() {
366 return false;
367 }
368 let trimmed = info.content(ctx.content).trim_start();
369 !Self::should_skip_line(info, trimmed)
370 && (Self::starts_with_list_marker(trimmed)
371 || crate::utils::skip_context::is_table_line(trimmed)
372 || Self::is_latent_setext_underline(ctx, line_num, trimmed))
373 })
374 })
375 }
376
377 fn is_latent_setext_underline(ctx: &LintContext, line_num: usize, trimmed: &str) -> bool {
391 crate::lint_context::is_setext_underline_content(trimmed)
392 && ctx.line_info(line_num - 1).is_some_and(|prev| {
393 prev.is_paragraph_context() && crate::lint_context::is_paragraph_text_line(prev.content(ctx.content))
394 })
395 }
396
397 fn sibling_column_usage(
407 ctx: &LintContext,
408 item_line: usize,
409 range_end: usize,
410 marker_col: usize,
411 content_col: usize,
412 task_col: usize,
413 ) -> (bool, bool) {
414 let mut uses_content = false;
415 let mut uses_task = false;
416
417 Self::walk_item_continuation(ctx, item_line, range_end, marker_col, |line| {
418 if line.actual == content_col {
419 uses_content = true;
420 }
421 if line.actual == task_col {
422 uses_task = true;
423 }
424 if uses_content && uses_task {
425 ControlFlow::Break(())
426 } else {
427 ControlFlow::Continue(())
428 }
429 });
430
431 (uses_content, uses_task)
432 }
433
434 fn compute_fix_target(
440 actual: usize,
441 required: usize,
442 task_col: Option<usize>,
443 uses_content_col: bool,
444 uses_task_col: bool,
445 ) -> usize {
446 let Some(t) = task_col else { return required };
447 match actual.abs_diff(t).cmp(&actual.abs_diff(required)) {
448 std::cmp::Ordering::Less => t,
449 std::cmp::Ordering::Greater => required,
450 std::cmp::Ordering::Equal => match (uses_task_col, uses_content_col) {
451 (true, false) => t,
452 _ => required,
453 },
454 }
455 }
456
457 fn should_skip_line(info: &crate::lint_context::LineInfo, trimmed: &str) -> bool {
468 if info.in_code_block && !Self::is_code_fence(trimmed) {
469 return true;
470 }
471 info.in_front_matter
472 || info.in_footnote_definition
473 || info.in_html_block
474 || info.in_html_comment
475 || info.in_mdx_comment
476 || info.in_mkdocstrings
477 || info.in_esm_block
478 || info.in_math_block
479 || info.in_admonition
480 || info.in_content_tab
481 || info.in_pymdown_block
482 || info.in_definition_list
483 || info.in_mkdocs_html_markdown
484 || info.in_kramdown_extension_block
485 }
486
487 fn build_over_indent_warning(
496 ctx: &LintContext,
497 line: &ContinuationLine<'_>,
498 fix_target: usize,
499 message: String,
500 ) -> LintWarning {
501 let line_content = line.info.content(ctx.content);
502 let fix_start = line.info.byte_offset;
503 let fix_end = fix_start + line.info.indent;
504 LintWarning {
505 rule_name: Some("MD077".to_string()),
506 line: line.line_num,
507 column: 1,
508 end_line: line.line_num,
509 end_column: line_content.chars().count() + 1,
510 message,
511 severity: Severity::Warning,
512 fix: Some(Fix::new(fix_start..fix_end, " ".repeat(fix_target))),
513 }
514 }
515
516 fn build_under_indent_warning(
528 ctx: &LintContext,
529 line: &ContinuationLine<'_>,
530 required: usize,
531 message: String,
532 ) -> UnderIndentOutcome {
533 let line_content = line.info.content(ctx.content);
534 let is_fence_opener = line.info.in_code_block
535 && Self::is_code_fence(line.trimmed)
536 && ctx.line_info(line.line_num - 1).is_none_or(|p| !p.in_code_block);
537
538 let (fix, warn_end_line, warn_end_column, compound_closer) = if is_fence_opener {
539 let closer_line = Self::find_fence_closer(ctx, line.line_num);
540 let fix = Self::build_compound_fence_fix(ctx, line.line_num, closer_line, line.actual, required);
541 let end_column = ctx
542 .line_info(closer_line)
543 .map_or(line_content.chars().count() + 1, |ci| {
544 ci.content(ctx.content).chars().count() + 1
545 });
546 let extra_flag = (closer_line != line.line_num).then_some(closer_line);
547 (fix, closer_line, end_column, extra_flag)
548 } else {
549 let fix_start = line.info.byte_offset;
550 let fix_end = fix_start + line.info.indent;
551 let fix = Some(Fix::new(fix_start..fix_end, " ".repeat(required)));
552 (fix, line.line_num, line_content.chars().count() + 1, None)
553 };
554
555 UnderIndentOutcome {
556 warning: LintWarning {
557 rule_name: Some("MD077".to_string()),
558 line: line.line_num,
559 column: 1,
560 end_line: warn_end_line,
561 end_column: warn_end_column,
562 message,
563 severity: Severity::Warning,
564 fix,
565 },
566 also_flag_line: compound_closer,
567 }
568 }
569}
570
571struct ContinuationLine<'a> {
575 line_num: usize,
576 info: &'a LineInfo,
577 trimmed: &'a str,
578 actual: usize,
579 saw_blank: bool,
580 saw_nested: bool,
584}
585
586struct UnderIndentOutcome {
591 warning: LintWarning,
592 also_flag_line: Option<usize>,
593}
594
595impl Rule for MD077ListContinuationIndent {
596 fn name(&self) -> &'static str {
597 "MD077"
598 }
599
600 fn description(&self) -> &'static str {
601 "List continuation content indentation"
602 }
603
604 fn check(&self, ctx: &LintContext) -> LintResult {
605 if ctx.content.is_empty() {
606 return Ok(Vec::new());
607 }
608
609 let strict_indent = ctx.flavor.requires_strict_list_indent();
610 let total_lines = ctx.lines.len();
611 let mut warnings = Vec::new();
612 let mut flagged_lines = std::collections::HashSet::new();
613
614 let mut items: Vec<(usize, usize, usize, Option<usize>)> = Vec::new();
623 for block in &ctx.list_blocks {
624 for &item_line in &block.item_lines {
625 if let Some(info) = ctx.line_info(item_line)
626 && let Some(ref li) = info.list_item
627 {
628 if info.blockquote.is_some() {
635 continue;
636 }
637 let line = info.content(ctx.content);
638 let task_col = Self::is_task_list_item(line, li.content_column)
639 .then_some(li.content_column + Self::TASK_CHECKBOX_PREFIX_LEN);
640 items.push((item_line, li.marker_column, li.content_column, task_col));
641 }
642 }
643 }
644 items.sort_unstable();
645 items.dedup_by_key(|&mut (ln, _, _, _)| ln);
646
647 let mut range_ends = vec![total_lines; items.len()];
660 let mut stack: Vec<usize> = Vec::new();
661 for i in (0..items.len()).rev() {
662 let marker_col = items[i].1;
663 while let Some(&top) = stack.last() {
664 if items[top].1 > marker_col {
665 stack.pop();
666 } else {
667 break;
668 }
669 }
670 range_ends[i] = stack.last().map_or(total_lines, |&j| items[j].0 - 1);
671 stack.push(i);
672 }
673
674 let scoped: Vec<(usize, usize, usize, Option<usize>, usize, usize)> = items
677 .iter()
678 .enumerate()
679 .map(|(item_idx, &(item_line, marker_col, content_col, task_col))| {
680 let required = match self.config.indent {
688 Some(indent) if strict_indent => (marker_col + indent).max(4),
689 Some(indent) => marker_col + indent,
690 None if strict_indent => content_col.max(4),
691 None => content_col,
692 };
693 (
694 item_line,
695 marker_col,
696 content_col,
697 task_col,
698 required,
699 range_ends[item_idx],
700 )
701 })
702 .collect();
703
704 let prose_candidate_lines: Vec<usize> = (1..=total_lines)
718 .filter(|&line_num| {
719 let Some(info) = ctx.line_info(line_num) else {
720 return false;
721 };
722 let trimmed = info.content(ctx.content).trim_start();
723 !Self::should_skip_line(info, trimmed)
724 && !info.is_blank
725 && info.list_item.is_none()
726 && info.heading.is_none()
727 && !info.is_horizontal_rule
728 && !Self::is_block_level_construct(trimmed)
729 })
730 .collect();
731 let range_has_prose_candidate = |after_line: usize, range_end: usize| -> bool {
734 let start = prose_candidate_lines.partition_point(|&l| l <= after_line);
735 prose_candidate_lines.get(start).is_some_and(|&l| l <= range_end)
736 };
737
738 let aligned = self.config.style == ContinuationStyle::Aligned;
765 for &(item_line, marker_col, _content_col, _task_col, required, range_end) in &scoped {
766 if !range_has_prose_candidate(item_line, range_end) {
769 continue;
770 }
771 let has_latent_structure = aligned && Self::item_range_has_latent_structure(ctx, item_line, range_end);
786 let from_configured_indent = self.config.indent.is_some_and(|indent| marker_col + indent == required);
792 Self::walk_item_continuation(ctx, item_line, range_end, marker_col, |line| {
793 let actual = line.actual;
794 let under_indented = actual < required;
795 let loose_escape = line.saw_blank && under_indented;
796 let confirmed_structure = line.info.in_code_block || line.info.blockquote.is_some();
803 let aligned_tight = aligned
804 && !has_latent_structure
805 && !line.saw_blank
806 && !line.saw_nested
807 && under_indented
808 && !confirmed_structure;
809 if (loose_escape || aligned_tight) && flagged_lines.insert(line.line_num) {
810 let message = if line.saw_blank {
811 if from_configured_indent {
812 format!(
813 "Content after blank line in list item needs {required} spaces of \
814 indentation to match the configured indent (found {actual})",
815 )
816 } else if strict_indent {
817 format!(
818 "Content inside list item needs {required} spaces of indentation \
819 for MkDocs compatibility (found {actual})",
820 )
821 } else {
822 format!(
823 "Content after blank line in list item needs {required} spaces of \
824 indentation to remain part of the list (found {actual})",
825 )
826 }
827 } else {
828 format!("Continuation line under-indented (expected {required}, found {actual})")
829 };
830 let outcome = Self::build_under_indent_warning(ctx, line, required, message);
831 if let Some(closer_line) = outcome.also_flag_line {
832 flagged_lines.insert(closer_line);
833 }
834 warnings.push(outcome.warning);
835 }
836 ControlFlow::Continue(())
837 });
838 }
839
840 for &(item_line, marker_col, content_col, task_col, required, range_end) in &scoped {
849 if !range_has_prose_candidate(item_line, range_end) {
851 continue;
852 }
853 let (uses_content_col, uses_task_col) = match task_col {
857 Some(t) => Self::sibling_column_usage(ctx, item_line, range_end, marker_col, content_col, t),
858 None => (false, false),
859 };
860
861 Self::walk_item_continuation(ctx, item_line, range_end, marker_col, |line| {
862 let actual = line.actual;
863 if actual > required
864 && !line.info.in_code_block
865 && Some(actual) != task_col
866 && !Self::starts_with_list_marker(line.trimmed)
867 && flagged_lines.insert(line.line_num)
868 {
869 let fix_target =
870 Self::compute_fix_target(actual, required, task_col, uses_content_col, uses_task_col);
871 let message = match task_col {
872 Some(t) => format!(
873 "Continuation line over-indented \
874 (expected {required} or {t}, found {actual})"
875 ),
876 None => {
877 format!("Continuation line over-indented (expected {required}, found {actual})")
878 }
879 };
880 warnings.push(Self::build_over_indent_warning(ctx, line, fix_target, message));
881 }
882 ControlFlow::Continue(())
883 });
884 }
885
886 warnings.sort_by_key(|w| (w.line, w.column));
889
890 Ok(warnings)
891 }
892
893 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
894 let warnings = self.check(ctx)?;
895 let warnings =
896 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
897 if warnings.is_empty() {
898 return Ok(ctx.content.to_string());
899 }
900
901 let mut fixes: Vec<Fix> = warnings.into_iter().filter_map(|w| w.fix).collect();
903 fixes.sort_by_key(|f| std::cmp::Reverse(f.range.start));
904
905 let mut content = ctx.content.to_string();
906 for fix in fixes {
907 if fix.range.start <= content.len() && fix.range.end <= content.len() {
908 content.replace_range(fix.range, &fix.replacement);
909 }
910 }
911
912 Ok(content)
913 }
914
915 fn category(&self) -> RuleCategory {
916 RuleCategory::List
917 }
918
919 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
920 ctx.content.is_empty() || ctx.list_blocks.is_empty()
921 }
922
923 fn as_any(&self) -> &dyn std::any::Any {
924 self
925 }
926
927 crate::impl_rule_config_methods!(MD077Config);
928}
929
930#[cfg(test)]
931mod tests {
932 use super::*;
933 use crate::config::MarkdownFlavor;
934
935 fn check(content: &str) -> Vec<LintWarning> {
936 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
937 let rule = MD077ListContinuationIndent::default();
938 rule.check(&ctx).unwrap()
939 }
940
941 fn check_mkdocs(content: &str) -> Vec<LintWarning> {
942 let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
943 let rule = MD077ListContinuationIndent::default();
944 rule.check(&ctx).unwrap()
945 }
946
947 fn fix(content: &str) -> String {
948 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
949 let rule = MD077ListContinuationIndent::default();
950 rule.fix(&ctx).unwrap()
951 }
952
953 fn fix_mkdocs(content: &str) -> String {
954 let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
955 let rule = MD077ListContinuationIndent::default();
956 rule.fix(&ctx).unwrap()
957 }
958
959 fn aligned_rule() -> MD077ListContinuationIndent {
960 MD077ListContinuationIndent::new(ContinuationStyle::Aligned)
961 }
962
963 fn check_aligned(content: &str) -> Vec<LintWarning> {
964 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
965 aligned_rule().check(&ctx).unwrap()
966 }
967
968 fn check_aligned_mkdocs(content: &str) -> Vec<LintWarning> {
969 let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
970 aligned_rule().check(&ctx).unwrap()
971 }
972
973 fn fix_aligned(content: &str) -> String {
974 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
975 aligned_rule().fix(&ctx).unwrap()
976 }
977
978 fn fix_aligned_quarto(content: &str) -> String {
979 let ctx = LintContext::new(content, MarkdownFlavor::Quarto, None);
980 aligned_rule().fix(&ctx).unwrap()
981 }
982
983 #[test]
984 fn aligned_idempotent_with_latent_marker_behind_unstable_heading() {
985 let input = "1. \n``\n``\n- \n``";
994 let once = fix_aligned_quarto(input);
995 let twice = fix_aligned_quarto(&once);
996 assert_eq!(once, twice, "MD077 aligned fix must be idempotent (Quarto)");
997 }
998
999 #[test]
1000 fn latent_underline_needs_paragraph_text_above_it() {
1001 for (label, content) in [
1005 ("ATX heading", "- item\nwrap\n# Heading\n===\n"),
1006 ("thematic break", "- item\nwrap\n***\n===\n"),
1007 ("HTML block", "- item\nwrap\n<div>\n===\n"),
1008 ] {
1009 assert_eq!(
1010 check_aligned(content).len(),
1011 1,
1012 "{label}: reindenting cannot make a setext heading here, so the under-indent is reportable"
1013 );
1014 }
1015
1016 assert_eq!(
1020 check_aligned("- item\nwrap\n```\ncode\n```\n===\n").len(),
1021 2,
1022 "a closing fence is not paragraph text, so both under-indents are reportable"
1023 );
1024
1025 for (label, content) in [
1031 ("empty bullet", "- item\n wrap\n > - \n ===\n"),
1032 ("empty ordered item", "- item\n wrap\n > 1. \n ===\n"),
1033 ("empty item in a nested quote", "- item\n wrap\n > > - \n ===\n"),
1034 ] {
1035 assert_eq!(
1036 check_aligned(content).len(),
1037 1,
1038 "{label}: an item holding no text cannot become a heading's text line"
1039 );
1040 }
1041
1042 for (label, content) in [
1046 ("bare blank line", "- item\n wrap\n\n ===\n"),
1047 ("blank line in a quote", "- item\n wrap\n >\n ===\n"),
1048 ("quoted whitespace", "- item\n wrap\n > \n ===\n"),
1049 ] {
1050 assert_eq!(
1051 check_aligned(content).len(),
1052 2,
1053 "{label}: nothing above the underline can become a heading's text line"
1054 );
1055 }
1056
1057 assert!(
1061 check_aligned("- item\nwrap\ntext\n===\n").is_empty(),
1062 "prose above the underline is latent structure, so the item is left alone"
1063 );
1064 }
1065
1066 #[test]
1067 fn aligned_idempotent_with_lazy_continuation_out_of_a_blockquote() {
1068 let input = "- \n> *\n> a\n``";
1072 let once = fix_aligned(input);
1073 let twice = fix_aligned(&once);
1074 assert_eq!(once, twice, "MD077 aligned fix must be idempotent");
1075 }
1076
1077 #[test]
1080 fn tight_lazy_continuation_zero_indent_not_flagged() {
1081 let content = "- Item\ncontinuation\n";
1083 assert!(check(content).is_empty());
1084 }
1085
1086 #[test]
1087 fn tight_continuation_correct_indent_not_flagged() {
1088 let content = "1. Item\n continuation\n";
1090 assert!(check(content).is_empty());
1091 }
1092
1093 #[test]
1094 fn tight_continuation_over_indented_ordered() {
1095 let content = "1. This is a list item with multiple lines.\n The second line is over-indented.\n";
1097 let warnings = check(content);
1098 assert_eq!(warnings.len(), 1);
1099 assert_eq!(warnings[0].line, 2);
1100 assert!(warnings[0].message.contains("over-indented"));
1101 }
1102
1103 #[test]
1104 fn tight_continuation_over_indented_unordered() {
1105 let content = "- Item\n over-indented\n";
1107 let warnings = check(content);
1108 assert_eq!(warnings.len(), 1);
1109 assert_eq!(warnings[0].line, 2);
1110 }
1111
1112 #[test]
1113 fn tight_continuation_multiple_over_indented_lines() {
1114 let content = "1. Item\n line one\n line two\n line three\n";
1115 let warnings = check(content);
1116 assert_eq!(warnings.len(), 3);
1117 }
1118
1119 #[test]
1120 fn tight_continuation_mixed_correct_and_over() {
1121 let content = "1. Item\n correct\n over-indented\n correct again\n";
1122 let warnings = check(content);
1123 assert_eq!(warnings.len(), 1);
1124 assert_eq!(warnings[0].line, 3);
1125 }
1126
1127 #[test]
1128 fn tight_continuation_nested_over_indented() {
1129 let content = "- L1\n - L2\n over-indented continuation of L2\n";
1131 let warnings = check(content);
1132 assert_eq!(warnings.len(), 1);
1133 assert_eq!(warnings[0].line, 3);
1134 assert!(warnings[0].message.contains("expected 4"));
1136 assert!(warnings[0].message.contains("found 5"));
1137 }
1138
1139 #[test]
1140 fn tight_continuation_nested_correct_indent_not_flagged() {
1141 let content = "- L1\n - L2\n correctly indented continuation of L2\n";
1144 assert!(check(content).is_empty());
1145 }
1146
1147 #[test]
1148 fn fix_tight_continuation_nested_over_indented() {
1149 let content = "- L1\n - L2\n over-indented continuation of L2\n";
1151 let fixed = fix(content);
1152 assert_eq!(fixed, "- L1\n - L2\n over-indented continuation of L2\n");
1153 }
1154
1155 #[test]
1156 fn tight_continuation_under_indented_not_flagged() {
1157 let content = "1. Item\n under-indented\n";
1160 assert!(check(content).is_empty());
1161 }
1162
1163 #[test]
1164 fn tight_continuation_tab_over_indented() {
1165 let content = "- Item\n\tover-indented\n";
1167 let warnings = check(content);
1168 assert_eq!(warnings.len(), 1);
1169 }
1170
1171 #[test]
1172 fn fix_tight_continuation_over_indented_ordered() {
1173 let content = "1. This is a list item with multiple lines.\n The second line is over-indented.\n";
1174 let fixed = fix(content);
1175 assert_eq!(
1176 fixed,
1177 "1. This is a list item with multiple lines.\n The second line is over-indented.\n"
1178 );
1179 }
1180
1181 #[test]
1182 fn fix_tight_continuation_over_indented_unordered() {
1183 let content = "- Item\n over-indented\n";
1184 let fixed = fix(content);
1185 assert_eq!(fixed, "- Item\n over-indented\n");
1186 }
1187
1188 #[test]
1189 fn fix_tight_continuation_multiple_lines() {
1190 let content = "1. Item\n line one\n line two\n";
1191 let fixed = fix(content);
1192 assert_eq!(fixed, "1. Item\n line one\n line two\n");
1193 }
1194
1195 #[test]
1196 fn tight_continuation_mkdocs_4space_ordered_not_flagged() {
1197 let content = "1. Item\n continuation\n";
1200 assert!(check_mkdocs(content).is_empty());
1201 }
1202
1203 #[test]
1204 fn tight_continuation_mkdocs_5space_ordered_flagged() {
1205 let content = "1. Item\n over-indented\n";
1207 let warnings = check_mkdocs(content);
1208 assert_eq!(warnings.len(), 1);
1209 assert!(warnings[0].message.contains("expected 4"));
1210 assert!(warnings[0].message.contains("found 5"));
1211 }
1212
1213 #[test]
1214 fn fix_tight_continuation_mkdocs_over_indented() {
1215 let content = "1. Item\n over-indented\n";
1216 let fixed = fix_mkdocs(content);
1217 assert_eq!(fixed, "1. Item\n over-indented\n");
1218 }
1219
1220 #[test]
1221 fn tight_continuation_deeply_indented_list_markers_not_flagged() {
1222 let content = "* Level 0\n * Level 1\n * Level 2\n";
1225 assert!(check(content).is_empty());
1226 }
1227
1228 #[test]
1229 fn tight_continuation_ordered_marker_not_flagged() {
1230 let content = "- Parent\n 1. Child item\n";
1232 assert!(check(content).is_empty());
1233 }
1234
1235 #[test]
1238 fn unordered_correct_indent_no_warning() {
1239 let content = "- Item\n\n continuation\n";
1240 assert!(check(content).is_empty());
1241 }
1242
1243 #[test]
1244 fn unordered_partial_indent_warns() {
1245 let content = "- Item\n\n continuation\n";
1248 let warnings = check(content);
1249 assert_eq!(warnings.len(), 1);
1250 assert_eq!(warnings[0].line, 3);
1251 assert!(warnings[0].message.contains("2 spaces"));
1252 assert!(warnings[0].message.contains("found 1"));
1253 }
1254
1255 #[test]
1256 fn unordered_zero_indent_is_new_paragraph() {
1257 let content = "- Item\n\ncontinuation\n";
1260 assert!(check(content).is_empty());
1261 }
1262
1263 #[test]
1266 fn ordered_3space_correct_commonmark() {
1267 let content = "1. Item\n\n continuation\n";
1269 assert!(check(content).is_empty());
1270 }
1271
1272 #[test]
1273 fn ordered_2space_under_indent_commonmark() {
1274 let content = "1. Item\n\n continuation\n";
1275 let warnings = check(content);
1276 assert_eq!(warnings.len(), 1);
1277 assert!(warnings[0].message.contains("3 spaces"));
1278 assert!(warnings[0].message.contains("found 2"));
1279 }
1280
1281 #[test]
1284 fn multi_digit_marker_correct() {
1285 let content = "10. Item\n\n continuation\n";
1287 assert!(check(content).is_empty());
1288 }
1289
1290 #[test]
1291 fn multi_digit_marker_under_indent() {
1292 let content = "10. Item\n\n continuation\n";
1293 let warnings = check(content);
1294 assert_eq!(warnings.len(), 1);
1295 assert!(warnings[0].message.contains("4 spaces"));
1296 }
1297
1298 #[test]
1301 fn mkdocs_3space_ordered_warns() {
1302 let content = "1. Item\n\n continuation\n";
1304 let warnings = check_mkdocs(content);
1305 assert_eq!(warnings.len(), 1);
1306 assert!(warnings[0].message.contains("4 spaces"));
1307 assert!(warnings[0].message.contains("MkDocs"));
1308 }
1309
1310 #[test]
1311 fn mkdocs_4space_ordered_no_warning() {
1312 let content = "1. Item\n\n continuation\n";
1313 assert!(check_mkdocs(content).is_empty());
1314 }
1315
1316 #[test]
1317 fn mkdocs_unordered_2space_ok() {
1318 let content = "- Item\n\n continuation\n";
1320 assert!(check_mkdocs(content).is_empty());
1321 }
1322
1323 #[test]
1324 fn mkdocs_unordered_2space_warns() {
1325 let content = "- Item\n\n continuation\n";
1327 let warnings = check_mkdocs(content);
1328 assert_eq!(warnings.len(), 1);
1329 assert!(warnings[0].message.contains("4 spaces"));
1330 }
1331
1332 #[test]
1335 fn fix_unordered_indent() {
1336 let content = "- Item\n\n continuation\n";
1338 let fixed = fix(content);
1339 assert_eq!(fixed, "- Item\n\n continuation\n");
1340 }
1341
1342 #[test]
1343 fn fix_ordered_indent() {
1344 let content = "1. Item\n\n continuation\n";
1345 let fixed = fix(content);
1346 assert_eq!(fixed, "1. Item\n\n continuation\n");
1347 }
1348
1349 #[test]
1350 fn fix_mkdocs_indent() {
1351 let content = "1. Item\n\n continuation\n";
1352 let fixed = fix_mkdocs(content);
1353 assert_eq!(fixed, "1. Item\n\n continuation\n");
1354 }
1355
1356 #[test]
1359 fn nested_list_items_not_flagged() {
1360 let content = "- Parent\n\n - Child\n";
1361 assert!(check(content).is_empty());
1362 }
1363
1364 #[test]
1365 fn nested_list_zero_indent_is_new_paragraph() {
1366 let content = "- Parent\n - Child\n\ncontinuation of parent\n";
1368 assert!(check(content).is_empty());
1369 }
1370
1371 #[test]
1372 fn nested_list_partial_indent_flagged() {
1373 let content = "- Parent\n - Child\n\n continuation of parent\n";
1375 let warnings = check(content);
1376 assert_eq!(warnings.len(), 1);
1377 assert!(warnings[0].message.contains("2 spaces"));
1378 }
1379
1380 #[test]
1383 fn code_block_correctly_indented_no_warning() {
1384 let content = "- Item\n\n ```\n code\n ```\n";
1386 assert!(check(content).is_empty());
1387 }
1388
1389 #[test]
1390 fn code_fence_under_indented_warns() {
1391 let content = "- Item\n\n ```\n code\n ```\n";
1395 let warnings = check(content);
1396 assert_eq!(warnings.len(), 1);
1397 assert_eq!(warnings[0].line, 3);
1398 }
1399
1400 #[test]
1401 fn code_fence_under_indented_ordered_mkdocs() {
1402 let content = "1. Item\n\n ```toml\n key = \"value\"\n ```\n";
1405 assert!(check(content).is_empty()); let warnings = check_mkdocs(content);
1407 assert_eq!(warnings.len(), 1); assert_eq!(warnings[0].line, 3);
1409 assert!(warnings[0].message.contains("4 spaces"));
1410 assert!(warnings[0].message.contains("MkDocs"));
1411 }
1412
1413 #[test]
1414 fn code_fence_tilde_under_indented() {
1415 let content = "- Item\n\n ~~~\n code\n ~~~\n";
1416 let warnings = check(content);
1417 assert_eq!(warnings.len(), 1); assert_eq!(warnings[0].line, 3);
1419 }
1420
1421 #[test]
1424 fn multiple_blank_lines_zero_indent_is_new_paragraph() {
1425 let content = "- Item\n\n\ncontinuation\n";
1427 assert!(check(content).is_empty());
1428 }
1429
1430 #[test]
1431 fn multiple_blank_lines_partial_indent_flags() {
1432 let content = "- Item\n\n\n continuation\n";
1433 let warnings = check(content);
1434 assert_eq!(warnings.len(), 1);
1435 }
1436
1437 #[test]
1440 fn empty_item_no_warning() {
1441 let content = "- \n- Second\n";
1442 assert!(check(content).is_empty());
1443 }
1444
1445 #[test]
1448 fn multiple_items_mixed_indent() {
1449 let content = "1. First\n\n correct continuation\n\n2. Second\n\n wrong continuation\n";
1450 let warnings = check(content);
1451 assert_eq!(warnings.len(), 1);
1452 assert_eq!(warnings[0].line, 7);
1453 }
1454
1455 #[test]
1458 fn task_list_correct_indent() {
1459 let content = "- [ ] Task\n\n continuation\n";
1461 assert!(check(content).is_empty());
1462 }
1463
1464 #[test]
1467 fn frontmatter_not_flagged() {
1468 let content = "---\ntitle: test\n---\n\n- Item\n\n continuation\n";
1469 assert!(check(content).is_empty());
1470 }
1471
1472 #[test]
1475 fn fix_multiple_items() {
1476 let content = "1. First\n\n wrong1\n\n2. Second\n\n wrong2\n";
1477 let fixed = fix(content);
1478 assert_eq!(fixed, "1. First\n\n wrong1\n\n2. Second\n\n wrong2\n");
1479 }
1480
1481 #[test]
1482 fn fix_multiline_loose_continuation_all_lines() {
1483 let content = "1. Item\n\n line one\n line two\n line three\n";
1484 let fixed = fix(content);
1485 assert_eq!(fixed, "1. Item\n\n line one\n line two\n line three\n");
1486 }
1487
1488 #[test]
1491 fn sibling_item_boundary_respected() {
1492 let content = "- First\n- Second\n\n continuation\n";
1494 assert!(check(content).is_empty());
1495 }
1496
1497 #[test]
1500 fn blockquote_list_correct_indent_no_warning() {
1501 let content = "> - Item\n>\n> continuation\n";
1504 assert!(check(content).is_empty());
1505 }
1506
1507 #[test]
1508 fn blockquote_list_under_indent_no_false_positive() {
1509 let content = "> - Item\n>\n> continuation\n";
1514 assert!(check(content).is_empty());
1515 }
1516
1517 #[test]
1520 fn deep_nesting_correct_indent() {
1521 let content = "- L1\n - L2\n - L3\n\n continuation of L3\n";
1522 assert!(check(content).is_empty());
1523 }
1524
1525 #[test]
1526 fn deep_nesting_under_indent() {
1527 let content = "- L1\n - L2\n - L3\n\n continuation of L3\n";
1530 let warnings = check(content);
1531 assert_eq!(warnings.len(), 1);
1532 assert!(warnings[0].message.contains("6 spaces"));
1533 assert!(warnings[0].message.contains("found 5"));
1534 }
1535
1536 #[test]
1537 fn deep_nesting_middle_level_continuation_bullets() {
1538 let content = "- L1\n - L2\n - L3\n\n continuation of L2\n";
1542 assert!(check(content).is_empty());
1543 }
1544
1545 #[test]
1546 fn deep_nesting_middle_level_continuation_ordered() {
1547 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";
1550 assert!(check(content).is_empty());
1551 }
1552
1553 #[test]
1554 fn deep_nesting_outermost_continuation() {
1555 let content = "- L1\n - L2\n - L3\n\n continuation of L1\n";
1558 assert!(check(content).is_empty());
1559 }
1560
1561 #[test]
1562 fn deep_nesting_between_levels_still_flagged() {
1563 let content = "- L1\n - L2\n - L3\n\n continuation\n";
1566 let warnings = check(content);
1567 assert_eq!(warnings.len(), 1);
1568 assert!(warnings[0].message.contains("4 spaces"));
1569 assert!(warnings[0].message.contains("found 3"));
1570 }
1571
1572 #[test]
1573 fn deep_nesting_beyond_deepest_still_flagged() {
1574 let content = "- L1\n - L2\n - L3\n\n continuation\n";
1576 let warnings = check(content);
1577 assert_eq!(warnings.len(), 1);
1578 assert!(warnings[0].message.contains("over-indented"));
1579 assert!(warnings[0].message.contains("expected 6, found 7"));
1580 }
1581
1582 #[test]
1583 fn four_levels_middle_continuation() {
1584 let content = "- L1\n - L2\n - L3\n - L4\n\n continuation of L2\n";
1587 assert!(check(content).is_empty());
1588 }
1589
1590 #[test]
1591 fn nested_sibling_closes_deeper_level() {
1592 let content = "- L1\n - L2a\n - L3\n - L2b\n\n continuation of L2b\n";
1595 assert!(check(content).is_empty());
1596 }
1597
1598 #[test]
1599 fn deep_nesting_middle_level_continuation_fix_preserved() {
1600 let content = "- L1\n - L2\n - L3\n\n continuation of L2\n";
1602 assert_eq!(fix(content), content);
1603 }
1604
1605 #[test]
1608 fn loose_tab_continuation_over_indented() {
1609 let content = "- Item\n\n\tcontinuation\n";
1614 let warnings = check(content);
1615 assert_eq!(warnings.len(), 1);
1616 assert_eq!(warnings[0].line, 3);
1617 assert_eq!(fix(content), "- Item\n\n continuation\n");
1618 }
1619
1620 #[test]
1623 fn multiple_continuations_correct() {
1624 let content = "- Item\n\n para 1\n\n para 2\n\n para 3\n";
1625 assert!(check(content).is_empty());
1626 }
1627
1628 #[test]
1629 fn multiple_continuations_second_under_indent() {
1630 let content = "- Item\n\n para 1\n\n continuation 2\n";
1632 let warnings = check(content);
1633 assert_eq!(warnings.len(), 1);
1634 assert_eq!(warnings[0].line, 5);
1635 }
1636
1637 #[test]
1640 fn ordered_paren_marker_correct() {
1641 let content = "1) Item\n\n continuation\n";
1643 assert!(check(content).is_empty());
1644 }
1645
1646 #[test]
1647 fn ordered_paren_marker_under_indent() {
1648 let content = "1) Item\n\n continuation\n";
1649 let warnings = check(content);
1650 assert_eq!(warnings.len(), 1);
1651 assert!(warnings[0].message.contains("3 spaces"));
1652 }
1653
1654 #[test]
1657 fn star_marker_correct() {
1658 let content = "* Item\n\n continuation\n";
1659 assert!(check(content).is_empty());
1660 }
1661
1662 #[test]
1663 fn star_marker_under_indent() {
1664 let content = "* Item\n\n continuation\n";
1665 let warnings = check(content);
1666 assert_eq!(warnings.len(), 1);
1667 }
1668
1669 #[test]
1670 fn plus_marker_correct() {
1671 let content = "+ Item\n\n continuation\n";
1672 assert!(check(content).is_empty());
1673 }
1674
1675 #[test]
1678 fn heading_after_list_no_warning() {
1679 let content = "- Item\n\n# Heading\n";
1680 assert!(check(content).is_empty());
1681 }
1682
1683 #[test]
1686 fn hr_after_list_no_warning() {
1687 let content = "- Item\n\n---\n";
1688 assert!(check(content).is_empty());
1689 }
1690
1691 #[test]
1694 fn reference_link_def_not_flagged() {
1695 let content = "- Item\n\n [link]: https://example.com\n";
1696 assert!(check(content).is_empty());
1697 }
1698
1699 #[test]
1702 fn footnote_def_not_flagged() {
1703 let content = "- Item\n\n [^1]: footnote text\n";
1704 assert!(check(content).is_empty());
1705 }
1706
1707 #[test]
1708 fn footnote_multiline_body_after_list_not_flagged() {
1709 let content = "# A list followed by a footnote\n\n\
1713 Here is a paragraph.[^fn]\n\n\
1714 - This is a list.\n\n\
1715 [^fn]:\n\
1716 \x20\x20\x20\x20Here is a footnote that spans multiple lines.\n\
1717 \x20\x20\x20\x20It should thus be indented by at least four spaces.\n";
1718 assert!(check(content).is_empty());
1719 }
1720
1721 #[test]
1722 fn fix_footnote_multiline_body_after_list_is_noop() {
1723 let content = "# A list followed by a footnote\n\n\
1727 Here is a paragraph.[^fn]\n\n\
1728 - This is a list.\n\n\
1729 [^fn]:\n\
1730 \x20\x20\x20\x20Here is a footnote that spans multiple lines.\n\
1731 \x20\x20\x20\x20It should thus be indented by at least four spaces.\n";
1732 assert_eq!(fix(content), content);
1733 }
1734
1735 #[test]
1736 fn footnote_body_indented_past_list_content_col_not_flagged() {
1737 let content = "- Item\n\n[^fn]:\n Body line one.\n Body line two.\n";
1741 assert!(check(content).is_empty());
1742 }
1743
1744 #[test]
1745 fn list_inside_footnote_body_continuation_not_flagged() {
1746 let content = "Text.[^fn]\n\n[^fn]:\n\
1750 \x20\x20\x20\x20- nested item\n\
1751 \x20\x20\x20\x20\x20\x20\x20over-indented continuation\n";
1752 assert!(check(content).is_empty());
1753 }
1754
1755 #[test]
1756 fn footnote_multiline_body_after_list_not_flagged_mkdocs() {
1757 let content = "Here is a paragraph.[^fn]\n\n\
1761 - This is a list.\n\n\
1762 [^fn]:\n\
1763 \x20\x20\x20\x20\x20\x20Footnote body that spans\n\
1764 \x20\x20\x20\x20\x20\x20multiple indented lines.\n";
1765 assert!(check_mkdocs(content).is_empty());
1766 }
1767
1768 #[test]
1771 fn fix_deep_nesting() {
1772 let content = "- L1\n - L2\n - L3\n\n under-indented\n";
1773 let fixed = fix(content);
1774 assert_eq!(fixed, "- L1\n - L2\n - L3\n\n under-indented\n");
1775 }
1776
1777 #[test]
1778 fn fix_mkdocs_unordered() {
1779 let content = "- Item\n\n continuation\n";
1781 let fixed = fix_mkdocs(content);
1782 assert_eq!(fixed, "- Item\n\n continuation\n");
1783 }
1784
1785 #[test]
1786 fn fix_code_fence_indent() {
1787 let content = "- Item\n\n ```\n code\n ```\n";
1790 let fixed = fix(content);
1791 assert_eq!(fixed, "- Item\n\n ```\n code\n ```\n");
1792 }
1793
1794 #[test]
1795 fn fix_mkdocs_code_fence_indent() {
1796 let content = "1. Item\n\n ```toml\n key = \"val\"\n ```\n";
1798 let fixed = fix_mkdocs(content);
1799 assert_eq!(fixed, "1. Item\n\n ```toml\n key = \"val\"\n ```\n");
1800 }
1801
1802 #[test]
1805 fn empty_document_no_warning() {
1806 assert!(check("").is_empty());
1807 }
1808
1809 #[test]
1810 fn whitespace_only_no_warning() {
1811 assert!(check(" \n\n \n").is_empty());
1812 }
1813
1814 #[test]
1817 fn no_list_no_warning() {
1818 let content = "# Heading\n\nSome paragraph.\n\nAnother paragraph.\n";
1819 assert!(check(content).is_empty());
1820 }
1821
1822 #[test]
1825 fn multiline_continuation_all_lines_flagged() {
1826 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";
1827 let warnings = check(content);
1828 assert_eq!(warnings.len(), 3);
1829 assert_eq!(warnings[0].line, 3);
1830 assert_eq!(warnings[1].line, 4);
1831 assert_eq!(warnings[2].line, 5);
1832 }
1833
1834 #[test]
1835 fn multiline_continuation_with_frontmatter_fix() {
1836 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";
1837 let fixed = fix(content);
1838 assert_eq!(
1839 fixed,
1840 "---\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"
1841 );
1842 }
1843
1844 #[test]
1845 fn multiline_continuation_correct_indent_no_warning() {
1846 let content = "1. Item\n\n line one\n line two\n line three\n";
1847 assert!(check(content).is_empty());
1848 }
1849
1850 #[test]
1851 fn multiline_continuation_mixed_indent() {
1852 let content = "1. Item\n\n correct\n wrong\n correct\n";
1853 let warnings = check(content);
1854 assert_eq!(warnings.len(), 1);
1855 assert_eq!(warnings[0].line, 4);
1856 }
1857
1858 #[test]
1859 fn multiline_continuation_unordered() {
1860 let content = "- Item\n\n continuation 1\n continuation 2\n continuation 3\n";
1861 let warnings = check(content);
1862 assert_eq!(warnings.len(), 3);
1863 let fixed = fix(content);
1864 assert_eq!(
1865 fixed,
1866 "- Item\n\n continuation 1\n continuation 2\n continuation 3\n"
1867 );
1868 }
1869
1870 #[test]
1871 fn multiline_continuation_two_items_fix() {
1872 let content = "1. First\n\n cont a\n cont b\n\n2. Second\n\n cont c\n cont d\n";
1873 let fixed = fix(content);
1874 assert_eq!(
1875 fixed,
1876 "1. First\n\n cont a\n cont b\n\n2. Second\n\n cont c\n cont d\n"
1877 );
1878 }
1879
1880 #[test]
1881 fn fence_fix_does_not_break_pairing_for_md031() {
1882 let content = "#### title\n\nabc\n\n\
1889 1. ab\n\n\
1890 \x20\x20`aabbccdd`\n\n\
1891 2. cd\n\n\
1892 \x20\x20`bbcc dd ee`\n\n\
1893 \x20\x20```\n\
1894 \x20\x20abcd\n\
1895 \x20\x20ef gh\n\
1896 \x20\x20```\n\n\
1897 \x20\x20uu\n\n\
1898 \x20\x20```\n\
1899 \x20\x20cdef\n\
1900 \x20\x20gh ij\n\
1901 \x20\x20```\n";
1902 let expected = "#### title\n\nabc\n\n\
1903 1. ab\n\n\
1904 \x20\x20\x20`aabbccdd`\n\n\
1905 2. cd\n\n\
1906 \x20\x20\x20`bbcc dd ee`\n\n\
1907 \x20\x20\x20```\n\
1908 \x20\x20\x20abcd\n\
1909 \x20\x20\x20ef gh\n\
1910 \x20\x20\x20```\n\n\
1911 \x20\x20\x20uu\n\n\
1912 \x20\x20\x20```\n\
1913 \x20\x20\x20cdef\n\
1914 \x20\x20\x20gh ij\n\
1915 \x20\x20\x20```\n";
1916 assert_eq!(fix(content), expected);
1917 }
1918
1919 #[test]
1920 fn multiline_continuation_separated_by_blank() {
1921 let content = "1. Item\n\n para1 line1\n para1 line2\n\n para2 line1\n para2 line2\n";
1922 let warnings = check(content);
1923 assert_eq!(warnings.len(), 4);
1924 let fixed = fix(content);
1925 assert_eq!(
1926 fixed,
1927 "1. Item\n\n para1 line1\n para1 line2\n\n para2 line1\n para2 line2\n"
1928 );
1929 }
1930
1931 #[test]
1932 fn tab_indented_fence_is_normalized_to_spaces() {
1933 let content = "100. ab\n\n\t```\n\tabcd\n\t```\n";
1941 let expected = "100. ab\n\n ```\n abcd\n ```\n";
1942 assert_eq!(fix(content), expected);
1943 }
1944
1945 #[test]
1954 fn loose_continuation_over_indented_flagged() {
1955 let content = "* Item\n\n over-indented\n";
1958 let warnings = check(content);
1959 assert_eq!(warnings.len(), 1);
1960 assert_eq!(warnings[0].line, 3);
1961 assert!(warnings[0].message.contains("over-indented"));
1962 assert!(warnings[0].message.contains("expected 2"));
1963 assert!(warnings[0].message.contains("found 3"));
1964 }
1965
1966 #[test]
1967 fn loose_continuation_over_indented_multiline_mixed() {
1968 let content = "* Item\n\n over one\n correct\n over two\n";
1970 let warnings = check(content);
1971 assert_eq!(warnings.len(), 2);
1972 assert_eq!(warnings[0].line, 3);
1973 assert_eq!(warnings[1].line, 5);
1974 }
1975
1976 #[test]
1977 fn fix_loose_continuation_over_indented() {
1978 let content = "* Item\n\n over one\n correct\n over two\n";
1979 let fixed = fix(content);
1980 assert_eq!(fixed, "* Item\n\n over one\n correct\n over two\n");
1981 }
1982
1983 #[test]
1984 fn fix_tight_and_loose_items_normalized_identically() {
1985 let content = "---\ntitle: Heading\n---\n\nSome introductory text:\n\n\
1988 * 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\
1989 * 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";
1990 let expected = "---\ntitle: Heading\n---\n\nSome introductory text:\n\n\
1991 * 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\
1992 * 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";
1993 assert_eq!(fix(content), expected);
1994 }
1995
1996 #[test]
1997 fn multi_paragraph_item_loose_paragraph_over_indented() {
1998 let content = "* Item.\n tight over\n\n loose over\n";
2001 let warnings = check(content);
2002 assert_eq!(warnings.len(), 2);
2003 assert_eq!(warnings[0].line, 2);
2004 assert_eq!(warnings[1].line, 4);
2005 }
2006
2007 #[test]
2008 fn loose_indented_code_block_not_flagged() {
2009 let content = "- Item\n\n code line\n";
2013 assert!(check(content).is_empty());
2014 }
2015
2016 #[test]
2017 fn mkdocs_loose_over_indented_flagged() {
2018 let content = "1. Item\n\n over\n";
2021 let warnings = check_mkdocs(content);
2022 assert_eq!(warnings.len(), 1);
2023 assert_eq!(warnings[0].line, 3);
2024 assert!(warnings[0].message.contains("over-indented"));
2025 assert!(warnings[0].message.contains("expected 4"));
2026 assert!(warnings[0].message.contains("found 5"));
2027 }
2028
2029 #[test]
2030 fn task_list_loose_over_indented_flagged() {
2031 let content = "- [ ] Task\n\n over\n";
2034 let warnings = check(content);
2035 assert_eq!(warnings.len(), 1);
2036 assert_eq!(warnings[0].line, 3);
2037 }
2038
2039 #[test]
2040 fn loose_over_indent_boundary_below_code_block_threshold_flagged() {
2041 let content = "- Item\n\n over\n";
2046 let warnings = check(content);
2047 assert_eq!(warnings.len(), 1);
2048 assert_eq!(warnings[0].line, 3);
2049 assert!(warnings[0].message.contains("expected 2"));
2050 assert!(warnings[0].message.contains("found 5"));
2051 }
2052
2053 #[test]
2054 fn loose_over_indent_does_not_steal_nested_under_indent() {
2055 let content = "- Outer\n - Inner\n\n continuation\n";
2062 let warnings = check(content);
2063 assert_eq!(warnings.len(), 1);
2064 assert_eq!(warnings[0].line, 4);
2065 assert!(warnings[0].message.contains("4 spaces"));
2066 assert!(warnings[0].message.contains("found 3"));
2067 assert_eq!(fix(content), "- Outer\n - Inner\n\n continuation\n");
2068 }
2069
2070 #[test]
2071 fn loose_over_indent_attributes_to_deepest_enclosing_item() {
2072 let content = "- Outer\n - Inner\n\n continuation\n";
2076 let warnings = check(content);
2077 assert_eq!(warnings.len(), 1);
2078 assert_eq!(warnings[0].line, 4);
2079 assert!(warnings[0].message.contains("expected 4"));
2080 assert!(warnings[0].message.contains("found 5"));
2081 assert_eq!(fix(content), "- Outer\n - Inner\n\n continuation\n");
2082 }
2083
2084 #[test]
2093 fn loose_over_indented_fence_not_flagged() {
2094 let content = "- Item\n\n ```\n code\n ```\n";
2095 assert!(check(content).is_empty());
2096 assert_eq!(fix(content), content);
2097 }
2098
2099 #[test]
2100 fn tight_over_indented_fence_not_flagged() {
2101 let content = "- Item\n ```\n code\n ```\n";
2102 assert!(check(content).is_empty());
2103 assert_eq!(fix(content), content);
2104 }
2105
2106 #[test]
2107 fn over_indented_tilde_fence_not_flagged() {
2108 let content = "- Item\n\n ~~~\n code\n ~~~\n";
2109 assert!(check(content).is_empty());
2110 assert_eq!(fix(content), content);
2111 }
2112
2113 #[test]
2114 fn fence_like_code_content_inside_fenced_block_not_flagged() {
2115 let content = "- Item\n\n ~~~\n ```\n ~~~\n";
2118 assert!(check(content).is_empty());
2119 assert_eq!(fix(content), content);
2120 }
2121
2122 #[test]
2123 fn unterminated_over_indented_fence_not_flagged() {
2124 let content = "- Item\n\n ```\n code1\n code2deeper\n";
2127 assert!(check(content).is_empty());
2128 assert_eq!(fix(content), content);
2129 }
2130
2131 #[test]
2139 fn task_list_tight_continuation_post_checkbox_reproducer_579() {
2140 let content = "- [ ] Lorem ipsum dolor sit amet, consectetur adipiscing\n tempor incididunt ut labore.\n";
2143 assert!(check(content).is_empty());
2144 }
2145
2146 #[test]
2147 fn task_list_tight_continuation_dash_unchecked() {
2148 let content = "- [ ] Task\n continuation\n";
2149 assert!(check(content).is_empty());
2150 }
2151
2152 #[test]
2153 fn task_list_tight_continuation_dash_checked_lower() {
2154 let content = "- [x] Task\n continuation\n";
2155 assert!(check(content).is_empty());
2156 }
2157
2158 #[test]
2159 fn task_list_tight_continuation_dash_checked_upper() {
2160 let content = "- [X] Task\n continuation\n";
2161 assert!(check(content).is_empty());
2162 }
2163
2164 #[test]
2165 fn task_list_tight_continuation_star_marker() {
2166 let content = "* [ ] Task\n continuation\n";
2167 assert!(check(content).is_empty());
2168 }
2169
2170 #[test]
2171 fn task_list_tight_continuation_plus_marker() {
2172 let content = "+ [ ] Task\n continuation\n";
2173 assert!(check(content).is_empty());
2174 }
2175
2176 #[test]
2177 fn task_list_tight_continuation_content_column_still_valid() {
2178 let content = "- [ ] Task\n continuation\n";
2181 assert!(check(content).is_empty());
2182 }
2183
2184 #[test]
2185 fn task_list_tight_continuation_between_columns_still_flagged() {
2186 let content = "- [ ] Task\n continuation\n";
2189 let warnings = check(content);
2190 assert_eq!(warnings.len(), 1);
2191 assert!(warnings[0].message.contains("expected 2 or 6"));
2193 assert!(warnings[0].message.contains("found 4"));
2194 }
2195
2196 #[test]
2197 fn task_list_tight_continuation_overshoot_still_flagged() {
2198 let content = "- [ ] Task\n continuation\n";
2200 let warnings = check(content);
2201 assert_eq!(warnings.len(), 1);
2202 assert!(warnings[0].message.contains("expected 2 or 6"));
2203 assert!(warnings[0].message.contains("found 7"));
2204 }
2205
2206 #[test]
2209 fn fix_task_list_overshoot_snaps_to_task_col() {
2210 let content = "- [ ] Task\n continuation\n";
2214 let fixed = fix(content);
2215 assert_eq!(fixed, "- [ ] Task\n continuation\n");
2216 }
2217
2218 #[test]
2219 fn fix_task_list_col_5_snaps_to_task_col() {
2220 let content = "- [ ] Task\n continuation\n";
2222 let fixed = fix(content);
2223 assert_eq!(fixed, "- [ ] Task\n continuation\n");
2224 }
2225
2226 #[test]
2227 fn fix_task_list_col_3_snaps_to_content_col() {
2228 let content = "- [ ] Task\n continuation\n";
2230 let fixed = fix(content);
2231 assert_eq!(fixed, "- [ ] Task\n continuation\n");
2232 }
2233
2234 #[test]
2235 fn fix_task_list_col_4_ties_to_content_col() {
2236 let content = "- [ ] Task\n continuation\n";
2241 let fixed = fix(content);
2242 assert_eq!(fixed, "- [ ] Task\n continuation\n");
2243 }
2244
2245 #[test]
2246 fn fix_task_list_ordered_overshoot_snaps_to_task_col() {
2247 let content = "1. [ ] Task\n continuation\n";
2250 let fixed = fix(content);
2251 assert_eq!(fixed, "1. [ ] Task\n continuation\n");
2252 }
2253
2254 #[test]
2255 fn fix_task_list_ordered_under_overshoot_snaps_to_content_col() {
2256 let content = "1. [ ] Task\n continuation\n";
2259 let fixed = fix(content);
2260 assert_eq!(fixed, "1. [ ] Task\n continuation\n");
2261 }
2262
2263 #[test]
2264 fn task_list_tight_continuation_ordered_single_digit() {
2265 let content = "1. [ ] Task\n continuation\n";
2267 assert!(check(content).is_empty());
2268 }
2269
2270 #[test]
2271 fn task_list_tight_continuation_ordered_multi_digit() {
2272 let content = "10. [ ] Task\n continuation\n";
2274 assert!(check(content).is_empty());
2275 }
2276
2277 #[test]
2278 fn task_list_tight_continuation_nested_dash() {
2279 let content = "- Parent\n - [ ] Nested task\n continuation\n";
2281 assert!(check(content).is_empty());
2282 }
2283
2284 #[test]
2285 fn task_list_loose_continuation_post_checkbox_column_not_flagged() {
2286 let content = "- [ ] Task\n\n continuation\n";
2291 assert!(check(content).is_empty());
2292 }
2293
2294 #[test]
2295 fn task_list_empty_body_is_not_a_task() {
2296 let content = "- [ ]\n continuation\n";
2302 let warnings = check(content);
2303 assert_eq!(warnings.len(), 1);
2304 assert!(warnings[0].message.contains("found 4"));
2305 }
2306
2307 #[test]
2308 fn task_list_malformed_checkbox_is_not_a_task() {
2309 let content = "- [~] Not a task\n continuation\n";
2311 let warnings = check(content);
2312 assert_eq!(warnings.len(), 1);
2313 }
2314
2315 #[test]
2322 fn task_list_mkdocs_unordered_required_min_valid() {
2323 let content = "- [ ] Task\n continuation\n";
2325 assert!(check_mkdocs(content).is_empty());
2326 }
2327
2328 #[test]
2329 fn task_list_mkdocs_unordered_post_checkbox_valid() {
2330 let content = "- [ ] Task\n continuation\n";
2331 assert!(check_mkdocs(content).is_empty());
2332 }
2333
2334 #[test]
2335 fn task_list_mkdocs_unordered_between_flagged() {
2336 let content = "- [ ] Task\n continuation\n";
2338 let warnings = check_mkdocs(content);
2339 assert_eq!(warnings.len(), 1);
2340 }
2341
2342 #[test]
2343 fn task_list_mkdocs_ordered_both_columns_valid() {
2344 let at_4 = "1. [ ] Task\n continuation\n";
2346 assert!(check_mkdocs(at_4).is_empty());
2347 let at_7 = "1. [ ] Task\n continuation\n";
2348 assert!(check_mkdocs(at_7).is_empty());
2349 }
2350
2351 #[test]
2352 fn task_list_mkdocs_ordered_between_flagged() {
2353 let at_5 = "1. [ ] Task\n continuation\n";
2355 assert_eq!(check_mkdocs(at_5).len(), 1);
2356 let at_6 = "1. [ ] Task\n continuation\n";
2357 assert_eq!(check_mkdocs(at_6).len(), 1);
2358 }
2359
2360 #[test]
2370 fn fix_task_list_tie_sibling_at_task_col_snaps_to_task_col() {
2371 let content = "- [ ] Task\n aligned continuation\n tied continuation\n";
2375 let fixed = fix(content);
2376 assert_eq!(
2377 fixed,
2378 "- [ ] Task\n aligned continuation\n tied continuation\n"
2379 );
2380 }
2381
2382 #[test]
2383 fn fix_task_list_tie_sibling_at_content_col_snaps_to_content_col() {
2384 let content = "- [ ] Task\n aligned continuation\n tied continuation\n";
2387 let fixed = fix(content);
2388 assert_eq!(fixed, "- [ ] Task\n aligned continuation\n tied continuation\n");
2389 }
2390
2391 #[test]
2392 fn fix_task_list_tie_both_siblings_snaps_to_content_col() {
2393 let content = "- [ ] Task\n at content col\n at task col\n tied continuation\n";
2397 let fixed = fix(content);
2398 assert_eq!(
2399 fixed,
2400 "- [ ] Task\n at content col\n at task col\n tied continuation\n"
2401 );
2402 }
2403
2404 #[test]
2405 fn fix_task_list_tie_sees_task_col_through_tight_lazy_continuation() {
2406 let content = concat!("- [ ] Task\n", "lazy\n", " aligned at task col\n", " tied\n",);
2420 let fixed = fix(content);
2421 assert!(
2422 fixed.contains("\n tied\n"),
2423 "tied line should snap to col 6 (task col) because a task-col \
2424 sibling is visible past the tight lazy-continuation line; got:\n{fixed}"
2425 );
2426 }
2427
2428 #[test]
2435 fn task_list_tab_indented_continuation_flagged() {
2436 let content = "- [ ] Task\n\t\twrap\n";
2439 let warnings = check(content);
2440 assert_eq!(warnings.len(), 1);
2441 assert!(warnings[0].message.contains("expected 2 or 6"));
2442 assert!(warnings[0].message.contains("found 8"));
2443 }
2444
2445 #[test]
2446 fn fix_task_list_tab_indented_snaps_to_task_col() {
2447 let content = "- [ ] Task\n\t\twrap\n";
2449 let fixed = fix(content);
2450 assert_eq!(fixed, "- [ ] Task\n wrap\n");
2451 }
2452
2453 #[test]
2454 fn fix_task_list_single_tab_equidistant_snaps_to_content_col() {
2455 let content = "- [ ] Task\n\twrap\n";
2458 let fixed = fix(content);
2459 assert_eq!(fixed, "- [ ] Task\n wrap\n");
2460 }
2461
2462 #[test]
2472 fn task_list_blockquote_post_checkbox_not_flagged() {
2473 let content = "> - [ ] Task\n> continuation\n";
2475 assert!(check(content).is_empty());
2476 }
2477
2478 #[test]
2479 fn task_list_blockquote_between_cols_documented_limitation() {
2480 let content = "> - [ ] Task\n> continuation\n";
2484 assert!(check(content).is_empty());
2485 }
2486
2487 #[test]
2488 fn task_list_blockquote_overshoot_documented_limitation() {
2489 let content = "> - [ ] Task\n> continuation\n";
2491 assert!(check(content).is_empty());
2492 }
2493
2494 #[test]
2501 fn fix_task_list_mkdocs_unordered_overshoot_snaps_to_task_col() {
2502 let content = "- [ ] Task\n continuation\n";
2505 let fixed = fix_mkdocs(content);
2506 assert_eq!(fixed, "- [ ] Task\n continuation\n");
2507 }
2508
2509 #[test]
2510 fn fix_task_list_mkdocs_unordered_tie_snaps_to_required() {
2511 let content = "- [ ] Task\n continuation\n";
2514 let fixed = fix_mkdocs(content);
2515 assert_eq!(fixed, "- [ ] Task\n continuation\n");
2516 }
2517
2518 #[test]
2519 fn fix_task_list_mkdocs_ordered_overshoot_snaps_to_task_col() {
2520 let content = "1. [ ] Task\n continuation\n";
2523 let fixed = fix_mkdocs(content);
2524 assert_eq!(fixed, "1. [ ] Task\n continuation\n");
2525 }
2526
2527 #[test]
2528 fn fix_task_list_mkdocs_ordered_near_required_snaps_to_required() {
2529 let content = "1. [ ] Task\n continuation\n";
2535 let fixed = fix_mkdocs(content);
2536 assert_eq!(fixed, "1. [ ] Task\n continuation\n");
2537 }
2538
2539 #[test]
2540 fn fix_task_list_mkdocs_ordered_between_cols_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 fn assert_idempotent(content: &str) {
2558 let once = fix(content);
2559 let twice = fix(&once);
2560 assert_eq!(once, twice, "MD077 fix was not idempotent on input: {content:?}");
2561 }
2562
2563 fn assert_idempotent_mkdocs(content: &str) {
2564 let once = fix_mkdocs(content);
2565 let twice = fix_mkdocs(&once);
2566 assert_eq!(
2567 once, twice,
2568 "MD077 (MkDocs) fix was not idempotent on input: {content:?}"
2569 );
2570 }
2571
2572 #[test]
2573 fn idempotent_task_list_between_cols() {
2574 assert_idempotent("- [ ] Task\n continuation\n");
2575 }
2576
2577 #[test]
2578 fn idempotent_task_list_overshoot() {
2579 assert_idempotent("- [ ] Task\n continuation\n");
2580 }
2581
2582 #[test]
2583 fn idempotent_task_list_under_post_checkbox() {
2584 assert_idempotent("- [ ] Task\n continuation\n");
2585 }
2586
2587 #[test]
2588 fn idempotent_task_list_near_post_checkbox() {
2589 assert_idempotent("- [ ] Task\n continuation\n");
2590 }
2591
2592 #[test]
2593 fn idempotent_task_list_tab_overshoot() {
2594 assert_idempotent("- [ ] Task\n\t\twrap\n");
2595 }
2596
2597 #[test]
2598 fn idempotent_task_list_single_tab() {
2599 assert_idempotent("- [ ] Task\n\twrap\n");
2600 }
2601
2602 #[test]
2603 fn idempotent_task_list_ordered_overshoot() {
2604 assert_idempotent("1. [ ] Task\n continuation\n");
2605 }
2606
2607 #[test]
2608 fn idempotent_task_list_ordered_under() {
2609 assert_idempotent("1. [ ] Task\n continuation\n");
2610 }
2611
2612 #[test]
2613 fn idempotent_task_list_tie_with_sibling_at_task_col() {
2614 assert_idempotent("- [ ] Task\n aligned\n tied\n");
2615 }
2616
2617 #[test]
2618 fn idempotent_task_list_tie_with_sibling_at_content_col() {
2619 assert_idempotent("- [ ] Task\n aligned\n tied\n");
2620 }
2621
2622 #[test]
2623 fn idempotent_task_list_mkdocs_unordered_overshoot() {
2624 assert_idempotent_mkdocs("- [ ] Task\n continuation\n");
2625 }
2626
2627 #[test]
2628 fn idempotent_task_list_mkdocs_unordered_tie() {
2629 assert_idempotent_mkdocs("- [ ] Task\n continuation\n");
2630 }
2631
2632 #[test]
2633 fn idempotent_task_list_mkdocs_ordered_overshoot() {
2634 assert_idempotent_mkdocs("1. [ ] Task\n continuation\n");
2635 }
2636
2637 #[test]
2638 fn idempotent_task_list_mkdocs_ordered_between() {
2639 assert_idempotent_mkdocs("1. [ ] Task\n continuation\n");
2640 }
2641
2642 #[test]
2643 fn idempotent_task_list_reproducer_579() {
2644 assert_idempotent(
2648 "- [ ] Lorem ipsum dolor sit amet, consectetur adipiscing\n tempor incididunt ut labore.\n",
2649 );
2650 }
2651
2652 #[test]
2653 fn idempotent_non_task_list_still_holds() {
2654 assert_idempotent("1. Item\n over-indented\n");
2657 assert_idempotent("- Item\n\n continuation\n");
2658 }
2659
2660 #[test]
2667 fn idempotent_non_task_loose_under_indent_ordered() {
2668 assert_idempotent("1. Item\n\n continuation\n");
2670 }
2671
2672 #[test]
2673 fn idempotent_non_task_loose_under_indent_multi_digit() {
2674 assert_idempotent("10. Item\n\n continuation\n");
2676 }
2677
2678 #[test]
2679 fn idempotent_non_task_tight_over_indent_ordered() {
2680 assert_idempotent("1. Item\n over-indented\n");
2682 }
2683
2684 #[test]
2692 fn idempotent_non_task_fence_ordered_loose() {
2693 assert_idempotent("1. Item\n\n ```rust\n let x = 1;\n ```\n");
2695 }
2696
2697 #[test]
2698 fn idempotent_non_task_fence_tilde_under_indent() {
2699 assert_idempotent("1. Item\n\n ~~~\nplain text\n ~~~\n");
2705 }
2706
2707 #[test]
2708 fn idempotent_non_task_fence_interior_above_required() {
2709 assert_idempotent("1. Item\n\n ```\n deeply indented code\n ```\n");
2713 }
2714
2715 #[test]
2716 fn fence_fix_promotes_interior_below_scope_in_single_pass() {
2717 let content = "1. Item\n\n ```\ncode\n ```\n";
2721 let fixed = fix(content);
2722 assert_eq!(fixed, "1. Item\n\n ```\n code\n ```\n");
2723 }
2724
2725 #[test]
2726 fn fence_fix_preserves_interior_above_required() {
2727 let content = "1. Item\n\n ```\n code\n ```\n";
2730 let fixed = fix(content);
2731 assert_eq!(fixed, "1. Item\n\n ```\n code\n ```\n");
2732 }
2733
2734 #[test]
2741 fn idempotent_non_task_mkdocs_ordered_at_3_spaces() {
2742 assert_idempotent_mkdocs("1. Item\n\n continuation\n");
2744 }
2745
2746 #[test]
2747 fn idempotent_non_task_mkdocs_unordered_at_2_spaces() {
2748 assert_idempotent_mkdocs("- Item\n\n continuation\n");
2750 }
2751
2752 #[test]
2753 fn idempotent_non_task_mkdocs_fence_compound() {
2754 assert_idempotent_mkdocs("1. Item\n\n ```toml\n k = 1\n ```\n");
2756 }
2757
2758 #[test]
2761 fn aligned_tight_zero_indent_continuation_flagged() {
2762 let content = "- this is a long line\nthat continues on a second line\n";
2766 let warnings = check_aligned(content);
2767 assert_eq!(warnings.len(), 1);
2768 assert_eq!(warnings[0].line, 2);
2769 assert_eq!(
2770 fix_aligned(content),
2771 "- this is a long line\n that continues on a second line\n"
2772 );
2773 }
2774
2775 #[test]
2776 fn aligned_full_issue_example_made_consistent() {
2777 let content = "- this is a long line\n\
2780 that continues on a second line\n\
2781 - this is another long line\n\
2782 \x20\x20that continues on the next line\n\
2783 - yet again a long line\n\
2784 and still inconsistently spaced\n\
2785 \x20\x20and even worse\n";
2786 let expected = "- this is a long line\n\
2787 \x20\x20that continues on a second line\n\
2788 - this is another long line\n\
2789 \x20\x20that continues on the next line\n\
2790 - yet again a long line\n\
2791 \x20\x20and still inconsistently spaced\n\
2792 \x20\x20and even worse\n";
2793 assert_eq!(fix_aligned(content), expected);
2794 assert_eq!(fix_aligned(expected), expected);
2796 }
2797
2798 #[test]
2799 fn aligned_already_aligned_not_flagged() {
2800 let content = "- item\n continuation at content column\n";
2801 assert!(check_aligned(content).is_empty());
2802 }
2803
2804 #[test]
2805 fn aligned_tight_partial_indent_flagged() {
2806 let content = "- item\n continuation\n";
2808 let warnings = check_aligned(content);
2809 assert_eq!(warnings.len(), 1);
2810 assert_eq!(fix_aligned(content), "- item\n continuation\n");
2811 }
2812
2813 #[test]
2814 fn aligned_post_blank_zero_indent_still_new_paragraph() {
2815 let content = "- item\n\nnew paragraph\n";
2818 assert!(check_aligned(content).is_empty());
2819 assert_eq!(fix_aligned(content), content);
2820 }
2821
2822 #[test]
2825 fn aligned_top_level_blockquote_after_list_untouched() {
2826 let content = "- item\n> quote\n";
2830 assert!(check_aligned(content).is_empty());
2831 assert_eq!(fix_aligned(content), content);
2832 }
2833
2834 #[test]
2835 fn aligned_top_level_fence_after_list_untouched() {
2836 let content = "- item\n```\ncode\n```\n";
2837 assert!(check_aligned(content).is_empty());
2838 assert_eq!(fix_aligned(content), content);
2839 }
2840
2841 #[test]
2842 fn aligned_top_level_table_after_list_untouched() {
2843 let content = "- item\n| a | b |\n|---|---|\n| 1 | 2 |\n";
2844 assert!(check_aligned(content).is_empty());
2845 assert_eq!(fix_aligned(content), content);
2846 }
2847
2848 #[test]
2851 fn aligned_nested_tight_lazy_continuation_aligns_to_inner() {
2852 let content = "- Outer\n - Inner\ncontinuation\n";
2857 let warnings = check_aligned(content);
2858 assert_eq!(warnings.len(), 1);
2859 assert_eq!(fix_aligned(content), "- Outer\n - Inner\n continuation\n");
2860 }
2861
2862 #[test]
2863 fn aligned_nested_continuation_already_aligned_not_flagged() {
2864 let content = "- L1\n - L2\n cont of L2 at 4\n";
2865 assert!(check_aligned(content).is_empty());
2866 }
2867
2868 #[test]
2869 fn aligned_nested_idempotent() {
2870 let content = "- Outer\n - Inner\ncontinuation\n";
2871 let once = fix_aligned(content);
2872 assert_eq!(fix_aligned(&once), once);
2873 }
2874
2875 #[test]
2876 fn aligned_three_level_nesting_aligns_to_innermost() {
2877 let content = "- L1\n - L2\n - L3\ncont\n";
2880 assert_eq!(fix_aligned(content), "- L1\n - L2\n - L3\n cont\n");
2881 }
2882
2883 #[test]
2884 fn aligned_continuation_after_sibling_owned_by_last_item() {
2885 let content = "- a\n- b\nlazy\n";
2888 assert_eq!(fix_aligned(content), "- a\n- b\n lazy\n");
2889 }
2890
2891 #[test]
2892 fn aligned_multi_digit_ordered_marker_aligns_to_content_column() {
2893 let content = "10. Item\nwrap\n";
2894 assert_eq!(fix_aligned(content), "10. Item\n wrap\n");
2895 }
2896
2897 #[test]
2898 fn aligned_latent_setext_underline_is_left_alone() {
2899 let content = "- item\nText\n===\n";
2904 assert!(check_aligned(content).is_empty());
2905 assert_eq!(fix_aligned(content), content);
2906 }
2907
2908 #[test]
2909 fn aligned_reindents_prose_that_only_looks_like_an_underline() {
2910 let content = "- item\nText\n= = =\n";
2913 assert_eq!(fix_aligned(content), "- item\n Text\n = = =\n");
2914 }
2915
2916 #[test]
2917 fn aligned_latent_marker_in_continuation_is_idempotent() {
2918 let content = "# \n- \n``\n2. \n![]()";
2924 let once = fix_aligned(content);
2925 assert_eq!(fix_aligned(&once), once, "fix must be idempotent in one pass");
2926 assert_eq!(once, content, "item with a latent marker is left untouched");
2927 }
2928
2929 #[test]
2930 fn aligned_latent_table_in_continuation_is_idempotent() {
2931 let content = "- \n![`]()\n| | ` |\n| --- | --- |";
2936 let once = fix_aligned(content);
2937 assert_eq!(fix_aligned(&once), once, "fix must be idempotent in one pass");
2938 assert_eq!(once, content, "item with a latent table is left untouched");
2939 }
2940
2941 #[test]
2942 fn aligned_blockquote_nested_list_not_touched() {
2943 let content = "> - item\n> wrap\n";
2947 assert!(check_aligned(content).is_empty());
2948 assert_eq!(fix_aligned(content), content);
2949 }
2950
2951 #[test]
2954 fn aligned_task_post_checkbox_column_accepted() {
2955 let content = "- [ ] Task\n wrap\n";
2958 assert!(check_aligned(content).is_empty());
2959 assert_eq!(fix_aligned(content), content);
2960 }
2961
2962 #[test]
2963 fn aligned_task_under_indent_snaps_to_content_column() {
2964 let content = "- [ ] Task\nwrap\n";
2965 let warnings = check_aligned(content);
2966 assert_eq!(warnings.len(), 1);
2967 assert_eq!(fix_aligned(content), "- [ ] Task\n wrap\n");
2968 }
2969
2970 #[test]
2973 fn aligned_mkdocs_tight_under_indent_snaps_to_four() {
2974 let content = "- item\nwrap\n";
2976 let warnings = check_aligned_mkdocs(content);
2977 assert_eq!(warnings.len(), 1);
2978 let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
2979 assert_eq!(aligned_rule().fix(&ctx).unwrap(), "- item\n wrap\n");
2980 }
2981
2982 #[test]
2985 fn any_default_does_not_flag_tight_lazy_continuation() {
2986 let content = "- item\nwrapped at zero indent\n";
2988 assert!(check(content).is_empty());
2989 assert_eq!(fix(content), content);
2990 }
2991
2992 #[test]
2993 fn from_config_aligned_enables_tight_flagging() {
2994 let mut config = crate::config::Config::default();
2996 let mut rule_config = crate::config::RuleConfig::default();
2997 rule_config
2998 .values
2999 .insert("style".to_string(), toml::Value::String("aligned".to_string()));
3000 config.rules.insert("MD077".to_string(), rule_config);
3001
3002 let rule = MD077ListContinuationIndent::from_config(&config);
3003 let ctx = LintContext::new("- item\nwrap\n", MarkdownFlavor::Standard, None);
3004 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
3005 }
3006
3007 #[test]
3008 fn from_config_default_is_any() {
3009 let config = crate::config::Config::default();
3011 let rule = MD077ListContinuationIndent::from_config(&config);
3012 let ctx = LintContext::new("- item\nwrap\n", MarkdownFlavor::Standard, None);
3013 assert!(rule.check(&ctx).unwrap().is_empty());
3014 }
3015
3016 #[test]
3017 fn from_config_indent_sets_fixed_requirement() {
3018 let mut config = crate::config::Config::default();
3021 let mut rule_config = crate::config::RuleConfig::default();
3022 rule_config.values.insert("indent".to_string(), toml::Value::Integer(4));
3023 config.rules.insert("MD077".to_string(), rule_config);
3024
3025 let rule = MD077ListContinuationIndent::from_config(&config);
3026
3027 let ok_ctx = LintContext::new("- item\n wrap\n", MarkdownFlavor::Standard, None);
3029 assert!(rule.check(&ok_ctx).unwrap().is_empty());
3030 assert_eq!(rule.fix(&ok_ctx).unwrap(), "- item\n wrap\n");
3031
3032 let bad_ctx = LintContext::new("- item\n\n wrap\n", MarkdownFlavor::Standard, None);
3035 let warnings = rule.check(&bad_ctx).unwrap();
3036 assert_eq!(warnings.len(), 1);
3037 assert!(warnings[0].message.contains("needs 4 spaces"));
3038 }
3039
3040 #[test]
3041 fn from_config_indent_applies_per_nested_marker() {
3042 let mut config = crate::config::Config::default();
3045 let mut rule_config = crate::config::RuleConfig::default();
3046 rule_config.values.insert("indent".to_string(), toml::Value::Integer(4));
3047 config.rules.insert("MD077".to_string(), rule_config);
3048
3049 let rule = MD077ListContinuationIndent::from_config(&config);
3050 let ctx = LintContext::new("- a\n - b\n wrap\n", MarkdownFlavor::Standard, None);
3051 let warnings = rule.check(&ctx).unwrap();
3052 assert!(
3053 warnings.is_empty(),
3054 "continuation at 6 spaces should pass: {warnings:?}"
3055 );
3056 }
3057
3058 fn rule_with(settings: &[(&str, toml::Value)]) -> Box<dyn Rule> {
3060 let mut config = crate::config::Config::default();
3061 let mut rule_config = crate::config::RuleConfig::default();
3062 for (key, value) in settings {
3063 rule_config.values.insert((*key).to_string(), value.clone());
3064 }
3065 config.rules.insert("MD077".to_string(), rule_config);
3066 MD077ListContinuationIndent::from_config(&config)
3067 }
3068
3069 #[test]
3070 fn configured_indent_cannot_lower_the_strict_flavor_minimum() {
3071 let rule = rule_with(&[("indent", toml::Value::Integer(2))]);
3075
3076 let two = LintContext::new("- item\n\n wrap\n", MarkdownFlavor::MkDocs, None);
3077 let warnings = rule.check(&two).unwrap();
3078 assert_eq!(warnings.len(), 1, "2 spaces is below the MkDocs minimum: {warnings:?}");
3079 assert!(
3080 warnings[0].message.contains("needs 4 spaces") && warnings[0].message.contains("MkDocs"),
3081 "the requirement comes from MkDocs, so the message must say so: {}",
3082 warnings[0].message
3083 );
3084 assert_eq!(rule.fix(&two).unwrap(), "- item\n\n wrap\n");
3085
3086 let four = LintContext::new("- item\n\n wrap\n", MarkdownFlavor::MkDocs, None);
3088 assert!(rule.check(&four).unwrap().is_empty());
3089
3090 let standard = LintContext::new("- item\n\n wrap\n", MarkdownFlavor::Standard, None);
3094 assert_eq!(rule.check(&standard).unwrap().len(), 1);
3095 assert_eq!(rule.fix(&standard).unwrap(), "- item\n\n wrap\n");
3096 }
3097
3098 #[test]
3099 fn configured_indent_can_raise_the_strict_flavor_minimum() {
3100 let rule = rule_with(&[("indent", toml::Value::Integer(6))]);
3103 let ctx = LintContext::new("- item\n\n wrap\n", MarkdownFlavor::MkDocs, None);
3104 let warnings = rule.check(&ctx).unwrap();
3105 assert_eq!(warnings.len(), 1);
3106 assert!(
3107 warnings[0].message.contains("needs 6 spaces"),
3108 "configured 6 must win over the 4-space floor: {}",
3109 warnings[0].message
3110 );
3111 assert_eq!(rule.fix(&ctx).unwrap(), "- item\n\n wrap\n");
3112 }
3113
3114 #[test]
3115 fn configured_indent_message_does_not_claim_a_structural_consequence() {
3116 let rule = rule_with(&[("indent", toml::Value::Integer(4))]);
3120 let ctx = LintContext::new("- item\n\n wrap\n", MarkdownFlavor::Standard, None);
3121 let warnings = rule.check(&ctx).unwrap();
3122 assert_eq!(warnings.len(), 1);
3123 assert!(
3124 warnings[0].message.contains("match the configured indent"),
3125 "expected the configured-indent wording, got: {}",
3126 warnings[0].message
3127 );
3128 assert!(
3129 !warnings[0].message.contains("remain part of the list"),
3130 "the content does remain part of the list here: {}",
3131 warnings[0].message
3132 );
3133
3134 assert!(check("- item\n\n wrap\n").is_empty());
3137
3138 let escaping = check("- item\n\n wrap\n");
3141 assert_eq!(escaping.len(), 1);
3142 assert!(
3143 escaping[0].message.contains("remain part of the list"),
3144 "unconfigured under-indent keeps its structural message, got: {}",
3145 escaping[0].message
3146 );
3147 }
3148
3149 #[test]
3150 fn configured_indent_leaves_tight_lazy_continuation_to_style() {
3151 let any = rule_with(&[("indent", toml::Value::Integer(4))]);
3155 let ctx = LintContext::new("- item\n wrap\n", MarkdownFlavor::Standard, None);
3156 assert!(
3157 any.check(&ctx).unwrap().is_empty(),
3158 "style = any accepts tight lazy continuation"
3159 );
3160
3161 let aligned = rule_with(&[
3162 ("indent", toml::Value::Integer(4)),
3163 ("style", toml::Value::String("aligned".to_string())),
3164 ]);
3165 let warnings = aligned.check(&ctx).unwrap();
3166 assert_eq!(warnings.len(), 1, "style = aligned raises it: {warnings:?}");
3167 assert!(warnings[0].message.contains("expected 4"));
3168 assert_eq!(aligned.fix(&ctx).unwrap(), "- item\n wrap\n");
3169 }
3170
3171 #[test]
3172 fn aligned_tight_underindented_fence_inside_item_left_alone() {
3173 let content = "- item\n ```\n code\n ```\n";
3177 assert!(check_aligned(content).is_empty());
3178 assert_eq!(fix_aligned(content), content);
3179 }
3180
3181 #[test]
3182 fn aligned_task_under_indent_fix_is_idempotent() {
3183 let content = "- [ ] Task\nwrap\n";
3184 let once = fix_aligned(content);
3185 assert_eq!(fix_aligned(&once), once);
3186 }
3187
3188 #[test]
3189 fn aligned_partial_indent_fix_is_idempotent() {
3190 let content = "- item\n continuation\n";
3191 let once = fix_aligned(content);
3192 assert_eq!(fix_aligned(&once), once);
3193 }
3194}