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 {
359 (item_line + 1..=range_end).any(|line_num| {
360 ctx.line_info(line_num).is_some_and(|info| {
361 if info.is_blank || info.list_item.is_some() {
362 return false;
363 }
364 let trimmed = info.content(ctx.content).trim_start();
365 !Self::should_skip_line(info, trimmed)
366 && (Self::starts_with_list_marker(trimmed) || crate::utils::skip_context::is_table_line(trimmed))
367 })
368 })
369 }
370
371 fn sibling_column_usage(
381 ctx: &LintContext,
382 item_line: usize,
383 range_end: usize,
384 marker_col: usize,
385 content_col: usize,
386 task_col: usize,
387 ) -> (bool, bool) {
388 let mut uses_content = false;
389 let mut uses_task = false;
390
391 Self::walk_item_continuation(ctx, item_line, range_end, marker_col, |line| {
392 if line.actual == content_col {
393 uses_content = true;
394 }
395 if line.actual == task_col {
396 uses_task = true;
397 }
398 if uses_content && uses_task {
399 ControlFlow::Break(())
400 } else {
401 ControlFlow::Continue(())
402 }
403 });
404
405 (uses_content, uses_task)
406 }
407
408 fn compute_fix_target(
414 actual: usize,
415 required: usize,
416 task_col: Option<usize>,
417 uses_content_col: bool,
418 uses_task_col: bool,
419 ) -> usize {
420 let Some(t) = task_col else { return required };
421 match actual.abs_diff(t).cmp(&actual.abs_diff(required)) {
422 std::cmp::Ordering::Less => t,
423 std::cmp::Ordering::Greater => required,
424 std::cmp::Ordering::Equal => match (uses_task_col, uses_content_col) {
425 (true, false) => t,
426 _ => required,
427 },
428 }
429 }
430
431 fn should_skip_line(info: &crate::lint_context::LineInfo, trimmed: &str) -> bool {
442 if info.in_code_block && !Self::is_code_fence(trimmed) {
443 return true;
444 }
445 info.in_front_matter
446 || info.in_footnote_definition
447 || info.in_html_block
448 || info.in_html_comment
449 || info.in_mdx_comment
450 || info.in_mkdocstrings
451 || info.in_esm_block
452 || info.in_math_block
453 || info.in_admonition
454 || info.in_content_tab
455 || info.in_pymdown_block
456 || info.in_definition_list
457 || info.in_mkdocs_html_markdown
458 || info.in_kramdown_extension_block
459 }
460
461 fn build_over_indent_warning(
470 ctx: &LintContext,
471 line: &ContinuationLine<'_>,
472 fix_target: usize,
473 message: String,
474 ) -> LintWarning {
475 let line_content = line.info.content(ctx.content);
476 let fix_start = line.info.byte_offset;
477 let fix_end = fix_start + line.info.indent;
478 LintWarning {
479 rule_name: Some("MD077".to_string()),
480 line: line.line_num,
481 column: 1,
482 end_line: line.line_num,
483 end_column: line_content.chars().count() + 1,
484 message,
485 severity: Severity::Warning,
486 fix: Some(Fix::new(fix_start..fix_end, " ".repeat(fix_target))),
487 }
488 }
489
490 fn build_under_indent_warning(
502 ctx: &LintContext,
503 line: &ContinuationLine<'_>,
504 required: usize,
505 message: String,
506 ) -> UnderIndentOutcome {
507 let line_content = line.info.content(ctx.content);
508 let is_fence_opener = line.info.in_code_block
509 && Self::is_code_fence(line.trimmed)
510 && ctx.line_info(line.line_num - 1).is_none_or(|p| !p.in_code_block);
511
512 let (fix, warn_end_line, warn_end_column, compound_closer) = if is_fence_opener {
513 let closer_line = Self::find_fence_closer(ctx, line.line_num);
514 let fix = Self::build_compound_fence_fix(ctx, line.line_num, closer_line, line.actual, required);
515 let end_column = ctx
516 .line_info(closer_line)
517 .map_or(line_content.chars().count() + 1, |ci| {
518 ci.content(ctx.content).chars().count() + 1
519 });
520 let extra_flag = (closer_line != line.line_num).then_some(closer_line);
521 (fix, closer_line, end_column, extra_flag)
522 } else {
523 let fix_start = line.info.byte_offset;
524 let fix_end = fix_start + line.info.indent;
525 let fix = Some(Fix::new(fix_start..fix_end, " ".repeat(required)));
526 (fix, line.line_num, line_content.chars().count() + 1, None)
527 };
528
529 UnderIndentOutcome {
530 warning: LintWarning {
531 rule_name: Some("MD077".to_string()),
532 line: line.line_num,
533 column: 1,
534 end_line: warn_end_line,
535 end_column: warn_end_column,
536 message,
537 severity: Severity::Warning,
538 fix,
539 },
540 also_flag_line: compound_closer,
541 }
542 }
543}
544
545struct ContinuationLine<'a> {
549 line_num: usize,
550 info: &'a LineInfo,
551 trimmed: &'a str,
552 actual: usize,
553 saw_blank: bool,
554 saw_nested: bool,
558}
559
560struct UnderIndentOutcome {
565 warning: LintWarning,
566 also_flag_line: Option<usize>,
567}
568
569impl Rule for MD077ListContinuationIndent {
570 fn name(&self) -> &'static str {
571 "MD077"
572 }
573
574 fn description(&self) -> &'static str {
575 "List continuation content indentation"
576 }
577
578 fn check(&self, ctx: &LintContext) -> LintResult {
579 if ctx.content.is_empty() {
580 return Ok(Vec::new());
581 }
582
583 let strict_indent = ctx.flavor.requires_strict_list_indent();
584 let total_lines = ctx.lines.len();
585 let mut warnings = Vec::new();
586 let mut flagged_lines = std::collections::HashSet::new();
587
588 let mut items: Vec<(usize, usize, usize, Option<usize>)> = Vec::new();
597 for block in &ctx.list_blocks {
598 for &item_line in &block.item_lines {
599 if let Some(info) = ctx.line_info(item_line)
600 && let Some(ref li) = info.list_item
601 {
602 if info.blockquote.is_some() {
609 continue;
610 }
611 let line = info.content(ctx.content);
612 let task_col = Self::is_task_list_item(line, li.content_column)
613 .then_some(li.content_column + Self::TASK_CHECKBOX_PREFIX_LEN);
614 items.push((item_line, li.marker_column, li.content_column, task_col));
615 }
616 }
617 }
618 items.sort_unstable();
619 items.dedup_by_key(|&mut (ln, _, _, _)| ln);
620
621 let mut range_ends = vec![total_lines; items.len()];
634 let mut stack: Vec<usize> = Vec::new();
635 for i in (0..items.len()).rev() {
636 let marker_col = items[i].1;
637 while let Some(&top) = stack.last() {
638 if items[top].1 > marker_col {
639 stack.pop();
640 } else {
641 break;
642 }
643 }
644 range_ends[i] = stack.last().map_or(total_lines, |&j| items[j].0 - 1);
645 stack.push(i);
646 }
647
648 let scoped: Vec<(usize, usize, usize, Option<usize>, usize, usize)> = items
651 .iter()
652 .enumerate()
653 .map(|(item_idx, &(item_line, marker_col, content_col, task_col))| {
654 let required = match self.config.indent {
662 Some(indent) if strict_indent => (marker_col + indent).max(4),
663 Some(indent) => marker_col + indent,
664 None if strict_indent => content_col.max(4),
665 None => content_col,
666 };
667 (
668 item_line,
669 marker_col,
670 content_col,
671 task_col,
672 required,
673 range_ends[item_idx],
674 )
675 })
676 .collect();
677
678 let prose_candidate_lines: Vec<usize> = (1..=total_lines)
692 .filter(|&line_num| {
693 let Some(info) = ctx.line_info(line_num) else {
694 return false;
695 };
696 let trimmed = info.content(ctx.content).trim_start();
697 !Self::should_skip_line(info, trimmed)
698 && !info.is_blank
699 && info.list_item.is_none()
700 && info.heading.is_none()
701 && !info.is_horizontal_rule
702 && !Self::is_block_level_construct(trimmed)
703 })
704 .collect();
705 let range_has_prose_candidate = |after_line: usize, range_end: usize| -> bool {
708 let start = prose_candidate_lines.partition_point(|&l| l <= after_line);
709 prose_candidate_lines.get(start).is_some_and(|&l| l <= range_end)
710 };
711
712 let aligned = self.config.style == ContinuationStyle::Aligned;
738 for &(item_line, marker_col, _content_col, _task_col, required, range_end) in &scoped {
739 if !range_has_prose_candidate(item_line, range_end) {
742 continue;
743 }
744 let has_latent_structure = aligned && Self::item_range_has_latent_structure(ctx, item_line, range_end);
759 let from_configured_indent = self.config.indent.is_some_and(|indent| marker_col + indent == required);
765 Self::walk_item_continuation(ctx, item_line, range_end, marker_col, |line| {
766 let actual = line.actual;
767 let under_indented = actual < required;
768 let loose_escape = line.saw_blank && under_indented;
769 let confirmed_structure = line.info.in_code_block || line.info.blockquote.is_some();
776 let aligned_tight = aligned
777 && !has_latent_structure
778 && !line.saw_blank
779 && !line.saw_nested
780 && under_indented
781 && !confirmed_structure;
782 if (loose_escape || aligned_tight) && flagged_lines.insert(line.line_num) {
783 let message = if line.saw_blank {
784 if from_configured_indent {
785 format!(
786 "Content after blank line in list item needs {required} spaces of \
787 indentation to match the configured indent (found {actual})",
788 )
789 } else if strict_indent {
790 format!(
791 "Content inside list item needs {required} spaces of indentation \
792 for MkDocs compatibility (found {actual})",
793 )
794 } else {
795 format!(
796 "Content after blank line in list item needs {required} spaces of \
797 indentation to remain part of the list (found {actual})",
798 )
799 }
800 } else {
801 format!("Continuation line under-indented (expected {required}, found {actual})")
802 };
803 let outcome = Self::build_under_indent_warning(ctx, line, required, message);
804 if let Some(closer_line) = outcome.also_flag_line {
805 flagged_lines.insert(closer_line);
806 }
807 warnings.push(outcome.warning);
808 }
809 ControlFlow::Continue(())
810 });
811 }
812
813 for &(item_line, marker_col, content_col, task_col, required, range_end) in &scoped {
822 if !range_has_prose_candidate(item_line, range_end) {
824 continue;
825 }
826 let (uses_content_col, uses_task_col) = match task_col {
830 Some(t) => Self::sibling_column_usage(ctx, item_line, range_end, marker_col, content_col, t),
831 None => (false, false),
832 };
833
834 Self::walk_item_continuation(ctx, item_line, range_end, marker_col, |line| {
835 let actual = line.actual;
836 if actual > required
837 && !line.info.in_code_block
838 && Some(actual) != task_col
839 && !Self::starts_with_list_marker(line.trimmed)
840 && flagged_lines.insert(line.line_num)
841 {
842 let fix_target =
843 Self::compute_fix_target(actual, required, task_col, uses_content_col, uses_task_col);
844 let message = match task_col {
845 Some(t) => format!(
846 "Continuation line over-indented \
847 (expected {required} or {t}, found {actual})"
848 ),
849 None => {
850 format!("Continuation line over-indented (expected {required}, found {actual})")
851 }
852 };
853 warnings.push(Self::build_over_indent_warning(ctx, line, fix_target, message));
854 }
855 ControlFlow::Continue(())
856 });
857 }
858
859 warnings.sort_by_key(|w| (w.line, w.column));
862
863 Ok(warnings)
864 }
865
866 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
867 let warnings = self.check(ctx)?;
868 let warnings =
869 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
870 if warnings.is_empty() {
871 return Ok(ctx.content.to_string());
872 }
873
874 let mut fixes: Vec<Fix> = warnings.into_iter().filter_map(|w| w.fix).collect();
876 fixes.sort_by_key(|f| std::cmp::Reverse(f.range.start));
877
878 let mut content = ctx.content.to_string();
879 for fix in fixes {
880 if fix.range.start <= content.len() && fix.range.end <= content.len() {
881 content.replace_range(fix.range, &fix.replacement);
882 }
883 }
884
885 Ok(content)
886 }
887
888 fn category(&self) -> RuleCategory {
889 RuleCategory::List
890 }
891
892 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
893 ctx.content.is_empty() || ctx.list_blocks.is_empty()
894 }
895
896 fn as_any(&self) -> &dyn std::any::Any {
897 self
898 }
899
900 crate::impl_rule_config_methods!(MD077Config);
901}
902
903#[cfg(test)]
904mod tests {
905 use super::*;
906 use crate::config::MarkdownFlavor;
907
908 fn check(content: &str) -> Vec<LintWarning> {
909 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
910 let rule = MD077ListContinuationIndent::default();
911 rule.check(&ctx).unwrap()
912 }
913
914 fn check_mkdocs(content: &str) -> Vec<LintWarning> {
915 let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
916 let rule = MD077ListContinuationIndent::default();
917 rule.check(&ctx).unwrap()
918 }
919
920 fn fix(content: &str) -> String {
921 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
922 let rule = MD077ListContinuationIndent::default();
923 rule.fix(&ctx).unwrap()
924 }
925
926 fn fix_mkdocs(content: &str) -> String {
927 let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
928 let rule = MD077ListContinuationIndent::default();
929 rule.fix(&ctx).unwrap()
930 }
931
932 fn aligned_rule() -> MD077ListContinuationIndent {
933 MD077ListContinuationIndent::new(ContinuationStyle::Aligned)
934 }
935
936 fn check_aligned(content: &str) -> Vec<LintWarning> {
937 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
938 aligned_rule().check(&ctx).unwrap()
939 }
940
941 fn check_aligned_mkdocs(content: &str) -> Vec<LintWarning> {
942 let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
943 aligned_rule().check(&ctx).unwrap()
944 }
945
946 fn fix_aligned(content: &str) -> String {
947 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
948 aligned_rule().fix(&ctx).unwrap()
949 }
950
951 fn fix_aligned_quarto(content: &str) -> String {
952 let ctx = LintContext::new(content, MarkdownFlavor::Quarto, None);
953 aligned_rule().fix(&ctx).unwrap()
954 }
955
956 #[test]
957 fn aligned_idempotent_with_latent_marker_behind_unstable_heading() {
958 let input = "1. \n``\n``\n- \n``";
967 let once = fix_aligned_quarto(input);
968 let twice = fix_aligned_quarto(&once);
969 assert_eq!(once, twice, "MD077 aligned fix must be idempotent (Quarto)");
970 }
971
972 #[test]
973 fn aligned_idempotent_with_lazy_continuation_out_of_a_blockquote() {
974 let input = "- \n> *\n> a\n``";
978 let once = fix_aligned(input);
979 let twice = fix_aligned(&once);
980 assert_eq!(once, twice, "MD077 aligned fix must be idempotent");
981 }
982
983 #[test]
986 fn tight_lazy_continuation_zero_indent_not_flagged() {
987 let content = "- Item\ncontinuation\n";
989 assert!(check(content).is_empty());
990 }
991
992 #[test]
993 fn tight_continuation_correct_indent_not_flagged() {
994 let content = "1. Item\n continuation\n";
996 assert!(check(content).is_empty());
997 }
998
999 #[test]
1000 fn tight_continuation_over_indented_ordered() {
1001 let content = "1. This is a list item with multiple lines.\n The second line is over-indented.\n";
1003 let warnings = check(content);
1004 assert_eq!(warnings.len(), 1);
1005 assert_eq!(warnings[0].line, 2);
1006 assert!(warnings[0].message.contains("over-indented"));
1007 }
1008
1009 #[test]
1010 fn tight_continuation_over_indented_unordered() {
1011 let content = "- Item\n over-indented\n";
1013 let warnings = check(content);
1014 assert_eq!(warnings.len(), 1);
1015 assert_eq!(warnings[0].line, 2);
1016 }
1017
1018 #[test]
1019 fn tight_continuation_multiple_over_indented_lines() {
1020 let content = "1. Item\n line one\n line two\n line three\n";
1021 let warnings = check(content);
1022 assert_eq!(warnings.len(), 3);
1023 }
1024
1025 #[test]
1026 fn tight_continuation_mixed_correct_and_over() {
1027 let content = "1. Item\n correct\n over-indented\n correct again\n";
1028 let warnings = check(content);
1029 assert_eq!(warnings.len(), 1);
1030 assert_eq!(warnings[0].line, 3);
1031 }
1032
1033 #[test]
1034 fn tight_continuation_nested_over_indented() {
1035 let content = "- L1\n - L2\n over-indented continuation of L2\n";
1037 let warnings = check(content);
1038 assert_eq!(warnings.len(), 1);
1039 assert_eq!(warnings[0].line, 3);
1040 assert!(warnings[0].message.contains("expected 4"));
1042 assert!(warnings[0].message.contains("found 5"));
1043 }
1044
1045 #[test]
1046 fn tight_continuation_nested_correct_indent_not_flagged() {
1047 let content = "- L1\n - L2\n correctly indented continuation of L2\n";
1050 assert!(check(content).is_empty());
1051 }
1052
1053 #[test]
1054 fn fix_tight_continuation_nested_over_indented() {
1055 let content = "- L1\n - L2\n over-indented continuation of L2\n";
1057 let fixed = fix(content);
1058 assert_eq!(fixed, "- L1\n - L2\n over-indented continuation of L2\n");
1059 }
1060
1061 #[test]
1062 fn tight_continuation_under_indented_not_flagged() {
1063 let content = "1. Item\n under-indented\n";
1066 assert!(check(content).is_empty());
1067 }
1068
1069 #[test]
1070 fn tight_continuation_tab_over_indented() {
1071 let content = "- Item\n\tover-indented\n";
1073 let warnings = check(content);
1074 assert_eq!(warnings.len(), 1);
1075 }
1076
1077 #[test]
1078 fn fix_tight_continuation_over_indented_ordered() {
1079 let content = "1. This is a list item with multiple lines.\n The second line is over-indented.\n";
1080 let fixed = fix(content);
1081 assert_eq!(
1082 fixed,
1083 "1. This is a list item with multiple lines.\n The second line is over-indented.\n"
1084 );
1085 }
1086
1087 #[test]
1088 fn fix_tight_continuation_over_indented_unordered() {
1089 let content = "- Item\n over-indented\n";
1090 let fixed = fix(content);
1091 assert_eq!(fixed, "- Item\n over-indented\n");
1092 }
1093
1094 #[test]
1095 fn fix_tight_continuation_multiple_lines() {
1096 let content = "1. Item\n line one\n line two\n";
1097 let fixed = fix(content);
1098 assert_eq!(fixed, "1. Item\n line one\n line two\n");
1099 }
1100
1101 #[test]
1102 fn tight_continuation_mkdocs_4space_ordered_not_flagged() {
1103 let content = "1. Item\n continuation\n";
1106 assert!(check_mkdocs(content).is_empty());
1107 }
1108
1109 #[test]
1110 fn tight_continuation_mkdocs_5space_ordered_flagged() {
1111 let content = "1. Item\n over-indented\n";
1113 let warnings = check_mkdocs(content);
1114 assert_eq!(warnings.len(), 1);
1115 assert!(warnings[0].message.contains("expected 4"));
1116 assert!(warnings[0].message.contains("found 5"));
1117 }
1118
1119 #[test]
1120 fn fix_tight_continuation_mkdocs_over_indented() {
1121 let content = "1. Item\n over-indented\n";
1122 let fixed = fix_mkdocs(content);
1123 assert_eq!(fixed, "1. Item\n over-indented\n");
1124 }
1125
1126 #[test]
1127 fn tight_continuation_deeply_indented_list_markers_not_flagged() {
1128 let content = "* Level 0\n * Level 1\n * Level 2\n";
1131 assert!(check(content).is_empty());
1132 }
1133
1134 #[test]
1135 fn tight_continuation_ordered_marker_not_flagged() {
1136 let content = "- Parent\n 1. Child item\n";
1138 assert!(check(content).is_empty());
1139 }
1140
1141 #[test]
1144 fn unordered_correct_indent_no_warning() {
1145 let content = "- Item\n\n continuation\n";
1146 assert!(check(content).is_empty());
1147 }
1148
1149 #[test]
1150 fn unordered_partial_indent_warns() {
1151 let content = "- Item\n\n continuation\n";
1154 let warnings = check(content);
1155 assert_eq!(warnings.len(), 1);
1156 assert_eq!(warnings[0].line, 3);
1157 assert!(warnings[0].message.contains("2 spaces"));
1158 assert!(warnings[0].message.contains("found 1"));
1159 }
1160
1161 #[test]
1162 fn unordered_zero_indent_is_new_paragraph() {
1163 let content = "- Item\n\ncontinuation\n";
1166 assert!(check(content).is_empty());
1167 }
1168
1169 #[test]
1172 fn ordered_3space_correct_commonmark() {
1173 let content = "1. Item\n\n continuation\n";
1175 assert!(check(content).is_empty());
1176 }
1177
1178 #[test]
1179 fn ordered_2space_under_indent_commonmark() {
1180 let content = "1. Item\n\n continuation\n";
1181 let warnings = check(content);
1182 assert_eq!(warnings.len(), 1);
1183 assert!(warnings[0].message.contains("3 spaces"));
1184 assert!(warnings[0].message.contains("found 2"));
1185 }
1186
1187 #[test]
1190 fn multi_digit_marker_correct() {
1191 let content = "10. Item\n\n continuation\n";
1193 assert!(check(content).is_empty());
1194 }
1195
1196 #[test]
1197 fn multi_digit_marker_under_indent() {
1198 let content = "10. Item\n\n continuation\n";
1199 let warnings = check(content);
1200 assert_eq!(warnings.len(), 1);
1201 assert!(warnings[0].message.contains("4 spaces"));
1202 }
1203
1204 #[test]
1207 fn mkdocs_3space_ordered_warns() {
1208 let content = "1. Item\n\n continuation\n";
1210 let warnings = check_mkdocs(content);
1211 assert_eq!(warnings.len(), 1);
1212 assert!(warnings[0].message.contains("4 spaces"));
1213 assert!(warnings[0].message.contains("MkDocs"));
1214 }
1215
1216 #[test]
1217 fn mkdocs_4space_ordered_no_warning() {
1218 let content = "1. Item\n\n continuation\n";
1219 assert!(check_mkdocs(content).is_empty());
1220 }
1221
1222 #[test]
1223 fn mkdocs_unordered_2space_ok() {
1224 let content = "- Item\n\n continuation\n";
1226 assert!(check_mkdocs(content).is_empty());
1227 }
1228
1229 #[test]
1230 fn mkdocs_unordered_2space_warns() {
1231 let content = "- Item\n\n continuation\n";
1233 let warnings = check_mkdocs(content);
1234 assert_eq!(warnings.len(), 1);
1235 assert!(warnings[0].message.contains("4 spaces"));
1236 }
1237
1238 #[test]
1241 fn fix_unordered_indent() {
1242 let content = "- Item\n\n continuation\n";
1244 let fixed = fix(content);
1245 assert_eq!(fixed, "- Item\n\n continuation\n");
1246 }
1247
1248 #[test]
1249 fn fix_ordered_indent() {
1250 let content = "1. Item\n\n continuation\n";
1251 let fixed = fix(content);
1252 assert_eq!(fixed, "1. Item\n\n continuation\n");
1253 }
1254
1255 #[test]
1256 fn fix_mkdocs_indent() {
1257 let content = "1. Item\n\n continuation\n";
1258 let fixed = fix_mkdocs(content);
1259 assert_eq!(fixed, "1. Item\n\n continuation\n");
1260 }
1261
1262 #[test]
1265 fn nested_list_items_not_flagged() {
1266 let content = "- Parent\n\n - Child\n";
1267 assert!(check(content).is_empty());
1268 }
1269
1270 #[test]
1271 fn nested_list_zero_indent_is_new_paragraph() {
1272 let content = "- Parent\n - Child\n\ncontinuation of parent\n";
1274 assert!(check(content).is_empty());
1275 }
1276
1277 #[test]
1278 fn nested_list_partial_indent_flagged() {
1279 let content = "- Parent\n - Child\n\n continuation of parent\n";
1281 let warnings = check(content);
1282 assert_eq!(warnings.len(), 1);
1283 assert!(warnings[0].message.contains("2 spaces"));
1284 }
1285
1286 #[test]
1289 fn code_block_correctly_indented_no_warning() {
1290 let content = "- Item\n\n ```\n code\n ```\n";
1292 assert!(check(content).is_empty());
1293 }
1294
1295 #[test]
1296 fn code_fence_under_indented_warns() {
1297 let content = "- Item\n\n ```\n code\n ```\n";
1301 let warnings = check(content);
1302 assert_eq!(warnings.len(), 1);
1303 assert_eq!(warnings[0].line, 3);
1304 }
1305
1306 #[test]
1307 fn code_fence_under_indented_ordered_mkdocs() {
1308 let content = "1. Item\n\n ```toml\n key = \"value\"\n ```\n";
1311 assert!(check(content).is_empty()); let warnings = check_mkdocs(content);
1313 assert_eq!(warnings.len(), 1); assert_eq!(warnings[0].line, 3);
1315 assert!(warnings[0].message.contains("4 spaces"));
1316 assert!(warnings[0].message.contains("MkDocs"));
1317 }
1318
1319 #[test]
1320 fn code_fence_tilde_under_indented() {
1321 let content = "- Item\n\n ~~~\n code\n ~~~\n";
1322 let warnings = check(content);
1323 assert_eq!(warnings.len(), 1); assert_eq!(warnings[0].line, 3);
1325 }
1326
1327 #[test]
1330 fn multiple_blank_lines_zero_indent_is_new_paragraph() {
1331 let content = "- Item\n\n\ncontinuation\n";
1333 assert!(check(content).is_empty());
1334 }
1335
1336 #[test]
1337 fn multiple_blank_lines_partial_indent_flags() {
1338 let content = "- Item\n\n\n continuation\n";
1339 let warnings = check(content);
1340 assert_eq!(warnings.len(), 1);
1341 }
1342
1343 #[test]
1346 fn empty_item_no_warning() {
1347 let content = "- \n- Second\n";
1348 assert!(check(content).is_empty());
1349 }
1350
1351 #[test]
1354 fn multiple_items_mixed_indent() {
1355 let content = "1. First\n\n correct continuation\n\n2. Second\n\n wrong continuation\n";
1356 let warnings = check(content);
1357 assert_eq!(warnings.len(), 1);
1358 assert_eq!(warnings[0].line, 7);
1359 }
1360
1361 #[test]
1364 fn task_list_correct_indent() {
1365 let content = "- [ ] Task\n\n continuation\n";
1367 assert!(check(content).is_empty());
1368 }
1369
1370 #[test]
1373 fn frontmatter_not_flagged() {
1374 let content = "---\ntitle: test\n---\n\n- Item\n\n continuation\n";
1375 assert!(check(content).is_empty());
1376 }
1377
1378 #[test]
1381 fn fix_multiple_items() {
1382 let content = "1. First\n\n wrong1\n\n2. Second\n\n wrong2\n";
1383 let fixed = fix(content);
1384 assert_eq!(fixed, "1. First\n\n wrong1\n\n2. Second\n\n wrong2\n");
1385 }
1386
1387 #[test]
1388 fn fix_multiline_loose_continuation_all_lines() {
1389 let content = "1. Item\n\n line one\n line two\n line three\n";
1390 let fixed = fix(content);
1391 assert_eq!(fixed, "1. Item\n\n line one\n line two\n line three\n");
1392 }
1393
1394 #[test]
1397 fn sibling_item_boundary_respected() {
1398 let content = "- First\n- Second\n\n continuation\n";
1400 assert!(check(content).is_empty());
1401 }
1402
1403 #[test]
1406 fn blockquote_list_correct_indent_no_warning() {
1407 let content = "> - Item\n>\n> continuation\n";
1410 assert!(check(content).is_empty());
1411 }
1412
1413 #[test]
1414 fn blockquote_list_under_indent_no_false_positive() {
1415 let content = "> - Item\n>\n> continuation\n";
1420 assert!(check(content).is_empty());
1421 }
1422
1423 #[test]
1426 fn deep_nesting_correct_indent() {
1427 let content = "- L1\n - L2\n - L3\n\n continuation of L3\n";
1428 assert!(check(content).is_empty());
1429 }
1430
1431 #[test]
1432 fn deep_nesting_under_indent() {
1433 let content = "- L1\n - L2\n - L3\n\n continuation of L3\n";
1436 let warnings = check(content);
1437 assert_eq!(warnings.len(), 1);
1438 assert!(warnings[0].message.contains("6 spaces"));
1439 assert!(warnings[0].message.contains("found 5"));
1440 }
1441
1442 #[test]
1443 fn deep_nesting_middle_level_continuation_bullets() {
1444 let content = "- L1\n - L2\n - L3\n\n continuation of L2\n";
1448 assert!(check(content).is_empty());
1449 }
1450
1451 #[test]
1452 fn deep_nesting_middle_level_continuation_ordered() {
1453 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";
1456 assert!(check(content).is_empty());
1457 }
1458
1459 #[test]
1460 fn deep_nesting_outermost_continuation() {
1461 let content = "- L1\n - L2\n - L3\n\n continuation of L1\n";
1464 assert!(check(content).is_empty());
1465 }
1466
1467 #[test]
1468 fn deep_nesting_between_levels_still_flagged() {
1469 let content = "- L1\n - L2\n - L3\n\n continuation\n";
1472 let warnings = check(content);
1473 assert_eq!(warnings.len(), 1);
1474 assert!(warnings[0].message.contains("4 spaces"));
1475 assert!(warnings[0].message.contains("found 3"));
1476 }
1477
1478 #[test]
1479 fn deep_nesting_beyond_deepest_still_flagged() {
1480 let content = "- L1\n - L2\n - L3\n\n continuation\n";
1482 let warnings = check(content);
1483 assert_eq!(warnings.len(), 1);
1484 assert!(warnings[0].message.contains("over-indented"));
1485 assert!(warnings[0].message.contains("expected 6, found 7"));
1486 }
1487
1488 #[test]
1489 fn four_levels_middle_continuation() {
1490 let content = "- L1\n - L2\n - L3\n - L4\n\n continuation of L2\n";
1493 assert!(check(content).is_empty());
1494 }
1495
1496 #[test]
1497 fn nested_sibling_closes_deeper_level() {
1498 let content = "- L1\n - L2a\n - L3\n - L2b\n\n continuation of L2b\n";
1501 assert!(check(content).is_empty());
1502 }
1503
1504 #[test]
1505 fn deep_nesting_middle_level_continuation_fix_preserved() {
1506 let content = "- L1\n - L2\n - L3\n\n continuation of L2\n";
1508 assert_eq!(fix(content), content);
1509 }
1510
1511 #[test]
1514 fn loose_tab_continuation_over_indented() {
1515 let content = "- Item\n\n\tcontinuation\n";
1520 let warnings = check(content);
1521 assert_eq!(warnings.len(), 1);
1522 assert_eq!(warnings[0].line, 3);
1523 assert_eq!(fix(content), "- Item\n\n continuation\n");
1524 }
1525
1526 #[test]
1529 fn multiple_continuations_correct() {
1530 let content = "- Item\n\n para 1\n\n para 2\n\n para 3\n";
1531 assert!(check(content).is_empty());
1532 }
1533
1534 #[test]
1535 fn multiple_continuations_second_under_indent() {
1536 let content = "- Item\n\n para 1\n\n continuation 2\n";
1538 let warnings = check(content);
1539 assert_eq!(warnings.len(), 1);
1540 assert_eq!(warnings[0].line, 5);
1541 }
1542
1543 #[test]
1546 fn ordered_paren_marker_correct() {
1547 let content = "1) Item\n\n continuation\n";
1549 assert!(check(content).is_empty());
1550 }
1551
1552 #[test]
1553 fn ordered_paren_marker_under_indent() {
1554 let content = "1) Item\n\n continuation\n";
1555 let warnings = check(content);
1556 assert_eq!(warnings.len(), 1);
1557 assert!(warnings[0].message.contains("3 spaces"));
1558 }
1559
1560 #[test]
1563 fn star_marker_correct() {
1564 let content = "* Item\n\n continuation\n";
1565 assert!(check(content).is_empty());
1566 }
1567
1568 #[test]
1569 fn star_marker_under_indent() {
1570 let content = "* Item\n\n continuation\n";
1571 let warnings = check(content);
1572 assert_eq!(warnings.len(), 1);
1573 }
1574
1575 #[test]
1576 fn plus_marker_correct() {
1577 let content = "+ Item\n\n continuation\n";
1578 assert!(check(content).is_empty());
1579 }
1580
1581 #[test]
1584 fn heading_after_list_no_warning() {
1585 let content = "- Item\n\n# Heading\n";
1586 assert!(check(content).is_empty());
1587 }
1588
1589 #[test]
1592 fn hr_after_list_no_warning() {
1593 let content = "- Item\n\n---\n";
1594 assert!(check(content).is_empty());
1595 }
1596
1597 #[test]
1600 fn reference_link_def_not_flagged() {
1601 let content = "- Item\n\n [link]: https://example.com\n";
1602 assert!(check(content).is_empty());
1603 }
1604
1605 #[test]
1608 fn footnote_def_not_flagged() {
1609 let content = "- Item\n\n [^1]: footnote text\n";
1610 assert!(check(content).is_empty());
1611 }
1612
1613 #[test]
1614 fn footnote_multiline_body_after_list_not_flagged() {
1615 let content = "# A list followed by a footnote\n\n\
1619 Here is a paragraph.[^fn]\n\n\
1620 - This is a list.\n\n\
1621 [^fn]:\n\
1622 \x20\x20\x20\x20Here is a footnote that spans multiple lines.\n\
1623 \x20\x20\x20\x20It should thus be indented by at least four spaces.\n";
1624 assert!(check(content).is_empty());
1625 }
1626
1627 #[test]
1628 fn fix_footnote_multiline_body_after_list_is_noop() {
1629 let content = "# A list followed by a footnote\n\n\
1633 Here is a paragraph.[^fn]\n\n\
1634 - This is a list.\n\n\
1635 [^fn]:\n\
1636 \x20\x20\x20\x20Here is a footnote that spans multiple lines.\n\
1637 \x20\x20\x20\x20It should thus be indented by at least four spaces.\n";
1638 assert_eq!(fix(content), content);
1639 }
1640
1641 #[test]
1642 fn footnote_body_indented_past_list_content_col_not_flagged() {
1643 let content = "- Item\n\n[^fn]:\n Body line one.\n Body line two.\n";
1647 assert!(check(content).is_empty());
1648 }
1649
1650 #[test]
1651 fn list_inside_footnote_body_continuation_not_flagged() {
1652 let content = "Text.[^fn]\n\n[^fn]:\n\
1656 \x20\x20\x20\x20- nested item\n\
1657 \x20\x20\x20\x20\x20\x20\x20over-indented continuation\n";
1658 assert!(check(content).is_empty());
1659 }
1660
1661 #[test]
1662 fn footnote_multiline_body_after_list_not_flagged_mkdocs() {
1663 let content = "Here is a paragraph.[^fn]\n\n\
1667 - This is a list.\n\n\
1668 [^fn]:\n\
1669 \x20\x20\x20\x20\x20\x20Footnote body that spans\n\
1670 \x20\x20\x20\x20\x20\x20multiple indented lines.\n";
1671 assert!(check_mkdocs(content).is_empty());
1672 }
1673
1674 #[test]
1677 fn fix_deep_nesting() {
1678 let content = "- L1\n - L2\n - L3\n\n under-indented\n";
1679 let fixed = fix(content);
1680 assert_eq!(fixed, "- L1\n - L2\n - L3\n\n under-indented\n");
1681 }
1682
1683 #[test]
1684 fn fix_mkdocs_unordered() {
1685 let content = "- Item\n\n continuation\n";
1687 let fixed = fix_mkdocs(content);
1688 assert_eq!(fixed, "- Item\n\n continuation\n");
1689 }
1690
1691 #[test]
1692 fn fix_code_fence_indent() {
1693 let content = "- Item\n\n ```\n code\n ```\n";
1696 let fixed = fix(content);
1697 assert_eq!(fixed, "- Item\n\n ```\n code\n ```\n");
1698 }
1699
1700 #[test]
1701 fn fix_mkdocs_code_fence_indent() {
1702 let content = "1. Item\n\n ```toml\n key = \"val\"\n ```\n";
1704 let fixed = fix_mkdocs(content);
1705 assert_eq!(fixed, "1. Item\n\n ```toml\n key = \"val\"\n ```\n");
1706 }
1707
1708 #[test]
1711 fn empty_document_no_warning() {
1712 assert!(check("").is_empty());
1713 }
1714
1715 #[test]
1716 fn whitespace_only_no_warning() {
1717 assert!(check(" \n\n \n").is_empty());
1718 }
1719
1720 #[test]
1723 fn no_list_no_warning() {
1724 let content = "# Heading\n\nSome paragraph.\n\nAnother paragraph.\n";
1725 assert!(check(content).is_empty());
1726 }
1727
1728 #[test]
1731 fn multiline_continuation_all_lines_flagged() {
1732 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";
1733 let warnings = check(content);
1734 assert_eq!(warnings.len(), 3);
1735 assert_eq!(warnings[0].line, 3);
1736 assert_eq!(warnings[1].line, 4);
1737 assert_eq!(warnings[2].line, 5);
1738 }
1739
1740 #[test]
1741 fn multiline_continuation_with_frontmatter_fix() {
1742 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";
1743 let fixed = fix(content);
1744 assert_eq!(
1745 fixed,
1746 "---\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"
1747 );
1748 }
1749
1750 #[test]
1751 fn multiline_continuation_correct_indent_no_warning() {
1752 let content = "1. Item\n\n line one\n line two\n line three\n";
1753 assert!(check(content).is_empty());
1754 }
1755
1756 #[test]
1757 fn multiline_continuation_mixed_indent() {
1758 let content = "1. Item\n\n correct\n wrong\n correct\n";
1759 let warnings = check(content);
1760 assert_eq!(warnings.len(), 1);
1761 assert_eq!(warnings[0].line, 4);
1762 }
1763
1764 #[test]
1765 fn multiline_continuation_unordered() {
1766 let content = "- Item\n\n continuation 1\n continuation 2\n continuation 3\n";
1767 let warnings = check(content);
1768 assert_eq!(warnings.len(), 3);
1769 let fixed = fix(content);
1770 assert_eq!(
1771 fixed,
1772 "- Item\n\n continuation 1\n continuation 2\n continuation 3\n"
1773 );
1774 }
1775
1776 #[test]
1777 fn multiline_continuation_two_items_fix() {
1778 let content = "1. First\n\n cont a\n cont b\n\n2. Second\n\n cont c\n cont d\n";
1779 let fixed = fix(content);
1780 assert_eq!(
1781 fixed,
1782 "1. First\n\n cont a\n cont b\n\n2. Second\n\n cont c\n cont d\n"
1783 );
1784 }
1785
1786 #[test]
1787 fn fence_fix_does_not_break_pairing_for_md031() {
1788 let content = "#### title\n\nabc\n\n\
1795 1. ab\n\n\
1796 \x20\x20`aabbccdd`\n\n\
1797 2. cd\n\n\
1798 \x20\x20`bbcc dd ee`\n\n\
1799 \x20\x20```\n\
1800 \x20\x20abcd\n\
1801 \x20\x20ef gh\n\
1802 \x20\x20```\n\n\
1803 \x20\x20uu\n\n\
1804 \x20\x20```\n\
1805 \x20\x20cdef\n\
1806 \x20\x20gh ij\n\
1807 \x20\x20```\n";
1808 let expected = "#### title\n\nabc\n\n\
1809 1. ab\n\n\
1810 \x20\x20\x20`aabbccdd`\n\n\
1811 2. cd\n\n\
1812 \x20\x20\x20`bbcc dd ee`\n\n\
1813 \x20\x20\x20```\n\
1814 \x20\x20\x20abcd\n\
1815 \x20\x20\x20ef gh\n\
1816 \x20\x20\x20```\n\n\
1817 \x20\x20\x20uu\n\n\
1818 \x20\x20\x20```\n\
1819 \x20\x20\x20cdef\n\
1820 \x20\x20\x20gh ij\n\
1821 \x20\x20\x20```\n";
1822 assert_eq!(fix(content), expected);
1823 }
1824
1825 #[test]
1826 fn multiline_continuation_separated_by_blank() {
1827 let content = "1. Item\n\n para1 line1\n para1 line2\n\n para2 line1\n para2 line2\n";
1828 let warnings = check(content);
1829 assert_eq!(warnings.len(), 4);
1830 let fixed = fix(content);
1831 assert_eq!(
1832 fixed,
1833 "1. Item\n\n para1 line1\n para1 line2\n\n para2 line1\n para2 line2\n"
1834 );
1835 }
1836
1837 #[test]
1838 fn tab_indented_fence_is_normalized_to_spaces() {
1839 let content = "100. ab\n\n\t```\n\tabcd\n\t```\n";
1847 let expected = "100. ab\n\n ```\n abcd\n ```\n";
1848 assert_eq!(fix(content), expected);
1849 }
1850
1851 #[test]
1860 fn loose_continuation_over_indented_flagged() {
1861 let content = "* Item\n\n over-indented\n";
1864 let warnings = check(content);
1865 assert_eq!(warnings.len(), 1);
1866 assert_eq!(warnings[0].line, 3);
1867 assert!(warnings[0].message.contains("over-indented"));
1868 assert!(warnings[0].message.contains("expected 2"));
1869 assert!(warnings[0].message.contains("found 3"));
1870 }
1871
1872 #[test]
1873 fn loose_continuation_over_indented_multiline_mixed() {
1874 let content = "* Item\n\n over one\n correct\n over two\n";
1876 let warnings = check(content);
1877 assert_eq!(warnings.len(), 2);
1878 assert_eq!(warnings[0].line, 3);
1879 assert_eq!(warnings[1].line, 5);
1880 }
1881
1882 #[test]
1883 fn fix_loose_continuation_over_indented() {
1884 let content = "* Item\n\n over one\n correct\n over two\n";
1885 let fixed = fix(content);
1886 assert_eq!(fixed, "* Item\n\n over one\n correct\n over two\n");
1887 }
1888
1889 #[test]
1890 fn fix_tight_and_loose_items_normalized_identically() {
1891 let content = "---\ntitle: Heading\n---\n\nSome introductory text:\n\n\
1894 * 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\
1895 * 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";
1896 let expected = "---\ntitle: Heading\n---\n\nSome introductory text:\n\n\
1897 * 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\
1898 * 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";
1899 assert_eq!(fix(content), expected);
1900 }
1901
1902 #[test]
1903 fn multi_paragraph_item_loose_paragraph_over_indented() {
1904 let content = "* Item.\n tight over\n\n loose over\n";
1907 let warnings = check(content);
1908 assert_eq!(warnings.len(), 2);
1909 assert_eq!(warnings[0].line, 2);
1910 assert_eq!(warnings[1].line, 4);
1911 }
1912
1913 #[test]
1914 fn loose_indented_code_block_not_flagged() {
1915 let content = "- Item\n\n code line\n";
1919 assert!(check(content).is_empty());
1920 }
1921
1922 #[test]
1923 fn mkdocs_loose_over_indented_flagged() {
1924 let content = "1. Item\n\n over\n";
1927 let warnings = check_mkdocs(content);
1928 assert_eq!(warnings.len(), 1);
1929 assert_eq!(warnings[0].line, 3);
1930 assert!(warnings[0].message.contains("over-indented"));
1931 assert!(warnings[0].message.contains("expected 4"));
1932 assert!(warnings[0].message.contains("found 5"));
1933 }
1934
1935 #[test]
1936 fn task_list_loose_over_indented_flagged() {
1937 let content = "- [ ] Task\n\n over\n";
1940 let warnings = check(content);
1941 assert_eq!(warnings.len(), 1);
1942 assert_eq!(warnings[0].line, 3);
1943 }
1944
1945 #[test]
1946 fn loose_over_indent_boundary_below_code_block_threshold_flagged() {
1947 let content = "- Item\n\n over\n";
1952 let warnings = check(content);
1953 assert_eq!(warnings.len(), 1);
1954 assert_eq!(warnings[0].line, 3);
1955 assert!(warnings[0].message.contains("expected 2"));
1956 assert!(warnings[0].message.contains("found 5"));
1957 }
1958
1959 #[test]
1960 fn loose_over_indent_does_not_steal_nested_under_indent() {
1961 let content = "- Outer\n - Inner\n\n continuation\n";
1968 let warnings = check(content);
1969 assert_eq!(warnings.len(), 1);
1970 assert_eq!(warnings[0].line, 4);
1971 assert!(warnings[0].message.contains("4 spaces"));
1972 assert!(warnings[0].message.contains("found 3"));
1973 assert_eq!(fix(content), "- Outer\n - Inner\n\n continuation\n");
1974 }
1975
1976 #[test]
1977 fn loose_over_indent_attributes_to_deepest_enclosing_item() {
1978 let content = "- Outer\n - Inner\n\n continuation\n";
1982 let warnings = check(content);
1983 assert_eq!(warnings.len(), 1);
1984 assert_eq!(warnings[0].line, 4);
1985 assert!(warnings[0].message.contains("expected 4"));
1986 assert!(warnings[0].message.contains("found 5"));
1987 assert_eq!(fix(content), "- Outer\n - Inner\n\n continuation\n");
1988 }
1989
1990 #[test]
1999 fn loose_over_indented_fence_not_flagged() {
2000 let content = "- Item\n\n ```\n code\n ```\n";
2001 assert!(check(content).is_empty());
2002 assert_eq!(fix(content), content);
2003 }
2004
2005 #[test]
2006 fn tight_over_indented_fence_not_flagged() {
2007 let content = "- Item\n ```\n code\n ```\n";
2008 assert!(check(content).is_empty());
2009 assert_eq!(fix(content), content);
2010 }
2011
2012 #[test]
2013 fn over_indented_tilde_fence_not_flagged() {
2014 let content = "- Item\n\n ~~~\n code\n ~~~\n";
2015 assert!(check(content).is_empty());
2016 assert_eq!(fix(content), content);
2017 }
2018
2019 #[test]
2020 fn fence_like_code_content_inside_fenced_block_not_flagged() {
2021 let content = "- Item\n\n ~~~\n ```\n ~~~\n";
2024 assert!(check(content).is_empty());
2025 assert_eq!(fix(content), content);
2026 }
2027
2028 #[test]
2029 fn unterminated_over_indented_fence_not_flagged() {
2030 let content = "- Item\n\n ```\n code1\n code2deeper\n";
2033 assert!(check(content).is_empty());
2034 assert_eq!(fix(content), content);
2035 }
2036
2037 #[test]
2045 fn task_list_tight_continuation_post_checkbox_reproducer_579() {
2046 let content = "- [ ] Lorem ipsum dolor sit amet, consectetur adipiscing\n tempor incididunt ut labore.\n";
2049 assert!(check(content).is_empty());
2050 }
2051
2052 #[test]
2053 fn task_list_tight_continuation_dash_unchecked() {
2054 let content = "- [ ] Task\n continuation\n";
2055 assert!(check(content).is_empty());
2056 }
2057
2058 #[test]
2059 fn task_list_tight_continuation_dash_checked_lower() {
2060 let content = "- [x] Task\n continuation\n";
2061 assert!(check(content).is_empty());
2062 }
2063
2064 #[test]
2065 fn task_list_tight_continuation_dash_checked_upper() {
2066 let content = "- [X] Task\n continuation\n";
2067 assert!(check(content).is_empty());
2068 }
2069
2070 #[test]
2071 fn task_list_tight_continuation_star_marker() {
2072 let content = "* [ ] Task\n continuation\n";
2073 assert!(check(content).is_empty());
2074 }
2075
2076 #[test]
2077 fn task_list_tight_continuation_plus_marker() {
2078 let content = "+ [ ] Task\n continuation\n";
2079 assert!(check(content).is_empty());
2080 }
2081
2082 #[test]
2083 fn task_list_tight_continuation_content_column_still_valid() {
2084 let content = "- [ ] Task\n continuation\n";
2087 assert!(check(content).is_empty());
2088 }
2089
2090 #[test]
2091 fn task_list_tight_continuation_between_columns_still_flagged() {
2092 let content = "- [ ] Task\n continuation\n";
2095 let warnings = check(content);
2096 assert_eq!(warnings.len(), 1);
2097 assert!(warnings[0].message.contains("expected 2 or 6"));
2099 assert!(warnings[0].message.contains("found 4"));
2100 }
2101
2102 #[test]
2103 fn task_list_tight_continuation_overshoot_still_flagged() {
2104 let content = "- [ ] Task\n continuation\n";
2106 let warnings = check(content);
2107 assert_eq!(warnings.len(), 1);
2108 assert!(warnings[0].message.contains("expected 2 or 6"));
2109 assert!(warnings[0].message.contains("found 7"));
2110 }
2111
2112 #[test]
2115 fn fix_task_list_overshoot_snaps_to_task_col() {
2116 let content = "- [ ] Task\n continuation\n";
2120 let fixed = fix(content);
2121 assert_eq!(fixed, "- [ ] Task\n continuation\n");
2122 }
2123
2124 #[test]
2125 fn fix_task_list_col_5_snaps_to_task_col() {
2126 let content = "- [ ] Task\n continuation\n";
2128 let fixed = fix(content);
2129 assert_eq!(fixed, "- [ ] Task\n continuation\n");
2130 }
2131
2132 #[test]
2133 fn fix_task_list_col_3_snaps_to_content_col() {
2134 let content = "- [ ] Task\n continuation\n";
2136 let fixed = fix(content);
2137 assert_eq!(fixed, "- [ ] Task\n continuation\n");
2138 }
2139
2140 #[test]
2141 fn fix_task_list_col_4_ties_to_content_col() {
2142 let content = "- [ ] Task\n continuation\n";
2147 let fixed = fix(content);
2148 assert_eq!(fixed, "- [ ] Task\n continuation\n");
2149 }
2150
2151 #[test]
2152 fn fix_task_list_ordered_overshoot_snaps_to_task_col() {
2153 let content = "1. [ ] Task\n continuation\n";
2156 let fixed = fix(content);
2157 assert_eq!(fixed, "1. [ ] Task\n continuation\n");
2158 }
2159
2160 #[test]
2161 fn fix_task_list_ordered_under_overshoot_snaps_to_content_col() {
2162 let content = "1. [ ] Task\n continuation\n";
2165 let fixed = fix(content);
2166 assert_eq!(fixed, "1. [ ] Task\n continuation\n");
2167 }
2168
2169 #[test]
2170 fn task_list_tight_continuation_ordered_single_digit() {
2171 let content = "1. [ ] Task\n continuation\n";
2173 assert!(check(content).is_empty());
2174 }
2175
2176 #[test]
2177 fn task_list_tight_continuation_ordered_multi_digit() {
2178 let content = "10. [ ] Task\n continuation\n";
2180 assert!(check(content).is_empty());
2181 }
2182
2183 #[test]
2184 fn task_list_tight_continuation_nested_dash() {
2185 let content = "- Parent\n - [ ] Nested task\n continuation\n";
2187 assert!(check(content).is_empty());
2188 }
2189
2190 #[test]
2191 fn task_list_loose_continuation_post_checkbox_column_not_flagged() {
2192 let content = "- [ ] Task\n\n continuation\n";
2197 assert!(check(content).is_empty());
2198 }
2199
2200 #[test]
2201 fn task_list_empty_body_is_not_a_task() {
2202 let content = "- [ ]\n continuation\n";
2208 let warnings = check(content);
2209 assert_eq!(warnings.len(), 1);
2210 assert!(warnings[0].message.contains("found 4"));
2211 }
2212
2213 #[test]
2214 fn task_list_malformed_checkbox_is_not_a_task() {
2215 let content = "- [~] Not a task\n continuation\n";
2217 let warnings = check(content);
2218 assert_eq!(warnings.len(), 1);
2219 }
2220
2221 #[test]
2228 fn task_list_mkdocs_unordered_required_min_valid() {
2229 let content = "- [ ] Task\n continuation\n";
2231 assert!(check_mkdocs(content).is_empty());
2232 }
2233
2234 #[test]
2235 fn task_list_mkdocs_unordered_post_checkbox_valid() {
2236 let content = "- [ ] Task\n continuation\n";
2237 assert!(check_mkdocs(content).is_empty());
2238 }
2239
2240 #[test]
2241 fn task_list_mkdocs_unordered_between_flagged() {
2242 let content = "- [ ] Task\n continuation\n";
2244 let warnings = check_mkdocs(content);
2245 assert_eq!(warnings.len(), 1);
2246 }
2247
2248 #[test]
2249 fn task_list_mkdocs_ordered_both_columns_valid() {
2250 let at_4 = "1. [ ] Task\n continuation\n";
2252 assert!(check_mkdocs(at_4).is_empty());
2253 let at_7 = "1. [ ] Task\n continuation\n";
2254 assert!(check_mkdocs(at_7).is_empty());
2255 }
2256
2257 #[test]
2258 fn task_list_mkdocs_ordered_between_flagged() {
2259 let at_5 = "1. [ ] Task\n continuation\n";
2261 assert_eq!(check_mkdocs(at_5).len(), 1);
2262 let at_6 = "1. [ ] Task\n continuation\n";
2263 assert_eq!(check_mkdocs(at_6).len(), 1);
2264 }
2265
2266 #[test]
2276 fn fix_task_list_tie_sibling_at_task_col_snaps_to_task_col() {
2277 let content = "- [ ] Task\n aligned continuation\n tied continuation\n";
2281 let fixed = fix(content);
2282 assert_eq!(
2283 fixed,
2284 "- [ ] Task\n aligned continuation\n tied continuation\n"
2285 );
2286 }
2287
2288 #[test]
2289 fn fix_task_list_tie_sibling_at_content_col_snaps_to_content_col() {
2290 let content = "- [ ] Task\n aligned continuation\n tied continuation\n";
2293 let fixed = fix(content);
2294 assert_eq!(fixed, "- [ ] Task\n aligned continuation\n tied continuation\n");
2295 }
2296
2297 #[test]
2298 fn fix_task_list_tie_both_siblings_snaps_to_content_col() {
2299 let content = "- [ ] Task\n at content col\n at task col\n tied continuation\n";
2303 let fixed = fix(content);
2304 assert_eq!(
2305 fixed,
2306 "- [ ] Task\n at content col\n at task col\n tied continuation\n"
2307 );
2308 }
2309
2310 #[test]
2311 fn fix_task_list_tie_sees_task_col_through_tight_lazy_continuation() {
2312 let content = concat!("- [ ] Task\n", "lazy\n", " aligned at task col\n", " tied\n",);
2326 let fixed = fix(content);
2327 assert!(
2328 fixed.contains("\n tied\n"),
2329 "tied line should snap to col 6 (task col) because a task-col \
2330 sibling is visible past the tight lazy-continuation line; got:\n{fixed}"
2331 );
2332 }
2333
2334 #[test]
2341 fn task_list_tab_indented_continuation_flagged() {
2342 let content = "- [ ] Task\n\t\twrap\n";
2345 let warnings = check(content);
2346 assert_eq!(warnings.len(), 1);
2347 assert!(warnings[0].message.contains("expected 2 or 6"));
2348 assert!(warnings[0].message.contains("found 8"));
2349 }
2350
2351 #[test]
2352 fn fix_task_list_tab_indented_snaps_to_task_col() {
2353 let content = "- [ ] Task\n\t\twrap\n";
2355 let fixed = fix(content);
2356 assert_eq!(fixed, "- [ ] Task\n wrap\n");
2357 }
2358
2359 #[test]
2360 fn fix_task_list_single_tab_equidistant_snaps_to_content_col() {
2361 let content = "- [ ] Task\n\twrap\n";
2364 let fixed = fix(content);
2365 assert_eq!(fixed, "- [ ] Task\n wrap\n");
2366 }
2367
2368 #[test]
2378 fn task_list_blockquote_post_checkbox_not_flagged() {
2379 let content = "> - [ ] Task\n> continuation\n";
2381 assert!(check(content).is_empty());
2382 }
2383
2384 #[test]
2385 fn task_list_blockquote_between_cols_documented_limitation() {
2386 let content = "> - [ ] Task\n> continuation\n";
2390 assert!(check(content).is_empty());
2391 }
2392
2393 #[test]
2394 fn task_list_blockquote_overshoot_documented_limitation() {
2395 let content = "> - [ ] Task\n> continuation\n";
2397 assert!(check(content).is_empty());
2398 }
2399
2400 #[test]
2407 fn fix_task_list_mkdocs_unordered_overshoot_snaps_to_task_col() {
2408 let content = "- [ ] Task\n continuation\n";
2411 let fixed = fix_mkdocs(content);
2412 assert_eq!(fixed, "- [ ] Task\n continuation\n");
2413 }
2414
2415 #[test]
2416 fn fix_task_list_mkdocs_unordered_tie_snaps_to_required() {
2417 let content = "- [ ] Task\n continuation\n";
2420 let fixed = fix_mkdocs(content);
2421 assert_eq!(fixed, "- [ ] Task\n continuation\n");
2422 }
2423
2424 #[test]
2425 fn fix_task_list_mkdocs_ordered_overshoot_snaps_to_task_col() {
2426 let content = "1. [ ] Task\n continuation\n";
2429 let fixed = fix_mkdocs(content);
2430 assert_eq!(fixed, "1. [ ] Task\n continuation\n");
2431 }
2432
2433 #[test]
2434 fn fix_task_list_mkdocs_ordered_near_required_snaps_to_required() {
2435 let content = "1. [ ] Task\n continuation\n";
2441 let fixed = fix_mkdocs(content);
2442 assert_eq!(fixed, "1. [ ] Task\n continuation\n");
2443 }
2444
2445 #[test]
2446 fn fix_task_list_mkdocs_ordered_between_cols_snaps_to_task_col() {
2447 let content = "1. [ ] Task\n continuation\n";
2450 let fixed = fix_mkdocs(content);
2451 assert_eq!(fixed, "1. [ ] Task\n continuation\n");
2452 }
2453
2454 fn assert_idempotent(content: &str) {
2464 let once = fix(content);
2465 let twice = fix(&once);
2466 assert_eq!(once, twice, "MD077 fix was not idempotent on input: {content:?}");
2467 }
2468
2469 fn assert_idempotent_mkdocs(content: &str) {
2470 let once = fix_mkdocs(content);
2471 let twice = fix_mkdocs(&once);
2472 assert_eq!(
2473 once, twice,
2474 "MD077 (MkDocs) fix was not idempotent on input: {content:?}"
2475 );
2476 }
2477
2478 #[test]
2479 fn idempotent_task_list_between_cols() {
2480 assert_idempotent("- [ ] Task\n continuation\n");
2481 }
2482
2483 #[test]
2484 fn idempotent_task_list_overshoot() {
2485 assert_idempotent("- [ ] Task\n continuation\n");
2486 }
2487
2488 #[test]
2489 fn idempotent_task_list_under_post_checkbox() {
2490 assert_idempotent("- [ ] Task\n continuation\n");
2491 }
2492
2493 #[test]
2494 fn idempotent_task_list_near_post_checkbox() {
2495 assert_idempotent("- [ ] Task\n continuation\n");
2496 }
2497
2498 #[test]
2499 fn idempotent_task_list_tab_overshoot() {
2500 assert_idempotent("- [ ] Task\n\t\twrap\n");
2501 }
2502
2503 #[test]
2504 fn idempotent_task_list_single_tab() {
2505 assert_idempotent("- [ ] Task\n\twrap\n");
2506 }
2507
2508 #[test]
2509 fn idempotent_task_list_ordered_overshoot() {
2510 assert_idempotent("1. [ ] Task\n continuation\n");
2511 }
2512
2513 #[test]
2514 fn idempotent_task_list_ordered_under() {
2515 assert_idempotent("1. [ ] Task\n continuation\n");
2516 }
2517
2518 #[test]
2519 fn idempotent_task_list_tie_with_sibling_at_task_col() {
2520 assert_idempotent("- [ ] Task\n aligned\n tied\n");
2521 }
2522
2523 #[test]
2524 fn idempotent_task_list_tie_with_sibling_at_content_col() {
2525 assert_idempotent("- [ ] Task\n aligned\n tied\n");
2526 }
2527
2528 #[test]
2529 fn idempotent_task_list_mkdocs_unordered_overshoot() {
2530 assert_idempotent_mkdocs("- [ ] Task\n continuation\n");
2531 }
2532
2533 #[test]
2534 fn idempotent_task_list_mkdocs_unordered_tie() {
2535 assert_idempotent_mkdocs("- [ ] Task\n continuation\n");
2536 }
2537
2538 #[test]
2539 fn idempotent_task_list_mkdocs_ordered_overshoot() {
2540 assert_idempotent_mkdocs("1. [ ] Task\n continuation\n");
2541 }
2542
2543 #[test]
2544 fn idempotent_task_list_mkdocs_ordered_between() {
2545 assert_idempotent_mkdocs("1. [ ] Task\n continuation\n");
2546 }
2547
2548 #[test]
2549 fn idempotent_task_list_reproducer_579() {
2550 assert_idempotent(
2554 "- [ ] Lorem ipsum dolor sit amet, consectetur adipiscing\n tempor incididunt ut labore.\n",
2555 );
2556 }
2557
2558 #[test]
2559 fn idempotent_non_task_list_still_holds() {
2560 assert_idempotent("1. Item\n over-indented\n");
2563 assert_idempotent("- Item\n\n continuation\n");
2564 }
2565
2566 #[test]
2573 fn idempotent_non_task_loose_under_indent_ordered() {
2574 assert_idempotent("1. Item\n\n continuation\n");
2576 }
2577
2578 #[test]
2579 fn idempotent_non_task_loose_under_indent_multi_digit() {
2580 assert_idempotent("10. Item\n\n continuation\n");
2582 }
2583
2584 #[test]
2585 fn idempotent_non_task_tight_over_indent_ordered() {
2586 assert_idempotent("1. Item\n over-indented\n");
2588 }
2589
2590 #[test]
2598 fn idempotent_non_task_fence_ordered_loose() {
2599 assert_idempotent("1. Item\n\n ```rust\n let x = 1;\n ```\n");
2601 }
2602
2603 #[test]
2604 fn idempotent_non_task_fence_tilde_under_indent() {
2605 assert_idempotent("1. Item\n\n ~~~\nplain text\n ~~~\n");
2611 }
2612
2613 #[test]
2614 fn idempotent_non_task_fence_interior_above_required() {
2615 assert_idempotent("1. Item\n\n ```\n deeply indented code\n ```\n");
2619 }
2620
2621 #[test]
2622 fn fence_fix_promotes_interior_below_scope_in_single_pass() {
2623 let content = "1. Item\n\n ```\ncode\n ```\n";
2627 let fixed = fix(content);
2628 assert_eq!(fixed, "1. Item\n\n ```\n code\n ```\n");
2629 }
2630
2631 #[test]
2632 fn fence_fix_preserves_interior_above_required() {
2633 let content = "1. Item\n\n ```\n code\n ```\n";
2636 let fixed = fix(content);
2637 assert_eq!(fixed, "1. Item\n\n ```\n code\n ```\n");
2638 }
2639
2640 #[test]
2647 fn idempotent_non_task_mkdocs_ordered_at_3_spaces() {
2648 assert_idempotent_mkdocs("1. Item\n\n continuation\n");
2650 }
2651
2652 #[test]
2653 fn idempotent_non_task_mkdocs_unordered_at_2_spaces() {
2654 assert_idempotent_mkdocs("- Item\n\n continuation\n");
2656 }
2657
2658 #[test]
2659 fn idempotent_non_task_mkdocs_fence_compound() {
2660 assert_idempotent_mkdocs("1. Item\n\n ```toml\n k = 1\n ```\n");
2662 }
2663
2664 #[test]
2667 fn aligned_tight_zero_indent_continuation_flagged() {
2668 let content = "- this is a long line\nthat continues on a second line\n";
2672 let warnings = check_aligned(content);
2673 assert_eq!(warnings.len(), 1);
2674 assert_eq!(warnings[0].line, 2);
2675 assert_eq!(
2676 fix_aligned(content),
2677 "- this is a long line\n that continues on a second line\n"
2678 );
2679 }
2680
2681 #[test]
2682 fn aligned_full_issue_example_made_consistent() {
2683 let content = "- this is a long line\n\
2686 that continues on a second line\n\
2687 - this is another long line\n\
2688 \x20\x20that continues on the next line\n\
2689 - yet again a long line\n\
2690 and still inconsistently spaced\n\
2691 \x20\x20and even worse\n";
2692 let expected = "- this is a long line\n\
2693 \x20\x20that continues on a second line\n\
2694 - this is another long line\n\
2695 \x20\x20that continues on the next line\n\
2696 - yet again a long line\n\
2697 \x20\x20and still inconsistently spaced\n\
2698 \x20\x20and even worse\n";
2699 assert_eq!(fix_aligned(content), expected);
2700 assert_eq!(fix_aligned(expected), expected);
2702 }
2703
2704 #[test]
2705 fn aligned_already_aligned_not_flagged() {
2706 let content = "- item\n continuation at content column\n";
2707 assert!(check_aligned(content).is_empty());
2708 }
2709
2710 #[test]
2711 fn aligned_tight_partial_indent_flagged() {
2712 let content = "- item\n continuation\n";
2714 let warnings = check_aligned(content);
2715 assert_eq!(warnings.len(), 1);
2716 assert_eq!(fix_aligned(content), "- item\n continuation\n");
2717 }
2718
2719 #[test]
2720 fn aligned_post_blank_zero_indent_still_new_paragraph() {
2721 let content = "- item\n\nnew paragraph\n";
2724 assert!(check_aligned(content).is_empty());
2725 assert_eq!(fix_aligned(content), content);
2726 }
2727
2728 #[test]
2731 fn aligned_top_level_blockquote_after_list_untouched() {
2732 let content = "- item\n> quote\n";
2736 assert!(check_aligned(content).is_empty());
2737 assert_eq!(fix_aligned(content), content);
2738 }
2739
2740 #[test]
2741 fn aligned_top_level_fence_after_list_untouched() {
2742 let content = "- item\n```\ncode\n```\n";
2743 assert!(check_aligned(content).is_empty());
2744 assert_eq!(fix_aligned(content), content);
2745 }
2746
2747 #[test]
2748 fn aligned_top_level_table_after_list_untouched() {
2749 let content = "- item\n| a | b |\n|---|---|\n| 1 | 2 |\n";
2750 assert!(check_aligned(content).is_empty());
2751 assert_eq!(fix_aligned(content), content);
2752 }
2753
2754 #[test]
2757 fn aligned_nested_tight_lazy_continuation_aligns_to_inner() {
2758 let content = "- Outer\n - Inner\ncontinuation\n";
2763 let warnings = check_aligned(content);
2764 assert_eq!(warnings.len(), 1);
2765 assert_eq!(fix_aligned(content), "- Outer\n - Inner\n continuation\n");
2766 }
2767
2768 #[test]
2769 fn aligned_nested_continuation_already_aligned_not_flagged() {
2770 let content = "- L1\n - L2\n cont of L2 at 4\n";
2771 assert!(check_aligned(content).is_empty());
2772 }
2773
2774 #[test]
2775 fn aligned_nested_idempotent() {
2776 let content = "- Outer\n - Inner\ncontinuation\n";
2777 let once = fix_aligned(content);
2778 assert_eq!(fix_aligned(&once), once);
2779 }
2780
2781 #[test]
2782 fn aligned_three_level_nesting_aligns_to_innermost() {
2783 let content = "- L1\n - L2\n - L3\ncont\n";
2786 assert_eq!(fix_aligned(content), "- L1\n - L2\n - L3\n cont\n");
2787 }
2788
2789 #[test]
2790 fn aligned_continuation_after_sibling_owned_by_last_item() {
2791 let content = "- a\n- b\nlazy\n";
2794 assert_eq!(fix_aligned(content), "- a\n- b\n lazy\n");
2795 }
2796
2797 #[test]
2798 fn aligned_multi_digit_ordered_marker_aligns_to_content_column() {
2799 let content = "10. Item\nwrap\n";
2800 assert_eq!(fix_aligned(content), "10. Item\n wrap\n");
2801 }
2802
2803 #[test]
2804 fn aligned_setext_heading_after_list_left_alone() {
2805 let content = "- item\nText\n===\n";
2808 assert!(check_aligned(content).is_empty());
2809 assert_eq!(fix_aligned(content), content);
2810 }
2811
2812 #[test]
2813 fn aligned_latent_marker_in_continuation_is_idempotent() {
2814 let content = "# \n- \n``\n2. \n![]()";
2820 let once = fix_aligned(content);
2821 assert_eq!(fix_aligned(&once), once, "fix must be idempotent in one pass");
2822 assert_eq!(once, content, "item with a latent marker is left untouched");
2823 }
2824
2825 #[test]
2826 fn aligned_latent_table_in_continuation_is_idempotent() {
2827 let content = "- \n![`]()\n| | ` |\n| --- | --- |";
2832 let once = fix_aligned(content);
2833 assert_eq!(fix_aligned(&once), once, "fix must be idempotent in one pass");
2834 assert_eq!(once, content, "item with a latent table is left untouched");
2835 }
2836
2837 #[test]
2838 fn aligned_blockquote_nested_list_not_touched() {
2839 let content = "> - item\n> wrap\n";
2843 assert!(check_aligned(content).is_empty());
2844 assert_eq!(fix_aligned(content), content);
2845 }
2846
2847 #[test]
2850 fn aligned_task_post_checkbox_column_accepted() {
2851 let content = "- [ ] Task\n wrap\n";
2854 assert!(check_aligned(content).is_empty());
2855 assert_eq!(fix_aligned(content), content);
2856 }
2857
2858 #[test]
2859 fn aligned_task_under_indent_snaps_to_content_column() {
2860 let content = "- [ ] Task\nwrap\n";
2861 let warnings = check_aligned(content);
2862 assert_eq!(warnings.len(), 1);
2863 assert_eq!(fix_aligned(content), "- [ ] Task\n wrap\n");
2864 }
2865
2866 #[test]
2869 fn aligned_mkdocs_tight_under_indent_snaps_to_four() {
2870 let content = "- item\nwrap\n";
2872 let warnings = check_aligned_mkdocs(content);
2873 assert_eq!(warnings.len(), 1);
2874 let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
2875 assert_eq!(aligned_rule().fix(&ctx).unwrap(), "- item\n wrap\n");
2876 }
2877
2878 #[test]
2881 fn any_default_does_not_flag_tight_lazy_continuation() {
2882 let content = "- item\nwrapped at zero indent\n";
2884 assert!(check(content).is_empty());
2885 assert_eq!(fix(content), content);
2886 }
2887
2888 #[test]
2889 fn from_config_aligned_enables_tight_flagging() {
2890 let mut config = crate::config::Config::default();
2892 let mut rule_config = crate::config::RuleConfig::default();
2893 rule_config
2894 .values
2895 .insert("style".to_string(), toml::Value::String("aligned".to_string()));
2896 config.rules.insert("MD077".to_string(), rule_config);
2897
2898 let rule = MD077ListContinuationIndent::from_config(&config);
2899 let ctx = LintContext::new("- item\nwrap\n", MarkdownFlavor::Standard, None);
2900 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
2901 }
2902
2903 #[test]
2904 fn from_config_default_is_any() {
2905 let config = crate::config::Config::default();
2907 let rule = MD077ListContinuationIndent::from_config(&config);
2908 let ctx = LintContext::new("- item\nwrap\n", MarkdownFlavor::Standard, None);
2909 assert!(rule.check(&ctx).unwrap().is_empty());
2910 }
2911
2912 #[test]
2913 fn from_config_indent_sets_fixed_requirement() {
2914 let mut config = crate::config::Config::default();
2917 let mut rule_config = crate::config::RuleConfig::default();
2918 rule_config.values.insert("indent".to_string(), toml::Value::Integer(4));
2919 config.rules.insert("MD077".to_string(), rule_config);
2920
2921 let rule = MD077ListContinuationIndent::from_config(&config);
2922
2923 let ok_ctx = LintContext::new("- item\n wrap\n", MarkdownFlavor::Standard, None);
2925 assert!(rule.check(&ok_ctx).unwrap().is_empty());
2926 assert_eq!(rule.fix(&ok_ctx).unwrap(), "- item\n wrap\n");
2927
2928 let bad_ctx = LintContext::new("- item\n\n wrap\n", MarkdownFlavor::Standard, None);
2931 let warnings = rule.check(&bad_ctx).unwrap();
2932 assert_eq!(warnings.len(), 1);
2933 assert!(warnings[0].message.contains("needs 4 spaces"));
2934 }
2935
2936 #[test]
2937 fn from_config_indent_applies_per_nested_marker() {
2938 let mut config = crate::config::Config::default();
2941 let mut rule_config = crate::config::RuleConfig::default();
2942 rule_config.values.insert("indent".to_string(), toml::Value::Integer(4));
2943 config.rules.insert("MD077".to_string(), rule_config);
2944
2945 let rule = MD077ListContinuationIndent::from_config(&config);
2946 let ctx = LintContext::new("- a\n - b\n wrap\n", MarkdownFlavor::Standard, None);
2947 let warnings = rule.check(&ctx).unwrap();
2948 assert!(
2949 warnings.is_empty(),
2950 "continuation at 6 spaces should pass: {warnings:?}"
2951 );
2952 }
2953
2954 fn rule_with(settings: &[(&str, toml::Value)]) -> Box<dyn Rule> {
2956 let mut config = crate::config::Config::default();
2957 let mut rule_config = crate::config::RuleConfig::default();
2958 for (key, value) in settings {
2959 rule_config.values.insert((*key).to_string(), value.clone());
2960 }
2961 config.rules.insert("MD077".to_string(), rule_config);
2962 MD077ListContinuationIndent::from_config(&config)
2963 }
2964
2965 #[test]
2966 fn configured_indent_cannot_lower_the_strict_flavor_minimum() {
2967 let rule = rule_with(&[("indent", toml::Value::Integer(2))]);
2971
2972 let two = LintContext::new("- item\n\n wrap\n", MarkdownFlavor::MkDocs, None);
2973 let warnings = rule.check(&two).unwrap();
2974 assert_eq!(warnings.len(), 1, "2 spaces is below the MkDocs minimum: {warnings:?}");
2975 assert!(
2976 warnings[0].message.contains("needs 4 spaces") && warnings[0].message.contains("MkDocs"),
2977 "the requirement comes from MkDocs, so the message must say so: {}",
2978 warnings[0].message
2979 );
2980 assert_eq!(rule.fix(&two).unwrap(), "- item\n\n wrap\n");
2981
2982 let four = LintContext::new("- item\n\n wrap\n", MarkdownFlavor::MkDocs, None);
2984 assert!(rule.check(&four).unwrap().is_empty());
2985
2986 let standard = LintContext::new("- item\n\n wrap\n", MarkdownFlavor::Standard, None);
2990 assert_eq!(rule.check(&standard).unwrap().len(), 1);
2991 assert_eq!(rule.fix(&standard).unwrap(), "- item\n\n wrap\n");
2992 }
2993
2994 #[test]
2995 fn configured_indent_can_raise_the_strict_flavor_minimum() {
2996 let rule = rule_with(&[("indent", toml::Value::Integer(6))]);
2999 let ctx = LintContext::new("- item\n\n wrap\n", MarkdownFlavor::MkDocs, None);
3000 let warnings = rule.check(&ctx).unwrap();
3001 assert_eq!(warnings.len(), 1);
3002 assert!(
3003 warnings[0].message.contains("needs 6 spaces"),
3004 "configured 6 must win over the 4-space floor: {}",
3005 warnings[0].message
3006 );
3007 assert_eq!(rule.fix(&ctx).unwrap(), "- item\n\n wrap\n");
3008 }
3009
3010 #[test]
3011 fn configured_indent_message_does_not_claim_a_structural_consequence() {
3012 let rule = rule_with(&[("indent", toml::Value::Integer(4))]);
3016 let ctx = LintContext::new("- item\n\n wrap\n", MarkdownFlavor::Standard, None);
3017 let warnings = rule.check(&ctx).unwrap();
3018 assert_eq!(warnings.len(), 1);
3019 assert!(
3020 warnings[0].message.contains("match the configured indent"),
3021 "expected the configured-indent wording, got: {}",
3022 warnings[0].message
3023 );
3024 assert!(
3025 !warnings[0].message.contains("remain part of the list"),
3026 "the content does remain part of the list here: {}",
3027 warnings[0].message
3028 );
3029
3030 assert!(check("- item\n\n wrap\n").is_empty());
3033
3034 let escaping = check("- item\n\n wrap\n");
3037 assert_eq!(escaping.len(), 1);
3038 assert!(
3039 escaping[0].message.contains("remain part of the list"),
3040 "unconfigured under-indent keeps its structural message, got: {}",
3041 escaping[0].message
3042 );
3043 }
3044
3045 #[test]
3046 fn configured_indent_leaves_tight_lazy_continuation_to_style() {
3047 let any = rule_with(&[("indent", toml::Value::Integer(4))]);
3051 let ctx = LintContext::new("- item\n wrap\n", MarkdownFlavor::Standard, None);
3052 assert!(
3053 any.check(&ctx).unwrap().is_empty(),
3054 "style = any accepts tight lazy continuation"
3055 );
3056
3057 let aligned = rule_with(&[
3058 ("indent", toml::Value::Integer(4)),
3059 ("style", toml::Value::String("aligned".to_string())),
3060 ]);
3061 let warnings = aligned.check(&ctx).unwrap();
3062 assert_eq!(warnings.len(), 1, "style = aligned raises it: {warnings:?}");
3063 assert!(warnings[0].message.contains("expected 4"));
3064 assert_eq!(aligned.fix(&ctx).unwrap(), "- item\n wrap\n");
3065 }
3066
3067 #[test]
3068 fn aligned_tight_underindented_fence_inside_item_left_alone() {
3069 let content = "- item\n ```\n code\n ```\n";
3073 assert!(check_aligned(content).is_empty());
3074 assert_eq!(fix_aligned(content), content);
3075 }
3076
3077 #[test]
3078 fn aligned_task_under_indent_fix_is_idempotent() {
3079 let content = "- [ ] Task\nwrap\n";
3080 let once = fix_aligned(content);
3081 assert_eq!(fix_aligned(&once), once);
3082 }
3083
3084 #[test]
3085 fn aligned_partial_indent_fix_is_idempotent() {
3086 let content = "- item\n continuation\n";
3087 let once = fix_aligned(content);
3088 assert_eq!(fix_aligned(&once), once);
3089 }
3090}