1use crate::rule::{LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
5use crate::utils::list_fix_guard::{Allowed, drop_structure_changing_fixes};
6use crate::utils::list_indent_shift::{Nesting, move_owned_lines};
7use std::collections::HashMap;
8
9pub mod md007_config;
10use md007_config::MD007Config;
11
12#[derive(Debug, Clone, Default)]
13pub struct MD007ULIndent {
14 config: MD007Config,
15}
16
17impl MD007ULIndent {
18 pub fn new(indent: usize) -> Self {
19 Self {
20 config: MD007Config {
21 indent: crate::types::IndentSize::from_const(indent as u8),
22 start_indented: false,
23 start_indent: crate::types::IndentSize::from_const(2),
24 style: md007_config::IndentStyle::TextAligned,
25 style_explicit: false, indent_explicit: false, },
28 }
29 }
30
31 pub fn from_config_struct(config: MD007Config) -> Self {
32 Self { config }
33 }
34
35 fn char_pos_to_visual_column(content: &str, char_pos: usize) -> usize {
37 let mut visual_col = 0;
38
39 for (current_pos, ch) in content.chars().enumerate() {
40 if current_pos >= char_pos {
41 break;
42 }
43 if ch == '\t' {
44 visual_col = (visual_col / 4 + 1) * 4;
46 } else {
47 visual_col += 1;
48 }
49 }
50 visual_col
51 }
52
53 fn indent_relative_to_depth(
77 ctx: &crate::lint_context::LintContext,
78 line_info: &crate::lint_context::LineInfo,
79 depth: usize,
80 ) -> usize {
81 if depth == 0 {
82 return line_info.visual_indent;
83 }
84 let line_content = line_info.content(ctx.content);
89 let mut remaining = line_content;
90 let mut content_start = 0;
91 let mut stripped_levels = 0;
92 while stripped_levels < depth {
93 let trimmed = remaining.trim_start();
94 if !trimmed.starts_with('>') {
95 break;
96 }
97 content_start += remaining.len() - trimmed.len();
98 content_start += 1;
99 let after_gt = &trimmed[1..];
100 if let Some(stripped) = after_gt.strip_prefix(' ') {
101 content_start += 1;
102 remaining = stripped;
103 } else if let Some(stripped) = after_gt.strip_prefix('\t') {
104 content_start += 1;
105 remaining = stripped;
106 } else {
107 remaining = after_gt;
108 }
109 stripped_levels += 1;
110 }
111 let content_after_prefix = &line_content[content_start..];
112 let ws_chars = content_after_prefix
113 .chars()
114 .take_while(|c| *c == ' ' || *c == '\t')
115 .count();
116 Self::char_pos_to_visual_column(content_after_prefix, ws_chars)
117 }
118
119 fn terminate_closed_items(
120 ctx: &crate::lint_context::LintContext,
121 line_info: &crate::lint_context::LineInfo,
122 list_stack: &mut Vec<(usize, usize, bool, usize, usize, bool, usize)>,
123 line_bq_depth: usize,
124 ) {
125 while let Some(&(_, _, _, content_col, item_bq_depth, _, _)) = list_stack.last() {
126 let closed = match item_bq_depth.cmp(&line_bq_depth) {
127 std::cmp::Ordering::Greater => true,
129 std::cmp::Ordering::Equal | std::cmp::Ordering::Less => {
138 content_col > Self::indent_relative_to_depth(ctx, line_info, item_bq_depth)
139 }
140 };
141 if closed {
142 list_stack.pop();
143 } else {
144 break;
145 }
146 }
147 }
148
149 fn calculate_expected_indent(
158 &self,
159 nesting_level: usize,
160 parent_info: Option<(bool, usize)>, ) -> usize {
162 if nesting_level == 0 {
163 return 0;
164 }
165
166 if self.config.style_explicit {
168 return match self.config.style {
169 md007_config::IndentStyle::Fixed => nesting_level * self.config.indent.get() as usize,
170 md007_config::IndentStyle::TextAligned => {
171 parent_info.map_or(nesting_level * 2, |(_, content_col)| content_col)
172 }
173 };
174 }
175
176 if self.config.indent_explicit {
179 match parent_info {
180 Some((true, parent_content_col)) => {
181 return parent_content_col;
184 }
185 _ => {
186 return nesting_level * self.config.indent.get() as usize;
188 }
189 }
190 }
191
192 match parent_info {
194 Some((true, parent_content_col)) => {
195 parent_content_col
198 }
199 Some((false, parent_content_col)) => {
200 let parent_level = nesting_level.saturating_sub(1);
204 let expected_parent_marker = parent_level * self.config.indent.get() as usize;
205 let parent_marker_col = parent_content_col.saturating_sub(2);
207
208 if parent_marker_col == expected_parent_marker {
209 nesting_level * self.config.indent.get() as usize
211 } else {
212 parent_content_col
214 }
215 }
216 None => {
217 nesting_level * self.config.indent.get() as usize
219 }
220 }
221 }
222}
223
224impl Rule for MD007ULIndent {
225 fn name(&self) -> &'static str {
226 "MD007"
227 }
228
229 fn description(&self) -> &'static str {
230 "Unordered list indentation"
231 }
232
233 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
234 let mut warnings = Vec::new();
235 let mut list_stack: Vec<(usize, usize, bool, usize, usize, bool, usize)> = Vec::new(); let mut moves = HashMap::new();
239
240 for (line_idx, line_info) in ctx.lines.iter().enumerate() {
241 let parsed_list_item = ctx.list_item_on_line(line_idx + 1);
242 let is_skipped_region = |info: &crate::lint_context::LineInfo| {
244 info.in_code_block || info.in_front_matter || info.in_mkdocstrings || info.in_footnote_definition
245 };
246 let opens_fence_on_marker_line = parsed_list_item
259 .and_then(|item| line_info.content(ctx.content).get(item.content_column()..))
260 .is_some_and(|after_marker| {
261 let after_marker = after_marker.trim_start();
262 after_marker.starts_with("```") || after_marker.starts_with("~~~")
263 });
264 let fence_opening_marker_line = opens_fence_on_marker_line
265 && line_info.in_code_block
266 && !line_info.in_front_matter
267 && !line_info.in_mkdocstrings
268 && !line_info.in_footnote_definition;
269 if is_skipped_region(line_info) && !fence_opening_marker_line {
270 let region_start = line_idx == 0 || !is_skipped_region(&ctx.lines[line_idx - 1]);
277 if region_start && !line_info.is_blank {
278 let bq_depth = line_info.blockquote.as_ref().map_or(0, |bq| bq.nesting_level);
279 Self::terminate_closed_items(ctx, line_info, &mut list_stack, bq_depth);
280 }
281 continue;
282 }
283
284 if let Some(list_item) = parsed_list_item {
286 let (content_for_calculation, adjusted_marker_column) = if line_info.blockquote.is_some() {
290 let line_content = line_info.content(ctx.content);
292 let mut remaining = line_content;
293 let mut content_start = 0;
294
295 loop {
296 let trimmed = remaining.trim_start();
297 if !trimmed.starts_with('>') {
298 break;
299 }
300 content_start += remaining.len() - trimmed.len();
302 content_start += 1;
304 let after_gt = &trimmed[1..];
305 if let Some(stripped) = after_gt.strip_prefix(' ') {
307 content_start += 1;
308 remaining = stripped;
309 } else if let Some(stripped) = after_gt.strip_prefix('\t') {
310 content_start += 1;
311 remaining = stripped;
312 } else {
313 remaining = after_gt;
314 }
315 }
316
317 let content_after_prefix = &line_content[content_start..];
319 let adjusted_col = if list_item.marker_column() >= content_start {
321 list_item.marker_column() - content_start
322 } else {
323 list_item.marker_column()
325 };
326 (content_after_prefix.to_string(), adjusted_col)
327 } else {
328 (line_info.content(ctx.content).to_string(), list_item.marker_column())
329 };
330
331 let visual_marker_column =
333 Self::char_pos_to_visual_column(&content_for_calculation, adjusted_marker_column);
334
335 let visual_content_column = if line_info.blockquote.is_some() {
337 let adjusted_content_col =
339 if list_item.content_column() >= (line_info.byte_len - content_for_calculation.len()) {
340 list_item.content_column() - (line_info.byte_len - content_for_calculation.len())
341 } else {
342 list_item.content_column()
343 };
344 Self::char_pos_to_visual_column(&content_for_calculation, adjusted_content_col)
345 } else {
346 Self::char_pos_to_visual_column(line_info.content(ctx.content), list_item.content_column())
347 };
348
349 let visual_marker_for_nesting = if visual_marker_column == 1 && self.config.indent.get() != 1 {
353 0
354 } else {
355 visual_marker_column
356 };
357
358 let bq_depth = line_info.blockquote.as_ref().map_or(0, |bq| bq.nesting_level);
360
361 while let Some(&(indent, _, _, _, item_bq_depth, _, _)) = list_stack.last() {
364 if item_bq_depth == bq_depth && indent >= visual_marker_for_nesting {
365 list_stack.pop();
366 } else if item_bq_depth > bq_depth {
367 list_stack.pop();
369 } else {
370 break;
371 }
372 }
373
374 while let Some(&(_, _, _, content_col, item_bq_depth, _, _)) = list_stack.last() {
387 if item_bq_depth < bq_depth
388 && content_col > Self::indent_relative_to_depth(ctx, line_info, item_bq_depth)
389 {
390 list_stack.pop();
391 } else {
392 break;
393 }
394 }
395
396 if list_item.is_ordered() {
398 list_stack.push((
401 visual_marker_column,
402 line_idx,
403 true,
404 visual_content_column,
405 bq_depth,
406 false,
407 visual_content_column,
408 ));
409 continue;
410 }
411
412 let threshold_ok = list_stack
436 .iter()
437 .any(|item| item.4 == bq_depth && item.2 && item.3 <= visual_marker_column);
438 if ctx.flavor != crate::config::MarkdownFlavor::MkDocs
449 && threshold_ok
450 && self.config.style_explicit
451 && self.config.style == md007_config::IndentStyle::Fixed
452 {
453 while let Some(&(_, _, _, _, item_bq_depth, _, source_content_col)) = list_stack.last() {
454 if item_bq_depth == bq_depth && source_content_col > visual_marker_column {
455 list_stack.pop();
456 } else {
457 break;
458 }
459 }
460 }
461 let chain_ok = list_stack
462 .iter()
463 .rev()
464 .find(|item| item.4 == bq_depth)
465 .is_some_and(|item| item.2 || item.5);
466 let ordered_chain = ctx.flavor != crate::config::MarkdownFlavor::MkDocs && threshold_ok && chain_ok;
467 let clamp_to_parent = ordered_chain
473 && self.config.style_explicit
474 && self.config.style == md007_config::IndentStyle::Fixed;
475 if ordered_chain && !clamp_to_parent {
476 list_stack.push((
477 visual_marker_column,
478 line_idx,
479 false,
480 visual_content_column,
481 bq_depth,
482 true,
483 visual_content_column,
484 ));
485 continue;
486 }
487
488 let nesting_level = list_stack.iter().filter(|item| item.4 == bq_depth).count();
490
491 let parent_info = list_stack
493 .iter()
494 .rev()
495 .find(|item| item.4 == bq_depth)
496 .map(|&(_, _, is_ordered, content_col, _, _, _)| (is_ordered, content_col));
497
498 let mut expected_indent = if self.config.start_indented && nesting_level == 0 {
504 self.config.start_indent.get() as usize
505 } else {
506 self.calculate_expected_indent(nesting_level, parent_info)
507 };
508
509 if clamp_to_parent && let Some((_, parent_content_col)) = parent_info {
514 expected_indent = expected_indent.max(parent_content_col);
515 }
516
517 let also_acceptable = if !clamp_to_parent
523 && self.config.indent_explicit
524 && parent_info.is_some_and(|(is_ordered, _)| is_ordered)
525 {
526 Some(nesting_level * self.config.indent.get() as usize)
527 } else {
528 None
529 };
530
531 if ctx.flavor == crate::config::MarkdownFlavor::MkDocs
535 && let Some(&(parent_marker_col, _, true, _, _, _, _)) =
536 list_stack.iter().rev().find(|item| item.4 == bq_depth && item.2)
537 {
538 expected_indent = expected_indent.max(parent_marker_col + 4);
539 }
540
541 let accepted_indent = if also_acceptable.is_some_and(|alt| visual_marker_column == alt) {
547 visual_marker_column
548 } else {
549 expected_indent
550 };
551 let marker_width = visual_content_column.saturating_sub(visual_marker_column);
561 let expected_content_visual_col = accepted_indent + marker_width;
562 list_stack.push((
567 visual_marker_column,
568 line_idx,
569 false,
570 expected_content_visual_col,
571 bq_depth,
572 clamp_to_parent,
573 visual_content_column,
574 ));
575
576 if !self.config.start_indented && nesting_level == 0 && visual_marker_column == 0 {
582 continue;
583 }
584
585 if visual_marker_column != expected_indent && also_acceptable != Some(visual_marker_column) {
586 if let Some(alt) = also_acceptable {
588 expected_indent = alt;
589 }
590 let fix = {
592 let correct_indent = " ".repeat(expected_indent);
593
594 let replacement = if line_info.blockquote.is_some() {
597 let mut blockquote_count = 0;
599 for ch in line_info.content(ctx.content).chars() {
600 if ch == '>' {
601 blockquote_count += 1;
602 } else if ch != ' ' && ch != '\t' {
603 break;
604 }
605 }
606 let blockquote_prefix = if blockquote_count > 1 {
608 (0..blockquote_count)
609 .map(|_| "> ")
610 .collect::<String>()
611 .trim_end()
612 .to_string()
613 } else {
614 ">".to_string()
615 };
616 format!("{blockquote_prefix} {correct_indent}")
619 } else {
620 correct_indent
621 };
622
623 let start_byte = line_info.byte_offset;
626 let mut end_byte = line_info.byte_offset;
627
628 for (i, ch) in line_info.content(ctx.content).chars().enumerate() {
630 if i >= list_item.marker_column() {
631 break;
632 }
633 end_byte += ch.len_utf8();
634 }
635
636 Some(crate::rule::Fix::new(start_byte..end_byte, replacement))
637 };
638
639 warnings.push(LintWarning {
640 rule_name: Some(self.name().to_string()),
641 message: format!(
642 "Expected {expected_indent} spaces for indent depth {nesting_level}, found {visual_marker_column}"
643 ),
644 line: line_idx + 1, column: 1, end_line: line_idx + 1,
647 end_column: visual_marker_column + 1, severity: Severity::Warning,
649 fix,
650 });
651 moves.insert(
652 line_idx,
653 (
654 warnings.len() - 1,
655 expected_indent as isize - visual_marker_column as isize,
656 ),
657 );
658 }
659 } else if !line_info.is_blank {
660 let bq_depth = line_info.blockquote.as_ref().map_or(0, |bq| bq.nesting_level);
687 let prev_line = line_idx.checked_sub(1).map(|i| &ctx.lines[i]);
688 let prev_blank = prev_line.is_none_or(|p| p.is_blank);
689 let prev_bq_depth = prev_line
690 .and_then(|p| p.blockquote.as_ref())
691 .map_or(0, |bq| bq.nesting_level);
692 let same_container = prev_bq_depth == bq_depth;
693 let text = line_info
694 .blockquote
695 .as_ref()
696 .map_or_else(|| line_info.content(ctx.content), |bq| bq.content.as_str());
697 let trimmed = text.trim_start();
698 let starts_like_list_marker = match trimmed.as_bytes().first() {
699 Some(b'-' | b'*' | b'+') => {
700 matches!(trimmed.as_bytes().get(1), Some(b' ' | b'\t'))
701 }
702 Some(c) if c.is_ascii_digit() => {
703 let after_digits = trimmed.trim_start_matches(|ch: char| ch.is_ascii_digit());
707 let num_digits = trimmed.len() - after_digits.len();
708 let mut rest = after_digits.chars();
709 (1..=9).contains(&num_digits)
710 && matches!(rest.next(), Some('.' | ')'))
711 && matches!(rest.next(), Some(' ' | '\t') | None)
712 }
713 _ => false,
714 };
715 let prev_is_open_paragraph = prev_line.is_some_and(|p| {
722 !p.is_blank
723 && !p.in_code_block
724 && p.heading.is_none()
725 && !p.is_horizontal_rule
726 && !p.in_html_block
727 && !p.in_html_comment
728 && !p.is_div_marker
729 });
730 let is_lazy_paragraph_continuation = !prev_blank
731 && prev_is_open_paragraph
732 && same_container
733 && !starts_like_list_marker
734 && line_info.heading.is_none()
735 && !line_info.is_horizontal_rule
736 && !line_info.in_code_block
737 && !line_info.in_html_block
738 && !line_info.in_html_comment
739 && !line_info.is_div_marker;
740 if is_lazy_paragraph_continuation {
741 continue;
743 }
744 Self::terminate_closed_items(ctx, line_info, &mut list_stack, bq_depth);
745 }
746 }
747
748 move_owned_lines(ctx, &moves, Nesting::Absolute, &mut warnings);
752 drop_structure_changing_fixes(ctx, &mut warnings, Allowed::Nothing);
753 Ok(warnings)
754 }
755
756 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
758 let warnings = self.check(ctx)?;
760 let warnings =
761 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
762
763 if warnings.is_empty() {
765 return Ok(ctx.content.to_string());
766 }
767
768 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings).map_err(LintError::InvalidInput)
769 }
770
771 fn category(&self) -> RuleCategory {
773 RuleCategory::List
774 }
775
776 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
778 if ctx.content.is_empty() || !ctx.likely_has_lists() {
780 return true;
781 }
782 !ctx.has_unordered_list_items()
784 }
785
786 fn as_any(&self) -> &dyn std::any::Any {
787 self
788 }
789
790 crate::impl_rule_config_sections!(MD007Config);
791
792 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
793 where
794 Self: Sized,
795 {
796 let mut rule_config = crate::rule_config_serde::load_rule_config::<MD007Config>(config);
797
798 if let Some(rule_cfg) = config.rules.get("MD007") {
800 rule_config.style_explicit = rule_cfg.values.contains_key("style");
801 rule_config.indent_explicit = rule_cfg.values.contains_key("indent");
802
803 if rule_config.indent_explicit
807 && rule_config.style_explicit
808 && rule_config.style == md007_config::IndentStyle::TextAligned
809 {
810 eprintln!(
811 "{} MD007: 'indent' has no effect when 'style = \"text-aligned\"'. \
812 Text-aligned style ignores indent and aligns nested items with parent text. \
813 To use fixed {} space increments, either remove 'style' or set 'style = \"fixed\"'.",
814 crate::utils::warning_label("config warning"),
815 rule_config.indent.get()
816 );
817 }
818 }
819
820 if config.markdown_flavor() == crate::config::MarkdownFlavor::MkDocs {
823 if rule_config.indent_explicit && rule_config.indent.get() < 4 {
824 eprintln!(
825 "{} MD007: MkDocs flavor requires indent >= 4 \
826 (Python-Markdown enforces 4-space indentation). \
827 Overriding indent={} to indent=4.",
828 crate::utils::warning_label("config warning"),
829 rule_config.indent.get()
830 );
831 }
832 if rule_config.style_explicit && rule_config.style == md007_config::IndentStyle::TextAligned {
833 eprintln!(
834 "{} MD007: MkDocs flavor requires style=\"fixed\" \
835 (Python-Markdown uses fixed 4-space indentation). \
836 Overriding style=\"text-aligned\" to style=\"fixed\".",
837 crate::utils::warning_label("config warning")
838 );
839 }
840 if rule_config.indent.get() < 4 {
841 rule_config.indent = crate::types::IndentSize::from_const(4);
842 }
843 rule_config.style = md007_config::IndentStyle::Fixed;
844 }
845
846 Box::new(Self::from_config_struct(rule_config))
847 }
848}
849
850#[cfg(test)]
851mod tests {
852 use super::*;
853 use crate::lint_context::LintContext;
854 use crate::rule::Rule;
855 use indoc::indoc;
856
857 fn fix_md007(content: &str) -> String {
858 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
859 MD007ULIndent::default().fix(&ctx).unwrap()
860 }
861
862 #[test]
863 fn test_fix_keeps_a_parent_paragraph_out_of_the_shifted_child() {
864 let content = "* parent\n\n * child\n\n Parent paragraph.\n";
867 assert_eq!(fix_md007(content), "* parent\n\n * child\n\n Parent paragraph.\n");
868
869 let quoted = "> * parent\n>\n> * child\n>\n> Parent paragraph.\n";
870 assert_eq!(
871 fix_md007(quoted),
872 "> * parent\n>\n> * child\n>\n> Parent paragraph.\n"
873 );
874 }
875
876 #[test]
877 fn test_fix_declines_when_the_child_would_capture_a_parent_code_block() {
878 let content = "* parent\n\n * child\n\n ```\n code\n ```\n";
881 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
882 let warnings = MD007ULIndent::default().check(&ctx).unwrap();
883 assert_eq!(warnings.len(), 1);
884 assert!(warnings[0].fix.is_none());
885 assert_eq!(fix_md007(content), content);
886 }
887
888 #[test]
889 fn test_unfixable_list_leaves_other_lists_fixable() {
890 let content = "* a\n * b\n\nParagraph.\n\n* parent\n\n * child\n\n ```\n code\n ```\n";
892 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
893 let warnings = MD007ULIndent::default().check(&ctx).unwrap();
894 assert_eq!(warnings.len(), 2, "{warnings:?}");
895 assert!(warnings[0].fix.is_some(), "{warnings:?}");
896 assert!(warnings[1].fix.is_none(), "{warnings:?}");
897 assert_eq!(
898 fix_md007(content),
899 "* a\n * b\n\nParagraph.\n\n* parent\n\n * child\n\n ```\n code\n ```\n"
900 );
901 }
902
903 #[test]
904 fn test_unfixable_list_leaves_a_later_list_fixable() {
905 let content = "* parent\n\n * child\n\n ```\n code\n ```\n\nParagraph.\n\n* a\n * b\n";
906 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
907 let warnings = MD007ULIndent::default().check(&ctx).unwrap();
908 assert_eq!(warnings.len(), 2, "{warnings:?}");
909 assert!(warnings[0].fix.is_none(), "{warnings:?}");
910 assert!(warnings[1].fix.is_some(), "{warnings:?}");
911 assert_eq!(
912 fix_md007(content),
913 "* parent\n\n * child\n\n ```\n code\n ```\n\nParagraph.\n\n* a\n * b\n"
914 );
915 }
916
917 #[test]
918 fn test_valid_list_indent() {
919 let rule = MD007ULIndent::default();
920 let content = "* Item 1\n * Item 2\n * Item 3";
921 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
922 let result = rule.check(&ctx).unwrap();
923 assert!(
924 result.is_empty(),
925 "Expected no warnings for valid indentation, but got {} warnings",
926 result.len()
927 );
928 }
929
930 #[test]
931 fn test_invalid_list_indent() {
932 let rule = MD007ULIndent::default();
933 let content = "* Item 1\n * Item 2\n * Item 3";
934 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
935 let result = rule.check(&ctx).unwrap();
936 assert_eq!(result.len(), 2);
937 assert_eq!(result[0].line, 2);
938 assert_eq!(result[0].column, 1);
939 assert_eq!(result[1].line, 3);
940 assert_eq!(result[1].column, 1);
941 }
942
943 #[test]
944 fn test_mixed_indentation() {
945 let rule = MD007ULIndent::default();
946 let content = "* Item 1\n * Item 2\n * Item 3\n * Item 4";
947 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
948 let result = rule.check(&ctx).unwrap();
949 assert_eq!(result.len(), 1);
950 assert_eq!(result[0].line, 3);
951 assert_eq!(result[0].column, 1);
952 }
953
954 #[test]
955 fn test_fix_indentation() {
956 let rule = MD007ULIndent::default();
957 let content = "* Item 1\n * Item 2\n * Item 3";
958 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
959 let result = rule.fix(&ctx).unwrap();
960 let expected = "* Item 1\n * Item 2\n * Item 3";
964 assert_eq!(result, expected);
965 }
966
967 #[test]
968 fn test_md007_in_yaml_code_block() {
969 let rule = MD007ULIndent::default();
970 let content = r#"```yaml
971repos:
972- repo: https://github.com/rvben/rumdl
973 rev: v0.5.0
974 hooks:
975 - id: rumdl-check
976```"#;
977 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
978 let result = rule.check(&ctx).unwrap();
979 assert!(
980 result.is_empty(),
981 "MD007 should not trigger inside a code block, but got warnings: {result:?}"
982 );
983 }
984
985 #[test]
986 fn test_blockquoted_list_indent() {
987 let rule = MD007ULIndent::default();
988 let content = "> * Item 1\n> * Item 2\n> * Item 3";
989 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
990 let result = rule.check(&ctx).unwrap();
991 assert!(
992 result.is_empty(),
993 "Expected no warnings for valid blockquoted list indentation, but got {result:?}"
994 );
995 }
996
997 #[test]
998 fn test_blockquoted_list_invalid_indent() {
999 let rule = MD007ULIndent::default();
1000 let content = "> * Item 1\n> * Item 2\n> * Item 3";
1001 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1002 let result = rule.check(&ctx).unwrap();
1003 assert_eq!(
1004 result.len(),
1005 2,
1006 "Expected 2 warnings for invalid blockquoted list indentation, got {result:?}"
1007 );
1008 assert_eq!(result[0].line, 2);
1009 assert_eq!(result[1].line, 3);
1010 }
1011
1012 #[test]
1013 fn test_nested_blockquote_list_indent() {
1014 let rule = MD007ULIndent::default();
1015 let content = "> > * Item 1\n> > * Item 2\n> > * Item 3";
1016 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1017 let result = rule.check(&ctx).unwrap();
1018 assert!(
1019 result.is_empty(),
1020 "Expected no warnings for valid nested blockquoted list indentation, but got {result:?}"
1021 );
1022 }
1023
1024 #[test]
1025 fn test_blockquote_list_with_code_block() {
1026 let rule = MD007ULIndent::default();
1027 let content = "> * Item 1\n> * Item 2\n> ```\n> code\n> ```\n> * Item 3";
1028 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1029 let result = rule.check(&ctx).unwrap();
1030 assert!(
1031 result.is_empty(),
1032 "MD007 should not trigger inside a code block within a blockquote, but got warnings: {result:?}"
1033 );
1034 }
1035
1036 #[test]
1037 fn test_properly_indented_lists() {
1038 let rule = MD007ULIndent::default();
1039
1040 let test_cases = vec![
1042 "* Item 1\n* Item 2",
1043 "* Item 1\n * Item 1.1\n * Item 1.1.1",
1044 "- Item 1\n - Item 1.1",
1045 "+ Item 1\n + Item 1.1",
1046 "* Item 1\n * Item 1.1\n* Item 2\n * Item 2.1",
1047 ];
1048
1049 for content in test_cases {
1050 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1051 let result = rule.check(&ctx).unwrap();
1052 assert!(
1053 result.is_empty(),
1054 "Expected no warnings for properly indented list:\n{}\nGot {} warnings",
1055 content,
1056 result.len()
1057 );
1058 }
1059 }
1060
1061 #[test]
1062 fn test_under_indented_lists() {
1063 let rule = MD007ULIndent::default();
1064
1065 let test_cases = vec![
1066 ("* Item 1\n * Item 1.1", 1, 2), ("* Item 1\n * Item 1.1\n * Item 1.1.1", 1, 3), ];
1069
1070 for (content, expected_warnings, line) in test_cases {
1071 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1072 let result = rule.check(&ctx).unwrap();
1073 assert_eq!(
1074 result.len(),
1075 expected_warnings,
1076 "Expected {expected_warnings} warnings for under-indented list:\n{content}"
1077 );
1078 if expected_warnings > 0 {
1079 assert_eq!(result[0].line, line);
1080 }
1081 }
1082 }
1083
1084 #[test]
1085 fn test_over_indented_lists() {
1086 let rule = MD007ULIndent::default();
1087
1088 let test_cases = vec![
1089 ("* Item 1\n * Item 1.1", 1, 2), ("* Item 1\n * Item 1.1", 1, 2), ("* Item 1\n * Item 1.1\n * Item 1.1.1", 1, 3), ];
1093
1094 for (content, expected_warnings, line) in test_cases {
1095 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1096 let result = rule.check(&ctx).unwrap();
1097 assert_eq!(
1098 result.len(),
1099 expected_warnings,
1100 "Expected {expected_warnings} warnings for over-indented list:\n{content}"
1101 );
1102 if expected_warnings > 0 {
1103 assert_eq!(result[0].line, line);
1104 }
1105 }
1106 }
1107
1108 #[test]
1109 fn test_custom_indent_2_spaces() {
1110 let rule = MD007ULIndent::new(2); let content = "* Item 1\n * Item 2\n * Item 3";
1112 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1113 let result = rule.check(&ctx).unwrap();
1114 assert!(result.is_empty());
1115 }
1116
1117 #[test]
1118 fn test_custom_indent_3_spaces() {
1119 let rule = MD007ULIndent::new(3);
1122
1123 let correct_content = "* Item 1\n * Item 2\n * Item 3";
1125 let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard, None);
1126 let result = rule.check(&ctx).unwrap();
1127 assert!(
1128 result.is_empty(),
1129 "Fixed style expects 0, 3, 6 spaces but got: {result:?}"
1130 );
1131
1132 let wrong_content = "* Item 1\n * Item 2\n * Item 3";
1134 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
1135 let result = rule.check(&ctx).unwrap();
1136 assert!(!result.is_empty(), "Should warn: expected 3 spaces, found 2");
1137 }
1138
1139 #[test]
1140 fn test_custom_indent_4_spaces() {
1141 let rule = MD007ULIndent::new(4);
1144
1145 let correct_content = "* Item 1\n * Item 2\n * Item 3";
1147 let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard, None);
1148 let result = rule.check(&ctx).unwrap();
1149 assert!(
1150 result.is_empty(),
1151 "Fixed style expects 0, 4, 8 spaces but got: {result:?}"
1152 );
1153
1154 let wrong_content = "* Item 1\n * Item 2\n * Item 3";
1156 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
1157 let result = rule.check(&ctx).unwrap();
1158 assert!(!result.is_empty(), "Should warn: expected 4 spaces, found 2");
1159 }
1160
1161 #[test]
1162 fn test_tab_indentation() {
1163 let rule = MD007ULIndent::default();
1164
1165 let content = "* Item 1\n * Item 2";
1171 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1172 let result = rule.check(&ctx).unwrap();
1173 assert_eq!(result.len(), 1, "Wrong indentation should trigger warning");
1174
1175 let fixed = rule.fix(&ctx).unwrap();
1177 assert_eq!(fixed, "* Item 1\n * Item 2");
1178
1179 let content_multi = "* Item 1\n * Item 2\n * Item 3";
1181 let ctx = LintContext::new(content_multi, crate::config::MarkdownFlavor::Standard, None);
1182 let fixed = rule.fix(&ctx).unwrap();
1183 assert_eq!(fixed, "* Item 1\n * Item 2\n * Item 3");
1186
1187 let content_mixed = "* Item 1\n * Item 2\n * Item 3";
1189 let ctx = LintContext::new(content_mixed, crate::config::MarkdownFlavor::Standard, None);
1190 let fixed = rule.fix(&ctx).unwrap();
1191 assert_eq!(fixed, "* Item 1\n * Item 2\n * Item 3");
1194 }
1195
1196 #[test]
1197 fn test_mixed_ordered_unordered_lists() {
1198 let rule = MD007ULIndent::default();
1199
1200 let content = r#"1. Ordered item
1203 * Unordered sub-item (correct - 3 spaces under ordered)
1204 2. Ordered sub-item
1205* Unordered item
1206 1. Ordered sub-item
1207 * Unordered sub-item"#;
1208
1209 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1210 let result = rule.check(&ctx).unwrap();
1211 assert_eq!(result.len(), 0, "All unordered list indentation should be correct");
1212
1213 let fixed = rule.fix(&ctx).unwrap();
1215 assert_eq!(fixed, content);
1216 }
1217
1218 #[test]
1219 fn test_list_markers_variety() {
1220 let rule = MD007ULIndent::default();
1221
1222 let content = r#"* Asterisk
1224 * Nested asterisk
1225- Hyphen
1226 - Nested hyphen
1227+ Plus
1228 + Nested plus"#;
1229
1230 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1231 let result = rule.check(&ctx).unwrap();
1232 assert!(
1233 result.is_empty(),
1234 "All unordered list markers should work with proper indentation"
1235 );
1236
1237 let wrong_content = r#"* Asterisk
1239 * Wrong asterisk
1240- Hyphen
1241 - Wrong hyphen
1242+ Plus
1243 + Wrong plus"#;
1244
1245 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
1246 let result = rule.check(&ctx).unwrap();
1247 assert_eq!(result.len(), 3, "All marker types should be checked for indentation");
1248 }
1249
1250 #[test]
1251 fn test_empty_list_items() {
1252 let rule = MD007ULIndent::default();
1253 let content = "* Item 1\n* \n * Item 2";
1254 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1255 let result = rule.check(&ctx).unwrap();
1256 assert!(
1257 result.is_empty(),
1258 "Empty list items should not affect indentation checks"
1259 );
1260 }
1261
1262 #[test]
1263 fn test_list_with_code_blocks() {
1264 let rule = MD007ULIndent::default();
1265 let content = r#"* Item 1
1266 ```
1267 code
1268 ```
1269 * Item 2
1270 * Item 3"#;
1271 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1272 let result = rule.check(&ctx).unwrap();
1273 assert!(result.is_empty());
1274 }
1275
1276 #[test]
1277 fn test_list_in_front_matter() {
1278 let rule = MD007ULIndent::default();
1279 let content = r#"---
1280tags:
1281 - tag1
1282 - tag2
1283---
1284* Item 1
1285 * Item 2"#;
1286 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1287 let result = rule.check(&ctx).unwrap();
1288 assert!(result.is_empty(), "Lists in YAML front matter should be ignored");
1289 }
1290
1291 #[test]
1292 fn test_fix_preserves_content() {
1293 let rule = MD007ULIndent::default();
1294 let content = "* Item 1 with **bold** and *italic*\n * Item 2 with `code`\n * Item 3 with [link](url)";
1295 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1296 let fixed = rule.fix(&ctx).unwrap();
1297 let expected = "* Item 1 with **bold** and *italic*\n * Item 2 with `code`\n * Item 3 with [link](url)";
1300 assert_eq!(fixed, expected, "Fix should only change indentation, not content");
1301 }
1302
1303 #[test]
1304 fn test_start_indented_config() {
1305 let config = MD007Config {
1306 start_indented: true,
1307 start_indent: crate::types::IndentSize::from_const(4),
1308 indent: crate::types::IndentSize::from_const(2),
1309 style: md007_config::IndentStyle::TextAligned,
1310 style_explicit: true, indent_explicit: false,
1312 };
1313 let rule = MD007ULIndent::from_config_struct(config);
1314
1315 let content = " * Item 1\n * Item 2\n * Item 3";
1320 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1321 let result = rule.check(&ctx).unwrap();
1322 assert!(result.is_empty(), "Expected no warnings with start_indented config");
1323
1324 let wrong_content = " * Item 1\n * Item 2";
1326 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
1327 let result = rule.check(&ctx).unwrap();
1328 assert_eq!(result.len(), 2);
1329 assert_eq!(result[0].line, 1);
1330 assert_eq!(result[0].message, "Expected 4 spaces for indent depth 0, found 2");
1331 assert_eq!(result[1].line, 2);
1332 assert_eq!(result[1].message, "Expected 6 spaces for indent depth 1, found 4");
1333
1334 let fixed = rule.fix(&ctx).unwrap();
1337 assert_eq!(fixed, wrong_content);
1338 }
1339
1340 #[test]
1341 fn test_start_indented_false_flags_indented_first_level() {
1342 let rule = MD007ULIndent::default(); let content = " * Item 1"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1350 let result = rule.check(&ctx).unwrap();
1351 assert!(
1352 result.iter().any(|w| w.line == 1 && w.message.contains("Expected 0")),
1353 "a top-level item indented 3 spaces must be flagged with Expected 0, got: {result:?}"
1354 );
1355
1356 let content = "* Item 1\n * Item 2\n * Item 3";
1360 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1361 let result = rule.check(&ctx).unwrap();
1362 assert!(
1363 result.is_empty(),
1364 "a correctly nested 0/2/4-space list should produce no warnings, got: {result:?}"
1365 );
1366 }
1367
1368 #[test]
1369 fn test_deeply_nested_lists() {
1370 let rule = MD007ULIndent::default();
1371 let content = r#"* L1
1372 * L2
1373 * L3
1374 * L4
1375 * L5
1376 * L6"#;
1377 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1378 let result = rule.check(&ctx).unwrap();
1379 assert!(result.is_empty());
1380
1381 let wrong_content = r#"* L1
1383 * L2
1384 * L3
1385 * L4
1386 * L5
1387 * L6"#;
1388 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
1389 let result = rule.check(&ctx).unwrap();
1390 assert_eq!(result.len(), 2, "Deep nesting errors should be detected");
1391 }
1392
1393 #[test]
1394 fn test_excessive_indentation_detected() {
1395 let rule = MD007ULIndent::default();
1396
1397 let content = "- Item 1\n - Item 2 with 5 spaces";
1399 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1400 let result = rule.check(&ctx).unwrap();
1401 assert_eq!(result.len(), 1, "Should detect excessive indentation (5 instead of 2)");
1402 assert_eq!(result[0].line, 2);
1403 assert!(result[0].message.contains("Expected 2 spaces"));
1404 assert!(result[0].message.contains("found 5"));
1405
1406 let content = "- Item 1\n - Item 2 with 3 spaces";
1408 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1409 let result = rule.check(&ctx).unwrap();
1410 assert_eq!(
1411 result.len(),
1412 1,
1413 "Should detect slightly excessive indentation (3 instead of 2)"
1414 );
1415 assert_eq!(result[0].line, 2);
1416 assert!(result[0].message.contains("Expected 2 spaces"));
1417 assert!(result[0].message.contains("found 3"));
1418
1419 let content = "- Item 1\n - Item 2 with 1 space";
1421 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1422 let result = rule.check(&ctx).unwrap();
1423 assert_eq!(
1424 result.len(),
1425 1,
1426 "Should detect 1-space indent (insufficient for nesting, expected 0)"
1427 );
1428 assert_eq!(result[0].line, 2);
1429 assert!(result[0].message.contains("Expected 0 spaces"));
1430 assert!(result[0].message.contains("found 1"));
1431 }
1432
1433 #[test]
1434 fn test_excessive_indentation_with_4_space_config() {
1435 let rule = MD007ULIndent::new(4);
1438
1439 let content = "- Formatter:\n - The stable style changed";
1441 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1442 let result = rule.check(&ctx).unwrap();
1443 assert!(
1444 !result.is_empty(),
1445 "Should detect 5 spaces when expecting 4 (fixed style)"
1446 );
1447
1448 let correct_content = "- Formatter:\n - The stable style changed";
1450 let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard, None);
1451 let result = rule.check(&ctx).unwrap();
1452 assert!(result.is_empty(), "Should accept correct fixed style indent (4 spaces)");
1453 }
1454
1455 #[test]
1456 fn test_bullets_nested_under_numbered_items() {
1457 let rule = MD007ULIndent::default();
1458 let content = "\
14591. **Active Directory/LDAP**
1460 - User authentication and directory services
1461 - LDAP for user information and validation
1462
14632. **Oracle Unified Directory (OUD)**
1464 - Extended user directory services";
1465 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1466 let result = rule.check(&ctx).unwrap();
1467 assert!(
1469 result.is_empty(),
1470 "Expected no warnings for bullets with 3 spaces under numbered items, got: {result:?}"
1471 );
1472 }
1473
1474 #[test]
1475 fn test_bullets_nested_under_numbered_items_wrong_indent() {
1476 let rule = MD007ULIndent::default();
1477 let content = "\
14781. **Active Directory/LDAP**
1479 - Wrong: only 2 spaces";
1480 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1481 let result = rule.check(&ctx).unwrap();
1482 assert_eq!(
1484 result.len(),
1485 1,
1486 "Expected warning for incorrect indentation under numbered items"
1487 );
1488 assert!(
1489 result
1490 .iter()
1491 .any(|w| w.line == 2 && w.message.contains("Expected 3 spaces"))
1492 );
1493 }
1494
1495 #[test]
1496 fn test_regular_bullet_nesting_still_works() {
1497 let rule = MD007ULIndent::default();
1498 let content = "\
1499* Top level
1500 * Nested bullet (2 spaces is correct)
1501 * Deeply nested (4 spaces)";
1502 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1503 let result = rule.check(&ctx).unwrap();
1504 assert!(
1506 result.is_empty(),
1507 "Expected no warnings for standard bullet nesting, got: {result:?}"
1508 );
1509 }
1510
1511 #[test]
1512 fn test_blockquote_with_tab_after_marker() {
1513 let rule = MD007ULIndent::default();
1514 let content = ">\t* List item\n>\t * Nested\n";
1515 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1516 let result = rule.check(&ctx).unwrap();
1517 assert!(
1518 result.is_empty(),
1519 "Tab after blockquote marker should be handled correctly, got: {result:?}"
1520 );
1521 }
1522
1523 #[test]
1524 fn test_blockquote_with_space_then_tab_after_marker() {
1525 let rule = MD007ULIndent::default();
1526 let content = "> \t* List item\n";
1527 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1528 let result = rule.check(&ctx).unwrap();
1529 assert!(
1534 result.iter().any(|w| w.line == 1 && w.message.contains("Expected 0")),
1535 "an indented blockquoted top-level item must be flagged with Expected 0, got: {result:?}"
1536 );
1537 }
1538
1539 #[test]
1540 fn test_blockquote_with_multiple_tabs() {
1541 let rule = MD007ULIndent::default();
1542 let content = ">\t\t* List item\n";
1543 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1544 let result = rule.check(&ctx).unwrap();
1545 assert!(
1547 result.is_empty(),
1548 "First-level list item at any indentation is allowed when start_indented=false, got: {result:?}"
1549 );
1550 }
1551
1552 #[test]
1553 fn test_nested_blockquote_with_tab() {
1554 let rule = MD007ULIndent::default();
1555 let content = ">\t>\t* List item\n>\t>\t * Nested\n";
1556 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1557 let result = rule.check(&ctx).unwrap();
1558 assert!(
1559 result.is_empty(),
1560 "Nested blockquotes with tabs should work correctly, got: {result:?}"
1561 );
1562 }
1563
1564 #[test]
1567 fn test_smart_style_pure_unordered_uses_fixed() {
1568 let rule = MD007ULIndent::new(4);
1570
1571 let content = "* Level 0\n * Level 1\n * Level 2";
1573 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1574 let result = rule.check(&ctx).unwrap();
1575 assert!(
1576 result.is_empty(),
1577 "Pure unordered with indent=4 should use fixed style (0, 4, 8), got: {result:?}"
1578 );
1579 }
1580
1581 #[test]
1582 fn test_smart_style_mixed_lists_uses_text_aligned() {
1583 let rule = MD007ULIndent::new(4);
1585
1586 let content = "1. Ordered\n * Bullet aligns with 'Ordered' text (3 spaces)";
1588 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1589 let result = rule.check(&ctx).unwrap();
1590 assert!(
1591 result.is_empty(),
1592 "Mixed lists should use text-aligned style, got: {result:?}"
1593 );
1594 }
1595
1596 #[test]
1597 fn test_smart_style_explicit_fixed_overrides() {
1598 let config = MD007Config {
1600 indent: crate::types::IndentSize::from_const(4),
1601 start_indented: false,
1602 start_indent: crate::types::IndentSize::from_const(2),
1603 style: md007_config::IndentStyle::Fixed,
1604 style_explicit: true, indent_explicit: false,
1606 };
1607 let rule = MD007ULIndent::from_config_struct(config);
1608
1609 let content = "1. Ordered\n * Should be at 4 spaces (fixed)";
1611 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1612 let result = rule.check(&ctx).unwrap();
1613 assert!(
1615 result.is_empty(),
1616 "Explicit fixed style should be respected, got: {result:?}"
1617 );
1618 }
1619
1620 #[test]
1621 fn test_smart_style_explicit_text_aligned_overrides() {
1622 let config = MD007Config {
1624 indent: crate::types::IndentSize::from_const(4),
1625 start_indented: false,
1626 start_indent: crate::types::IndentSize::from_const(2),
1627 style: md007_config::IndentStyle::TextAligned,
1628 style_explicit: true, indent_explicit: false,
1630 };
1631 let rule = MD007ULIndent::from_config_struct(config);
1632
1633 let content = "* Level 0\n * Level 1 (aligned with 'Level 0' text)";
1635 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1636 let result = rule.check(&ctx).unwrap();
1637 assert!(
1638 result.is_empty(),
1639 "Explicit text-aligned should be respected, got: {result:?}"
1640 );
1641
1642 let fixed_style_content = "* Level 0\n * Level 1 (4 spaces - fixed style)";
1644 let ctx = LintContext::new(fixed_style_content, crate::config::MarkdownFlavor::Standard, None);
1645 let result = rule.check(&ctx).unwrap();
1646 assert!(
1647 !result.is_empty(),
1648 "With explicit text-aligned, 4-space indent should be wrong (expected 2)"
1649 );
1650 }
1651
1652 #[test]
1653 fn test_smart_style_default_indent_no_autoswitch() {
1654 let rule = MD007ULIndent::new(2);
1656
1657 let content = "* Level 0\n * Level 1\n * Level 2";
1658 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1659 let result = rule.check(&ctx).unwrap();
1660 assert!(
1661 result.is_empty(),
1662 "Default indent should work regardless of style, got: {result:?}"
1663 );
1664 }
1665
1666 #[test]
1667 fn test_has_mixed_list_nesting_detection() {
1668 let content = "* Item 1\n * Item 2\n * Item 3";
1672 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1673 assert!(
1674 !ctx.has_mixed_list_nesting(),
1675 "Pure unordered should not be detected as mixed"
1676 );
1677
1678 let content = "1. Item 1\n 2. Item 2\n 3. Item 3";
1680 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1681 assert!(
1682 !ctx.has_mixed_list_nesting(),
1683 "Pure ordered should not be detected as mixed"
1684 );
1685
1686 let content = "1. Ordered\n * Unordered child";
1688 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1689 assert!(
1690 ctx.has_mixed_list_nesting(),
1691 "Unordered under ordered should be detected as mixed"
1692 );
1693
1694 let content = "* Unordered\n 1. Ordered child";
1696 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1697 assert!(
1698 ctx.has_mixed_list_nesting(),
1699 "Ordered under unordered should be detected as mixed"
1700 );
1701
1702 let content = "* Unordered\n\n1. Ordered (separate list)";
1704 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1705 assert!(
1706 !ctx.has_mixed_list_nesting(),
1707 "Separate lists should not be detected as mixed"
1708 );
1709
1710 let content = "> 1. Ordered in blockquote\n> * Unordered child";
1712 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1713 assert!(
1714 ctx.has_mixed_list_nesting(),
1715 "Mixed lists in blockquotes should be detected"
1716 );
1717 }
1718
1719 #[test]
1720 fn test_issue_210_exact_reproduction() {
1721 let config = MD007Config {
1723 indent: crate::types::IndentSize::from_const(4),
1724 start_indented: false,
1725 start_indent: crate::types::IndentSize::from_const(2),
1726 style: md007_config::IndentStyle::TextAligned, style_explicit: false, indent_explicit: false, };
1730 let rule = MD007ULIndent::from_config_struct(config);
1731
1732 let content = "# Title\n\n* some\n * list\n * items\n";
1733 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1734 let result = rule.check(&ctx).unwrap();
1735
1736 assert!(
1737 result.is_empty(),
1738 "Issue #210: indent=4 on pure unordered should work (auto-fixed style), got: {result:?}"
1739 );
1740 }
1741
1742 #[test]
1743 fn test_issue_209_still_fixed() {
1744 let config = MD007Config {
1747 indent: crate::types::IndentSize::from_const(3),
1748 start_indented: false,
1749 start_indent: crate::types::IndentSize::from_const(2),
1750 style: md007_config::IndentStyle::TextAligned,
1751 style_explicit: true, indent_explicit: false,
1753 };
1754 let rule = MD007ULIndent::from_config_struct(config);
1755
1756 let content = r#"# Header 1
1758
1759- **Second item**:
1760 - **This is a nested list**:
1761 1. **First point**
1762 - First subpoint
1763"#;
1764 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1765 let result = rule.check(&ctx).unwrap();
1766
1767 assert!(
1768 result.is_empty(),
1769 "Issue #209: With explicit text-aligned style, should have no issues, got: {result:?}"
1770 );
1771 }
1772
1773 #[test]
1776 fn test_multi_level_mixed_detection_grandparent() {
1777 let content = "1. Ordered grandparent\n * Unordered child\n * Unordered grandchild";
1781 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1782 assert!(
1783 ctx.has_mixed_list_nesting(),
1784 "Should detect mixed nesting when grandparent differs in type"
1785 );
1786
1787 let content = "* Unordered grandparent\n 1. Ordered child\n 2. Ordered grandchild";
1789 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1790 assert!(
1791 ctx.has_mixed_list_nesting(),
1792 "Should detect mixed nesting for ordered descendants under unordered"
1793 );
1794 }
1795
1796 #[test]
1797 fn test_html_comments_skipped_in_detection() {
1798 let content = r#"* Unordered list
1800<!-- This is a comment
1801 1. This ordered list is inside a comment
1802 * This nested bullet is also inside
1803-->
1804 * Another unordered item"#;
1805 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1806 assert!(
1807 !ctx.has_mixed_list_nesting(),
1808 "Lists in HTML comments should be ignored in mixed detection"
1809 );
1810 }
1811
1812 #[test]
1813 fn test_blank_lines_separate_lists() {
1814 let content = "* First unordered list\n\n1. Second list is ordered (separate)";
1816 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1817 assert!(
1818 !ctx.has_mixed_list_nesting(),
1819 "Blank line at root should separate lists"
1820 );
1821
1822 let content = "1. Ordered parent\n\n * Still a child due to indentation";
1824 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1825 assert!(
1826 ctx.has_mixed_list_nesting(),
1827 "Indented list after blank is still nested"
1828 );
1829 }
1830
1831 #[test]
1832 fn test_column_1_normalization() {
1833 let content = "* First item\n * Second item with 1 space (sibling)";
1836 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1837 let rule = MD007ULIndent::default();
1838 let result = rule.check(&ctx).unwrap();
1839 assert!(
1841 result.iter().any(|w| w.line == 2),
1842 "1-space indent should be flagged as incorrect"
1843 );
1844 }
1845
1846 #[test]
1847 fn test_code_blocks_skipped_in_detection() {
1848 let content = r#"* Unordered list
1850```
18511. This ordered list is inside a code block
1852 * This nested bullet is also inside
1853```
1854 * Another unordered item"#;
1855 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1856 assert!(
1857 !ctx.has_mixed_list_nesting(),
1858 "Lists in code blocks should be ignored in mixed detection"
1859 );
1860 }
1861
1862 #[test]
1863 fn test_front_matter_skipped_in_detection() {
1864 let content = r#"---
1866items:
1867 - yaml list item
1868 - another item
1869---
1870* Unordered list after front matter"#;
1871 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1872 assert!(
1873 !ctx.has_mixed_list_nesting(),
1874 "Lists in front matter should be ignored in mixed detection"
1875 );
1876 }
1877
1878 #[test]
1879 fn test_alternating_types_at_same_level() {
1880 let content = "* First bullet\n1. First number\n* Second bullet\n2. Second number";
1883 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1884 assert!(
1885 !ctx.has_mixed_list_nesting(),
1886 "Alternating types at same level should not be detected as mixed"
1887 );
1888 }
1889
1890 #[test]
1891 fn test_five_level_deep_mixed_nesting() {
1892 let content = "* L0\n 1. L1\n * L2\n 1. L3\n * L4\n 1. L5";
1894 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1895 assert!(ctx.has_mixed_list_nesting(), "Should detect mixed nesting at 5+ levels");
1896 }
1897
1898 #[test]
1899 fn test_very_deep_pure_unordered_nesting() {
1900 let mut content = String::from("* L1");
1902 for level in 2..=12 {
1903 let indent = " ".repeat(level - 1);
1904 content.push_str(&format!("\n{indent}* L{level}"));
1905 }
1906
1907 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1908
1909 assert!(
1911 !ctx.has_mixed_list_nesting(),
1912 "Pure unordered deep nesting should not be detected as mixed"
1913 );
1914
1915 let rule = MD007ULIndent::new(4);
1917 let result = rule.check(&ctx).unwrap();
1918 assert!(!result.is_empty(), "Should flag incorrect indentation for fixed style");
1921 }
1922
1923 #[test]
1924 fn test_interleaved_content_between_list_items() {
1925 let content = "1. Ordered parent\n\n Paragraph continuation\n\n * Unordered child";
1927 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1928 assert!(
1929 ctx.has_mixed_list_nesting(),
1930 "Should detect mixed nesting even with interleaved paragraphs"
1931 );
1932 }
1933
1934 #[test]
1935 fn test_esm_blocks_skipped_in_detection() {
1936 let content = "* Unordered list\n * Nested unordered";
1939 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1940 assert!(
1941 !ctx.has_mixed_list_nesting(),
1942 "Pure unordered should not be detected as mixed"
1943 );
1944 }
1945
1946 #[test]
1947 fn test_multiple_list_blocks_pure_then_mixed() {
1948 let content = r#"* Pure unordered
1951 * Nested unordered
1952
19531. Mixed section
1954 * Bullet under ordered"#;
1955 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1956 assert!(
1957 ctx.has_mixed_list_nesting(),
1958 "Should detect mixed nesting in any part of document"
1959 );
1960 }
1961
1962 #[test]
1963 fn test_multiple_separate_pure_lists() {
1964 let content = r#"* First list
1967 * Nested
1968
1969* Second list
1970 * Also nested
1971
1972* Third list
1973 * Deeply
1974 * Nested"#;
1975 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1976 assert!(
1977 !ctx.has_mixed_list_nesting(),
1978 "Multiple separate pure unordered lists should not be mixed"
1979 );
1980 }
1981
1982 #[test]
1983 fn test_code_block_between_list_items() {
1984 let content = r#"1. Ordered
1986 ```
1987 code
1988 ```
1989 * Still a mixed child"#;
1990 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1991 assert!(
1992 ctx.has_mixed_list_nesting(),
1993 "Code block between items should not prevent mixed detection"
1994 );
1995 }
1996
1997 #[test]
1998 fn test_blockquoted_mixed_detection() {
1999 let content = "> 1. Ordered in blockquote\n> * Mixed child";
2001 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2002 assert!(
2005 ctx.has_mixed_list_nesting(),
2006 "Should detect mixed nesting in blockquotes"
2007 );
2008 }
2009
2010 #[test]
2013 fn test_indent_explicit_uses_fixed_style() {
2014 let config = MD007Config {
2017 indent: crate::types::IndentSize::from_const(4),
2018 start_indented: false,
2019 start_indent: crate::types::IndentSize::from_const(2),
2020 style: md007_config::IndentStyle::TextAligned, style_explicit: false, indent_explicit: true, };
2024 let rule = MD007ULIndent::from_config_struct(config);
2025
2026 let content = "* Level 0\n * Level 1\n * Level 2";
2029 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2030 let result = rule.check(&ctx).unwrap();
2031 assert!(
2032 result.is_empty(),
2033 "With indent_explicit=true, should use fixed style (0, 4, 8), got: {result:?}"
2034 );
2035
2036 let wrong_content = "* Level 0\n * Level 1\n * Level 2";
2038 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
2039 let result = rule.check(&ctx).unwrap();
2040 assert!(
2041 !result.is_empty(),
2042 "Should flag text-aligned spacing when indent_explicit=true"
2043 );
2044 }
2045
2046 #[test]
2047 fn test_explicit_style_overrides_indent_explicit() {
2048 let config = MD007Config {
2051 indent: crate::types::IndentSize::from_const(4),
2052 start_indented: false,
2053 start_indent: crate::types::IndentSize::from_const(2),
2054 style: md007_config::IndentStyle::TextAligned,
2055 style_explicit: true, indent_explicit: true, };
2058 let rule = MD007ULIndent::from_config_struct(config);
2059
2060 let content = "* Level 0\n * Level 1\n * Level 2";
2062 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2063 let result = rule.check(&ctx).unwrap();
2064 assert!(
2065 result.is_empty(),
2066 "Explicit text-aligned style should be respected, got: {result:?}"
2067 );
2068 }
2069
2070 #[test]
2071 fn test_no_indent_explicit_uses_smart_detection() {
2072 let config = MD007Config {
2074 indent: crate::types::IndentSize::from_const(4),
2075 start_indented: false,
2076 start_indent: crate::types::IndentSize::from_const(2),
2077 style: md007_config::IndentStyle::TextAligned,
2078 style_explicit: false,
2079 indent_explicit: false, };
2081 let rule = MD007ULIndent::from_config_struct(config);
2082
2083 let content = "* Level 0\n * Level 1";
2086 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2087 let result = rule.check(&ctx).unwrap();
2088 assert!(
2090 result.is_empty(),
2091 "Smart detection should accept 4-space indent, got: {result:?}"
2092 );
2093 }
2094
2095 #[test]
2096 fn test_issue_273_exact_reproduction() {
2097 let config = MD007Config {
2100 indent: crate::types::IndentSize::from_const(4),
2101 start_indented: false,
2102 start_indent: crate::types::IndentSize::from_const(2),
2103 style: md007_config::IndentStyle::TextAligned, style_explicit: false,
2105 indent_explicit: true, };
2107 let rule = MD007ULIndent::from_config_struct(config);
2108
2109 let content = r#"* Item 1
2110 * Item 2
2111 * Item 3"#;
2112 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2113 let result = rule.check(&ctx).unwrap();
2114 assert!(
2115 result.is_empty(),
2116 "Issue #273: indent=4 should use 4-space increments, got: {result:?}"
2117 );
2118 }
2119
2120 #[test]
2121 fn test_indent_explicit_with_ordered_parent() {
2122 let config = MD007Config {
2126 indent: crate::types::IndentSize::from_const(4),
2127 start_indented: false,
2128 start_indent: crate::types::IndentSize::from_const(2),
2129 style: md007_config::IndentStyle::TextAligned,
2130 style_explicit: false,
2131 indent_explicit: true, };
2133 let rule = MD007ULIndent::from_config_struct(config);
2134
2135 let content = "1. Ordered\n * Bullet with 4-space indent";
2137 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2138 let result = rule.check(&ctx).unwrap();
2139 assert!(
2140 result.is_empty(),
2141 "4-space indent under ordered should pass with indent=4: {result:?}"
2142 );
2143
2144 let content_3 = "1. Ordered\n * Bullet with 3-space indent";
2146 let ctx = LintContext::new(content_3, crate::config::MarkdownFlavor::Standard, None);
2147 let result = rule.check(&ctx).unwrap();
2148 assert!(
2149 result.is_empty(),
2150 "3-space indent under ordered should pass (text-aligned): {result:?}"
2151 );
2152
2153 let wrong_content = "1. Ordered\n * Bullet with 2-space indent";
2155 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
2156 let result = rule.check(&ctx).unwrap();
2157 assert!(
2158 !result.is_empty(),
2159 "2-space indent under ordered list should be flagged when indent=4: {result:?}"
2160 );
2161 }
2162
2163 #[test]
2164 fn test_indent_explicit_mixed_list_deep_nesting() {
2165 let config = MD007Config {
2170 indent: crate::types::IndentSize::from_const(4),
2171 start_indented: false,
2172 start_indent: crate::types::IndentSize::from_const(2),
2173 style: md007_config::IndentStyle::TextAligned,
2174 style_explicit: false,
2175 indent_explicit: true,
2176 };
2177 let rule = MD007ULIndent::from_config_struct(config);
2178
2179 let content_text_aligned = r#"* Level 0
2185 * Level 1 (4-space indent from bullet parent)
2186 1. Level 2 ordered
2187 * Level 3 bullet (text-aligned under ordered)"#;
2188 let ctx = LintContext::new(content_text_aligned, crate::config::MarkdownFlavor::Standard, None);
2189 let result = rule.check(&ctx).unwrap();
2190 assert!(
2191 result.is_empty(),
2192 "Text-aligned nesting under ordered should pass: {result:?}"
2193 );
2194
2195 let content_fixed = r#"* Level 0
2196 * Level 1 (4-space indent from bullet parent)
2197 1. Level 2 ordered
2198 * Level 3 bullet (fixed indent under ordered)"#;
2199 let ctx = LintContext::new(content_fixed, crate::config::MarkdownFlavor::Standard, None);
2200 let result = rule.check(&ctx).unwrap();
2201 assert!(
2202 result.is_empty(),
2203 "Fixed indent nesting under ordered should also pass: {result:?}"
2204 );
2205 }
2206
2207 #[test]
2208 fn test_ordered_list_double_digit_markers() {
2209 let config = MD007Config {
2212 indent: crate::types::IndentSize::from_const(4),
2213 start_indented: false,
2214 start_indent: crate::types::IndentSize::from_const(2),
2215 style: md007_config::IndentStyle::TextAligned,
2216 style_explicit: false,
2217 indent_explicit: true,
2218 };
2219 let rule = MD007ULIndent::from_config_struct(config);
2220
2221 let content = "10. Double digit\n * Bullet at col 4";
2223 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2224 let result = rule.check(&ctx).unwrap();
2225 assert!(
2226 result.is_empty(),
2227 "Bullet under '10.' should align at column 4: {result:?}"
2228 );
2229
2230 let content_3 = "1. Single digit\n * Bullet at col 3";
2233 let ctx = LintContext::new(content_3, crate::config::MarkdownFlavor::Standard, None);
2234 let result = rule.check(&ctx).unwrap();
2235 assert!(
2236 result.is_empty(),
2237 "Bullet under '1.' with 3-space indent should pass (text-aligned): {result:?}"
2238 );
2239
2240 let content_4 = "1. Single digit\n * Bullet at col 4";
2241 let ctx = LintContext::new(content_4, crate::config::MarkdownFlavor::Standard, None);
2242 let result = rule.check(&ctx).unwrap();
2243 assert!(
2244 result.is_empty(),
2245 "Bullet under '1.' with 4-space indent should pass (fixed): {result:?}"
2246 );
2247 }
2248
2249 #[test]
2250 fn test_indent_explicit_pure_unordered_uses_fixed() {
2251 let config = MD007Config {
2254 indent: crate::types::IndentSize::from_const(4),
2255 start_indented: false,
2256 start_indent: crate::types::IndentSize::from_const(2),
2257 style: md007_config::IndentStyle::TextAligned,
2258 style_explicit: false,
2259 indent_explicit: true,
2260 };
2261 let rule = MD007ULIndent::from_config_struct(config);
2262
2263 let content = "* Level 0\n * Level 1\n * Level 2";
2265 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2266 let result = rule.check(&ctx).unwrap();
2267 assert!(
2268 result.is_empty(),
2269 "Pure unordered with indent=4 should use 4-space increments: {result:?}"
2270 );
2271
2272 let wrong_content = "* Level 0\n * Level 1\n * Level 2";
2274 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
2275 let result = rule.check(&ctx).unwrap();
2276 assert!(
2277 !result.is_empty(),
2278 "2-space indent should be flagged when indent=4 is configured"
2279 );
2280 }
2281
2282 #[test]
2283 fn test_mkdocs_ordered_list_with_4_space_nested_unordered() {
2284 let rule = MD007ULIndent::default();
2288 let content = "1. text\n\n - nested item";
2289 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2290 let result = rule.check(&ctx).unwrap();
2291 assert!(
2292 result.is_empty(),
2293 "4-space indent under ordered list should be valid in MkDocs flavor, got: {result:?}"
2294 );
2295 }
2296
2297 #[test]
2298 fn test_standard_flavor_ordered_list_with_3_space_nested_unordered() {
2299 let rule = MD007ULIndent::default();
2302 let content = "1. text\n\n - nested item";
2303 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2304 let result = rule.check(&ctx).unwrap();
2305 assert!(
2306 result.is_empty(),
2307 "3-space indent under ordered list should be valid in Standard flavor, got: {result:?}"
2308 );
2309 }
2310
2311 #[test]
2312 fn test_standard_flavor_ordered_list_under_ordered_is_exempt() {
2313 let rule = MD007ULIndent::default();
2318 let content = "1. text\n\n - nested item";
2319 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2320 let result = rule.check(&ctx).unwrap();
2321 assert!(
2322 result.is_empty(),
2323 "unordered sublist of an ordered list must be exempt in Standard flavor, got: {result:?}"
2324 );
2325 }
2326
2327 #[test]
2328 fn test_mkdocs_multi_digit_ordered_list() {
2329 let rule = MD007ULIndent::default();
2332 let content = "10. text\n\n - nested item";
2333 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2334 let result = rule.check(&ctx).unwrap();
2335 assert!(
2336 result.is_empty(),
2337 "4-space indent under `10.` should be valid in MkDocs flavor, got: {result:?}"
2338 );
2339 }
2340
2341 #[test]
2342 fn test_mkdocs_triple_digit_ordered_list() {
2343 let rule = MD007ULIndent::default();
2346 let content = "100. text\n\n - nested item";
2347 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2348 let result = rule.check(&ctx).unwrap();
2349 assert!(
2350 result.is_empty(),
2351 "5-space indent under `100.` should be valid in MkDocs flavor, got: {result:?}"
2352 );
2353 }
2354
2355 #[test]
2356 fn test_mkdocs_insufficient_indent_under_ordered() {
2357 let rule = MD007ULIndent::default();
2360 let content = "1. text\n\n - nested item";
2361 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2362 let result = rule.check(&ctx).unwrap();
2363 assert_eq!(
2364 result.len(),
2365 1,
2366 "2-space indent under ordered list should warn in MkDocs flavor"
2367 );
2368 assert!(
2369 result[0].message.contains("Expected 4"),
2370 "Warning should expect 4 spaces (MkDocs minimum), got: {}",
2371 result[0].message
2372 );
2373 }
2374
2375 #[test]
2376 fn test_mkdocs_deeper_nesting_under_ordered() {
2377 let rule = MD007ULIndent::default();
2382 let content = "1. text\n\n - sub\n - subsub";
2383 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2384 let result = rule.check(&ctx).unwrap();
2385 assert!(
2386 result.is_empty(),
2387 "Deeper nesting under ordered list should be valid in MkDocs flavor, got: {result:?}"
2388 );
2389 }
2390
2391 #[test]
2392 fn test_mkdocs_fix_adjusts_to_4_spaces() {
2393 let rule = MD007ULIndent::default();
2395 let content = "1. text\n\n - nested item";
2396 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2397 let result = rule.check(&ctx).unwrap();
2398 assert_eq!(result.len(), 1, "3-space indent should warn in MkDocs");
2399 let fixed = rule.fix(&ctx).unwrap();
2400 assert_eq!(
2401 fixed, "1. text\n\n - nested item",
2402 "Fix should adjust indent to 4 spaces in MkDocs"
2403 );
2404 }
2405
2406 #[test]
2407 fn test_mkdocs_start_indented_with_ordered_parent() {
2408 let config = MD007Config {
2411 start_indented: true,
2412 ..Default::default()
2413 };
2414 let rule = MD007ULIndent::from_config_struct(config);
2415 let content = "1. text\n\n - nested item";
2416 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2417 let result = rule.check(&ctx).unwrap();
2418 assert!(
2419 result.is_empty(),
2420 "4-space indent under ordered list with start_indented should be valid in MkDocs, got: {result:?}"
2421 );
2422 }
2423
2424 #[test]
2425 fn test_mkdocs_ordered_at_nonzero_indent() {
2426 let rule = MD007ULIndent::default();
2431 let content = "- outer\n 1. inner\n - deep";
2432 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2433 let result = rule.check(&ctx).unwrap();
2434 assert!(
2435 result.is_empty(),
2436 "6-space indent under nested ordered list should be valid in MkDocs, got: {result:?}"
2437 );
2438 }
2439
2440 #[test]
2441 fn test_mkdocs_blockquoted_ordered_list() {
2442 let rule = MD007ULIndent::default();
2446 let content = "> 1. text\n>\n> - nested item";
2447 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2448 let result = rule.check(&ctx).unwrap();
2449 assert!(
2450 result.is_empty(),
2451 "4-space indent under blockquoted ordered list should be valid in MkDocs, got: {result:?}"
2452 );
2453 }
2454
2455 #[test]
2456 fn test_mkdocs_ordered_at_nonzero_indent_insufficient() {
2457 let rule = MD007ULIndent::default();
2460 let content = "- outer\n 1. inner\n - deep";
2461 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2462 let result = rule.check(&ctx).unwrap();
2463 assert_eq!(
2464 result.len(),
2465 1,
2466 "5-space indent under nested ordered at col 2 should warn in MkDocs (needs 6)"
2467 );
2468 }
2469
2470 #[test]
2471 fn test_issue_504_indent4_ordered_parent() {
2472 let config = MD007Config {
2476 indent: crate::types::IndentSize::from_const(4),
2477 start_indented: false,
2478 start_indent: crate::types::IndentSize::from_const(2),
2479 style: md007_config::IndentStyle::TextAligned,
2480 style_explicit: false,
2481 indent_explicit: true,
2482 };
2483 let rule = MD007ULIndent::from_config_struct(config);
2484
2485 let content = r#"# Things
2486
2487+ An unordered list
2488 + An item with 4 spaces, ok.
2489
24901. A numbered list
2491 + A sublist with 4 spaces, not ok
2492 + A sub item with 4 spaces, ok
2493 + Why is rumdl expecting 3 spaces for a 4 space indent?
24942. Item 2
24953. Item 3"#;
2496 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2497 let result = rule.check(&ctx).unwrap();
2498 assert!(
2499 result.is_empty(),
2500 "Issue #504: indent=4 with ordered parent should accept 4-space indent: {result:?}"
2501 );
2502 }
2503
2504 #[test]
2505 fn test_indent2_explicit_with_ordered_parent() {
2506 let config = MD007Config {
2509 indent: crate::types::IndentSize::from_const(2),
2510 start_indented: false,
2511 start_indent: crate::types::IndentSize::from_const(2),
2512 style: md007_config::IndentStyle::TextAligned,
2513 style_explicit: false,
2514 indent_explicit: true,
2515 };
2516 let rule = MD007ULIndent::from_config_struct(config);
2517
2518 let content = "1. Ordered\n * Bullet at 3 spaces";
2520 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2521 let result = rule.check(&ctx).unwrap();
2522 assert!(
2523 result.is_empty(),
2524 "indent=2 under '1.' should accept text-aligned (3 spaces): {result:?}"
2525 );
2526
2527 let content_2 = "1. Ordered\n * Bullet at 2 spaces";
2529 let ctx = LintContext::new(content_2, crate::config::MarkdownFlavor::Standard, None);
2530 let result = rule.check(&ctx).unwrap();
2531 assert!(
2532 result.is_empty(),
2533 "indent=2 under '1.' should accept fixed indent (2 spaces): {result:?}"
2534 );
2535 }
2536
2537 const ISSUE_638_INPUT: &str = "# Title\n\n1. Some text\n - Indented text\n - more indented\n";
2541
2542 #[test]
2543 fn test_issue_638_unordered_under_ordered_smart_default() {
2544 let rule = MD007ULIndent::new(2);
2545 let ctx = LintContext::new(ISSUE_638_INPUT, crate::config::MarkdownFlavor::Standard, None);
2546 let result = rule.check(&ctx).unwrap();
2547 assert!(
2548 result.is_empty(),
2549 "smart default: unordered items under an ordered list must not be flagged, got: {result:?}"
2550 );
2551 }
2552
2553 #[test]
2554 fn test_issue_638_unordered_under_ordered_indent_explicit() {
2555 let config = MD007Config {
2556 indent: crate::types::IndentSize::from_const(2),
2557 start_indented: false,
2558 start_indent: crate::types::IndentSize::from_const(2),
2559 style: md007_config::IndentStyle::TextAligned,
2560 style_explicit: false,
2561 indent_explicit: true,
2562 };
2563 let rule = MD007ULIndent::from_config_struct(config);
2564 let ctx = LintContext::new(ISSUE_638_INPUT, crate::config::MarkdownFlavor::Standard, None);
2565 let result = rule.check(&ctx).unwrap();
2566 assert!(
2567 result.is_empty(),
2568 "indent=2 explicit: unordered items under an ordered list must not be flagged, got: {result:?}"
2569 );
2570 }
2571
2572 #[test]
2573 fn test_issue_638_unordered_under_ordered_style_fixed() {
2574 let config = MD007Config {
2576 indent: crate::types::IndentSize::from_const(2),
2577 start_indented: false,
2578 start_indent: crate::types::IndentSize::from_const(2),
2579 style: md007_config::IndentStyle::Fixed,
2580 style_explicit: true,
2581 indent_explicit: true,
2582 };
2583 let rule = MD007ULIndent::from_config_struct(config);
2584 let ctx = LintContext::new(ISSUE_638_INPUT, crate::config::MarkdownFlavor::Standard, None);
2585 let result = rule.check(&ctx).unwrap();
2586 assert!(
2587 result.is_empty(),
2588 "style=fixed: unordered items under an ordered list must not be flagged, got: {result:?}"
2589 );
2590 }
2591
2592 fn fixed_style_rule(indent: u8) -> MD007ULIndent {
2599 MD007ULIndent::from_config_struct(MD007Config {
2600 indent: crate::types::IndentSize::from_const(indent),
2601 start_indented: false,
2602 start_indent: crate::types::IndentSize::from_const(2),
2603 style: md007_config::IndentStyle::Fixed,
2604 style_explicit: true,
2605 indent_explicit: true,
2606 })
2607 }
2608
2609 #[test]
2610 fn test_fixed_style_clamp_flags_over_indented_bullet_under_ordered() {
2611 let rule = fixed_style_rule(2);
2612 let content = "1. Some text\n - four spaces\n";
2613 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2614 let result = rule.check(&ctx).unwrap();
2615 assert_eq!(
2616 result.len(),
2617 1,
2618 "a bullet at 4 under a content column of 3 is flagged: {result:?}"
2619 );
2620 assert!(
2621 result[0].message.contains("Expected 3") && result[0].message.contains("found 4"),
2622 "clamped expectation is the parent content column, got: {}",
2623 result[0].message
2624 );
2625 let fixed = rule.fix(&ctx).unwrap();
2626 assert_eq!(fixed, "1. Some text\n - four spaces\n");
2627 }
2628
2629 #[test]
2630 fn test_fixed_style_clamp_accepts_bullet_at_parent_content_column() {
2631 let rule = fixed_style_rule(2);
2632 let content = "1. Some text\n - three spaces\n";
2633 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2634 let result = rule.check(&ctx).unwrap();
2635 assert!(result.is_empty(), "the clamped expectation itself passes: {result:?}");
2636 }
2637
2638 #[test]
2639 fn test_fixed_style_clamp_pulls_five_spaces_to_content_column() {
2640 let rule = fixed_style_rule(2);
2641 let content = "1. Some text\n - five spaces\n";
2642 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2643 let result = rule.check(&ctx).unwrap();
2644 assert_eq!(result.len(), 1, "{result:?}");
2645 let fixed = rule.fix(&ctx).unwrap();
2646 assert_eq!(fixed, "1. Some text\n - five spaces\n");
2647 }
2648
2649 #[test]
2650 fn test_fixed_style_clamp_cascades_through_nested_bullets() {
2651 let rule = fixed_style_rule(2);
2655 let content = "1. Ordered\n - child\n - grandchild\n";
2656 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2657 let result = rule.check(&ctx).unwrap();
2658 assert_eq!(result.len(), 1, "only the grandchild is off: {result:?}");
2659 assert!(
2660 result[0].message.contains("Expected 5") && result[0].message.contains("found 6"),
2661 "got: {}",
2662 result[0].message
2663 );
2664 let fixed = rule.fix(&ctx).unwrap();
2665 assert_eq!(fixed, "1. Ordered\n - child\n - grandchild\n");
2666 let refixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
2667 assert!(rule.check(&refixed_ctx).unwrap().is_empty(), "fix is stable");
2668 }
2669
2670 #[test]
2671 fn test_fixed_style_clamp_respects_wider_fixed_indent() {
2672 let rule = fixed_style_rule(4);
2675 let content = "1. Some text\n - three spaces\n";
2676 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2677 let result = rule.check(&ctx).unwrap();
2678 assert_eq!(result.len(), 1, "{result:?}");
2679 assert!(
2680 result[0].message.contains("Expected 4") && result[0].message.contains("found 3"),
2681 "got: {}",
2682 result[0].message
2683 );
2684 let fixed = rule.fix(&ctx).unwrap();
2685 assert_eq!(fixed, "1. Some text\n - three spaces\n");
2686 }
2687
2688 #[test]
2689 fn test_fixed_style_clamp_uses_measured_content_column_of_wide_marker() {
2690 let rule = fixed_style_rule(2);
2693 let content = "1. Some text\n - five spaces\n";
2694 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2695 let result = rule.check(&ctx).unwrap();
2696 assert_eq!(result.len(), 1, "{result:?}");
2697 assert!(
2698 result[0].message.contains("Expected 4") && result[0].message.contains("found 5"),
2699 "got: {}",
2700 result[0].message
2701 );
2702 let fixed = rule.fix(&ctx).unwrap();
2703 assert_eq!(fixed, "1. Some text\n - five spaces\n");
2704
2705 let ok = "1. Some text\n - four spaces\n";
2706 let ok_ctx = LintContext::new(ok, crate::config::MarkdownFlavor::Standard, None);
2707 assert!(rule.check(&ok_ctx).unwrap().is_empty());
2708 }
2709
2710 #[test]
2711 fn test_fixed_style_clamp_in_blockquote() {
2712 let rule = fixed_style_rule(2);
2713 let content = "> 1. Some text\n> - four spaces\n";
2714 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2715 let result = rule.check(&ctx).unwrap();
2716 assert_eq!(result.len(), 1, "{result:?}");
2717 assert!(
2718 result[0].message.contains("Expected 3") && result[0].message.contains("found 4"),
2719 "got: {}",
2720 result[0].message
2721 );
2722 let fixed = rule.fix(&ctx).unwrap();
2723 assert_eq!(fixed, "> 1. Some text\n> - four spaces\n");
2724 }
2725
2726 #[test]
2727 fn test_fixed_style_clamp_treats_near_sibling_as_sibling() {
2728 let rule = fixed_style_rule(2);
2734 let content = "1. x\n - a\n - b\n";
2735 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2736 let result = rule.check(&ctx).unwrap();
2737 assert_eq!(result.len(), 1, "{result:?}");
2738 assert!(
2739 result[0].message.contains("Expected 3") && result[0].message.contains("found 4"),
2740 "near-sibling resolves against the ordered parent, got: {}",
2741 result[0].message
2742 );
2743 let fixed = rule.fix(&ctx).unwrap();
2744 assert_eq!(fixed, "1. x\n - a\n - b\n");
2745 }
2746
2747 #[test]
2748 fn test_fixed_style_clamp_child_after_near_sibling_resolves_against_it() {
2749 let rule = fixed_style_rule(2);
2753 let content = "1. x\n - a\n - b\n - c\n";
2754 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2755 let result = rule.check(&ctx).unwrap();
2756 assert_eq!(result.len(), 2, "b and c are both off: {result:?}");
2757 assert!(
2758 result[0].message.contains("Expected 3") && result[0].message.contains("found 4"),
2759 "got: {}",
2760 result[0].message
2761 );
2762 assert!(
2763 result[1].message.contains("Expected 5") && result[1].message.contains("found 6"),
2764 "got: {}",
2765 result[1].message
2766 );
2767 let fixed = rule.fix(&ctx).unwrap();
2768 assert_eq!(fixed, "1. x\n - a\n - b\n - c\n");
2769 }
2770
2771 #[test]
2772 fn test_fixed_style_clamp_pops_near_sibling_of_over_indented_bullet() {
2773 let rule = fixed_style_rule(2);
2778 let content = "1. x\n - a\n - b\n";
2779 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2780 let result = rule.check(&ctx).unwrap();
2781 assert_eq!(result.len(), 2, "a and b are both flagged: {result:?}");
2782 assert!(
2783 result[1].message.contains("Expected 3") && result[1].message.contains("found 5"),
2784 "b resolves against the ordered parent, got: {}",
2785 result[1].message
2786 );
2787 let fixed = rule.fix(&ctx).unwrap();
2788 assert_eq!(fixed, "1. x\n - a\n - b\n");
2789 }
2790
2791 #[test]
2792 fn test_fixed_style_clamp_keeps_child_of_over_indented_bullet() {
2793 let rule = fixed_style_rule(2);
2796 let content = "1. x\n - a\n - c\n";
2797 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2798 let result = rule.check(&ctx).unwrap();
2799 assert_eq!(result.len(), 2, "a and c are both flagged: {result:?}");
2800 assert!(
2801 result[1].message.contains("Expected 5") && result[1].message.contains("found 7"),
2802 "c's floor is a's corrected content column, got: {}",
2803 result[1].message
2804 );
2805 let fixed = rule.fix(&ctx).unwrap();
2806 assert_eq!(fixed, "1. x\n - a\n - c\n");
2807 }
2808
2809 #[test]
2810 fn test_fixed_style_clamp_pops_ordered_near_sibling() {
2811 let rule = fixed_style_rule(2);
2817 let content = "1. root\n - a\n 1. sub\n - b\n";
2818 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2819 let result = rule.check(&ctx).unwrap();
2820 assert_eq!(result.len(), 1, "only b is off: {result:?}");
2821 assert!(
2822 result[0].message.contains("Expected 5") && result[0].message.contains("found 6"),
2823 "b resolves against a, not the nested ordered sibling, got: {}",
2824 result[0].message
2825 );
2826 let fixed = rule.fix(&ctx).unwrap();
2827 assert_eq!(fixed, "1. root\n - a\n 1. sub\n - b\n");
2828 }
2829
2830 #[test]
2831 fn test_fixed_style_clamp_leaves_sibling_bullet_left_of_content_column() {
2832 let rule = fixed_style_rule(2);
2836 let content = "1. Some text\n - two spaces\n";
2837 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2838 let result = rule.check(&ctx).unwrap();
2839 assert!(
2840 result.is_empty(),
2841 "sibling bullet at the fixed indent stays silent: {result:?}"
2842 );
2843 }
2844
2845 #[test]
2846 fn test_fixed_style_clamp_requires_explicit_style() {
2847 let config = MD007Config {
2850 indent: crate::types::IndentSize::from_const(2),
2851 start_indented: false,
2852 start_indent: crate::types::IndentSize::from_const(2),
2853 style: md007_config::IndentStyle::TextAligned,
2854 style_explicit: false,
2855 indent_explicit: true,
2856 };
2857 let rule = MD007ULIndent::from_config_struct(config);
2858 let content = "1. Some text\n - four spaces\n";
2859 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2860 let result = rule.check(&ctx).unwrap();
2861 assert!(result.is_empty(), "no explicit style, exemption stays: {result:?}");
2862
2863 let smart = MD007ULIndent::new(2);
2864 assert!(
2865 smart.check(&ctx).unwrap().is_empty(),
2866 "smart default keeps the exemption too"
2867 );
2868 }
2869
2870 #[test]
2871 fn test_issue_638_deeper_unordered_chain_under_ordered() {
2872 let rule = MD007ULIndent::new(2);
2874 let content = "1. Ordered\n - child\n - grandchild\n - great-grandchild\n";
2875 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2876 let result = rule.check(&ctx).unwrap();
2877 assert!(
2878 result.is_empty(),
2879 "all unordered descendants of an ordered list are exempt, got: {result:?}"
2880 );
2881 }
2882
2883 #[test]
2884 fn test_issue_638_pure_unordered_still_checked() {
2885 let rule = MD007ULIndent::new(2);
2887 let content = "- Top\n - three spaces (wrong, expected 2)\n";
2888 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2889 let result = rule.check(&ctx).unwrap();
2890 assert_eq!(
2891 result.len(),
2892 1,
2893 "pure unordered nesting must still be checked, got: {result:?}"
2894 );
2895 }
2896
2897 #[test]
2898 fn test_issue_638_exemption_not_applied_after_list_terminated_by_paragraph() {
2899 let rule = MD007ULIndent::new(2);
2906 let content = "1. ordered\n\nparagraph\n\n - parent\n - child six\n";
2907 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2908 let result = rule.check(&ctx).unwrap();
2909 assert_eq!(
2910 result.len(),
2911 2,
2912 "the new top-level list following a terminated ordered list is checked at both levels, got: {result:?}"
2913 );
2914 assert!(
2915 result.iter().any(|w| w.line == 5 && w.message.contains("Expected 0")),
2916 "the misindented top-level item must be flagged with Expected 0, got: {result:?}"
2917 );
2918 assert!(
2919 result
2920 .iter()
2921 .any(|w| w.line == 6 && w.message.contains("Expected 2") && w.message.contains("found 6")),
2922 "the misindented child must be flagged with Expected 2, found 6, got: {result:?}"
2923 );
2924 }
2925
2926 #[test]
2927 fn test_issue_638_lazy_continuation_does_not_terminate_ordered_list() {
2928 let rule = MD007ULIndent::new(2);
2934 let content = "1. ordered\nlazy continuation\n - child\n - grandchild\n";
2935 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2936 let result = rule.check(&ctx).unwrap();
2937 assert!(
2938 result.is_empty(),
2939 "lazy continuation must not terminate the ordered list; sublist stays exempt, got: {result:?}"
2940 );
2941 }
2942
2943 #[test]
2944 fn test_issue_638_heading_interrupts_ordered_list_without_blank() {
2945 let rule = MD007ULIndent::new(2);
2952 let content = "1. ordered\n# heading\n - child\n - grandchild\n";
2953 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2954 let result = rule.check(&ctx).unwrap();
2955 assert_eq!(
2956 result.len(),
2957 2,
2958 "a heading terminates the ordered list, so the new top-level list and its child are both checked, got: {result:?}"
2959 );
2960 assert!(
2961 result.iter().any(|w| w.line == 3 && w.message.contains("Expected 0")),
2962 "the misindented top-level item must be flagged with Expected 0, got: {result:?}"
2963 );
2964 assert!(
2965 result.iter().any(|w| w.line == 4 && w.message.contains("Expected 2")),
2966 "the misindented child must be flagged with Expected 2, got: {result:?}"
2967 );
2968 }
2969
2970 #[test]
2971 fn test_issue_638_lazy_continuation_inside_blockquote_keeps_exemption() {
2972 let rule = MD007ULIndent::new(2);
2977 let content = "> 1. ordered\n> continuation\n>\n> - child\n> - grandchild\n";
2978 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2979 let result = rule.check(&ctx).unwrap();
2980 assert!(
2981 result.is_empty(),
2982 "a lazy continuation within the same blockquote must keep the sublist exempt, got: {result:?}"
2983 );
2984 }
2985
2986 #[test]
2987 fn test_issue_638_indented_fence_inside_blockquoted_ordered_item_keeps_exemption() {
2988 let rule = MD007ULIndent::new(2);
2993 let content = "> 1. ordered\n> ```\n> code\n> ```\n> - child\n> - grandchild\n";
2994 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2995 let result = rule.check(&ctx).unwrap();
2996 assert!(
2997 result.is_empty(),
2998 "an indented fence inside a blockquoted ordered item must keep the sublist exempt, got: {result:?}"
2999 );
3000 }
3001
3002 #[test]
3003 fn test_issue_638_fenced_code_block_terminates_ordered_list() {
3004 let rule = MD007ULIndent::new(2);
3010 let content = "1. ordered\n```\ncode\n```\n\n - parent\n - child\n";
3011 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3012 let result = rule.check(&ctx).unwrap();
3013 assert!(
3014 result.iter().any(|w| w.line == 7),
3015 "a top-level fenced code block terminates the ordered list; the child must be flagged, got: {result:?}"
3016 );
3017 }
3018
3019 #[test]
3020 fn test_issue_638_fenced_code_block_inside_item_keeps_exemption() {
3021 let rule = MD007ULIndent::new(2);
3026 let content = "1. ordered\n ```\n code\n ```\n - child\n - grandchild\n";
3027 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3028 let result = rule.check(&ctx).unwrap();
3029 assert!(
3030 result.is_empty(),
3031 "a fenced code block nested inside the item must keep the sublist exempt, got: {result:?}"
3032 );
3033 }
3034
3035 #[test]
3036 fn test_issue_638_blockquote_terminates_ordered_list() {
3037 let rule = MD007ULIndent::new(2);
3044 let content = "1. ordered\n> quote\n\n - parent\n - child\n";
3045 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3046 let result = rule.check(&ctx).unwrap();
3047 assert!(
3048 result.iter().any(|w| w.line == 5),
3049 "blockquote terminates the ordered list, so the child must still be flagged, got: {result:?}"
3050 );
3051 }
3052
3053 #[test]
3054 fn test_issue_638_blockquote_inside_item_keeps_exemption() {
3055 let rule = MD007ULIndent::new(2);
3060 let content = "1. ordered\n > quote inside item\n - child\n - grandchild\n";
3061 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3062 let result = rule.check(&ctx).unwrap();
3063 assert!(
3064 result.is_empty(),
3065 "a blockquote nested inside the item must keep the sublist exempt, got: {result:?}"
3066 );
3067 }
3068
3069 #[test]
3070 fn test_issue_638_exemption_requires_genuine_nesting_under_ordered() {
3071 let rule = MD007ULIndent::new(2);
3080 let content = "100. ordered\n - parent\n - child\n";
3081 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3082 let result = rule.check(&ctx).unwrap();
3083 assert!(
3084 result.iter().any(|w| w.line == 3),
3085 "the child of a non-nested bullet must still be checked, not exempted; got: {result:?}"
3086 );
3087 }
3088
3089 #[test]
3090 fn test_issue_638_paragraph_after_fenced_code_closes_ordered_list() {
3091 let rule = MD007ULIndent::new(2);
3100 let content = "1. ordered\n ```\n code\n ```\nnot lazy text\n - parent\n - child\n";
3101 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3102 let result = rule.check(&ctx).unwrap();
3103 assert!(
3104 result.iter().any(|w| w.line == 7),
3105 "fenced code is not paragraph text, so the list closes and the nested child must still be checked, not exempted; got: {result:?}"
3106 );
3107 }
3108
3109 #[test]
3110 fn test_issue_638_overlong_ordered_marker_is_lazy_continuation() {
3111 let rule = MD007ULIndent::new(2);
3117 let content = "1. ordered\n1234567890. this is continuation text\n - child\n - grandchild\n";
3118 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3119 let result = rule.check(&ctx).unwrap();
3120 assert!(
3121 result.is_empty(),
3122 "an overlong digit run is not a valid ordered marker, so the list stays open and the nested bullets are exempt; got: {result:?}"
3123 );
3124 }
3125
3126 #[test]
3127 fn test_indented_top_level_list_item_is_flagged() {
3128 let rule = MD007ULIndent::new(2);
3134 for indent in 2..=3 {
3135 let pad = " ".repeat(indent);
3136 let content = format!("{pad}- parent\n{pad} - child\n");
3137 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
3138 let result = rule.check(&ctx).unwrap();
3139 assert!(
3140 result.iter().any(|w| w.line == 1),
3141 "a top-level item indented {indent} spaces must be flagged (Expected 0); got: {result:?}"
3142 );
3143 }
3144 }
3145
3146 #[test]
3147 fn test_indented_code_block_bullet_is_not_a_list_item() {
3148 let rule = MD007ULIndent::new(2);
3151 let content = " - not a list, this is code\n";
3152 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3153 let result = rule.check(&ctx).unwrap();
3154 assert!(
3155 result.is_empty(),
3156 "a 4-space-indented bullet is an indented code block, not a misindented list; got: {result:?}"
3157 );
3158 }
3159
3160 #[test]
3161 fn test_tab_indent_expands_to_four_column_tabstop() {
3162 let rule = MD007ULIndent::new(2);
3169 let content = "- a\n\t- b\n";
3170 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3171 let result = rule.check(&ctx).unwrap();
3172 let warning = result
3173 .iter()
3174 .find(|w| w.line == 2)
3175 .expect("a tab-indented sublist at column 4 is over-indented for depth 1 and must be flagged");
3176 assert!(
3177 warning.message.contains("found 4"),
3178 "the tab must expand to the 4-column tab stop (found 4), not be counted as one character; got: {}",
3179 warning.message
3180 );
3181 }
3182
3183 #[test]
3184 fn test_tab_completing_two_space_indent_to_tabstop_is_accepted() {
3185 let rule = MD007ULIndent::new(2);
3191 let content = "- a\n - b\n \t- c\n";
3192 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3193 let result = rule.check(&ctx).unwrap();
3194 assert!(
3195 result.is_empty(),
3196 "` \\t` expands to column 4, the correct depth-2 indent, so no MD007 warning is expected; got: {result:?}"
3197 );
3198 }
3199
3200 #[test]
3201 fn test_issue_638_html_comment_terminates_ordered_list() {
3202 let rule = MD007ULIndent::new(2);
3209 let content = "1. ordered\n<!-- comment -->\n\n - parent\n - child\n";
3210 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3211 let result = rule.check(&ctx).unwrap();
3212 assert!(
3213 result.iter().any(|w| w.line == 5),
3214 "an HTML comment terminates the ordered list, so the child must still be flagged, got: {result:?}"
3215 );
3216 }
3217
3218 #[test]
3219 fn test_issue_638_blockquoted_list_item_terminates_ordered_list() {
3220 let rule = MD007ULIndent::new(2);
3228 let content = "1. ordered\n> - quote list\n\n - parent\n - child\n";
3229 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3230 let result = rule.check(&ctx).unwrap();
3231 assert!(
3232 result.iter().any(|w| w.line == 5),
3233 "a blockquoted list item terminates the ordered list, so the child must still be flagged, got: {result:?}"
3234 );
3235 }
3236
3237 #[test]
3238 fn test_issue_638_deeper_nested_quote_terminates_blockquoted_ordered_list() {
3239 let rule = MD007ULIndent::new(2);
3249 let content = "> 1. ordered\n> > quote\n>\n> - parent\n> - child\n";
3250 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3251 let result = rule.check(&ctx).unwrap();
3252 assert!(
3253 result.iter().any(|w| w.line == 4),
3254 "deeper nested quote closes the ordered list, so the misindented parent must be flagged, got: {result:?}"
3255 );
3256 assert!(
3257 result.iter().any(|w| w.line == 5),
3258 "the child of the fresh unordered list must be flagged, not exempted, got: {result:?}"
3259 );
3260 }
3261
3262 #[test]
3263 fn test_issue_638_deeper_quote_list_item_terminates_blockquoted_ordered_list() {
3264 let rule = MD007ULIndent::new(2);
3272 let content = "> 1. ordered\n> > - quote list\n>\n> - parent\n> - child\n";
3273 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3274 let result = rule.check(&ctx).unwrap();
3275 assert!(
3276 result.iter().any(|w| w.line == 4),
3277 "a deeper-quote list item closes the ordered list, so the parent must be flagged, got: {result:?}"
3278 );
3279 assert!(
3280 result.iter().any(|w| w.line == 5),
3281 "the child of the fresh unordered list must be flagged, not exempted, got: {result:?}"
3282 );
3283 }
3284
3285 #[test]
3286 fn test_issue_638_deeper_quote_indented_into_item_keeps_exemption() {
3287 let rule = MD007ULIndent::new(2);
3292 let content = "> 1. ordered\n> > quote inside item\n> - child\n> - grandchild\n";
3293 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3294 let result = rule.check(&ctx).unwrap();
3295 assert!(
3296 result.is_empty(),
3297 "a deeper quote indented into the item must keep the sublist exempt, got: {result:?}"
3298 );
3299 }
3300
3301 #[test]
3302 fn test_indent4_explicit_with_wide_ordered_parent() {
3303 let config = MD007Config {
3307 indent: crate::types::IndentSize::from_const(4),
3308 start_indented: false,
3309 start_indent: crate::types::IndentSize::from_const(2),
3310 style: md007_config::IndentStyle::TextAligned,
3311 style_explicit: false,
3312 indent_explicit: true,
3313 };
3314 let rule = MD007ULIndent::from_config_struct(config);
3315
3316 let content = "100. Wide ordered\n * Bullet at 5 spaces";
3318 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3319 let result = rule.check(&ctx).unwrap();
3320 assert!(
3321 result.is_empty(),
3322 "indent=4 under '100.' should accept 5-space indent: {result:?}"
3323 );
3324
3325 let content_4 = "100. Wide ordered\n * Bullet at 4 spaces";
3327 let ctx = LintContext::new(content_4, crate::config::MarkdownFlavor::Standard, None);
3328 let result = rule.check(&ctx).unwrap();
3329 assert!(
3330 result.is_empty(),
3331 "indent=4 under '100.' should accept 4-space indent: {result:?}"
3332 );
3333 }
3334
3335 fn commonmark_max_list_depth(md: &str) -> usize {
3339 use pulldown_cmark::{Event, Parser, Tag, TagEnd};
3340 let (mut depth, mut max) = (0usize, 0usize);
3341 for event in Parser::new(md) {
3342 match event {
3343 Event::Start(Tag::List(_)) => {
3344 depth += 1;
3345 max = max.max(depth);
3346 }
3347 Event::End(TagEnd::List(_)) => depth = depth.saturating_sub(1),
3348 _ => {}
3349 }
3350 }
3351 max
3352 }
3353
3354 #[test]
3355 fn test_md007_widened_parent_marker_keeps_nested_child() {
3356 let rule = MD007ULIndent::default();
3362 let content = indoc! {"
3363 - Parent item
3364 - Nested item
3365 "};
3366 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3367 let result = rule.check(&ctx).unwrap();
3368 assert!(
3369 result.is_empty(),
3370 "a child aligned to a widened parent's content column must not be flagged: {result:?}"
3371 );
3372 assert_eq!(commonmark_max_list_depth(content), 2, "precondition: source is nested");
3373 assert_eq!(
3374 rule.fix(&ctx).unwrap(),
3375 content,
3376 "fix must be a no-op for an already correctly nested child"
3377 );
3378 }
3379
3380 #[test]
3381 fn test_md007_widened_parent_aligns_child_to_content_column() {
3382 let rule = MD007ULIndent::default();
3385 let content = indoc! {"
3386 - Parent item
3387 - Nested item
3388 "};
3389 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3390 let fixed = rule.fix(&ctx).unwrap();
3391 assert_eq!(
3392 fixed,
3393 indoc! {"
3394 - Parent item
3395 - Nested item
3396 "},
3397 "child must align to the parent's content column 4: {fixed:?}"
3398 );
3399 assert_eq!(
3400 commonmark_max_list_depth(&fixed),
3401 2,
3402 "fixed child must remain nested, not flattened to a sibling:\n{fixed}"
3403 );
3404 }
3405
3406 #[test]
3407 fn test_md007_widened_markers_nested_multiple_levels() {
3408 let rule = MD007ULIndent::default();
3411 let content = indoc! {"
3412 - Level 0
3413 - Level 1
3414 - Level 2
3415 "};
3416 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3417 let result = rule.check(&ctx).unwrap();
3418 assert!(
3419 result.is_empty(),
3420 "deeply nested widened markers must not be flagged: {result:?}"
3421 );
3422 assert_eq!(
3423 commonmark_max_list_depth(content),
3424 3,
3425 "three nesting levels are preserved"
3426 );
3427 }
3428
3429 #[test]
3430 fn test_md007_default_marker_indent_still_enforced() {
3431 let rule = MD007ULIndent::default();
3435 let content = indoc! {"
3436 - Parent item
3437 - Nested item
3438 "};
3439 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3440 let result = rule.check(&ctx).unwrap();
3441 assert_eq!(
3442 result.len(),
3443 1,
3444 "an over-indented child under a normal marker is still flagged: {result:?}"
3445 );
3446 assert_eq!(
3447 rule.fix(&ctx).unwrap(),
3448 indoc! {"
3449 - Parent item
3450 - Nested item
3451 "}
3452 );
3453 }
3454}