1use crate::rule::{LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
5use crate::rule_config_serde::RuleConfig;
6
7pub mod md007_config;
8use md007_config::MD007Config;
9
10#[derive(Debug, Clone, Default)]
11pub struct MD007ULIndent {
12 config: MD007Config,
13}
14
15impl MD007ULIndent {
16 pub fn new(indent: usize) -> Self {
17 Self {
18 config: MD007Config {
19 indent: crate::types::IndentSize::from_const(indent as u8),
20 start_indented: false,
21 start_indent: crate::types::IndentSize::from_const(2),
22 style: md007_config::IndentStyle::TextAligned,
23 style_explicit: false, indent_explicit: false, },
26 }
27 }
28
29 pub fn from_config_struct(config: MD007Config) -> Self {
30 Self { config }
31 }
32
33 fn char_pos_to_visual_column(content: &str, char_pos: usize) -> usize {
35 let mut visual_col = 0;
36
37 for (current_pos, ch) in content.chars().enumerate() {
38 if current_pos >= char_pos {
39 break;
40 }
41 if ch == '\t' {
42 visual_col = (visual_col / 4 + 1) * 4;
44 } else {
45 visual_col += 1;
46 }
47 }
48 visual_col
49 }
50
51 fn indent_relative_to_depth(
75 ctx: &crate::lint_context::LintContext,
76 line_info: &crate::lint_context::LineInfo,
77 depth: usize,
78 ) -> usize {
79 if depth == 0 {
80 return line_info.visual_indent;
81 }
82 let line_content = line_info.content(ctx.content);
87 let mut remaining = line_content;
88 let mut content_start = 0;
89 let mut stripped_levels = 0;
90 while stripped_levels < depth {
91 let trimmed = remaining.trim_start();
92 if !trimmed.starts_with('>') {
93 break;
94 }
95 content_start += remaining.len() - trimmed.len();
96 content_start += 1;
97 let after_gt = &trimmed[1..];
98 if let Some(stripped) = after_gt.strip_prefix(' ') {
99 content_start += 1;
100 remaining = stripped;
101 } else if let Some(stripped) = after_gt.strip_prefix('\t') {
102 content_start += 1;
103 remaining = stripped;
104 } else {
105 remaining = after_gt;
106 }
107 stripped_levels += 1;
108 }
109 let content_after_prefix = &line_content[content_start..];
110 let ws_chars = content_after_prefix
111 .chars()
112 .take_while(|c| *c == ' ' || *c == '\t')
113 .count();
114 Self::char_pos_to_visual_column(content_after_prefix, ws_chars)
115 }
116
117 fn terminate_closed_items(
118 ctx: &crate::lint_context::LintContext,
119 line_info: &crate::lint_context::LineInfo,
120 list_stack: &mut Vec<(usize, usize, bool, usize, usize, bool)>,
121 line_bq_depth: usize,
122 ) {
123 while let Some(&(_, _, _, content_col, item_bq_depth, _)) = list_stack.last() {
124 let closed = match item_bq_depth.cmp(&line_bq_depth) {
125 std::cmp::Ordering::Greater => true,
127 std::cmp::Ordering::Equal | std::cmp::Ordering::Less => {
136 content_col > Self::indent_relative_to_depth(ctx, line_info, item_bq_depth)
137 }
138 };
139 if closed {
140 list_stack.pop();
141 } else {
142 break;
143 }
144 }
145 }
146
147 fn calculate_expected_indent(
156 &self,
157 nesting_level: usize,
158 parent_info: Option<(bool, usize)>, ) -> usize {
160 if nesting_level == 0 {
161 return 0;
162 }
163
164 if self.config.style_explicit {
166 return match self.config.style {
167 md007_config::IndentStyle::Fixed => nesting_level * self.config.indent.get() as usize,
168 md007_config::IndentStyle::TextAligned => {
169 parent_info.map_or(nesting_level * 2, |(_, content_col)| content_col)
170 }
171 };
172 }
173
174 if self.config.indent_explicit {
177 match parent_info {
178 Some((true, parent_content_col)) => {
179 return parent_content_col;
182 }
183 _ => {
184 return nesting_level * self.config.indent.get() as usize;
186 }
187 }
188 }
189
190 match parent_info {
192 Some((true, parent_content_col)) => {
193 parent_content_col
196 }
197 Some((false, parent_content_col)) => {
198 let parent_level = nesting_level.saturating_sub(1);
202 let expected_parent_marker = parent_level * self.config.indent.get() as usize;
203 let parent_marker_col = parent_content_col.saturating_sub(2);
205
206 if parent_marker_col == expected_parent_marker {
207 nesting_level * self.config.indent.get() as usize
209 } else {
210 parent_content_col
212 }
213 }
214 None => {
215 nesting_level * self.config.indent.get() as usize
217 }
218 }
219 }
220}
221
222impl Rule for MD007ULIndent {
223 fn name(&self) -> &'static str {
224 "MD007"
225 }
226
227 fn description(&self) -> &'static str {
228 "Unordered list indentation"
229 }
230
231 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
232 let mut warnings = Vec::new();
233 let mut list_stack: Vec<(usize, usize, bool, usize, usize, bool)> = Vec::new(); for (line_idx, line_info) in ctx.lines.iter().enumerate() {
236 let is_skipped_region = |info: &crate::lint_context::LineInfo| {
238 info.in_code_block || info.in_front_matter || info.in_mkdocstrings || info.in_footnote_definition
239 };
240 let opens_fence_on_marker_line = line_info
253 .list_item
254 .as_ref()
255 .and_then(|item| line_info.content(ctx.content).get(item.content_column..))
256 .is_some_and(|after_marker| {
257 let after_marker = after_marker.trim_start();
258 after_marker.starts_with("```") || after_marker.starts_with("~~~")
259 });
260 let fence_opening_marker_line = opens_fence_on_marker_line
261 && line_info.in_code_block
262 && !line_info.in_front_matter
263 && !line_info.in_mkdocstrings
264 && !line_info.in_footnote_definition;
265 if is_skipped_region(line_info) && !fence_opening_marker_line {
266 let region_start = line_idx == 0 || !is_skipped_region(&ctx.lines[line_idx - 1]);
273 if region_start && !line_info.is_blank {
274 let bq_depth = line_info.blockquote.as_ref().map_or(0, |bq| bq.nesting_level);
275 Self::terminate_closed_items(ctx, line_info, &mut list_stack, bq_depth);
276 }
277 continue;
278 }
279
280 if let Some(list_item) = &line_info.list_item {
282 let (content_for_calculation, adjusted_marker_column) = if line_info.blockquote.is_some() {
286 let line_content = line_info.content(ctx.content);
288 let mut remaining = line_content;
289 let mut content_start = 0;
290
291 loop {
292 let trimmed = remaining.trim_start();
293 if !trimmed.starts_with('>') {
294 break;
295 }
296 content_start += remaining.len() - trimmed.len();
298 content_start += 1;
300 let after_gt = &trimmed[1..];
301 if let Some(stripped) = after_gt.strip_prefix(' ') {
303 content_start += 1;
304 remaining = stripped;
305 } else if let Some(stripped) = after_gt.strip_prefix('\t') {
306 content_start += 1;
307 remaining = stripped;
308 } else {
309 remaining = after_gt;
310 }
311 }
312
313 let content_after_prefix = &line_content[content_start..];
315 let adjusted_col = if list_item.marker_column >= content_start {
317 list_item.marker_column - content_start
318 } else {
319 list_item.marker_column
321 };
322 (content_after_prefix.to_string(), adjusted_col)
323 } else {
324 (line_info.content(ctx.content).to_string(), list_item.marker_column)
325 };
326
327 let visual_marker_column =
329 Self::char_pos_to_visual_column(&content_for_calculation, adjusted_marker_column);
330
331 let visual_content_column = if line_info.blockquote.is_some() {
333 let adjusted_content_col =
335 if list_item.content_column >= (line_info.byte_len - content_for_calculation.len()) {
336 list_item.content_column - (line_info.byte_len - content_for_calculation.len())
337 } else {
338 list_item.content_column
339 };
340 Self::char_pos_to_visual_column(&content_for_calculation, adjusted_content_col)
341 } else {
342 Self::char_pos_to_visual_column(line_info.content(ctx.content), list_item.content_column)
343 };
344
345 let visual_marker_for_nesting = if visual_marker_column == 1 && self.config.indent.get() != 1 {
349 0
350 } else {
351 visual_marker_column
352 };
353
354 let bq_depth = line_info.blockquote.as_ref().map_or(0, |bq| bq.nesting_level);
356
357 while let Some(&(indent, _, _, _, item_bq_depth, _)) = list_stack.last() {
360 if item_bq_depth == bq_depth && indent >= visual_marker_for_nesting {
361 list_stack.pop();
362 } else if item_bq_depth > bq_depth {
363 list_stack.pop();
365 } else {
366 break;
367 }
368 }
369
370 while let Some(&(_, _, _, content_col, item_bq_depth, _)) = list_stack.last() {
383 if item_bq_depth < bq_depth
384 && content_col > Self::indent_relative_to_depth(ctx, line_info, item_bq_depth)
385 {
386 list_stack.pop();
387 } else {
388 break;
389 }
390 }
391
392 if list_item.is_ordered {
394 list_stack.push((
397 visual_marker_column,
398 line_idx,
399 true,
400 visual_content_column,
401 bq_depth,
402 false,
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 let chain_ok = list_stack
434 .iter()
435 .rev()
436 .find(|item| item.4 == bq_depth)
437 .is_some_and(|item| item.2 || item.5);
438 if ctx.flavor != crate::config::MarkdownFlavor::MkDocs && threshold_ok && chain_ok {
439 list_stack.push((
440 visual_marker_column,
441 line_idx,
442 false,
443 visual_content_column,
444 bq_depth,
445 true,
446 ));
447 continue;
448 }
449
450 let nesting_level = list_stack.iter().filter(|item| item.4 == bq_depth).count();
452
453 let parent_info = list_stack
455 .iter()
456 .rev()
457 .find(|item| item.4 == bq_depth)
458 .map(|&(_, _, is_ordered, content_col, _, _)| (is_ordered, content_col));
459
460 let mut expected_indent = if self.config.start_indented && nesting_level == 0 {
466 self.config.start_indent.get() as usize
467 } else {
468 self.calculate_expected_indent(nesting_level, parent_info)
469 };
470
471 let also_acceptable =
475 if self.config.indent_explicit && parent_info.is_some_and(|(is_ordered, _)| is_ordered) {
476 Some(nesting_level * self.config.indent.get() as usize)
477 } else {
478 None
479 };
480
481 if ctx.flavor == crate::config::MarkdownFlavor::MkDocs
485 && let Some(&(parent_marker_col, _, true, _, _, _)) =
486 list_stack.iter().rev().find(|item| item.4 == bq_depth && item.2)
487 {
488 expected_indent = expected_indent.max(parent_marker_col + 4);
489 }
490
491 let accepted_indent = if also_acceptable.is_some_and(|alt| visual_marker_column == alt) {
497 visual_marker_column
498 } else {
499 expected_indent
500 };
501 let marker_width = visual_content_column.saturating_sub(visual_marker_column);
511 let expected_content_visual_col = accepted_indent + marker_width;
512 list_stack.push((
513 visual_marker_column,
514 line_idx,
515 false,
516 expected_content_visual_col,
517 bq_depth,
518 false,
519 ));
520
521 if !self.config.start_indented && nesting_level == 0 && visual_marker_column == 0 {
527 continue;
528 }
529
530 if visual_marker_column != expected_indent && also_acceptable != Some(visual_marker_column) {
531 if let Some(alt) = also_acceptable {
533 expected_indent = alt;
534 }
535 let fix = {
537 let correct_indent = " ".repeat(expected_indent);
538
539 let replacement = if line_info.blockquote.is_some() {
542 let mut blockquote_count = 0;
544 for ch in line_info.content(ctx.content).chars() {
545 if ch == '>' {
546 blockquote_count += 1;
547 } else if ch != ' ' && ch != '\t' {
548 break;
549 }
550 }
551 let blockquote_prefix = if blockquote_count > 1 {
553 (0..blockquote_count)
554 .map(|_| "> ")
555 .collect::<String>()
556 .trim_end()
557 .to_string()
558 } else {
559 ">".to_string()
560 };
561 format!("{blockquote_prefix} {correct_indent}")
564 } else {
565 correct_indent
566 };
567
568 let start_byte = line_info.byte_offset;
571 let mut end_byte = line_info.byte_offset;
572
573 for (i, ch) in line_info.content(ctx.content).chars().enumerate() {
575 if i >= list_item.marker_column {
576 break;
577 }
578 end_byte += ch.len_utf8();
579 }
580
581 Some(crate::rule::Fix::new(start_byte..end_byte, replacement))
582 };
583
584 warnings.push(LintWarning {
585 rule_name: Some(self.name().to_string()),
586 message: format!(
587 "Expected {expected_indent} spaces for indent depth {nesting_level}, found {visual_marker_column}"
588 ),
589 line: line_idx + 1, column: 1, end_line: line_idx + 1,
592 end_column: visual_marker_column + 1, severity: Severity::Warning,
594 fix,
595 });
596 }
597 } else if !line_info.is_blank {
598 let bq_depth = line_info.blockquote.as_ref().map_or(0, |bq| bq.nesting_level);
625 let prev_line = line_idx.checked_sub(1).map(|i| &ctx.lines[i]);
626 let prev_blank = prev_line.is_none_or(|p| p.is_blank);
627 let prev_bq_depth = prev_line
628 .and_then(|p| p.blockquote.as_ref())
629 .map_or(0, |bq| bq.nesting_level);
630 let same_container = prev_bq_depth == bq_depth;
631 let text = line_info
632 .blockquote
633 .as_ref()
634 .map_or_else(|| line_info.content(ctx.content), |bq| bq.content.as_str());
635 let trimmed = text.trim_start();
636 let starts_like_list_marker = match trimmed.as_bytes().first() {
637 Some(b'-' | b'*' | b'+') => {
638 matches!(trimmed.as_bytes().get(1), Some(b' ' | b'\t'))
639 }
640 Some(c) if c.is_ascii_digit() => {
641 let after_digits = trimmed.trim_start_matches(|ch: char| ch.is_ascii_digit());
645 let num_digits = trimmed.len() - after_digits.len();
646 let mut rest = after_digits.chars();
647 (1..=9).contains(&num_digits)
648 && matches!(rest.next(), Some('.' | ')'))
649 && matches!(rest.next(), Some(' ' | '\t') | None)
650 }
651 _ => false,
652 };
653 let prev_is_open_paragraph = prev_line.is_some_and(|p| {
660 !p.is_blank
661 && !p.in_code_block
662 && p.heading.is_none()
663 && !p.is_horizontal_rule
664 && !p.in_html_block
665 && !p.in_html_comment
666 && !p.is_div_marker
667 });
668 let is_lazy_paragraph_continuation = !prev_blank
669 && prev_is_open_paragraph
670 && same_container
671 && !starts_like_list_marker
672 && line_info.heading.is_none()
673 && !line_info.is_horizontal_rule
674 && !line_info.in_code_block
675 && !line_info.in_html_block
676 && !line_info.in_html_comment
677 && !line_info.is_div_marker;
678 if is_lazy_paragraph_continuation {
679 continue;
681 }
682 Self::terminate_closed_items(ctx, line_info, &mut list_stack, bq_depth);
683 }
684 }
685 Ok(warnings)
686 }
687
688 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
690 let warnings = self.check(ctx)?;
692 let warnings =
693 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
694
695 if warnings.is_empty() {
697 return Ok(ctx.content.to_string());
698 }
699
700 let mut fixes: Vec<_> = warnings
702 .iter()
703 .filter_map(|w| w.fix.as_ref().map(|f| (f.range.start, f.range.end, &f.replacement)))
704 .collect();
705 fixes.sort_by_key(|f| std::cmp::Reverse(f.0));
706
707 let mut result = ctx.content.to_string();
709 for (start, end, replacement) in fixes {
710 if start < result.len() && end <= result.len() && start <= end {
711 result.replace_range(start..end, replacement);
712 }
713 }
714
715 Ok(result)
716 }
717
718 fn category(&self) -> RuleCategory {
720 RuleCategory::List
721 }
722
723 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
725 if ctx.content.is_empty() || !ctx.likely_has_lists() {
727 return true;
728 }
729 !ctx.lines
731 .iter()
732 .any(|line| line.list_item.as_ref().is_some_and(|item| !item.is_ordered))
733 }
734
735 fn as_any(&self) -> &dyn std::any::Any {
736 self
737 }
738
739 fn default_config_section(&self) -> Option<(String, toml::Value)> {
740 let default_config = MD007Config::default();
741 let json_value = serde_json::to_value(&default_config).ok()?;
742 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
743
744 if let toml::Value::Table(table) = toml_value {
745 if !table.is_empty() {
746 Some((MD007Config::RULE_NAME.to_string(), toml::Value::Table(table)))
747 } else {
748 None
749 }
750 } else {
751 None
752 }
753 }
754
755 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
756 where
757 Self: Sized,
758 {
759 let mut rule_config = crate::rule_config_serde::load_rule_config::<MD007Config>(config);
760
761 if let Some(rule_cfg) = config.rules.get("MD007") {
763 rule_config.style_explicit = rule_cfg.values.contains_key("style");
764 rule_config.indent_explicit = rule_cfg.values.contains_key("indent");
765
766 if rule_config.indent_explicit
770 && rule_config.style_explicit
771 && rule_config.style == md007_config::IndentStyle::TextAligned
772 {
773 eprintln!(
774 "\x1b[33m[config warning]\x1b[0m MD007: 'indent' has no effect when 'style = \"text-aligned\"'. \
775 Text-aligned style ignores indent and aligns nested items with parent text. \
776 To use fixed {} space increments, either remove 'style' or set 'style = \"fixed\"'.",
777 rule_config.indent.get()
778 );
779 }
780 }
781
782 if config.markdown_flavor() == crate::config::MarkdownFlavor::MkDocs {
785 if rule_config.indent_explicit && rule_config.indent.get() < 4 {
786 eprintln!(
787 "\x1b[33m[config warning]\x1b[0m MD007: MkDocs flavor requires indent >= 4 \
788 (Python-Markdown enforces 4-space indentation). \
789 Overriding indent={} to indent=4.",
790 rule_config.indent.get()
791 );
792 }
793 if rule_config.style_explicit && rule_config.style == md007_config::IndentStyle::TextAligned {
794 eprintln!(
795 "\x1b[33m[config warning]\x1b[0m MD007: MkDocs flavor requires style=\"fixed\" \
796 (Python-Markdown uses fixed 4-space indentation). \
797 Overriding style=\"text-aligned\" to style=\"fixed\"."
798 );
799 }
800 if rule_config.indent.get() < 4 {
801 rule_config.indent = crate::types::IndentSize::from_const(4);
802 }
803 rule_config.style = md007_config::IndentStyle::Fixed;
804 }
805
806 Box::new(Self::from_config_struct(rule_config))
807 }
808}
809
810#[cfg(test)]
811mod tests {
812 use super::*;
813 use crate::lint_context::LintContext;
814 use crate::rule::Rule;
815 use indoc::indoc;
816
817 #[test]
818 fn test_valid_list_indent() {
819 let rule = MD007ULIndent::default();
820 let content = "* Item 1\n * Item 2\n * Item 3";
821 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
822 let result = rule.check(&ctx).unwrap();
823 assert!(
824 result.is_empty(),
825 "Expected no warnings for valid indentation, but got {} warnings",
826 result.len()
827 );
828 }
829
830 #[test]
831 fn test_invalid_list_indent() {
832 let rule = MD007ULIndent::default();
833 let content = "* Item 1\n * Item 2\n * Item 3";
834 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
835 let result = rule.check(&ctx).unwrap();
836 assert_eq!(result.len(), 2);
837 assert_eq!(result[0].line, 2);
838 assert_eq!(result[0].column, 1);
839 assert_eq!(result[1].line, 3);
840 assert_eq!(result[1].column, 1);
841 }
842
843 #[test]
844 fn test_mixed_indentation() {
845 let rule = MD007ULIndent::default();
846 let content = "* Item 1\n * Item 2\n * Item 3\n * Item 4";
847 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
848 let result = rule.check(&ctx).unwrap();
849 assert_eq!(result.len(), 1);
850 assert_eq!(result[0].line, 3);
851 assert_eq!(result[0].column, 1);
852 }
853
854 #[test]
855 fn test_fix_indentation() {
856 let rule = MD007ULIndent::default();
857 let content = "* Item 1\n * Item 2\n * Item 3";
858 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
859 let result = rule.fix(&ctx).unwrap();
860 let expected = "* Item 1\n * Item 2\n * Item 3";
864 assert_eq!(result, expected);
865 }
866
867 #[test]
868 fn test_md007_in_yaml_code_block() {
869 let rule = MD007ULIndent::default();
870 let content = r#"```yaml
871repos:
872- repo: https://github.com/rvben/rumdl
873 rev: v0.5.0
874 hooks:
875 - id: rumdl-check
876```"#;
877 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
878 let result = rule.check(&ctx).unwrap();
879 assert!(
880 result.is_empty(),
881 "MD007 should not trigger inside a code block, but got warnings: {result:?}"
882 );
883 }
884
885 #[test]
886 fn test_blockquoted_list_indent() {
887 let rule = MD007ULIndent::default();
888 let content = "> * Item 1\n> * Item 2\n> * Item 3";
889 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
890 let result = rule.check(&ctx).unwrap();
891 assert!(
892 result.is_empty(),
893 "Expected no warnings for valid blockquoted list indentation, but got {result:?}"
894 );
895 }
896
897 #[test]
898 fn test_blockquoted_list_invalid_indent() {
899 let rule = MD007ULIndent::default();
900 let content = "> * Item 1\n> * Item 2\n> * Item 3";
901 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
902 let result = rule.check(&ctx).unwrap();
903 assert_eq!(
904 result.len(),
905 2,
906 "Expected 2 warnings for invalid blockquoted list indentation, got {result:?}"
907 );
908 assert_eq!(result[0].line, 2);
909 assert_eq!(result[1].line, 3);
910 }
911
912 #[test]
913 fn test_nested_blockquote_list_indent() {
914 let rule = MD007ULIndent::default();
915 let content = "> > * Item 1\n> > * Item 2\n> > * Item 3";
916 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
917 let result = rule.check(&ctx).unwrap();
918 assert!(
919 result.is_empty(),
920 "Expected no warnings for valid nested blockquoted list indentation, but got {result:?}"
921 );
922 }
923
924 #[test]
925 fn test_blockquote_list_with_code_block() {
926 let rule = MD007ULIndent::default();
927 let content = "> * Item 1\n> * Item 2\n> ```\n> code\n> ```\n> * Item 3";
928 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
929 let result = rule.check(&ctx).unwrap();
930 assert!(
931 result.is_empty(),
932 "MD007 should not trigger inside a code block within a blockquote, but got warnings: {result:?}"
933 );
934 }
935
936 #[test]
937 fn test_properly_indented_lists() {
938 let rule = MD007ULIndent::default();
939
940 let test_cases = vec![
942 "* Item 1\n* Item 2",
943 "* Item 1\n * Item 1.1\n * Item 1.1.1",
944 "- Item 1\n - Item 1.1",
945 "+ Item 1\n + Item 1.1",
946 "* Item 1\n * Item 1.1\n* Item 2\n * Item 2.1",
947 ];
948
949 for content in test_cases {
950 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
951 let result = rule.check(&ctx).unwrap();
952 assert!(
953 result.is_empty(),
954 "Expected no warnings for properly indented list:\n{}\nGot {} warnings",
955 content,
956 result.len()
957 );
958 }
959 }
960
961 #[test]
962 fn test_under_indented_lists() {
963 let rule = MD007ULIndent::default();
964
965 let test_cases = vec![
966 ("* Item 1\n * Item 1.1", 1, 2), ("* Item 1\n * Item 1.1\n * Item 1.1.1", 1, 3), ];
969
970 for (content, expected_warnings, line) in test_cases {
971 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
972 let result = rule.check(&ctx).unwrap();
973 assert_eq!(
974 result.len(),
975 expected_warnings,
976 "Expected {expected_warnings} warnings for under-indented list:\n{content}"
977 );
978 if expected_warnings > 0 {
979 assert_eq!(result[0].line, line);
980 }
981 }
982 }
983
984 #[test]
985 fn test_over_indented_lists() {
986 let rule = MD007ULIndent::default();
987
988 let test_cases = vec![
989 ("* 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), ];
993
994 for (content, expected_warnings, line) in test_cases {
995 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
996 let result = rule.check(&ctx).unwrap();
997 assert_eq!(
998 result.len(),
999 expected_warnings,
1000 "Expected {expected_warnings} warnings for over-indented list:\n{content}"
1001 );
1002 if expected_warnings > 0 {
1003 assert_eq!(result[0].line, line);
1004 }
1005 }
1006 }
1007
1008 #[test]
1009 fn test_custom_indent_2_spaces() {
1010 let rule = MD007ULIndent::new(2); let content = "* Item 1\n * Item 2\n * Item 3";
1012 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1013 let result = rule.check(&ctx).unwrap();
1014 assert!(result.is_empty());
1015 }
1016
1017 #[test]
1018 fn test_custom_indent_3_spaces() {
1019 let rule = MD007ULIndent::new(3);
1022
1023 let correct_content = "* Item 1\n * Item 2\n * Item 3";
1025 let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard, None);
1026 let result = rule.check(&ctx).unwrap();
1027 assert!(
1028 result.is_empty(),
1029 "Fixed style expects 0, 3, 6 spaces but got: {result:?}"
1030 );
1031
1032 let wrong_content = "* Item 1\n * Item 2\n * Item 3";
1034 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
1035 let result = rule.check(&ctx).unwrap();
1036 assert!(!result.is_empty(), "Should warn: expected 3 spaces, found 2");
1037 }
1038
1039 #[test]
1040 fn test_custom_indent_4_spaces() {
1041 let rule = MD007ULIndent::new(4);
1044
1045 let correct_content = "* Item 1\n * Item 2\n * Item 3";
1047 let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard, None);
1048 let result = rule.check(&ctx).unwrap();
1049 assert!(
1050 result.is_empty(),
1051 "Fixed style expects 0, 4, 8 spaces but got: {result:?}"
1052 );
1053
1054 let wrong_content = "* Item 1\n * Item 2\n * Item 3";
1056 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
1057 let result = rule.check(&ctx).unwrap();
1058 assert!(!result.is_empty(), "Should warn: expected 4 spaces, found 2");
1059 }
1060
1061 #[test]
1062 fn test_tab_indentation() {
1063 let rule = MD007ULIndent::default();
1064
1065 let content = "* Item 1\n * Item 2";
1071 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1072 let result = rule.check(&ctx).unwrap();
1073 assert_eq!(result.len(), 1, "Wrong indentation should trigger warning");
1074
1075 let fixed = rule.fix(&ctx).unwrap();
1077 assert_eq!(fixed, "* Item 1\n * Item 2");
1078
1079 let content_multi = "* Item 1\n * Item 2\n * Item 3";
1081 let ctx = LintContext::new(content_multi, crate::config::MarkdownFlavor::Standard, None);
1082 let fixed = rule.fix(&ctx).unwrap();
1083 assert_eq!(fixed, "* Item 1\n * Item 2\n * Item 3");
1086
1087 let content_mixed = "* Item 1\n * Item 2\n * Item 3";
1089 let ctx = LintContext::new(content_mixed, crate::config::MarkdownFlavor::Standard, None);
1090 let fixed = rule.fix(&ctx).unwrap();
1091 assert_eq!(fixed, "* Item 1\n * Item 2\n * Item 3");
1094 }
1095
1096 #[test]
1097 fn test_mixed_ordered_unordered_lists() {
1098 let rule = MD007ULIndent::default();
1099
1100 let content = r#"1. Ordered item
1103 * Unordered sub-item (correct - 3 spaces under ordered)
1104 2. Ordered sub-item
1105* Unordered item
1106 1. Ordered sub-item
1107 * Unordered sub-item"#;
1108
1109 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1110 let result = rule.check(&ctx).unwrap();
1111 assert_eq!(result.len(), 0, "All unordered list indentation should be correct");
1112
1113 let fixed = rule.fix(&ctx).unwrap();
1115 assert_eq!(fixed, content);
1116 }
1117
1118 #[test]
1119 fn test_list_markers_variety() {
1120 let rule = MD007ULIndent::default();
1121
1122 let content = r#"* Asterisk
1124 * Nested asterisk
1125- Hyphen
1126 - Nested hyphen
1127+ Plus
1128 + Nested plus"#;
1129
1130 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1131 let result = rule.check(&ctx).unwrap();
1132 assert!(
1133 result.is_empty(),
1134 "All unordered list markers should work with proper indentation"
1135 );
1136
1137 let wrong_content = r#"* Asterisk
1139 * Wrong asterisk
1140- Hyphen
1141 - Wrong hyphen
1142+ Plus
1143 + Wrong plus"#;
1144
1145 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
1146 let result = rule.check(&ctx).unwrap();
1147 assert_eq!(result.len(), 3, "All marker types should be checked for indentation");
1148 }
1149
1150 #[test]
1151 fn test_empty_list_items() {
1152 let rule = MD007ULIndent::default();
1153 let content = "* Item 1\n* \n * Item 2";
1154 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1155 let result = rule.check(&ctx).unwrap();
1156 assert!(
1157 result.is_empty(),
1158 "Empty list items should not affect indentation checks"
1159 );
1160 }
1161
1162 #[test]
1163 fn test_list_with_code_blocks() {
1164 let rule = MD007ULIndent::default();
1165 let content = r#"* Item 1
1166 ```
1167 code
1168 ```
1169 * Item 2
1170 * Item 3"#;
1171 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1172 let result = rule.check(&ctx).unwrap();
1173 assert!(result.is_empty());
1174 }
1175
1176 #[test]
1177 fn test_list_in_front_matter() {
1178 let rule = MD007ULIndent::default();
1179 let content = r#"---
1180tags:
1181 - tag1
1182 - tag2
1183---
1184* Item 1
1185 * Item 2"#;
1186 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1187 let result = rule.check(&ctx).unwrap();
1188 assert!(result.is_empty(), "Lists in YAML front matter should be ignored");
1189 }
1190
1191 #[test]
1192 fn test_fix_preserves_content() {
1193 let rule = MD007ULIndent::default();
1194 let content = "* Item 1 with **bold** and *italic*\n * Item 2 with `code`\n * Item 3 with [link](url)";
1195 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1196 let fixed = rule.fix(&ctx).unwrap();
1197 let expected = "* Item 1 with **bold** and *italic*\n * Item 2 with `code`\n * Item 3 with [link](url)";
1200 assert_eq!(fixed, expected, "Fix should only change indentation, not content");
1201 }
1202
1203 #[test]
1204 fn test_start_indented_config() {
1205 let config = MD007Config {
1206 start_indented: true,
1207 start_indent: crate::types::IndentSize::from_const(4),
1208 indent: crate::types::IndentSize::from_const(2),
1209 style: md007_config::IndentStyle::TextAligned,
1210 style_explicit: true, indent_explicit: false,
1212 };
1213 let rule = MD007ULIndent::from_config_struct(config);
1214
1215 let content = " * Item 1\n * Item 2\n * Item 3";
1220 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1221 let result = rule.check(&ctx).unwrap();
1222 assert!(result.is_empty(), "Expected no warnings with start_indented config");
1223
1224 let wrong_content = " * Item 1\n * Item 2";
1226 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
1227 let result = rule.check(&ctx).unwrap();
1228 assert_eq!(result.len(), 2);
1229 assert_eq!(result[0].line, 1);
1230 assert_eq!(result[0].message, "Expected 4 spaces for indent depth 0, found 2");
1231 assert_eq!(result[1].line, 2);
1232 assert_eq!(result[1].message, "Expected 6 spaces for indent depth 1, found 4");
1233
1234 let fixed = rule.fix(&ctx).unwrap();
1236 assert_eq!(fixed, " * Item 1\n * Item 2");
1237 }
1238
1239 #[test]
1240 fn test_start_indented_false_flags_indented_first_level() {
1241 let rule = MD007ULIndent::default(); let content = " * Item 1"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1249 let result = rule.check(&ctx).unwrap();
1250 assert!(
1251 result.iter().any(|w| w.line == 1 && w.message.contains("Expected 0")),
1252 "a top-level item indented 3 spaces must be flagged with Expected 0, got: {result:?}"
1253 );
1254
1255 let content = "* Item 1\n * Item 2\n * Item 3";
1259 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1260 let result = rule.check(&ctx).unwrap();
1261 assert!(
1262 result.is_empty(),
1263 "a correctly nested 0/2/4-space list should produce no warnings, got: {result:?}"
1264 );
1265 }
1266
1267 #[test]
1268 fn test_deeply_nested_lists() {
1269 let rule = MD007ULIndent::default();
1270 let content = r#"* L1
1271 * L2
1272 * L3
1273 * L4
1274 * L5
1275 * L6"#;
1276 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1277 let result = rule.check(&ctx).unwrap();
1278 assert!(result.is_empty());
1279
1280 let wrong_content = r#"* L1
1282 * L2
1283 * L3
1284 * L4
1285 * L5
1286 * L6"#;
1287 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
1288 let result = rule.check(&ctx).unwrap();
1289 assert_eq!(result.len(), 2, "Deep nesting errors should be detected");
1290 }
1291
1292 #[test]
1293 fn test_excessive_indentation_detected() {
1294 let rule = MD007ULIndent::default();
1295
1296 let content = "- Item 1\n - Item 2 with 5 spaces";
1298 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1299 let result = rule.check(&ctx).unwrap();
1300 assert_eq!(result.len(), 1, "Should detect excessive indentation (5 instead of 2)");
1301 assert_eq!(result[0].line, 2);
1302 assert!(result[0].message.contains("Expected 2 spaces"));
1303 assert!(result[0].message.contains("found 5"));
1304
1305 let content = "- Item 1\n - Item 2 with 3 spaces";
1307 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1308 let result = rule.check(&ctx).unwrap();
1309 assert_eq!(
1310 result.len(),
1311 1,
1312 "Should detect slightly excessive indentation (3 instead of 2)"
1313 );
1314 assert_eq!(result[0].line, 2);
1315 assert!(result[0].message.contains("Expected 2 spaces"));
1316 assert!(result[0].message.contains("found 3"));
1317
1318 let content = "- Item 1\n - Item 2 with 1 space";
1320 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1321 let result = rule.check(&ctx).unwrap();
1322 assert_eq!(
1323 result.len(),
1324 1,
1325 "Should detect 1-space indent (insufficient for nesting, expected 0)"
1326 );
1327 assert_eq!(result[0].line, 2);
1328 assert!(result[0].message.contains("Expected 0 spaces"));
1329 assert!(result[0].message.contains("found 1"));
1330 }
1331
1332 #[test]
1333 fn test_excessive_indentation_with_4_space_config() {
1334 let rule = MD007ULIndent::new(4);
1337
1338 let content = "- Formatter:\n - The stable style changed";
1340 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1341 let result = rule.check(&ctx).unwrap();
1342 assert!(
1343 !result.is_empty(),
1344 "Should detect 5 spaces when expecting 4 (fixed style)"
1345 );
1346
1347 let correct_content = "- Formatter:\n - The stable style changed";
1349 let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard, None);
1350 let result = rule.check(&ctx).unwrap();
1351 assert!(result.is_empty(), "Should accept correct fixed style indent (4 spaces)");
1352 }
1353
1354 #[test]
1355 fn test_bullets_nested_under_numbered_items() {
1356 let rule = MD007ULIndent::default();
1357 let content = "\
13581. **Active Directory/LDAP**
1359 - User authentication and directory services
1360 - LDAP for user information and validation
1361
13622. **Oracle Unified Directory (OUD)**
1363 - Extended user directory services";
1364 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1365 let result = rule.check(&ctx).unwrap();
1366 assert!(
1368 result.is_empty(),
1369 "Expected no warnings for bullets with 3 spaces under numbered items, got: {result:?}"
1370 );
1371 }
1372
1373 #[test]
1374 fn test_bullets_nested_under_numbered_items_wrong_indent() {
1375 let rule = MD007ULIndent::default();
1376 let content = "\
13771. **Active Directory/LDAP**
1378 - Wrong: only 2 spaces";
1379 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1380 let result = rule.check(&ctx).unwrap();
1381 assert_eq!(
1383 result.len(),
1384 1,
1385 "Expected warning for incorrect indentation under numbered items"
1386 );
1387 assert!(
1388 result
1389 .iter()
1390 .any(|w| w.line == 2 && w.message.contains("Expected 3 spaces"))
1391 );
1392 }
1393
1394 #[test]
1395 fn test_regular_bullet_nesting_still_works() {
1396 let rule = MD007ULIndent::default();
1397 let content = "\
1398* Top level
1399 * Nested bullet (2 spaces is correct)
1400 * Deeply nested (4 spaces)";
1401 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1402 let result = rule.check(&ctx).unwrap();
1403 assert!(
1405 result.is_empty(),
1406 "Expected no warnings for standard bullet nesting, got: {result:?}"
1407 );
1408 }
1409
1410 #[test]
1411 fn test_blockquote_with_tab_after_marker() {
1412 let rule = MD007ULIndent::default();
1413 let content = ">\t* List item\n>\t * Nested\n";
1414 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1415 let result = rule.check(&ctx).unwrap();
1416 assert!(
1417 result.is_empty(),
1418 "Tab after blockquote marker should be handled correctly, got: {result:?}"
1419 );
1420 }
1421
1422 #[test]
1423 fn test_blockquote_with_space_then_tab_after_marker() {
1424 let rule = MD007ULIndent::default();
1425 let content = "> \t* List item\n";
1426 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1427 let result = rule.check(&ctx).unwrap();
1428 assert!(
1433 result.iter().any(|w| w.line == 1 && w.message.contains("Expected 0")),
1434 "an indented blockquoted top-level item must be flagged with Expected 0, got: {result:?}"
1435 );
1436 }
1437
1438 #[test]
1439 fn test_blockquote_with_multiple_tabs() {
1440 let rule = MD007ULIndent::default();
1441 let content = ">\t\t* List item\n";
1442 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1443 let result = rule.check(&ctx).unwrap();
1444 assert!(
1446 result.is_empty(),
1447 "First-level list item at any indentation is allowed when start_indented=false, got: {result:?}"
1448 );
1449 }
1450
1451 #[test]
1452 fn test_nested_blockquote_with_tab() {
1453 let rule = MD007ULIndent::default();
1454 let content = ">\t>\t* List item\n>\t>\t * Nested\n";
1455 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1456 let result = rule.check(&ctx).unwrap();
1457 assert!(
1458 result.is_empty(),
1459 "Nested blockquotes with tabs should work correctly, got: {result:?}"
1460 );
1461 }
1462
1463 #[test]
1466 fn test_smart_style_pure_unordered_uses_fixed() {
1467 let rule = MD007ULIndent::new(4);
1469
1470 let content = "* Level 0\n * Level 1\n * Level 2";
1472 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1473 let result = rule.check(&ctx).unwrap();
1474 assert!(
1475 result.is_empty(),
1476 "Pure unordered with indent=4 should use fixed style (0, 4, 8), got: {result:?}"
1477 );
1478 }
1479
1480 #[test]
1481 fn test_smart_style_mixed_lists_uses_text_aligned() {
1482 let rule = MD007ULIndent::new(4);
1484
1485 let content = "1. Ordered\n * Bullet aligns with 'Ordered' text (3 spaces)";
1487 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1488 let result = rule.check(&ctx).unwrap();
1489 assert!(
1490 result.is_empty(),
1491 "Mixed lists should use text-aligned style, got: {result:?}"
1492 );
1493 }
1494
1495 #[test]
1496 fn test_smart_style_explicit_fixed_overrides() {
1497 let config = MD007Config {
1499 indent: crate::types::IndentSize::from_const(4),
1500 start_indented: false,
1501 start_indent: crate::types::IndentSize::from_const(2),
1502 style: md007_config::IndentStyle::Fixed,
1503 style_explicit: true, indent_explicit: false,
1505 };
1506 let rule = MD007ULIndent::from_config_struct(config);
1507
1508 let content = "1. Ordered\n * Should be at 4 spaces (fixed)";
1510 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1511 let result = rule.check(&ctx).unwrap();
1512 assert!(
1514 result.is_empty(),
1515 "Explicit fixed style should be respected, got: {result:?}"
1516 );
1517 }
1518
1519 #[test]
1520 fn test_smart_style_explicit_text_aligned_overrides() {
1521 let config = MD007Config {
1523 indent: crate::types::IndentSize::from_const(4),
1524 start_indented: false,
1525 start_indent: crate::types::IndentSize::from_const(2),
1526 style: md007_config::IndentStyle::TextAligned,
1527 style_explicit: true, indent_explicit: false,
1529 };
1530 let rule = MD007ULIndent::from_config_struct(config);
1531
1532 let content = "* Level 0\n * Level 1 (aligned with 'Level 0' text)";
1534 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1535 let result = rule.check(&ctx).unwrap();
1536 assert!(
1537 result.is_empty(),
1538 "Explicit text-aligned should be respected, got: {result:?}"
1539 );
1540
1541 let fixed_style_content = "* Level 0\n * Level 1 (4 spaces - fixed style)";
1543 let ctx = LintContext::new(fixed_style_content, crate::config::MarkdownFlavor::Standard, None);
1544 let result = rule.check(&ctx).unwrap();
1545 assert!(
1546 !result.is_empty(),
1547 "With explicit text-aligned, 4-space indent should be wrong (expected 2)"
1548 );
1549 }
1550
1551 #[test]
1552 fn test_smart_style_default_indent_no_autoswitch() {
1553 let rule = MD007ULIndent::new(2);
1555
1556 let content = "* Level 0\n * Level 1\n * Level 2";
1557 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1558 let result = rule.check(&ctx).unwrap();
1559 assert!(
1560 result.is_empty(),
1561 "Default indent should work regardless of style, got: {result:?}"
1562 );
1563 }
1564
1565 #[test]
1566 fn test_has_mixed_list_nesting_detection() {
1567 let content = "* Item 1\n * Item 2\n * Item 3";
1571 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1572 assert!(
1573 !ctx.has_mixed_list_nesting(),
1574 "Pure unordered should not be detected as mixed"
1575 );
1576
1577 let content = "1. Item 1\n 2. Item 2\n 3. Item 3";
1579 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1580 assert!(
1581 !ctx.has_mixed_list_nesting(),
1582 "Pure ordered should not be detected as mixed"
1583 );
1584
1585 let content = "1. Ordered\n * Unordered child";
1587 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1588 assert!(
1589 ctx.has_mixed_list_nesting(),
1590 "Unordered under ordered should be detected as mixed"
1591 );
1592
1593 let content = "* Unordered\n 1. Ordered child";
1595 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1596 assert!(
1597 ctx.has_mixed_list_nesting(),
1598 "Ordered under unordered should be detected as mixed"
1599 );
1600
1601 let content = "* Unordered\n\n1. Ordered (separate list)";
1603 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1604 assert!(
1605 !ctx.has_mixed_list_nesting(),
1606 "Separate lists should not be detected as mixed"
1607 );
1608
1609 let content = "> 1. Ordered in blockquote\n> * Unordered child";
1611 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1612 assert!(
1613 ctx.has_mixed_list_nesting(),
1614 "Mixed lists in blockquotes should be detected"
1615 );
1616 }
1617
1618 #[test]
1619 fn test_issue_210_exact_reproduction() {
1620 let config = MD007Config {
1622 indent: crate::types::IndentSize::from_const(4),
1623 start_indented: false,
1624 start_indent: crate::types::IndentSize::from_const(2),
1625 style: md007_config::IndentStyle::TextAligned, style_explicit: false, indent_explicit: false, };
1629 let rule = MD007ULIndent::from_config_struct(config);
1630
1631 let content = "# Title\n\n* some\n * list\n * items\n";
1632 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1633 let result = rule.check(&ctx).unwrap();
1634
1635 assert!(
1636 result.is_empty(),
1637 "Issue #210: indent=4 on pure unordered should work (auto-fixed style), got: {result:?}"
1638 );
1639 }
1640
1641 #[test]
1642 fn test_issue_209_still_fixed() {
1643 let config = MD007Config {
1646 indent: crate::types::IndentSize::from_const(3),
1647 start_indented: false,
1648 start_indent: crate::types::IndentSize::from_const(2),
1649 style: md007_config::IndentStyle::TextAligned,
1650 style_explicit: true, indent_explicit: false,
1652 };
1653 let rule = MD007ULIndent::from_config_struct(config);
1654
1655 let content = r#"# Header 1
1657
1658- **Second item**:
1659 - **This is a nested list**:
1660 1. **First point**
1661 - First subpoint
1662"#;
1663 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1664 let result = rule.check(&ctx).unwrap();
1665
1666 assert!(
1667 result.is_empty(),
1668 "Issue #209: With explicit text-aligned style, should have no issues, got: {result:?}"
1669 );
1670 }
1671
1672 #[test]
1675 fn test_multi_level_mixed_detection_grandparent() {
1676 let content = "1. Ordered grandparent\n * Unordered child\n * Unordered grandchild";
1680 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1681 assert!(
1682 ctx.has_mixed_list_nesting(),
1683 "Should detect mixed nesting when grandparent differs in type"
1684 );
1685
1686 let content = "* Unordered grandparent\n 1. Ordered child\n 2. Ordered grandchild";
1688 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1689 assert!(
1690 ctx.has_mixed_list_nesting(),
1691 "Should detect mixed nesting for ordered descendants under unordered"
1692 );
1693 }
1694
1695 #[test]
1696 fn test_html_comments_skipped_in_detection() {
1697 let content = r#"* Unordered list
1699<!-- This is a comment
1700 1. This ordered list is inside a comment
1701 * This nested bullet is also inside
1702-->
1703 * Another unordered item"#;
1704 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1705 assert!(
1706 !ctx.has_mixed_list_nesting(),
1707 "Lists in HTML comments should be ignored in mixed detection"
1708 );
1709 }
1710
1711 #[test]
1712 fn test_blank_lines_separate_lists() {
1713 let content = "* First unordered list\n\n1. Second list is ordered (separate)";
1715 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1716 assert!(
1717 !ctx.has_mixed_list_nesting(),
1718 "Blank line at root should separate lists"
1719 );
1720
1721 let content = "1. Ordered parent\n\n * Still a child due to indentation";
1723 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1724 assert!(
1725 ctx.has_mixed_list_nesting(),
1726 "Indented list after blank is still nested"
1727 );
1728 }
1729
1730 #[test]
1731 fn test_column_1_normalization() {
1732 let content = "* First item\n * Second item with 1 space (sibling)";
1735 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1736 let rule = MD007ULIndent::default();
1737 let result = rule.check(&ctx).unwrap();
1738 assert!(
1740 result.iter().any(|w| w.line == 2),
1741 "1-space indent should be flagged as incorrect"
1742 );
1743 }
1744
1745 #[test]
1746 fn test_code_blocks_skipped_in_detection() {
1747 let content = r#"* Unordered list
1749```
17501. This ordered list is inside a code block
1751 * This nested bullet is also inside
1752```
1753 * Another unordered item"#;
1754 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1755 assert!(
1756 !ctx.has_mixed_list_nesting(),
1757 "Lists in code blocks should be ignored in mixed detection"
1758 );
1759 }
1760
1761 #[test]
1762 fn test_front_matter_skipped_in_detection() {
1763 let content = r#"---
1765items:
1766 - yaml list item
1767 - another item
1768---
1769* Unordered list after front matter"#;
1770 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1771 assert!(
1772 !ctx.has_mixed_list_nesting(),
1773 "Lists in front matter should be ignored in mixed detection"
1774 );
1775 }
1776
1777 #[test]
1778 fn test_alternating_types_at_same_level() {
1779 let content = "* First bullet\n1. First number\n* Second bullet\n2. Second number";
1782 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1783 assert!(
1784 !ctx.has_mixed_list_nesting(),
1785 "Alternating types at same level should not be detected as mixed"
1786 );
1787 }
1788
1789 #[test]
1790 fn test_five_level_deep_mixed_nesting() {
1791 let content = "* L0\n 1. L1\n * L2\n 1. L3\n * L4\n 1. L5";
1793 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1794 assert!(ctx.has_mixed_list_nesting(), "Should detect mixed nesting at 5+ levels");
1795 }
1796
1797 #[test]
1798 fn test_very_deep_pure_unordered_nesting() {
1799 let mut content = String::from("* L1");
1801 for level in 2..=12 {
1802 let indent = " ".repeat(level - 1);
1803 content.push_str(&format!("\n{indent}* L{level}"));
1804 }
1805
1806 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1807
1808 assert!(
1810 !ctx.has_mixed_list_nesting(),
1811 "Pure unordered deep nesting should not be detected as mixed"
1812 );
1813
1814 let rule = MD007ULIndent::new(4);
1816 let result = rule.check(&ctx).unwrap();
1817 assert!(!result.is_empty(), "Should flag incorrect indentation for fixed style");
1820 }
1821
1822 #[test]
1823 fn test_interleaved_content_between_list_items() {
1824 let content = "1. Ordered parent\n\n Paragraph continuation\n\n * Unordered child";
1826 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1827 assert!(
1828 ctx.has_mixed_list_nesting(),
1829 "Should detect mixed nesting even with interleaved paragraphs"
1830 );
1831 }
1832
1833 #[test]
1834 fn test_esm_blocks_skipped_in_detection() {
1835 let content = "* Unordered list\n * Nested unordered";
1838 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1839 assert!(
1840 !ctx.has_mixed_list_nesting(),
1841 "Pure unordered should not be detected as mixed"
1842 );
1843 }
1844
1845 #[test]
1846 fn test_multiple_list_blocks_pure_then_mixed() {
1847 let content = r#"* Pure unordered
1850 * Nested unordered
1851
18521. Mixed section
1853 * Bullet under ordered"#;
1854 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1855 assert!(
1856 ctx.has_mixed_list_nesting(),
1857 "Should detect mixed nesting in any part of document"
1858 );
1859 }
1860
1861 #[test]
1862 fn test_multiple_separate_pure_lists() {
1863 let content = r#"* First list
1866 * Nested
1867
1868* Second list
1869 * Also nested
1870
1871* Third list
1872 * Deeply
1873 * Nested"#;
1874 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1875 assert!(
1876 !ctx.has_mixed_list_nesting(),
1877 "Multiple separate pure unordered lists should not be mixed"
1878 );
1879 }
1880
1881 #[test]
1882 fn test_code_block_between_list_items() {
1883 let content = r#"1. Ordered
1885 ```
1886 code
1887 ```
1888 * Still a mixed child"#;
1889 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1890 assert!(
1891 ctx.has_mixed_list_nesting(),
1892 "Code block between items should not prevent mixed detection"
1893 );
1894 }
1895
1896 #[test]
1897 fn test_blockquoted_mixed_detection() {
1898 let content = "> 1. Ordered in blockquote\n> * Mixed child";
1900 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1901 assert!(
1904 ctx.has_mixed_list_nesting(),
1905 "Should detect mixed nesting in blockquotes"
1906 );
1907 }
1908
1909 #[test]
1912 fn test_indent_explicit_uses_fixed_style() {
1913 let config = MD007Config {
1916 indent: crate::types::IndentSize::from_const(4),
1917 start_indented: false,
1918 start_indent: crate::types::IndentSize::from_const(2),
1919 style: md007_config::IndentStyle::TextAligned, style_explicit: false, indent_explicit: true, };
1923 let rule = MD007ULIndent::from_config_struct(config);
1924
1925 let content = "* Level 0\n * Level 1\n * Level 2";
1928 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1929 let result = rule.check(&ctx).unwrap();
1930 assert!(
1931 result.is_empty(),
1932 "With indent_explicit=true, should use fixed style (0, 4, 8), got: {result:?}"
1933 );
1934
1935 let wrong_content = "* Level 0\n * Level 1\n * Level 2";
1937 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
1938 let result = rule.check(&ctx).unwrap();
1939 assert!(
1940 !result.is_empty(),
1941 "Should flag text-aligned spacing when indent_explicit=true"
1942 );
1943 }
1944
1945 #[test]
1946 fn test_explicit_style_overrides_indent_explicit() {
1947 let config = MD007Config {
1950 indent: crate::types::IndentSize::from_const(4),
1951 start_indented: false,
1952 start_indent: crate::types::IndentSize::from_const(2),
1953 style: md007_config::IndentStyle::TextAligned,
1954 style_explicit: true, indent_explicit: true, };
1957 let rule = MD007ULIndent::from_config_struct(config);
1958
1959 let content = "* Level 0\n * Level 1\n * Level 2";
1961 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1962 let result = rule.check(&ctx).unwrap();
1963 assert!(
1964 result.is_empty(),
1965 "Explicit text-aligned style should be respected, got: {result:?}"
1966 );
1967 }
1968
1969 #[test]
1970 fn test_no_indent_explicit_uses_smart_detection() {
1971 let config = MD007Config {
1973 indent: crate::types::IndentSize::from_const(4),
1974 start_indented: false,
1975 start_indent: crate::types::IndentSize::from_const(2),
1976 style: md007_config::IndentStyle::TextAligned,
1977 style_explicit: false,
1978 indent_explicit: false, };
1980 let rule = MD007ULIndent::from_config_struct(config);
1981
1982 let content = "* Level 0\n * Level 1";
1985 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1986 let result = rule.check(&ctx).unwrap();
1987 assert!(
1989 result.is_empty(),
1990 "Smart detection should accept 4-space indent, got: {result:?}"
1991 );
1992 }
1993
1994 #[test]
1995 fn test_issue_273_exact_reproduction() {
1996 let config = MD007Config {
1999 indent: crate::types::IndentSize::from_const(4),
2000 start_indented: false,
2001 start_indent: crate::types::IndentSize::from_const(2),
2002 style: md007_config::IndentStyle::TextAligned, style_explicit: false,
2004 indent_explicit: true, };
2006 let rule = MD007ULIndent::from_config_struct(config);
2007
2008 let content = r#"* Item 1
2009 * Item 2
2010 * Item 3"#;
2011 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2012 let result = rule.check(&ctx).unwrap();
2013 assert!(
2014 result.is_empty(),
2015 "Issue #273: indent=4 should use 4-space increments, got: {result:?}"
2016 );
2017 }
2018
2019 #[test]
2020 fn test_indent_explicit_with_ordered_parent() {
2021 let config = MD007Config {
2025 indent: crate::types::IndentSize::from_const(4),
2026 start_indented: false,
2027 start_indent: crate::types::IndentSize::from_const(2),
2028 style: md007_config::IndentStyle::TextAligned,
2029 style_explicit: false,
2030 indent_explicit: true, };
2032 let rule = MD007ULIndent::from_config_struct(config);
2033
2034 let content = "1. Ordered\n * Bullet with 4-space indent";
2036 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2037 let result = rule.check(&ctx).unwrap();
2038 assert!(
2039 result.is_empty(),
2040 "4-space indent under ordered should pass with indent=4: {result:?}"
2041 );
2042
2043 let content_3 = "1. Ordered\n * Bullet with 3-space indent";
2045 let ctx = LintContext::new(content_3, crate::config::MarkdownFlavor::Standard, None);
2046 let result = rule.check(&ctx).unwrap();
2047 assert!(
2048 result.is_empty(),
2049 "3-space indent under ordered should pass (text-aligned): {result:?}"
2050 );
2051
2052 let wrong_content = "1. Ordered\n * Bullet with 2-space indent";
2054 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
2055 let result = rule.check(&ctx).unwrap();
2056 assert!(
2057 !result.is_empty(),
2058 "2-space indent under ordered list should be flagged when indent=4: {result:?}"
2059 );
2060 }
2061
2062 #[test]
2063 fn test_indent_explicit_mixed_list_deep_nesting() {
2064 let config = MD007Config {
2069 indent: crate::types::IndentSize::from_const(4),
2070 start_indented: false,
2071 start_indent: crate::types::IndentSize::from_const(2),
2072 style: md007_config::IndentStyle::TextAligned,
2073 style_explicit: false,
2074 indent_explicit: true,
2075 };
2076 let rule = MD007ULIndent::from_config_struct(config);
2077
2078 let content_text_aligned = r#"* Level 0
2084 * Level 1 (4-space indent from bullet parent)
2085 1. Level 2 ordered
2086 * Level 3 bullet (text-aligned under ordered)"#;
2087 let ctx = LintContext::new(content_text_aligned, crate::config::MarkdownFlavor::Standard, None);
2088 let result = rule.check(&ctx).unwrap();
2089 assert!(
2090 result.is_empty(),
2091 "Text-aligned nesting under ordered should pass: {result:?}"
2092 );
2093
2094 let content_fixed = r#"* Level 0
2095 * Level 1 (4-space indent from bullet parent)
2096 1. Level 2 ordered
2097 * Level 3 bullet (fixed indent under ordered)"#;
2098 let ctx = LintContext::new(content_fixed, crate::config::MarkdownFlavor::Standard, None);
2099 let result = rule.check(&ctx).unwrap();
2100 assert!(
2101 result.is_empty(),
2102 "Fixed indent nesting under ordered should also pass: {result:?}"
2103 );
2104 }
2105
2106 #[test]
2107 fn test_ordered_list_double_digit_markers() {
2108 let config = MD007Config {
2111 indent: crate::types::IndentSize::from_const(4),
2112 start_indented: false,
2113 start_indent: crate::types::IndentSize::from_const(2),
2114 style: md007_config::IndentStyle::TextAligned,
2115 style_explicit: false,
2116 indent_explicit: true,
2117 };
2118 let rule = MD007ULIndent::from_config_struct(config);
2119
2120 let content = "10. Double digit\n * Bullet at col 4";
2122 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2123 let result = rule.check(&ctx).unwrap();
2124 assert!(
2125 result.is_empty(),
2126 "Bullet under '10.' should align at column 4: {result:?}"
2127 );
2128
2129 let content_3 = "1. Single digit\n * Bullet at col 3";
2132 let ctx = LintContext::new(content_3, crate::config::MarkdownFlavor::Standard, None);
2133 let result = rule.check(&ctx).unwrap();
2134 assert!(
2135 result.is_empty(),
2136 "Bullet under '1.' with 3-space indent should pass (text-aligned): {result:?}"
2137 );
2138
2139 let content_4 = "1. Single digit\n * Bullet at col 4";
2140 let ctx = LintContext::new(content_4, crate::config::MarkdownFlavor::Standard, None);
2141 let result = rule.check(&ctx).unwrap();
2142 assert!(
2143 result.is_empty(),
2144 "Bullet under '1.' with 4-space indent should pass (fixed): {result:?}"
2145 );
2146 }
2147
2148 #[test]
2149 fn test_indent_explicit_pure_unordered_uses_fixed() {
2150 let config = MD007Config {
2153 indent: crate::types::IndentSize::from_const(4),
2154 start_indented: false,
2155 start_indent: crate::types::IndentSize::from_const(2),
2156 style: md007_config::IndentStyle::TextAligned,
2157 style_explicit: false,
2158 indent_explicit: true,
2159 };
2160 let rule = MD007ULIndent::from_config_struct(config);
2161
2162 let content = "* Level 0\n * Level 1\n * Level 2";
2164 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2165 let result = rule.check(&ctx).unwrap();
2166 assert!(
2167 result.is_empty(),
2168 "Pure unordered with indent=4 should use 4-space increments: {result:?}"
2169 );
2170
2171 let wrong_content = "* Level 0\n * Level 1\n * Level 2";
2173 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
2174 let result = rule.check(&ctx).unwrap();
2175 assert!(
2176 !result.is_empty(),
2177 "2-space indent should be flagged when indent=4 is configured"
2178 );
2179 }
2180
2181 #[test]
2182 fn test_mkdocs_ordered_list_with_4_space_nested_unordered() {
2183 let rule = MD007ULIndent::default();
2187 let content = "1. text\n\n - nested item";
2188 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2189 let result = rule.check(&ctx).unwrap();
2190 assert!(
2191 result.is_empty(),
2192 "4-space indent under ordered list should be valid in MkDocs flavor, got: {result:?}"
2193 );
2194 }
2195
2196 #[test]
2197 fn test_standard_flavor_ordered_list_with_3_space_nested_unordered() {
2198 let rule = MD007ULIndent::default();
2201 let content = "1. text\n\n - nested item";
2202 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2203 let result = rule.check(&ctx).unwrap();
2204 assert!(
2205 result.is_empty(),
2206 "3-space indent under ordered list should be valid in Standard flavor, got: {result:?}"
2207 );
2208 }
2209
2210 #[test]
2211 fn test_standard_flavor_ordered_list_under_ordered_is_exempt() {
2212 let rule = MD007ULIndent::default();
2217 let content = "1. text\n\n - nested item";
2218 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2219 let result = rule.check(&ctx).unwrap();
2220 assert!(
2221 result.is_empty(),
2222 "unordered sublist of an ordered list must be exempt in Standard flavor, got: {result:?}"
2223 );
2224 }
2225
2226 #[test]
2227 fn test_mkdocs_multi_digit_ordered_list() {
2228 let rule = MD007ULIndent::default();
2231 let content = "10. text\n\n - nested item";
2232 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2233 let result = rule.check(&ctx).unwrap();
2234 assert!(
2235 result.is_empty(),
2236 "4-space indent under `10.` should be valid in MkDocs flavor, got: {result:?}"
2237 );
2238 }
2239
2240 #[test]
2241 fn test_mkdocs_triple_digit_ordered_list() {
2242 let rule = MD007ULIndent::default();
2245 let content = "100. text\n\n - nested item";
2246 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2247 let result = rule.check(&ctx).unwrap();
2248 assert!(
2249 result.is_empty(),
2250 "5-space indent under `100.` should be valid in MkDocs flavor, got: {result:?}"
2251 );
2252 }
2253
2254 #[test]
2255 fn test_mkdocs_insufficient_indent_under_ordered() {
2256 let rule = MD007ULIndent::default();
2259 let content = "1. text\n\n - nested item";
2260 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2261 let result = rule.check(&ctx).unwrap();
2262 assert_eq!(
2263 result.len(),
2264 1,
2265 "2-space indent under ordered list should warn in MkDocs flavor"
2266 );
2267 assert!(
2268 result[0].message.contains("Expected 4"),
2269 "Warning should expect 4 spaces (MkDocs minimum), got: {}",
2270 result[0].message
2271 );
2272 }
2273
2274 #[test]
2275 fn test_mkdocs_deeper_nesting_under_ordered() {
2276 let rule = MD007ULIndent::default();
2281 let content = "1. text\n\n - sub\n - subsub";
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 "Deeper nesting under ordered list should be valid in MkDocs flavor, got: {result:?}"
2287 );
2288 }
2289
2290 #[test]
2291 fn test_mkdocs_fix_adjusts_to_4_spaces() {
2292 let rule = MD007ULIndent::default();
2294 let content = "1. text\n\n - nested item";
2295 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2296 let result = rule.check(&ctx).unwrap();
2297 assert_eq!(result.len(), 1, "3-space indent should warn in MkDocs");
2298 let fixed = rule.fix(&ctx).unwrap();
2299 assert_eq!(
2300 fixed, "1. text\n\n - nested item",
2301 "Fix should adjust indent to 4 spaces in MkDocs"
2302 );
2303 }
2304
2305 #[test]
2306 fn test_mkdocs_start_indented_with_ordered_parent() {
2307 let config = MD007Config {
2310 start_indented: true,
2311 ..Default::default()
2312 };
2313 let rule = MD007ULIndent::from_config_struct(config);
2314 let content = "1. text\n\n - nested item";
2315 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2316 let result = rule.check(&ctx).unwrap();
2317 assert!(
2318 result.is_empty(),
2319 "4-space indent under ordered list with start_indented should be valid in MkDocs, got: {result:?}"
2320 );
2321 }
2322
2323 #[test]
2324 fn test_mkdocs_ordered_at_nonzero_indent() {
2325 let rule = MD007ULIndent::default();
2330 let content = "- outer\n 1. inner\n - deep";
2331 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2332 let result = rule.check(&ctx).unwrap();
2333 assert!(
2334 result.is_empty(),
2335 "6-space indent under nested ordered list should be valid in MkDocs, got: {result:?}"
2336 );
2337 }
2338
2339 #[test]
2340 fn test_mkdocs_blockquoted_ordered_list() {
2341 let rule = MD007ULIndent::default();
2345 let content = "> 1. text\n>\n> - nested item";
2346 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2347 let result = rule.check(&ctx).unwrap();
2348 assert!(
2349 result.is_empty(),
2350 "4-space indent under blockquoted ordered list should be valid in MkDocs, got: {result:?}"
2351 );
2352 }
2353
2354 #[test]
2355 fn test_mkdocs_ordered_at_nonzero_indent_insufficient() {
2356 let rule = MD007ULIndent::default();
2359 let content = "- outer\n 1. inner\n - deep";
2360 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2361 let result = rule.check(&ctx).unwrap();
2362 assert_eq!(
2363 result.len(),
2364 1,
2365 "5-space indent under nested ordered at col 2 should warn in MkDocs (needs 6)"
2366 );
2367 }
2368
2369 #[test]
2370 fn test_issue_504_indent4_ordered_parent() {
2371 let config = MD007Config {
2375 indent: crate::types::IndentSize::from_const(4),
2376 start_indented: false,
2377 start_indent: crate::types::IndentSize::from_const(2),
2378 style: md007_config::IndentStyle::TextAligned,
2379 style_explicit: false,
2380 indent_explicit: true,
2381 };
2382 let rule = MD007ULIndent::from_config_struct(config);
2383
2384 let content = r#"# Things
2385
2386+ An unordered list
2387 + An item with 4 spaces, ok.
2388
23891. A numbered list
2390 + A sublist with 4 spaces, not ok
2391 + A sub item with 4 spaces, ok
2392 + Why is rumdl expecting 3 spaces for a 4 space indent?
23932. Item 2
23943. Item 3"#;
2395 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2396 let result = rule.check(&ctx).unwrap();
2397 assert!(
2398 result.is_empty(),
2399 "Issue #504: indent=4 with ordered parent should accept 4-space indent: {result:?}"
2400 );
2401 }
2402
2403 #[test]
2404 fn test_indent2_explicit_with_ordered_parent() {
2405 let config = MD007Config {
2408 indent: crate::types::IndentSize::from_const(2),
2409 start_indented: false,
2410 start_indent: crate::types::IndentSize::from_const(2),
2411 style: md007_config::IndentStyle::TextAligned,
2412 style_explicit: false,
2413 indent_explicit: true,
2414 };
2415 let rule = MD007ULIndent::from_config_struct(config);
2416
2417 let content = "1. Ordered\n * Bullet at 3 spaces";
2419 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2420 let result = rule.check(&ctx).unwrap();
2421 assert!(
2422 result.is_empty(),
2423 "indent=2 under '1.' should accept text-aligned (3 spaces): {result:?}"
2424 );
2425
2426 let content_2 = "1. Ordered\n * Bullet at 2 spaces";
2428 let ctx = LintContext::new(content_2, crate::config::MarkdownFlavor::Standard, None);
2429 let result = rule.check(&ctx).unwrap();
2430 assert!(
2431 result.is_empty(),
2432 "indent=2 under '1.' should accept fixed indent (2 spaces): {result:?}"
2433 );
2434 }
2435
2436 const ISSUE_638_INPUT: &str = "# Title\n\n1. Some text\n - Indented text\n - more indented\n";
2440
2441 #[test]
2442 fn test_issue_638_unordered_under_ordered_smart_default() {
2443 let rule = MD007ULIndent::new(2);
2444 let ctx = LintContext::new(ISSUE_638_INPUT, crate::config::MarkdownFlavor::Standard, None);
2445 let result = rule.check(&ctx).unwrap();
2446 assert!(
2447 result.is_empty(),
2448 "smart default: unordered items under an ordered list must not be flagged, got: {result:?}"
2449 );
2450 }
2451
2452 #[test]
2453 fn test_issue_638_unordered_under_ordered_indent_explicit() {
2454 let config = MD007Config {
2455 indent: crate::types::IndentSize::from_const(2),
2456 start_indented: false,
2457 start_indent: crate::types::IndentSize::from_const(2),
2458 style: md007_config::IndentStyle::TextAligned,
2459 style_explicit: false,
2460 indent_explicit: true,
2461 };
2462 let rule = MD007ULIndent::from_config_struct(config);
2463 let ctx = LintContext::new(ISSUE_638_INPUT, crate::config::MarkdownFlavor::Standard, None);
2464 let result = rule.check(&ctx).unwrap();
2465 assert!(
2466 result.is_empty(),
2467 "indent=2 explicit: unordered items under an ordered list must not be flagged, got: {result:?}"
2468 );
2469 }
2470
2471 #[test]
2472 fn test_issue_638_unordered_under_ordered_style_fixed() {
2473 let config = MD007Config {
2475 indent: crate::types::IndentSize::from_const(2),
2476 start_indented: false,
2477 start_indent: crate::types::IndentSize::from_const(2),
2478 style: md007_config::IndentStyle::Fixed,
2479 style_explicit: true,
2480 indent_explicit: true,
2481 };
2482 let rule = MD007ULIndent::from_config_struct(config);
2483 let ctx = LintContext::new(ISSUE_638_INPUT, crate::config::MarkdownFlavor::Standard, None);
2484 let result = rule.check(&ctx).unwrap();
2485 assert!(
2486 result.is_empty(),
2487 "style=fixed: unordered items under an ordered list must not be flagged, got: {result:?}"
2488 );
2489 }
2490
2491 #[test]
2492 fn test_issue_638_deeper_unordered_chain_under_ordered() {
2493 let rule = MD007ULIndent::new(2);
2495 let content = "1. Ordered\n - child\n - grandchild\n - great-grandchild\n";
2496 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2497 let result = rule.check(&ctx).unwrap();
2498 assert!(
2499 result.is_empty(),
2500 "all unordered descendants of an ordered list are exempt, got: {result:?}"
2501 );
2502 }
2503
2504 #[test]
2505 fn test_issue_638_pure_unordered_still_checked() {
2506 let rule = MD007ULIndent::new(2);
2508 let content = "- Top\n - three spaces (wrong, expected 2)\n";
2509 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2510 let result = rule.check(&ctx).unwrap();
2511 assert_eq!(
2512 result.len(),
2513 1,
2514 "pure unordered nesting must still be checked, got: {result:?}"
2515 );
2516 }
2517
2518 #[test]
2519 fn test_issue_638_exemption_not_applied_after_list_terminated_by_paragraph() {
2520 let rule = MD007ULIndent::new(2);
2527 let content = "1. ordered\n\nparagraph\n\n - parent\n - child six\n";
2528 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2529 let result = rule.check(&ctx).unwrap();
2530 assert_eq!(
2531 result.len(),
2532 2,
2533 "the new top-level list following a terminated ordered list is checked at both levels, got: {result:?}"
2534 );
2535 assert!(
2536 result.iter().any(|w| w.line == 5 && w.message.contains("Expected 0")),
2537 "the misindented top-level item must be flagged with Expected 0, got: {result:?}"
2538 );
2539 assert!(
2540 result
2541 .iter()
2542 .any(|w| w.line == 6 && w.message.contains("Expected 2") && w.message.contains("found 6")),
2543 "the misindented child must be flagged with Expected 2, found 6, got: {result:?}"
2544 );
2545 }
2546
2547 #[test]
2548 fn test_issue_638_lazy_continuation_does_not_terminate_ordered_list() {
2549 let rule = MD007ULIndent::new(2);
2555 let content = "1. ordered\nlazy continuation\n - child\n - grandchild\n";
2556 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2557 let result = rule.check(&ctx).unwrap();
2558 assert!(
2559 result.is_empty(),
2560 "lazy continuation must not terminate the ordered list; sublist stays exempt, got: {result:?}"
2561 );
2562 }
2563
2564 #[test]
2565 fn test_issue_638_heading_interrupts_ordered_list_without_blank() {
2566 let rule = MD007ULIndent::new(2);
2573 let content = "1. ordered\n# heading\n - child\n - grandchild\n";
2574 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2575 let result = rule.check(&ctx).unwrap();
2576 assert_eq!(
2577 result.len(),
2578 2,
2579 "a heading terminates the ordered list, so the new top-level list and its child are both checked, got: {result:?}"
2580 );
2581 assert!(
2582 result.iter().any(|w| w.line == 3 && w.message.contains("Expected 0")),
2583 "the misindented top-level item must be flagged with Expected 0, got: {result:?}"
2584 );
2585 assert!(
2586 result.iter().any(|w| w.line == 4 && w.message.contains("Expected 2")),
2587 "the misindented child must be flagged with Expected 2, got: {result:?}"
2588 );
2589 }
2590
2591 #[test]
2592 fn test_issue_638_lazy_continuation_inside_blockquote_keeps_exemption() {
2593 let rule = MD007ULIndent::new(2);
2598 let content = "> 1. ordered\n> continuation\n>\n> - child\n> - grandchild\n";
2599 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2600 let result = rule.check(&ctx).unwrap();
2601 assert!(
2602 result.is_empty(),
2603 "a lazy continuation within the same blockquote must keep the sublist exempt, got: {result:?}"
2604 );
2605 }
2606
2607 #[test]
2608 fn test_issue_638_indented_fence_inside_blockquoted_ordered_item_keeps_exemption() {
2609 let rule = MD007ULIndent::new(2);
2614 let content = "> 1. ordered\n> ```\n> code\n> ```\n> - child\n> - grandchild\n";
2615 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2616 let result = rule.check(&ctx).unwrap();
2617 assert!(
2618 result.is_empty(),
2619 "an indented fence inside a blockquoted ordered item must keep the sublist exempt, got: {result:?}"
2620 );
2621 }
2622
2623 #[test]
2624 fn test_issue_638_fenced_code_block_terminates_ordered_list() {
2625 let rule = MD007ULIndent::new(2);
2631 let content = "1. ordered\n```\ncode\n```\n\n - parent\n - child\n";
2632 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2633 let result = rule.check(&ctx).unwrap();
2634 assert!(
2635 result.iter().any(|w| w.line == 7),
2636 "a top-level fenced code block terminates the ordered list; the child must be flagged, got: {result:?}"
2637 );
2638 }
2639
2640 #[test]
2641 fn test_issue_638_fenced_code_block_inside_item_keeps_exemption() {
2642 let rule = MD007ULIndent::new(2);
2647 let content = "1. ordered\n ```\n code\n ```\n - child\n - grandchild\n";
2648 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2649 let result = rule.check(&ctx).unwrap();
2650 assert!(
2651 result.is_empty(),
2652 "a fenced code block nested inside the item must keep the sublist exempt, got: {result:?}"
2653 );
2654 }
2655
2656 #[test]
2657 fn test_issue_638_blockquote_terminates_ordered_list() {
2658 let rule = MD007ULIndent::new(2);
2665 let content = "1. ordered\n> quote\n\n - parent\n - child\n";
2666 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2667 let result = rule.check(&ctx).unwrap();
2668 assert!(
2669 result.iter().any(|w| w.line == 5),
2670 "blockquote terminates the ordered list, so the child must still be flagged, got: {result:?}"
2671 );
2672 }
2673
2674 #[test]
2675 fn test_issue_638_blockquote_inside_item_keeps_exemption() {
2676 let rule = MD007ULIndent::new(2);
2681 let content = "1. ordered\n > quote inside item\n - child\n - grandchild\n";
2682 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2683 let result = rule.check(&ctx).unwrap();
2684 assert!(
2685 result.is_empty(),
2686 "a blockquote nested inside the item must keep the sublist exempt, got: {result:?}"
2687 );
2688 }
2689
2690 #[test]
2691 fn test_issue_638_exemption_requires_genuine_nesting_under_ordered() {
2692 let rule = MD007ULIndent::new(2);
2701 let content = "100. ordered\n - parent\n - child\n";
2702 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2703 let result = rule.check(&ctx).unwrap();
2704 assert!(
2705 result.iter().any(|w| w.line == 3),
2706 "the child of a non-nested bullet must still be checked, not exempted; got: {result:?}"
2707 );
2708 }
2709
2710 #[test]
2711 fn test_issue_638_paragraph_after_fenced_code_closes_ordered_list() {
2712 let rule = MD007ULIndent::new(2);
2721 let content = "1. ordered\n ```\n code\n ```\nnot lazy text\n - parent\n - child\n";
2722 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2723 let result = rule.check(&ctx).unwrap();
2724 assert!(
2725 result.iter().any(|w| w.line == 7),
2726 "fenced code is not paragraph text, so the list closes and the nested child must still be checked, not exempted; got: {result:?}"
2727 );
2728 }
2729
2730 #[test]
2731 fn test_issue_638_overlong_ordered_marker_is_lazy_continuation() {
2732 let rule = MD007ULIndent::new(2);
2738 let content = "1. ordered\n1234567890. this is continuation text\n - child\n - grandchild\n";
2739 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2740 let result = rule.check(&ctx).unwrap();
2741 assert!(
2742 result.is_empty(),
2743 "an overlong digit run is not a valid ordered marker, so the list stays open and the nested bullets are exempt; got: {result:?}"
2744 );
2745 }
2746
2747 #[test]
2748 fn test_indented_top_level_list_item_is_flagged() {
2749 let rule = MD007ULIndent::new(2);
2755 for indent in 2..=3 {
2756 let pad = " ".repeat(indent);
2757 let content = format!("{pad}- parent\n{pad} - child\n");
2758 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
2759 let result = rule.check(&ctx).unwrap();
2760 assert!(
2761 result.iter().any(|w| w.line == 1),
2762 "a top-level item indented {indent} spaces must be flagged (Expected 0); got: {result:?}"
2763 );
2764 }
2765 }
2766
2767 #[test]
2768 fn test_indented_code_block_bullet_is_not_a_list_item() {
2769 let rule = MD007ULIndent::new(2);
2772 let content = " - not a list, this is code\n";
2773 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2774 let result = rule.check(&ctx).unwrap();
2775 assert!(
2776 result.is_empty(),
2777 "a 4-space-indented bullet is an indented code block, not a misindented list; got: {result:?}"
2778 );
2779 }
2780
2781 #[test]
2782 fn test_tab_indent_expands_to_four_column_tabstop() {
2783 let rule = MD007ULIndent::new(2);
2790 let content = "- a\n\t- b\n";
2791 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2792 let result = rule.check(&ctx).unwrap();
2793 let warning = result
2794 .iter()
2795 .find(|w| w.line == 2)
2796 .expect("a tab-indented sublist at column 4 is over-indented for depth 1 and must be flagged");
2797 assert!(
2798 warning.message.contains("found 4"),
2799 "the tab must expand to the 4-column tab stop (found 4), not be counted as one character; got: {}",
2800 warning.message
2801 );
2802 }
2803
2804 #[test]
2805 fn test_tab_completing_two_space_indent_to_tabstop_is_accepted() {
2806 let rule = MD007ULIndent::new(2);
2812 let content = "- a\n - b\n \t- c\n";
2813 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2814 let result = rule.check(&ctx).unwrap();
2815 assert!(
2816 result.is_empty(),
2817 "` \\t` expands to column 4, the correct depth-2 indent, so no MD007 warning is expected; got: {result:?}"
2818 );
2819 }
2820
2821 #[test]
2822 fn test_issue_638_html_comment_terminates_ordered_list() {
2823 let rule = MD007ULIndent::new(2);
2830 let content = "1. ordered\n<!-- comment -->\n\n - parent\n - child\n";
2831 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2832 let result = rule.check(&ctx).unwrap();
2833 assert!(
2834 result.iter().any(|w| w.line == 5),
2835 "an HTML comment terminates the ordered list, so the child must still be flagged, got: {result:?}"
2836 );
2837 }
2838
2839 #[test]
2840 fn test_issue_638_blockquoted_list_item_terminates_ordered_list() {
2841 let rule = MD007ULIndent::new(2);
2849 let content = "1. ordered\n> - quote list\n\n - parent\n - child\n";
2850 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2851 let result = rule.check(&ctx).unwrap();
2852 assert!(
2853 result.iter().any(|w| w.line == 5),
2854 "a blockquoted list item terminates the ordered list, so the child must still be flagged, got: {result:?}"
2855 );
2856 }
2857
2858 #[test]
2859 fn test_issue_638_deeper_nested_quote_terminates_blockquoted_ordered_list() {
2860 let rule = MD007ULIndent::new(2);
2870 let content = "> 1. ordered\n> > quote\n>\n> - parent\n> - child\n";
2871 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2872 let result = rule.check(&ctx).unwrap();
2873 assert!(
2874 result.iter().any(|w| w.line == 4),
2875 "deeper nested quote closes the ordered list, so the misindented parent must be flagged, got: {result:?}"
2876 );
2877 assert!(
2878 result.iter().any(|w| w.line == 5),
2879 "the child of the fresh unordered list must be flagged, not exempted, got: {result:?}"
2880 );
2881 }
2882
2883 #[test]
2884 fn test_issue_638_deeper_quote_list_item_terminates_blockquoted_ordered_list() {
2885 let rule = MD007ULIndent::new(2);
2893 let content = "> 1. ordered\n> > - quote list\n>\n> - parent\n> - child\n";
2894 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2895 let result = rule.check(&ctx).unwrap();
2896 assert!(
2897 result.iter().any(|w| w.line == 4),
2898 "a deeper-quote list item closes the ordered list, so the parent must be flagged, got: {result:?}"
2899 );
2900 assert!(
2901 result.iter().any(|w| w.line == 5),
2902 "the child of the fresh unordered list must be flagged, not exempted, got: {result:?}"
2903 );
2904 }
2905
2906 #[test]
2907 fn test_issue_638_deeper_quote_indented_into_item_keeps_exemption() {
2908 let rule = MD007ULIndent::new(2);
2913 let content = "> 1. ordered\n> > quote inside item\n> - child\n> - grandchild\n";
2914 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2915 let result = rule.check(&ctx).unwrap();
2916 assert!(
2917 result.is_empty(),
2918 "a deeper quote indented into the item must keep the sublist exempt, got: {result:?}"
2919 );
2920 }
2921
2922 #[test]
2923 fn test_indent4_explicit_with_wide_ordered_parent() {
2924 let config = MD007Config {
2928 indent: crate::types::IndentSize::from_const(4),
2929 start_indented: false,
2930 start_indent: crate::types::IndentSize::from_const(2),
2931 style: md007_config::IndentStyle::TextAligned,
2932 style_explicit: false,
2933 indent_explicit: true,
2934 };
2935 let rule = MD007ULIndent::from_config_struct(config);
2936
2937 let content = "100. Wide ordered\n * Bullet at 5 spaces";
2939 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2940 let result = rule.check(&ctx).unwrap();
2941 assert!(
2942 result.is_empty(),
2943 "indent=4 under '100.' should accept 5-space indent: {result:?}"
2944 );
2945
2946 let content_4 = "100. Wide ordered\n * Bullet at 4 spaces";
2948 let ctx = LintContext::new(content_4, crate::config::MarkdownFlavor::Standard, None);
2949 let result = rule.check(&ctx).unwrap();
2950 assert!(
2951 result.is_empty(),
2952 "indent=4 under '100.' should accept 4-space indent: {result:?}"
2953 );
2954 }
2955
2956 fn commonmark_max_list_depth(md: &str) -> usize {
2960 use pulldown_cmark::{Event, Parser, Tag, TagEnd};
2961 let (mut depth, mut max) = (0usize, 0usize);
2962 for event in Parser::new(md) {
2963 match event {
2964 Event::Start(Tag::List(_)) => {
2965 depth += 1;
2966 max = max.max(depth);
2967 }
2968 Event::End(TagEnd::List(_)) => depth = depth.saturating_sub(1),
2969 _ => {}
2970 }
2971 }
2972 max
2973 }
2974
2975 #[test]
2976 fn test_md007_widened_parent_marker_keeps_nested_child() {
2977 let rule = MD007ULIndent::default();
2983 let content = indoc! {"
2984 - Parent item
2985 - Nested item
2986 "};
2987 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2988 let result = rule.check(&ctx).unwrap();
2989 assert!(
2990 result.is_empty(),
2991 "a child aligned to a widened parent's content column must not be flagged: {result:?}"
2992 );
2993 assert_eq!(commonmark_max_list_depth(content), 2, "precondition: source is nested");
2994 assert_eq!(
2995 rule.fix(&ctx).unwrap(),
2996 content,
2997 "fix must be a no-op for an already correctly nested child"
2998 );
2999 }
3000
3001 #[test]
3002 fn test_md007_widened_parent_aligns_child_to_content_column() {
3003 let rule = MD007ULIndent::default();
3006 let content = indoc! {"
3007 - Parent item
3008 - Nested item
3009 "};
3010 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3011 let fixed = rule.fix(&ctx).unwrap();
3012 assert_eq!(
3013 fixed,
3014 indoc! {"
3015 - Parent item
3016 - Nested item
3017 "},
3018 "child must align to the parent's content column 4: {fixed:?}"
3019 );
3020 assert_eq!(
3021 commonmark_max_list_depth(&fixed),
3022 2,
3023 "fixed child must remain nested, not flattened to a sibling:\n{fixed}"
3024 );
3025 }
3026
3027 #[test]
3028 fn test_md007_widened_markers_nested_multiple_levels() {
3029 let rule = MD007ULIndent::default();
3032 let content = indoc! {"
3033 - Level 0
3034 - Level 1
3035 - Level 2
3036 "};
3037 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3038 let result = rule.check(&ctx).unwrap();
3039 assert!(
3040 result.is_empty(),
3041 "deeply nested widened markers must not be flagged: {result:?}"
3042 );
3043 assert_eq!(
3044 commonmark_max_list_depth(content),
3045 3,
3046 "three nesting levels are preserved"
3047 );
3048 }
3049
3050 #[test]
3051 fn test_md007_default_marker_indent_still_enforced() {
3052 let rule = MD007ULIndent::default();
3056 let content = indoc! {"
3057 - Parent item
3058 - Nested item
3059 "};
3060 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3061 let result = rule.check(&ctx).unwrap();
3062 assert_eq!(
3063 result.len(),
3064 1,
3065 "an over-indented child under a normal marker is still flagged: {result:?}"
3066 );
3067 assert_eq!(
3068 rule.fix(&ctx).unwrap(),
3069 indoc! {"
3070 - Parent item
3071 - Nested item
3072 "}
3073 );
3074 }
3075}