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_horizontal_rule {
309 break;
310 }
311
312 if Self::is_block_level_construct(trimmed) {
313 continue;
314 }
315
316 let col = info.visual_indent;
317
318 while nested_stack.last().is_some_and(|&(_, c)| c > col) {
322 nested_stack.pop();
323 }
324 if !nested_stack.is_empty() {
325 continue;
326 }
327
328 if saw_blank && col <= marker_col {
329 break;
330 }
331
332 let line = ContinuationLine {
333 line_num,
334 info,
335 trimmed,
336 actual: col,
337 saw_blank,
338 saw_nested,
339 };
340 if per_line(&line).is_break() {
341 break;
342 }
343 }
344 }
345
346 fn item_range_has_latent_structure(ctx: &LintContext, item_line: usize, range_end: usize) -> bool {
366 (item_line + 1..=range_end).any(|line_num| {
367 ctx.line_info(line_num).is_some_and(|info| {
368 if info.is_blank || info.list_item.is_some() {
369 return false;
370 }
371 let trimmed = info.content(ctx.content).trim_start();
372 !Self::should_skip_line(info, trimmed)
373 && (Self::starts_with_list_marker(trimmed)
374 || crate::utils::skip_context::is_table_line(trimmed)
375 || Self::is_latent_setext_underline(ctx, line_num, trimmed))
376 })
377 })
378 }
379
380 fn is_latent_setext_underline(ctx: &LintContext, line_num: usize, trimmed: &str) -> bool {
394 crate::lint_context::is_setext_underline_content(trimmed)
395 && ctx.line_info(line_num - 1).is_some_and(|prev| {
396 prev.is_paragraph_context() && crate::lint_context::is_paragraph_text_line(prev.content(ctx.content))
397 })
398 }
399
400 fn sibling_column_usage(
410 ctx: &LintContext,
411 item_line: usize,
412 range_end: usize,
413 marker_col: usize,
414 content_col: usize,
415 task_col: usize,
416 ) -> (bool, bool) {
417 let mut uses_content = false;
418 let mut uses_task = false;
419
420 Self::walk_item_continuation(ctx, item_line, range_end, marker_col, |line| {
421 if line.actual == content_col {
422 uses_content = true;
423 }
424 if line.actual == task_col {
425 uses_task = true;
426 }
427 if uses_content && uses_task {
428 ControlFlow::Break(())
429 } else {
430 ControlFlow::Continue(())
431 }
432 });
433
434 (uses_content, uses_task)
435 }
436
437 fn compute_fix_target(
443 actual: usize,
444 required: usize,
445 task_col: Option<usize>,
446 uses_content_col: bool,
447 uses_task_col: bool,
448 ) -> usize {
449 let Some(t) = task_col else { return required };
450 match actual.abs_diff(t).cmp(&actual.abs_diff(required)) {
451 std::cmp::Ordering::Less => t,
452 std::cmp::Ordering::Greater => required,
453 std::cmp::Ordering::Equal => match (uses_task_col, uses_content_col) {
454 (true, false) => t,
455 _ => required,
456 },
457 }
458 }
459
460 fn should_skip_line(info: &crate::lint_context::LineInfo, trimmed: &str) -> bool {
471 if info.in_code_block && !Self::is_code_fence(trimmed) {
472 return true;
473 }
474 info.in_front_matter
475 || info.in_footnote_definition
476 || info.in_html_block
477 || info.in_html_comment
478 || info.in_mdx_comment
479 || info.in_mkdocstrings
480 || info.in_esm_block
481 || info.in_math_block
482 || info.in_admonition
483 || info.in_content_tab
484 || info.in_pymdown_block
485 || info.in_definition_list
486 || info.in_mkdocs_html_markdown
487 || info.in_kramdown_extension_block
488 }
489
490 fn build_over_indent_warning(
499 ctx: &LintContext,
500 line: &ContinuationLine<'_>,
501 fix_target: usize,
502 message: String,
503 ) -> LintWarning {
504 let line_content = line.info.content(ctx.content);
505 let fix_start = line.info.byte_offset;
506 let fix_end = fix_start + line.info.indent;
507 LintWarning {
508 rule_name: Some("MD077".to_string()),
509 line: line.line_num,
510 column: 1,
511 end_line: line.line_num,
512 end_column: line_content.chars().count() + 1,
513 message,
514 severity: Severity::Warning,
515 fix: Some(Fix::new(fix_start..fix_end, " ".repeat(fix_target))),
516 }
517 }
518
519 fn build_under_indent_warning(
531 ctx: &LintContext,
532 line: &ContinuationLine<'_>,
533 required: usize,
534 message: String,
535 ) -> UnderIndentOutcome {
536 let line_content = line.info.content(ctx.content);
537 let is_fence_opener = line.info.in_code_block
538 && Self::is_code_fence(line.trimmed)
539 && ctx.line_info(line.line_num - 1).is_none_or(|p| !p.in_code_block);
540
541 let (fix, warn_end_line, warn_end_column, compound_closer) = if is_fence_opener {
542 let closer_line = Self::find_fence_closer(ctx, line.line_num);
543 let fix = Self::build_compound_fence_fix(ctx, line.line_num, closer_line, line.actual, required);
544 let end_column = ctx
545 .line_info(closer_line)
546 .map_or(line_content.chars().count() + 1, |ci| {
547 ci.content(ctx.content).chars().count() + 1
548 });
549 let extra_flag = (closer_line != line.line_num).then_some(closer_line);
550 (fix, closer_line, end_column, extra_flag)
551 } else {
552 let fix_start = line.info.byte_offset;
553 let fix_end = fix_start + line.info.indent;
554 let fix = Some(Fix::new(fix_start..fix_end, " ".repeat(required)));
555 (fix, line.line_num, line_content.chars().count() + 1, None)
556 };
557
558 UnderIndentOutcome {
559 warning: LintWarning {
560 rule_name: Some("MD077".to_string()),
561 line: line.line_num,
562 column: 1,
563 end_line: warn_end_line,
564 end_column: warn_end_column,
565 message,
566 severity: Severity::Warning,
567 fix,
568 },
569 also_flag_line: compound_closer,
570 }
571 }
572}
573
574struct ContinuationLine<'a> {
578 line_num: usize,
579 info: &'a LineInfo,
580 trimmed: &'a str,
581 actual: usize,
582 saw_blank: bool,
583 saw_nested: bool,
587}
588
589struct UnderIndentOutcome {
594 warning: LintWarning,
595 also_flag_line: Option<usize>,
596}
597
598impl Rule for MD077ListContinuationIndent {
599 fn name(&self) -> &'static str {
600 "MD077"
601 }
602
603 fn description(&self) -> &'static str {
604 "List continuation content indentation"
605 }
606
607 fn check(&self, ctx: &LintContext) -> LintResult {
608 if ctx.content.is_empty() {
609 return Ok(Vec::new());
610 }
611
612 let strict_indent = ctx.flavor.requires_strict_list_indent();
613 let total_lines = ctx.lines.len();
614 let mut warnings = Vec::new();
615 let mut flagged_lines = std::collections::HashSet::new();
616
617 let mut items: Vec<(usize, usize, usize, Option<usize>)> = Vec::new();
626 for block in &ctx.list_blocks {
627 for &item_line in &block.item_lines {
628 if let Some(info) = ctx.line_info(item_line)
629 && let Some(ref li) = info.list_item
630 {
631 if info.blockquote.is_some() {
638 continue;
639 }
640 let line = info.content(ctx.content);
641 let task_col = Self::is_task_list_item(line, li.content_column)
642 .then_some(li.content_column + Self::TASK_CHECKBOX_PREFIX_LEN);
643 items.push((item_line, li.marker_column, li.content_column, task_col));
644 }
645 }
646 }
647 items.sort_unstable();
648 items.dedup_by_key(|&mut (ln, _, _, _)| ln);
649
650 let mut range_ends = vec![total_lines; items.len()];
663 let mut stack: Vec<usize> = Vec::new();
664 for i in (0..items.len()).rev() {
665 let marker_col = items[i].1;
666 while let Some(&top) = stack.last() {
667 if items[top].1 > marker_col {
668 stack.pop();
669 } else {
670 break;
671 }
672 }
673 range_ends[i] = stack.last().map_or(total_lines, |&j| items[j].0 - 1);
674 stack.push(i);
675 }
676
677 let scoped: Vec<(usize, usize, usize, Option<usize>, usize, usize)> = items
680 .iter()
681 .enumerate()
682 .map(|(item_idx, &(item_line, marker_col, content_col, task_col))| {
683 let required = match self.config.indent {
691 Some(indent) if strict_indent => (marker_col + indent).max(4),
692 Some(indent) => marker_col + indent,
693 None if strict_indent => content_col.max(4),
694 None => content_col,
695 };
696 (
697 item_line,
698 marker_col,
699 content_col,
700 task_col,
701 required,
702 range_ends[item_idx],
703 )
704 })
705 .collect();
706
707 let prose_candidate_lines: Vec<usize> = (1..=total_lines)
721 .filter(|&line_num| {
722 let Some(info) = ctx.line_info(line_num) else {
723 return false;
724 };
725 let trimmed = info.content(ctx.content).trim_start();
726 !Self::should_skip_line(info, trimmed)
727 && !info.is_blank
728 && info.list_item.is_none()
729 && info.heading.is_none()
730 && !info.is_horizontal_rule
731 && !Self::is_block_level_construct(trimmed)
732 })
733 .collect();
734 let range_has_prose_candidate = |after_line: usize, range_end: usize| -> bool {
737 let start = prose_candidate_lines.partition_point(|&l| l <= after_line);
738 prose_candidate_lines.get(start).is_some_and(|&l| l <= range_end)
739 };
740
741 let aligned = self.config.style == ContinuationStyle::Aligned;
768 for &(item_line, marker_col, _content_col, _task_col, required, range_end) in &scoped {
769 if !range_has_prose_candidate(item_line, range_end) {
772 continue;
773 }
774 let has_latent_structure = aligned && Self::item_range_has_latent_structure(ctx, item_line, range_end);
789 let from_configured_indent = self.config.indent.is_some_and(|indent| marker_col + indent == required);
795 Self::walk_item_continuation(ctx, item_line, range_end, marker_col, |line| {
796 let actual = line.actual;
797 let under_indented = actual < required;
798 let loose_escape = line.saw_blank && under_indented;
799 let confirmed_structure = line.info.in_code_block || line.info.blockquote.is_some();
806 let aligned_tight = aligned
807 && !has_latent_structure
808 && !line.saw_blank
809 && !line.saw_nested
810 && under_indented
811 && !confirmed_structure;
812 if (loose_escape || aligned_tight) && flagged_lines.insert(line.line_num) {
813 let message = if line.saw_blank {
814 if from_configured_indent {
815 format!(
816 "Content after blank line in list item needs {required} spaces of \
817 indentation to match the configured indent (found {actual})",
818 )
819 } else if strict_indent {
820 format!(
821 "Content inside list item needs {required} spaces of indentation \
822 for MkDocs compatibility (found {actual})",
823 )
824 } else {
825 format!(
826 "Content after blank line in list item needs {required} spaces of \
827 indentation to remain part of the list (found {actual})",
828 )
829 }
830 } else {
831 format!("Continuation line under-indented (expected {required}, found {actual})")
832 };
833 let outcome = Self::build_under_indent_warning(ctx, line, required, message);
834 if let Some(closer_line) = outcome.also_flag_line {
835 flagged_lines.insert(closer_line);
836 }
837 warnings.push(outcome.warning);
838 }
839 ControlFlow::Continue(())
840 });
841 }
842
843 for &(item_line, marker_col, content_col, task_col, required, range_end) in &scoped {
852 if !range_has_prose_candidate(item_line, range_end) {
854 continue;
855 }
856 let (uses_content_col, uses_task_col) = match task_col {
860 Some(t) => Self::sibling_column_usage(ctx, item_line, range_end, marker_col, content_col, t),
861 None => (false, false),
862 };
863
864 Self::walk_item_continuation(ctx, item_line, range_end, marker_col, |line| {
865 let actual = line.actual;
866 if actual > required
867 && !line.info.in_code_block
868 && Some(actual) != task_col
869 && !Self::starts_with_list_marker(line.trimmed)
870 && flagged_lines.insert(line.line_num)
871 {
872 let fix_target =
873 Self::compute_fix_target(actual, required, task_col, uses_content_col, uses_task_col);
874 let message = match task_col {
875 Some(t) => format!(
876 "Continuation line over-indented \
877 (expected {required} or {t}, found {actual})"
878 ),
879 None => {
880 format!("Continuation line over-indented (expected {required}, found {actual})")
881 }
882 };
883 warnings.push(Self::build_over_indent_warning(ctx, line, fix_target, message));
884 }
885 ControlFlow::Continue(())
886 });
887 }
888
889 warnings.sort_by_key(|w| (w.line, w.column));
892
893 Ok(warnings)
894 }
895
896 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
897 let warnings = self.check(ctx)?;
898 let warnings =
899 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
900 if warnings.is_empty() {
901 return Ok(ctx.content.to_string());
902 }
903
904 let mut fixes: Vec<Fix> = warnings.into_iter().filter_map(|w| w.fix).collect();
906 fixes.sort_by_key(|f| std::cmp::Reverse(f.range.start));
907
908 let mut content = ctx.content.to_string();
909 for fix in fixes {
910 if fix.range.start <= content.len() && fix.range.end <= content.len() {
911 content.replace_range(fix.range, &fix.replacement);
912 }
913 }
914
915 Ok(content)
916 }
917
918 fn category(&self) -> RuleCategory {
919 RuleCategory::List
920 }
921
922 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
923 ctx.content.is_empty() || ctx.list_blocks.is_empty()
924 }
925
926 fn as_any(&self) -> &dyn std::any::Any {
927 self
928 }
929
930 crate::impl_rule_config_methods!(MD077Config);
931}
932
933#[cfg(test)]
934mod tests {
935 use super::*;
936 use crate::config::MarkdownFlavor;
937
938 fn check(content: &str) -> Vec<LintWarning> {
939 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
940 let rule = MD077ListContinuationIndent::default();
941 rule.check(&ctx).unwrap()
942 }
943
944 fn check_mkdocs(content: &str) -> Vec<LintWarning> {
945 let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
946 let rule = MD077ListContinuationIndent::default();
947 rule.check(&ctx).unwrap()
948 }
949
950 fn fix(content: &str) -> String {
951 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
952 let rule = MD077ListContinuationIndent::default();
953 rule.fix(&ctx).unwrap()
954 }
955
956 fn fix_mkdocs(content: &str) -> String {
957 let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
958 let rule = MD077ListContinuationIndent::default();
959 rule.fix(&ctx).unwrap()
960 }
961
962 fn aligned_rule() -> MD077ListContinuationIndent {
963 MD077ListContinuationIndent::new(ContinuationStyle::Aligned)
964 }
965
966 fn check_aligned(content: &str) -> Vec<LintWarning> {
967 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
968 aligned_rule().check(&ctx).unwrap()
969 }
970
971 fn check_aligned_mkdocs(content: &str) -> Vec<LintWarning> {
972 let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
973 aligned_rule().check(&ctx).unwrap()
974 }
975
976 fn fix_aligned(content: &str) -> String {
977 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
978 aligned_rule().fix(&ctx).unwrap()
979 }
980
981 fn fix_aligned_quarto(content: &str) -> String {
982 let ctx = LintContext::new(content, MarkdownFlavor::Quarto, None);
983 aligned_rule().fix(&ctx).unwrap()
984 }
985
986 #[test]
987 fn aligned_idempotent_with_latent_marker_behind_unstable_heading() {
988 let input = "1. \n``\n``\n- \n``";
997 let once = fix_aligned_quarto(input);
998 let twice = fix_aligned_quarto(&once);
999 assert_eq!(once, twice, "MD077 aligned fix must be idempotent (Quarto)");
1000 }
1001
1002 #[test]
1003 fn latent_underline_needs_paragraph_text_above_it() {
1004 for (label, content) in [
1008 ("ATX heading", "- item\nwrap\n# Heading\n===\n"),
1009 ("thematic break", "- item\nwrap\n***\n===\n"),
1010 ("HTML block", "- item\nwrap\n<div>\n===\n"),
1011 ] {
1012 assert_eq!(
1013 check_aligned(content).len(),
1014 1,
1015 "{label}: reindenting cannot make a setext heading here, so the under-indent is reportable"
1016 );
1017 }
1018
1019 assert_eq!(
1023 check_aligned("- item\nwrap\n```\ncode\n```\n===\n").len(),
1024 2,
1025 "a closing fence is not paragraph text, so both under-indents are reportable"
1026 );
1027
1028 for (label, content) in [
1034 ("empty bullet", "- item\n wrap\n > - \n ===\n"),
1035 ("empty ordered item", "- item\n wrap\n > 1. \n ===\n"),
1036 ("empty item in a nested quote", "- item\n wrap\n > > - \n ===\n"),
1037 ] {
1038 assert_eq!(
1039 check_aligned(content).len(),
1040 1,
1041 "{label}: an item holding no text cannot become a heading's text line"
1042 );
1043 }
1044
1045 for (label, content) in [
1049 ("bare blank line", "- item\n wrap\n\n ===\n"),
1050 ("blank line in a quote", "- item\n wrap\n >\n ===\n"),
1051 ("quoted whitespace", "- item\n wrap\n > \n ===\n"),
1052 ] {
1053 assert_eq!(
1054 check_aligned(content).len(),
1055 2,
1056 "{label}: nothing above the underline can become a heading's text line"
1057 );
1058 }
1059
1060 assert!(
1064 check_aligned("- item\nwrap\ntext\n===\n").is_empty(),
1065 "prose above the underline is latent structure, so the item is left alone"
1066 );
1067 }
1068
1069 #[test]
1070 fn aligned_idempotent_with_lazy_continuation_out_of_a_blockquote() {
1071 let input = "- \n> *\n> a\n``";
1075 let once = fix_aligned(input);
1076 let twice = fix_aligned(&once);
1077 assert_eq!(once, twice, "MD077 aligned fix must be idempotent");
1078 }
1079
1080 #[test]
1083 fn tight_lazy_continuation_zero_indent_not_flagged() {
1084 let content = "- Item\ncontinuation\n";
1086 assert!(check(content).is_empty());
1087 }
1088
1089 #[test]
1090 fn tight_continuation_correct_indent_not_flagged() {
1091 let content = "1. Item\n continuation\n";
1093 assert!(check(content).is_empty());
1094 }
1095
1096 #[test]
1097 fn tight_continuation_over_indented_ordered() {
1098 let content = "1. This is a list item with multiple lines.\n The second line is over-indented.\n";
1100 let warnings = check(content);
1101 assert_eq!(warnings.len(), 1);
1102 assert_eq!(warnings[0].line, 2);
1103 assert!(warnings[0].message.contains("over-indented"));
1104 }
1105
1106 #[test]
1107 fn tight_continuation_over_indented_unordered() {
1108 let content = "- Item\n over-indented\n";
1110 let warnings = check(content);
1111 assert_eq!(warnings.len(), 1);
1112 assert_eq!(warnings[0].line, 2);
1113 }
1114
1115 #[test]
1116 fn tight_continuation_multiple_over_indented_lines() {
1117 let content = "1. Item\n line one\n line two\n line three\n";
1118 let warnings = check(content);
1119 assert_eq!(warnings.len(), 3);
1120 }
1121
1122 #[test]
1123 fn tight_continuation_mixed_correct_and_over() {
1124 let content = "1. Item\n correct\n over-indented\n correct again\n";
1125 let warnings = check(content);
1126 assert_eq!(warnings.len(), 1);
1127 assert_eq!(warnings[0].line, 3);
1128 }
1129
1130 #[test]
1131 fn tight_continuation_nested_over_indented() {
1132 let content = "- L1\n - L2\n over-indented continuation of L2\n";
1134 let warnings = check(content);
1135 assert_eq!(warnings.len(), 1);
1136 assert_eq!(warnings[0].line, 3);
1137 assert!(warnings[0].message.contains("expected 4"));
1139 assert!(warnings[0].message.contains("found 5"));
1140 }
1141
1142 #[test]
1143 fn tight_continuation_nested_correct_indent_not_flagged() {
1144 let content = "- L1\n - L2\n correctly indented continuation of L2\n";
1147 assert!(check(content).is_empty());
1148 }
1149
1150 #[test]
1151 fn fix_tight_continuation_nested_over_indented() {
1152 let content = "- L1\n - L2\n over-indented continuation of L2\n";
1154 let fixed = fix(content);
1155 assert_eq!(fixed, "- L1\n - L2\n over-indented continuation of L2\n");
1156 }
1157
1158 #[test]
1159 fn tight_continuation_under_indented_not_flagged() {
1160 let content = "1. Item\n under-indented\n";
1163 assert!(check(content).is_empty());
1164 }
1165
1166 #[test]
1167 fn tight_continuation_tab_over_indented() {
1168 let content = "- Item\n\tover-indented\n";
1170 let warnings = check(content);
1171 assert_eq!(warnings.len(), 1);
1172 }
1173
1174 #[test]
1175 fn fix_tight_continuation_over_indented_ordered() {
1176 let content = "1. This is a list item with multiple lines.\n The second line is over-indented.\n";
1177 let fixed = fix(content);
1178 assert_eq!(
1179 fixed,
1180 "1. This is a list item with multiple lines.\n The second line is over-indented.\n"
1181 );
1182 }
1183
1184 #[test]
1185 fn fix_tight_continuation_over_indented_unordered() {
1186 let content = "- Item\n over-indented\n";
1187 let fixed = fix(content);
1188 assert_eq!(fixed, "- Item\n over-indented\n");
1189 }
1190
1191 #[test]
1192 fn fix_tight_continuation_multiple_lines() {
1193 let content = "1. Item\n line one\n line two\n";
1194 let fixed = fix(content);
1195 assert_eq!(fixed, "1. Item\n line one\n line two\n");
1196 }
1197
1198 #[test]
1199 fn tight_continuation_mkdocs_4space_ordered_not_flagged() {
1200 let content = "1. Item\n continuation\n";
1203 assert!(check_mkdocs(content).is_empty());
1204 }
1205
1206 #[test]
1207 fn tight_continuation_mkdocs_5space_ordered_flagged() {
1208 let content = "1. Item\n over-indented\n";
1210 let warnings = check_mkdocs(content);
1211 assert_eq!(warnings.len(), 1);
1212 assert!(warnings[0].message.contains("expected 4"));
1213 assert!(warnings[0].message.contains("found 5"));
1214 }
1215
1216 #[test]
1217 fn fix_tight_continuation_mkdocs_over_indented() {
1218 let content = "1. Item\n over-indented\n";
1219 let fixed = fix_mkdocs(content);
1220 assert_eq!(fixed, "1. Item\n over-indented\n");
1221 }
1222
1223 #[test]
1224 fn tight_continuation_deeply_indented_list_markers_not_flagged() {
1225 let content = "* Level 0\n * Level 1\n * Level 2\n";
1228 assert!(check(content).is_empty());
1229 }
1230
1231 #[test]
1232 fn tight_continuation_ordered_marker_not_flagged() {
1233 let content = "- Parent\n 1. Child item\n";
1235 assert!(check(content).is_empty());
1236 }
1237
1238 #[test]
1241 fn unordered_correct_indent_no_warning() {
1242 let content = "- Item\n\n continuation\n";
1243 assert!(check(content).is_empty());
1244 }
1245
1246 #[test]
1247 fn unordered_partial_indent_warns() {
1248 let content = "- Item\n\n continuation\n";
1251 let warnings = check(content);
1252 assert_eq!(warnings.len(), 1);
1253 assert_eq!(warnings[0].line, 3);
1254 assert!(warnings[0].message.contains("2 spaces"));
1255 assert!(warnings[0].message.contains("found 1"));
1256 }
1257
1258 #[test]
1259 fn unordered_zero_indent_is_new_paragraph() {
1260 let content = "- Item\n\ncontinuation\n";
1263 assert!(check(content).is_empty());
1264 }
1265
1266 #[test]
1269 fn ordered_3space_correct_commonmark() {
1270 let content = "1. Item\n\n continuation\n";
1272 assert!(check(content).is_empty());
1273 }
1274
1275 #[test]
1276 fn ordered_2space_under_indent_commonmark() {
1277 let content = "1. Item\n\n continuation\n";
1278 let warnings = check(content);
1279 assert_eq!(warnings.len(), 1);
1280 assert!(warnings[0].message.contains("3 spaces"));
1281 assert!(warnings[0].message.contains("found 2"));
1282 }
1283
1284 #[test]
1287 fn multi_digit_marker_correct() {
1288 let content = "10. Item\n\n continuation\n";
1290 assert!(check(content).is_empty());
1291 }
1292
1293 #[test]
1294 fn multi_digit_marker_under_indent() {
1295 let content = "10. Item\n\n continuation\n";
1296 let warnings = check(content);
1297 assert_eq!(warnings.len(), 1);
1298 assert!(warnings[0].message.contains("4 spaces"));
1299 }
1300
1301 #[test]
1304 fn mkdocs_3space_ordered_warns() {
1305 let content = "1. Item\n\n continuation\n";
1307 let warnings = check_mkdocs(content);
1308 assert_eq!(warnings.len(), 1);
1309 assert!(warnings[0].message.contains("4 spaces"));
1310 assert!(warnings[0].message.contains("MkDocs"));
1311 }
1312
1313 #[test]
1314 fn mkdocs_4space_ordered_no_warning() {
1315 let content = "1. Item\n\n continuation\n";
1316 assert!(check_mkdocs(content).is_empty());
1317 }
1318
1319 #[test]
1320 fn mkdocs_unordered_2space_ok() {
1321 let content = "- Item\n\n continuation\n";
1323 assert!(check_mkdocs(content).is_empty());
1324 }
1325
1326 #[test]
1327 fn mkdocs_unordered_2space_warns() {
1328 let content = "- Item\n\n continuation\n";
1330 let warnings = check_mkdocs(content);
1331 assert_eq!(warnings.len(), 1);
1332 assert!(warnings[0].message.contains("4 spaces"));
1333 }
1334
1335 #[test]
1338 fn fix_unordered_indent() {
1339 let content = "- Item\n\n continuation\n";
1341 let fixed = fix(content);
1342 assert_eq!(fixed, "- Item\n\n continuation\n");
1343 }
1344
1345 #[test]
1346 fn fix_ordered_indent() {
1347 let content = "1. Item\n\n continuation\n";
1348 let fixed = fix(content);
1349 assert_eq!(fixed, "1. Item\n\n continuation\n");
1350 }
1351
1352 #[test]
1353 fn fix_mkdocs_indent() {
1354 let content = "1. Item\n\n continuation\n";
1355 let fixed = fix_mkdocs(content);
1356 assert_eq!(fixed, "1. Item\n\n continuation\n");
1357 }
1358
1359 #[test]
1362 fn nested_list_items_not_flagged() {
1363 let content = "- Parent\n\n - Child\n";
1364 assert!(check(content).is_empty());
1365 }
1366
1367 #[test]
1368 fn nested_list_zero_indent_is_new_paragraph() {
1369 let content = "- Parent\n - Child\n\ncontinuation of parent\n";
1371 assert!(check(content).is_empty());
1372 }
1373
1374 #[test]
1375 fn nested_list_partial_indent_flagged() {
1376 let content = "- Parent\n - Child\n\n continuation of parent\n";
1378 let warnings = check(content);
1379 assert_eq!(warnings.len(), 1);
1380 assert!(warnings[0].message.contains("2 spaces"));
1381 }
1382
1383 #[test]
1386 fn code_block_correctly_indented_no_warning() {
1387 let content = "- Item\n\n ```\n code\n ```\n";
1389 assert!(check(content).is_empty());
1390 }
1391
1392 #[test]
1393 fn code_fence_under_indented_warns() {
1394 let content = "- Item\n\n ```\n code\n ```\n";
1398 let warnings = check(content);
1399 assert_eq!(warnings.len(), 1);
1400 assert_eq!(warnings[0].line, 3);
1401 }
1402
1403 #[test]
1404 fn code_fence_under_indented_ordered_mkdocs() {
1405 let content = "1. Item\n\n ```toml\n key = \"value\"\n ```\n";
1408 assert!(check(content).is_empty()); let warnings = check_mkdocs(content);
1410 assert_eq!(warnings.len(), 1); assert_eq!(warnings[0].line, 3);
1412 assert!(warnings[0].message.contains("4 spaces"));
1413 assert!(warnings[0].message.contains("MkDocs"));
1414 }
1415
1416 #[test]
1417 fn code_fence_tilde_under_indented() {
1418 let content = "- Item\n\n ~~~\n code\n ~~~\n";
1419 let warnings = check(content);
1420 assert_eq!(warnings.len(), 1); assert_eq!(warnings[0].line, 3);
1422 }
1423
1424 #[test]
1427 fn multiple_blank_lines_zero_indent_is_new_paragraph() {
1428 let content = "- Item\n\n\ncontinuation\n";
1430 assert!(check(content).is_empty());
1431 }
1432
1433 #[test]
1434 fn multiple_blank_lines_partial_indent_flags() {
1435 let content = "- Item\n\n\n continuation\n";
1436 let warnings = check(content);
1437 assert_eq!(warnings.len(), 1);
1438 }
1439
1440 #[test]
1443 fn empty_item_no_warning() {
1444 let content = "- \n- Second\n";
1445 assert!(check(content).is_empty());
1446 }
1447
1448 #[test]
1451 fn multiple_items_mixed_indent() {
1452 let content = "1. First\n\n correct continuation\n\n2. Second\n\n wrong continuation\n";
1453 let warnings = check(content);
1454 assert_eq!(warnings.len(), 1);
1455 assert_eq!(warnings[0].line, 7);
1456 }
1457
1458 #[test]
1461 fn task_list_correct_indent() {
1462 let content = "- [ ] Task\n\n continuation\n";
1464 assert!(check(content).is_empty());
1465 }
1466
1467 #[test]
1470 fn frontmatter_not_flagged() {
1471 let content = "---\ntitle: test\n---\n\n- Item\n\n continuation\n";
1472 assert!(check(content).is_empty());
1473 }
1474
1475 #[test]
1478 fn fix_multiple_items() {
1479 let content = "1. First\n\n wrong1\n\n2. Second\n\n wrong2\n";
1480 let fixed = fix(content);
1481 assert_eq!(fixed, "1. First\n\n wrong1\n\n2. Second\n\n wrong2\n");
1482 }
1483
1484 #[test]
1485 fn fix_multiline_loose_continuation_all_lines() {
1486 let content = "1. Item\n\n line one\n line two\n line three\n";
1487 let fixed = fix(content);
1488 assert_eq!(fixed, "1. Item\n\n line one\n line two\n line three\n");
1489 }
1490
1491 #[test]
1494 fn sibling_item_boundary_respected() {
1495 let content = "- First\n- Second\n\n continuation\n";
1497 assert!(check(content).is_empty());
1498 }
1499
1500 #[test]
1503 fn blockquote_list_correct_indent_no_warning() {
1504 let content = "> - Item\n>\n> continuation\n";
1507 assert!(check(content).is_empty());
1508 }
1509
1510 #[test]
1511 fn blockquote_list_under_indent_no_false_positive() {
1512 let content = "> - Item\n>\n> continuation\n";
1517 assert!(check(content).is_empty());
1518 }
1519
1520 #[test]
1523 fn deep_nesting_correct_indent() {
1524 let content = "- L1\n - L2\n - L3\n\n continuation of L3\n";
1525 assert!(check(content).is_empty());
1526 }
1527
1528 #[test]
1529 fn deep_nesting_under_indent() {
1530 let content = "- L1\n - L2\n - L3\n\n continuation of L3\n";
1533 let warnings = check(content);
1534 assert_eq!(warnings.len(), 1);
1535 assert!(warnings[0].message.contains("6 spaces"));
1536 assert!(warnings[0].message.contains("found 5"));
1537 }
1538
1539 #[test]
1540 fn deep_nesting_middle_level_continuation_bullets() {
1541 let content = "- L1\n - L2\n - L3\n\n continuation of L2\n";
1545 assert!(check(content).is_empty());
1546 }
1547
1548 #[test]
1549 fn deep_nesting_middle_level_continuation_ordered() {
1550 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";
1553 assert!(check(content).is_empty());
1554 }
1555
1556 #[test]
1557 fn deep_nesting_outermost_continuation() {
1558 let content = "- L1\n - L2\n - L3\n\n continuation of L1\n";
1561 assert!(check(content).is_empty());
1562 }
1563
1564 #[test]
1565 fn deep_nesting_between_levels_still_flagged() {
1566 let content = "- L1\n - L2\n - L3\n\n continuation\n";
1569 let warnings = check(content);
1570 assert_eq!(warnings.len(), 1);
1571 assert!(warnings[0].message.contains("4 spaces"));
1572 assert!(warnings[0].message.contains("found 3"));
1573 }
1574
1575 #[test]
1576 fn deep_nesting_beyond_deepest_still_flagged() {
1577 let content = "- L1\n - L2\n - L3\n\n continuation\n";
1579 let warnings = check(content);
1580 assert_eq!(warnings.len(), 1);
1581 assert!(warnings[0].message.contains("over-indented"));
1582 assert!(warnings[0].message.contains("expected 6, found 7"));
1583 }
1584
1585 #[test]
1586 fn four_levels_middle_continuation() {
1587 let content = "- L1\n - L2\n - L3\n - L4\n\n continuation of L2\n";
1590 assert!(check(content).is_empty());
1591 }
1592
1593 #[test]
1594 fn nested_sibling_closes_deeper_level() {
1595 let content = "- L1\n - L2a\n - L3\n - L2b\n\n continuation of L2b\n";
1598 assert!(check(content).is_empty());
1599 }
1600
1601 #[test]
1602 fn deep_nesting_middle_level_continuation_fix_preserved() {
1603 let content = "- L1\n - L2\n - L3\n\n continuation of L2\n";
1605 assert_eq!(fix(content), content);
1606 }
1607
1608 #[test]
1611 fn loose_tab_continuation_over_indented() {
1612 let content = "- Item\n\n\tcontinuation\n";
1617 let warnings = check(content);
1618 assert_eq!(warnings.len(), 1);
1619 assert_eq!(warnings[0].line, 3);
1620 assert_eq!(fix(content), "- Item\n\n continuation\n");
1621 }
1622
1623 #[test]
1626 fn multiple_continuations_correct() {
1627 let content = "- Item\n\n para 1\n\n para 2\n\n para 3\n";
1628 assert!(check(content).is_empty());
1629 }
1630
1631 #[test]
1632 fn multiple_continuations_second_under_indent() {
1633 let content = "- Item\n\n para 1\n\n continuation 2\n";
1635 let warnings = check(content);
1636 assert_eq!(warnings.len(), 1);
1637 assert_eq!(warnings[0].line, 5);
1638 }
1639
1640 #[test]
1643 fn ordered_paren_marker_correct() {
1644 let content = "1) Item\n\n continuation\n";
1646 assert!(check(content).is_empty());
1647 }
1648
1649 #[test]
1650 fn ordered_paren_marker_under_indent() {
1651 let content = "1) Item\n\n continuation\n";
1652 let warnings = check(content);
1653 assert_eq!(warnings.len(), 1);
1654 assert!(warnings[0].message.contains("3 spaces"));
1655 }
1656
1657 #[test]
1660 fn star_marker_correct() {
1661 let content = "* Item\n\n continuation\n";
1662 assert!(check(content).is_empty());
1663 }
1664
1665 #[test]
1666 fn star_marker_under_indent() {
1667 let content = "* Item\n\n continuation\n";
1668 let warnings = check(content);
1669 assert_eq!(warnings.len(), 1);
1670 }
1671
1672 #[test]
1673 fn plus_marker_correct() {
1674 let content = "+ Item\n\n continuation\n";
1675 assert!(check(content).is_empty());
1676 }
1677
1678 #[test]
1681 fn heading_after_list_no_warning() {
1682 let content = "- Item\n\n# Heading\n";
1683 assert!(check(content).is_empty());
1684 }
1685
1686 #[test]
1689 fn hr_after_list_no_warning() {
1690 let content = "- Item\n\n---\n";
1691 assert!(check(content).is_empty());
1692 }
1693
1694 #[test]
1697 fn reference_link_def_not_flagged() {
1698 let content = "- Item\n\n [link]: https://example.com\n";
1699 assert!(check(content).is_empty());
1700 }
1701
1702 #[test]
1705 fn footnote_def_not_flagged() {
1706 let content = "- Item\n\n [^1]: footnote text\n";
1707 assert!(check(content).is_empty());
1708 }
1709
1710 #[test]
1711 fn footnote_multiline_body_after_list_not_flagged() {
1712 let content = "# A list followed by a footnote\n\n\
1716 Here is a paragraph.[^fn]\n\n\
1717 - This is a list.\n\n\
1718 [^fn]:\n\
1719 \x20\x20\x20\x20Here is a footnote that spans multiple lines.\n\
1720 \x20\x20\x20\x20It should thus be indented by at least four spaces.\n";
1721 assert!(check(content).is_empty());
1722 }
1723
1724 #[test]
1725 fn fix_footnote_multiline_body_after_list_is_noop() {
1726 let content = "# A list followed by a footnote\n\n\
1730 Here is a paragraph.[^fn]\n\n\
1731 - This is a list.\n\n\
1732 [^fn]:\n\
1733 \x20\x20\x20\x20Here is a footnote that spans multiple lines.\n\
1734 \x20\x20\x20\x20It should thus be indented by at least four spaces.\n";
1735 assert_eq!(fix(content), content);
1736 }
1737
1738 #[test]
1739 fn footnote_body_indented_past_list_content_col_not_flagged() {
1740 let content = "- Item\n\n[^fn]:\n Body line one.\n Body line two.\n";
1744 assert!(check(content).is_empty());
1745 }
1746
1747 #[test]
1748 fn list_inside_footnote_body_continuation_not_flagged() {
1749 let content = "Text.[^fn]\n\n[^fn]:\n\
1753 \x20\x20\x20\x20- nested item\n\
1754 \x20\x20\x20\x20\x20\x20\x20over-indented continuation\n";
1755 assert!(check(content).is_empty());
1756 }
1757
1758 #[test]
1759 fn footnote_multiline_body_after_list_not_flagged_mkdocs() {
1760 let content = "Here is a paragraph.[^fn]\n\n\
1764 - This is a list.\n\n\
1765 [^fn]:\n\
1766 \x20\x20\x20\x20\x20\x20Footnote body that spans\n\
1767 \x20\x20\x20\x20\x20\x20multiple indented lines.\n";
1768 assert!(check_mkdocs(content).is_empty());
1769 }
1770
1771 #[test]
1774 fn fix_deep_nesting() {
1775 let content = "- L1\n - L2\n - L3\n\n under-indented\n";
1776 let fixed = fix(content);
1777 assert_eq!(fixed, "- L1\n - L2\n - L3\n\n under-indented\n");
1778 }
1779
1780 #[test]
1781 fn fix_mkdocs_unordered() {
1782 let content = "- Item\n\n continuation\n";
1784 let fixed = fix_mkdocs(content);
1785 assert_eq!(fixed, "- Item\n\n continuation\n");
1786 }
1787
1788 #[test]
1789 fn fix_code_fence_indent() {
1790 let content = "- Item\n\n ```\n code\n ```\n";
1793 let fixed = fix(content);
1794 assert_eq!(fixed, "- Item\n\n ```\n code\n ```\n");
1795 }
1796
1797 #[test]
1798 fn fix_mkdocs_code_fence_indent() {
1799 let content = "1. Item\n\n ```toml\n key = \"val\"\n ```\n";
1801 let fixed = fix_mkdocs(content);
1802 assert_eq!(fixed, "1. Item\n\n ```toml\n key = \"val\"\n ```\n");
1803 }
1804
1805 #[test]
1808 fn empty_document_no_warning() {
1809 assert!(check("").is_empty());
1810 }
1811
1812 #[test]
1813 fn whitespace_only_no_warning() {
1814 assert!(check(" \n\n \n").is_empty());
1815 }
1816
1817 #[test]
1820 fn no_list_no_warning() {
1821 let content = "# Heading\n\nSome paragraph.\n\nAnother paragraph.\n";
1822 assert!(check(content).is_empty());
1823 }
1824
1825 #[test]
1828 fn multiline_continuation_all_lines_flagged() {
1829 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";
1830 let warnings = check(content);
1831 assert_eq!(warnings.len(), 3);
1832 assert_eq!(warnings[0].line, 3);
1833 assert_eq!(warnings[1].line, 4);
1834 assert_eq!(warnings[2].line, 5);
1835 }
1836
1837 #[test]
1838 fn multiline_continuation_with_frontmatter_fix() {
1839 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";
1840 let fixed = fix(content);
1841 assert_eq!(
1842 fixed,
1843 "---\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"
1844 );
1845 }
1846
1847 #[test]
1848 fn multiline_continuation_correct_indent_no_warning() {
1849 let content = "1. Item\n\n line one\n line two\n line three\n";
1850 assert!(check(content).is_empty());
1851 }
1852
1853 #[test]
1854 fn multiline_continuation_mixed_indent() {
1855 let content = "1. Item\n\n correct\n wrong\n correct\n";
1856 let warnings = check(content);
1857 assert_eq!(warnings.len(), 1);
1858 assert_eq!(warnings[0].line, 4);
1859 }
1860
1861 #[test]
1862 fn multiline_continuation_unordered() {
1863 let content = "- Item\n\n continuation 1\n continuation 2\n continuation 3\n";
1864 let warnings = check(content);
1865 assert_eq!(warnings.len(), 3);
1866 let fixed = fix(content);
1867 assert_eq!(
1868 fixed,
1869 "- Item\n\n continuation 1\n continuation 2\n continuation 3\n"
1870 );
1871 }
1872
1873 #[test]
1874 fn multiline_continuation_two_items_fix() {
1875 let content = "1. First\n\n cont a\n cont b\n\n2. Second\n\n cont c\n cont d\n";
1876 let fixed = fix(content);
1877 assert_eq!(
1878 fixed,
1879 "1. First\n\n cont a\n cont b\n\n2. Second\n\n cont c\n cont d\n"
1880 );
1881 }
1882
1883 #[test]
1884 fn fence_fix_does_not_break_pairing_for_md031() {
1885 let content = "#### title\n\nabc\n\n\
1892 1. ab\n\n\
1893 \x20\x20`aabbccdd`\n\n\
1894 2. cd\n\n\
1895 \x20\x20`bbcc dd ee`\n\n\
1896 \x20\x20```\n\
1897 \x20\x20abcd\n\
1898 \x20\x20ef gh\n\
1899 \x20\x20```\n\n\
1900 \x20\x20uu\n\n\
1901 \x20\x20```\n\
1902 \x20\x20cdef\n\
1903 \x20\x20gh ij\n\
1904 \x20\x20```\n";
1905 let expected = "#### title\n\nabc\n\n\
1906 1. ab\n\n\
1907 \x20\x20\x20`aabbccdd`\n\n\
1908 2. cd\n\n\
1909 \x20\x20\x20`bbcc dd ee`\n\n\
1910 \x20\x20\x20```\n\
1911 \x20\x20\x20abcd\n\
1912 \x20\x20\x20ef gh\n\
1913 \x20\x20\x20```\n\n\
1914 \x20\x20\x20uu\n\n\
1915 \x20\x20\x20```\n\
1916 \x20\x20\x20cdef\n\
1917 \x20\x20\x20gh ij\n\
1918 \x20\x20\x20```\n";
1919 assert_eq!(fix(content), expected);
1920 }
1921
1922 #[test]
1923 fn multiline_continuation_separated_by_blank() {
1924 let content = "1. Item\n\n para1 line1\n para1 line2\n\n para2 line1\n para2 line2\n";
1925 let warnings = check(content);
1926 assert_eq!(warnings.len(), 4);
1927 let fixed = fix(content);
1928 assert_eq!(
1929 fixed,
1930 "1. Item\n\n para1 line1\n para1 line2\n\n para2 line1\n para2 line2\n"
1931 );
1932 }
1933
1934 #[test]
1935 fn tab_indented_fence_is_normalized_to_spaces() {
1936 let content = "100. ab\n\n\t```\n\tabcd\n\t```\n";
1944 let expected = "100. ab\n\n ```\n abcd\n ```\n";
1945 assert_eq!(fix(content), expected);
1946 }
1947
1948 #[test]
1957 fn loose_continuation_over_indented_flagged() {
1958 let content = "* Item\n\n over-indented\n";
1961 let warnings = check(content);
1962 assert_eq!(warnings.len(), 1);
1963 assert_eq!(warnings[0].line, 3);
1964 assert!(warnings[0].message.contains("over-indented"));
1965 assert!(warnings[0].message.contains("expected 2"));
1966 assert!(warnings[0].message.contains("found 3"));
1967 }
1968
1969 #[test]
1970 fn loose_continuation_over_indented_multiline_mixed() {
1971 let content = "* Item\n\n over one\n correct\n over two\n";
1973 let warnings = check(content);
1974 assert_eq!(warnings.len(), 2);
1975 assert_eq!(warnings[0].line, 3);
1976 assert_eq!(warnings[1].line, 5);
1977 }
1978
1979 #[test]
1980 fn fix_loose_continuation_over_indented() {
1981 let content = "* Item\n\n over one\n correct\n over two\n";
1982 let fixed = fix(content);
1983 assert_eq!(fixed, "* Item\n\n over one\n correct\n over two\n");
1984 }
1985
1986 #[test]
1987 fn fix_tight_and_loose_items_normalized_identically() {
1988 let content = "---\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 let expected = "---\ntitle: Heading\n---\n\nSome introductory text:\n\n\
1994 * 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\
1995 * 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";
1996 assert_eq!(fix(content), expected);
1997 }
1998
1999 #[test]
2000 fn multi_paragraph_item_loose_paragraph_over_indented() {
2001 let content = "* Item.\n tight over\n\n loose over\n";
2004 let warnings = check(content);
2005 assert_eq!(warnings.len(), 2);
2006 assert_eq!(warnings[0].line, 2);
2007 assert_eq!(warnings[1].line, 4);
2008 }
2009
2010 #[test]
2011 fn loose_indented_code_block_not_flagged() {
2012 let content = "- Item\n\n code line\n";
2016 assert!(check(content).is_empty());
2017 }
2018
2019 #[test]
2020 fn mkdocs_loose_over_indented_flagged() {
2021 let content = "1. Item\n\n over\n";
2024 let warnings = check_mkdocs(content);
2025 assert_eq!(warnings.len(), 1);
2026 assert_eq!(warnings[0].line, 3);
2027 assert!(warnings[0].message.contains("over-indented"));
2028 assert!(warnings[0].message.contains("expected 4"));
2029 assert!(warnings[0].message.contains("found 5"));
2030 }
2031
2032 #[test]
2033 fn task_list_loose_over_indented_flagged() {
2034 let content = "- [ ] Task\n\n over\n";
2037 let warnings = check(content);
2038 assert_eq!(warnings.len(), 1);
2039 assert_eq!(warnings[0].line, 3);
2040 }
2041
2042 #[test]
2043 fn loose_over_indent_boundary_below_code_block_threshold_flagged() {
2044 let content = "- Item\n\n over\n";
2049 let warnings = check(content);
2050 assert_eq!(warnings.len(), 1);
2051 assert_eq!(warnings[0].line, 3);
2052 assert!(warnings[0].message.contains("expected 2"));
2053 assert!(warnings[0].message.contains("found 5"));
2054 }
2055
2056 #[test]
2057 fn loose_over_indent_does_not_steal_nested_under_indent() {
2058 let content = "- Outer\n - Inner\n\n continuation\n";
2065 let warnings = check(content);
2066 assert_eq!(warnings.len(), 1);
2067 assert_eq!(warnings[0].line, 4);
2068 assert!(warnings[0].message.contains("4 spaces"));
2069 assert!(warnings[0].message.contains("found 3"));
2070 assert_eq!(fix(content), "- Outer\n - Inner\n\n continuation\n");
2071 }
2072
2073 #[test]
2074 fn loose_over_indent_attributes_to_deepest_enclosing_item() {
2075 let content = "- Outer\n - Inner\n\n continuation\n";
2079 let warnings = check(content);
2080 assert_eq!(warnings.len(), 1);
2081 assert_eq!(warnings[0].line, 4);
2082 assert!(warnings[0].message.contains("expected 4"));
2083 assert!(warnings[0].message.contains("found 5"));
2084 assert_eq!(fix(content), "- Outer\n - Inner\n\n continuation\n");
2085 }
2086
2087 #[test]
2096 fn loose_over_indented_fence_not_flagged() {
2097 let content = "- Item\n\n ```\n code\n ```\n";
2098 assert!(check(content).is_empty());
2099 assert_eq!(fix(content), content);
2100 }
2101
2102 #[test]
2103 fn tight_over_indented_fence_not_flagged() {
2104 let content = "- Item\n ```\n code\n ```\n";
2105 assert!(check(content).is_empty());
2106 assert_eq!(fix(content), content);
2107 }
2108
2109 #[test]
2110 fn over_indented_tilde_fence_not_flagged() {
2111 let content = "- Item\n\n ~~~\n code\n ~~~\n";
2112 assert!(check(content).is_empty());
2113 assert_eq!(fix(content), content);
2114 }
2115
2116 #[test]
2117 fn fence_like_code_content_inside_fenced_block_not_flagged() {
2118 let content = "- Item\n\n ~~~\n ```\n ~~~\n";
2121 assert!(check(content).is_empty());
2122 assert_eq!(fix(content), content);
2123 }
2124
2125 #[test]
2126 fn unterminated_over_indented_fence_not_flagged() {
2127 let content = "- Item\n\n ```\n code1\n code2deeper\n";
2130 assert!(check(content).is_empty());
2131 assert_eq!(fix(content), content);
2132 }
2133
2134 #[test]
2142 fn task_list_tight_continuation_post_checkbox_reproducer_579() {
2143 let content = "- [ ] Lorem ipsum dolor sit amet, consectetur adipiscing\n tempor incididunt ut labore.\n";
2146 assert!(check(content).is_empty());
2147 }
2148
2149 #[test]
2150 fn task_list_tight_continuation_dash_unchecked() {
2151 let content = "- [ ] Task\n continuation\n";
2152 assert!(check(content).is_empty());
2153 }
2154
2155 #[test]
2156 fn task_list_tight_continuation_dash_checked_lower() {
2157 let content = "- [x] Task\n continuation\n";
2158 assert!(check(content).is_empty());
2159 }
2160
2161 #[test]
2162 fn task_list_tight_continuation_dash_checked_upper() {
2163 let content = "- [X] Task\n continuation\n";
2164 assert!(check(content).is_empty());
2165 }
2166
2167 #[test]
2168 fn task_list_tight_continuation_star_marker() {
2169 let content = "* [ ] Task\n continuation\n";
2170 assert!(check(content).is_empty());
2171 }
2172
2173 #[test]
2174 fn task_list_tight_continuation_plus_marker() {
2175 let content = "+ [ ] Task\n continuation\n";
2176 assert!(check(content).is_empty());
2177 }
2178
2179 #[test]
2180 fn task_list_tight_continuation_content_column_still_valid() {
2181 let content = "- [ ] Task\n continuation\n";
2184 assert!(check(content).is_empty());
2185 }
2186
2187 #[test]
2188 fn task_list_tight_continuation_between_columns_still_flagged() {
2189 let content = "- [ ] Task\n continuation\n";
2192 let warnings = check(content);
2193 assert_eq!(warnings.len(), 1);
2194 assert!(warnings[0].message.contains("expected 2 or 6"));
2196 assert!(warnings[0].message.contains("found 4"));
2197 }
2198
2199 #[test]
2200 fn task_list_tight_continuation_overshoot_still_flagged() {
2201 let content = "- [ ] Task\n continuation\n";
2203 let warnings = check(content);
2204 assert_eq!(warnings.len(), 1);
2205 assert!(warnings[0].message.contains("expected 2 or 6"));
2206 assert!(warnings[0].message.contains("found 7"));
2207 }
2208
2209 #[test]
2212 fn fix_task_list_overshoot_snaps_to_task_col() {
2213 let content = "- [ ] Task\n continuation\n";
2217 let fixed = fix(content);
2218 assert_eq!(fixed, "- [ ] Task\n continuation\n");
2219 }
2220
2221 #[test]
2222 fn fix_task_list_col_5_snaps_to_task_col() {
2223 let content = "- [ ] Task\n continuation\n";
2225 let fixed = fix(content);
2226 assert_eq!(fixed, "- [ ] Task\n continuation\n");
2227 }
2228
2229 #[test]
2230 fn fix_task_list_col_3_snaps_to_content_col() {
2231 let content = "- [ ] Task\n continuation\n";
2233 let fixed = fix(content);
2234 assert_eq!(fixed, "- [ ] Task\n continuation\n");
2235 }
2236
2237 #[test]
2238 fn fix_task_list_col_4_ties_to_content_col() {
2239 let content = "- [ ] Task\n continuation\n";
2244 let fixed = fix(content);
2245 assert_eq!(fixed, "- [ ] Task\n continuation\n");
2246 }
2247
2248 #[test]
2249 fn fix_task_list_ordered_overshoot_snaps_to_task_col() {
2250 let content = "1. [ ] Task\n continuation\n";
2253 let fixed = fix(content);
2254 assert_eq!(fixed, "1. [ ] Task\n continuation\n");
2255 }
2256
2257 #[test]
2258 fn fix_task_list_ordered_under_overshoot_snaps_to_content_col() {
2259 let content = "1. [ ] Task\n continuation\n";
2262 let fixed = fix(content);
2263 assert_eq!(fixed, "1. [ ] Task\n continuation\n");
2264 }
2265
2266 #[test]
2267 fn task_list_tight_continuation_ordered_single_digit() {
2268 let content = "1. [ ] Task\n continuation\n";
2270 assert!(check(content).is_empty());
2271 }
2272
2273 #[test]
2274 fn task_list_tight_continuation_ordered_multi_digit() {
2275 let content = "10. [ ] Task\n continuation\n";
2277 assert!(check(content).is_empty());
2278 }
2279
2280 #[test]
2281 fn task_list_tight_continuation_nested_dash() {
2282 let content = "- Parent\n - [ ] Nested task\n continuation\n";
2284 assert!(check(content).is_empty());
2285 }
2286
2287 #[test]
2288 fn task_list_loose_continuation_post_checkbox_column_not_flagged() {
2289 let content = "- [ ] Task\n\n continuation\n";
2294 assert!(check(content).is_empty());
2295 }
2296
2297 #[test]
2298 fn task_list_empty_body_is_not_a_task() {
2299 let content = "- [ ]\n continuation\n";
2305 let warnings = check(content);
2306 assert_eq!(warnings.len(), 1);
2307 assert!(warnings[0].message.contains("found 4"));
2308 }
2309
2310 #[test]
2311 fn task_list_malformed_checkbox_is_not_a_task() {
2312 let content = "- [~] Not a task\n continuation\n";
2314 let warnings = check(content);
2315 assert_eq!(warnings.len(), 1);
2316 }
2317
2318 #[test]
2325 fn task_list_mkdocs_unordered_required_min_valid() {
2326 let content = "- [ ] Task\n continuation\n";
2328 assert!(check_mkdocs(content).is_empty());
2329 }
2330
2331 #[test]
2332 fn task_list_mkdocs_unordered_post_checkbox_valid() {
2333 let content = "- [ ] Task\n continuation\n";
2334 assert!(check_mkdocs(content).is_empty());
2335 }
2336
2337 #[test]
2338 fn task_list_mkdocs_unordered_between_flagged() {
2339 let content = "- [ ] Task\n continuation\n";
2341 let warnings = check_mkdocs(content);
2342 assert_eq!(warnings.len(), 1);
2343 }
2344
2345 #[test]
2346 fn task_list_mkdocs_ordered_both_columns_valid() {
2347 let at_4 = "1. [ ] Task\n continuation\n";
2349 assert!(check_mkdocs(at_4).is_empty());
2350 let at_7 = "1. [ ] Task\n continuation\n";
2351 assert!(check_mkdocs(at_7).is_empty());
2352 }
2353
2354 #[test]
2355 fn task_list_mkdocs_ordered_between_flagged() {
2356 let at_5 = "1. [ ] Task\n continuation\n";
2358 assert_eq!(check_mkdocs(at_5).len(), 1);
2359 let at_6 = "1. [ ] Task\n continuation\n";
2360 assert_eq!(check_mkdocs(at_6).len(), 1);
2361 }
2362
2363 #[test]
2373 fn fix_task_list_tie_sibling_at_task_col_snaps_to_task_col() {
2374 let content = "- [ ] Task\n aligned continuation\n tied continuation\n";
2378 let fixed = fix(content);
2379 assert_eq!(
2380 fixed,
2381 "- [ ] Task\n aligned continuation\n tied continuation\n"
2382 );
2383 }
2384
2385 #[test]
2386 fn fix_task_list_tie_sibling_at_content_col_snaps_to_content_col() {
2387 let content = "- [ ] Task\n aligned continuation\n tied continuation\n";
2390 let fixed = fix(content);
2391 assert_eq!(fixed, "- [ ] Task\n aligned continuation\n tied continuation\n");
2392 }
2393
2394 #[test]
2395 fn fix_task_list_tie_both_siblings_snaps_to_content_col() {
2396 let content = "- [ ] Task\n at content col\n at task col\n tied continuation\n";
2400 let fixed = fix(content);
2401 assert_eq!(
2402 fixed,
2403 "- [ ] Task\n at content col\n at task col\n tied continuation\n"
2404 );
2405 }
2406
2407 #[test]
2408 fn fix_task_list_tie_sees_task_col_through_tight_lazy_continuation() {
2409 let content = concat!("- [ ] Task\n", "lazy\n", " aligned at task col\n", " tied\n",);
2423 let fixed = fix(content);
2424 assert!(
2425 fixed.contains("\n tied\n"),
2426 "tied line should snap to col 6 (task col) because a task-col \
2427 sibling is visible past the tight lazy-continuation line; got:\n{fixed}"
2428 );
2429 }
2430
2431 #[test]
2438 fn task_list_tab_indented_continuation_flagged() {
2439 let content = "- [ ] Task\n\t\twrap\n";
2442 let warnings = check(content);
2443 assert_eq!(warnings.len(), 1);
2444 assert!(warnings[0].message.contains("expected 2 or 6"));
2445 assert!(warnings[0].message.contains("found 8"));
2446 }
2447
2448 #[test]
2449 fn fix_task_list_tab_indented_snaps_to_task_col() {
2450 let content = "- [ ] Task\n\t\twrap\n";
2452 let fixed = fix(content);
2453 assert_eq!(fixed, "- [ ] Task\n wrap\n");
2454 }
2455
2456 #[test]
2457 fn fix_task_list_single_tab_equidistant_snaps_to_content_col() {
2458 let content = "- [ ] Task\n\twrap\n";
2461 let fixed = fix(content);
2462 assert_eq!(fixed, "- [ ] Task\n wrap\n");
2463 }
2464
2465 #[test]
2475 fn task_list_blockquote_post_checkbox_not_flagged() {
2476 let content = "> - [ ] Task\n> continuation\n";
2478 assert!(check(content).is_empty());
2479 }
2480
2481 #[test]
2482 fn task_list_blockquote_between_cols_documented_limitation() {
2483 let content = "> - [ ] Task\n> continuation\n";
2487 assert!(check(content).is_empty());
2488 }
2489
2490 #[test]
2491 fn task_list_blockquote_overshoot_documented_limitation() {
2492 let content = "> - [ ] Task\n> continuation\n";
2494 assert!(check(content).is_empty());
2495 }
2496
2497 #[test]
2504 fn fix_task_list_mkdocs_unordered_overshoot_snaps_to_task_col() {
2505 let content = "- [ ] Task\n continuation\n";
2508 let fixed = fix_mkdocs(content);
2509 assert_eq!(fixed, "- [ ] Task\n continuation\n");
2510 }
2511
2512 #[test]
2513 fn fix_task_list_mkdocs_unordered_tie_snaps_to_required() {
2514 let content = "- [ ] Task\n continuation\n";
2517 let fixed = fix_mkdocs(content);
2518 assert_eq!(fixed, "- [ ] Task\n continuation\n");
2519 }
2520
2521 #[test]
2522 fn fix_task_list_mkdocs_ordered_overshoot_snaps_to_task_col() {
2523 let content = "1. [ ] Task\n continuation\n";
2526 let fixed = fix_mkdocs(content);
2527 assert_eq!(fixed, "1. [ ] Task\n continuation\n");
2528 }
2529
2530 #[test]
2531 fn fix_task_list_mkdocs_ordered_near_required_snaps_to_required() {
2532 let content = "1. [ ] Task\n continuation\n";
2538 let fixed = fix_mkdocs(content);
2539 assert_eq!(fixed, "1. [ ] Task\n continuation\n");
2540 }
2541
2542 #[test]
2543 fn fix_task_list_mkdocs_ordered_between_cols_snaps_to_task_col() {
2544 let content = "1. [ ] Task\n continuation\n";
2547 let fixed = fix_mkdocs(content);
2548 assert_eq!(fixed, "1. [ ] Task\n continuation\n");
2549 }
2550
2551 fn assert_idempotent(content: &str) {
2561 let once = fix(content);
2562 let twice = fix(&once);
2563 assert_eq!(once, twice, "MD077 fix was not idempotent on input: {content:?}");
2564 }
2565
2566 fn assert_idempotent_mkdocs(content: &str) {
2567 let once = fix_mkdocs(content);
2568 let twice = fix_mkdocs(&once);
2569 assert_eq!(
2570 once, twice,
2571 "MD077 (MkDocs) fix was not idempotent on input: {content:?}"
2572 );
2573 }
2574
2575 #[test]
2576 fn idempotent_task_list_between_cols() {
2577 assert_idempotent("- [ ] Task\n continuation\n");
2578 }
2579
2580 #[test]
2581 fn idempotent_task_list_overshoot() {
2582 assert_idempotent("- [ ] Task\n continuation\n");
2583 }
2584
2585 #[test]
2586 fn idempotent_task_list_under_post_checkbox() {
2587 assert_idempotent("- [ ] Task\n continuation\n");
2588 }
2589
2590 #[test]
2591 fn idempotent_task_list_near_post_checkbox() {
2592 assert_idempotent("- [ ] Task\n continuation\n");
2593 }
2594
2595 #[test]
2596 fn idempotent_task_list_tab_overshoot() {
2597 assert_idempotent("- [ ] Task\n\t\twrap\n");
2598 }
2599
2600 #[test]
2601 fn idempotent_task_list_single_tab() {
2602 assert_idempotent("- [ ] Task\n\twrap\n");
2603 }
2604
2605 #[test]
2606 fn idempotent_task_list_ordered_overshoot() {
2607 assert_idempotent("1. [ ] Task\n continuation\n");
2608 }
2609
2610 #[test]
2611 fn idempotent_task_list_ordered_under() {
2612 assert_idempotent("1. [ ] Task\n continuation\n");
2613 }
2614
2615 #[test]
2616 fn idempotent_task_list_tie_with_sibling_at_task_col() {
2617 assert_idempotent("- [ ] Task\n aligned\n tied\n");
2618 }
2619
2620 #[test]
2621 fn idempotent_task_list_tie_with_sibling_at_content_col() {
2622 assert_idempotent("- [ ] Task\n aligned\n tied\n");
2623 }
2624
2625 #[test]
2626 fn idempotent_task_list_mkdocs_unordered_overshoot() {
2627 assert_idempotent_mkdocs("- [ ] Task\n continuation\n");
2628 }
2629
2630 #[test]
2631 fn idempotent_task_list_mkdocs_unordered_tie() {
2632 assert_idempotent_mkdocs("- [ ] Task\n continuation\n");
2633 }
2634
2635 #[test]
2636 fn idempotent_task_list_mkdocs_ordered_overshoot() {
2637 assert_idempotent_mkdocs("1. [ ] Task\n continuation\n");
2638 }
2639
2640 #[test]
2641 fn idempotent_task_list_mkdocs_ordered_between() {
2642 assert_idempotent_mkdocs("1. [ ] Task\n continuation\n");
2643 }
2644
2645 #[test]
2646 fn idempotent_task_list_reproducer_579() {
2647 assert_idempotent(
2651 "- [ ] Lorem ipsum dolor sit amet, consectetur adipiscing\n tempor incididunt ut labore.\n",
2652 );
2653 }
2654
2655 #[test]
2656 fn idempotent_non_task_list_still_holds() {
2657 assert_idempotent("1. Item\n over-indented\n");
2660 assert_idempotent("- Item\n\n continuation\n");
2661 }
2662
2663 #[test]
2670 fn idempotent_non_task_loose_under_indent_ordered() {
2671 assert_idempotent("1. Item\n\n continuation\n");
2673 }
2674
2675 #[test]
2676 fn idempotent_non_task_loose_under_indent_multi_digit() {
2677 assert_idempotent("10. Item\n\n continuation\n");
2679 }
2680
2681 #[test]
2682 fn idempotent_non_task_tight_over_indent_ordered() {
2683 assert_idempotent("1. Item\n over-indented\n");
2685 }
2686
2687 #[test]
2695 fn idempotent_non_task_fence_ordered_loose() {
2696 assert_idempotent("1. Item\n\n ```rust\n let x = 1;\n ```\n");
2698 }
2699
2700 #[test]
2701 fn idempotent_non_task_fence_tilde_under_indent() {
2702 assert_idempotent("1. Item\n\n ~~~\nplain text\n ~~~\n");
2708 }
2709
2710 #[test]
2711 fn idempotent_non_task_fence_interior_above_required() {
2712 assert_idempotent("1. Item\n\n ```\n deeply indented code\n ```\n");
2716 }
2717
2718 #[test]
2719 fn fence_fix_promotes_interior_below_scope_in_single_pass() {
2720 let content = "1. Item\n\n ```\ncode\n ```\n";
2724 let fixed = fix(content);
2725 assert_eq!(fixed, "1. Item\n\n ```\n code\n ```\n");
2726 }
2727
2728 #[test]
2729 fn fence_fix_preserves_interior_offset_from_the_fence() {
2730 let content = "1. Item\n\n ```\n code\n ```\n";
2735 let fixed = fix(content);
2736 assert_eq!(fixed, "1. Item\n\n ```\n code\n ```\n");
2737 }
2738
2739 #[test]
2746 fn idempotent_non_task_mkdocs_ordered_at_3_spaces() {
2747 assert_idempotent_mkdocs("1. Item\n\n continuation\n");
2749 }
2750
2751 #[test]
2752 fn idempotent_non_task_mkdocs_unordered_at_2_spaces() {
2753 assert_idempotent_mkdocs("- Item\n\n continuation\n");
2755 }
2756
2757 #[test]
2758 fn idempotent_non_task_mkdocs_fence_compound() {
2759 assert_idempotent_mkdocs("1. Item\n\n ```toml\n k = 1\n ```\n");
2761 }
2762
2763 #[test]
2766 fn aligned_tight_zero_indent_continuation_flagged() {
2767 let content = "- this is a long line\nthat continues on a second line\n";
2771 let warnings = check_aligned(content);
2772 assert_eq!(warnings.len(), 1);
2773 assert_eq!(warnings[0].line, 2);
2774 assert_eq!(
2775 fix_aligned(content),
2776 "- this is a long line\n that continues on a second line\n"
2777 );
2778 }
2779
2780 #[test]
2781 fn aligned_full_issue_example_made_consistent() {
2782 let content = "- this is a long line\n\
2785 that continues on a second line\n\
2786 - this is another long line\n\
2787 \x20\x20that continues on the next line\n\
2788 - yet again a long line\n\
2789 and still inconsistently spaced\n\
2790 \x20\x20and even worse\n";
2791 let expected = "- this is a long line\n\
2792 \x20\x20that continues on a second line\n\
2793 - this is another long line\n\
2794 \x20\x20that continues on the next line\n\
2795 - yet again a long line\n\
2796 \x20\x20and still inconsistently spaced\n\
2797 \x20\x20and even worse\n";
2798 assert_eq!(fix_aligned(content), expected);
2799 assert_eq!(fix_aligned(expected), expected);
2801 }
2802
2803 #[test]
2804 fn aligned_already_aligned_not_flagged() {
2805 let content = "- item\n continuation at content column\n";
2806 assert!(check_aligned(content).is_empty());
2807 }
2808
2809 #[test]
2810 fn aligned_tight_partial_indent_flagged() {
2811 let content = "- item\n continuation\n";
2813 let warnings = check_aligned(content);
2814 assert_eq!(warnings.len(), 1);
2815 assert_eq!(fix_aligned(content), "- item\n continuation\n");
2816 }
2817
2818 #[test]
2819 fn aligned_post_blank_zero_indent_still_new_paragraph() {
2820 let content = "- item\n\nnew paragraph\n";
2823 assert!(check_aligned(content).is_empty());
2824 assert_eq!(fix_aligned(content), content);
2825 }
2826
2827 #[test]
2830 fn aligned_top_level_blockquote_after_list_untouched() {
2831 let content = "- item\n> quote\n";
2835 assert!(check_aligned(content).is_empty());
2836 assert_eq!(fix_aligned(content), content);
2837 }
2838
2839 #[test]
2840 fn aligned_top_level_fence_after_list_untouched() {
2841 let content = "- item\n```\ncode\n```\n";
2842 assert!(check_aligned(content).is_empty());
2843 assert_eq!(fix_aligned(content), content);
2844 }
2845
2846 #[test]
2847 fn aligned_top_level_table_after_list_untouched() {
2848 let content = "- item\n| a | b |\n|---|---|\n| 1 | 2 |\n";
2849 assert!(check_aligned(content).is_empty());
2850 assert_eq!(fix_aligned(content), content);
2851 }
2852
2853 #[test]
2856 fn aligned_nested_tight_lazy_continuation_aligns_to_inner() {
2857 let content = "- Outer\n - Inner\ncontinuation\n";
2862 let warnings = check_aligned(content);
2863 assert_eq!(warnings.len(), 1);
2864 assert_eq!(fix_aligned(content), "- Outer\n - Inner\n continuation\n");
2865 }
2866
2867 #[test]
2868 fn aligned_nested_continuation_already_aligned_not_flagged() {
2869 let content = "- L1\n - L2\n cont of L2 at 4\n";
2870 assert!(check_aligned(content).is_empty());
2871 }
2872
2873 #[test]
2874 fn aligned_nested_idempotent() {
2875 let content = "- Outer\n - Inner\ncontinuation\n";
2876 let once = fix_aligned(content);
2877 assert_eq!(fix_aligned(&once), once);
2878 }
2879
2880 #[test]
2881 fn aligned_three_level_nesting_aligns_to_innermost() {
2882 let content = "- L1\n - L2\n - L3\ncont\n";
2885 assert_eq!(fix_aligned(content), "- L1\n - L2\n - L3\n cont\n");
2886 }
2887
2888 #[test]
2889 fn aligned_continuation_after_sibling_owned_by_last_item() {
2890 let content = "- a\n- b\nlazy\n";
2893 assert_eq!(fix_aligned(content), "- a\n- b\n lazy\n");
2894 }
2895
2896 #[test]
2897 fn aligned_multi_digit_ordered_marker_aligns_to_content_column() {
2898 let content = "10. Item\nwrap\n";
2899 assert_eq!(fix_aligned(content), "10. Item\n wrap\n");
2900 }
2901
2902 #[test]
2903 fn aligned_latent_setext_underline_is_left_alone() {
2904 let content = "- item\nText\n===\n";
2909 assert!(check_aligned(content).is_empty());
2910 assert_eq!(fix_aligned(content), content);
2911 }
2912
2913 #[test]
2914 fn aligned_reindents_prose_that_only_looks_like_an_underline() {
2915 let content = "- item\nText\n= = =\n";
2918 assert_eq!(fix_aligned(content), "- item\n Text\n = = =\n");
2919 }
2920
2921 #[test]
2922 fn aligned_latent_marker_in_continuation_is_idempotent() {
2923 let content = "# \n- \n``\n2. \n![]()";
2929 let once = fix_aligned(content);
2930 assert_eq!(fix_aligned(&once), once, "fix must be idempotent in one pass");
2931 assert_eq!(once, content, "item with a latent marker is left untouched");
2932 }
2933
2934 #[test]
2935 fn aligned_latent_table_in_continuation_is_idempotent() {
2936 let content = "- \n![`]()\n| | ` |\n| --- | --- |";
2941 let once = fix_aligned(content);
2942 assert_eq!(fix_aligned(&once), once, "fix must be idempotent in one pass");
2943 assert_eq!(once, content, "item with a latent table is left untouched");
2944 }
2945
2946 #[test]
2947 fn aligned_blockquote_nested_list_not_touched() {
2948 let content = "> - item\n> wrap\n";
2952 assert!(check_aligned(content).is_empty());
2953 assert_eq!(fix_aligned(content), content);
2954 }
2955
2956 #[test]
2959 fn aligned_task_post_checkbox_column_accepted() {
2960 let content = "- [ ] Task\n wrap\n";
2963 assert!(check_aligned(content).is_empty());
2964 assert_eq!(fix_aligned(content), content);
2965 }
2966
2967 #[test]
2968 fn aligned_task_under_indent_snaps_to_content_column() {
2969 let content = "- [ ] Task\nwrap\n";
2970 let warnings = check_aligned(content);
2971 assert_eq!(warnings.len(), 1);
2972 assert_eq!(fix_aligned(content), "- [ ] Task\n wrap\n");
2973 }
2974
2975 #[test]
2978 fn aligned_mkdocs_tight_under_indent_snaps_to_four() {
2979 let content = "- item\nwrap\n";
2981 let warnings = check_aligned_mkdocs(content);
2982 assert_eq!(warnings.len(), 1);
2983 let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
2984 assert_eq!(aligned_rule().fix(&ctx).unwrap(), "- item\n wrap\n");
2985 }
2986
2987 #[test]
2990 fn any_default_does_not_flag_tight_lazy_continuation() {
2991 let content = "- item\nwrapped at zero indent\n";
2993 assert!(check(content).is_empty());
2994 assert_eq!(fix(content), content);
2995 }
2996
2997 #[test]
2998 fn from_config_aligned_enables_tight_flagging() {
2999 let mut config = crate::config::Config::default();
3001 let mut rule_config = crate::config::RuleConfig::default();
3002 rule_config
3003 .values
3004 .insert("style".to_string(), toml::Value::String("aligned".to_string()));
3005 config.rules.insert("MD077".to_string(), rule_config);
3006
3007 let rule = MD077ListContinuationIndent::from_config(&config);
3008 let ctx = LintContext::new("- item\nwrap\n", MarkdownFlavor::Standard, None);
3009 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
3010 }
3011
3012 #[test]
3013 fn from_config_default_is_any() {
3014 let config = crate::config::Config::default();
3016 let rule = MD077ListContinuationIndent::from_config(&config);
3017 let ctx = LintContext::new("- item\nwrap\n", MarkdownFlavor::Standard, None);
3018 assert!(rule.check(&ctx).unwrap().is_empty());
3019 }
3020
3021 #[test]
3022 fn from_config_indent_sets_fixed_requirement() {
3023 let mut config = crate::config::Config::default();
3026 let mut rule_config = crate::config::RuleConfig::default();
3027 rule_config.values.insert("indent".to_string(), toml::Value::Integer(4));
3028 config.rules.insert("MD077".to_string(), rule_config);
3029
3030 let rule = MD077ListContinuationIndent::from_config(&config);
3031
3032 let ok_ctx = LintContext::new("- item\n wrap\n", MarkdownFlavor::Standard, None);
3034 assert!(rule.check(&ok_ctx).unwrap().is_empty());
3035 assert_eq!(rule.fix(&ok_ctx).unwrap(), "- item\n wrap\n");
3036
3037 let bad_ctx = LintContext::new("- item\n\n wrap\n", MarkdownFlavor::Standard, None);
3040 let warnings = rule.check(&bad_ctx).unwrap();
3041 assert_eq!(warnings.len(), 1);
3042 assert!(warnings[0].message.contains("needs 4 spaces"));
3043 }
3044
3045 #[test]
3046 fn from_config_indent_applies_per_nested_marker() {
3047 let mut config = crate::config::Config::default();
3050 let mut rule_config = crate::config::RuleConfig::default();
3051 rule_config.values.insert("indent".to_string(), toml::Value::Integer(4));
3052 config.rules.insert("MD077".to_string(), rule_config);
3053
3054 let rule = MD077ListContinuationIndent::from_config(&config);
3055 let ctx = LintContext::new("- a\n - b\n wrap\n", MarkdownFlavor::Standard, None);
3056 let warnings = rule.check(&ctx).unwrap();
3057 assert!(
3058 warnings.is_empty(),
3059 "continuation at 6 spaces should pass: {warnings:?}"
3060 );
3061 }
3062
3063 fn rule_with(settings: &[(&str, toml::Value)]) -> Box<dyn Rule> {
3065 let mut config = crate::config::Config::default();
3066 let mut rule_config = crate::config::RuleConfig::default();
3067 for (key, value) in settings {
3068 rule_config.values.insert((*key).to_string(), value.clone());
3069 }
3070 config.rules.insert("MD077".to_string(), rule_config);
3071 MD077ListContinuationIndent::from_config(&config)
3072 }
3073
3074 #[test]
3075 fn configured_indent_cannot_lower_the_strict_flavor_minimum() {
3076 let rule = rule_with(&[("indent", toml::Value::Integer(2))]);
3080
3081 let two = LintContext::new("- item\n\n wrap\n", MarkdownFlavor::MkDocs, None);
3082 let warnings = rule.check(&two).unwrap();
3083 assert_eq!(warnings.len(), 1, "2 spaces is below the MkDocs minimum: {warnings:?}");
3084 assert!(
3085 warnings[0].message.contains("needs 4 spaces") && warnings[0].message.contains("MkDocs"),
3086 "the requirement comes from MkDocs, so the message must say so: {}",
3087 warnings[0].message
3088 );
3089 assert_eq!(rule.fix(&two).unwrap(), "- item\n\n wrap\n");
3090
3091 let four = LintContext::new("- item\n\n wrap\n", MarkdownFlavor::MkDocs, None);
3093 assert!(rule.check(&four).unwrap().is_empty());
3094
3095 let standard = LintContext::new("- item\n\n wrap\n", MarkdownFlavor::Standard, None);
3099 assert_eq!(rule.check(&standard).unwrap().len(), 1);
3100 assert_eq!(rule.fix(&standard).unwrap(), "- item\n\n wrap\n");
3101 }
3102
3103 #[test]
3104 fn configured_indent_can_raise_the_strict_flavor_minimum() {
3105 let rule = rule_with(&[("indent", toml::Value::Integer(6))]);
3108 let ctx = LintContext::new("- item\n\n wrap\n", MarkdownFlavor::MkDocs, None);
3109 let warnings = rule.check(&ctx).unwrap();
3110 assert_eq!(warnings.len(), 1);
3111 assert!(
3112 warnings[0].message.contains("needs 6 spaces"),
3113 "configured 6 must win over the 4-space floor: {}",
3114 warnings[0].message
3115 );
3116 assert_eq!(rule.fix(&ctx).unwrap(), "- item\n\n wrap\n");
3117 }
3118
3119 #[test]
3120 fn configured_indent_message_does_not_claim_a_structural_consequence() {
3121 let rule = rule_with(&[("indent", toml::Value::Integer(4))]);
3125 let ctx = LintContext::new("- item\n\n wrap\n", MarkdownFlavor::Standard, None);
3126 let warnings = rule.check(&ctx).unwrap();
3127 assert_eq!(warnings.len(), 1);
3128 assert!(
3129 warnings[0].message.contains("match the configured indent"),
3130 "expected the configured-indent wording, got: {}",
3131 warnings[0].message
3132 );
3133 assert!(
3134 !warnings[0].message.contains("remain part of the list"),
3135 "the content does remain part of the list here: {}",
3136 warnings[0].message
3137 );
3138
3139 assert!(check("- item\n\n wrap\n").is_empty());
3142
3143 let escaping = check("- item\n\n wrap\n");
3146 assert_eq!(escaping.len(), 1);
3147 assert!(
3148 escaping[0].message.contains("remain part of the list"),
3149 "unconfigured under-indent keeps its structural message, got: {}",
3150 escaping[0].message
3151 );
3152 }
3153
3154 #[test]
3155 fn configured_indent_leaves_tight_lazy_continuation_to_style() {
3156 let any = rule_with(&[("indent", toml::Value::Integer(4))]);
3160 let ctx = LintContext::new("- item\n wrap\n", MarkdownFlavor::Standard, None);
3161 assert!(
3162 any.check(&ctx).unwrap().is_empty(),
3163 "style = any accepts tight lazy continuation"
3164 );
3165
3166 let aligned = rule_with(&[
3167 ("indent", toml::Value::Integer(4)),
3168 ("style", toml::Value::String("aligned".to_string())),
3169 ]);
3170 let warnings = aligned.check(&ctx).unwrap();
3171 assert_eq!(warnings.len(), 1, "style = aligned raises it: {warnings:?}");
3172 assert!(warnings[0].message.contains("expected 4"));
3173 assert_eq!(aligned.fix(&ctx).unwrap(), "- item\n wrap\n");
3174 }
3175
3176 #[test]
3177 fn aligned_tight_underindented_fence_inside_item_left_alone() {
3178 let content = "- item\n ```\n code\n ```\n";
3182 assert!(check_aligned(content).is_empty());
3183 assert_eq!(fix_aligned(content), content);
3184 }
3185
3186 #[test]
3187 fn aligned_task_under_indent_fix_is_idempotent() {
3188 let content = "- [ ] Task\nwrap\n";
3189 let once = fix_aligned(content);
3190 assert_eq!(fix_aligned(&once), once);
3191 }
3192
3193 #[test]
3194 fn aligned_partial_indent_fix_is_idempotent() {
3195 let content = "- item\n continuation\n";
3196 let once = fix_aligned(content);
3197 assert_eq!(fix_aligned(&once), once);
3198 }
3199
3200 #[test]
3201 fn fence_shift_preserves_one_space_of_interior_nesting() {
3202 let content = "1. Configure:\n\n ```json\n {\n \"a\": 1\n }\n ```\n";
3205 let expected = "1. Configure:\n\n ```json\n {\n \"a\": 1\n }\n ```\n";
3206 assert_eq!(fix(content), expected);
3207 }
3208
3209 #[test]
3210 fn fence_shift_preserves_every_interior_nesting_level() {
3211 let content = "1. Configure:\n\n ```json\n {\n \"a\": {\n \"b\": 1\n }\n }\n ```\n";
3212 let expected = "1. Configure:\n\n ```json\n {\n \"a\": {\n \"b\": 1\n }\n }\n ```\n";
3213 assert_eq!(fix(content), expected);
3214 }
3215
3216 #[test]
3217 fn fence_shift_lifts_interior_below_the_list_scope_all_the_way() {
3218 let content = "1. Configure:\n\n ```json\n{\n ```\n";
3222 let expected = "1. Configure:\n\n ```json\n {\n ```\n";
3223 assert_eq!(fix(content), expected);
3224 assert_eq!(fix(expected), expected, "and the result is stable");
3225 }
3226
3227 #[test]
3228 fn fence_shift_is_idempotent() {
3229 for content in [
3230 "1. Configure:\n\n ```json\n {\n \"a\": 1\n }\n ```\n",
3231 "1. Configure:\n\n ```json\n{\n deep\n }\n ```\n",
3232 "- item\n\n ```\n nested\n ```\n",
3233 ] {
3234 let once = fix(content);
3235 assert_eq!(fix(&once), once, "MD077 fence fix must be idempotent: {content:?}");
3236 }
3237 }
3238
3239 #[test]
3240 fn fence_already_at_the_content_column_is_left_alone() {
3241 let content = "1. Configure:\n\n ```json\n {\n \"a\": 1\n }\n ```\n";
3242 assert!(check(content).is_empty());
3243 assert_eq!(fix(content), content);
3244 }
3245
3246 #[test]
3247 fn over_indented_fence_keeps_its_interior_untouched() {
3248 let content = "1. Configure:\n\n ```json\n {\n \"a\": 1\n }\n ```\n";
3250 assert_eq!(fix(content), content);
3251 }
3252}