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 expected_content_visual_col = accepted_indent + 2;
502 list_stack.push((
503 visual_marker_column,
504 line_idx,
505 false,
506 expected_content_visual_col,
507 bq_depth,
508 false,
509 ));
510
511 if !self.config.start_indented && nesting_level == 0 && visual_marker_column == 0 {
517 continue;
518 }
519
520 if visual_marker_column != expected_indent && also_acceptable != Some(visual_marker_column) {
521 if let Some(alt) = also_acceptable {
523 expected_indent = alt;
524 }
525 let fix = {
527 let correct_indent = " ".repeat(expected_indent);
528
529 let replacement = if line_info.blockquote.is_some() {
532 let mut blockquote_count = 0;
534 for ch in line_info.content(ctx.content).chars() {
535 if ch == '>' {
536 blockquote_count += 1;
537 } else if ch != ' ' && ch != '\t' {
538 break;
539 }
540 }
541 let blockquote_prefix = if blockquote_count > 1 {
543 (0..blockquote_count)
544 .map(|_| "> ")
545 .collect::<String>()
546 .trim_end()
547 .to_string()
548 } else {
549 ">".to_string()
550 };
551 format!("{blockquote_prefix} {correct_indent}")
554 } else {
555 correct_indent
556 };
557
558 let start_byte = line_info.byte_offset;
561 let mut end_byte = line_info.byte_offset;
562
563 for (i, ch) in line_info.content(ctx.content).chars().enumerate() {
565 if i >= list_item.marker_column {
566 break;
567 }
568 end_byte += ch.len_utf8();
569 }
570
571 Some(crate::rule::Fix::new(start_byte..end_byte, replacement))
572 };
573
574 warnings.push(LintWarning {
575 rule_name: Some(self.name().to_string()),
576 message: format!(
577 "Expected {expected_indent} spaces for indent depth {nesting_level}, found {visual_marker_column}"
578 ),
579 line: line_idx + 1, column: 1, end_line: line_idx + 1,
582 end_column: visual_marker_column + 1, severity: Severity::Warning,
584 fix,
585 });
586 }
587 } else if !line_info.is_blank {
588 let bq_depth = line_info.blockquote.as_ref().map_or(0, |bq| bq.nesting_level);
615 let prev_line = line_idx.checked_sub(1).map(|i| &ctx.lines[i]);
616 let prev_blank = prev_line.is_none_or(|p| p.is_blank);
617 let prev_bq_depth = prev_line
618 .and_then(|p| p.blockquote.as_ref())
619 .map_or(0, |bq| bq.nesting_level);
620 let same_container = prev_bq_depth == bq_depth;
621 let text = line_info
622 .blockquote
623 .as_ref()
624 .map_or_else(|| line_info.content(ctx.content), |bq| bq.content.as_str());
625 let trimmed = text.trim_start();
626 let starts_like_list_marker = match trimmed.as_bytes().first() {
627 Some(b'-' | b'*' | b'+') => {
628 matches!(trimmed.as_bytes().get(1), Some(b' ' | b'\t'))
629 }
630 Some(c) if c.is_ascii_digit() => {
631 let after_digits = trimmed.trim_start_matches(|ch: char| ch.is_ascii_digit());
635 let num_digits = trimmed.len() - after_digits.len();
636 let mut rest = after_digits.chars();
637 (1..=9).contains(&num_digits)
638 && matches!(rest.next(), Some('.' | ')'))
639 && matches!(rest.next(), Some(' ' | '\t') | None)
640 }
641 _ => false,
642 };
643 let prev_is_open_paragraph = prev_line.is_some_and(|p| {
650 !p.is_blank
651 && !p.in_code_block
652 && p.heading.is_none()
653 && !p.is_horizontal_rule
654 && !p.in_html_block
655 && !p.in_html_comment
656 && !p.is_div_marker
657 });
658 let is_lazy_paragraph_continuation = !prev_blank
659 && prev_is_open_paragraph
660 && same_container
661 && !starts_like_list_marker
662 && line_info.heading.is_none()
663 && !line_info.is_horizontal_rule
664 && !line_info.in_code_block
665 && !line_info.in_html_block
666 && !line_info.in_html_comment
667 && !line_info.is_div_marker;
668 if is_lazy_paragraph_continuation {
669 continue;
671 }
672 Self::terminate_closed_items(ctx, line_info, &mut list_stack, bq_depth);
673 }
674 }
675 Ok(warnings)
676 }
677
678 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
680 let warnings = self.check(ctx)?;
682 let warnings =
683 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
684
685 if warnings.is_empty() {
687 return Ok(ctx.content.to_string());
688 }
689
690 let mut fixes: Vec<_> = warnings
692 .iter()
693 .filter_map(|w| w.fix.as_ref().map(|f| (f.range.start, f.range.end, &f.replacement)))
694 .collect();
695 fixes.sort_by_key(|f| std::cmp::Reverse(f.0));
696
697 let mut result = ctx.content.to_string();
699 for (start, end, replacement) in fixes {
700 if start < result.len() && end <= result.len() && start <= end {
701 result.replace_range(start..end, replacement);
702 }
703 }
704
705 Ok(result)
706 }
707
708 fn category(&self) -> RuleCategory {
710 RuleCategory::List
711 }
712
713 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
715 if ctx.content.is_empty() || !ctx.likely_has_lists() {
717 return true;
718 }
719 !ctx.lines
721 .iter()
722 .any(|line| line.list_item.as_ref().is_some_and(|item| !item.is_ordered))
723 }
724
725 fn as_any(&self) -> &dyn std::any::Any {
726 self
727 }
728
729 fn default_config_section(&self) -> Option<(String, toml::Value)> {
730 let default_config = MD007Config::default();
731 let json_value = serde_json::to_value(&default_config).ok()?;
732 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
733
734 if let toml::Value::Table(table) = toml_value {
735 if !table.is_empty() {
736 Some((MD007Config::RULE_NAME.to_string(), toml::Value::Table(table)))
737 } else {
738 None
739 }
740 } else {
741 None
742 }
743 }
744
745 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
746 where
747 Self: Sized,
748 {
749 let mut rule_config = crate::rule_config_serde::load_rule_config::<MD007Config>(config);
750
751 if let Some(rule_cfg) = config.rules.get("MD007") {
753 rule_config.style_explicit = rule_cfg.values.contains_key("style");
754 rule_config.indent_explicit = rule_cfg.values.contains_key("indent");
755
756 if rule_config.indent_explicit
760 && rule_config.style_explicit
761 && rule_config.style == md007_config::IndentStyle::TextAligned
762 {
763 eprintln!(
764 "\x1b[33m[config warning]\x1b[0m MD007: 'indent' has no effect when 'style = \"text-aligned\"'. \
765 Text-aligned style ignores indent and aligns nested items with parent text. \
766 To use fixed {} space increments, either remove 'style' or set 'style = \"fixed\"'.",
767 rule_config.indent.get()
768 );
769 }
770 }
771
772 if config.markdown_flavor() == crate::config::MarkdownFlavor::MkDocs {
775 if rule_config.indent_explicit && rule_config.indent.get() < 4 {
776 eprintln!(
777 "\x1b[33m[config warning]\x1b[0m MD007: MkDocs flavor requires indent >= 4 \
778 (Python-Markdown enforces 4-space indentation). \
779 Overriding indent={} to indent=4.",
780 rule_config.indent.get()
781 );
782 }
783 if rule_config.style_explicit && rule_config.style == md007_config::IndentStyle::TextAligned {
784 eprintln!(
785 "\x1b[33m[config warning]\x1b[0m MD007: MkDocs flavor requires style=\"fixed\" \
786 (Python-Markdown uses fixed 4-space indentation). \
787 Overriding style=\"text-aligned\" to style=\"fixed\"."
788 );
789 }
790 if rule_config.indent.get() < 4 {
791 rule_config.indent = crate::types::IndentSize::from_const(4);
792 }
793 rule_config.style = md007_config::IndentStyle::Fixed;
794 }
795
796 Box::new(Self::from_config_struct(rule_config))
797 }
798}
799
800#[cfg(test)]
801mod tests {
802 use super::*;
803 use crate::lint_context::LintContext;
804 use crate::rule::Rule;
805
806 #[test]
807 fn test_valid_list_indent() {
808 let rule = MD007ULIndent::default();
809 let content = "* Item 1\n * Item 2\n * Item 3";
810 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
811 let result = rule.check(&ctx).unwrap();
812 assert!(
813 result.is_empty(),
814 "Expected no warnings for valid indentation, but got {} warnings",
815 result.len()
816 );
817 }
818
819 #[test]
820 fn test_invalid_list_indent() {
821 let rule = MD007ULIndent::default();
822 let content = "* Item 1\n * Item 2\n * Item 3";
823 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
824 let result = rule.check(&ctx).unwrap();
825 assert_eq!(result.len(), 2);
826 assert_eq!(result[0].line, 2);
827 assert_eq!(result[0].column, 1);
828 assert_eq!(result[1].line, 3);
829 assert_eq!(result[1].column, 1);
830 }
831
832 #[test]
833 fn test_mixed_indentation() {
834 let rule = MD007ULIndent::default();
835 let content = "* Item 1\n * Item 2\n * Item 3\n * Item 4";
836 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
837 let result = rule.check(&ctx).unwrap();
838 assert_eq!(result.len(), 1);
839 assert_eq!(result[0].line, 3);
840 assert_eq!(result[0].column, 1);
841 }
842
843 #[test]
844 fn test_fix_indentation() {
845 let rule = MD007ULIndent::default();
846 let content = "* Item 1\n * Item 2\n * Item 3";
847 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
848 let result = rule.fix(&ctx).unwrap();
849 let expected = "* Item 1\n * Item 2\n * Item 3";
853 assert_eq!(result, expected);
854 }
855
856 #[test]
857 fn test_md007_in_yaml_code_block() {
858 let rule = MD007ULIndent::default();
859 let content = r#"```yaml
860repos:
861- repo: https://github.com/rvben/rumdl
862 rev: v0.5.0
863 hooks:
864 - id: rumdl-check
865```"#;
866 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
867 let result = rule.check(&ctx).unwrap();
868 assert!(
869 result.is_empty(),
870 "MD007 should not trigger inside a code block, but got warnings: {result:?}"
871 );
872 }
873
874 #[test]
875 fn test_blockquoted_list_indent() {
876 let rule = MD007ULIndent::default();
877 let content = "> * Item 1\n> * Item 2\n> * Item 3";
878 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
879 let result = rule.check(&ctx).unwrap();
880 assert!(
881 result.is_empty(),
882 "Expected no warnings for valid blockquoted list indentation, but got {result:?}"
883 );
884 }
885
886 #[test]
887 fn test_blockquoted_list_invalid_indent() {
888 let rule = MD007ULIndent::default();
889 let content = "> * Item 1\n> * Item 2\n> * Item 3";
890 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
891 let result = rule.check(&ctx).unwrap();
892 assert_eq!(
893 result.len(),
894 2,
895 "Expected 2 warnings for invalid blockquoted list indentation, got {result:?}"
896 );
897 assert_eq!(result[0].line, 2);
898 assert_eq!(result[1].line, 3);
899 }
900
901 #[test]
902 fn test_nested_blockquote_list_indent() {
903 let rule = MD007ULIndent::default();
904 let content = "> > * Item 1\n> > * Item 2\n> > * Item 3";
905 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
906 let result = rule.check(&ctx).unwrap();
907 assert!(
908 result.is_empty(),
909 "Expected no warnings for valid nested blockquoted list indentation, but got {result:?}"
910 );
911 }
912
913 #[test]
914 fn test_blockquote_list_with_code_block() {
915 let rule = MD007ULIndent::default();
916 let content = "> * Item 1\n> * Item 2\n> ```\n> code\n> ```\n> * Item 3";
917 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
918 let result = rule.check(&ctx).unwrap();
919 assert!(
920 result.is_empty(),
921 "MD007 should not trigger inside a code block within a blockquote, but got warnings: {result:?}"
922 );
923 }
924
925 #[test]
926 fn test_properly_indented_lists() {
927 let rule = MD007ULIndent::default();
928
929 let test_cases = vec![
931 "* Item 1\n* Item 2",
932 "* Item 1\n * Item 1.1\n * Item 1.1.1",
933 "- Item 1\n - Item 1.1",
934 "+ Item 1\n + Item 1.1",
935 "* Item 1\n * Item 1.1\n* Item 2\n * Item 2.1",
936 ];
937
938 for content in test_cases {
939 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
940 let result = rule.check(&ctx).unwrap();
941 assert!(
942 result.is_empty(),
943 "Expected no warnings for properly indented list:\n{}\nGot {} warnings",
944 content,
945 result.len()
946 );
947 }
948 }
949
950 #[test]
951 fn test_under_indented_lists() {
952 let rule = MD007ULIndent::default();
953
954 let test_cases = vec![
955 ("* Item 1\n * Item 1.1", 1, 2), ("* Item 1\n * Item 1.1\n * Item 1.1.1", 1, 3), ];
958
959 for (content, expected_warnings, line) in test_cases {
960 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
961 let result = rule.check(&ctx).unwrap();
962 assert_eq!(
963 result.len(),
964 expected_warnings,
965 "Expected {expected_warnings} warnings for under-indented list:\n{content}"
966 );
967 if expected_warnings > 0 {
968 assert_eq!(result[0].line, line);
969 }
970 }
971 }
972
973 #[test]
974 fn test_over_indented_lists() {
975 let rule = MD007ULIndent::default();
976
977 let test_cases = vec![
978 ("* 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), ];
982
983 for (content, expected_warnings, line) in test_cases {
984 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
985 let result = rule.check(&ctx).unwrap();
986 assert_eq!(
987 result.len(),
988 expected_warnings,
989 "Expected {expected_warnings} warnings for over-indented list:\n{content}"
990 );
991 if expected_warnings > 0 {
992 assert_eq!(result[0].line, line);
993 }
994 }
995 }
996
997 #[test]
998 fn test_custom_indent_2_spaces() {
999 let rule = MD007ULIndent::new(2); let content = "* Item 1\n * Item 2\n * Item 3";
1001 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1002 let result = rule.check(&ctx).unwrap();
1003 assert!(result.is_empty());
1004 }
1005
1006 #[test]
1007 fn test_custom_indent_3_spaces() {
1008 let rule = MD007ULIndent::new(3);
1011
1012 let correct_content = "* Item 1\n * Item 2\n * Item 3";
1014 let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard, None);
1015 let result = rule.check(&ctx).unwrap();
1016 assert!(
1017 result.is_empty(),
1018 "Fixed style expects 0, 3, 6 spaces but got: {result:?}"
1019 );
1020
1021 let wrong_content = "* Item 1\n * Item 2\n * Item 3";
1023 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
1024 let result = rule.check(&ctx).unwrap();
1025 assert!(!result.is_empty(), "Should warn: expected 3 spaces, found 2");
1026 }
1027
1028 #[test]
1029 fn test_custom_indent_4_spaces() {
1030 let rule = MD007ULIndent::new(4);
1033
1034 let correct_content = "* Item 1\n * Item 2\n * Item 3";
1036 let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard, None);
1037 let result = rule.check(&ctx).unwrap();
1038 assert!(
1039 result.is_empty(),
1040 "Fixed style expects 0, 4, 8 spaces but got: {result:?}"
1041 );
1042
1043 let wrong_content = "* Item 1\n * Item 2\n * Item 3";
1045 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
1046 let result = rule.check(&ctx).unwrap();
1047 assert!(!result.is_empty(), "Should warn: expected 4 spaces, found 2");
1048 }
1049
1050 #[test]
1051 fn test_tab_indentation() {
1052 let rule = MD007ULIndent::default();
1053
1054 let content = "* Item 1\n * Item 2";
1060 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1061 let result = rule.check(&ctx).unwrap();
1062 assert_eq!(result.len(), 1, "Wrong indentation should trigger warning");
1063
1064 let fixed = rule.fix(&ctx).unwrap();
1066 assert_eq!(fixed, "* Item 1\n * Item 2");
1067
1068 let content_multi = "* Item 1\n * Item 2\n * Item 3";
1070 let ctx = LintContext::new(content_multi, crate::config::MarkdownFlavor::Standard, None);
1071 let fixed = rule.fix(&ctx).unwrap();
1072 assert_eq!(fixed, "* Item 1\n * Item 2\n * Item 3");
1075
1076 let content_mixed = "* Item 1\n * Item 2\n * Item 3";
1078 let ctx = LintContext::new(content_mixed, crate::config::MarkdownFlavor::Standard, None);
1079 let fixed = rule.fix(&ctx).unwrap();
1080 assert_eq!(fixed, "* Item 1\n * Item 2\n * Item 3");
1083 }
1084
1085 #[test]
1086 fn test_mixed_ordered_unordered_lists() {
1087 let rule = MD007ULIndent::default();
1088
1089 let content = r#"1. Ordered item
1092 * Unordered sub-item (correct - 3 spaces under ordered)
1093 2. Ordered sub-item
1094* Unordered item
1095 1. Ordered sub-item
1096 * Unordered sub-item"#;
1097
1098 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1099 let result = rule.check(&ctx).unwrap();
1100 assert_eq!(result.len(), 0, "All unordered list indentation should be correct");
1101
1102 let fixed = rule.fix(&ctx).unwrap();
1104 assert_eq!(fixed, content);
1105 }
1106
1107 #[test]
1108 fn test_list_markers_variety() {
1109 let rule = MD007ULIndent::default();
1110
1111 let content = r#"* Asterisk
1113 * Nested asterisk
1114- Hyphen
1115 - Nested hyphen
1116+ Plus
1117 + Nested plus"#;
1118
1119 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1120 let result = rule.check(&ctx).unwrap();
1121 assert!(
1122 result.is_empty(),
1123 "All unordered list markers should work with proper indentation"
1124 );
1125
1126 let wrong_content = r#"* Asterisk
1128 * Wrong asterisk
1129- Hyphen
1130 - Wrong hyphen
1131+ Plus
1132 + Wrong plus"#;
1133
1134 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
1135 let result = rule.check(&ctx).unwrap();
1136 assert_eq!(result.len(), 3, "All marker types should be checked for indentation");
1137 }
1138
1139 #[test]
1140 fn test_empty_list_items() {
1141 let rule = MD007ULIndent::default();
1142 let content = "* Item 1\n* \n * Item 2";
1143 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1144 let result = rule.check(&ctx).unwrap();
1145 assert!(
1146 result.is_empty(),
1147 "Empty list items should not affect indentation checks"
1148 );
1149 }
1150
1151 #[test]
1152 fn test_list_with_code_blocks() {
1153 let rule = MD007ULIndent::default();
1154 let content = r#"* Item 1
1155 ```
1156 code
1157 ```
1158 * Item 2
1159 * Item 3"#;
1160 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1161 let result = rule.check(&ctx).unwrap();
1162 assert!(result.is_empty());
1163 }
1164
1165 #[test]
1166 fn test_list_in_front_matter() {
1167 let rule = MD007ULIndent::default();
1168 let content = r#"---
1169tags:
1170 - tag1
1171 - tag2
1172---
1173* Item 1
1174 * Item 2"#;
1175 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1176 let result = rule.check(&ctx).unwrap();
1177 assert!(result.is_empty(), "Lists in YAML front matter should be ignored");
1178 }
1179
1180 #[test]
1181 fn test_fix_preserves_content() {
1182 let rule = MD007ULIndent::default();
1183 let content = "* Item 1 with **bold** and *italic*\n * Item 2 with `code`\n * Item 3 with [link](url)";
1184 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1185 let fixed = rule.fix(&ctx).unwrap();
1186 let expected = "* Item 1 with **bold** and *italic*\n * Item 2 with `code`\n * Item 3 with [link](url)";
1189 assert_eq!(fixed, expected, "Fix should only change indentation, not content");
1190 }
1191
1192 #[test]
1193 fn test_start_indented_config() {
1194 let config = MD007Config {
1195 start_indented: true,
1196 start_indent: crate::types::IndentSize::from_const(4),
1197 indent: crate::types::IndentSize::from_const(2),
1198 style: md007_config::IndentStyle::TextAligned,
1199 style_explicit: true, indent_explicit: false,
1201 };
1202 let rule = MD007ULIndent::from_config_struct(config);
1203
1204 let content = " * Item 1\n * Item 2\n * Item 3";
1209 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1210 let result = rule.check(&ctx).unwrap();
1211 assert!(result.is_empty(), "Expected no warnings with start_indented config");
1212
1213 let wrong_content = " * Item 1\n * Item 2";
1215 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
1216 let result = rule.check(&ctx).unwrap();
1217 assert_eq!(result.len(), 2);
1218 assert_eq!(result[0].line, 1);
1219 assert_eq!(result[0].message, "Expected 4 spaces for indent depth 0, found 2");
1220 assert_eq!(result[1].line, 2);
1221 assert_eq!(result[1].message, "Expected 6 spaces for indent depth 1, found 4");
1222
1223 let fixed = rule.fix(&ctx).unwrap();
1225 assert_eq!(fixed, " * Item 1\n * Item 2");
1226 }
1227
1228 #[test]
1229 fn test_start_indented_false_flags_indented_first_level() {
1230 let rule = MD007ULIndent::default(); let content = " * Item 1"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1238 let result = rule.check(&ctx).unwrap();
1239 assert!(
1240 result.iter().any(|w| w.line == 1 && w.message.contains("Expected 0")),
1241 "a top-level item indented 3 spaces must be flagged with Expected 0, got: {result:?}"
1242 );
1243
1244 let content = "* Item 1\n * Item 2\n * Item 3";
1248 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1249 let result = rule.check(&ctx).unwrap();
1250 assert!(
1251 result.is_empty(),
1252 "a correctly nested 0/2/4-space list should produce no warnings, got: {result:?}"
1253 );
1254 }
1255
1256 #[test]
1257 fn test_deeply_nested_lists() {
1258 let rule = MD007ULIndent::default();
1259 let content = r#"* L1
1260 * L2
1261 * L3
1262 * L4
1263 * L5
1264 * L6"#;
1265 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1266 let result = rule.check(&ctx).unwrap();
1267 assert!(result.is_empty());
1268
1269 let wrong_content = r#"* L1
1271 * L2
1272 * L3
1273 * L4
1274 * L5
1275 * L6"#;
1276 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
1277 let result = rule.check(&ctx).unwrap();
1278 assert_eq!(result.len(), 2, "Deep nesting errors should be detected");
1279 }
1280
1281 #[test]
1282 fn test_excessive_indentation_detected() {
1283 let rule = MD007ULIndent::default();
1284
1285 let content = "- Item 1\n - Item 2 with 5 spaces";
1287 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1288 let result = rule.check(&ctx).unwrap();
1289 assert_eq!(result.len(), 1, "Should detect excessive indentation (5 instead of 2)");
1290 assert_eq!(result[0].line, 2);
1291 assert!(result[0].message.contains("Expected 2 spaces"));
1292 assert!(result[0].message.contains("found 5"));
1293
1294 let content = "- Item 1\n - Item 2 with 3 spaces";
1296 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1297 let result = rule.check(&ctx).unwrap();
1298 assert_eq!(
1299 result.len(),
1300 1,
1301 "Should detect slightly excessive indentation (3 instead of 2)"
1302 );
1303 assert_eq!(result[0].line, 2);
1304 assert!(result[0].message.contains("Expected 2 spaces"));
1305 assert!(result[0].message.contains("found 3"));
1306
1307 let content = "- Item 1\n - Item 2 with 1 space";
1309 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1310 let result = rule.check(&ctx).unwrap();
1311 assert_eq!(
1312 result.len(),
1313 1,
1314 "Should detect 1-space indent (insufficient for nesting, expected 0)"
1315 );
1316 assert_eq!(result[0].line, 2);
1317 assert!(result[0].message.contains("Expected 0 spaces"));
1318 assert!(result[0].message.contains("found 1"));
1319 }
1320
1321 #[test]
1322 fn test_excessive_indentation_with_4_space_config() {
1323 let rule = MD007ULIndent::new(4);
1326
1327 let content = "- Formatter:\n - The stable style changed";
1329 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1330 let result = rule.check(&ctx).unwrap();
1331 assert!(
1332 !result.is_empty(),
1333 "Should detect 5 spaces when expecting 4 (fixed style)"
1334 );
1335
1336 let correct_content = "- Formatter:\n - The stable style changed";
1338 let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard, None);
1339 let result = rule.check(&ctx).unwrap();
1340 assert!(result.is_empty(), "Should accept correct fixed style indent (4 spaces)");
1341 }
1342
1343 #[test]
1344 fn test_bullets_nested_under_numbered_items() {
1345 let rule = MD007ULIndent::default();
1346 let content = "\
13471. **Active Directory/LDAP**
1348 - User authentication and directory services
1349 - LDAP for user information and validation
1350
13512. **Oracle Unified Directory (OUD)**
1352 - Extended user directory services";
1353 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1354 let result = rule.check(&ctx).unwrap();
1355 assert!(
1357 result.is_empty(),
1358 "Expected no warnings for bullets with 3 spaces under numbered items, got: {result:?}"
1359 );
1360 }
1361
1362 #[test]
1363 fn test_bullets_nested_under_numbered_items_wrong_indent() {
1364 let rule = MD007ULIndent::default();
1365 let content = "\
13661. **Active Directory/LDAP**
1367 - Wrong: only 2 spaces";
1368 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1369 let result = rule.check(&ctx).unwrap();
1370 assert_eq!(
1372 result.len(),
1373 1,
1374 "Expected warning for incorrect indentation under numbered items"
1375 );
1376 assert!(
1377 result
1378 .iter()
1379 .any(|w| w.line == 2 && w.message.contains("Expected 3 spaces"))
1380 );
1381 }
1382
1383 #[test]
1384 fn test_regular_bullet_nesting_still_works() {
1385 let rule = MD007ULIndent::default();
1386 let content = "\
1387* Top level
1388 * Nested bullet (2 spaces is correct)
1389 * Deeply nested (4 spaces)";
1390 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1391 let result = rule.check(&ctx).unwrap();
1392 assert!(
1394 result.is_empty(),
1395 "Expected no warnings for standard bullet nesting, got: {result:?}"
1396 );
1397 }
1398
1399 #[test]
1400 fn test_blockquote_with_tab_after_marker() {
1401 let rule = MD007ULIndent::default();
1402 let content = ">\t* List item\n>\t * Nested\n";
1403 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1404 let result = rule.check(&ctx).unwrap();
1405 assert!(
1406 result.is_empty(),
1407 "Tab after blockquote marker should be handled correctly, got: {result:?}"
1408 );
1409 }
1410
1411 #[test]
1412 fn test_blockquote_with_space_then_tab_after_marker() {
1413 let rule = MD007ULIndent::default();
1414 let content = "> \t* List item\n";
1415 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1416 let result = rule.check(&ctx).unwrap();
1417 assert!(
1422 result.iter().any(|w| w.line == 1 && w.message.contains("Expected 0")),
1423 "an indented blockquoted top-level item must be flagged with Expected 0, got: {result:?}"
1424 );
1425 }
1426
1427 #[test]
1428 fn test_blockquote_with_multiple_tabs() {
1429 let rule = MD007ULIndent::default();
1430 let content = ">\t\t* List item\n";
1431 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1432 let result = rule.check(&ctx).unwrap();
1433 assert!(
1435 result.is_empty(),
1436 "First-level list item at any indentation is allowed when start_indented=false, got: {result:?}"
1437 );
1438 }
1439
1440 #[test]
1441 fn test_nested_blockquote_with_tab() {
1442 let rule = MD007ULIndent::default();
1443 let content = ">\t>\t* List item\n>\t>\t * Nested\n";
1444 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1445 let result = rule.check(&ctx).unwrap();
1446 assert!(
1447 result.is_empty(),
1448 "Nested blockquotes with tabs should work correctly, got: {result:?}"
1449 );
1450 }
1451
1452 #[test]
1455 fn test_smart_style_pure_unordered_uses_fixed() {
1456 let rule = MD007ULIndent::new(4);
1458
1459 let content = "* Level 0\n * Level 1\n * Level 2";
1461 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1462 let result = rule.check(&ctx).unwrap();
1463 assert!(
1464 result.is_empty(),
1465 "Pure unordered with indent=4 should use fixed style (0, 4, 8), got: {result:?}"
1466 );
1467 }
1468
1469 #[test]
1470 fn test_smart_style_mixed_lists_uses_text_aligned() {
1471 let rule = MD007ULIndent::new(4);
1473
1474 let content = "1. Ordered\n * Bullet aligns with 'Ordered' text (3 spaces)";
1476 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1477 let result = rule.check(&ctx).unwrap();
1478 assert!(
1479 result.is_empty(),
1480 "Mixed lists should use text-aligned style, got: {result:?}"
1481 );
1482 }
1483
1484 #[test]
1485 fn test_smart_style_explicit_fixed_overrides() {
1486 let config = MD007Config {
1488 indent: crate::types::IndentSize::from_const(4),
1489 start_indented: false,
1490 start_indent: crate::types::IndentSize::from_const(2),
1491 style: md007_config::IndentStyle::Fixed,
1492 style_explicit: true, indent_explicit: false,
1494 };
1495 let rule = MD007ULIndent::from_config_struct(config);
1496
1497 let content = "1. Ordered\n * Should be at 4 spaces (fixed)";
1499 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1500 let result = rule.check(&ctx).unwrap();
1501 assert!(
1503 result.is_empty(),
1504 "Explicit fixed style should be respected, got: {result:?}"
1505 );
1506 }
1507
1508 #[test]
1509 fn test_smart_style_explicit_text_aligned_overrides() {
1510 let config = MD007Config {
1512 indent: crate::types::IndentSize::from_const(4),
1513 start_indented: false,
1514 start_indent: crate::types::IndentSize::from_const(2),
1515 style: md007_config::IndentStyle::TextAligned,
1516 style_explicit: true, indent_explicit: false,
1518 };
1519 let rule = MD007ULIndent::from_config_struct(config);
1520
1521 let content = "* Level 0\n * Level 1 (aligned with 'Level 0' text)";
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 "Explicit text-aligned should be respected, got: {result:?}"
1528 );
1529
1530 let fixed_style_content = "* Level 0\n * Level 1 (4 spaces - fixed style)";
1532 let ctx = LintContext::new(fixed_style_content, crate::config::MarkdownFlavor::Standard, None);
1533 let result = rule.check(&ctx).unwrap();
1534 assert!(
1535 !result.is_empty(),
1536 "With explicit text-aligned, 4-space indent should be wrong (expected 2)"
1537 );
1538 }
1539
1540 #[test]
1541 fn test_smart_style_default_indent_no_autoswitch() {
1542 let rule = MD007ULIndent::new(2);
1544
1545 let content = "* Level 0\n * Level 1\n * Level 2";
1546 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1547 let result = rule.check(&ctx).unwrap();
1548 assert!(
1549 result.is_empty(),
1550 "Default indent should work regardless of style, got: {result:?}"
1551 );
1552 }
1553
1554 #[test]
1555 fn test_has_mixed_list_nesting_detection() {
1556 let content = "* Item 1\n * Item 2\n * Item 3";
1560 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1561 assert!(
1562 !ctx.has_mixed_list_nesting(),
1563 "Pure unordered should not be detected as mixed"
1564 );
1565
1566 let content = "1. Item 1\n 2. Item 2\n 3. Item 3";
1568 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1569 assert!(
1570 !ctx.has_mixed_list_nesting(),
1571 "Pure ordered should not be detected as mixed"
1572 );
1573
1574 let content = "1. Ordered\n * Unordered child";
1576 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1577 assert!(
1578 ctx.has_mixed_list_nesting(),
1579 "Unordered under ordered should be detected as mixed"
1580 );
1581
1582 let content = "* Unordered\n 1. Ordered child";
1584 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1585 assert!(
1586 ctx.has_mixed_list_nesting(),
1587 "Ordered under unordered should be detected as mixed"
1588 );
1589
1590 let content = "* Unordered\n\n1. Ordered (separate list)";
1592 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1593 assert!(
1594 !ctx.has_mixed_list_nesting(),
1595 "Separate lists should not be detected as mixed"
1596 );
1597
1598 let content = "> 1. Ordered in blockquote\n> * Unordered child";
1600 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1601 assert!(
1602 ctx.has_mixed_list_nesting(),
1603 "Mixed lists in blockquotes should be detected"
1604 );
1605 }
1606
1607 #[test]
1608 fn test_issue_210_exact_reproduction() {
1609 let config = MD007Config {
1611 indent: crate::types::IndentSize::from_const(4),
1612 start_indented: false,
1613 start_indent: crate::types::IndentSize::from_const(2),
1614 style: md007_config::IndentStyle::TextAligned, style_explicit: false, indent_explicit: false, };
1618 let rule = MD007ULIndent::from_config_struct(config);
1619
1620 let content = "# Title\n\n* some\n * list\n * items\n";
1621 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1622 let result = rule.check(&ctx).unwrap();
1623
1624 assert!(
1625 result.is_empty(),
1626 "Issue #210: indent=4 on pure unordered should work (auto-fixed style), got: {result:?}"
1627 );
1628 }
1629
1630 #[test]
1631 fn test_issue_209_still_fixed() {
1632 let config = MD007Config {
1635 indent: crate::types::IndentSize::from_const(3),
1636 start_indented: false,
1637 start_indent: crate::types::IndentSize::from_const(2),
1638 style: md007_config::IndentStyle::TextAligned,
1639 style_explicit: true, indent_explicit: false,
1641 };
1642 let rule = MD007ULIndent::from_config_struct(config);
1643
1644 let content = r#"# Header 1
1646
1647- **Second item**:
1648 - **This is a nested list**:
1649 1. **First point**
1650 - First subpoint
1651"#;
1652 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1653 let result = rule.check(&ctx).unwrap();
1654
1655 assert!(
1656 result.is_empty(),
1657 "Issue #209: With explicit text-aligned style, should have no issues, got: {result:?}"
1658 );
1659 }
1660
1661 #[test]
1664 fn test_multi_level_mixed_detection_grandparent() {
1665 let content = "1. Ordered grandparent\n * Unordered child\n * Unordered grandchild";
1669 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1670 assert!(
1671 ctx.has_mixed_list_nesting(),
1672 "Should detect mixed nesting when grandparent differs in type"
1673 );
1674
1675 let content = "* Unordered grandparent\n 1. Ordered child\n 2. Ordered grandchild";
1677 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1678 assert!(
1679 ctx.has_mixed_list_nesting(),
1680 "Should detect mixed nesting for ordered descendants under unordered"
1681 );
1682 }
1683
1684 #[test]
1685 fn test_html_comments_skipped_in_detection() {
1686 let content = r#"* Unordered list
1688<!-- This is a comment
1689 1. This ordered list is inside a comment
1690 * This nested bullet is also inside
1691-->
1692 * Another unordered item"#;
1693 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1694 assert!(
1695 !ctx.has_mixed_list_nesting(),
1696 "Lists in HTML comments should be ignored in mixed detection"
1697 );
1698 }
1699
1700 #[test]
1701 fn test_blank_lines_separate_lists() {
1702 let content = "* First unordered list\n\n1. Second list is ordered (separate)";
1704 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1705 assert!(
1706 !ctx.has_mixed_list_nesting(),
1707 "Blank line at root should separate lists"
1708 );
1709
1710 let content = "1. Ordered parent\n\n * Still a child due to indentation";
1712 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1713 assert!(
1714 ctx.has_mixed_list_nesting(),
1715 "Indented list after blank is still nested"
1716 );
1717 }
1718
1719 #[test]
1720 fn test_column_1_normalization() {
1721 let content = "* First item\n * Second item with 1 space (sibling)";
1724 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1725 let rule = MD007ULIndent::default();
1726 let result = rule.check(&ctx).unwrap();
1727 assert!(
1729 result.iter().any(|w| w.line == 2),
1730 "1-space indent should be flagged as incorrect"
1731 );
1732 }
1733
1734 #[test]
1735 fn test_code_blocks_skipped_in_detection() {
1736 let content = r#"* Unordered list
1738```
17391. This ordered list is inside a code block
1740 * This nested bullet is also inside
1741```
1742 * Another unordered item"#;
1743 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1744 assert!(
1745 !ctx.has_mixed_list_nesting(),
1746 "Lists in code blocks should be ignored in mixed detection"
1747 );
1748 }
1749
1750 #[test]
1751 fn test_front_matter_skipped_in_detection() {
1752 let content = r#"---
1754items:
1755 - yaml list item
1756 - another item
1757---
1758* Unordered list after front matter"#;
1759 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1760 assert!(
1761 !ctx.has_mixed_list_nesting(),
1762 "Lists in front matter should be ignored in mixed detection"
1763 );
1764 }
1765
1766 #[test]
1767 fn test_alternating_types_at_same_level() {
1768 let content = "* First bullet\n1. First number\n* Second bullet\n2. Second number";
1771 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1772 assert!(
1773 !ctx.has_mixed_list_nesting(),
1774 "Alternating types at same level should not be detected as mixed"
1775 );
1776 }
1777
1778 #[test]
1779 fn test_five_level_deep_mixed_nesting() {
1780 let content = "* L0\n 1. L1\n * L2\n 1. L3\n * L4\n 1. L5";
1782 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1783 assert!(ctx.has_mixed_list_nesting(), "Should detect mixed nesting at 5+ levels");
1784 }
1785
1786 #[test]
1787 fn test_very_deep_pure_unordered_nesting() {
1788 let mut content = String::from("* L1");
1790 for level in 2..=12 {
1791 let indent = " ".repeat(level - 1);
1792 content.push_str(&format!("\n{indent}* L{level}"));
1793 }
1794
1795 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1796
1797 assert!(
1799 !ctx.has_mixed_list_nesting(),
1800 "Pure unordered deep nesting should not be detected as mixed"
1801 );
1802
1803 let rule = MD007ULIndent::new(4);
1805 let result = rule.check(&ctx).unwrap();
1806 assert!(!result.is_empty(), "Should flag incorrect indentation for fixed style");
1809 }
1810
1811 #[test]
1812 fn test_interleaved_content_between_list_items() {
1813 let content = "1. Ordered parent\n\n Paragraph continuation\n\n * Unordered child";
1815 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1816 assert!(
1817 ctx.has_mixed_list_nesting(),
1818 "Should detect mixed nesting even with interleaved paragraphs"
1819 );
1820 }
1821
1822 #[test]
1823 fn test_esm_blocks_skipped_in_detection() {
1824 let content = "* Unordered list\n * Nested unordered";
1827 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1828 assert!(
1829 !ctx.has_mixed_list_nesting(),
1830 "Pure unordered should not be detected as mixed"
1831 );
1832 }
1833
1834 #[test]
1835 fn test_multiple_list_blocks_pure_then_mixed() {
1836 let content = r#"* Pure unordered
1839 * Nested unordered
1840
18411. Mixed section
1842 * Bullet under ordered"#;
1843 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1844 assert!(
1845 ctx.has_mixed_list_nesting(),
1846 "Should detect mixed nesting in any part of document"
1847 );
1848 }
1849
1850 #[test]
1851 fn test_multiple_separate_pure_lists() {
1852 let content = r#"* First list
1855 * Nested
1856
1857* Second list
1858 * Also nested
1859
1860* Third list
1861 * Deeply
1862 * Nested"#;
1863 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1864 assert!(
1865 !ctx.has_mixed_list_nesting(),
1866 "Multiple separate pure unordered lists should not be mixed"
1867 );
1868 }
1869
1870 #[test]
1871 fn test_code_block_between_list_items() {
1872 let content = r#"1. Ordered
1874 ```
1875 code
1876 ```
1877 * Still a mixed child"#;
1878 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1879 assert!(
1880 ctx.has_mixed_list_nesting(),
1881 "Code block between items should not prevent mixed detection"
1882 );
1883 }
1884
1885 #[test]
1886 fn test_blockquoted_mixed_detection() {
1887 let content = "> 1. Ordered in blockquote\n> * Mixed child";
1889 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1890 assert!(
1893 ctx.has_mixed_list_nesting(),
1894 "Should detect mixed nesting in blockquotes"
1895 );
1896 }
1897
1898 #[test]
1901 fn test_indent_explicit_uses_fixed_style() {
1902 let config = MD007Config {
1905 indent: crate::types::IndentSize::from_const(4),
1906 start_indented: false,
1907 start_indent: crate::types::IndentSize::from_const(2),
1908 style: md007_config::IndentStyle::TextAligned, style_explicit: false, indent_explicit: true, };
1912 let rule = MD007ULIndent::from_config_struct(config);
1913
1914 let content = "* Level 0\n * Level 1\n * Level 2";
1917 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1918 let result = rule.check(&ctx).unwrap();
1919 assert!(
1920 result.is_empty(),
1921 "With indent_explicit=true, should use fixed style (0, 4, 8), got: {result:?}"
1922 );
1923
1924 let wrong_content = "* Level 0\n * Level 1\n * Level 2";
1926 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
1927 let result = rule.check(&ctx).unwrap();
1928 assert!(
1929 !result.is_empty(),
1930 "Should flag text-aligned spacing when indent_explicit=true"
1931 );
1932 }
1933
1934 #[test]
1935 fn test_explicit_style_overrides_indent_explicit() {
1936 let config = MD007Config {
1939 indent: crate::types::IndentSize::from_const(4),
1940 start_indented: false,
1941 start_indent: crate::types::IndentSize::from_const(2),
1942 style: md007_config::IndentStyle::TextAligned,
1943 style_explicit: true, indent_explicit: true, };
1946 let rule = MD007ULIndent::from_config_struct(config);
1947
1948 let content = "* Level 0\n * Level 1\n * Level 2";
1950 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1951 let result = rule.check(&ctx).unwrap();
1952 assert!(
1953 result.is_empty(),
1954 "Explicit text-aligned style should be respected, got: {result:?}"
1955 );
1956 }
1957
1958 #[test]
1959 fn test_no_indent_explicit_uses_smart_detection() {
1960 let config = MD007Config {
1962 indent: crate::types::IndentSize::from_const(4),
1963 start_indented: false,
1964 start_indent: crate::types::IndentSize::from_const(2),
1965 style: md007_config::IndentStyle::TextAligned,
1966 style_explicit: false,
1967 indent_explicit: false, };
1969 let rule = MD007ULIndent::from_config_struct(config);
1970
1971 let content = "* Level 0\n * Level 1";
1974 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1975 let result = rule.check(&ctx).unwrap();
1976 assert!(
1978 result.is_empty(),
1979 "Smart detection should accept 4-space indent, got: {result:?}"
1980 );
1981 }
1982
1983 #[test]
1984 fn test_issue_273_exact_reproduction() {
1985 let config = MD007Config {
1988 indent: crate::types::IndentSize::from_const(4),
1989 start_indented: false,
1990 start_indent: crate::types::IndentSize::from_const(2),
1991 style: md007_config::IndentStyle::TextAligned, style_explicit: false,
1993 indent_explicit: true, };
1995 let rule = MD007ULIndent::from_config_struct(config);
1996
1997 let content = r#"* Item 1
1998 * Item 2
1999 * Item 3"#;
2000 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2001 let result = rule.check(&ctx).unwrap();
2002 assert!(
2003 result.is_empty(),
2004 "Issue #273: indent=4 should use 4-space increments, got: {result:?}"
2005 );
2006 }
2007
2008 #[test]
2009 fn test_indent_explicit_with_ordered_parent() {
2010 let config = MD007Config {
2014 indent: crate::types::IndentSize::from_const(4),
2015 start_indented: false,
2016 start_indent: crate::types::IndentSize::from_const(2),
2017 style: md007_config::IndentStyle::TextAligned,
2018 style_explicit: false,
2019 indent_explicit: true, };
2021 let rule = MD007ULIndent::from_config_struct(config);
2022
2023 let content = "1. Ordered\n * Bullet with 4-space indent";
2025 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2026 let result = rule.check(&ctx).unwrap();
2027 assert!(
2028 result.is_empty(),
2029 "4-space indent under ordered should pass with indent=4: {result:?}"
2030 );
2031
2032 let content_3 = "1. Ordered\n * Bullet with 3-space indent";
2034 let ctx = LintContext::new(content_3, crate::config::MarkdownFlavor::Standard, None);
2035 let result = rule.check(&ctx).unwrap();
2036 assert!(
2037 result.is_empty(),
2038 "3-space indent under ordered should pass (text-aligned): {result:?}"
2039 );
2040
2041 let wrong_content = "1. Ordered\n * Bullet with 2-space indent";
2043 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
2044 let result = rule.check(&ctx).unwrap();
2045 assert!(
2046 !result.is_empty(),
2047 "2-space indent under ordered list should be flagged when indent=4: {result:?}"
2048 );
2049 }
2050
2051 #[test]
2052 fn test_indent_explicit_mixed_list_deep_nesting() {
2053 let config = MD007Config {
2058 indent: crate::types::IndentSize::from_const(4),
2059 start_indented: false,
2060 start_indent: crate::types::IndentSize::from_const(2),
2061 style: md007_config::IndentStyle::TextAligned,
2062 style_explicit: false,
2063 indent_explicit: true,
2064 };
2065 let rule = MD007ULIndent::from_config_struct(config);
2066
2067 let content_text_aligned = r#"* Level 0
2073 * Level 1 (4-space indent from bullet parent)
2074 1. Level 2 ordered
2075 * Level 3 bullet (text-aligned under ordered)"#;
2076 let ctx = LintContext::new(content_text_aligned, crate::config::MarkdownFlavor::Standard, None);
2077 let result = rule.check(&ctx).unwrap();
2078 assert!(
2079 result.is_empty(),
2080 "Text-aligned nesting under ordered should pass: {result:?}"
2081 );
2082
2083 let content_fixed = r#"* Level 0
2084 * Level 1 (4-space indent from bullet parent)
2085 1. Level 2 ordered
2086 * Level 3 bullet (fixed indent under ordered)"#;
2087 let ctx = LintContext::new(content_fixed, crate::config::MarkdownFlavor::Standard, None);
2088 let result = rule.check(&ctx).unwrap();
2089 assert!(
2090 result.is_empty(),
2091 "Fixed indent nesting under ordered should also pass: {result:?}"
2092 );
2093 }
2094
2095 #[test]
2096 fn test_ordered_list_double_digit_markers() {
2097 let config = MD007Config {
2100 indent: crate::types::IndentSize::from_const(4),
2101 start_indented: false,
2102 start_indent: crate::types::IndentSize::from_const(2),
2103 style: md007_config::IndentStyle::TextAligned,
2104 style_explicit: false,
2105 indent_explicit: true,
2106 };
2107 let rule = MD007ULIndent::from_config_struct(config);
2108
2109 let content = "10. Double digit\n * Bullet at col 4";
2111 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2112 let result = rule.check(&ctx).unwrap();
2113 assert!(
2114 result.is_empty(),
2115 "Bullet under '10.' should align at column 4: {result:?}"
2116 );
2117
2118 let content_3 = "1. Single digit\n * Bullet at col 3";
2121 let ctx = LintContext::new(content_3, crate::config::MarkdownFlavor::Standard, None);
2122 let result = rule.check(&ctx).unwrap();
2123 assert!(
2124 result.is_empty(),
2125 "Bullet under '1.' with 3-space indent should pass (text-aligned): {result:?}"
2126 );
2127
2128 let content_4 = "1. Single digit\n * Bullet at col 4";
2129 let ctx = LintContext::new(content_4, crate::config::MarkdownFlavor::Standard, None);
2130 let result = rule.check(&ctx).unwrap();
2131 assert!(
2132 result.is_empty(),
2133 "Bullet under '1.' with 4-space indent should pass (fixed): {result:?}"
2134 );
2135 }
2136
2137 #[test]
2138 fn test_indent_explicit_pure_unordered_uses_fixed() {
2139 let config = MD007Config {
2142 indent: crate::types::IndentSize::from_const(4),
2143 start_indented: false,
2144 start_indent: crate::types::IndentSize::from_const(2),
2145 style: md007_config::IndentStyle::TextAligned,
2146 style_explicit: false,
2147 indent_explicit: true,
2148 };
2149 let rule = MD007ULIndent::from_config_struct(config);
2150
2151 let content = "* Level 0\n * Level 1\n * Level 2";
2153 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2154 let result = rule.check(&ctx).unwrap();
2155 assert!(
2156 result.is_empty(),
2157 "Pure unordered with indent=4 should use 4-space increments: {result:?}"
2158 );
2159
2160 let wrong_content = "* Level 0\n * Level 1\n * Level 2";
2162 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
2163 let result = rule.check(&ctx).unwrap();
2164 assert!(
2165 !result.is_empty(),
2166 "2-space indent should be flagged when indent=4 is configured"
2167 );
2168 }
2169
2170 #[test]
2171 fn test_mkdocs_ordered_list_with_4_space_nested_unordered() {
2172 let rule = MD007ULIndent::default();
2176 let content = "1. text\n\n - nested item";
2177 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2178 let result = rule.check(&ctx).unwrap();
2179 assert!(
2180 result.is_empty(),
2181 "4-space indent under ordered list should be valid in MkDocs flavor, got: {result:?}"
2182 );
2183 }
2184
2185 #[test]
2186 fn test_standard_flavor_ordered_list_with_3_space_nested_unordered() {
2187 let rule = MD007ULIndent::default();
2190 let content = "1. text\n\n - nested item";
2191 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2192 let result = rule.check(&ctx).unwrap();
2193 assert!(
2194 result.is_empty(),
2195 "3-space indent under ordered list should be valid in Standard flavor, got: {result:?}"
2196 );
2197 }
2198
2199 #[test]
2200 fn test_standard_flavor_ordered_list_under_ordered_is_exempt() {
2201 let rule = MD007ULIndent::default();
2206 let content = "1. text\n\n - nested item";
2207 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2208 let result = rule.check(&ctx).unwrap();
2209 assert!(
2210 result.is_empty(),
2211 "unordered sublist of an ordered list must be exempt in Standard flavor, got: {result:?}"
2212 );
2213 }
2214
2215 #[test]
2216 fn test_mkdocs_multi_digit_ordered_list() {
2217 let rule = MD007ULIndent::default();
2220 let content = "10. text\n\n - nested item";
2221 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2222 let result = rule.check(&ctx).unwrap();
2223 assert!(
2224 result.is_empty(),
2225 "4-space indent under `10.` should be valid in MkDocs flavor, got: {result:?}"
2226 );
2227 }
2228
2229 #[test]
2230 fn test_mkdocs_triple_digit_ordered_list() {
2231 let rule = MD007ULIndent::default();
2234 let content = "100. text\n\n - nested item";
2235 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2236 let result = rule.check(&ctx).unwrap();
2237 assert!(
2238 result.is_empty(),
2239 "5-space indent under `100.` should be valid in MkDocs flavor, got: {result:?}"
2240 );
2241 }
2242
2243 #[test]
2244 fn test_mkdocs_insufficient_indent_under_ordered() {
2245 let rule = MD007ULIndent::default();
2248 let content = "1. text\n\n - nested item";
2249 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2250 let result = rule.check(&ctx).unwrap();
2251 assert_eq!(
2252 result.len(),
2253 1,
2254 "2-space indent under ordered list should warn in MkDocs flavor"
2255 );
2256 assert!(
2257 result[0].message.contains("Expected 4"),
2258 "Warning should expect 4 spaces (MkDocs minimum), got: {}",
2259 result[0].message
2260 );
2261 }
2262
2263 #[test]
2264 fn test_mkdocs_deeper_nesting_under_ordered() {
2265 let rule = MD007ULIndent::default();
2270 let content = "1. text\n\n - sub\n - subsub";
2271 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2272 let result = rule.check(&ctx).unwrap();
2273 assert!(
2274 result.is_empty(),
2275 "Deeper nesting under ordered list should be valid in MkDocs flavor, got: {result:?}"
2276 );
2277 }
2278
2279 #[test]
2280 fn test_mkdocs_fix_adjusts_to_4_spaces() {
2281 let rule = MD007ULIndent::default();
2283 let content = "1. text\n\n - nested item";
2284 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2285 let result = rule.check(&ctx).unwrap();
2286 assert_eq!(result.len(), 1, "3-space indent should warn in MkDocs");
2287 let fixed = rule.fix(&ctx).unwrap();
2288 assert_eq!(
2289 fixed, "1. text\n\n - nested item",
2290 "Fix should adjust indent to 4 spaces in MkDocs"
2291 );
2292 }
2293
2294 #[test]
2295 fn test_mkdocs_start_indented_with_ordered_parent() {
2296 let config = MD007Config {
2299 start_indented: true,
2300 ..Default::default()
2301 };
2302 let rule = MD007ULIndent::from_config_struct(config);
2303 let content = "1. text\n\n - nested item";
2304 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2305 let result = rule.check(&ctx).unwrap();
2306 assert!(
2307 result.is_empty(),
2308 "4-space indent under ordered list with start_indented should be valid in MkDocs, got: {result:?}"
2309 );
2310 }
2311
2312 #[test]
2313 fn test_mkdocs_ordered_at_nonzero_indent() {
2314 let rule = MD007ULIndent::default();
2319 let content = "- outer\n 1. inner\n - deep";
2320 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2321 let result = rule.check(&ctx).unwrap();
2322 assert!(
2323 result.is_empty(),
2324 "6-space indent under nested ordered list should be valid in MkDocs, got: {result:?}"
2325 );
2326 }
2327
2328 #[test]
2329 fn test_mkdocs_blockquoted_ordered_list() {
2330 let rule = MD007ULIndent::default();
2334 let content = "> 1. text\n>\n> - nested item";
2335 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2336 let result = rule.check(&ctx).unwrap();
2337 assert!(
2338 result.is_empty(),
2339 "4-space indent under blockquoted ordered list should be valid in MkDocs, got: {result:?}"
2340 );
2341 }
2342
2343 #[test]
2344 fn test_mkdocs_ordered_at_nonzero_indent_insufficient() {
2345 let rule = MD007ULIndent::default();
2348 let content = "- outer\n 1. inner\n - deep";
2349 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2350 let result = rule.check(&ctx).unwrap();
2351 assert_eq!(
2352 result.len(),
2353 1,
2354 "5-space indent under nested ordered at col 2 should warn in MkDocs (needs 6)"
2355 );
2356 }
2357
2358 #[test]
2359 fn test_issue_504_indent4_ordered_parent() {
2360 let config = MD007Config {
2364 indent: crate::types::IndentSize::from_const(4),
2365 start_indented: false,
2366 start_indent: crate::types::IndentSize::from_const(2),
2367 style: md007_config::IndentStyle::TextAligned,
2368 style_explicit: false,
2369 indent_explicit: true,
2370 };
2371 let rule = MD007ULIndent::from_config_struct(config);
2372
2373 let content = r#"# Things
2374
2375+ An unordered list
2376 + An item with 4 spaces, ok.
2377
23781. A numbered list
2379 + A sublist with 4 spaces, not ok
2380 + A sub item with 4 spaces, ok
2381 + Why is rumdl expecting 3 spaces for a 4 space indent?
23822. Item 2
23833. Item 3"#;
2384 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2385 let result = rule.check(&ctx).unwrap();
2386 assert!(
2387 result.is_empty(),
2388 "Issue #504: indent=4 with ordered parent should accept 4-space indent: {result:?}"
2389 );
2390 }
2391
2392 #[test]
2393 fn test_indent2_explicit_with_ordered_parent() {
2394 let config = MD007Config {
2397 indent: crate::types::IndentSize::from_const(2),
2398 start_indented: false,
2399 start_indent: crate::types::IndentSize::from_const(2),
2400 style: md007_config::IndentStyle::TextAligned,
2401 style_explicit: false,
2402 indent_explicit: true,
2403 };
2404 let rule = MD007ULIndent::from_config_struct(config);
2405
2406 let content = "1. Ordered\n * Bullet at 3 spaces";
2408 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2409 let result = rule.check(&ctx).unwrap();
2410 assert!(
2411 result.is_empty(),
2412 "indent=2 under '1.' should accept text-aligned (3 spaces): {result:?}"
2413 );
2414
2415 let content_2 = "1. Ordered\n * Bullet at 2 spaces";
2417 let ctx = LintContext::new(content_2, crate::config::MarkdownFlavor::Standard, None);
2418 let result = rule.check(&ctx).unwrap();
2419 assert!(
2420 result.is_empty(),
2421 "indent=2 under '1.' should accept fixed indent (2 spaces): {result:?}"
2422 );
2423 }
2424
2425 const ISSUE_638_INPUT: &str = "# Title\n\n1. Some text\n - Indented text\n - more indented\n";
2429
2430 #[test]
2431 fn test_issue_638_unordered_under_ordered_smart_default() {
2432 let rule = MD007ULIndent::new(2);
2433 let ctx = LintContext::new(ISSUE_638_INPUT, crate::config::MarkdownFlavor::Standard, None);
2434 let result = rule.check(&ctx).unwrap();
2435 assert!(
2436 result.is_empty(),
2437 "smart default: unordered items under an ordered list must not be flagged, got: {result:?}"
2438 );
2439 }
2440
2441 #[test]
2442 fn test_issue_638_unordered_under_ordered_indent_explicit() {
2443 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 let ctx = LintContext::new(ISSUE_638_INPUT, crate::config::MarkdownFlavor::Standard, None);
2453 let result = rule.check(&ctx).unwrap();
2454 assert!(
2455 result.is_empty(),
2456 "indent=2 explicit: unordered items under an ordered list must not be flagged, got: {result:?}"
2457 );
2458 }
2459
2460 #[test]
2461 fn test_issue_638_unordered_under_ordered_style_fixed() {
2462 let config = MD007Config {
2464 indent: crate::types::IndentSize::from_const(2),
2465 start_indented: false,
2466 start_indent: crate::types::IndentSize::from_const(2),
2467 style: md007_config::IndentStyle::Fixed,
2468 style_explicit: true,
2469 indent_explicit: true,
2470 };
2471 let rule = MD007ULIndent::from_config_struct(config);
2472 let ctx = LintContext::new(ISSUE_638_INPUT, crate::config::MarkdownFlavor::Standard, None);
2473 let result = rule.check(&ctx).unwrap();
2474 assert!(
2475 result.is_empty(),
2476 "style=fixed: unordered items under an ordered list must not be flagged, got: {result:?}"
2477 );
2478 }
2479
2480 #[test]
2481 fn test_issue_638_deeper_unordered_chain_under_ordered() {
2482 let rule = MD007ULIndent::new(2);
2484 let content = "1. Ordered\n - child\n - grandchild\n - great-grandchild\n";
2485 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2486 let result = rule.check(&ctx).unwrap();
2487 assert!(
2488 result.is_empty(),
2489 "all unordered descendants of an ordered list are exempt, got: {result:?}"
2490 );
2491 }
2492
2493 #[test]
2494 fn test_issue_638_pure_unordered_still_checked() {
2495 let rule = MD007ULIndent::new(2);
2497 let content = "- Top\n - three spaces (wrong, expected 2)\n";
2498 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2499 let result = rule.check(&ctx).unwrap();
2500 assert_eq!(
2501 result.len(),
2502 1,
2503 "pure unordered nesting must still be checked, got: {result:?}"
2504 );
2505 }
2506
2507 #[test]
2508 fn test_issue_638_exemption_not_applied_after_list_terminated_by_paragraph() {
2509 let rule = MD007ULIndent::new(2);
2516 let content = "1. ordered\n\nparagraph\n\n - parent\n - child six\n";
2517 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2518 let result = rule.check(&ctx).unwrap();
2519 assert_eq!(
2520 result.len(),
2521 2,
2522 "the new top-level list following a terminated ordered list is checked at both levels, got: {result:?}"
2523 );
2524 assert!(
2525 result.iter().any(|w| w.line == 5 && w.message.contains("Expected 0")),
2526 "the misindented top-level item must be flagged with Expected 0, got: {result:?}"
2527 );
2528 assert!(
2529 result
2530 .iter()
2531 .any(|w| w.line == 6 && w.message.contains("Expected 2") && w.message.contains("found 6")),
2532 "the misindented child must be flagged with Expected 2, found 6, got: {result:?}"
2533 );
2534 }
2535
2536 #[test]
2537 fn test_issue_638_lazy_continuation_does_not_terminate_ordered_list() {
2538 let rule = MD007ULIndent::new(2);
2544 let content = "1. ordered\nlazy continuation\n - child\n - grandchild\n";
2545 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2546 let result = rule.check(&ctx).unwrap();
2547 assert!(
2548 result.is_empty(),
2549 "lazy continuation must not terminate the ordered list; sublist stays exempt, got: {result:?}"
2550 );
2551 }
2552
2553 #[test]
2554 fn test_issue_638_heading_interrupts_ordered_list_without_blank() {
2555 let rule = MD007ULIndent::new(2);
2562 let content = "1. ordered\n# heading\n - child\n - grandchild\n";
2563 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2564 let result = rule.check(&ctx).unwrap();
2565 assert_eq!(
2566 result.len(),
2567 2,
2568 "a heading terminates the ordered list, so the new top-level list and its child are both checked, got: {result:?}"
2569 );
2570 assert!(
2571 result.iter().any(|w| w.line == 3 && w.message.contains("Expected 0")),
2572 "the misindented top-level item must be flagged with Expected 0, got: {result:?}"
2573 );
2574 assert!(
2575 result.iter().any(|w| w.line == 4 && w.message.contains("Expected 2")),
2576 "the misindented child must be flagged with Expected 2, got: {result:?}"
2577 );
2578 }
2579
2580 #[test]
2581 fn test_issue_638_lazy_continuation_inside_blockquote_keeps_exemption() {
2582 let rule = MD007ULIndent::new(2);
2587 let content = "> 1. ordered\n> continuation\n>\n> - child\n> - grandchild\n";
2588 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2589 let result = rule.check(&ctx).unwrap();
2590 assert!(
2591 result.is_empty(),
2592 "a lazy continuation within the same blockquote must keep the sublist exempt, got: {result:?}"
2593 );
2594 }
2595
2596 #[test]
2597 fn test_issue_638_indented_fence_inside_blockquoted_ordered_item_keeps_exemption() {
2598 let rule = MD007ULIndent::new(2);
2603 let content = "> 1. ordered\n> ```\n> code\n> ```\n> - child\n> - grandchild\n";
2604 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2605 let result = rule.check(&ctx).unwrap();
2606 assert!(
2607 result.is_empty(),
2608 "an indented fence inside a blockquoted ordered item must keep the sublist exempt, got: {result:?}"
2609 );
2610 }
2611
2612 #[test]
2613 fn test_issue_638_fenced_code_block_terminates_ordered_list() {
2614 let rule = MD007ULIndent::new(2);
2620 let content = "1. ordered\n```\ncode\n```\n\n - parent\n - child\n";
2621 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2622 let result = rule.check(&ctx).unwrap();
2623 assert!(
2624 result.iter().any(|w| w.line == 7),
2625 "a top-level fenced code block terminates the ordered list; the child must be flagged, got: {result:?}"
2626 );
2627 }
2628
2629 #[test]
2630 fn test_issue_638_fenced_code_block_inside_item_keeps_exemption() {
2631 let rule = MD007ULIndent::new(2);
2636 let content = "1. ordered\n ```\n code\n ```\n - child\n - grandchild\n";
2637 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2638 let result = rule.check(&ctx).unwrap();
2639 assert!(
2640 result.is_empty(),
2641 "a fenced code block nested inside the item must keep the sublist exempt, got: {result:?}"
2642 );
2643 }
2644
2645 #[test]
2646 fn test_issue_638_blockquote_terminates_ordered_list() {
2647 let rule = MD007ULIndent::new(2);
2654 let content = "1. ordered\n> quote\n\n - parent\n - child\n";
2655 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2656 let result = rule.check(&ctx).unwrap();
2657 assert!(
2658 result.iter().any(|w| w.line == 5),
2659 "blockquote terminates the ordered list, so the child must still be flagged, got: {result:?}"
2660 );
2661 }
2662
2663 #[test]
2664 fn test_issue_638_blockquote_inside_item_keeps_exemption() {
2665 let rule = MD007ULIndent::new(2);
2670 let content = "1. ordered\n > quote inside item\n - child\n - grandchild\n";
2671 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2672 let result = rule.check(&ctx).unwrap();
2673 assert!(
2674 result.is_empty(),
2675 "a blockquote nested inside the item must keep the sublist exempt, got: {result:?}"
2676 );
2677 }
2678
2679 #[test]
2680 fn test_issue_638_exemption_requires_genuine_nesting_under_ordered() {
2681 let rule = MD007ULIndent::new(2);
2690 let content = "100. ordered\n - parent\n - child\n";
2691 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2692 let result = rule.check(&ctx).unwrap();
2693 assert!(
2694 result.iter().any(|w| w.line == 3),
2695 "the child of a non-nested bullet must still be checked, not exempted; got: {result:?}"
2696 );
2697 }
2698
2699 #[test]
2700 fn test_issue_638_paragraph_after_fenced_code_closes_ordered_list() {
2701 let rule = MD007ULIndent::new(2);
2710 let content = "1. ordered\n ```\n code\n ```\nnot lazy text\n - parent\n - child\n";
2711 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2712 let result = rule.check(&ctx).unwrap();
2713 assert!(
2714 result.iter().any(|w| w.line == 7),
2715 "fenced code is not paragraph text, so the list closes and the nested child must still be checked, not exempted; got: {result:?}"
2716 );
2717 }
2718
2719 #[test]
2720 fn test_issue_638_overlong_ordered_marker_is_lazy_continuation() {
2721 let rule = MD007ULIndent::new(2);
2727 let content = "1. ordered\n1234567890. this is continuation text\n - child\n - grandchild\n";
2728 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2729 let result = rule.check(&ctx).unwrap();
2730 assert!(
2731 result.is_empty(),
2732 "an overlong digit run is not a valid ordered marker, so the list stays open and the nested bullets are exempt; got: {result:?}"
2733 );
2734 }
2735
2736 #[test]
2737 fn test_indented_top_level_list_item_is_flagged() {
2738 let rule = MD007ULIndent::new(2);
2744 for indent in 2..=3 {
2745 let pad = " ".repeat(indent);
2746 let content = format!("{pad}- parent\n{pad} - child\n");
2747 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
2748 let result = rule.check(&ctx).unwrap();
2749 assert!(
2750 result.iter().any(|w| w.line == 1),
2751 "a top-level item indented {indent} spaces must be flagged (Expected 0); got: {result:?}"
2752 );
2753 }
2754 }
2755
2756 #[test]
2757 fn test_indented_code_block_bullet_is_not_a_list_item() {
2758 let rule = MD007ULIndent::new(2);
2761 let content = " - not a list, this is code\n";
2762 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2763 let result = rule.check(&ctx).unwrap();
2764 assert!(
2765 result.is_empty(),
2766 "a 4-space-indented bullet is an indented code block, not a misindented list; got: {result:?}"
2767 );
2768 }
2769
2770 #[test]
2771 fn test_tab_indent_expands_to_four_column_tabstop() {
2772 let rule = MD007ULIndent::new(2);
2779 let content = "- a\n\t- b\n";
2780 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2781 let result = rule.check(&ctx).unwrap();
2782 let warning = result
2783 .iter()
2784 .find(|w| w.line == 2)
2785 .expect("a tab-indented sublist at column 4 is over-indented for depth 1 and must be flagged");
2786 assert!(
2787 warning.message.contains("found 4"),
2788 "the tab must expand to the 4-column tab stop (found 4), not be counted as one character; got: {}",
2789 warning.message
2790 );
2791 }
2792
2793 #[test]
2794 fn test_tab_completing_two_space_indent_to_tabstop_is_accepted() {
2795 let rule = MD007ULIndent::new(2);
2801 let content = "- a\n - b\n \t- c\n";
2802 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2803 let result = rule.check(&ctx).unwrap();
2804 assert!(
2805 result.is_empty(),
2806 "` \\t` expands to column 4, the correct depth-2 indent, so no MD007 warning is expected; got: {result:?}"
2807 );
2808 }
2809
2810 #[test]
2811 fn test_issue_638_html_comment_terminates_ordered_list() {
2812 let rule = MD007ULIndent::new(2);
2819 let content = "1. ordered\n<!-- comment -->\n\n - parent\n - child\n";
2820 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2821 let result = rule.check(&ctx).unwrap();
2822 assert!(
2823 result.iter().any(|w| w.line == 5),
2824 "an HTML comment terminates the ordered list, so the child must still be flagged, got: {result:?}"
2825 );
2826 }
2827
2828 #[test]
2829 fn test_issue_638_blockquoted_list_item_terminates_ordered_list() {
2830 let rule = MD007ULIndent::new(2);
2838 let content = "1. ordered\n> - quote list\n\n - parent\n - child\n";
2839 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2840 let result = rule.check(&ctx).unwrap();
2841 assert!(
2842 result.iter().any(|w| w.line == 5),
2843 "a blockquoted list item terminates the ordered list, so the child must still be flagged, got: {result:?}"
2844 );
2845 }
2846
2847 #[test]
2848 fn test_issue_638_deeper_nested_quote_terminates_blockquoted_ordered_list() {
2849 let rule = MD007ULIndent::new(2);
2859 let content = "> 1. ordered\n> > quote\n>\n> - parent\n> - child\n";
2860 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2861 let result = rule.check(&ctx).unwrap();
2862 assert!(
2863 result.iter().any(|w| w.line == 4),
2864 "deeper nested quote closes the ordered list, so the misindented parent must be flagged, got: {result:?}"
2865 );
2866 assert!(
2867 result.iter().any(|w| w.line == 5),
2868 "the child of the fresh unordered list must be flagged, not exempted, got: {result:?}"
2869 );
2870 }
2871
2872 #[test]
2873 fn test_issue_638_deeper_quote_list_item_terminates_blockquoted_ordered_list() {
2874 let rule = MD007ULIndent::new(2);
2882 let content = "> 1. ordered\n> > - quote list\n>\n> - parent\n> - child\n";
2883 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2884 let result = rule.check(&ctx).unwrap();
2885 assert!(
2886 result.iter().any(|w| w.line == 4),
2887 "a deeper-quote list item closes the ordered list, so the parent must be flagged, got: {result:?}"
2888 );
2889 assert!(
2890 result.iter().any(|w| w.line == 5),
2891 "the child of the fresh unordered list must be flagged, not exempted, got: {result:?}"
2892 );
2893 }
2894
2895 #[test]
2896 fn test_issue_638_deeper_quote_indented_into_item_keeps_exemption() {
2897 let rule = MD007ULIndent::new(2);
2902 let content = "> 1. ordered\n> > quote inside item\n> - child\n> - grandchild\n";
2903 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2904 let result = rule.check(&ctx).unwrap();
2905 assert!(
2906 result.is_empty(),
2907 "a deeper quote indented into the item must keep the sublist exempt, got: {result:?}"
2908 );
2909 }
2910
2911 #[test]
2912 fn test_indent4_explicit_with_wide_ordered_parent() {
2913 let config = MD007Config {
2917 indent: crate::types::IndentSize::from_const(4),
2918 start_indented: false,
2919 start_indent: crate::types::IndentSize::from_const(2),
2920 style: md007_config::IndentStyle::TextAligned,
2921 style_explicit: false,
2922 indent_explicit: true,
2923 };
2924 let rule = MD007ULIndent::from_config_struct(config);
2925
2926 let content = "100. Wide ordered\n * Bullet at 5 spaces";
2928 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2929 let result = rule.check(&ctx).unwrap();
2930 assert!(
2931 result.is_empty(),
2932 "indent=4 under '100.' should accept 5-space indent: {result:?}"
2933 );
2934
2935 let content_4 = "100. Wide ordered\n * Bullet at 4 spaces";
2937 let ctx = LintContext::new(content_4, crate::config::MarkdownFlavor::Standard, None);
2938 let result = rule.check(&ctx).unwrap();
2939 assert!(
2940 result.is_empty(),
2941 "indent=4 under '100.' should accept 4-space indent: {result:?}"
2942 );
2943 }
2944}