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 is_skipped_region = |info: &crate::lint_context::LineInfo| {
237 info.in_code_block || info.in_front_matter || info.in_mkdocstrings || info.in_footnote_definition
238 };
239 let opens_fence_on_marker_line = line_info
252 .list_item
253 .as_ref()
254 .and_then(|item| line_info.content(ctx.content).get(item.content_column..))
255 .is_some_and(|after_marker| {
256 let after_marker = after_marker.trim_start();
257 after_marker.starts_with("```") || after_marker.starts_with("~~~")
258 });
259 let fence_opening_marker_line = opens_fence_on_marker_line
260 && line_info.in_code_block
261 && !line_info.in_front_matter
262 && !line_info.in_mkdocstrings
263 && !line_info.in_footnote_definition;
264 if is_skipped_region(line_info) && !fence_opening_marker_line {
265 let region_start = line_idx == 0 || !is_skipped_region(&ctx.lines[line_idx - 1]);
272 if region_start && !line_info.is_blank {
273 let bq_depth = line_info.blockquote.as_ref().map_or(0, |bq| bq.nesting_level);
274 Self::terminate_closed_items(ctx, line_info, &mut list_stack, bq_depth);
275 }
276 continue;
277 }
278
279 if let Some(list_item) = &line_info.list_item {
281 let (content_for_calculation, adjusted_marker_column) = if line_info.blockquote.is_some() {
285 let line_content = line_info.content(ctx.content);
287 let mut remaining = line_content;
288 let mut content_start = 0;
289
290 loop {
291 let trimmed = remaining.trim_start();
292 if !trimmed.starts_with('>') {
293 break;
294 }
295 content_start += remaining.len() - trimmed.len();
297 content_start += 1;
299 let after_gt = &trimmed[1..];
300 if let Some(stripped) = after_gt.strip_prefix(' ') {
302 content_start += 1;
303 remaining = stripped;
304 } else if let Some(stripped) = after_gt.strip_prefix('\t') {
305 content_start += 1;
306 remaining = stripped;
307 } else {
308 remaining = after_gt;
309 }
310 }
311
312 let content_after_prefix = &line_content[content_start..];
314 let adjusted_col = if list_item.marker_column >= content_start {
316 list_item.marker_column - content_start
317 } else {
318 list_item.marker_column
320 };
321 (content_after_prefix.to_string(), adjusted_col)
322 } else {
323 (line_info.content(ctx.content).to_string(), list_item.marker_column)
324 };
325
326 let visual_marker_column =
328 Self::char_pos_to_visual_column(&content_for_calculation, adjusted_marker_column);
329
330 let visual_content_column = if line_info.blockquote.is_some() {
332 let adjusted_content_col =
334 if list_item.content_column >= (line_info.byte_len - content_for_calculation.len()) {
335 list_item.content_column - (line_info.byte_len - content_for_calculation.len())
336 } else {
337 list_item.content_column
338 };
339 Self::char_pos_to_visual_column(&content_for_calculation, adjusted_content_col)
340 } else {
341 Self::char_pos_to_visual_column(line_info.content(ctx.content), list_item.content_column)
342 };
343
344 let visual_marker_for_nesting = if visual_marker_column == 1 && self.config.indent.get() != 1 {
348 0
349 } else {
350 visual_marker_column
351 };
352
353 let bq_depth = line_info.blockquote.as_ref().map_or(0, |bq| bq.nesting_level);
355
356 while let Some(&(indent, _, _, _, item_bq_depth, _, _)) = list_stack.last() {
359 if item_bq_depth == bq_depth && indent >= visual_marker_for_nesting {
360 list_stack.pop();
361 } else if item_bq_depth > bq_depth {
362 list_stack.pop();
364 } else {
365 break;
366 }
367 }
368
369 while let Some(&(_, _, _, content_col, item_bq_depth, _, _)) = list_stack.last() {
382 if item_bq_depth < bq_depth
383 && content_col > Self::indent_relative_to_depth(ctx, line_info, item_bq_depth)
384 {
385 list_stack.pop();
386 } else {
387 break;
388 }
389 }
390
391 if list_item.is_ordered {
393 list_stack.push((
396 visual_marker_column,
397 line_idx,
398 true,
399 visual_content_column,
400 bq_depth,
401 false,
402 visual_content_column,
403 ));
404 continue;
405 }
406
407 let threshold_ok = list_stack
431 .iter()
432 .any(|item| item.4 == bq_depth && item.2 && item.3 <= visual_marker_column);
433 if ctx.flavor != crate::config::MarkdownFlavor::MkDocs
444 && threshold_ok
445 && self.config.style_explicit
446 && self.config.style == md007_config::IndentStyle::Fixed
447 {
448 while let Some(&(_, _, _, _, item_bq_depth, _, source_content_col)) = list_stack.last() {
449 if item_bq_depth == bq_depth && source_content_col > visual_marker_column {
450 list_stack.pop();
451 } else {
452 break;
453 }
454 }
455 }
456 let chain_ok = list_stack
457 .iter()
458 .rev()
459 .find(|item| item.4 == bq_depth)
460 .is_some_and(|item| item.2 || item.5);
461 let ordered_chain = ctx.flavor != crate::config::MarkdownFlavor::MkDocs && threshold_ok && chain_ok;
462 let clamp_to_parent = ordered_chain
468 && self.config.style_explicit
469 && self.config.style == md007_config::IndentStyle::Fixed;
470 if ordered_chain && !clamp_to_parent {
471 list_stack.push((
472 visual_marker_column,
473 line_idx,
474 false,
475 visual_content_column,
476 bq_depth,
477 true,
478 visual_content_column,
479 ));
480 continue;
481 }
482
483 let nesting_level = list_stack.iter().filter(|item| item.4 == bq_depth).count();
485
486 let parent_info = list_stack
488 .iter()
489 .rev()
490 .find(|item| item.4 == bq_depth)
491 .map(|&(_, _, is_ordered, content_col, _, _, _)| (is_ordered, content_col));
492
493 let mut expected_indent = if self.config.start_indented && nesting_level == 0 {
499 self.config.start_indent.get() as usize
500 } else {
501 self.calculate_expected_indent(nesting_level, parent_info)
502 };
503
504 if clamp_to_parent && let Some((_, parent_content_col)) = parent_info {
509 expected_indent = expected_indent.max(parent_content_col);
510 }
511
512 let also_acceptable = if !clamp_to_parent
518 && self.config.indent_explicit
519 && parent_info.is_some_and(|(is_ordered, _)| is_ordered)
520 {
521 Some(nesting_level * self.config.indent.get() as usize)
522 } else {
523 None
524 };
525
526 if ctx.flavor == crate::config::MarkdownFlavor::MkDocs
530 && let Some(&(parent_marker_col, _, true, _, _, _, _)) =
531 list_stack.iter().rev().find(|item| item.4 == bq_depth && item.2)
532 {
533 expected_indent = expected_indent.max(parent_marker_col + 4);
534 }
535
536 let accepted_indent = if also_acceptable.is_some_and(|alt| visual_marker_column == alt) {
542 visual_marker_column
543 } else {
544 expected_indent
545 };
546 let marker_width = visual_content_column.saturating_sub(visual_marker_column);
556 let expected_content_visual_col = accepted_indent + marker_width;
557 list_stack.push((
562 visual_marker_column,
563 line_idx,
564 false,
565 expected_content_visual_col,
566 bq_depth,
567 clamp_to_parent,
568 visual_content_column,
569 ));
570
571 if !self.config.start_indented && nesting_level == 0 && visual_marker_column == 0 {
577 continue;
578 }
579
580 if visual_marker_column != expected_indent && also_acceptable != Some(visual_marker_column) {
581 if let Some(alt) = also_acceptable {
583 expected_indent = alt;
584 }
585 let fix = {
587 let correct_indent = " ".repeat(expected_indent);
588
589 let replacement = if line_info.blockquote.is_some() {
592 let mut blockquote_count = 0;
594 for ch in line_info.content(ctx.content).chars() {
595 if ch == '>' {
596 blockquote_count += 1;
597 } else if ch != ' ' && ch != '\t' {
598 break;
599 }
600 }
601 let blockquote_prefix = if blockquote_count > 1 {
603 (0..blockquote_count)
604 .map(|_| "> ")
605 .collect::<String>()
606 .trim_end()
607 .to_string()
608 } else {
609 ">".to_string()
610 };
611 format!("{blockquote_prefix} {correct_indent}")
614 } else {
615 correct_indent
616 };
617
618 let start_byte = line_info.byte_offset;
621 let mut end_byte = line_info.byte_offset;
622
623 for (i, ch) in line_info.content(ctx.content).chars().enumerate() {
625 if i >= list_item.marker_column {
626 break;
627 }
628 end_byte += ch.len_utf8();
629 }
630
631 Some(crate::rule::Fix::new(start_byte..end_byte, replacement))
632 };
633
634 warnings.push(LintWarning {
635 rule_name: Some(self.name().to_string()),
636 message: format!(
637 "Expected {expected_indent} spaces for indent depth {nesting_level}, found {visual_marker_column}"
638 ),
639 line: line_idx + 1, column: 1, end_line: line_idx + 1,
642 end_column: visual_marker_column + 1, severity: Severity::Warning,
644 fix,
645 });
646 }
647 } else if !line_info.is_blank {
648 let bq_depth = line_info.blockquote.as_ref().map_or(0, |bq| bq.nesting_level);
675 let prev_line = line_idx.checked_sub(1).map(|i| &ctx.lines[i]);
676 let prev_blank = prev_line.is_none_or(|p| p.is_blank);
677 let prev_bq_depth = prev_line
678 .and_then(|p| p.blockquote.as_ref())
679 .map_or(0, |bq| bq.nesting_level);
680 let same_container = prev_bq_depth == bq_depth;
681 let text = line_info
682 .blockquote
683 .as_ref()
684 .map_or_else(|| line_info.content(ctx.content), |bq| bq.content.as_str());
685 let trimmed = text.trim_start();
686 let starts_like_list_marker = match trimmed.as_bytes().first() {
687 Some(b'-' | b'*' | b'+') => {
688 matches!(trimmed.as_bytes().get(1), Some(b' ' | b'\t'))
689 }
690 Some(c) if c.is_ascii_digit() => {
691 let after_digits = trimmed.trim_start_matches(|ch: char| ch.is_ascii_digit());
695 let num_digits = trimmed.len() - after_digits.len();
696 let mut rest = after_digits.chars();
697 (1..=9).contains(&num_digits)
698 && matches!(rest.next(), Some('.' | ')'))
699 && matches!(rest.next(), Some(' ' | '\t') | None)
700 }
701 _ => false,
702 };
703 let prev_is_open_paragraph = prev_line.is_some_and(|p| {
710 !p.is_blank
711 && !p.in_code_block
712 && p.heading.is_none()
713 && !p.is_horizontal_rule
714 && !p.in_html_block
715 && !p.in_html_comment
716 && !p.is_div_marker
717 });
718 let is_lazy_paragraph_continuation = !prev_blank
719 && prev_is_open_paragraph
720 && same_container
721 && !starts_like_list_marker
722 && line_info.heading.is_none()
723 && !line_info.is_horizontal_rule
724 && !line_info.in_code_block
725 && !line_info.in_html_block
726 && !line_info.in_html_comment
727 && !line_info.is_div_marker;
728 if is_lazy_paragraph_continuation {
729 continue;
731 }
732 Self::terminate_closed_items(ctx, line_info, &mut list_stack, bq_depth);
733 }
734 }
735 Ok(warnings)
736 }
737
738 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
740 let warnings = self.check(ctx)?;
742 let warnings =
743 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
744
745 if warnings.is_empty() {
747 return Ok(ctx.content.to_string());
748 }
749
750 let mut fixes: Vec<_> = warnings
752 .iter()
753 .filter_map(|w| w.fix.as_ref().map(|f| (f.range.start, f.range.end, &f.replacement)))
754 .collect();
755 fixes.sort_by_key(|f| std::cmp::Reverse(f.0));
756
757 let mut result = ctx.content.to_string();
759 for (start, end, replacement) in fixes {
760 if start < result.len() && end <= result.len() && start <= end {
761 result.replace_range(start..end, replacement);
762 }
763 }
764
765 Ok(result)
766 }
767
768 fn category(&self) -> RuleCategory {
770 RuleCategory::List
771 }
772
773 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
775 if ctx.content.is_empty() || !ctx.likely_has_lists() {
777 return true;
778 }
779 !ctx.lines
781 .iter()
782 .any(|line| line.list_item.as_ref().is_some_and(|item| !item.is_ordered))
783 }
784
785 fn as_any(&self) -> &dyn std::any::Any {
786 self
787 }
788
789 crate::impl_rule_config_sections!(MD007Config);
790
791 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
792 where
793 Self: Sized,
794 {
795 let mut rule_config = crate::rule_config_serde::load_rule_config::<MD007Config>(config);
796
797 if let Some(rule_cfg) = config.rules.get("MD007") {
799 rule_config.style_explicit = rule_cfg.values.contains_key("style");
800 rule_config.indent_explicit = rule_cfg.values.contains_key("indent");
801
802 if rule_config.indent_explicit
806 && rule_config.style_explicit
807 && rule_config.style == md007_config::IndentStyle::TextAligned
808 {
809 eprintln!(
810 "\x1b[33m[config warning]\x1b[0m MD007: 'indent' has no effect when 'style = \"text-aligned\"'. \
811 Text-aligned style ignores indent and aligns nested items with parent text. \
812 To use fixed {} space increments, either remove 'style' or set 'style = \"fixed\"'.",
813 rule_config.indent.get()
814 );
815 }
816 }
817
818 if config.markdown_flavor() == crate::config::MarkdownFlavor::MkDocs {
821 if rule_config.indent_explicit && rule_config.indent.get() < 4 {
822 eprintln!(
823 "\x1b[33m[config warning]\x1b[0m MD007: MkDocs flavor requires indent >= 4 \
824 (Python-Markdown enforces 4-space indentation). \
825 Overriding indent={} to indent=4.",
826 rule_config.indent.get()
827 );
828 }
829 if rule_config.style_explicit && rule_config.style == md007_config::IndentStyle::TextAligned {
830 eprintln!(
831 "\x1b[33m[config warning]\x1b[0m MD007: MkDocs flavor requires style=\"fixed\" \
832 (Python-Markdown uses fixed 4-space indentation). \
833 Overriding style=\"text-aligned\" to style=\"fixed\"."
834 );
835 }
836 if rule_config.indent.get() < 4 {
837 rule_config.indent = crate::types::IndentSize::from_const(4);
838 }
839 rule_config.style = md007_config::IndentStyle::Fixed;
840 }
841
842 Box::new(Self::from_config_struct(rule_config))
843 }
844}
845
846#[cfg(test)]
847mod tests {
848 use super::*;
849 use crate::lint_context::LintContext;
850 use crate::rule::Rule;
851 use indoc::indoc;
852
853 #[test]
854 fn test_valid_list_indent() {
855 let rule = MD007ULIndent::default();
856 let content = "* Item 1\n * Item 2\n * Item 3";
857 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
858 let result = rule.check(&ctx).unwrap();
859 assert!(
860 result.is_empty(),
861 "Expected no warnings for valid indentation, but got {} warnings",
862 result.len()
863 );
864 }
865
866 #[test]
867 fn test_invalid_list_indent() {
868 let rule = MD007ULIndent::default();
869 let content = "* Item 1\n * Item 2\n * Item 3";
870 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
871 let result = rule.check(&ctx).unwrap();
872 assert_eq!(result.len(), 2);
873 assert_eq!(result[0].line, 2);
874 assert_eq!(result[0].column, 1);
875 assert_eq!(result[1].line, 3);
876 assert_eq!(result[1].column, 1);
877 }
878
879 #[test]
880 fn test_mixed_indentation() {
881 let rule = MD007ULIndent::default();
882 let content = "* Item 1\n * Item 2\n * Item 3\n * Item 4";
883 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
884 let result = rule.check(&ctx).unwrap();
885 assert_eq!(result.len(), 1);
886 assert_eq!(result[0].line, 3);
887 assert_eq!(result[0].column, 1);
888 }
889
890 #[test]
891 fn test_fix_indentation() {
892 let rule = MD007ULIndent::default();
893 let content = "* Item 1\n * Item 2\n * Item 3";
894 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
895 let result = rule.fix(&ctx).unwrap();
896 let expected = "* Item 1\n * Item 2\n * Item 3";
900 assert_eq!(result, expected);
901 }
902
903 #[test]
904 fn test_md007_in_yaml_code_block() {
905 let rule = MD007ULIndent::default();
906 let content = r#"```yaml
907repos:
908- repo: https://github.com/rvben/rumdl
909 rev: v0.5.0
910 hooks:
911 - id: rumdl-check
912```"#;
913 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
914 let result = rule.check(&ctx).unwrap();
915 assert!(
916 result.is_empty(),
917 "MD007 should not trigger inside a code block, but got warnings: {result:?}"
918 );
919 }
920
921 #[test]
922 fn test_blockquoted_list_indent() {
923 let rule = MD007ULIndent::default();
924 let content = "> * Item 1\n> * Item 2\n> * Item 3";
925 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
926 let result = rule.check(&ctx).unwrap();
927 assert!(
928 result.is_empty(),
929 "Expected no warnings for valid blockquoted list indentation, but got {result:?}"
930 );
931 }
932
933 #[test]
934 fn test_blockquoted_list_invalid_indent() {
935 let rule = MD007ULIndent::default();
936 let content = "> * Item 1\n> * Item 2\n> * Item 3";
937 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
938 let result = rule.check(&ctx).unwrap();
939 assert_eq!(
940 result.len(),
941 2,
942 "Expected 2 warnings for invalid blockquoted list indentation, got {result:?}"
943 );
944 assert_eq!(result[0].line, 2);
945 assert_eq!(result[1].line, 3);
946 }
947
948 #[test]
949 fn test_nested_blockquote_list_indent() {
950 let rule = MD007ULIndent::default();
951 let content = "> > * Item 1\n> > * Item 2\n> > * Item 3";
952 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
953 let result = rule.check(&ctx).unwrap();
954 assert!(
955 result.is_empty(),
956 "Expected no warnings for valid nested blockquoted list indentation, but got {result:?}"
957 );
958 }
959
960 #[test]
961 fn test_blockquote_list_with_code_block() {
962 let rule = MD007ULIndent::default();
963 let content = "> * Item 1\n> * Item 2\n> ```\n> code\n> ```\n> * Item 3";
964 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
965 let result = rule.check(&ctx).unwrap();
966 assert!(
967 result.is_empty(),
968 "MD007 should not trigger inside a code block within a blockquote, but got warnings: {result:?}"
969 );
970 }
971
972 #[test]
973 fn test_properly_indented_lists() {
974 let rule = MD007ULIndent::default();
975
976 let test_cases = vec![
978 "* Item 1\n* Item 2",
979 "* Item 1\n * Item 1.1\n * Item 1.1.1",
980 "- Item 1\n - Item 1.1",
981 "+ Item 1\n + Item 1.1",
982 "* Item 1\n * Item 1.1\n* Item 2\n * Item 2.1",
983 ];
984
985 for content in test_cases {
986 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
987 let result = rule.check(&ctx).unwrap();
988 assert!(
989 result.is_empty(),
990 "Expected no warnings for properly indented list:\n{}\nGot {} warnings",
991 content,
992 result.len()
993 );
994 }
995 }
996
997 #[test]
998 fn test_under_indented_lists() {
999 let rule = MD007ULIndent::default();
1000
1001 let test_cases = vec![
1002 ("* Item 1\n * Item 1.1", 1, 2), ("* Item 1\n * Item 1.1\n * Item 1.1.1", 1, 3), ];
1005
1006 for (content, expected_warnings, line) in test_cases {
1007 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1008 let result = rule.check(&ctx).unwrap();
1009 assert_eq!(
1010 result.len(),
1011 expected_warnings,
1012 "Expected {expected_warnings} warnings for under-indented list:\n{content}"
1013 );
1014 if expected_warnings > 0 {
1015 assert_eq!(result[0].line, line);
1016 }
1017 }
1018 }
1019
1020 #[test]
1021 fn test_over_indented_lists() {
1022 let rule = MD007ULIndent::default();
1023
1024 let test_cases = vec![
1025 ("* 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), ];
1029
1030 for (content, expected_warnings, line) in test_cases {
1031 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1032 let result = rule.check(&ctx).unwrap();
1033 assert_eq!(
1034 result.len(),
1035 expected_warnings,
1036 "Expected {expected_warnings} warnings for over-indented list:\n{content}"
1037 );
1038 if expected_warnings > 0 {
1039 assert_eq!(result[0].line, line);
1040 }
1041 }
1042 }
1043
1044 #[test]
1045 fn test_custom_indent_2_spaces() {
1046 let rule = MD007ULIndent::new(2); let content = "* Item 1\n * Item 2\n * Item 3";
1048 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1049 let result = rule.check(&ctx).unwrap();
1050 assert!(result.is_empty());
1051 }
1052
1053 #[test]
1054 fn test_custom_indent_3_spaces() {
1055 let rule = MD007ULIndent::new(3);
1058
1059 let correct_content = "* Item 1\n * Item 2\n * Item 3";
1061 let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard, None);
1062 let result = rule.check(&ctx).unwrap();
1063 assert!(
1064 result.is_empty(),
1065 "Fixed style expects 0, 3, 6 spaces but got: {result:?}"
1066 );
1067
1068 let wrong_content = "* Item 1\n * Item 2\n * Item 3";
1070 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
1071 let result = rule.check(&ctx).unwrap();
1072 assert!(!result.is_empty(), "Should warn: expected 3 spaces, found 2");
1073 }
1074
1075 #[test]
1076 fn test_custom_indent_4_spaces() {
1077 let rule = MD007ULIndent::new(4);
1080
1081 let correct_content = "* Item 1\n * Item 2\n * Item 3";
1083 let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard, None);
1084 let result = rule.check(&ctx).unwrap();
1085 assert!(
1086 result.is_empty(),
1087 "Fixed style expects 0, 4, 8 spaces but got: {result:?}"
1088 );
1089
1090 let wrong_content = "* Item 1\n * Item 2\n * Item 3";
1092 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
1093 let result = rule.check(&ctx).unwrap();
1094 assert!(!result.is_empty(), "Should warn: expected 4 spaces, found 2");
1095 }
1096
1097 #[test]
1098 fn test_tab_indentation() {
1099 let rule = MD007ULIndent::default();
1100
1101 let content = "* Item 1\n * Item 2";
1107 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1108 let result = rule.check(&ctx).unwrap();
1109 assert_eq!(result.len(), 1, "Wrong indentation should trigger warning");
1110
1111 let fixed = rule.fix(&ctx).unwrap();
1113 assert_eq!(fixed, "* Item 1\n * Item 2");
1114
1115 let content_multi = "* Item 1\n * Item 2\n * Item 3";
1117 let ctx = LintContext::new(content_multi, crate::config::MarkdownFlavor::Standard, None);
1118 let fixed = rule.fix(&ctx).unwrap();
1119 assert_eq!(fixed, "* Item 1\n * Item 2\n * Item 3");
1122
1123 let content_mixed = "* Item 1\n * Item 2\n * Item 3";
1125 let ctx = LintContext::new(content_mixed, crate::config::MarkdownFlavor::Standard, None);
1126 let fixed = rule.fix(&ctx).unwrap();
1127 assert_eq!(fixed, "* Item 1\n * Item 2\n * Item 3");
1130 }
1131
1132 #[test]
1133 fn test_mixed_ordered_unordered_lists() {
1134 let rule = MD007ULIndent::default();
1135
1136 let content = r#"1. Ordered item
1139 * Unordered sub-item (correct - 3 spaces under ordered)
1140 2. Ordered sub-item
1141* Unordered item
1142 1. Ordered sub-item
1143 * Unordered sub-item"#;
1144
1145 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1146 let result = rule.check(&ctx).unwrap();
1147 assert_eq!(result.len(), 0, "All unordered list indentation should be correct");
1148
1149 let fixed = rule.fix(&ctx).unwrap();
1151 assert_eq!(fixed, content);
1152 }
1153
1154 #[test]
1155 fn test_list_markers_variety() {
1156 let rule = MD007ULIndent::default();
1157
1158 let content = r#"* Asterisk
1160 * Nested asterisk
1161- Hyphen
1162 - Nested hyphen
1163+ Plus
1164 + Nested plus"#;
1165
1166 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1167 let result = rule.check(&ctx).unwrap();
1168 assert!(
1169 result.is_empty(),
1170 "All unordered list markers should work with proper indentation"
1171 );
1172
1173 let wrong_content = r#"* Asterisk
1175 * Wrong asterisk
1176- Hyphen
1177 - Wrong hyphen
1178+ Plus
1179 + Wrong plus"#;
1180
1181 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
1182 let result = rule.check(&ctx).unwrap();
1183 assert_eq!(result.len(), 3, "All marker types should be checked for indentation");
1184 }
1185
1186 #[test]
1187 fn test_empty_list_items() {
1188 let rule = MD007ULIndent::default();
1189 let content = "* Item 1\n* \n * Item 2";
1190 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1191 let result = rule.check(&ctx).unwrap();
1192 assert!(
1193 result.is_empty(),
1194 "Empty list items should not affect indentation checks"
1195 );
1196 }
1197
1198 #[test]
1199 fn test_list_with_code_blocks() {
1200 let rule = MD007ULIndent::default();
1201 let content = r#"* Item 1
1202 ```
1203 code
1204 ```
1205 * Item 2
1206 * Item 3"#;
1207 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1208 let result = rule.check(&ctx).unwrap();
1209 assert!(result.is_empty());
1210 }
1211
1212 #[test]
1213 fn test_list_in_front_matter() {
1214 let rule = MD007ULIndent::default();
1215 let content = r#"---
1216tags:
1217 - tag1
1218 - tag2
1219---
1220* Item 1
1221 * Item 2"#;
1222 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1223 let result = rule.check(&ctx).unwrap();
1224 assert!(result.is_empty(), "Lists in YAML front matter should be ignored");
1225 }
1226
1227 #[test]
1228 fn test_fix_preserves_content() {
1229 let rule = MD007ULIndent::default();
1230 let content = "* Item 1 with **bold** and *italic*\n * Item 2 with `code`\n * Item 3 with [link](url)";
1231 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1232 let fixed = rule.fix(&ctx).unwrap();
1233 let expected = "* Item 1 with **bold** and *italic*\n * Item 2 with `code`\n * Item 3 with [link](url)";
1236 assert_eq!(fixed, expected, "Fix should only change indentation, not content");
1237 }
1238
1239 #[test]
1240 fn test_start_indented_config() {
1241 let config = MD007Config {
1242 start_indented: true,
1243 start_indent: crate::types::IndentSize::from_const(4),
1244 indent: crate::types::IndentSize::from_const(2),
1245 style: md007_config::IndentStyle::TextAligned,
1246 style_explicit: true, indent_explicit: false,
1248 };
1249 let rule = MD007ULIndent::from_config_struct(config);
1250
1251 let content = " * Item 1\n * Item 2\n * Item 3";
1256 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1257 let result = rule.check(&ctx).unwrap();
1258 assert!(result.is_empty(), "Expected no warnings with start_indented config");
1259
1260 let wrong_content = " * Item 1\n * Item 2";
1262 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
1263 let result = rule.check(&ctx).unwrap();
1264 assert_eq!(result.len(), 2);
1265 assert_eq!(result[0].line, 1);
1266 assert_eq!(result[0].message, "Expected 4 spaces for indent depth 0, found 2");
1267 assert_eq!(result[1].line, 2);
1268 assert_eq!(result[1].message, "Expected 6 spaces for indent depth 1, found 4");
1269
1270 let fixed = rule.fix(&ctx).unwrap();
1272 assert_eq!(fixed, " * Item 1\n * Item 2");
1273 }
1274
1275 #[test]
1276 fn test_start_indented_false_flags_indented_first_level() {
1277 let rule = MD007ULIndent::default(); let content = " * Item 1"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1285 let result = rule.check(&ctx).unwrap();
1286 assert!(
1287 result.iter().any(|w| w.line == 1 && w.message.contains("Expected 0")),
1288 "a top-level item indented 3 spaces must be flagged with Expected 0, got: {result:?}"
1289 );
1290
1291 let content = "* Item 1\n * Item 2\n * Item 3";
1295 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1296 let result = rule.check(&ctx).unwrap();
1297 assert!(
1298 result.is_empty(),
1299 "a correctly nested 0/2/4-space list should produce no warnings, got: {result:?}"
1300 );
1301 }
1302
1303 #[test]
1304 fn test_deeply_nested_lists() {
1305 let rule = MD007ULIndent::default();
1306 let content = r#"* L1
1307 * L2
1308 * L3
1309 * L4
1310 * L5
1311 * L6"#;
1312 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1313 let result = rule.check(&ctx).unwrap();
1314 assert!(result.is_empty());
1315
1316 let wrong_content = r#"* L1
1318 * L2
1319 * L3
1320 * L4
1321 * L5
1322 * L6"#;
1323 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
1324 let result = rule.check(&ctx).unwrap();
1325 assert_eq!(result.len(), 2, "Deep nesting errors should be detected");
1326 }
1327
1328 #[test]
1329 fn test_excessive_indentation_detected() {
1330 let rule = MD007ULIndent::default();
1331
1332 let content = "- Item 1\n - Item 2 with 5 spaces";
1334 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1335 let result = rule.check(&ctx).unwrap();
1336 assert_eq!(result.len(), 1, "Should detect excessive indentation (5 instead of 2)");
1337 assert_eq!(result[0].line, 2);
1338 assert!(result[0].message.contains("Expected 2 spaces"));
1339 assert!(result[0].message.contains("found 5"));
1340
1341 let content = "- Item 1\n - Item 2 with 3 spaces";
1343 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1344 let result = rule.check(&ctx).unwrap();
1345 assert_eq!(
1346 result.len(),
1347 1,
1348 "Should detect slightly excessive indentation (3 instead of 2)"
1349 );
1350 assert_eq!(result[0].line, 2);
1351 assert!(result[0].message.contains("Expected 2 spaces"));
1352 assert!(result[0].message.contains("found 3"));
1353
1354 let content = "- Item 1\n - Item 2 with 1 space";
1356 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1357 let result = rule.check(&ctx).unwrap();
1358 assert_eq!(
1359 result.len(),
1360 1,
1361 "Should detect 1-space indent (insufficient for nesting, expected 0)"
1362 );
1363 assert_eq!(result[0].line, 2);
1364 assert!(result[0].message.contains("Expected 0 spaces"));
1365 assert!(result[0].message.contains("found 1"));
1366 }
1367
1368 #[test]
1369 fn test_excessive_indentation_with_4_space_config() {
1370 let rule = MD007ULIndent::new(4);
1373
1374 let content = "- Formatter:\n - The stable style changed";
1376 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1377 let result = rule.check(&ctx).unwrap();
1378 assert!(
1379 !result.is_empty(),
1380 "Should detect 5 spaces when expecting 4 (fixed style)"
1381 );
1382
1383 let correct_content = "- Formatter:\n - The stable style changed";
1385 let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard, None);
1386 let result = rule.check(&ctx).unwrap();
1387 assert!(result.is_empty(), "Should accept correct fixed style indent (4 spaces)");
1388 }
1389
1390 #[test]
1391 fn test_bullets_nested_under_numbered_items() {
1392 let rule = MD007ULIndent::default();
1393 let content = "\
13941. **Active Directory/LDAP**
1395 - User authentication and directory services
1396 - LDAP for user information and validation
1397
13982. **Oracle Unified Directory (OUD)**
1399 - Extended user directory services";
1400 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1401 let result = rule.check(&ctx).unwrap();
1402 assert!(
1404 result.is_empty(),
1405 "Expected no warnings for bullets with 3 spaces under numbered items, got: {result:?}"
1406 );
1407 }
1408
1409 #[test]
1410 fn test_bullets_nested_under_numbered_items_wrong_indent() {
1411 let rule = MD007ULIndent::default();
1412 let content = "\
14131. **Active Directory/LDAP**
1414 - Wrong: only 2 spaces";
1415 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1416 let result = rule.check(&ctx).unwrap();
1417 assert_eq!(
1419 result.len(),
1420 1,
1421 "Expected warning for incorrect indentation under numbered items"
1422 );
1423 assert!(
1424 result
1425 .iter()
1426 .any(|w| w.line == 2 && w.message.contains("Expected 3 spaces"))
1427 );
1428 }
1429
1430 #[test]
1431 fn test_regular_bullet_nesting_still_works() {
1432 let rule = MD007ULIndent::default();
1433 let content = "\
1434* Top level
1435 * Nested bullet (2 spaces is correct)
1436 * Deeply nested (4 spaces)";
1437 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1438 let result = rule.check(&ctx).unwrap();
1439 assert!(
1441 result.is_empty(),
1442 "Expected no warnings for standard bullet nesting, got: {result:?}"
1443 );
1444 }
1445
1446 #[test]
1447 fn test_blockquote_with_tab_after_marker() {
1448 let rule = MD007ULIndent::default();
1449 let content = ">\t* List item\n>\t * Nested\n";
1450 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1451 let result = rule.check(&ctx).unwrap();
1452 assert!(
1453 result.is_empty(),
1454 "Tab after blockquote marker should be handled correctly, got: {result:?}"
1455 );
1456 }
1457
1458 #[test]
1459 fn test_blockquote_with_space_then_tab_after_marker() {
1460 let rule = MD007ULIndent::default();
1461 let content = "> \t* List item\n";
1462 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1463 let result = rule.check(&ctx).unwrap();
1464 assert!(
1469 result.iter().any(|w| w.line == 1 && w.message.contains("Expected 0")),
1470 "an indented blockquoted top-level item must be flagged with Expected 0, got: {result:?}"
1471 );
1472 }
1473
1474 #[test]
1475 fn test_blockquote_with_multiple_tabs() {
1476 let rule = MD007ULIndent::default();
1477 let content = ">\t\t* List item\n";
1478 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1479 let result = rule.check(&ctx).unwrap();
1480 assert!(
1482 result.is_empty(),
1483 "First-level list item at any indentation is allowed when start_indented=false, got: {result:?}"
1484 );
1485 }
1486
1487 #[test]
1488 fn test_nested_blockquote_with_tab() {
1489 let rule = MD007ULIndent::default();
1490 let content = ">\t>\t* List item\n>\t>\t * Nested\n";
1491 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1492 let result = rule.check(&ctx).unwrap();
1493 assert!(
1494 result.is_empty(),
1495 "Nested blockquotes with tabs should work correctly, got: {result:?}"
1496 );
1497 }
1498
1499 #[test]
1502 fn test_smart_style_pure_unordered_uses_fixed() {
1503 let rule = MD007ULIndent::new(4);
1505
1506 let content = "* Level 0\n * Level 1\n * Level 2";
1508 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1509 let result = rule.check(&ctx).unwrap();
1510 assert!(
1511 result.is_empty(),
1512 "Pure unordered with indent=4 should use fixed style (0, 4, 8), got: {result:?}"
1513 );
1514 }
1515
1516 #[test]
1517 fn test_smart_style_mixed_lists_uses_text_aligned() {
1518 let rule = MD007ULIndent::new(4);
1520
1521 let content = "1. Ordered\n * Bullet aligns with 'Ordered' text (3 spaces)";
1523 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1524 let result = rule.check(&ctx).unwrap();
1525 assert!(
1526 result.is_empty(),
1527 "Mixed lists should use text-aligned style, got: {result:?}"
1528 );
1529 }
1530
1531 #[test]
1532 fn test_smart_style_explicit_fixed_overrides() {
1533 let config = MD007Config {
1535 indent: crate::types::IndentSize::from_const(4),
1536 start_indented: false,
1537 start_indent: crate::types::IndentSize::from_const(2),
1538 style: md007_config::IndentStyle::Fixed,
1539 style_explicit: true, indent_explicit: false,
1541 };
1542 let rule = MD007ULIndent::from_config_struct(config);
1543
1544 let content = "1. Ordered\n * Should be at 4 spaces (fixed)";
1546 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1547 let result = rule.check(&ctx).unwrap();
1548 assert!(
1550 result.is_empty(),
1551 "Explicit fixed style should be respected, got: {result:?}"
1552 );
1553 }
1554
1555 #[test]
1556 fn test_smart_style_explicit_text_aligned_overrides() {
1557 let config = MD007Config {
1559 indent: crate::types::IndentSize::from_const(4),
1560 start_indented: false,
1561 start_indent: crate::types::IndentSize::from_const(2),
1562 style: md007_config::IndentStyle::TextAligned,
1563 style_explicit: true, indent_explicit: false,
1565 };
1566 let rule = MD007ULIndent::from_config_struct(config);
1567
1568 let content = "* Level 0\n * Level 1 (aligned with 'Level 0' text)";
1570 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1571 let result = rule.check(&ctx).unwrap();
1572 assert!(
1573 result.is_empty(),
1574 "Explicit text-aligned should be respected, got: {result:?}"
1575 );
1576
1577 let fixed_style_content = "* Level 0\n * Level 1 (4 spaces - fixed style)";
1579 let ctx = LintContext::new(fixed_style_content, crate::config::MarkdownFlavor::Standard, None);
1580 let result = rule.check(&ctx).unwrap();
1581 assert!(
1582 !result.is_empty(),
1583 "With explicit text-aligned, 4-space indent should be wrong (expected 2)"
1584 );
1585 }
1586
1587 #[test]
1588 fn test_smart_style_default_indent_no_autoswitch() {
1589 let rule = MD007ULIndent::new(2);
1591
1592 let content = "* Level 0\n * Level 1\n * Level 2";
1593 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1594 let result = rule.check(&ctx).unwrap();
1595 assert!(
1596 result.is_empty(),
1597 "Default indent should work regardless of style, got: {result:?}"
1598 );
1599 }
1600
1601 #[test]
1602 fn test_has_mixed_list_nesting_detection() {
1603 let content = "* Item 1\n * Item 2\n * Item 3";
1607 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1608 assert!(
1609 !ctx.has_mixed_list_nesting(),
1610 "Pure unordered should not be detected as mixed"
1611 );
1612
1613 let content = "1. Item 1\n 2. Item 2\n 3. Item 3";
1615 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1616 assert!(
1617 !ctx.has_mixed_list_nesting(),
1618 "Pure ordered should not be detected as mixed"
1619 );
1620
1621 let content = "1. Ordered\n * Unordered child";
1623 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1624 assert!(
1625 ctx.has_mixed_list_nesting(),
1626 "Unordered under ordered should be detected as mixed"
1627 );
1628
1629 let content = "* Unordered\n 1. Ordered child";
1631 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1632 assert!(
1633 ctx.has_mixed_list_nesting(),
1634 "Ordered under unordered should be detected as mixed"
1635 );
1636
1637 let content = "* Unordered\n\n1. Ordered (separate list)";
1639 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1640 assert!(
1641 !ctx.has_mixed_list_nesting(),
1642 "Separate lists should not be detected as mixed"
1643 );
1644
1645 let content = "> 1. Ordered in blockquote\n> * Unordered child";
1647 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1648 assert!(
1649 ctx.has_mixed_list_nesting(),
1650 "Mixed lists in blockquotes should be detected"
1651 );
1652 }
1653
1654 #[test]
1655 fn test_issue_210_exact_reproduction() {
1656 let config = MD007Config {
1658 indent: crate::types::IndentSize::from_const(4),
1659 start_indented: false,
1660 start_indent: crate::types::IndentSize::from_const(2),
1661 style: md007_config::IndentStyle::TextAligned, style_explicit: false, indent_explicit: false, };
1665 let rule = MD007ULIndent::from_config_struct(config);
1666
1667 let content = "# Title\n\n* some\n * list\n * items\n";
1668 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1669 let result = rule.check(&ctx).unwrap();
1670
1671 assert!(
1672 result.is_empty(),
1673 "Issue #210: indent=4 on pure unordered should work (auto-fixed style), got: {result:?}"
1674 );
1675 }
1676
1677 #[test]
1678 fn test_issue_209_still_fixed() {
1679 let config = MD007Config {
1682 indent: crate::types::IndentSize::from_const(3),
1683 start_indented: false,
1684 start_indent: crate::types::IndentSize::from_const(2),
1685 style: md007_config::IndentStyle::TextAligned,
1686 style_explicit: true, indent_explicit: false,
1688 };
1689 let rule = MD007ULIndent::from_config_struct(config);
1690
1691 let content = r#"# Header 1
1693
1694- **Second item**:
1695 - **This is a nested list**:
1696 1. **First point**
1697 - First subpoint
1698"#;
1699 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1700 let result = rule.check(&ctx).unwrap();
1701
1702 assert!(
1703 result.is_empty(),
1704 "Issue #209: With explicit text-aligned style, should have no issues, got: {result:?}"
1705 );
1706 }
1707
1708 #[test]
1711 fn test_multi_level_mixed_detection_grandparent() {
1712 let content = "1. Ordered grandparent\n * Unordered child\n * Unordered grandchild";
1716 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1717 assert!(
1718 ctx.has_mixed_list_nesting(),
1719 "Should detect mixed nesting when grandparent differs in type"
1720 );
1721
1722 let content = "* Unordered grandparent\n 1. Ordered child\n 2. Ordered grandchild";
1724 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1725 assert!(
1726 ctx.has_mixed_list_nesting(),
1727 "Should detect mixed nesting for ordered descendants under unordered"
1728 );
1729 }
1730
1731 #[test]
1732 fn test_html_comments_skipped_in_detection() {
1733 let content = r#"* Unordered list
1735<!-- This is a comment
1736 1. This ordered list is inside a comment
1737 * This nested bullet is also inside
1738-->
1739 * Another unordered item"#;
1740 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1741 assert!(
1742 !ctx.has_mixed_list_nesting(),
1743 "Lists in HTML comments should be ignored in mixed detection"
1744 );
1745 }
1746
1747 #[test]
1748 fn test_blank_lines_separate_lists() {
1749 let content = "* First unordered list\n\n1. Second list is ordered (separate)";
1751 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1752 assert!(
1753 !ctx.has_mixed_list_nesting(),
1754 "Blank line at root should separate lists"
1755 );
1756
1757 let content = "1. Ordered parent\n\n * Still a child due to indentation";
1759 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1760 assert!(
1761 ctx.has_mixed_list_nesting(),
1762 "Indented list after blank is still nested"
1763 );
1764 }
1765
1766 #[test]
1767 fn test_column_1_normalization() {
1768 let content = "* First item\n * Second item with 1 space (sibling)";
1771 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1772 let rule = MD007ULIndent::default();
1773 let result = rule.check(&ctx).unwrap();
1774 assert!(
1776 result.iter().any(|w| w.line == 2),
1777 "1-space indent should be flagged as incorrect"
1778 );
1779 }
1780
1781 #[test]
1782 fn test_code_blocks_skipped_in_detection() {
1783 let content = r#"* Unordered list
1785```
17861. This ordered list is inside a code block
1787 * This nested bullet is also inside
1788```
1789 * Another unordered item"#;
1790 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1791 assert!(
1792 !ctx.has_mixed_list_nesting(),
1793 "Lists in code blocks should be ignored in mixed detection"
1794 );
1795 }
1796
1797 #[test]
1798 fn test_front_matter_skipped_in_detection() {
1799 let content = r#"---
1801items:
1802 - yaml list item
1803 - another item
1804---
1805* Unordered list after front matter"#;
1806 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1807 assert!(
1808 !ctx.has_mixed_list_nesting(),
1809 "Lists in front matter should be ignored in mixed detection"
1810 );
1811 }
1812
1813 #[test]
1814 fn test_alternating_types_at_same_level() {
1815 let content = "* First bullet\n1. First number\n* Second bullet\n2. Second number";
1818 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1819 assert!(
1820 !ctx.has_mixed_list_nesting(),
1821 "Alternating types at same level should not be detected as mixed"
1822 );
1823 }
1824
1825 #[test]
1826 fn test_five_level_deep_mixed_nesting() {
1827 let content = "* L0\n 1. L1\n * L2\n 1. L3\n * L4\n 1. L5";
1829 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1830 assert!(ctx.has_mixed_list_nesting(), "Should detect mixed nesting at 5+ levels");
1831 }
1832
1833 #[test]
1834 fn test_very_deep_pure_unordered_nesting() {
1835 let mut content = String::from("* L1");
1837 for level in 2..=12 {
1838 let indent = " ".repeat(level - 1);
1839 content.push_str(&format!("\n{indent}* L{level}"));
1840 }
1841
1842 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1843
1844 assert!(
1846 !ctx.has_mixed_list_nesting(),
1847 "Pure unordered deep nesting should not be detected as mixed"
1848 );
1849
1850 let rule = MD007ULIndent::new(4);
1852 let result = rule.check(&ctx).unwrap();
1853 assert!(!result.is_empty(), "Should flag incorrect indentation for fixed style");
1856 }
1857
1858 #[test]
1859 fn test_interleaved_content_between_list_items() {
1860 let content = "1. Ordered parent\n\n Paragraph continuation\n\n * Unordered child";
1862 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1863 assert!(
1864 ctx.has_mixed_list_nesting(),
1865 "Should detect mixed nesting even with interleaved paragraphs"
1866 );
1867 }
1868
1869 #[test]
1870 fn test_esm_blocks_skipped_in_detection() {
1871 let content = "* Unordered list\n * Nested unordered";
1874 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1875 assert!(
1876 !ctx.has_mixed_list_nesting(),
1877 "Pure unordered should not be detected as mixed"
1878 );
1879 }
1880
1881 #[test]
1882 fn test_multiple_list_blocks_pure_then_mixed() {
1883 let content = r#"* Pure unordered
1886 * Nested unordered
1887
18881. Mixed section
1889 * Bullet under ordered"#;
1890 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1891 assert!(
1892 ctx.has_mixed_list_nesting(),
1893 "Should detect mixed nesting in any part of document"
1894 );
1895 }
1896
1897 #[test]
1898 fn test_multiple_separate_pure_lists() {
1899 let content = r#"* First list
1902 * Nested
1903
1904* Second list
1905 * Also nested
1906
1907* Third list
1908 * Deeply
1909 * Nested"#;
1910 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1911 assert!(
1912 !ctx.has_mixed_list_nesting(),
1913 "Multiple separate pure unordered lists should not be mixed"
1914 );
1915 }
1916
1917 #[test]
1918 fn test_code_block_between_list_items() {
1919 let content = r#"1. Ordered
1921 ```
1922 code
1923 ```
1924 * Still a mixed child"#;
1925 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1926 assert!(
1927 ctx.has_mixed_list_nesting(),
1928 "Code block between items should not prevent mixed detection"
1929 );
1930 }
1931
1932 #[test]
1933 fn test_blockquoted_mixed_detection() {
1934 let content = "> 1. Ordered in blockquote\n> * Mixed child";
1936 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1937 assert!(
1940 ctx.has_mixed_list_nesting(),
1941 "Should detect mixed nesting in blockquotes"
1942 );
1943 }
1944
1945 #[test]
1948 fn test_indent_explicit_uses_fixed_style() {
1949 let config = MD007Config {
1952 indent: crate::types::IndentSize::from_const(4),
1953 start_indented: false,
1954 start_indent: crate::types::IndentSize::from_const(2),
1955 style: md007_config::IndentStyle::TextAligned, style_explicit: false, indent_explicit: true, };
1959 let rule = MD007ULIndent::from_config_struct(config);
1960
1961 let content = "* Level 0\n * Level 1\n * Level 2";
1964 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1965 let result = rule.check(&ctx).unwrap();
1966 assert!(
1967 result.is_empty(),
1968 "With indent_explicit=true, should use fixed style (0, 4, 8), got: {result:?}"
1969 );
1970
1971 let wrong_content = "* Level 0\n * Level 1\n * Level 2";
1973 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
1974 let result = rule.check(&ctx).unwrap();
1975 assert!(
1976 !result.is_empty(),
1977 "Should flag text-aligned spacing when indent_explicit=true"
1978 );
1979 }
1980
1981 #[test]
1982 fn test_explicit_style_overrides_indent_explicit() {
1983 let config = MD007Config {
1986 indent: crate::types::IndentSize::from_const(4),
1987 start_indented: false,
1988 start_indent: crate::types::IndentSize::from_const(2),
1989 style: md007_config::IndentStyle::TextAligned,
1990 style_explicit: true, indent_explicit: true, };
1993 let rule = MD007ULIndent::from_config_struct(config);
1994
1995 let content = "* Level 0\n * Level 1\n * Level 2";
1997 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1998 let result = rule.check(&ctx).unwrap();
1999 assert!(
2000 result.is_empty(),
2001 "Explicit text-aligned style should be respected, got: {result:?}"
2002 );
2003 }
2004
2005 #[test]
2006 fn test_no_indent_explicit_uses_smart_detection() {
2007 let config = MD007Config {
2009 indent: crate::types::IndentSize::from_const(4),
2010 start_indented: false,
2011 start_indent: crate::types::IndentSize::from_const(2),
2012 style: md007_config::IndentStyle::TextAligned,
2013 style_explicit: false,
2014 indent_explicit: false, };
2016 let rule = MD007ULIndent::from_config_struct(config);
2017
2018 let content = "* Level 0\n * Level 1";
2021 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2022 let result = rule.check(&ctx).unwrap();
2023 assert!(
2025 result.is_empty(),
2026 "Smart detection should accept 4-space indent, got: {result:?}"
2027 );
2028 }
2029
2030 #[test]
2031 fn test_issue_273_exact_reproduction() {
2032 let config = MD007Config {
2035 indent: crate::types::IndentSize::from_const(4),
2036 start_indented: false,
2037 start_indent: crate::types::IndentSize::from_const(2),
2038 style: md007_config::IndentStyle::TextAligned, style_explicit: false,
2040 indent_explicit: true, };
2042 let rule = MD007ULIndent::from_config_struct(config);
2043
2044 let content = r#"* Item 1
2045 * Item 2
2046 * Item 3"#;
2047 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2048 let result = rule.check(&ctx).unwrap();
2049 assert!(
2050 result.is_empty(),
2051 "Issue #273: indent=4 should use 4-space increments, got: {result:?}"
2052 );
2053 }
2054
2055 #[test]
2056 fn test_indent_explicit_with_ordered_parent() {
2057 let config = MD007Config {
2061 indent: crate::types::IndentSize::from_const(4),
2062 start_indented: false,
2063 start_indent: crate::types::IndentSize::from_const(2),
2064 style: md007_config::IndentStyle::TextAligned,
2065 style_explicit: false,
2066 indent_explicit: true, };
2068 let rule = MD007ULIndent::from_config_struct(config);
2069
2070 let content = "1. Ordered\n * Bullet with 4-space indent";
2072 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2073 let result = rule.check(&ctx).unwrap();
2074 assert!(
2075 result.is_empty(),
2076 "4-space indent under ordered should pass with indent=4: {result:?}"
2077 );
2078
2079 let content_3 = "1. Ordered\n * Bullet with 3-space indent";
2081 let ctx = LintContext::new(content_3, crate::config::MarkdownFlavor::Standard, None);
2082 let result = rule.check(&ctx).unwrap();
2083 assert!(
2084 result.is_empty(),
2085 "3-space indent under ordered should pass (text-aligned): {result:?}"
2086 );
2087
2088 let wrong_content = "1. Ordered\n * Bullet with 2-space indent";
2090 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
2091 let result = rule.check(&ctx).unwrap();
2092 assert!(
2093 !result.is_empty(),
2094 "2-space indent under ordered list should be flagged when indent=4: {result:?}"
2095 );
2096 }
2097
2098 #[test]
2099 fn test_indent_explicit_mixed_list_deep_nesting() {
2100 let config = MD007Config {
2105 indent: crate::types::IndentSize::from_const(4),
2106 start_indented: false,
2107 start_indent: crate::types::IndentSize::from_const(2),
2108 style: md007_config::IndentStyle::TextAligned,
2109 style_explicit: false,
2110 indent_explicit: true,
2111 };
2112 let rule = MD007ULIndent::from_config_struct(config);
2113
2114 let content_text_aligned = r#"* Level 0
2120 * Level 1 (4-space indent from bullet parent)
2121 1. Level 2 ordered
2122 * Level 3 bullet (text-aligned under ordered)"#;
2123 let ctx = LintContext::new(content_text_aligned, crate::config::MarkdownFlavor::Standard, None);
2124 let result = rule.check(&ctx).unwrap();
2125 assert!(
2126 result.is_empty(),
2127 "Text-aligned nesting under ordered should pass: {result:?}"
2128 );
2129
2130 let content_fixed = r#"* Level 0
2131 * Level 1 (4-space indent from bullet parent)
2132 1. Level 2 ordered
2133 * Level 3 bullet (fixed indent under ordered)"#;
2134 let ctx = LintContext::new(content_fixed, crate::config::MarkdownFlavor::Standard, None);
2135 let result = rule.check(&ctx).unwrap();
2136 assert!(
2137 result.is_empty(),
2138 "Fixed indent nesting under ordered should also pass: {result:?}"
2139 );
2140 }
2141
2142 #[test]
2143 fn test_ordered_list_double_digit_markers() {
2144 let config = MD007Config {
2147 indent: crate::types::IndentSize::from_const(4),
2148 start_indented: false,
2149 start_indent: crate::types::IndentSize::from_const(2),
2150 style: md007_config::IndentStyle::TextAligned,
2151 style_explicit: false,
2152 indent_explicit: true,
2153 };
2154 let rule = MD007ULIndent::from_config_struct(config);
2155
2156 let content = "10. Double digit\n * Bullet at col 4";
2158 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2159 let result = rule.check(&ctx).unwrap();
2160 assert!(
2161 result.is_empty(),
2162 "Bullet under '10.' should align at column 4: {result:?}"
2163 );
2164
2165 let content_3 = "1. Single digit\n * Bullet at col 3";
2168 let ctx = LintContext::new(content_3, crate::config::MarkdownFlavor::Standard, None);
2169 let result = rule.check(&ctx).unwrap();
2170 assert!(
2171 result.is_empty(),
2172 "Bullet under '1.' with 3-space indent should pass (text-aligned): {result:?}"
2173 );
2174
2175 let content_4 = "1. Single digit\n * Bullet at col 4";
2176 let ctx = LintContext::new(content_4, crate::config::MarkdownFlavor::Standard, None);
2177 let result = rule.check(&ctx).unwrap();
2178 assert!(
2179 result.is_empty(),
2180 "Bullet under '1.' with 4-space indent should pass (fixed): {result:?}"
2181 );
2182 }
2183
2184 #[test]
2185 fn test_indent_explicit_pure_unordered_uses_fixed() {
2186 let config = MD007Config {
2189 indent: crate::types::IndentSize::from_const(4),
2190 start_indented: false,
2191 start_indent: crate::types::IndentSize::from_const(2),
2192 style: md007_config::IndentStyle::TextAligned,
2193 style_explicit: false,
2194 indent_explicit: true,
2195 };
2196 let rule = MD007ULIndent::from_config_struct(config);
2197
2198 let content = "* Level 0\n * Level 1\n * Level 2";
2200 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2201 let result = rule.check(&ctx).unwrap();
2202 assert!(
2203 result.is_empty(),
2204 "Pure unordered with indent=4 should use 4-space increments: {result:?}"
2205 );
2206
2207 let wrong_content = "* Level 0\n * Level 1\n * Level 2";
2209 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
2210 let result = rule.check(&ctx).unwrap();
2211 assert!(
2212 !result.is_empty(),
2213 "2-space indent should be flagged when indent=4 is configured"
2214 );
2215 }
2216
2217 #[test]
2218 fn test_mkdocs_ordered_list_with_4_space_nested_unordered() {
2219 let rule = MD007ULIndent::default();
2223 let content = "1. text\n\n - nested item";
2224 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2225 let result = rule.check(&ctx).unwrap();
2226 assert!(
2227 result.is_empty(),
2228 "4-space indent under ordered list should be valid in MkDocs flavor, got: {result:?}"
2229 );
2230 }
2231
2232 #[test]
2233 fn test_standard_flavor_ordered_list_with_3_space_nested_unordered() {
2234 let rule = MD007ULIndent::default();
2237 let content = "1. text\n\n - nested item";
2238 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2239 let result = rule.check(&ctx).unwrap();
2240 assert!(
2241 result.is_empty(),
2242 "3-space indent under ordered list should be valid in Standard flavor, got: {result:?}"
2243 );
2244 }
2245
2246 #[test]
2247 fn test_standard_flavor_ordered_list_under_ordered_is_exempt() {
2248 let rule = MD007ULIndent::default();
2253 let content = "1. text\n\n - nested item";
2254 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2255 let result = rule.check(&ctx).unwrap();
2256 assert!(
2257 result.is_empty(),
2258 "unordered sublist of an ordered list must be exempt in Standard flavor, got: {result:?}"
2259 );
2260 }
2261
2262 #[test]
2263 fn test_mkdocs_multi_digit_ordered_list() {
2264 let rule = MD007ULIndent::default();
2267 let content = "10. text\n\n - nested item";
2268 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2269 let result = rule.check(&ctx).unwrap();
2270 assert!(
2271 result.is_empty(),
2272 "4-space indent under `10.` should be valid in MkDocs flavor, got: {result:?}"
2273 );
2274 }
2275
2276 #[test]
2277 fn test_mkdocs_triple_digit_ordered_list() {
2278 let rule = MD007ULIndent::default();
2281 let content = "100. text\n\n - nested item";
2282 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2283 let result = rule.check(&ctx).unwrap();
2284 assert!(
2285 result.is_empty(),
2286 "5-space indent under `100.` should be valid in MkDocs flavor, got: {result:?}"
2287 );
2288 }
2289
2290 #[test]
2291 fn test_mkdocs_insufficient_indent_under_ordered() {
2292 let rule = MD007ULIndent::default();
2295 let content = "1. text\n\n - nested item";
2296 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2297 let result = rule.check(&ctx).unwrap();
2298 assert_eq!(
2299 result.len(),
2300 1,
2301 "2-space indent under ordered list should warn in MkDocs flavor"
2302 );
2303 assert!(
2304 result[0].message.contains("Expected 4"),
2305 "Warning should expect 4 spaces (MkDocs minimum), got: {}",
2306 result[0].message
2307 );
2308 }
2309
2310 #[test]
2311 fn test_mkdocs_deeper_nesting_under_ordered() {
2312 let rule = MD007ULIndent::default();
2317 let content = "1. text\n\n - sub\n - subsub";
2318 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2319 let result = rule.check(&ctx).unwrap();
2320 assert!(
2321 result.is_empty(),
2322 "Deeper nesting under ordered list should be valid in MkDocs flavor, got: {result:?}"
2323 );
2324 }
2325
2326 #[test]
2327 fn test_mkdocs_fix_adjusts_to_4_spaces() {
2328 let rule = MD007ULIndent::default();
2330 let content = "1. text\n\n - nested item";
2331 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2332 let result = rule.check(&ctx).unwrap();
2333 assert_eq!(result.len(), 1, "3-space indent should warn in MkDocs");
2334 let fixed = rule.fix(&ctx).unwrap();
2335 assert_eq!(
2336 fixed, "1. text\n\n - nested item",
2337 "Fix should adjust indent to 4 spaces in MkDocs"
2338 );
2339 }
2340
2341 #[test]
2342 fn test_mkdocs_start_indented_with_ordered_parent() {
2343 let config = MD007Config {
2346 start_indented: true,
2347 ..Default::default()
2348 };
2349 let rule = MD007ULIndent::from_config_struct(config);
2350 let content = "1. text\n\n - nested item";
2351 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2352 let result = rule.check(&ctx).unwrap();
2353 assert!(
2354 result.is_empty(),
2355 "4-space indent under ordered list with start_indented should be valid in MkDocs, got: {result:?}"
2356 );
2357 }
2358
2359 #[test]
2360 fn test_mkdocs_ordered_at_nonzero_indent() {
2361 let rule = MD007ULIndent::default();
2366 let content = "- outer\n 1. inner\n - deep";
2367 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2368 let result = rule.check(&ctx).unwrap();
2369 assert!(
2370 result.is_empty(),
2371 "6-space indent under nested ordered list should be valid in MkDocs, got: {result:?}"
2372 );
2373 }
2374
2375 #[test]
2376 fn test_mkdocs_blockquoted_ordered_list() {
2377 let rule = MD007ULIndent::default();
2381 let content = "> 1. text\n>\n> - nested item";
2382 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2383 let result = rule.check(&ctx).unwrap();
2384 assert!(
2385 result.is_empty(),
2386 "4-space indent under blockquoted ordered list should be valid in MkDocs, got: {result:?}"
2387 );
2388 }
2389
2390 #[test]
2391 fn test_mkdocs_ordered_at_nonzero_indent_insufficient() {
2392 let rule = MD007ULIndent::default();
2395 let content = "- outer\n 1. inner\n - deep";
2396 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2397 let result = rule.check(&ctx).unwrap();
2398 assert_eq!(
2399 result.len(),
2400 1,
2401 "5-space indent under nested ordered at col 2 should warn in MkDocs (needs 6)"
2402 );
2403 }
2404
2405 #[test]
2406 fn test_issue_504_indent4_ordered_parent() {
2407 let config = MD007Config {
2411 indent: crate::types::IndentSize::from_const(4),
2412 start_indented: false,
2413 start_indent: crate::types::IndentSize::from_const(2),
2414 style: md007_config::IndentStyle::TextAligned,
2415 style_explicit: false,
2416 indent_explicit: true,
2417 };
2418 let rule = MD007ULIndent::from_config_struct(config);
2419
2420 let content = r#"# Things
2421
2422+ An unordered list
2423 + An item with 4 spaces, ok.
2424
24251. A numbered list
2426 + A sublist with 4 spaces, not ok
2427 + A sub item with 4 spaces, ok
2428 + Why is rumdl expecting 3 spaces for a 4 space indent?
24292. Item 2
24303. Item 3"#;
2431 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2432 let result = rule.check(&ctx).unwrap();
2433 assert!(
2434 result.is_empty(),
2435 "Issue #504: indent=4 with ordered parent should accept 4-space indent: {result:?}"
2436 );
2437 }
2438
2439 #[test]
2440 fn test_indent2_explicit_with_ordered_parent() {
2441 let config = MD007Config {
2444 indent: crate::types::IndentSize::from_const(2),
2445 start_indented: false,
2446 start_indent: crate::types::IndentSize::from_const(2),
2447 style: md007_config::IndentStyle::TextAligned,
2448 style_explicit: false,
2449 indent_explicit: true,
2450 };
2451 let rule = MD007ULIndent::from_config_struct(config);
2452
2453 let content = "1. Ordered\n * Bullet at 3 spaces";
2455 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2456 let result = rule.check(&ctx).unwrap();
2457 assert!(
2458 result.is_empty(),
2459 "indent=2 under '1.' should accept text-aligned (3 spaces): {result:?}"
2460 );
2461
2462 let content_2 = "1. Ordered\n * Bullet at 2 spaces";
2464 let ctx = LintContext::new(content_2, crate::config::MarkdownFlavor::Standard, None);
2465 let result = rule.check(&ctx).unwrap();
2466 assert!(
2467 result.is_empty(),
2468 "indent=2 under '1.' should accept fixed indent (2 spaces): {result:?}"
2469 );
2470 }
2471
2472 const ISSUE_638_INPUT: &str = "# Title\n\n1. Some text\n - Indented text\n - more indented\n";
2476
2477 #[test]
2478 fn test_issue_638_unordered_under_ordered_smart_default() {
2479 let rule = MD007ULIndent::new(2);
2480 let ctx = LintContext::new(ISSUE_638_INPUT, crate::config::MarkdownFlavor::Standard, None);
2481 let result = rule.check(&ctx).unwrap();
2482 assert!(
2483 result.is_empty(),
2484 "smart default: unordered items under an ordered list must not be flagged, got: {result:?}"
2485 );
2486 }
2487
2488 #[test]
2489 fn test_issue_638_unordered_under_ordered_indent_explicit() {
2490 let config = MD007Config {
2491 indent: crate::types::IndentSize::from_const(2),
2492 start_indented: false,
2493 start_indent: crate::types::IndentSize::from_const(2),
2494 style: md007_config::IndentStyle::TextAligned,
2495 style_explicit: false,
2496 indent_explicit: true,
2497 };
2498 let rule = MD007ULIndent::from_config_struct(config);
2499 let ctx = LintContext::new(ISSUE_638_INPUT, crate::config::MarkdownFlavor::Standard, None);
2500 let result = rule.check(&ctx).unwrap();
2501 assert!(
2502 result.is_empty(),
2503 "indent=2 explicit: unordered items under an ordered list must not be flagged, got: {result:?}"
2504 );
2505 }
2506
2507 #[test]
2508 fn test_issue_638_unordered_under_ordered_style_fixed() {
2509 let config = MD007Config {
2511 indent: crate::types::IndentSize::from_const(2),
2512 start_indented: false,
2513 start_indent: crate::types::IndentSize::from_const(2),
2514 style: md007_config::IndentStyle::Fixed,
2515 style_explicit: true,
2516 indent_explicit: true,
2517 };
2518 let rule = MD007ULIndent::from_config_struct(config);
2519 let ctx = LintContext::new(ISSUE_638_INPUT, crate::config::MarkdownFlavor::Standard, None);
2520 let result = rule.check(&ctx).unwrap();
2521 assert!(
2522 result.is_empty(),
2523 "style=fixed: unordered items under an ordered list must not be flagged, got: {result:?}"
2524 );
2525 }
2526
2527 fn fixed_style_rule(indent: u8) -> MD007ULIndent {
2534 MD007ULIndent::from_config_struct(MD007Config {
2535 indent: crate::types::IndentSize::from_const(indent),
2536 start_indented: false,
2537 start_indent: crate::types::IndentSize::from_const(2),
2538 style: md007_config::IndentStyle::Fixed,
2539 style_explicit: true,
2540 indent_explicit: true,
2541 })
2542 }
2543
2544 #[test]
2545 fn test_fixed_style_clamp_flags_over_indented_bullet_under_ordered() {
2546 let rule = fixed_style_rule(2);
2547 let content = "1. Some text\n - four spaces\n";
2548 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2549 let result = rule.check(&ctx).unwrap();
2550 assert_eq!(
2551 result.len(),
2552 1,
2553 "a bullet at 4 under a content column of 3 is flagged: {result:?}"
2554 );
2555 assert!(
2556 result[0].message.contains("Expected 3") && result[0].message.contains("found 4"),
2557 "clamped expectation is the parent content column, got: {}",
2558 result[0].message
2559 );
2560 let fixed = rule.fix(&ctx).unwrap();
2561 assert_eq!(fixed, "1. Some text\n - four spaces\n");
2562 }
2563
2564 #[test]
2565 fn test_fixed_style_clamp_accepts_bullet_at_parent_content_column() {
2566 let rule = fixed_style_rule(2);
2567 let content = "1. Some text\n - three spaces\n";
2568 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2569 let result = rule.check(&ctx).unwrap();
2570 assert!(result.is_empty(), "the clamped expectation itself passes: {result:?}");
2571 }
2572
2573 #[test]
2574 fn test_fixed_style_clamp_pulls_five_spaces_to_content_column() {
2575 let rule = fixed_style_rule(2);
2576 let content = "1. Some text\n - five spaces\n";
2577 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2578 let result = rule.check(&ctx).unwrap();
2579 assert_eq!(result.len(), 1, "{result:?}");
2580 let fixed = rule.fix(&ctx).unwrap();
2581 assert_eq!(fixed, "1. Some text\n - five spaces\n");
2582 }
2583
2584 #[test]
2585 fn test_fixed_style_clamp_cascades_through_nested_bullets() {
2586 let rule = fixed_style_rule(2);
2590 let content = "1. Ordered\n - child\n - grandchild\n";
2591 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2592 let result = rule.check(&ctx).unwrap();
2593 assert_eq!(result.len(), 1, "only the grandchild is off: {result:?}");
2594 assert!(
2595 result[0].message.contains("Expected 5") && result[0].message.contains("found 6"),
2596 "got: {}",
2597 result[0].message
2598 );
2599 let fixed = rule.fix(&ctx).unwrap();
2600 assert_eq!(fixed, "1. Ordered\n - child\n - grandchild\n");
2601 let refixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
2602 assert!(rule.check(&refixed_ctx).unwrap().is_empty(), "fix is stable");
2603 }
2604
2605 #[test]
2606 fn test_fixed_style_clamp_respects_wider_fixed_indent() {
2607 let rule = fixed_style_rule(4);
2610 let content = "1. Some text\n - three spaces\n";
2611 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2612 let result = rule.check(&ctx).unwrap();
2613 assert_eq!(result.len(), 1, "{result:?}");
2614 assert!(
2615 result[0].message.contains("Expected 4") && result[0].message.contains("found 3"),
2616 "got: {}",
2617 result[0].message
2618 );
2619 let fixed = rule.fix(&ctx).unwrap();
2620 assert_eq!(fixed, "1. Some text\n - three spaces\n");
2621 }
2622
2623 #[test]
2624 fn test_fixed_style_clamp_uses_measured_content_column_of_wide_marker() {
2625 let rule = fixed_style_rule(2);
2628 let content = "1. Some text\n - five spaces\n";
2629 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2630 let result = rule.check(&ctx).unwrap();
2631 assert_eq!(result.len(), 1, "{result:?}");
2632 assert!(
2633 result[0].message.contains("Expected 4") && result[0].message.contains("found 5"),
2634 "got: {}",
2635 result[0].message
2636 );
2637 let fixed = rule.fix(&ctx).unwrap();
2638 assert_eq!(fixed, "1. Some text\n - five spaces\n");
2639
2640 let ok = "1. Some text\n - four spaces\n";
2641 let ok_ctx = LintContext::new(ok, crate::config::MarkdownFlavor::Standard, None);
2642 assert!(rule.check(&ok_ctx).unwrap().is_empty());
2643 }
2644
2645 #[test]
2646 fn test_fixed_style_clamp_in_blockquote() {
2647 let rule = fixed_style_rule(2);
2648 let content = "> 1. Some text\n> - four spaces\n";
2649 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2650 let result = rule.check(&ctx).unwrap();
2651 assert_eq!(result.len(), 1, "{result:?}");
2652 assert!(
2653 result[0].message.contains("Expected 3") && result[0].message.contains("found 4"),
2654 "got: {}",
2655 result[0].message
2656 );
2657 let fixed = rule.fix(&ctx).unwrap();
2658 assert_eq!(fixed, "> 1. Some text\n> - four spaces\n");
2659 }
2660
2661 #[test]
2662 fn test_fixed_style_clamp_treats_near_sibling_as_sibling() {
2663 let rule = fixed_style_rule(2);
2669 let content = "1. x\n - a\n - b\n";
2670 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2671 let result = rule.check(&ctx).unwrap();
2672 assert_eq!(result.len(), 1, "{result:?}");
2673 assert!(
2674 result[0].message.contains("Expected 3") && result[0].message.contains("found 4"),
2675 "near-sibling resolves against the ordered parent, got: {}",
2676 result[0].message
2677 );
2678 let fixed = rule.fix(&ctx).unwrap();
2679 assert_eq!(fixed, "1. x\n - a\n - b\n");
2680 }
2681
2682 #[test]
2683 fn test_fixed_style_clamp_child_after_near_sibling_resolves_against_it() {
2684 let rule = fixed_style_rule(2);
2688 let content = "1. x\n - a\n - b\n - c\n";
2689 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2690 let result = rule.check(&ctx).unwrap();
2691 assert_eq!(result.len(), 2, "b and c are both off: {result:?}");
2692 assert!(
2693 result[0].message.contains("Expected 3") && result[0].message.contains("found 4"),
2694 "got: {}",
2695 result[0].message
2696 );
2697 assert!(
2698 result[1].message.contains("Expected 5") && result[1].message.contains("found 6"),
2699 "got: {}",
2700 result[1].message
2701 );
2702 let fixed = rule.fix(&ctx).unwrap();
2703 assert_eq!(fixed, "1. x\n - a\n - b\n - c\n");
2704 }
2705
2706 #[test]
2707 fn test_fixed_style_clamp_pops_near_sibling_of_over_indented_bullet() {
2708 let rule = fixed_style_rule(2);
2713 let content = "1. x\n - a\n - b\n";
2714 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2715 let result = rule.check(&ctx).unwrap();
2716 assert_eq!(result.len(), 2, "a and b are both flagged: {result:?}");
2717 assert!(
2718 result[1].message.contains("Expected 3") && result[1].message.contains("found 5"),
2719 "b resolves against the ordered parent, got: {}",
2720 result[1].message
2721 );
2722 let fixed = rule.fix(&ctx).unwrap();
2723 assert_eq!(fixed, "1. x\n - a\n - b\n");
2724 }
2725
2726 #[test]
2727 fn test_fixed_style_clamp_keeps_child_of_over_indented_bullet() {
2728 let rule = fixed_style_rule(2);
2731 let content = "1. x\n - a\n - c\n";
2732 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2733 let result = rule.check(&ctx).unwrap();
2734 assert_eq!(result.len(), 2, "a and c are both flagged: {result:?}");
2735 assert!(
2736 result[1].message.contains("Expected 5") && result[1].message.contains("found 7"),
2737 "c's floor is a's corrected content column, got: {}",
2738 result[1].message
2739 );
2740 let fixed = rule.fix(&ctx).unwrap();
2741 assert_eq!(fixed, "1. x\n - a\n - c\n");
2742 }
2743
2744 #[test]
2745 fn test_fixed_style_clamp_pops_ordered_near_sibling() {
2746 let rule = fixed_style_rule(2);
2752 let content = "1. root\n - a\n 1. sub\n - b\n";
2753 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2754 let result = rule.check(&ctx).unwrap();
2755 assert_eq!(result.len(), 1, "only b is off: {result:?}");
2756 assert!(
2757 result[0].message.contains("Expected 5") && result[0].message.contains("found 6"),
2758 "b resolves against a, not the nested ordered sibling, got: {}",
2759 result[0].message
2760 );
2761 let fixed = rule.fix(&ctx).unwrap();
2762 assert_eq!(fixed, "1. root\n - a\n 1. sub\n - b\n");
2763 }
2764
2765 #[test]
2766 fn test_fixed_style_clamp_leaves_sibling_bullet_left_of_content_column() {
2767 let rule = fixed_style_rule(2);
2771 let content = "1. Some text\n - two spaces\n";
2772 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2773 let result = rule.check(&ctx).unwrap();
2774 assert!(
2775 result.is_empty(),
2776 "sibling bullet at the fixed indent stays silent: {result:?}"
2777 );
2778 }
2779
2780 #[test]
2781 fn test_fixed_style_clamp_requires_explicit_style() {
2782 let config = MD007Config {
2785 indent: crate::types::IndentSize::from_const(2),
2786 start_indented: false,
2787 start_indent: crate::types::IndentSize::from_const(2),
2788 style: md007_config::IndentStyle::TextAligned,
2789 style_explicit: false,
2790 indent_explicit: true,
2791 };
2792 let rule = MD007ULIndent::from_config_struct(config);
2793 let content = "1. Some text\n - four spaces\n";
2794 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2795 let result = rule.check(&ctx).unwrap();
2796 assert!(result.is_empty(), "no explicit style, exemption stays: {result:?}");
2797
2798 let smart = MD007ULIndent::new(2);
2799 assert!(
2800 smart.check(&ctx).unwrap().is_empty(),
2801 "smart default keeps the exemption too"
2802 );
2803 }
2804
2805 #[test]
2806 fn test_issue_638_deeper_unordered_chain_under_ordered() {
2807 let rule = MD007ULIndent::new(2);
2809 let content = "1. Ordered\n - child\n - grandchild\n - great-grandchild\n";
2810 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2811 let result = rule.check(&ctx).unwrap();
2812 assert!(
2813 result.is_empty(),
2814 "all unordered descendants of an ordered list are exempt, got: {result:?}"
2815 );
2816 }
2817
2818 #[test]
2819 fn test_issue_638_pure_unordered_still_checked() {
2820 let rule = MD007ULIndent::new(2);
2822 let content = "- Top\n - three spaces (wrong, expected 2)\n";
2823 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2824 let result = rule.check(&ctx).unwrap();
2825 assert_eq!(
2826 result.len(),
2827 1,
2828 "pure unordered nesting must still be checked, got: {result:?}"
2829 );
2830 }
2831
2832 #[test]
2833 fn test_issue_638_exemption_not_applied_after_list_terminated_by_paragraph() {
2834 let rule = MD007ULIndent::new(2);
2841 let content = "1. ordered\n\nparagraph\n\n - parent\n - child six\n";
2842 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2843 let result = rule.check(&ctx).unwrap();
2844 assert_eq!(
2845 result.len(),
2846 2,
2847 "the new top-level list following a terminated ordered list is checked at both levels, got: {result:?}"
2848 );
2849 assert!(
2850 result.iter().any(|w| w.line == 5 && w.message.contains("Expected 0")),
2851 "the misindented top-level item must be flagged with Expected 0, got: {result:?}"
2852 );
2853 assert!(
2854 result
2855 .iter()
2856 .any(|w| w.line == 6 && w.message.contains("Expected 2") && w.message.contains("found 6")),
2857 "the misindented child must be flagged with Expected 2, found 6, got: {result:?}"
2858 );
2859 }
2860
2861 #[test]
2862 fn test_issue_638_lazy_continuation_does_not_terminate_ordered_list() {
2863 let rule = MD007ULIndent::new(2);
2869 let content = "1. ordered\nlazy continuation\n - child\n - grandchild\n";
2870 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2871 let result = rule.check(&ctx).unwrap();
2872 assert!(
2873 result.is_empty(),
2874 "lazy continuation must not terminate the ordered list; sublist stays exempt, got: {result:?}"
2875 );
2876 }
2877
2878 #[test]
2879 fn test_issue_638_heading_interrupts_ordered_list_without_blank() {
2880 let rule = MD007ULIndent::new(2);
2887 let content = "1. ordered\n# heading\n - child\n - grandchild\n";
2888 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2889 let result = rule.check(&ctx).unwrap();
2890 assert_eq!(
2891 result.len(),
2892 2,
2893 "a heading terminates the ordered list, so the new top-level list and its child are both checked, got: {result:?}"
2894 );
2895 assert!(
2896 result.iter().any(|w| w.line == 3 && w.message.contains("Expected 0")),
2897 "the misindented top-level item must be flagged with Expected 0, got: {result:?}"
2898 );
2899 assert!(
2900 result.iter().any(|w| w.line == 4 && w.message.contains("Expected 2")),
2901 "the misindented child must be flagged with Expected 2, got: {result:?}"
2902 );
2903 }
2904
2905 #[test]
2906 fn test_issue_638_lazy_continuation_inside_blockquote_keeps_exemption() {
2907 let rule = MD007ULIndent::new(2);
2912 let content = "> 1. ordered\n> continuation\n>\n> - child\n> - grandchild\n";
2913 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2914 let result = rule.check(&ctx).unwrap();
2915 assert!(
2916 result.is_empty(),
2917 "a lazy continuation within the same blockquote must keep the sublist exempt, got: {result:?}"
2918 );
2919 }
2920
2921 #[test]
2922 fn test_issue_638_indented_fence_inside_blockquoted_ordered_item_keeps_exemption() {
2923 let rule = MD007ULIndent::new(2);
2928 let content = "> 1. ordered\n> ```\n> code\n> ```\n> - child\n> - grandchild\n";
2929 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2930 let result = rule.check(&ctx).unwrap();
2931 assert!(
2932 result.is_empty(),
2933 "an indented fence inside a blockquoted ordered item must keep the sublist exempt, got: {result:?}"
2934 );
2935 }
2936
2937 #[test]
2938 fn test_issue_638_fenced_code_block_terminates_ordered_list() {
2939 let rule = MD007ULIndent::new(2);
2945 let content = "1. ordered\n```\ncode\n```\n\n - parent\n - child\n";
2946 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2947 let result = rule.check(&ctx).unwrap();
2948 assert!(
2949 result.iter().any(|w| w.line == 7),
2950 "a top-level fenced code block terminates the ordered list; the child must be flagged, got: {result:?}"
2951 );
2952 }
2953
2954 #[test]
2955 fn test_issue_638_fenced_code_block_inside_item_keeps_exemption() {
2956 let rule = MD007ULIndent::new(2);
2961 let content = "1. ordered\n ```\n code\n ```\n - child\n - grandchild\n";
2962 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2963 let result = rule.check(&ctx).unwrap();
2964 assert!(
2965 result.is_empty(),
2966 "a fenced code block nested inside the item must keep the sublist exempt, got: {result:?}"
2967 );
2968 }
2969
2970 #[test]
2971 fn test_issue_638_blockquote_terminates_ordered_list() {
2972 let rule = MD007ULIndent::new(2);
2979 let content = "1. ordered\n> quote\n\n - parent\n - child\n";
2980 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2981 let result = rule.check(&ctx).unwrap();
2982 assert!(
2983 result.iter().any(|w| w.line == 5),
2984 "blockquote terminates the ordered list, so the child must still be flagged, got: {result:?}"
2985 );
2986 }
2987
2988 #[test]
2989 fn test_issue_638_blockquote_inside_item_keeps_exemption() {
2990 let rule = MD007ULIndent::new(2);
2995 let content = "1. ordered\n > quote inside item\n - child\n - grandchild\n";
2996 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2997 let result = rule.check(&ctx).unwrap();
2998 assert!(
2999 result.is_empty(),
3000 "a blockquote nested inside the item must keep the sublist exempt, got: {result:?}"
3001 );
3002 }
3003
3004 #[test]
3005 fn test_issue_638_exemption_requires_genuine_nesting_under_ordered() {
3006 let rule = MD007ULIndent::new(2);
3015 let content = "100. ordered\n - parent\n - child\n";
3016 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3017 let result = rule.check(&ctx).unwrap();
3018 assert!(
3019 result.iter().any(|w| w.line == 3),
3020 "the child of a non-nested bullet must still be checked, not exempted; got: {result:?}"
3021 );
3022 }
3023
3024 #[test]
3025 fn test_issue_638_paragraph_after_fenced_code_closes_ordered_list() {
3026 let rule = MD007ULIndent::new(2);
3035 let content = "1. ordered\n ```\n code\n ```\nnot lazy text\n - parent\n - child\n";
3036 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3037 let result = rule.check(&ctx).unwrap();
3038 assert!(
3039 result.iter().any(|w| w.line == 7),
3040 "fenced code is not paragraph text, so the list closes and the nested child must still be checked, not exempted; got: {result:?}"
3041 );
3042 }
3043
3044 #[test]
3045 fn test_issue_638_overlong_ordered_marker_is_lazy_continuation() {
3046 let rule = MD007ULIndent::new(2);
3052 let content = "1. ordered\n1234567890. this is continuation text\n - child\n - grandchild\n";
3053 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3054 let result = rule.check(&ctx).unwrap();
3055 assert!(
3056 result.is_empty(),
3057 "an overlong digit run is not a valid ordered marker, so the list stays open and the nested bullets are exempt; got: {result:?}"
3058 );
3059 }
3060
3061 #[test]
3062 fn test_indented_top_level_list_item_is_flagged() {
3063 let rule = MD007ULIndent::new(2);
3069 for indent in 2..=3 {
3070 let pad = " ".repeat(indent);
3071 let content = format!("{pad}- parent\n{pad} - child\n");
3072 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
3073 let result = rule.check(&ctx).unwrap();
3074 assert!(
3075 result.iter().any(|w| w.line == 1),
3076 "a top-level item indented {indent} spaces must be flagged (Expected 0); got: {result:?}"
3077 );
3078 }
3079 }
3080
3081 #[test]
3082 fn test_indented_code_block_bullet_is_not_a_list_item() {
3083 let rule = MD007ULIndent::new(2);
3086 let content = " - not a list, this is code\n";
3087 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3088 let result = rule.check(&ctx).unwrap();
3089 assert!(
3090 result.is_empty(),
3091 "a 4-space-indented bullet is an indented code block, not a misindented list; got: {result:?}"
3092 );
3093 }
3094
3095 #[test]
3096 fn test_tab_indent_expands_to_four_column_tabstop() {
3097 let rule = MD007ULIndent::new(2);
3104 let content = "- a\n\t- b\n";
3105 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3106 let result = rule.check(&ctx).unwrap();
3107 let warning = result
3108 .iter()
3109 .find(|w| w.line == 2)
3110 .expect("a tab-indented sublist at column 4 is over-indented for depth 1 and must be flagged");
3111 assert!(
3112 warning.message.contains("found 4"),
3113 "the tab must expand to the 4-column tab stop (found 4), not be counted as one character; got: {}",
3114 warning.message
3115 );
3116 }
3117
3118 #[test]
3119 fn test_tab_completing_two_space_indent_to_tabstop_is_accepted() {
3120 let rule = MD007ULIndent::new(2);
3126 let content = "- a\n - b\n \t- c\n";
3127 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3128 let result = rule.check(&ctx).unwrap();
3129 assert!(
3130 result.is_empty(),
3131 "` \\t` expands to column 4, the correct depth-2 indent, so no MD007 warning is expected; got: {result:?}"
3132 );
3133 }
3134
3135 #[test]
3136 fn test_issue_638_html_comment_terminates_ordered_list() {
3137 let rule = MD007ULIndent::new(2);
3144 let content = "1. ordered\n<!-- comment -->\n\n - parent\n - child\n";
3145 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3146 let result = rule.check(&ctx).unwrap();
3147 assert!(
3148 result.iter().any(|w| w.line == 5),
3149 "an HTML comment terminates the ordered list, so the child must still be flagged, got: {result:?}"
3150 );
3151 }
3152
3153 #[test]
3154 fn test_issue_638_blockquoted_list_item_terminates_ordered_list() {
3155 let rule = MD007ULIndent::new(2);
3163 let content = "1. ordered\n> - quote list\n\n - parent\n - child\n";
3164 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3165 let result = rule.check(&ctx).unwrap();
3166 assert!(
3167 result.iter().any(|w| w.line == 5),
3168 "a blockquoted list item terminates the ordered list, so the child must still be flagged, got: {result:?}"
3169 );
3170 }
3171
3172 #[test]
3173 fn test_issue_638_deeper_nested_quote_terminates_blockquoted_ordered_list() {
3174 let rule = MD007ULIndent::new(2);
3184 let content = "> 1. ordered\n> > quote\n>\n> - parent\n> - child\n";
3185 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3186 let result = rule.check(&ctx).unwrap();
3187 assert!(
3188 result.iter().any(|w| w.line == 4),
3189 "deeper nested quote closes the ordered list, so the misindented parent must be flagged, got: {result:?}"
3190 );
3191 assert!(
3192 result.iter().any(|w| w.line == 5),
3193 "the child of the fresh unordered list must be flagged, not exempted, got: {result:?}"
3194 );
3195 }
3196
3197 #[test]
3198 fn test_issue_638_deeper_quote_list_item_terminates_blockquoted_ordered_list() {
3199 let rule = MD007ULIndent::new(2);
3207 let content = "> 1. ordered\n> > - quote list\n>\n> - parent\n> - child\n";
3208 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3209 let result = rule.check(&ctx).unwrap();
3210 assert!(
3211 result.iter().any(|w| w.line == 4),
3212 "a deeper-quote list item closes the ordered list, so the parent must be flagged, got: {result:?}"
3213 );
3214 assert!(
3215 result.iter().any(|w| w.line == 5),
3216 "the child of the fresh unordered list must be flagged, not exempted, got: {result:?}"
3217 );
3218 }
3219
3220 #[test]
3221 fn test_issue_638_deeper_quote_indented_into_item_keeps_exemption() {
3222 let rule = MD007ULIndent::new(2);
3227 let content = "> 1. ordered\n> > quote inside item\n> - child\n> - grandchild\n";
3228 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3229 let result = rule.check(&ctx).unwrap();
3230 assert!(
3231 result.is_empty(),
3232 "a deeper quote indented into the item must keep the sublist exempt, got: {result:?}"
3233 );
3234 }
3235
3236 #[test]
3237 fn test_indent4_explicit_with_wide_ordered_parent() {
3238 let config = MD007Config {
3242 indent: crate::types::IndentSize::from_const(4),
3243 start_indented: false,
3244 start_indent: crate::types::IndentSize::from_const(2),
3245 style: md007_config::IndentStyle::TextAligned,
3246 style_explicit: false,
3247 indent_explicit: true,
3248 };
3249 let rule = MD007ULIndent::from_config_struct(config);
3250
3251 let content = "100. Wide ordered\n * Bullet at 5 spaces";
3253 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3254 let result = rule.check(&ctx).unwrap();
3255 assert!(
3256 result.is_empty(),
3257 "indent=4 under '100.' should accept 5-space indent: {result:?}"
3258 );
3259
3260 let content_4 = "100. Wide ordered\n * Bullet at 4 spaces";
3262 let ctx = LintContext::new(content_4, crate::config::MarkdownFlavor::Standard, None);
3263 let result = rule.check(&ctx).unwrap();
3264 assert!(
3265 result.is_empty(),
3266 "indent=4 under '100.' should accept 4-space indent: {result:?}"
3267 );
3268 }
3269
3270 fn commonmark_max_list_depth(md: &str) -> usize {
3274 use pulldown_cmark::{Event, Parser, Tag, TagEnd};
3275 let (mut depth, mut max) = (0usize, 0usize);
3276 for event in Parser::new(md) {
3277 match event {
3278 Event::Start(Tag::List(_)) => {
3279 depth += 1;
3280 max = max.max(depth);
3281 }
3282 Event::End(TagEnd::List(_)) => depth = depth.saturating_sub(1),
3283 _ => {}
3284 }
3285 }
3286 max
3287 }
3288
3289 #[test]
3290 fn test_md007_widened_parent_marker_keeps_nested_child() {
3291 let rule = MD007ULIndent::default();
3297 let content = indoc! {"
3298 - Parent item
3299 - Nested item
3300 "};
3301 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3302 let result = rule.check(&ctx).unwrap();
3303 assert!(
3304 result.is_empty(),
3305 "a child aligned to a widened parent's content column must not be flagged: {result:?}"
3306 );
3307 assert_eq!(commonmark_max_list_depth(content), 2, "precondition: source is nested");
3308 assert_eq!(
3309 rule.fix(&ctx).unwrap(),
3310 content,
3311 "fix must be a no-op for an already correctly nested child"
3312 );
3313 }
3314
3315 #[test]
3316 fn test_md007_widened_parent_aligns_child_to_content_column() {
3317 let rule = MD007ULIndent::default();
3320 let content = indoc! {"
3321 - Parent item
3322 - Nested item
3323 "};
3324 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3325 let fixed = rule.fix(&ctx).unwrap();
3326 assert_eq!(
3327 fixed,
3328 indoc! {"
3329 - Parent item
3330 - Nested item
3331 "},
3332 "child must align to the parent's content column 4: {fixed:?}"
3333 );
3334 assert_eq!(
3335 commonmark_max_list_depth(&fixed),
3336 2,
3337 "fixed child must remain nested, not flattened to a sibling:\n{fixed}"
3338 );
3339 }
3340
3341 #[test]
3342 fn test_md007_widened_markers_nested_multiple_levels() {
3343 let rule = MD007ULIndent::default();
3346 let content = indoc! {"
3347 - Level 0
3348 - Level 1
3349 - Level 2
3350 "};
3351 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3352 let result = rule.check(&ctx).unwrap();
3353 assert!(
3354 result.is_empty(),
3355 "deeply nested widened markers must not be flagged: {result:?}"
3356 );
3357 assert_eq!(
3358 commonmark_max_list_depth(content),
3359 3,
3360 "three nesting levels are preserved"
3361 );
3362 }
3363
3364 #[test]
3365 fn test_md007_default_marker_indent_still_enforced() {
3366 let rule = MD007ULIndent::default();
3370 let content = indoc! {"
3371 - Parent item
3372 - Nested item
3373 "};
3374 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3375 let result = rule.check(&ctx).unwrap();
3376 assert_eq!(
3377 result.len(),
3378 1,
3379 "an over-indented child under a normal marker is still flagged: {result:?}"
3380 );
3381 assert_eq!(
3382 rule.fix(&ctx).unwrap(),
3383 indoc! {"
3384 - Parent item
3385 - Nested item
3386 "}
3387 );
3388 }
3389}