1use crate::rule::{LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
5
6pub mod md007_config;
7use md007_config::MD007Config;
8
9#[derive(Debug, Clone, Default)]
10pub struct MD007ULIndent {
11 config: MD007Config,
12}
13
14impl MD007ULIndent {
15 pub fn new(indent: usize) -> Self {
16 Self {
17 config: MD007Config {
18 indent: crate::types::IndentSize::from_const(indent as u8),
19 start_indented: false,
20 start_indent: crate::types::IndentSize::from_const(2),
21 style: md007_config::IndentStyle::TextAligned,
22 style_explicit: false, indent_explicit: false, },
25 }
26 }
27
28 pub fn from_config_struct(config: MD007Config) -> Self {
29 Self { config }
30 }
31
32 fn char_pos_to_visual_column(content: &str, char_pos: usize) -> usize {
34 let mut visual_col = 0;
35
36 for (current_pos, ch) in content.chars().enumerate() {
37 if current_pos >= char_pos {
38 break;
39 }
40 if ch == '\t' {
41 visual_col = (visual_col / 4 + 1) * 4;
43 } else {
44 visual_col += 1;
45 }
46 }
47 visual_col
48 }
49
50 fn indent_relative_to_depth(
74 ctx: &crate::lint_context::LintContext,
75 line_info: &crate::lint_context::LineInfo,
76 depth: usize,
77 ) -> usize {
78 if depth == 0 {
79 return line_info.visual_indent;
80 }
81 let line_content = line_info.content(ctx.content);
86 let mut remaining = line_content;
87 let mut content_start = 0;
88 let mut stripped_levels = 0;
89 while stripped_levels < depth {
90 let trimmed = remaining.trim_start();
91 if !trimmed.starts_with('>') {
92 break;
93 }
94 content_start += remaining.len() - trimmed.len();
95 content_start += 1;
96 let after_gt = &trimmed[1..];
97 if let Some(stripped) = after_gt.strip_prefix(' ') {
98 content_start += 1;
99 remaining = stripped;
100 } else if let Some(stripped) = after_gt.strip_prefix('\t') {
101 content_start += 1;
102 remaining = stripped;
103 } else {
104 remaining = after_gt;
105 }
106 stripped_levels += 1;
107 }
108 let content_after_prefix = &line_content[content_start..];
109 let ws_chars = content_after_prefix
110 .chars()
111 .take_while(|c| *c == ' ' || *c == '\t')
112 .count();
113 Self::char_pos_to_visual_column(content_after_prefix, ws_chars)
114 }
115
116 fn terminate_closed_items(
117 ctx: &crate::lint_context::LintContext,
118 line_info: &crate::lint_context::LineInfo,
119 list_stack: &mut Vec<(usize, usize, bool, usize, usize, bool, usize)>,
120 line_bq_depth: usize,
121 ) {
122 while let Some(&(_, _, _, content_col, item_bq_depth, _, _)) = list_stack.last() {
123 let closed = match item_bq_depth.cmp(&line_bq_depth) {
124 std::cmp::Ordering::Greater => true,
126 std::cmp::Ordering::Equal | std::cmp::Ordering::Less => {
135 content_col > Self::indent_relative_to_depth(ctx, line_info, item_bq_depth)
136 }
137 };
138 if closed {
139 list_stack.pop();
140 } else {
141 break;
142 }
143 }
144 }
145
146 fn calculate_expected_indent(
155 &self,
156 nesting_level: usize,
157 parent_info: Option<(bool, usize)>, ) -> usize {
159 if nesting_level == 0 {
160 return 0;
161 }
162
163 if self.config.style_explicit {
165 return match self.config.style {
166 md007_config::IndentStyle::Fixed => nesting_level * self.config.indent.get() as usize,
167 md007_config::IndentStyle::TextAligned => {
168 parent_info.map_or(nesting_level * 2, |(_, content_col)| content_col)
169 }
170 };
171 }
172
173 if self.config.indent_explicit {
176 match parent_info {
177 Some((true, parent_content_col)) => {
178 return parent_content_col;
181 }
182 _ => {
183 return nesting_level * self.config.indent.get() as usize;
185 }
186 }
187 }
188
189 match parent_info {
191 Some((true, parent_content_col)) => {
192 parent_content_col
195 }
196 Some((false, parent_content_col)) => {
197 let parent_level = nesting_level.saturating_sub(1);
201 let expected_parent_marker = parent_level * self.config.indent.get() as usize;
202 let parent_marker_col = parent_content_col.saturating_sub(2);
204
205 if parent_marker_col == expected_parent_marker {
206 nesting_level * self.config.indent.get() as usize
208 } else {
209 parent_content_col
211 }
212 }
213 None => {
214 nesting_level * self.config.indent.get() as usize
216 }
217 }
218 }
219}
220
221impl Rule for MD007ULIndent {
222 fn name(&self) -> &'static str {
223 "MD007"
224 }
225
226 fn description(&self) -> &'static str {
227 "Unordered list indentation"
228 }
229
230 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
231 let mut warnings = Vec::new();
232 let mut list_stack: Vec<(usize, usize, bool, usize, usize, bool, usize)> = Vec::new(); for (line_idx, line_info) in ctx.lines.iter().enumerate() {
235 let parsed_list_item = ctx.list_item_on_line(line_idx + 1);
236 let is_skipped_region = |info: &crate::lint_context::LineInfo| {
238 info.in_code_block || info.in_front_matter || info.in_mkdocstrings || info.in_footnote_definition
239 };
240 let opens_fence_on_marker_line = parsed_list_item
253 .and_then(|item| line_info.content(ctx.content).get(item.content_column()..))
254 .is_some_and(|after_marker| {
255 let after_marker = after_marker.trim_start();
256 after_marker.starts_with("```") || after_marker.starts_with("~~~")
257 });
258 let fence_opening_marker_line = opens_fence_on_marker_line
259 && line_info.in_code_block
260 && !line_info.in_front_matter
261 && !line_info.in_mkdocstrings
262 && !line_info.in_footnote_definition;
263 if is_skipped_region(line_info) && !fence_opening_marker_line {
264 let region_start = line_idx == 0 || !is_skipped_region(&ctx.lines[line_idx - 1]);
271 if region_start && !line_info.is_blank {
272 let bq_depth = line_info.blockquote.as_ref().map_or(0, |bq| bq.nesting_level);
273 Self::terminate_closed_items(ctx, line_info, &mut list_stack, bq_depth);
274 }
275 continue;
276 }
277
278 if let Some(list_item) = parsed_list_item {
280 let (content_for_calculation, adjusted_marker_column) = if line_info.blockquote.is_some() {
284 let line_content = line_info.content(ctx.content);
286 let mut remaining = line_content;
287 let mut content_start = 0;
288
289 loop {
290 let trimmed = remaining.trim_start();
291 if !trimmed.starts_with('>') {
292 break;
293 }
294 content_start += remaining.len() - trimmed.len();
296 content_start += 1;
298 let after_gt = &trimmed[1..];
299 if let Some(stripped) = after_gt.strip_prefix(' ') {
301 content_start += 1;
302 remaining = stripped;
303 } else if let Some(stripped) = after_gt.strip_prefix('\t') {
304 content_start += 1;
305 remaining = stripped;
306 } else {
307 remaining = after_gt;
308 }
309 }
310
311 let content_after_prefix = &line_content[content_start..];
313 let adjusted_col = if list_item.marker_column() >= content_start {
315 list_item.marker_column() - content_start
316 } else {
317 list_item.marker_column()
319 };
320 (content_after_prefix.to_string(), adjusted_col)
321 } else {
322 (line_info.content(ctx.content).to_string(), list_item.marker_column())
323 };
324
325 let visual_marker_column =
327 Self::char_pos_to_visual_column(&content_for_calculation, adjusted_marker_column);
328
329 let visual_content_column = if line_info.blockquote.is_some() {
331 let adjusted_content_col =
333 if list_item.content_column() >= (line_info.byte_len - content_for_calculation.len()) {
334 list_item.content_column() - (line_info.byte_len - content_for_calculation.len())
335 } else {
336 list_item.content_column()
337 };
338 Self::char_pos_to_visual_column(&content_for_calculation, adjusted_content_col)
339 } else {
340 Self::char_pos_to_visual_column(line_info.content(ctx.content), list_item.content_column())
341 };
342
343 let visual_marker_for_nesting = if visual_marker_column == 1 && self.config.indent.get() != 1 {
347 0
348 } else {
349 visual_marker_column
350 };
351
352 let bq_depth = line_info.blockquote.as_ref().map_or(0, |bq| bq.nesting_level);
354
355 while let Some(&(indent, _, _, _, item_bq_depth, _, _)) = list_stack.last() {
358 if item_bq_depth == bq_depth && indent >= visual_marker_for_nesting {
359 list_stack.pop();
360 } else if item_bq_depth > bq_depth {
361 list_stack.pop();
363 } else {
364 break;
365 }
366 }
367
368 while let Some(&(_, _, _, content_col, item_bq_depth, _, _)) = list_stack.last() {
381 if item_bq_depth < bq_depth
382 && content_col > Self::indent_relative_to_depth(ctx, line_info, item_bq_depth)
383 {
384 list_stack.pop();
385 } else {
386 break;
387 }
388 }
389
390 if list_item.is_ordered() {
392 list_stack.push((
395 visual_marker_column,
396 line_idx,
397 true,
398 visual_content_column,
399 bq_depth,
400 false,
401 visual_content_column,
402 ));
403 continue;
404 }
405
406 let threshold_ok = list_stack
430 .iter()
431 .any(|item| item.4 == bq_depth && item.2 && item.3 <= visual_marker_column);
432 if ctx.flavor != crate::config::MarkdownFlavor::MkDocs
443 && threshold_ok
444 && self.config.style_explicit
445 && self.config.style == md007_config::IndentStyle::Fixed
446 {
447 while let Some(&(_, _, _, _, item_bq_depth, _, source_content_col)) = list_stack.last() {
448 if item_bq_depth == bq_depth && source_content_col > visual_marker_column {
449 list_stack.pop();
450 } else {
451 break;
452 }
453 }
454 }
455 let chain_ok = list_stack
456 .iter()
457 .rev()
458 .find(|item| item.4 == bq_depth)
459 .is_some_and(|item| item.2 || item.5);
460 let ordered_chain = ctx.flavor != crate::config::MarkdownFlavor::MkDocs && threshold_ok && chain_ok;
461 let clamp_to_parent = ordered_chain
467 && self.config.style_explicit
468 && self.config.style == md007_config::IndentStyle::Fixed;
469 if ordered_chain && !clamp_to_parent {
470 list_stack.push((
471 visual_marker_column,
472 line_idx,
473 false,
474 visual_content_column,
475 bq_depth,
476 true,
477 visual_content_column,
478 ));
479 continue;
480 }
481
482 let nesting_level = list_stack.iter().filter(|item| item.4 == bq_depth).count();
484
485 let parent_info = list_stack
487 .iter()
488 .rev()
489 .find(|item| item.4 == bq_depth)
490 .map(|&(_, _, is_ordered, content_col, _, _, _)| (is_ordered, content_col));
491
492 let mut expected_indent = if self.config.start_indented && nesting_level == 0 {
498 self.config.start_indent.get() as usize
499 } else {
500 self.calculate_expected_indent(nesting_level, parent_info)
501 };
502
503 if clamp_to_parent && let Some((_, parent_content_col)) = parent_info {
508 expected_indent = expected_indent.max(parent_content_col);
509 }
510
511 let also_acceptable = if !clamp_to_parent
517 && self.config.indent_explicit
518 && parent_info.is_some_and(|(is_ordered, _)| is_ordered)
519 {
520 Some(nesting_level * self.config.indent.get() as usize)
521 } else {
522 None
523 };
524
525 if ctx.flavor == crate::config::MarkdownFlavor::MkDocs
529 && let Some(&(parent_marker_col, _, true, _, _, _, _)) =
530 list_stack.iter().rev().find(|item| item.4 == bq_depth && item.2)
531 {
532 expected_indent = expected_indent.max(parent_marker_col + 4);
533 }
534
535 let accepted_indent = if also_acceptable.is_some_and(|alt| visual_marker_column == alt) {
541 visual_marker_column
542 } else {
543 expected_indent
544 };
545 let marker_width = visual_content_column.saturating_sub(visual_marker_column);
555 let expected_content_visual_col = accepted_indent + marker_width;
556 list_stack.push((
561 visual_marker_column,
562 line_idx,
563 false,
564 expected_content_visual_col,
565 bq_depth,
566 clamp_to_parent,
567 visual_content_column,
568 ));
569
570 if !self.config.start_indented && nesting_level == 0 && visual_marker_column == 0 {
576 continue;
577 }
578
579 if visual_marker_column != expected_indent && also_acceptable != Some(visual_marker_column) {
580 if let Some(alt) = also_acceptable {
582 expected_indent = alt;
583 }
584 let fix = {
586 let correct_indent = " ".repeat(expected_indent);
587
588 let replacement = if line_info.blockquote.is_some() {
591 let mut blockquote_count = 0;
593 for ch in line_info.content(ctx.content).chars() {
594 if ch == '>' {
595 blockquote_count += 1;
596 } else if ch != ' ' && ch != '\t' {
597 break;
598 }
599 }
600 let blockquote_prefix = if blockquote_count > 1 {
602 (0..blockquote_count)
603 .map(|_| "> ")
604 .collect::<String>()
605 .trim_end()
606 .to_string()
607 } else {
608 ">".to_string()
609 };
610 format!("{blockquote_prefix} {correct_indent}")
613 } else {
614 correct_indent
615 };
616
617 let start_byte = line_info.byte_offset;
620 let mut end_byte = line_info.byte_offset;
621
622 for (i, ch) in line_info.content(ctx.content).chars().enumerate() {
624 if i >= list_item.marker_column() {
625 break;
626 }
627 end_byte += ch.len_utf8();
628 }
629
630 Some(crate::rule::Fix::new(start_byte..end_byte, replacement))
631 };
632
633 warnings.push(LintWarning {
634 rule_name: Some(self.name().to_string()),
635 message: format!(
636 "Expected {expected_indent} spaces for indent depth {nesting_level}, found {visual_marker_column}"
637 ),
638 line: line_idx + 1, column: 1, end_line: line_idx + 1,
641 end_column: visual_marker_column + 1, severity: Severity::Warning,
643 fix,
644 });
645 }
646 } else if !line_info.is_blank {
647 let bq_depth = line_info.blockquote.as_ref().map_or(0, |bq| bq.nesting_level);
674 let prev_line = line_idx.checked_sub(1).map(|i| &ctx.lines[i]);
675 let prev_blank = prev_line.is_none_or(|p| p.is_blank);
676 let prev_bq_depth = prev_line
677 .and_then(|p| p.blockquote.as_ref())
678 .map_or(0, |bq| bq.nesting_level);
679 let same_container = prev_bq_depth == bq_depth;
680 let text = line_info
681 .blockquote
682 .as_ref()
683 .map_or_else(|| line_info.content(ctx.content), |bq| bq.content.as_str());
684 let trimmed = text.trim_start();
685 let starts_like_list_marker = match trimmed.as_bytes().first() {
686 Some(b'-' | b'*' | b'+') => {
687 matches!(trimmed.as_bytes().get(1), Some(b' ' | b'\t'))
688 }
689 Some(c) if c.is_ascii_digit() => {
690 let after_digits = trimmed.trim_start_matches(|ch: char| ch.is_ascii_digit());
694 let num_digits = trimmed.len() - after_digits.len();
695 let mut rest = after_digits.chars();
696 (1..=9).contains(&num_digits)
697 && matches!(rest.next(), Some('.' | ')'))
698 && matches!(rest.next(), Some(' ' | '\t') | None)
699 }
700 _ => false,
701 };
702 let prev_is_open_paragraph = prev_line.is_some_and(|p| {
709 !p.is_blank
710 && !p.in_code_block
711 && p.heading.is_none()
712 && !p.is_horizontal_rule
713 && !p.in_html_block
714 && !p.in_html_comment
715 && !p.is_div_marker
716 });
717 let is_lazy_paragraph_continuation = !prev_blank
718 && prev_is_open_paragraph
719 && same_container
720 && !starts_like_list_marker
721 && line_info.heading.is_none()
722 && !line_info.is_horizontal_rule
723 && !line_info.in_code_block
724 && !line_info.in_html_block
725 && !line_info.in_html_comment
726 && !line_info.is_div_marker;
727 if is_lazy_paragraph_continuation {
728 continue;
730 }
731 Self::terminate_closed_items(ctx, line_info, &mut list_stack, bq_depth);
732 }
733 }
734 Ok(warnings)
735 }
736
737 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
739 let warnings = self.check(ctx)?;
741 let warnings =
742 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
743
744 if warnings.is_empty() {
746 return Ok(ctx.content.to_string());
747 }
748
749 let mut fixes: Vec<_> = warnings
751 .iter()
752 .filter_map(|w| w.fix.as_ref().map(|f| (f.range.start, f.range.end, &f.replacement)))
753 .collect();
754 fixes.sort_by_key(|f| std::cmp::Reverse(f.0));
755
756 let mut result = ctx.content.to_string();
758 for (start, end, replacement) in fixes {
759 if start < result.len() && end <= result.len() && start <= end {
760 result.replace_range(start..end, replacement);
761 }
762 }
763
764 Ok(result)
765 }
766
767 fn category(&self) -> RuleCategory {
769 RuleCategory::List
770 }
771
772 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
774 if ctx.content.is_empty() || !ctx.likely_has_lists() {
776 return true;
777 }
778 !ctx.has_unordered_list_items()
780 }
781
782 fn as_any(&self) -> &dyn std::any::Any {
783 self
784 }
785
786 crate::impl_rule_config_sections!(MD007Config);
787
788 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
789 where
790 Self: Sized,
791 {
792 let mut rule_config = crate::rule_config_serde::load_rule_config::<MD007Config>(config);
793
794 if let Some(rule_cfg) = config.rules.get("MD007") {
796 rule_config.style_explicit = rule_cfg.values.contains_key("style");
797 rule_config.indent_explicit = rule_cfg.values.contains_key("indent");
798
799 if rule_config.indent_explicit
803 && rule_config.style_explicit
804 && rule_config.style == md007_config::IndentStyle::TextAligned
805 {
806 eprintln!(
807 "\x1b[33m[config warning]\x1b[0m MD007: 'indent' has no effect when 'style = \"text-aligned\"'. \
808 Text-aligned style ignores indent and aligns nested items with parent text. \
809 To use fixed {} space increments, either remove 'style' or set 'style = \"fixed\"'.",
810 rule_config.indent.get()
811 );
812 }
813 }
814
815 if config.markdown_flavor() == crate::config::MarkdownFlavor::MkDocs {
818 if rule_config.indent_explicit && rule_config.indent.get() < 4 {
819 eprintln!(
820 "\x1b[33m[config warning]\x1b[0m MD007: MkDocs flavor requires indent >= 4 \
821 (Python-Markdown enforces 4-space indentation). \
822 Overriding indent={} to indent=4.",
823 rule_config.indent.get()
824 );
825 }
826 if rule_config.style_explicit && rule_config.style == md007_config::IndentStyle::TextAligned {
827 eprintln!(
828 "\x1b[33m[config warning]\x1b[0m MD007: MkDocs flavor requires style=\"fixed\" \
829 (Python-Markdown uses fixed 4-space indentation). \
830 Overriding style=\"text-aligned\" to style=\"fixed\"."
831 );
832 }
833 if rule_config.indent.get() < 4 {
834 rule_config.indent = crate::types::IndentSize::from_const(4);
835 }
836 rule_config.style = md007_config::IndentStyle::Fixed;
837 }
838
839 Box::new(Self::from_config_struct(rule_config))
840 }
841}
842
843#[cfg(test)]
844mod tests {
845 use super::*;
846 use crate::lint_context::LintContext;
847 use crate::rule::Rule;
848 use indoc::indoc;
849
850 #[test]
851 fn test_valid_list_indent() {
852 let rule = MD007ULIndent::default();
853 let content = "* Item 1\n * Item 2\n * Item 3";
854 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
855 let result = rule.check(&ctx).unwrap();
856 assert!(
857 result.is_empty(),
858 "Expected no warnings for valid indentation, but got {} warnings",
859 result.len()
860 );
861 }
862
863 #[test]
864 fn test_invalid_list_indent() {
865 let rule = MD007ULIndent::default();
866 let content = "* Item 1\n * Item 2\n * Item 3";
867 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
868 let result = rule.check(&ctx).unwrap();
869 assert_eq!(result.len(), 2);
870 assert_eq!(result[0].line, 2);
871 assert_eq!(result[0].column, 1);
872 assert_eq!(result[1].line, 3);
873 assert_eq!(result[1].column, 1);
874 }
875
876 #[test]
877 fn test_mixed_indentation() {
878 let rule = MD007ULIndent::default();
879 let content = "* Item 1\n * Item 2\n * Item 3\n * Item 4";
880 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
881 let result = rule.check(&ctx).unwrap();
882 assert_eq!(result.len(), 1);
883 assert_eq!(result[0].line, 3);
884 assert_eq!(result[0].column, 1);
885 }
886
887 #[test]
888 fn test_fix_indentation() {
889 let rule = MD007ULIndent::default();
890 let content = "* Item 1\n * Item 2\n * Item 3";
891 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
892 let result = rule.fix(&ctx).unwrap();
893 let expected = "* Item 1\n * Item 2\n * Item 3";
897 assert_eq!(result, expected);
898 }
899
900 #[test]
901 fn test_md007_in_yaml_code_block() {
902 let rule = MD007ULIndent::default();
903 let content = r#"```yaml
904repos:
905- repo: https://github.com/rvben/rumdl
906 rev: v0.5.0
907 hooks:
908 - id: rumdl-check
909```"#;
910 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
911 let result = rule.check(&ctx).unwrap();
912 assert!(
913 result.is_empty(),
914 "MD007 should not trigger inside a code block, but got warnings: {result:?}"
915 );
916 }
917
918 #[test]
919 fn test_blockquoted_list_indent() {
920 let rule = MD007ULIndent::default();
921 let content = "> * Item 1\n> * Item 2\n> * Item 3";
922 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
923 let result = rule.check(&ctx).unwrap();
924 assert!(
925 result.is_empty(),
926 "Expected no warnings for valid blockquoted list indentation, but got {result:?}"
927 );
928 }
929
930 #[test]
931 fn test_blockquoted_list_invalid_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!(
937 result.len(),
938 2,
939 "Expected 2 warnings for invalid blockquoted list indentation, got {result:?}"
940 );
941 assert_eq!(result[0].line, 2);
942 assert_eq!(result[1].line, 3);
943 }
944
945 #[test]
946 fn test_nested_blockquote_list_indent() {
947 let rule = MD007ULIndent::default();
948 let content = "> > * Item 1\n> > * Item 2\n> > * Item 3";
949 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
950 let result = rule.check(&ctx).unwrap();
951 assert!(
952 result.is_empty(),
953 "Expected no warnings for valid nested blockquoted list indentation, but got {result:?}"
954 );
955 }
956
957 #[test]
958 fn test_blockquote_list_with_code_block() {
959 let rule = MD007ULIndent::default();
960 let content = "> * Item 1\n> * Item 2\n> ```\n> code\n> ```\n> * Item 3";
961 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
962 let result = rule.check(&ctx).unwrap();
963 assert!(
964 result.is_empty(),
965 "MD007 should not trigger inside a code block within a blockquote, but got warnings: {result:?}"
966 );
967 }
968
969 #[test]
970 fn test_properly_indented_lists() {
971 let rule = MD007ULIndent::default();
972
973 let test_cases = vec![
975 "* Item 1\n* Item 2",
976 "* Item 1\n * Item 1.1\n * Item 1.1.1",
977 "- Item 1\n - Item 1.1",
978 "+ Item 1\n + Item 1.1",
979 "* Item 1\n * Item 1.1\n* Item 2\n * Item 2.1",
980 ];
981
982 for content in test_cases {
983 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
984 let result = rule.check(&ctx).unwrap();
985 assert!(
986 result.is_empty(),
987 "Expected no warnings for properly indented list:\n{}\nGot {} warnings",
988 content,
989 result.len()
990 );
991 }
992 }
993
994 #[test]
995 fn test_under_indented_lists() {
996 let rule = MD007ULIndent::default();
997
998 let test_cases = vec![
999 ("* Item 1\n * Item 1.1", 1, 2), ("* Item 1\n * Item 1.1\n * Item 1.1.1", 1, 3), ];
1002
1003 for (content, expected_warnings, line) in test_cases {
1004 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1005 let result = rule.check(&ctx).unwrap();
1006 assert_eq!(
1007 result.len(),
1008 expected_warnings,
1009 "Expected {expected_warnings} warnings for under-indented list:\n{content}"
1010 );
1011 if expected_warnings > 0 {
1012 assert_eq!(result[0].line, line);
1013 }
1014 }
1015 }
1016
1017 #[test]
1018 fn test_over_indented_lists() {
1019 let rule = MD007ULIndent::default();
1020
1021 let test_cases = vec![
1022 ("* 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), ];
1026
1027 for (content, expected_warnings, line) in test_cases {
1028 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1029 let result = rule.check(&ctx).unwrap();
1030 assert_eq!(
1031 result.len(),
1032 expected_warnings,
1033 "Expected {expected_warnings} warnings for over-indented list:\n{content}"
1034 );
1035 if expected_warnings > 0 {
1036 assert_eq!(result[0].line, line);
1037 }
1038 }
1039 }
1040
1041 #[test]
1042 fn test_custom_indent_2_spaces() {
1043 let rule = MD007ULIndent::new(2); let content = "* Item 1\n * Item 2\n * Item 3";
1045 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1046 let result = rule.check(&ctx).unwrap();
1047 assert!(result.is_empty());
1048 }
1049
1050 #[test]
1051 fn test_custom_indent_3_spaces() {
1052 let rule = MD007ULIndent::new(3);
1055
1056 let correct_content = "* Item 1\n * Item 2\n * Item 3";
1058 let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard, None);
1059 let result = rule.check(&ctx).unwrap();
1060 assert!(
1061 result.is_empty(),
1062 "Fixed style expects 0, 3, 6 spaces but got: {result:?}"
1063 );
1064
1065 let wrong_content = "* Item 1\n * Item 2\n * Item 3";
1067 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
1068 let result = rule.check(&ctx).unwrap();
1069 assert!(!result.is_empty(), "Should warn: expected 3 spaces, found 2");
1070 }
1071
1072 #[test]
1073 fn test_custom_indent_4_spaces() {
1074 let rule = MD007ULIndent::new(4);
1077
1078 let correct_content = "* Item 1\n * Item 2\n * Item 3";
1080 let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard, None);
1081 let result = rule.check(&ctx).unwrap();
1082 assert!(
1083 result.is_empty(),
1084 "Fixed style expects 0, 4, 8 spaces but got: {result:?}"
1085 );
1086
1087 let wrong_content = "* Item 1\n * Item 2\n * Item 3";
1089 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
1090 let result = rule.check(&ctx).unwrap();
1091 assert!(!result.is_empty(), "Should warn: expected 4 spaces, found 2");
1092 }
1093
1094 #[test]
1095 fn test_tab_indentation() {
1096 let rule = MD007ULIndent::default();
1097
1098 let content = "* Item 1\n * Item 2";
1104 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1105 let result = rule.check(&ctx).unwrap();
1106 assert_eq!(result.len(), 1, "Wrong indentation should trigger warning");
1107
1108 let fixed = rule.fix(&ctx).unwrap();
1110 assert_eq!(fixed, "* Item 1\n * Item 2");
1111
1112 let content_multi = "* Item 1\n * Item 2\n * Item 3";
1114 let ctx = LintContext::new(content_multi, crate::config::MarkdownFlavor::Standard, None);
1115 let fixed = rule.fix(&ctx).unwrap();
1116 assert_eq!(fixed, "* Item 1\n * Item 2\n * Item 3");
1119
1120 let content_mixed = "* Item 1\n * Item 2\n * Item 3";
1122 let ctx = LintContext::new(content_mixed, crate::config::MarkdownFlavor::Standard, None);
1123 let fixed = rule.fix(&ctx).unwrap();
1124 assert_eq!(fixed, "* Item 1\n * Item 2\n * Item 3");
1127 }
1128
1129 #[test]
1130 fn test_mixed_ordered_unordered_lists() {
1131 let rule = MD007ULIndent::default();
1132
1133 let content = r#"1. Ordered item
1136 * Unordered sub-item (correct - 3 spaces under ordered)
1137 2. Ordered sub-item
1138* Unordered item
1139 1. Ordered sub-item
1140 * Unordered sub-item"#;
1141
1142 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1143 let result = rule.check(&ctx).unwrap();
1144 assert_eq!(result.len(), 0, "All unordered list indentation should be correct");
1145
1146 let fixed = rule.fix(&ctx).unwrap();
1148 assert_eq!(fixed, content);
1149 }
1150
1151 #[test]
1152 fn test_list_markers_variety() {
1153 let rule = MD007ULIndent::default();
1154
1155 let content = r#"* Asterisk
1157 * Nested asterisk
1158- Hyphen
1159 - Nested hyphen
1160+ Plus
1161 + Nested plus"#;
1162
1163 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1164 let result = rule.check(&ctx).unwrap();
1165 assert!(
1166 result.is_empty(),
1167 "All unordered list markers should work with proper indentation"
1168 );
1169
1170 let wrong_content = r#"* Asterisk
1172 * Wrong asterisk
1173- Hyphen
1174 - Wrong hyphen
1175+ Plus
1176 + Wrong plus"#;
1177
1178 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
1179 let result = rule.check(&ctx).unwrap();
1180 assert_eq!(result.len(), 3, "All marker types should be checked for indentation");
1181 }
1182
1183 #[test]
1184 fn test_empty_list_items() {
1185 let rule = MD007ULIndent::default();
1186 let content = "* Item 1\n* \n * Item 2";
1187 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1188 let result = rule.check(&ctx).unwrap();
1189 assert!(
1190 result.is_empty(),
1191 "Empty list items should not affect indentation checks"
1192 );
1193 }
1194
1195 #[test]
1196 fn test_list_with_code_blocks() {
1197 let rule = MD007ULIndent::default();
1198 let content = r#"* Item 1
1199 ```
1200 code
1201 ```
1202 * Item 2
1203 * Item 3"#;
1204 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1205 let result = rule.check(&ctx).unwrap();
1206 assert!(result.is_empty());
1207 }
1208
1209 #[test]
1210 fn test_list_in_front_matter() {
1211 let rule = MD007ULIndent::default();
1212 let content = r#"---
1213tags:
1214 - tag1
1215 - tag2
1216---
1217* Item 1
1218 * Item 2"#;
1219 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1220 let result = rule.check(&ctx).unwrap();
1221 assert!(result.is_empty(), "Lists in YAML front matter should be ignored");
1222 }
1223
1224 #[test]
1225 fn test_fix_preserves_content() {
1226 let rule = MD007ULIndent::default();
1227 let content = "* Item 1 with **bold** and *italic*\n * Item 2 with `code`\n * Item 3 with [link](url)";
1228 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1229 let fixed = rule.fix(&ctx).unwrap();
1230 let expected = "* Item 1 with **bold** and *italic*\n * Item 2 with `code`\n * Item 3 with [link](url)";
1233 assert_eq!(fixed, expected, "Fix should only change indentation, not content");
1234 }
1235
1236 #[test]
1237 fn test_start_indented_config() {
1238 let config = MD007Config {
1239 start_indented: true,
1240 start_indent: crate::types::IndentSize::from_const(4),
1241 indent: crate::types::IndentSize::from_const(2),
1242 style: md007_config::IndentStyle::TextAligned,
1243 style_explicit: true, indent_explicit: false,
1245 };
1246 let rule = MD007ULIndent::from_config_struct(config);
1247
1248 let content = " * Item 1\n * Item 2\n * Item 3";
1253 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1254 let result = rule.check(&ctx).unwrap();
1255 assert!(result.is_empty(), "Expected no warnings with start_indented config");
1256
1257 let wrong_content = " * Item 1\n * Item 2";
1259 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
1260 let result = rule.check(&ctx).unwrap();
1261 assert_eq!(result.len(), 2);
1262 assert_eq!(result[0].line, 1);
1263 assert_eq!(result[0].message, "Expected 4 spaces for indent depth 0, found 2");
1264 assert_eq!(result[1].line, 2);
1265 assert_eq!(result[1].message, "Expected 6 spaces for indent depth 1, found 4");
1266
1267 let fixed = rule.fix(&ctx).unwrap();
1269 assert_eq!(fixed, " * Item 1\n * Item 2");
1270 }
1271
1272 #[test]
1273 fn test_start_indented_false_flags_indented_first_level() {
1274 let rule = MD007ULIndent::default(); let content = " * Item 1"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1282 let result = rule.check(&ctx).unwrap();
1283 assert!(
1284 result.iter().any(|w| w.line == 1 && w.message.contains("Expected 0")),
1285 "a top-level item indented 3 spaces must be flagged with Expected 0, got: {result:?}"
1286 );
1287
1288 let content = "* Item 1\n * Item 2\n * Item 3";
1292 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1293 let result = rule.check(&ctx).unwrap();
1294 assert!(
1295 result.is_empty(),
1296 "a correctly nested 0/2/4-space list should produce no warnings, got: {result:?}"
1297 );
1298 }
1299
1300 #[test]
1301 fn test_deeply_nested_lists() {
1302 let rule = MD007ULIndent::default();
1303 let content = r#"* L1
1304 * L2
1305 * L3
1306 * L4
1307 * L5
1308 * L6"#;
1309 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1310 let result = rule.check(&ctx).unwrap();
1311 assert!(result.is_empty());
1312
1313 let wrong_content = r#"* L1
1315 * L2
1316 * L3
1317 * L4
1318 * L5
1319 * L6"#;
1320 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
1321 let result = rule.check(&ctx).unwrap();
1322 assert_eq!(result.len(), 2, "Deep nesting errors should be detected");
1323 }
1324
1325 #[test]
1326 fn test_excessive_indentation_detected() {
1327 let rule = MD007ULIndent::default();
1328
1329 let content = "- Item 1\n - Item 2 with 5 spaces";
1331 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1332 let result = rule.check(&ctx).unwrap();
1333 assert_eq!(result.len(), 1, "Should detect excessive indentation (5 instead of 2)");
1334 assert_eq!(result[0].line, 2);
1335 assert!(result[0].message.contains("Expected 2 spaces"));
1336 assert!(result[0].message.contains("found 5"));
1337
1338 let content = "- Item 1\n - Item 2 with 3 spaces";
1340 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1341 let result = rule.check(&ctx).unwrap();
1342 assert_eq!(
1343 result.len(),
1344 1,
1345 "Should detect slightly excessive indentation (3 instead of 2)"
1346 );
1347 assert_eq!(result[0].line, 2);
1348 assert!(result[0].message.contains("Expected 2 spaces"));
1349 assert!(result[0].message.contains("found 3"));
1350
1351 let content = "- Item 1\n - Item 2 with 1 space";
1353 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1354 let result = rule.check(&ctx).unwrap();
1355 assert_eq!(
1356 result.len(),
1357 1,
1358 "Should detect 1-space indent (insufficient for nesting, expected 0)"
1359 );
1360 assert_eq!(result[0].line, 2);
1361 assert!(result[0].message.contains("Expected 0 spaces"));
1362 assert!(result[0].message.contains("found 1"));
1363 }
1364
1365 #[test]
1366 fn test_excessive_indentation_with_4_space_config() {
1367 let rule = MD007ULIndent::new(4);
1370
1371 let content = "- Formatter:\n - The stable style changed";
1373 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1374 let result = rule.check(&ctx).unwrap();
1375 assert!(
1376 !result.is_empty(),
1377 "Should detect 5 spaces when expecting 4 (fixed style)"
1378 );
1379
1380 let correct_content = "- Formatter:\n - The stable style changed";
1382 let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard, None);
1383 let result = rule.check(&ctx).unwrap();
1384 assert!(result.is_empty(), "Should accept correct fixed style indent (4 spaces)");
1385 }
1386
1387 #[test]
1388 fn test_bullets_nested_under_numbered_items() {
1389 let rule = MD007ULIndent::default();
1390 let content = "\
13911. **Active Directory/LDAP**
1392 - User authentication and directory services
1393 - LDAP for user information and validation
1394
13952. **Oracle Unified Directory (OUD)**
1396 - Extended user directory services";
1397 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1398 let result = rule.check(&ctx).unwrap();
1399 assert!(
1401 result.is_empty(),
1402 "Expected no warnings for bullets with 3 spaces under numbered items, got: {result:?}"
1403 );
1404 }
1405
1406 #[test]
1407 fn test_bullets_nested_under_numbered_items_wrong_indent() {
1408 let rule = MD007ULIndent::default();
1409 let content = "\
14101. **Active Directory/LDAP**
1411 - Wrong: only 2 spaces";
1412 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1413 let result = rule.check(&ctx).unwrap();
1414 assert_eq!(
1416 result.len(),
1417 1,
1418 "Expected warning for incorrect indentation under numbered items"
1419 );
1420 assert!(
1421 result
1422 .iter()
1423 .any(|w| w.line == 2 && w.message.contains("Expected 3 spaces"))
1424 );
1425 }
1426
1427 #[test]
1428 fn test_regular_bullet_nesting_still_works() {
1429 let rule = MD007ULIndent::default();
1430 let content = "\
1431* Top level
1432 * Nested bullet (2 spaces is correct)
1433 * Deeply nested (4 spaces)";
1434 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1435 let result = rule.check(&ctx).unwrap();
1436 assert!(
1438 result.is_empty(),
1439 "Expected no warnings for standard bullet nesting, got: {result:?}"
1440 );
1441 }
1442
1443 #[test]
1444 fn test_blockquote_with_tab_after_marker() {
1445 let rule = MD007ULIndent::default();
1446 let content = ">\t* List item\n>\t * Nested\n";
1447 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1448 let result = rule.check(&ctx).unwrap();
1449 assert!(
1450 result.is_empty(),
1451 "Tab after blockquote marker should be handled correctly, got: {result:?}"
1452 );
1453 }
1454
1455 #[test]
1456 fn test_blockquote_with_space_then_tab_after_marker() {
1457 let rule = MD007ULIndent::default();
1458 let content = "> \t* List item\n";
1459 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1460 let result = rule.check(&ctx).unwrap();
1461 assert!(
1466 result.iter().any(|w| w.line == 1 && w.message.contains("Expected 0")),
1467 "an indented blockquoted top-level item must be flagged with Expected 0, got: {result:?}"
1468 );
1469 }
1470
1471 #[test]
1472 fn test_blockquote_with_multiple_tabs() {
1473 let rule = MD007ULIndent::default();
1474 let content = ">\t\t* List item\n";
1475 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1476 let result = rule.check(&ctx).unwrap();
1477 assert!(
1479 result.is_empty(),
1480 "First-level list item at any indentation is allowed when start_indented=false, got: {result:?}"
1481 );
1482 }
1483
1484 #[test]
1485 fn test_nested_blockquote_with_tab() {
1486 let rule = MD007ULIndent::default();
1487 let content = ">\t>\t* List item\n>\t>\t * Nested\n";
1488 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1489 let result = rule.check(&ctx).unwrap();
1490 assert!(
1491 result.is_empty(),
1492 "Nested blockquotes with tabs should work correctly, got: {result:?}"
1493 );
1494 }
1495
1496 #[test]
1499 fn test_smart_style_pure_unordered_uses_fixed() {
1500 let rule = MD007ULIndent::new(4);
1502
1503 let content = "* Level 0\n * Level 1\n * Level 2";
1505 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1506 let result = rule.check(&ctx).unwrap();
1507 assert!(
1508 result.is_empty(),
1509 "Pure unordered with indent=4 should use fixed style (0, 4, 8), got: {result:?}"
1510 );
1511 }
1512
1513 #[test]
1514 fn test_smart_style_mixed_lists_uses_text_aligned() {
1515 let rule = MD007ULIndent::new(4);
1517
1518 let content = "1. Ordered\n * Bullet aligns with 'Ordered' text (3 spaces)";
1520 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1521 let result = rule.check(&ctx).unwrap();
1522 assert!(
1523 result.is_empty(),
1524 "Mixed lists should use text-aligned style, got: {result:?}"
1525 );
1526 }
1527
1528 #[test]
1529 fn test_smart_style_explicit_fixed_overrides() {
1530 let config = MD007Config {
1532 indent: crate::types::IndentSize::from_const(4),
1533 start_indented: false,
1534 start_indent: crate::types::IndentSize::from_const(2),
1535 style: md007_config::IndentStyle::Fixed,
1536 style_explicit: true, indent_explicit: false,
1538 };
1539 let rule = MD007ULIndent::from_config_struct(config);
1540
1541 let content = "1. Ordered\n * Should be at 4 spaces (fixed)";
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 "Explicit fixed style should be respected, got: {result:?}"
1549 );
1550 }
1551
1552 #[test]
1553 fn test_smart_style_explicit_text_aligned_overrides() {
1554 let config = MD007Config {
1556 indent: crate::types::IndentSize::from_const(4),
1557 start_indented: false,
1558 start_indent: crate::types::IndentSize::from_const(2),
1559 style: md007_config::IndentStyle::TextAligned,
1560 style_explicit: true, indent_explicit: false,
1562 };
1563 let rule = MD007ULIndent::from_config_struct(config);
1564
1565 let content = "* Level 0\n * Level 1 (aligned with 'Level 0' text)";
1567 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1568 let result = rule.check(&ctx).unwrap();
1569 assert!(
1570 result.is_empty(),
1571 "Explicit text-aligned should be respected, got: {result:?}"
1572 );
1573
1574 let fixed_style_content = "* Level 0\n * Level 1 (4 spaces - fixed style)";
1576 let ctx = LintContext::new(fixed_style_content, crate::config::MarkdownFlavor::Standard, None);
1577 let result = rule.check(&ctx).unwrap();
1578 assert!(
1579 !result.is_empty(),
1580 "With explicit text-aligned, 4-space indent should be wrong (expected 2)"
1581 );
1582 }
1583
1584 #[test]
1585 fn test_smart_style_default_indent_no_autoswitch() {
1586 let rule = MD007ULIndent::new(2);
1588
1589 let content = "* Level 0\n * Level 1\n * Level 2";
1590 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1591 let result = rule.check(&ctx).unwrap();
1592 assert!(
1593 result.is_empty(),
1594 "Default indent should work regardless of style, got: {result:?}"
1595 );
1596 }
1597
1598 #[test]
1599 fn test_has_mixed_list_nesting_detection() {
1600 let content = "* Item 1\n * Item 2\n * Item 3";
1604 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1605 assert!(
1606 !ctx.has_mixed_list_nesting(),
1607 "Pure unordered should not be detected as mixed"
1608 );
1609
1610 let content = "1. Item 1\n 2. Item 2\n 3. Item 3";
1612 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1613 assert!(
1614 !ctx.has_mixed_list_nesting(),
1615 "Pure ordered should not be detected as mixed"
1616 );
1617
1618 let content = "1. Ordered\n * Unordered child";
1620 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1621 assert!(
1622 ctx.has_mixed_list_nesting(),
1623 "Unordered under ordered should be detected as mixed"
1624 );
1625
1626 let content = "* Unordered\n 1. Ordered child";
1628 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1629 assert!(
1630 ctx.has_mixed_list_nesting(),
1631 "Ordered under unordered should be detected as mixed"
1632 );
1633
1634 let content = "* Unordered\n\n1. Ordered (separate list)";
1636 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1637 assert!(
1638 !ctx.has_mixed_list_nesting(),
1639 "Separate lists should not be detected as mixed"
1640 );
1641
1642 let content = "> 1. Ordered in blockquote\n> * Unordered child";
1644 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1645 assert!(
1646 ctx.has_mixed_list_nesting(),
1647 "Mixed lists in blockquotes should be detected"
1648 );
1649 }
1650
1651 #[test]
1652 fn test_issue_210_exact_reproduction() {
1653 let config = MD007Config {
1655 indent: crate::types::IndentSize::from_const(4),
1656 start_indented: false,
1657 start_indent: crate::types::IndentSize::from_const(2),
1658 style: md007_config::IndentStyle::TextAligned, style_explicit: false, indent_explicit: false, };
1662 let rule = MD007ULIndent::from_config_struct(config);
1663
1664 let content = "# Title\n\n* some\n * list\n * items\n";
1665 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1666 let result = rule.check(&ctx).unwrap();
1667
1668 assert!(
1669 result.is_empty(),
1670 "Issue #210: indent=4 on pure unordered should work (auto-fixed style), got: {result:?}"
1671 );
1672 }
1673
1674 #[test]
1675 fn test_issue_209_still_fixed() {
1676 let config = MD007Config {
1679 indent: crate::types::IndentSize::from_const(3),
1680 start_indented: false,
1681 start_indent: crate::types::IndentSize::from_const(2),
1682 style: md007_config::IndentStyle::TextAligned,
1683 style_explicit: true, indent_explicit: false,
1685 };
1686 let rule = MD007ULIndent::from_config_struct(config);
1687
1688 let content = r#"# Header 1
1690
1691- **Second item**:
1692 - **This is a nested list**:
1693 1. **First point**
1694 - First subpoint
1695"#;
1696 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1697 let result = rule.check(&ctx).unwrap();
1698
1699 assert!(
1700 result.is_empty(),
1701 "Issue #209: With explicit text-aligned style, should have no issues, got: {result:?}"
1702 );
1703 }
1704
1705 #[test]
1708 fn test_multi_level_mixed_detection_grandparent() {
1709 let content = "1. Ordered grandparent\n * Unordered child\n * Unordered grandchild";
1713 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1714 assert!(
1715 ctx.has_mixed_list_nesting(),
1716 "Should detect mixed nesting when grandparent differs in type"
1717 );
1718
1719 let content = "* Unordered grandparent\n 1. Ordered child\n 2. Ordered grandchild";
1721 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1722 assert!(
1723 ctx.has_mixed_list_nesting(),
1724 "Should detect mixed nesting for ordered descendants under unordered"
1725 );
1726 }
1727
1728 #[test]
1729 fn test_html_comments_skipped_in_detection() {
1730 let content = r#"* Unordered list
1732<!-- This is a comment
1733 1. This ordered list is inside a comment
1734 * This nested bullet is also inside
1735-->
1736 * Another unordered item"#;
1737 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1738 assert!(
1739 !ctx.has_mixed_list_nesting(),
1740 "Lists in HTML comments should be ignored in mixed detection"
1741 );
1742 }
1743
1744 #[test]
1745 fn test_blank_lines_separate_lists() {
1746 let content = "* First unordered list\n\n1. Second list is ordered (separate)";
1748 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1749 assert!(
1750 !ctx.has_mixed_list_nesting(),
1751 "Blank line at root should separate lists"
1752 );
1753
1754 let content = "1. Ordered parent\n\n * Still a child due to indentation";
1756 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1757 assert!(
1758 ctx.has_mixed_list_nesting(),
1759 "Indented list after blank is still nested"
1760 );
1761 }
1762
1763 #[test]
1764 fn test_column_1_normalization() {
1765 let content = "* First item\n * Second item with 1 space (sibling)";
1768 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1769 let rule = MD007ULIndent::default();
1770 let result = rule.check(&ctx).unwrap();
1771 assert!(
1773 result.iter().any(|w| w.line == 2),
1774 "1-space indent should be flagged as incorrect"
1775 );
1776 }
1777
1778 #[test]
1779 fn test_code_blocks_skipped_in_detection() {
1780 let content = r#"* Unordered list
1782```
17831. This ordered list is inside a code block
1784 * This nested bullet is also inside
1785```
1786 * Another unordered item"#;
1787 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1788 assert!(
1789 !ctx.has_mixed_list_nesting(),
1790 "Lists in code blocks should be ignored in mixed detection"
1791 );
1792 }
1793
1794 #[test]
1795 fn test_front_matter_skipped_in_detection() {
1796 let content = r#"---
1798items:
1799 - yaml list item
1800 - another item
1801---
1802* Unordered list after front matter"#;
1803 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1804 assert!(
1805 !ctx.has_mixed_list_nesting(),
1806 "Lists in front matter should be ignored in mixed detection"
1807 );
1808 }
1809
1810 #[test]
1811 fn test_alternating_types_at_same_level() {
1812 let content = "* First bullet\n1. First number\n* Second bullet\n2. Second number";
1815 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1816 assert!(
1817 !ctx.has_mixed_list_nesting(),
1818 "Alternating types at same level should not be detected as mixed"
1819 );
1820 }
1821
1822 #[test]
1823 fn test_five_level_deep_mixed_nesting() {
1824 let content = "* L0\n 1. L1\n * L2\n 1. L3\n * L4\n 1. L5";
1826 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1827 assert!(ctx.has_mixed_list_nesting(), "Should detect mixed nesting at 5+ levels");
1828 }
1829
1830 #[test]
1831 fn test_very_deep_pure_unordered_nesting() {
1832 let mut content = String::from("* L1");
1834 for level in 2..=12 {
1835 let indent = " ".repeat(level - 1);
1836 content.push_str(&format!("\n{indent}* L{level}"));
1837 }
1838
1839 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1840
1841 assert!(
1843 !ctx.has_mixed_list_nesting(),
1844 "Pure unordered deep nesting should not be detected as mixed"
1845 );
1846
1847 let rule = MD007ULIndent::new(4);
1849 let result = rule.check(&ctx).unwrap();
1850 assert!(!result.is_empty(), "Should flag incorrect indentation for fixed style");
1853 }
1854
1855 #[test]
1856 fn test_interleaved_content_between_list_items() {
1857 let content = "1. Ordered parent\n\n Paragraph continuation\n\n * Unordered child";
1859 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1860 assert!(
1861 ctx.has_mixed_list_nesting(),
1862 "Should detect mixed nesting even with interleaved paragraphs"
1863 );
1864 }
1865
1866 #[test]
1867 fn test_esm_blocks_skipped_in_detection() {
1868 let content = "* Unordered list\n * Nested unordered";
1871 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1872 assert!(
1873 !ctx.has_mixed_list_nesting(),
1874 "Pure unordered should not be detected as mixed"
1875 );
1876 }
1877
1878 #[test]
1879 fn test_multiple_list_blocks_pure_then_mixed() {
1880 let content = r#"* Pure unordered
1883 * Nested unordered
1884
18851. Mixed section
1886 * Bullet under ordered"#;
1887 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1888 assert!(
1889 ctx.has_mixed_list_nesting(),
1890 "Should detect mixed nesting in any part of document"
1891 );
1892 }
1893
1894 #[test]
1895 fn test_multiple_separate_pure_lists() {
1896 let content = r#"* First list
1899 * Nested
1900
1901* Second list
1902 * Also nested
1903
1904* Third list
1905 * Deeply
1906 * Nested"#;
1907 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1908 assert!(
1909 !ctx.has_mixed_list_nesting(),
1910 "Multiple separate pure unordered lists should not be mixed"
1911 );
1912 }
1913
1914 #[test]
1915 fn test_code_block_between_list_items() {
1916 let content = r#"1. Ordered
1918 ```
1919 code
1920 ```
1921 * Still a mixed child"#;
1922 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1923 assert!(
1924 ctx.has_mixed_list_nesting(),
1925 "Code block between items should not prevent mixed detection"
1926 );
1927 }
1928
1929 #[test]
1930 fn test_blockquoted_mixed_detection() {
1931 let content = "> 1. Ordered in blockquote\n> * Mixed child";
1933 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1934 assert!(
1937 ctx.has_mixed_list_nesting(),
1938 "Should detect mixed nesting in blockquotes"
1939 );
1940 }
1941
1942 #[test]
1945 fn test_indent_explicit_uses_fixed_style() {
1946 let config = MD007Config {
1949 indent: crate::types::IndentSize::from_const(4),
1950 start_indented: false,
1951 start_indent: crate::types::IndentSize::from_const(2),
1952 style: md007_config::IndentStyle::TextAligned, style_explicit: false, indent_explicit: true, };
1956 let rule = MD007ULIndent::from_config_struct(config);
1957
1958 let content = "* Level 0\n * Level 1\n * Level 2";
1961 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1962 let result = rule.check(&ctx).unwrap();
1963 assert!(
1964 result.is_empty(),
1965 "With indent_explicit=true, should use fixed style (0, 4, 8), got: {result:?}"
1966 );
1967
1968 let wrong_content = "* Level 0\n * Level 1\n * Level 2";
1970 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
1971 let result = rule.check(&ctx).unwrap();
1972 assert!(
1973 !result.is_empty(),
1974 "Should flag text-aligned spacing when indent_explicit=true"
1975 );
1976 }
1977
1978 #[test]
1979 fn test_explicit_style_overrides_indent_explicit() {
1980 let config = MD007Config {
1983 indent: crate::types::IndentSize::from_const(4),
1984 start_indented: false,
1985 start_indent: crate::types::IndentSize::from_const(2),
1986 style: md007_config::IndentStyle::TextAligned,
1987 style_explicit: true, indent_explicit: true, };
1990 let rule = MD007ULIndent::from_config_struct(config);
1991
1992 let content = "* Level 0\n * Level 1\n * Level 2";
1994 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1995 let result = rule.check(&ctx).unwrap();
1996 assert!(
1997 result.is_empty(),
1998 "Explicit text-aligned style should be respected, got: {result:?}"
1999 );
2000 }
2001
2002 #[test]
2003 fn test_no_indent_explicit_uses_smart_detection() {
2004 let config = MD007Config {
2006 indent: crate::types::IndentSize::from_const(4),
2007 start_indented: false,
2008 start_indent: crate::types::IndentSize::from_const(2),
2009 style: md007_config::IndentStyle::TextAligned,
2010 style_explicit: false,
2011 indent_explicit: false, };
2013 let rule = MD007ULIndent::from_config_struct(config);
2014
2015 let content = "* Level 0\n * Level 1";
2018 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2019 let result = rule.check(&ctx).unwrap();
2020 assert!(
2022 result.is_empty(),
2023 "Smart detection should accept 4-space indent, got: {result:?}"
2024 );
2025 }
2026
2027 #[test]
2028 fn test_issue_273_exact_reproduction() {
2029 let config = MD007Config {
2032 indent: crate::types::IndentSize::from_const(4),
2033 start_indented: false,
2034 start_indent: crate::types::IndentSize::from_const(2),
2035 style: md007_config::IndentStyle::TextAligned, style_explicit: false,
2037 indent_explicit: true, };
2039 let rule = MD007ULIndent::from_config_struct(config);
2040
2041 let content = r#"* Item 1
2042 * Item 2
2043 * Item 3"#;
2044 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2045 let result = rule.check(&ctx).unwrap();
2046 assert!(
2047 result.is_empty(),
2048 "Issue #273: indent=4 should use 4-space increments, got: {result:?}"
2049 );
2050 }
2051
2052 #[test]
2053 fn test_indent_explicit_with_ordered_parent() {
2054 let config = MD007Config {
2058 indent: crate::types::IndentSize::from_const(4),
2059 start_indented: false,
2060 start_indent: crate::types::IndentSize::from_const(2),
2061 style: md007_config::IndentStyle::TextAligned,
2062 style_explicit: false,
2063 indent_explicit: true, };
2065 let rule = MD007ULIndent::from_config_struct(config);
2066
2067 let content = "1. Ordered\n * Bullet with 4-space indent";
2069 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2070 let result = rule.check(&ctx).unwrap();
2071 assert!(
2072 result.is_empty(),
2073 "4-space indent under ordered should pass with indent=4: {result:?}"
2074 );
2075
2076 let content_3 = "1. Ordered\n * Bullet with 3-space indent";
2078 let ctx = LintContext::new(content_3, crate::config::MarkdownFlavor::Standard, None);
2079 let result = rule.check(&ctx).unwrap();
2080 assert!(
2081 result.is_empty(),
2082 "3-space indent under ordered should pass (text-aligned): {result:?}"
2083 );
2084
2085 let wrong_content = "1. Ordered\n * Bullet with 2-space indent";
2087 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
2088 let result = rule.check(&ctx).unwrap();
2089 assert!(
2090 !result.is_empty(),
2091 "2-space indent under ordered list should be flagged when indent=4: {result:?}"
2092 );
2093 }
2094
2095 #[test]
2096 fn test_indent_explicit_mixed_list_deep_nesting() {
2097 let config = MD007Config {
2102 indent: crate::types::IndentSize::from_const(4),
2103 start_indented: false,
2104 start_indent: crate::types::IndentSize::from_const(2),
2105 style: md007_config::IndentStyle::TextAligned,
2106 style_explicit: false,
2107 indent_explicit: true,
2108 };
2109 let rule = MD007ULIndent::from_config_struct(config);
2110
2111 let content_text_aligned = r#"* Level 0
2117 * Level 1 (4-space indent from bullet parent)
2118 1. Level 2 ordered
2119 * Level 3 bullet (text-aligned under ordered)"#;
2120 let ctx = LintContext::new(content_text_aligned, crate::config::MarkdownFlavor::Standard, None);
2121 let result = rule.check(&ctx).unwrap();
2122 assert!(
2123 result.is_empty(),
2124 "Text-aligned nesting under ordered should pass: {result:?}"
2125 );
2126
2127 let content_fixed = r#"* Level 0
2128 * Level 1 (4-space indent from bullet parent)
2129 1. Level 2 ordered
2130 * Level 3 bullet (fixed indent under ordered)"#;
2131 let ctx = LintContext::new(content_fixed, crate::config::MarkdownFlavor::Standard, None);
2132 let result = rule.check(&ctx).unwrap();
2133 assert!(
2134 result.is_empty(),
2135 "Fixed indent nesting under ordered should also pass: {result:?}"
2136 );
2137 }
2138
2139 #[test]
2140 fn test_ordered_list_double_digit_markers() {
2141 let config = MD007Config {
2144 indent: crate::types::IndentSize::from_const(4),
2145 start_indented: false,
2146 start_indent: crate::types::IndentSize::from_const(2),
2147 style: md007_config::IndentStyle::TextAligned,
2148 style_explicit: false,
2149 indent_explicit: true,
2150 };
2151 let rule = MD007ULIndent::from_config_struct(config);
2152
2153 let content = "10. Double digit\n * Bullet at col 4";
2155 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2156 let result = rule.check(&ctx).unwrap();
2157 assert!(
2158 result.is_empty(),
2159 "Bullet under '10.' should align at column 4: {result:?}"
2160 );
2161
2162 let content_3 = "1. Single digit\n * Bullet at col 3";
2165 let ctx = LintContext::new(content_3, crate::config::MarkdownFlavor::Standard, None);
2166 let result = rule.check(&ctx).unwrap();
2167 assert!(
2168 result.is_empty(),
2169 "Bullet under '1.' with 3-space indent should pass (text-aligned): {result:?}"
2170 );
2171
2172 let content_4 = "1. Single digit\n * Bullet at col 4";
2173 let ctx = LintContext::new(content_4, crate::config::MarkdownFlavor::Standard, None);
2174 let result = rule.check(&ctx).unwrap();
2175 assert!(
2176 result.is_empty(),
2177 "Bullet under '1.' with 4-space indent should pass (fixed): {result:?}"
2178 );
2179 }
2180
2181 #[test]
2182 fn test_indent_explicit_pure_unordered_uses_fixed() {
2183 let config = MD007Config {
2186 indent: crate::types::IndentSize::from_const(4),
2187 start_indented: false,
2188 start_indent: crate::types::IndentSize::from_const(2),
2189 style: md007_config::IndentStyle::TextAligned,
2190 style_explicit: false,
2191 indent_explicit: true,
2192 };
2193 let rule = MD007ULIndent::from_config_struct(config);
2194
2195 let content = "* Level 0\n * Level 1\n * Level 2";
2197 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2198 let result = rule.check(&ctx).unwrap();
2199 assert!(
2200 result.is_empty(),
2201 "Pure unordered with indent=4 should use 4-space increments: {result:?}"
2202 );
2203
2204 let wrong_content = "* Level 0\n * Level 1\n * Level 2";
2206 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
2207 let result = rule.check(&ctx).unwrap();
2208 assert!(
2209 !result.is_empty(),
2210 "2-space indent should be flagged when indent=4 is configured"
2211 );
2212 }
2213
2214 #[test]
2215 fn test_mkdocs_ordered_list_with_4_space_nested_unordered() {
2216 let rule = MD007ULIndent::default();
2220 let content = "1. text\n\n - nested item";
2221 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2222 let result = rule.check(&ctx).unwrap();
2223 assert!(
2224 result.is_empty(),
2225 "4-space indent under ordered list should be valid in MkDocs flavor, got: {result:?}"
2226 );
2227 }
2228
2229 #[test]
2230 fn test_standard_flavor_ordered_list_with_3_space_nested_unordered() {
2231 let rule = MD007ULIndent::default();
2234 let content = "1. text\n\n - nested item";
2235 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2236 let result = rule.check(&ctx).unwrap();
2237 assert!(
2238 result.is_empty(),
2239 "3-space indent under ordered list should be valid in Standard flavor, got: {result:?}"
2240 );
2241 }
2242
2243 #[test]
2244 fn test_standard_flavor_ordered_list_under_ordered_is_exempt() {
2245 let rule = MD007ULIndent::default();
2250 let content = "1. text\n\n - nested item";
2251 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2252 let result = rule.check(&ctx).unwrap();
2253 assert!(
2254 result.is_empty(),
2255 "unordered sublist of an ordered list must be exempt in Standard flavor, got: {result:?}"
2256 );
2257 }
2258
2259 #[test]
2260 fn test_mkdocs_multi_digit_ordered_list() {
2261 let rule = MD007ULIndent::default();
2264 let content = "10. text\n\n - nested item";
2265 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2266 let result = rule.check(&ctx).unwrap();
2267 assert!(
2268 result.is_empty(),
2269 "4-space indent under `10.` should be valid in MkDocs flavor, got: {result:?}"
2270 );
2271 }
2272
2273 #[test]
2274 fn test_mkdocs_triple_digit_ordered_list() {
2275 let rule = MD007ULIndent::default();
2278 let content = "100. text\n\n - nested item";
2279 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2280 let result = rule.check(&ctx).unwrap();
2281 assert!(
2282 result.is_empty(),
2283 "5-space indent under `100.` should be valid in MkDocs flavor, got: {result:?}"
2284 );
2285 }
2286
2287 #[test]
2288 fn test_mkdocs_insufficient_indent_under_ordered() {
2289 let rule = MD007ULIndent::default();
2292 let content = "1. text\n\n - nested item";
2293 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2294 let result = rule.check(&ctx).unwrap();
2295 assert_eq!(
2296 result.len(),
2297 1,
2298 "2-space indent under ordered list should warn in MkDocs flavor"
2299 );
2300 assert!(
2301 result[0].message.contains("Expected 4"),
2302 "Warning should expect 4 spaces (MkDocs minimum), got: {}",
2303 result[0].message
2304 );
2305 }
2306
2307 #[test]
2308 fn test_mkdocs_deeper_nesting_under_ordered() {
2309 let rule = MD007ULIndent::default();
2314 let content = "1. text\n\n - sub\n - subsub";
2315 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2316 let result = rule.check(&ctx).unwrap();
2317 assert!(
2318 result.is_empty(),
2319 "Deeper nesting under ordered list should be valid in MkDocs flavor, got: {result:?}"
2320 );
2321 }
2322
2323 #[test]
2324 fn test_mkdocs_fix_adjusts_to_4_spaces() {
2325 let rule = MD007ULIndent::default();
2327 let content = "1. text\n\n - nested item";
2328 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2329 let result = rule.check(&ctx).unwrap();
2330 assert_eq!(result.len(), 1, "3-space indent should warn in MkDocs");
2331 let fixed = rule.fix(&ctx).unwrap();
2332 assert_eq!(
2333 fixed, "1. text\n\n - nested item",
2334 "Fix should adjust indent to 4 spaces in MkDocs"
2335 );
2336 }
2337
2338 #[test]
2339 fn test_mkdocs_start_indented_with_ordered_parent() {
2340 let config = MD007Config {
2343 start_indented: true,
2344 ..Default::default()
2345 };
2346 let rule = MD007ULIndent::from_config_struct(config);
2347 let content = "1. text\n\n - nested item";
2348 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2349 let result = rule.check(&ctx).unwrap();
2350 assert!(
2351 result.is_empty(),
2352 "4-space indent under ordered list with start_indented should be valid in MkDocs, got: {result:?}"
2353 );
2354 }
2355
2356 #[test]
2357 fn test_mkdocs_ordered_at_nonzero_indent() {
2358 let rule = MD007ULIndent::default();
2363 let content = "- outer\n 1. inner\n - deep";
2364 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2365 let result = rule.check(&ctx).unwrap();
2366 assert!(
2367 result.is_empty(),
2368 "6-space indent under nested ordered list should be valid in MkDocs, got: {result:?}"
2369 );
2370 }
2371
2372 #[test]
2373 fn test_mkdocs_blockquoted_ordered_list() {
2374 let rule = MD007ULIndent::default();
2378 let content = "> 1. text\n>\n> - nested item";
2379 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2380 let result = rule.check(&ctx).unwrap();
2381 assert!(
2382 result.is_empty(),
2383 "4-space indent under blockquoted ordered list should be valid in MkDocs, got: {result:?}"
2384 );
2385 }
2386
2387 #[test]
2388 fn test_mkdocs_ordered_at_nonzero_indent_insufficient() {
2389 let rule = MD007ULIndent::default();
2392 let content = "- outer\n 1. inner\n - deep";
2393 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2394 let result = rule.check(&ctx).unwrap();
2395 assert_eq!(
2396 result.len(),
2397 1,
2398 "5-space indent under nested ordered at col 2 should warn in MkDocs (needs 6)"
2399 );
2400 }
2401
2402 #[test]
2403 fn test_issue_504_indent4_ordered_parent() {
2404 let config = MD007Config {
2408 indent: crate::types::IndentSize::from_const(4),
2409 start_indented: false,
2410 start_indent: crate::types::IndentSize::from_const(2),
2411 style: md007_config::IndentStyle::TextAligned,
2412 style_explicit: false,
2413 indent_explicit: true,
2414 };
2415 let rule = MD007ULIndent::from_config_struct(config);
2416
2417 let content = r#"# Things
2418
2419+ An unordered list
2420 + An item with 4 spaces, ok.
2421
24221. A numbered list
2423 + A sublist with 4 spaces, not ok
2424 + A sub item with 4 spaces, ok
2425 + Why is rumdl expecting 3 spaces for a 4 space indent?
24262. Item 2
24273. Item 3"#;
2428 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2429 let result = rule.check(&ctx).unwrap();
2430 assert!(
2431 result.is_empty(),
2432 "Issue #504: indent=4 with ordered parent should accept 4-space indent: {result:?}"
2433 );
2434 }
2435
2436 #[test]
2437 fn test_indent2_explicit_with_ordered_parent() {
2438 let config = MD007Config {
2441 indent: crate::types::IndentSize::from_const(2),
2442 start_indented: false,
2443 start_indent: crate::types::IndentSize::from_const(2),
2444 style: md007_config::IndentStyle::TextAligned,
2445 style_explicit: false,
2446 indent_explicit: true,
2447 };
2448 let rule = MD007ULIndent::from_config_struct(config);
2449
2450 let content = "1. Ordered\n * Bullet at 3 spaces";
2452 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2453 let result = rule.check(&ctx).unwrap();
2454 assert!(
2455 result.is_empty(),
2456 "indent=2 under '1.' should accept text-aligned (3 spaces): {result:?}"
2457 );
2458
2459 let content_2 = "1. Ordered\n * Bullet at 2 spaces";
2461 let ctx = LintContext::new(content_2, crate::config::MarkdownFlavor::Standard, None);
2462 let result = rule.check(&ctx).unwrap();
2463 assert!(
2464 result.is_empty(),
2465 "indent=2 under '1.' should accept fixed indent (2 spaces): {result:?}"
2466 );
2467 }
2468
2469 const ISSUE_638_INPUT: &str = "# Title\n\n1. Some text\n - Indented text\n - more indented\n";
2473
2474 #[test]
2475 fn test_issue_638_unordered_under_ordered_smart_default() {
2476 let rule = MD007ULIndent::new(2);
2477 let ctx = LintContext::new(ISSUE_638_INPUT, crate::config::MarkdownFlavor::Standard, None);
2478 let result = rule.check(&ctx).unwrap();
2479 assert!(
2480 result.is_empty(),
2481 "smart default: unordered items under an ordered list must not be flagged, got: {result:?}"
2482 );
2483 }
2484
2485 #[test]
2486 fn test_issue_638_unordered_under_ordered_indent_explicit() {
2487 let config = MD007Config {
2488 indent: crate::types::IndentSize::from_const(2),
2489 start_indented: false,
2490 start_indent: crate::types::IndentSize::from_const(2),
2491 style: md007_config::IndentStyle::TextAligned,
2492 style_explicit: false,
2493 indent_explicit: true,
2494 };
2495 let rule = MD007ULIndent::from_config_struct(config);
2496 let ctx = LintContext::new(ISSUE_638_INPUT, crate::config::MarkdownFlavor::Standard, None);
2497 let result = rule.check(&ctx).unwrap();
2498 assert!(
2499 result.is_empty(),
2500 "indent=2 explicit: unordered items under an ordered list must not be flagged, got: {result:?}"
2501 );
2502 }
2503
2504 #[test]
2505 fn test_issue_638_unordered_under_ordered_style_fixed() {
2506 let config = MD007Config {
2508 indent: crate::types::IndentSize::from_const(2),
2509 start_indented: false,
2510 start_indent: crate::types::IndentSize::from_const(2),
2511 style: md007_config::IndentStyle::Fixed,
2512 style_explicit: true,
2513 indent_explicit: true,
2514 };
2515 let rule = MD007ULIndent::from_config_struct(config);
2516 let ctx = LintContext::new(ISSUE_638_INPUT, crate::config::MarkdownFlavor::Standard, None);
2517 let result = rule.check(&ctx).unwrap();
2518 assert!(
2519 result.is_empty(),
2520 "style=fixed: unordered items under an ordered list must not be flagged, got: {result:?}"
2521 );
2522 }
2523
2524 fn fixed_style_rule(indent: u8) -> MD007ULIndent {
2531 MD007ULIndent::from_config_struct(MD007Config {
2532 indent: crate::types::IndentSize::from_const(indent),
2533 start_indented: false,
2534 start_indent: crate::types::IndentSize::from_const(2),
2535 style: md007_config::IndentStyle::Fixed,
2536 style_explicit: true,
2537 indent_explicit: true,
2538 })
2539 }
2540
2541 #[test]
2542 fn test_fixed_style_clamp_flags_over_indented_bullet_under_ordered() {
2543 let rule = fixed_style_rule(2);
2544 let content = "1. Some text\n - four spaces\n";
2545 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2546 let result = rule.check(&ctx).unwrap();
2547 assert_eq!(
2548 result.len(),
2549 1,
2550 "a bullet at 4 under a content column of 3 is flagged: {result:?}"
2551 );
2552 assert!(
2553 result[0].message.contains("Expected 3") && result[0].message.contains("found 4"),
2554 "clamped expectation is the parent content column, got: {}",
2555 result[0].message
2556 );
2557 let fixed = rule.fix(&ctx).unwrap();
2558 assert_eq!(fixed, "1. Some text\n - four spaces\n");
2559 }
2560
2561 #[test]
2562 fn test_fixed_style_clamp_accepts_bullet_at_parent_content_column() {
2563 let rule = fixed_style_rule(2);
2564 let content = "1. Some text\n - three spaces\n";
2565 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2566 let result = rule.check(&ctx).unwrap();
2567 assert!(result.is_empty(), "the clamped expectation itself passes: {result:?}");
2568 }
2569
2570 #[test]
2571 fn test_fixed_style_clamp_pulls_five_spaces_to_content_column() {
2572 let rule = fixed_style_rule(2);
2573 let content = "1. Some text\n - five spaces\n";
2574 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2575 let result = rule.check(&ctx).unwrap();
2576 assert_eq!(result.len(), 1, "{result:?}");
2577 let fixed = rule.fix(&ctx).unwrap();
2578 assert_eq!(fixed, "1. Some text\n - five spaces\n");
2579 }
2580
2581 #[test]
2582 fn test_fixed_style_clamp_cascades_through_nested_bullets() {
2583 let rule = fixed_style_rule(2);
2587 let content = "1. Ordered\n - child\n - grandchild\n";
2588 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2589 let result = rule.check(&ctx).unwrap();
2590 assert_eq!(result.len(), 1, "only the grandchild is off: {result:?}");
2591 assert!(
2592 result[0].message.contains("Expected 5") && result[0].message.contains("found 6"),
2593 "got: {}",
2594 result[0].message
2595 );
2596 let fixed = rule.fix(&ctx).unwrap();
2597 assert_eq!(fixed, "1. Ordered\n - child\n - grandchild\n");
2598 let refixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
2599 assert!(rule.check(&refixed_ctx).unwrap().is_empty(), "fix is stable");
2600 }
2601
2602 #[test]
2603 fn test_fixed_style_clamp_respects_wider_fixed_indent() {
2604 let rule = fixed_style_rule(4);
2607 let content = "1. Some text\n - three spaces\n";
2608 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2609 let result = rule.check(&ctx).unwrap();
2610 assert_eq!(result.len(), 1, "{result:?}");
2611 assert!(
2612 result[0].message.contains("Expected 4") && result[0].message.contains("found 3"),
2613 "got: {}",
2614 result[0].message
2615 );
2616 let fixed = rule.fix(&ctx).unwrap();
2617 assert_eq!(fixed, "1. Some text\n - three spaces\n");
2618 }
2619
2620 #[test]
2621 fn test_fixed_style_clamp_uses_measured_content_column_of_wide_marker() {
2622 let rule = fixed_style_rule(2);
2625 let content = "1. Some text\n - five spaces\n";
2626 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2627 let result = rule.check(&ctx).unwrap();
2628 assert_eq!(result.len(), 1, "{result:?}");
2629 assert!(
2630 result[0].message.contains("Expected 4") && result[0].message.contains("found 5"),
2631 "got: {}",
2632 result[0].message
2633 );
2634 let fixed = rule.fix(&ctx).unwrap();
2635 assert_eq!(fixed, "1. Some text\n - five spaces\n");
2636
2637 let ok = "1. Some text\n - four spaces\n";
2638 let ok_ctx = LintContext::new(ok, crate::config::MarkdownFlavor::Standard, None);
2639 assert!(rule.check(&ok_ctx).unwrap().is_empty());
2640 }
2641
2642 #[test]
2643 fn test_fixed_style_clamp_in_blockquote() {
2644 let rule = fixed_style_rule(2);
2645 let content = "> 1. Some text\n> - four spaces\n";
2646 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2647 let result = rule.check(&ctx).unwrap();
2648 assert_eq!(result.len(), 1, "{result:?}");
2649 assert!(
2650 result[0].message.contains("Expected 3") && result[0].message.contains("found 4"),
2651 "got: {}",
2652 result[0].message
2653 );
2654 let fixed = rule.fix(&ctx).unwrap();
2655 assert_eq!(fixed, "> 1. Some text\n> - four spaces\n");
2656 }
2657
2658 #[test]
2659 fn test_fixed_style_clamp_treats_near_sibling_as_sibling() {
2660 let rule = fixed_style_rule(2);
2666 let content = "1. x\n - a\n - b\n";
2667 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2668 let result = rule.check(&ctx).unwrap();
2669 assert_eq!(result.len(), 1, "{result:?}");
2670 assert!(
2671 result[0].message.contains("Expected 3") && result[0].message.contains("found 4"),
2672 "near-sibling resolves against the ordered parent, got: {}",
2673 result[0].message
2674 );
2675 let fixed = rule.fix(&ctx).unwrap();
2676 assert_eq!(fixed, "1. x\n - a\n - b\n");
2677 }
2678
2679 #[test]
2680 fn test_fixed_style_clamp_child_after_near_sibling_resolves_against_it() {
2681 let rule = fixed_style_rule(2);
2685 let content = "1. x\n - a\n - b\n - c\n";
2686 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2687 let result = rule.check(&ctx).unwrap();
2688 assert_eq!(result.len(), 2, "b and c are both off: {result:?}");
2689 assert!(
2690 result[0].message.contains("Expected 3") && result[0].message.contains("found 4"),
2691 "got: {}",
2692 result[0].message
2693 );
2694 assert!(
2695 result[1].message.contains("Expected 5") && result[1].message.contains("found 6"),
2696 "got: {}",
2697 result[1].message
2698 );
2699 let fixed = rule.fix(&ctx).unwrap();
2700 assert_eq!(fixed, "1. x\n - a\n - b\n - c\n");
2701 }
2702
2703 #[test]
2704 fn test_fixed_style_clamp_pops_near_sibling_of_over_indented_bullet() {
2705 let rule = fixed_style_rule(2);
2710 let content = "1. x\n - a\n - b\n";
2711 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2712 let result = rule.check(&ctx).unwrap();
2713 assert_eq!(result.len(), 2, "a and b are both flagged: {result:?}");
2714 assert!(
2715 result[1].message.contains("Expected 3") && result[1].message.contains("found 5"),
2716 "b resolves against the ordered parent, got: {}",
2717 result[1].message
2718 );
2719 let fixed = rule.fix(&ctx).unwrap();
2720 assert_eq!(fixed, "1. x\n - a\n - b\n");
2721 }
2722
2723 #[test]
2724 fn test_fixed_style_clamp_keeps_child_of_over_indented_bullet() {
2725 let rule = fixed_style_rule(2);
2728 let content = "1. x\n - a\n - c\n";
2729 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2730 let result = rule.check(&ctx).unwrap();
2731 assert_eq!(result.len(), 2, "a and c are both flagged: {result:?}");
2732 assert!(
2733 result[1].message.contains("Expected 5") && result[1].message.contains("found 7"),
2734 "c's floor is a's corrected content column, got: {}",
2735 result[1].message
2736 );
2737 let fixed = rule.fix(&ctx).unwrap();
2738 assert_eq!(fixed, "1. x\n - a\n - c\n");
2739 }
2740
2741 #[test]
2742 fn test_fixed_style_clamp_pops_ordered_near_sibling() {
2743 let rule = fixed_style_rule(2);
2749 let content = "1. root\n - a\n 1. sub\n - b\n";
2750 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2751 let result = rule.check(&ctx).unwrap();
2752 assert_eq!(result.len(), 1, "only b is off: {result:?}");
2753 assert!(
2754 result[0].message.contains("Expected 5") && result[0].message.contains("found 6"),
2755 "b resolves against a, not the nested ordered sibling, got: {}",
2756 result[0].message
2757 );
2758 let fixed = rule.fix(&ctx).unwrap();
2759 assert_eq!(fixed, "1. root\n - a\n 1. sub\n - b\n");
2760 }
2761
2762 #[test]
2763 fn test_fixed_style_clamp_leaves_sibling_bullet_left_of_content_column() {
2764 let rule = fixed_style_rule(2);
2768 let content = "1. Some text\n - two spaces\n";
2769 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2770 let result = rule.check(&ctx).unwrap();
2771 assert!(
2772 result.is_empty(),
2773 "sibling bullet at the fixed indent stays silent: {result:?}"
2774 );
2775 }
2776
2777 #[test]
2778 fn test_fixed_style_clamp_requires_explicit_style() {
2779 let config = MD007Config {
2782 indent: crate::types::IndentSize::from_const(2),
2783 start_indented: false,
2784 start_indent: crate::types::IndentSize::from_const(2),
2785 style: md007_config::IndentStyle::TextAligned,
2786 style_explicit: false,
2787 indent_explicit: true,
2788 };
2789 let rule = MD007ULIndent::from_config_struct(config);
2790 let content = "1. Some text\n - four spaces\n";
2791 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2792 let result = rule.check(&ctx).unwrap();
2793 assert!(result.is_empty(), "no explicit style, exemption stays: {result:?}");
2794
2795 let smart = MD007ULIndent::new(2);
2796 assert!(
2797 smart.check(&ctx).unwrap().is_empty(),
2798 "smart default keeps the exemption too"
2799 );
2800 }
2801
2802 #[test]
2803 fn test_issue_638_deeper_unordered_chain_under_ordered() {
2804 let rule = MD007ULIndent::new(2);
2806 let content = "1. Ordered\n - child\n - grandchild\n - great-grandchild\n";
2807 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2808 let result = rule.check(&ctx).unwrap();
2809 assert!(
2810 result.is_empty(),
2811 "all unordered descendants of an ordered list are exempt, got: {result:?}"
2812 );
2813 }
2814
2815 #[test]
2816 fn test_issue_638_pure_unordered_still_checked() {
2817 let rule = MD007ULIndent::new(2);
2819 let content = "- Top\n - three spaces (wrong, expected 2)\n";
2820 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2821 let result = rule.check(&ctx).unwrap();
2822 assert_eq!(
2823 result.len(),
2824 1,
2825 "pure unordered nesting must still be checked, got: {result:?}"
2826 );
2827 }
2828
2829 #[test]
2830 fn test_issue_638_exemption_not_applied_after_list_terminated_by_paragraph() {
2831 let rule = MD007ULIndent::new(2);
2838 let content = "1. ordered\n\nparagraph\n\n - parent\n - child six\n";
2839 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2840 let result = rule.check(&ctx).unwrap();
2841 assert_eq!(
2842 result.len(),
2843 2,
2844 "the new top-level list following a terminated ordered list is checked at both levels, got: {result:?}"
2845 );
2846 assert!(
2847 result.iter().any(|w| w.line == 5 && w.message.contains("Expected 0")),
2848 "the misindented top-level item must be flagged with Expected 0, got: {result:?}"
2849 );
2850 assert!(
2851 result
2852 .iter()
2853 .any(|w| w.line == 6 && w.message.contains("Expected 2") && w.message.contains("found 6")),
2854 "the misindented child must be flagged with Expected 2, found 6, got: {result:?}"
2855 );
2856 }
2857
2858 #[test]
2859 fn test_issue_638_lazy_continuation_does_not_terminate_ordered_list() {
2860 let rule = MD007ULIndent::new(2);
2866 let content = "1. ordered\nlazy continuation\n - child\n - grandchild\n";
2867 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2868 let result = rule.check(&ctx).unwrap();
2869 assert!(
2870 result.is_empty(),
2871 "lazy continuation must not terminate the ordered list; sublist stays exempt, got: {result:?}"
2872 );
2873 }
2874
2875 #[test]
2876 fn test_issue_638_heading_interrupts_ordered_list_without_blank() {
2877 let rule = MD007ULIndent::new(2);
2884 let content = "1. ordered\n# heading\n - child\n - grandchild\n";
2885 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2886 let result = rule.check(&ctx).unwrap();
2887 assert_eq!(
2888 result.len(),
2889 2,
2890 "a heading terminates the ordered list, so the new top-level list and its child are both checked, got: {result:?}"
2891 );
2892 assert!(
2893 result.iter().any(|w| w.line == 3 && w.message.contains("Expected 0")),
2894 "the misindented top-level item must be flagged with Expected 0, got: {result:?}"
2895 );
2896 assert!(
2897 result.iter().any(|w| w.line == 4 && w.message.contains("Expected 2")),
2898 "the misindented child must be flagged with Expected 2, got: {result:?}"
2899 );
2900 }
2901
2902 #[test]
2903 fn test_issue_638_lazy_continuation_inside_blockquote_keeps_exemption() {
2904 let rule = MD007ULIndent::new(2);
2909 let content = "> 1. ordered\n> continuation\n>\n> - child\n> - grandchild\n";
2910 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2911 let result = rule.check(&ctx).unwrap();
2912 assert!(
2913 result.is_empty(),
2914 "a lazy continuation within the same blockquote must keep the sublist exempt, got: {result:?}"
2915 );
2916 }
2917
2918 #[test]
2919 fn test_issue_638_indented_fence_inside_blockquoted_ordered_item_keeps_exemption() {
2920 let rule = MD007ULIndent::new(2);
2925 let content = "> 1. ordered\n> ```\n> code\n> ```\n> - child\n> - grandchild\n";
2926 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2927 let result = rule.check(&ctx).unwrap();
2928 assert!(
2929 result.is_empty(),
2930 "an indented fence inside a blockquoted ordered item must keep the sublist exempt, got: {result:?}"
2931 );
2932 }
2933
2934 #[test]
2935 fn test_issue_638_fenced_code_block_terminates_ordered_list() {
2936 let rule = MD007ULIndent::new(2);
2942 let content = "1. ordered\n```\ncode\n```\n\n - parent\n - child\n";
2943 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2944 let result = rule.check(&ctx).unwrap();
2945 assert!(
2946 result.iter().any(|w| w.line == 7),
2947 "a top-level fenced code block terminates the ordered list; the child must be flagged, got: {result:?}"
2948 );
2949 }
2950
2951 #[test]
2952 fn test_issue_638_fenced_code_block_inside_item_keeps_exemption() {
2953 let rule = MD007ULIndent::new(2);
2958 let content = "1. ordered\n ```\n code\n ```\n - child\n - grandchild\n";
2959 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2960 let result = rule.check(&ctx).unwrap();
2961 assert!(
2962 result.is_empty(),
2963 "a fenced code block nested inside the item must keep the sublist exempt, got: {result:?}"
2964 );
2965 }
2966
2967 #[test]
2968 fn test_issue_638_blockquote_terminates_ordered_list() {
2969 let rule = MD007ULIndent::new(2);
2976 let content = "1. ordered\n> quote\n\n - parent\n - child\n";
2977 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2978 let result = rule.check(&ctx).unwrap();
2979 assert!(
2980 result.iter().any(|w| w.line == 5),
2981 "blockquote terminates the ordered list, so the child must still be flagged, got: {result:?}"
2982 );
2983 }
2984
2985 #[test]
2986 fn test_issue_638_blockquote_inside_item_keeps_exemption() {
2987 let rule = MD007ULIndent::new(2);
2992 let content = "1. ordered\n > quote inside item\n - child\n - grandchild\n";
2993 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2994 let result = rule.check(&ctx).unwrap();
2995 assert!(
2996 result.is_empty(),
2997 "a blockquote nested inside the item must keep the sublist exempt, got: {result:?}"
2998 );
2999 }
3000
3001 #[test]
3002 fn test_issue_638_exemption_requires_genuine_nesting_under_ordered() {
3003 let rule = MD007ULIndent::new(2);
3012 let content = "100. ordered\n - parent\n - child\n";
3013 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3014 let result = rule.check(&ctx).unwrap();
3015 assert!(
3016 result.iter().any(|w| w.line == 3),
3017 "the child of a non-nested bullet must still be checked, not exempted; got: {result:?}"
3018 );
3019 }
3020
3021 #[test]
3022 fn test_issue_638_paragraph_after_fenced_code_closes_ordered_list() {
3023 let rule = MD007ULIndent::new(2);
3032 let content = "1. ordered\n ```\n code\n ```\nnot lazy text\n - parent\n - child\n";
3033 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3034 let result = rule.check(&ctx).unwrap();
3035 assert!(
3036 result.iter().any(|w| w.line == 7),
3037 "fenced code is not paragraph text, so the list closes and the nested child must still be checked, not exempted; got: {result:?}"
3038 );
3039 }
3040
3041 #[test]
3042 fn test_issue_638_overlong_ordered_marker_is_lazy_continuation() {
3043 let rule = MD007ULIndent::new(2);
3049 let content = "1. ordered\n1234567890. this is continuation text\n - child\n - grandchild\n";
3050 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3051 let result = rule.check(&ctx).unwrap();
3052 assert!(
3053 result.is_empty(),
3054 "an overlong digit run is not a valid ordered marker, so the list stays open and the nested bullets are exempt; got: {result:?}"
3055 );
3056 }
3057
3058 #[test]
3059 fn test_indented_top_level_list_item_is_flagged() {
3060 let rule = MD007ULIndent::new(2);
3066 for indent in 2..=3 {
3067 let pad = " ".repeat(indent);
3068 let content = format!("{pad}- parent\n{pad} - child\n");
3069 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
3070 let result = rule.check(&ctx).unwrap();
3071 assert!(
3072 result.iter().any(|w| w.line == 1),
3073 "a top-level item indented {indent} spaces must be flagged (Expected 0); got: {result:?}"
3074 );
3075 }
3076 }
3077
3078 #[test]
3079 fn test_indented_code_block_bullet_is_not_a_list_item() {
3080 let rule = MD007ULIndent::new(2);
3083 let content = " - not a list, this is code\n";
3084 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3085 let result = rule.check(&ctx).unwrap();
3086 assert!(
3087 result.is_empty(),
3088 "a 4-space-indented bullet is an indented code block, not a misindented list; got: {result:?}"
3089 );
3090 }
3091
3092 #[test]
3093 fn test_tab_indent_expands_to_four_column_tabstop() {
3094 let rule = MD007ULIndent::new(2);
3101 let content = "- a\n\t- b\n";
3102 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3103 let result = rule.check(&ctx).unwrap();
3104 let warning = result
3105 .iter()
3106 .find(|w| w.line == 2)
3107 .expect("a tab-indented sublist at column 4 is over-indented for depth 1 and must be flagged");
3108 assert!(
3109 warning.message.contains("found 4"),
3110 "the tab must expand to the 4-column tab stop (found 4), not be counted as one character; got: {}",
3111 warning.message
3112 );
3113 }
3114
3115 #[test]
3116 fn test_tab_completing_two_space_indent_to_tabstop_is_accepted() {
3117 let rule = MD007ULIndent::new(2);
3123 let content = "- a\n - b\n \t- c\n";
3124 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3125 let result = rule.check(&ctx).unwrap();
3126 assert!(
3127 result.is_empty(),
3128 "` \\t` expands to column 4, the correct depth-2 indent, so no MD007 warning is expected; got: {result:?}"
3129 );
3130 }
3131
3132 #[test]
3133 fn test_issue_638_html_comment_terminates_ordered_list() {
3134 let rule = MD007ULIndent::new(2);
3141 let content = "1. ordered\n<!-- comment -->\n\n - parent\n - child\n";
3142 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3143 let result = rule.check(&ctx).unwrap();
3144 assert!(
3145 result.iter().any(|w| w.line == 5),
3146 "an HTML comment terminates the ordered list, so the child must still be flagged, got: {result:?}"
3147 );
3148 }
3149
3150 #[test]
3151 fn test_issue_638_blockquoted_list_item_terminates_ordered_list() {
3152 let rule = MD007ULIndent::new(2);
3160 let content = "1. ordered\n> - quote list\n\n - parent\n - child\n";
3161 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3162 let result = rule.check(&ctx).unwrap();
3163 assert!(
3164 result.iter().any(|w| w.line == 5),
3165 "a blockquoted list item terminates the ordered list, so the child must still be flagged, got: {result:?}"
3166 );
3167 }
3168
3169 #[test]
3170 fn test_issue_638_deeper_nested_quote_terminates_blockquoted_ordered_list() {
3171 let rule = MD007ULIndent::new(2);
3181 let content = "> 1. ordered\n> > quote\n>\n> - parent\n> - child\n";
3182 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3183 let result = rule.check(&ctx).unwrap();
3184 assert!(
3185 result.iter().any(|w| w.line == 4),
3186 "deeper nested quote closes the ordered list, so the misindented parent must be flagged, got: {result:?}"
3187 );
3188 assert!(
3189 result.iter().any(|w| w.line == 5),
3190 "the child of the fresh unordered list must be flagged, not exempted, got: {result:?}"
3191 );
3192 }
3193
3194 #[test]
3195 fn test_issue_638_deeper_quote_list_item_terminates_blockquoted_ordered_list() {
3196 let rule = MD007ULIndent::new(2);
3204 let content = "> 1. ordered\n> > - quote list\n>\n> - parent\n> - child\n";
3205 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3206 let result = rule.check(&ctx).unwrap();
3207 assert!(
3208 result.iter().any(|w| w.line == 4),
3209 "a deeper-quote list item closes the ordered list, so the parent must be flagged, got: {result:?}"
3210 );
3211 assert!(
3212 result.iter().any(|w| w.line == 5),
3213 "the child of the fresh unordered list must be flagged, not exempted, got: {result:?}"
3214 );
3215 }
3216
3217 #[test]
3218 fn test_issue_638_deeper_quote_indented_into_item_keeps_exemption() {
3219 let rule = MD007ULIndent::new(2);
3224 let content = "> 1. ordered\n> > quote inside item\n> - child\n> - grandchild\n";
3225 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3226 let result = rule.check(&ctx).unwrap();
3227 assert!(
3228 result.is_empty(),
3229 "a deeper quote indented into the item must keep the sublist exempt, got: {result:?}"
3230 );
3231 }
3232
3233 #[test]
3234 fn test_indent4_explicit_with_wide_ordered_parent() {
3235 let config = MD007Config {
3239 indent: crate::types::IndentSize::from_const(4),
3240 start_indented: false,
3241 start_indent: crate::types::IndentSize::from_const(2),
3242 style: md007_config::IndentStyle::TextAligned,
3243 style_explicit: false,
3244 indent_explicit: true,
3245 };
3246 let rule = MD007ULIndent::from_config_struct(config);
3247
3248 let content = "100. Wide ordered\n * Bullet at 5 spaces";
3250 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3251 let result = rule.check(&ctx).unwrap();
3252 assert!(
3253 result.is_empty(),
3254 "indent=4 under '100.' should accept 5-space indent: {result:?}"
3255 );
3256
3257 let content_4 = "100. Wide ordered\n * Bullet at 4 spaces";
3259 let ctx = LintContext::new(content_4, crate::config::MarkdownFlavor::Standard, None);
3260 let result = rule.check(&ctx).unwrap();
3261 assert!(
3262 result.is_empty(),
3263 "indent=4 under '100.' should accept 4-space indent: {result:?}"
3264 );
3265 }
3266
3267 fn commonmark_max_list_depth(md: &str) -> usize {
3271 use pulldown_cmark::{Event, Parser, Tag, TagEnd};
3272 let (mut depth, mut max) = (0usize, 0usize);
3273 for event in Parser::new(md) {
3274 match event {
3275 Event::Start(Tag::List(_)) => {
3276 depth += 1;
3277 max = max.max(depth);
3278 }
3279 Event::End(TagEnd::List(_)) => depth = depth.saturating_sub(1),
3280 _ => {}
3281 }
3282 }
3283 max
3284 }
3285
3286 #[test]
3287 fn test_md007_widened_parent_marker_keeps_nested_child() {
3288 let rule = MD007ULIndent::default();
3294 let content = indoc! {"
3295 - Parent item
3296 - Nested item
3297 "};
3298 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3299 let result = rule.check(&ctx).unwrap();
3300 assert!(
3301 result.is_empty(),
3302 "a child aligned to a widened parent's content column must not be flagged: {result:?}"
3303 );
3304 assert_eq!(commonmark_max_list_depth(content), 2, "precondition: source is nested");
3305 assert_eq!(
3306 rule.fix(&ctx).unwrap(),
3307 content,
3308 "fix must be a no-op for an already correctly nested child"
3309 );
3310 }
3311
3312 #[test]
3313 fn test_md007_widened_parent_aligns_child_to_content_column() {
3314 let rule = MD007ULIndent::default();
3317 let content = indoc! {"
3318 - Parent item
3319 - Nested item
3320 "};
3321 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3322 let fixed = rule.fix(&ctx).unwrap();
3323 assert_eq!(
3324 fixed,
3325 indoc! {"
3326 - Parent item
3327 - Nested item
3328 "},
3329 "child must align to the parent's content column 4: {fixed:?}"
3330 );
3331 assert_eq!(
3332 commonmark_max_list_depth(&fixed),
3333 2,
3334 "fixed child must remain nested, not flattened to a sibling:\n{fixed}"
3335 );
3336 }
3337
3338 #[test]
3339 fn test_md007_widened_markers_nested_multiple_levels() {
3340 let rule = MD007ULIndent::default();
3343 let content = indoc! {"
3344 - Level 0
3345 - Level 1
3346 - Level 2
3347 "};
3348 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3349 let result = rule.check(&ctx).unwrap();
3350 assert!(
3351 result.is_empty(),
3352 "deeply nested widened markers must not be flagged: {result:?}"
3353 );
3354 assert_eq!(
3355 commonmark_max_list_depth(content),
3356 3,
3357 "three nesting levels are preserved"
3358 );
3359 }
3360
3361 #[test]
3362 fn test_md007_default_marker_indent_still_enforced() {
3363 let rule = MD007ULIndent::default();
3367 let content = indoc! {"
3368 - Parent item
3369 - Nested item
3370 "};
3371 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3372 let result = rule.check(&ctx).unwrap();
3373 assert_eq!(
3374 result.len(),
3375 1,
3376 "an over-indented child under a normal marker is still flagged: {result:?}"
3377 );
3378 assert_eq!(
3379 rule.fix(&ctx).unwrap(),
3380 indoc! {"
3381 - Parent item
3382 - Nested item
3383 "}
3384 );
3385 }
3386}