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, usize)>,
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, usize)> = 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 visual_content_column,
404 ));
405 continue;
406 }
407
408 let threshold_ok = list_stack
432 .iter()
433 .any(|item| item.4 == bq_depth && item.2 && item.3 <= visual_marker_column);
434 if ctx.flavor != crate::config::MarkdownFlavor::MkDocs
445 && threshold_ok
446 && self.config.style_explicit
447 && self.config.style == md007_config::IndentStyle::Fixed
448 {
449 while let Some(&(_, _, _, _, item_bq_depth, _, source_content_col)) = list_stack.last() {
450 if item_bq_depth == bq_depth && source_content_col > visual_marker_column {
451 list_stack.pop();
452 } else {
453 break;
454 }
455 }
456 }
457 let chain_ok = list_stack
458 .iter()
459 .rev()
460 .find(|item| item.4 == bq_depth)
461 .is_some_and(|item| item.2 || item.5);
462 let ordered_chain = ctx.flavor != crate::config::MarkdownFlavor::MkDocs && threshold_ok && chain_ok;
463 let clamp_to_parent = ordered_chain
469 && self.config.style_explicit
470 && self.config.style == md007_config::IndentStyle::Fixed;
471 if ordered_chain && !clamp_to_parent {
472 list_stack.push((
473 visual_marker_column,
474 line_idx,
475 false,
476 visual_content_column,
477 bq_depth,
478 true,
479 visual_content_column,
480 ));
481 continue;
482 }
483
484 let nesting_level = list_stack.iter().filter(|item| item.4 == bq_depth).count();
486
487 let parent_info = list_stack
489 .iter()
490 .rev()
491 .find(|item| item.4 == bq_depth)
492 .map(|&(_, _, is_ordered, content_col, _, _, _)| (is_ordered, content_col));
493
494 let mut expected_indent = if self.config.start_indented && nesting_level == 0 {
500 self.config.start_indent.get() as usize
501 } else {
502 self.calculate_expected_indent(nesting_level, parent_info)
503 };
504
505 if clamp_to_parent && let Some((_, parent_content_col)) = parent_info {
510 expected_indent = expected_indent.max(parent_content_col);
511 }
512
513 let also_acceptable = if !clamp_to_parent
519 && self.config.indent_explicit
520 && parent_info.is_some_and(|(is_ordered, _)| is_ordered)
521 {
522 Some(nesting_level * self.config.indent.get() as usize)
523 } else {
524 None
525 };
526
527 if ctx.flavor == crate::config::MarkdownFlavor::MkDocs
531 && let Some(&(parent_marker_col, _, true, _, _, _, _)) =
532 list_stack.iter().rev().find(|item| item.4 == bq_depth && item.2)
533 {
534 expected_indent = expected_indent.max(parent_marker_col + 4);
535 }
536
537 let accepted_indent = if also_acceptable.is_some_and(|alt| visual_marker_column == alt) {
543 visual_marker_column
544 } else {
545 expected_indent
546 };
547 let marker_width = visual_content_column.saturating_sub(visual_marker_column);
557 let expected_content_visual_col = accepted_indent + marker_width;
558 list_stack.push((
563 visual_marker_column,
564 line_idx,
565 false,
566 expected_content_visual_col,
567 bq_depth,
568 clamp_to_parent,
569 visual_content_column,
570 ));
571
572 if !self.config.start_indented && nesting_level == 0 && visual_marker_column == 0 {
578 continue;
579 }
580
581 if visual_marker_column != expected_indent && also_acceptable != Some(visual_marker_column) {
582 if let Some(alt) = also_acceptable {
584 expected_indent = alt;
585 }
586 let fix = {
588 let correct_indent = " ".repeat(expected_indent);
589
590 let replacement = if line_info.blockquote.is_some() {
593 let mut blockquote_count = 0;
595 for ch in line_info.content(ctx.content).chars() {
596 if ch == '>' {
597 blockquote_count += 1;
598 } else if ch != ' ' && ch != '\t' {
599 break;
600 }
601 }
602 let blockquote_prefix = if blockquote_count > 1 {
604 (0..blockquote_count)
605 .map(|_| "> ")
606 .collect::<String>()
607 .trim_end()
608 .to_string()
609 } else {
610 ">".to_string()
611 };
612 format!("{blockquote_prefix} {correct_indent}")
615 } else {
616 correct_indent
617 };
618
619 let start_byte = line_info.byte_offset;
622 let mut end_byte = line_info.byte_offset;
623
624 for (i, ch) in line_info.content(ctx.content).chars().enumerate() {
626 if i >= list_item.marker_column {
627 break;
628 }
629 end_byte += ch.len_utf8();
630 }
631
632 Some(crate::rule::Fix::new(start_byte..end_byte, replacement))
633 };
634
635 warnings.push(LintWarning {
636 rule_name: Some(self.name().to_string()),
637 message: format!(
638 "Expected {expected_indent} spaces for indent depth {nesting_level}, found {visual_marker_column}"
639 ),
640 line: line_idx + 1, column: 1, end_line: line_idx + 1,
643 end_column: visual_marker_column + 1, severity: Severity::Warning,
645 fix,
646 });
647 }
648 } else if !line_info.is_blank {
649 let bq_depth = line_info.blockquote.as_ref().map_or(0, |bq| bq.nesting_level);
676 let prev_line = line_idx.checked_sub(1).map(|i| &ctx.lines[i]);
677 let prev_blank = prev_line.is_none_or(|p| p.is_blank);
678 let prev_bq_depth = prev_line
679 .and_then(|p| p.blockquote.as_ref())
680 .map_or(0, |bq| bq.nesting_level);
681 let same_container = prev_bq_depth == bq_depth;
682 let text = line_info
683 .blockquote
684 .as_ref()
685 .map_or_else(|| line_info.content(ctx.content), |bq| bq.content.as_str());
686 let trimmed = text.trim_start();
687 let starts_like_list_marker = match trimmed.as_bytes().first() {
688 Some(b'-' | b'*' | b'+') => {
689 matches!(trimmed.as_bytes().get(1), Some(b' ' | b'\t'))
690 }
691 Some(c) if c.is_ascii_digit() => {
692 let after_digits = trimmed.trim_start_matches(|ch: char| ch.is_ascii_digit());
696 let num_digits = trimmed.len() - after_digits.len();
697 let mut rest = after_digits.chars();
698 (1..=9).contains(&num_digits)
699 && matches!(rest.next(), Some('.' | ')'))
700 && matches!(rest.next(), Some(' ' | '\t') | None)
701 }
702 _ => false,
703 };
704 let prev_is_open_paragraph = prev_line.is_some_and(|p| {
711 !p.is_blank
712 && !p.in_code_block
713 && p.heading.is_none()
714 && !p.is_horizontal_rule
715 && !p.in_html_block
716 && !p.in_html_comment
717 && !p.is_div_marker
718 });
719 let is_lazy_paragraph_continuation = !prev_blank
720 && prev_is_open_paragraph
721 && same_container
722 && !starts_like_list_marker
723 && line_info.heading.is_none()
724 && !line_info.is_horizontal_rule
725 && !line_info.in_code_block
726 && !line_info.in_html_block
727 && !line_info.in_html_comment
728 && !line_info.is_div_marker;
729 if is_lazy_paragraph_continuation {
730 continue;
732 }
733 Self::terminate_closed_items(ctx, line_info, &mut list_stack, bq_depth);
734 }
735 }
736 Ok(warnings)
737 }
738
739 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
741 let warnings = self.check(ctx)?;
743 let warnings =
744 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
745
746 if warnings.is_empty() {
748 return Ok(ctx.content.to_string());
749 }
750
751 let mut fixes: Vec<_> = warnings
753 .iter()
754 .filter_map(|w| w.fix.as_ref().map(|f| (f.range.start, f.range.end, &f.replacement)))
755 .collect();
756 fixes.sort_by_key(|f| std::cmp::Reverse(f.0));
757
758 let mut result = ctx.content.to_string();
760 for (start, end, replacement) in fixes {
761 if start < result.len() && end <= result.len() && start <= end {
762 result.replace_range(start..end, replacement);
763 }
764 }
765
766 Ok(result)
767 }
768
769 fn category(&self) -> RuleCategory {
771 RuleCategory::List
772 }
773
774 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
776 if ctx.content.is_empty() || !ctx.likely_has_lists() {
778 return true;
779 }
780 !ctx.lines
782 .iter()
783 .any(|line| line.list_item.as_ref().is_some_and(|item| !item.is_ordered))
784 }
785
786 fn as_any(&self) -> &dyn std::any::Any {
787 self
788 }
789
790 fn default_config_section(&self) -> Option<(String, toml::Value)> {
791 let default_config = MD007Config::default();
792 let json_value = serde_json::to_value(&default_config).ok()?;
793 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
794
795 if let toml::Value::Table(table) = toml_value {
796 if !table.is_empty() {
797 Some((MD007Config::RULE_NAME.to_string(), toml::Value::Table(table)))
798 } else {
799 None
800 }
801 } else {
802 None
803 }
804 }
805
806 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
807 where
808 Self: Sized,
809 {
810 let mut rule_config = crate::rule_config_serde::load_rule_config::<MD007Config>(config);
811
812 if let Some(rule_cfg) = config.rules.get("MD007") {
814 rule_config.style_explicit = rule_cfg.values.contains_key("style");
815 rule_config.indent_explicit = rule_cfg.values.contains_key("indent");
816
817 if rule_config.indent_explicit
821 && rule_config.style_explicit
822 && rule_config.style == md007_config::IndentStyle::TextAligned
823 {
824 eprintln!(
825 "\x1b[33m[config warning]\x1b[0m MD007: 'indent' has no effect when 'style = \"text-aligned\"'. \
826 Text-aligned style ignores indent and aligns nested items with parent text. \
827 To use fixed {} space increments, either remove 'style' or set 'style = \"fixed\"'.",
828 rule_config.indent.get()
829 );
830 }
831 }
832
833 if config.markdown_flavor() == crate::config::MarkdownFlavor::MkDocs {
836 if rule_config.indent_explicit && rule_config.indent.get() < 4 {
837 eprintln!(
838 "\x1b[33m[config warning]\x1b[0m MD007: MkDocs flavor requires indent >= 4 \
839 (Python-Markdown enforces 4-space indentation). \
840 Overriding indent={} to indent=4.",
841 rule_config.indent.get()
842 );
843 }
844 if rule_config.style_explicit && rule_config.style == md007_config::IndentStyle::TextAligned {
845 eprintln!(
846 "\x1b[33m[config warning]\x1b[0m MD007: MkDocs flavor requires style=\"fixed\" \
847 (Python-Markdown uses fixed 4-space indentation). \
848 Overriding style=\"text-aligned\" to style=\"fixed\"."
849 );
850 }
851 if rule_config.indent.get() < 4 {
852 rule_config.indent = crate::types::IndentSize::from_const(4);
853 }
854 rule_config.style = md007_config::IndentStyle::Fixed;
855 }
856
857 Box::new(Self::from_config_struct(rule_config))
858 }
859}
860
861#[cfg(test)]
862mod tests {
863 use super::*;
864 use crate::lint_context::LintContext;
865 use crate::rule::Rule;
866 use indoc::indoc;
867
868 #[test]
869 fn test_valid_list_indent() {
870 let rule = MD007ULIndent::default();
871 let content = "* Item 1\n * Item 2\n * Item 3";
872 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
873 let result = rule.check(&ctx).unwrap();
874 assert!(
875 result.is_empty(),
876 "Expected no warnings for valid indentation, but got {} warnings",
877 result.len()
878 );
879 }
880
881 #[test]
882 fn test_invalid_list_indent() {
883 let rule = MD007ULIndent::default();
884 let content = "* Item 1\n * Item 2\n * Item 3";
885 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
886 let result = rule.check(&ctx).unwrap();
887 assert_eq!(result.len(), 2);
888 assert_eq!(result[0].line, 2);
889 assert_eq!(result[0].column, 1);
890 assert_eq!(result[1].line, 3);
891 assert_eq!(result[1].column, 1);
892 }
893
894 #[test]
895 fn test_mixed_indentation() {
896 let rule = MD007ULIndent::default();
897 let content = "* Item 1\n * Item 2\n * Item 3\n * Item 4";
898 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
899 let result = rule.check(&ctx).unwrap();
900 assert_eq!(result.len(), 1);
901 assert_eq!(result[0].line, 3);
902 assert_eq!(result[0].column, 1);
903 }
904
905 #[test]
906 fn test_fix_indentation() {
907 let rule = MD007ULIndent::default();
908 let content = "* Item 1\n * Item 2\n * Item 3";
909 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
910 let result = rule.fix(&ctx).unwrap();
911 let expected = "* Item 1\n * Item 2\n * Item 3";
915 assert_eq!(result, expected);
916 }
917
918 #[test]
919 fn test_md007_in_yaml_code_block() {
920 let rule = MD007ULIndent::default();
921 let content = r#"```yaml
922repos:
923- repo: https://github.com/rvben/rumdl
924 rev: v0.5.0
925 hooks:
926 - id: rumdl-check
927```"#;
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, but got warnings: {result:?}"
933 );
934 }
935
936 #[test]
937 fn test_blockquoted_list_indent() {
938 let rule = MD007ULIndent::default();
939 let content = "> * Item 1\n> * Item 2\n> * Item 3";
940 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
941 let result = rule.check(&ctx).unwrap();
942 assert!(
943 result.is_empty(),
944 "Expected no warnings for valid blockquoted list indentation, but got {result:?}"
945 );
946 }
947
948 #[test]
949 fn test_blockquoted_list_invalid_indent() {
950 let rule = MD007ULIndent::default();
951 let content = "> * Item 1\n> * Item 2\n> * Item 3";
952 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
953 let result = rule.check(&ctx).unwrap();
954 assert_eq!(
955 result.len(),
956 2,
957 "Expected 2 warnings for invalid blockquoted list indentation, got {result:?}"
958 );
959 assert_eq!(result[0].line, 2);
960 assert_eq!(result[1].line, 3);
961 }
962
963 #[test]
964 fn test_nested_blockquote_list_indent() {
965 let rule = MD007ULIndent::default();
966 let content = "> > * Item 1\n> > * Item 2\n> > * Item 3";
967 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
968 let result = rule.check(&ctx).unwrap();
969 assert!(
970 result.is_empty(),
971 "Expected no warnings for valid nested blockquoted list indentation, but got {result:?}"
972 );
973 }
974
975 #[test]
976 fn test_blockquote_list_with_code_block() {
977 let rule = MD007ULIndent::default();
978 let content = "> * Item 1\n> * Item 2\n> ```\n> code\n> ```\n> * Item 3";
979 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
980 let result = rule.check(&ctx).unwrap();
981 assert!(
982 result.is_empty(),
983 "MD007 should not trigger inside a code block within a blockquote, but got warnings: {result:?}"
984 );
985 }
986
987 #[test]
988 fn test_properly_indented_lists() {
989 let rule = MD007ULIndent::default();
990
991 let test_cases = vec![
993 "* Item 1\n* Item 2",
994 "* Item 1\n * Item 1.1\n * Item 1.1.1",
995 "- Item 1\n - Item 1.1",
996 "+ Item 1\n + Item 1.1",
997 "* Item 1\n * Item 1.1\n* Item 2\n * Item 2.1",
998 ];
999
1000 for content in test_cases {
1001 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1002 let result = rule.check(&ctx).unwrap();
1003 assert!(
1004 result.is_empty(),
1005 "Expected no warnings for properly indented list:\n{}\nGot {} warnings",
1006 content,
1007 result.len()
1008 );
1009 }
1010 }
1011
1012 #[test]
1013 fn test_under_indented_lists() {
1014 let rule = MD007ULIndent::default();
1015
1016 let test_cases = vec![
1017 ("* Item 1\n * Item 1.1", 1, 2), ("* Item 1\n * Item 1.1\n * Item 1.1.1", 1, 3), ];
1020
1021 for (content, expected_warnings, line) in test_cases {
1022 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1023 let result = rule.check(&ctx).unwrap();
1024 assert_eq!(
1025 result.len(),
1026 expected_warnings,
1027 "Expected {expected_warnings} warnings for under-indented list:\n{content}"
1028 );
1029 if expected_warnings > 0 {
1030 assert_eq!(result[0].line, line);
1031 }
1032 }
1033 }
1034
1035 #[test]
1036 fn test_over_indented_lists() {
1037 let rule = MD007ULIndent::default();
1038
1039 let test_cases = vec![
1040 ("* 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), ];
1044
1045 for (content, expected_warnings, line) in test_cases {
1046 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1047 let result = rule.check(&ctx).unwrap();
1048 assert_eq!(
1049 result.len(),
1050 expected_warnings,
1051 "Expected {expected_warnings} warnings for over-indented list:\n{content}"
1052 );
1053 if expected_warnings > 0 {
1054 assert_eq!(result[0].line, line);
1055 }
1056 }
1057 }
1058
1059 #[test]
1060 fn test_custom_indent_2_spaces() {
1061 let rule = MD007ULIndent::new(2); let content = "* Item 1\n * Item 2\n * Item 3";
1063 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1064 let result = rule.check(&ctx).unwrap();
1065 assert!(result.is_empty());
1066 }
1067
1068 #[test]
1069 fn test_custom_indent_3_spaces() {
1070 let rule = MD007ULIndent::new(3);
1073
1074 let correct_content = "* Item 1\n * Item 2\n * Item 3";
1076 let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard, None);
1077 let result = rule.check(&ctx).unwrap();
1078 assert!(
1079 result.is_empty(),
1080 "Fixed style expects 0, 3, 6 spaces but got: {result:?}"
1081 );
1082
1083 let wrong_content = "* Item 1\n * Item 2\n * Item 3";
1085 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
1086 let result = rule.check(&ctx).unwrap();
1087 assert!(!result.is_empty(), "Should warn: expected 3 spaces, found 2");
1088 }
1089
1090 #[test]
1091 fn test_custom_indent_4_spaces() {
1092 let rule = MD007ULIndent::new(4);
1095
1096 let correct_content = "* Item 1\n * Item 2\n * Item 3";
1098 let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard, None);
1099 let result = rule.check(&ctx).unwrap();
1100 assert!(
1101 result.is_empty(),
1102 "Fixed style expects 0, 4, 8 spaces but got: {result:?}"
1103 );
1104
1105 let wrong_content = "* Item 1\n * Item 2\n * Item 3";
1107 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
1108 let result = rule.check(&ctx).unwrap();
1109 assert!(!result.is_empty(), "Should warn: expected 4 spaces, found 2");
1110 }
1111
1112 #[test]
1113 fn test_tab_indentation() {
1114 let rule = MD007ULIndent::default();
1115
1116 let content = "* Item 1\n * Item 2";
1122 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1123 let result = rule.check(&ctx).unwrap();
1124 assert_eq!(result.len(), 1, "Wrong indentation should trigger warning");
1125
1126 let fixed = rule.fix(&ctx).unwrap();
1128 assert_eq!(fixed, "* Item 1\n * Item 2");
1129
1130 let content_multi = "* Item 1\n * Item 2\n * Item 3";
1132 let ctx = LintContext::new(content_multi, crate::config::MarkdownFlavor::Standard, None);
1133 let fixed = rule.fix(&ctx).unwrap();
1134 assert_eq!(fixed, "* Item 1\n * Item 2\n * Item 3");
1137
1138 let content_mixed = "* Item 1\n * Item 2\n * Item 3";
1140 let ctx = LintContext::new(content_mixed, crate::config::MarkdownFlavor::Standard, None);
1141 let fixed = rule.fix(&ctx).unwrap();
1142 assert_eq!(fixed, "* Item 1\n * Item 2\n * Item 3");
1145 }
1146
1147 #[test]
1148 fn test_mixed_ordered_unordered_lists() {
1149 let rule = MD007ULIndent::default();
1150
1151 let content = r#"1. Ordered item
1154 * Unordered sub-item (correct - 3 spaces under ordered)
1155 2. Ordered sub-item
1156* Unordered item
1157 1. Ordered sub-item
1158 * Unordered sub-item"#;
1159
1160 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1161 let result = rule.check(&ctx).unwrap();
1162 assert_eq!(result.len(), 0, "All unordered list indentation should be correct");
1163
1164 let fixed = rule.fix(&ctx).unwrap();
1166 assert_eq!(fixed, content);
1167 }
1168
1169 #[test]
1170 fn test_list_markers_variety() {
1171 let rule = MD007ULIndent::default();
1172
1173 let content = r#"* Asterisk
1175 * Nested asterisk
1176- Hyphen
1177 - Nested hyphen
1178+ Plus
1179 + Nested plus"#;
1180
1181 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1182 let result = rule.check(&ctx).unwrap();
1183 assert!(
1184 result.is_empty(),
1185 "All unordered list markers should work with proper indentation"
1186 );
1187
1188 let wrong_content = r#"* Asterisk
1190 * Wrong asterisk
1191- Hyphen
1192 - Wrong hyphen
1193+ Plus
1194 + Wrong plus"#;
1195
1196 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
1197 let result = rule.check(&ctx).unwrap();
1198 assert_eq!(result.len(), 3, "All marker types should be checked for indentation");
1199 }
1200
1201 #[test]
1202 fn test_empty_list_items() {
1203 let rule = MD007ULIndent::default();
1204 let content = "* Item 1\n* \n * Item 2";
1205 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1206 let result = rule.check(&ctx).unwrap();
1207 assert!(
1208 result.is_empty(),
1209 "Empty list items should not affect indentation checks"
1210 );
1211 }
1212
1213 #[test]
1214 fn test_list_with_code_blocks() {
1215 let rule = MD007ULIndent::default();
1216 let content = r#"* Item 1
1217 ```
1218 code
1219 ```
1220 * Item 2
1221 * Item 3"#;
1222 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1223 let result = rule.check(&ctx).unwrap();
1224 assert!(result.is_empty());
1225 }
1226
1227 #[test]
1228 fn test_list_in_front_matter() {
1229 let rule = MD007ULIndent::default();
1230 let content = r#"---
1231tags:
1232 - tag1
1233 - tag2
1234---
1235* Item 1
1236 * Item 2"#;
1237 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1238 let result = rule.check(&ctx).unwrap();
1239 assert!(result.is_empty(), "Lists in YAML front matter should be ignored");
1240 }
1241
1242 #[test]
1243 fn test_fix_preserves_content() {
1244 let rule = MD007ULIndent::default();
1245 let content = "* Item 1 with **bold** and *italic*\n * Item 2 with `code`\n * Item 3 with [link](url)";
1246 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1247 let fixed = rule.fix(&ctx).unwrap();
1248 let expected = "* Item 1 with **bold** and *italic*\n * Item 2 with `code`\n * Item 3 with [link](url)";
1251 assert_eq!(fixed, expected, "Fix should only change indentation, not content");
1252 }
1253
1254 #[test]
1255 fn test_start_indented_config() {
1256 let config = MD007Config {
1257 start_indented: true,
1258 start_indent: crate::types::IndentSize::from_const(4),
1259 indent: crate::types::IndentSize::from_const(2),
1260 style: md007_config::IndentStyle::TextAligned,
1261 style_explicit: true, indent_explicit: false,
1263 };
1264 let rule = MD007ULIndent::from_config_struct(config);
1265
1266 let content = " * Item 1\n * Item 2\n * Item 3";
1271 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1272 let result = rule.check(&ctx).unwrap();
1273 assert!(result.is_empty(), "Expected no warnings with start_indented config");
1274
1275 let wrong_content = " * Item 1\n * Item 2";
1277 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
1278 let result = rule.check(&ctx).unwrap();
1279 assert_eq!(result.len(), 2);
1280 assert_eq!(result[0].line, 1);
1281 assert_eq!(result[0].message, "Expected 4 spaces for indent depth 0, found 2");
1282 assert_eq!(result[1].line, 2);
1283 assert_eq!(result[1].message, "Expected 6 spaces for indent depth 1, found 4");
1284
1285 let fixed = rule.fix(&ctx).unwrap();
1287 assert_eq!(fixed, " * Item 1\n * Item 2");
1288 }
1289
1290 #[test]
1291 fn test_start_indented_false_flags_indented_first_level() {
1292 let rule = MD007ULIndent::default(); let content = " * Item 1"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1300 let result = rule.check(&ctx).unwrap();
1301 assert!(
1302 result.iter().any(|w| w.line == 1 && w.message.contains("Expected 0")),
1303 "a top-level item indented 3 spaces must be flagged with Expected 0, got: {result:?}"
1304 );
1305
1306 let content = "* Item 1\n * Item 2\n * Item 3";
1310 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1311 let result = rule.check(&ctx).unwrap();
1312 assert!(
1313 result.is_empty(),
1314 "a correctly nested 0/2/4-space list should produce no warnings, got: {result:?}"
1315 );
1316 }
1317
1318 #[test]
1319 fn test_deeply_nested_lists() {
1320 let rule = MD007ULIndent::default();
1321 let content = r#"* L1
1322 * L2
1323 * L3
1324 * L4
1325 * L5
1326 * L6"#;
1327 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1328 let result = rule.check(&ctx).unwrap();
1329 assert!(result.is_empty());
1330
1331 let wrong_content = r#"* L1
1333 * L2
1334 * L3
1335 * L4
1336 * L5
1337 * L6"#;
1338 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
1339 let result = rule.check(&ctx).unwrap();
1340 assert_eq!(result.len(), 2, "Deep nesting errors should be detected");
1341 }
1342
1343 #[test]
1344 fn test_excessive_indentation_detected() {
1345 let rule = MD007ULIndent::default();
1346
1347 let content = "- Item 1\n - Item 2 with 5 spaces";
1349 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1350 let result = rule.check(&ctx).unwrap();
1351 assert_eq!(result.len(), 1, "Should detect excessive indentation (5 instead of 2)");
1352 assert_eq!(result[0].line, 2);
1353 assert!(result[0].message.contains("Expected 2 spaces"));
1354 assert!(result[0].message.contains("found 5"));
1355
1356 let content = "- Item 1\n - Item 2 with 3 spaces";
1358 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1359 let result = rule.check(&ctx).unwrap();
1360 assert_eq!(
1361 result.len(),
1362 1,
1363 "Should detect slightly excessive indentation (3 instead of 2)"
1364 );
1365 assert_eq!(result[0].line, 2);
1366 assert!(result[0].message.contains("Expected 2 spaces"));
1367 assert!(result[0].message.contains("found 3"));
1368
1369 let content = "- Item 1\n - Item 2 with 1 space";
1371 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1372 let result = rule.check(&ctx).unwrap();
1373 assert_eq!(
1374 result.len(),
1375 1,
1376 "Should detect 1-space indent (insufficient for nesting, expected 0)"
1377 );
1378 assert_eq!(result[0].line, 2);
1379 assert!(result[0].message.contains("Expected 0 spaces"));
1380 assert!(result[0].message.contains("found 1"));
1381 }
1382
1383 #[test]
1384 fn test_excessive_indentation_with_4_space_config() {
1385 let rule = MD007ULIndent::new(4);
1388
1389 let content = "- Formatter:\n - The stable style changed";
1391 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1392 let result = rule.check(&ctx).unwrap();
1393 assert!(
1394 !result.is_empty(),
1395 "Should detect 5 spaces when expecting 4 (fixed style)"
1396 );
1397
1398 let correct_content = "- Formatter:\n - The stable style changed";
1400 let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard, None);
1401 let result = rule.check(&ctx).unwrap();
1402 assert!(result.is_empty(), "Should accept correct fixed style indent (4 spaces)");
1403 }
1404
1405 #[test]
1406 fn test_bullets_nested_under_numbered_items() {
1407 let rule = MD007ULIndent::default();
1408 let content = "\
14091. **Active Directory/LDAP**
1410 - User authentication and directory services
1411 - LDAP for user information and validation
1412
14132. **Oracle Unified Directory (OUD)**
1414 - Extended user directory services";
1415 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1416 let result = rule.check(&ctx).unwrap();
1417 assert!(
1419 result.is_empty(),
1420 "Expected no warnings for bullets with 3 spaces under numbered items, got: {result:?}"
1421 );
1422 }
1423
1424 #[test]
1425 fn test_bullets_nested_under_numbered_items_wrong_indent() {
1426 let rule = MD007ULIndent::default();
1427 let content = "\
14281. **Active Directory/LDAP**
1429 - Wrong: only 2 spaces";
1430 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1431 let result = rule.check(&ctx).unwrap();
1432 assert_eq!(
1434 result.len(),
1435 1,
1436 "Expected warning for incorrect indentation under numbered items"
1437 );
1438 assert!(
1439 result
1440 .iter()
1441 .any(|w| w.line == 2 && w.message.contains("Expected 3 spaces"))
1442 );
1443 }
1444
1445 #[test]
1446 fn test_regular_bullet_nesting_still_works() {
1447 let rule = MD007ULIndent::default();
1448 let content = "\
1449* Top level
1450 * Nested bullet (2 spaces is correct)
1451 * Deeply nested (4 spaces)";
1452 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1453 let result = rule.check(&ctx).unwrap();
1454 assert!(
1456 result.is_empty(),
1457 "Expected no warnings for standard bullet nesting, got: {result:?}"
1458 );
1459 }
1460
1461 #[test]
1462 fn test_blockquote_with_tab_after_marker() {
1463 let rule = MD007ULIndent::default();
1464 let content = ">\t* List item\n>\t * Nested\n";
1465 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1466 let result = rule.check(&ctx).unwrap();
1467 assert!(
1468 result.is_empty(),
1469 "Tab after blockquote marker should be handled correctly, got: {result:?}"
1470 );
1471 }
1472
1473 #[test]
1474 fn test_blockquote_with_space_then_tab_after_marker() {
1475 let rule = MD007ULIndent::default();
1476 let content = "> \t* List item\n";
1477 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1478 let result = rule.check(&ctx).unwrap();
1479 assert!(
1484 result.iter().any(|w| w.line == 1 && w.message.contains("Expected 0")),
1485 "an indented blockquoted top-level item must be flagged with Expected 0, got: {result:?}"
1486 );
1487 }
1488
1489 #[test]
1490 fn test_blockquote_with_multiple_tabs() {
1491 let rule = MD007ULIndent::default();
1492 let content = ">\t\t* List item\n";
1493 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1494 let result = rule.check(&ctx).unwrap();
1495 assert!(
1497 result.is_empty(),
1498 "First-level list item at any indentation is allowed when start_indented=false, got: {result:?}"
1499 );
1500 }
1501
1502 #[test]
1503 fn test_nested_blockquote_with_tab() {
1504 let rule = MD007ULIndent::default();
1505 let content = ">\t>\t* List item\n>\t>\t * Nested\n";
1506 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1507 let result = rule.check(&ctx).unwrap();
1508 assert!(
1509 result.is_empty(),
1510 "Nested blockquotes with tabs should work correctly, got: {result:?}"
1511 );
1512 }
1513
1514 #[test]
1517 fn test_smart_style_pure_unordered_uses_fixed() {
1518 let rule = MD007ULIndent::new(4);
1520
1521 let content = "* Level 0\n * Level 1\n * Level 2";
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 "Pure unordered with indent=4 should use fixed style (0, 4, 8), got: {result:?}"
1528 );
1529 }
1530
1531 #[test]
1532 fn test_smart_style_mixed_lists_uses_text_aligned() {
1533 let rule = MD007ULIndent::new(4);
1535
1536 let content = "1. Ordered\n * Bullet aligns with 'Ordered' text (3 spaces)";
1538 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1539 let result = rule.check(&ctx).unwrap();
1540 assert!(
1541 result.is_empty(),
1542 "Mixed lists should use text-aligned style, got: {result:?}"
1543 );
1544 }
1545
1546 #[test]
1547 fn test_smart_style_explicit_fixed_overrides() {
1548 let config = MD007Config {
1550 indent: crate::types::IndentSize::from_const(4),
1551 start_indented: false,
1552 start_indent: crate::types::IndentSize::from_const(2),
1553 style: md007_config::IndentStyle::Fixed,
1554 style_explicit: true, indent_explicit: false,
1556 };
1557 let rule = MD007ULIndent::from_config_struct(config);
1558
1559 let content = "1. Ordered\n * Should be at 4 spaces (fixed)";
1561 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1562 let result = rule.check(&ctx).unwrap();
1563 assert!(
1565 result.is_empty(),
1566 "Explicit fixed style should be respected, got: {result:?}"
1567 );
1568 }
1569
1570 #[test]
1571 fn test_smart_style_explicit_text_aligned_overrides() {
1572 let config = MD007Config {
1574 indent: crate::types::IndentSize::from_const(4),
1575 start_indented: false,
1576 start_indent: crate::types::IndentSize::from_const(2),
1577 style: md007_config::IndentStyle::TextAligned,
1578 style_explicit: true, indent_explicit: false,
1580 };
1581 let rule = MD007ULIndent::from_config_struct(config);
1582
1583 let content = "* Level 0\n * Level 1 (aligned with 'Level 0' text)";
1585 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1586 let result = rule.check(&ctx).unwrap();
1587 assert!(
1588 result.is_empty(),
1589 "Explicit text-aligned should be respected, got: {result:?}"
1590 );
1591
1592 let fixed_style_content = "* Level 0\n * Level 1 (4 spaces - fixed style)";
1594 let ctx = LintContext::new(fixed_style_content, crate::config::MarkdownFlavor::Standard, None);
1595 let result = rule.check(&ctx).unwrap();
1596 assert!(
1597 !result.is_empty(),
1598 "With explicit text-aligned, 4-space indent should be wrong (expected 2)"
1599 );
1600 }
1601
1602 #[test]
1603 fn test_smart_style_default_indent_no_autoswitch() {
1604 let rule = MD007ULIndent::new(2);
1606
1607 let content = "* Level 0\n * Level 1\n * Level 2";
1608 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1609 let result = rule.check(&ctx).unwrap();
1610 assert!(
1611 result.is_empty(),
1612 "Default indent should work regardless of style, got: {result:?}"
1613 );
1614 }
1615
1616 #[test]
1617 fn test_has_mixed_list_nesting_detection() {
1618 let content = "* Item 1\n * Item 2\n * Item 3";
1622 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1623 assert!(
1624 !ctx.has_mixed_list_nesting(),
1625 "Pure unordered should not be detected as mixed"
1626 );
1627
1628 let content = "1. Item 1\n 2. Item 2\n 3. Item 3";
1630 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1631 assert!(
1632 !ctx.has_mixed_list_nesting(),
1633 "Pure ordered should not be detected as mixed"
1634 );
1635
1636 let content = "1. Ordered\n * Unordered child";
1638 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1639 assert!(
1640 ctx.has_mixed_list_nesting(),
1641 "Unordered under ordered should be detected as mixed"
1642 );
1643
1644 let content = "* Unordered\n 1. Ordered child";
1646 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1647 assert!(
1648 ctx.has_mixed_list_nesting(),
1649 "Ordered under unordered should be detected as mixed"
1650 );
1651
1652 let content = "* Unordered\n\n1. Ordered (separate list)";
1654 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1655 assert!(
1656 !ctx.has_mixed_list_nesting(),
1657 "Separate lists should not be detected as mixed"
1658 );
1659
1660 let content = "> 1. Ordered in blockquote\n> * Unordered child";
1662 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1663 assert!(
1664 ctx.has_mixed_list_nesting(),
1665 "Mixed lists in blockquotes should be detected"
1666 );
1667 }
1668
1669 #[test]
1670 fn test_issue_210_exact_reproduction() {
1671 let config = MD007Config {
1673 indent: crate::types::IndentSize::from_const(4),
1674 start_indented: false,
1675 start_indent: crate::types::IndentSize::from_const(2),
1676 style: md007_config::IndentStyle::TextAligned, style_explicit: false, indent_explicit: false, };
1680 let rule = MD007ULIndent::from_config_struct(config);
1681
1682 let content = "# Title\n\n* some\n * list\n * items\n";
1683 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1684 let result = rule.check(&ctx).unwrap();
1685
1686 assert!(
1687 result.is_empty(),
1688 "Issue #210: indent=4 on pure unordered should work (auto-fixed style), got: {result:?}"
1689 );
1690 }
1691
1692 #[test]
1693 fn test_issue_209_still_fixed() {
1694 let config = MD007Config {
1697 indent: crate::types::IndentSize::from_const(3),
1698 start_indented: false,
1699 start_indent: crate::types::IndentSize::from_const(2),
1700 style: md007_config::IndentStyle::TextAligned,
1701 style_explicit: true, indent_explicit: false,
1703 };
1704 let rule = MD007ULIndent::from_config_struct(config);
1705
1706 let content = r#"# Header 1
1708
1709- **Second item**:
1710 - **This is a nested list**:
1711 1. **First point**
1712 - First subpoint
1713"#;
1714 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1715 let result = rule.check(&ctx).unwrap();
1716
1717 assert!(
1718 result.is_empty(),
1719 "Issue #209: With explicit text-aligned style, should have no issues, got: {result:?}"
1720 );
1721 }
1722
1723 #[test]
1726 fn test_multi_level_mixed_detection_grandparent() {
1727 let content = "1. Ordered grandparent\n * Unordered child\n * Unordered grandchild";
1731 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1732 assert!(
1733 ctx.has_mixed_list_nesting(),
1734 "Should detect mixed nesting when grandparent differs in type"
1735 );
1736
1737 let content = "* Unordered grandparent\n 1. Ordered child\n 2. Ordered grandchild";
1739 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1740 assert!(
1741 ctx.has_mixed_list_nesting(),
1742 "Should detect mixed nesting for ordered descendants under unordered"
1743 );
1744 }
1745
1746 #[test]
1747 fn test_html_comments_skipped_in_detection() {
1748 let content = r#"* Unordered list
1750<!-- This is a comment
1751 1. This ordered list is inside a comment
1752 * This nested bullet is also inside
1753-->
1754 * Another unordered item"#;
1755 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1756 assert!(
1757 !ctx.has_mixed_list_nesting(),
1758 "Lists in HTML comments should be ignored in mixed detection"
1759 );
1760 }
1761
1762 #[test]
1763 fn test_blank_lines_separate_lists() {
1764 let content = "* First unordered list\n\n1. Second list is ordered (separate)";
1766 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1767 assert!(
1768 !ctx.has_mixed_list_nesting(),
1769 "Blank line at root should separate lists"
1770 );
1771
1772 let content = "1. Ordered parent\n\n * Still a child due to indentation";
1774 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1775 assert!(
1776 ctx.has_mixed_list_nesting(),
1777 "Indented list after blank is still nested"
1778 );
1779 }
1780
1781 #[test]
1782 fn test_column_1_normalization() {
1783 let content = "* First item\n * Second item with 1 space (sibling)";
1786 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1787 let rule = MD007ULIndent::default();
1788 let result = rule.check(&ctx).unwrap();
1789 assert!(
1791 result.iter().any(|w| w.line == 2),
1792 "1-space indent should be flagged as incorrect"
1793 );
1794 }
1795
1796 #[test]
1797 fn test_code_blocks_skipped_in_detection() {
1798 let content = r#"* Unordered list
1800```
18011. This ordered list is inside a code block
1802 * This nested bullet is also inside
1803```
1804 * Another unordered item"#;
1805 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1806 assert!(
1807 !ctx.has_mixed_list_nesting(),
1808 "Lists in code blocks should be ignored in mixed detection"
1809 );
1810 }
1811
1812 #[test]
1813 fn test_front_matter_skipped_in_detection() {
1814 let content = r#"---
1816items:
1817 - yaml list item
1818 - another item
1819---
1820* Unordered list after front matter"#;
1821 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1822 assert!(
1823 !ctx.has_mixed_list_nesting(),
1824 "Lists in front matter should be ignored in mixed detection"
1825 );
1826 }
1827
1828 #[test]
1829 fn test_alternating_types_at_same_level() {
1830 let content = "* First bullet\n1. First number\n* Second bullet\n2. Second number";
1833 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1834 assert!(
1835 !ctx.has_mixed_list_nesting(),
1836 "Alternating types at same level should not be detected as mixed"
1837 );
1838 }
1839
1840 #[test]
1841 fn test_five_level_deep_mixed_nesting() {
1842 let content = "* L0\n 1. L1\n * L2\n 1. L3\n * L4\n 1. L5";
1844 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1845 assert!(ctx.has_mixed_list_nesting(), "Should detect mixed nesting at 5+ levels");
1846 }
1847
1848 #[test]
1849 fn test_very_deep_pure_unordered_nesting() {
1850 let mut content = String::from("* L1");
1852 for level in 2..=12 {
1853 let indent = " ".repeat(level - 1);
1854 content.push_str(&format!("\n{indent}* L{level}"));
1855 }
1856
1857 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1858
1859 assert!(
1861 !ctx.has_mixed_list_nesting(),
1862 "Pure unordered deep nesting should not be detected as mixed"
1863 );
1864
1865 let rule = MD007ULIndent::new(4);
1867 let result = rule.check(&ctx).unwrap();
1868 assert!(!result.is_empty(), "Should flag incorrect indentation for fixed style");
1871 }
1872
1873 #[test]
1874 fn test_interleaved_content_between_list_items() {
1875 let content = "1. Ordered parent\n\n Paragraph continuation\n\n * Unordered child";
1877 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1878 assert!(
1879 ctx.has_mixed_list_nesting(),
1880 "Should detect mixed nesting even with interleaved paragraphs"
1881 );
1882 }
1883
1884 #[test]
1885 fn test_esm_blocks_skipped_in_detection() {
1886 let content = "* Unordered list\n * Nested unordered";
1889 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1890 assert!(
1891 !ctx.has_mixed_list_nesting(),
1892 "Pure unordered should not be detected as mixed"
1893 );
1894 }
1895
1896 #[test]
1897 fn test_multiple_list_blocks_pure_then_mixed() {
1898 let content = r#"* Pure unordered
1901 * Nested unordered
1902
19031. Mixed section
1904 * Bullet under ordered"#;
1905 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1906 assert!(
1907 ctx.has_mixed_list_nesting(),
1908 "Should detect mixed nesting in any part of document"
1909 );
1910 }
1911
1912 #[test]
1913 fn test_multiple_separate_pure_lists() {
1914 let content = r#"* First list
1917 * Nested
1918
1919* Second list
1920 * Also nested
1921
1922* Third list
1923 * Deeply
1924 * Nested"#;
1925 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1926 assert!(
1927 !ctx.has_mixed_list_nesting(),
1928 "Multiple separate pure unordered lists should not be mixed"
1929 );
1930 }
1931
1932 #[test]
1933 fn test_code_block_between_list_items() {
1934 let content = r#"1. Ordered
1936 ```
1937 code
1938 ```
1939 * Still a mixed child"#;
1940 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1941 assert!(
1942 ctx.has_mixed_list_nesting(),
1943 "Code block between items should not prevent mixed detection"
1944 );
1945 }
1946
1947 #[test]
1948 fn test_blockquoted_mixed_detection() {
1949 let content = "> 1. Ordered in blockquote\n> * Mixed child";
1951 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1952 assert!(
1955 ctx.has_mixed_list_nesting(),
1956 "Should detect mixed nesting in blockquotes"
1957 );
1958 }
1959
1960 #[test]
1963 fn test_indent_explicit_uses_fixed_style() {
1964 let config = MD007Config {
1967 indent: crate::types::IndentSize::from_const(4),
1968 start_indented: false,
1969 start_indent: crate::types::IndentSize::from_const(2),
1970 style: md007_config::IndentStyle::TextAligned, style_explicit: false, indent_explicit: true, };
1974 let rule = MD007ULIndent::from_config_struct(config);
1975
1976 let content = "* Level 0\n * Level 1\n * Level 2";
1979 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1980 let result = rule.check(&ctx).unwrap();
1981 assert!(
1982 result.is_empty(),
1983 "With indent_explicit=true, should use fixed style (0, 4, 8), got: {result:?}"
1984 );
1985
1986 let wrong_content = "* Level 0\n * Level 1\n * Level 2";
1988 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
1989 let result = rule.check(&ctx).unwrap();
1990 assert!(
1991 !result.is_empty(),
1992 "Should flag text-aligned spacing when indent_explicit=true"
1993 );
1994 }
1995
1996 #[test]
1997 fn test_explicit_style_overrides_indent_explicit() {
1998 let config = MD007Config {
2001 indent: crate::types::IndentSize::from_const(4),
2002 start_indented: false,
2003 start_indent: crate::types::IndentSize::from_const(2),
2004 style: md007_config::IndentStyle::TextAligned,
2005 style_explicit: true, indent_explicit: true, };
2008 let rule = MD007ULIndent::from_config_struct(config);
2009
2010 let content = "* Level 0\n * Level 1\n * Level 2";
2012 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2013 let result = rule.check(&ctx).unwrap();
2014 assert!(
2015 result.is_empty(),
2016 "Explicit text-aligned style should be respected, got: {result:?}"
2017 );
2018 }
2019
2020 #[test]
2021 fn test_no_indent_explicit_uses_smart_detection() {
2022 let config = MD007Config {
2024 indent: crate::types::IndentSize::from_const(4),
2025 start_indented: false,
2026 start_indent: crate::types::IndentSize::from_const(2),
2027 style: md007_config::IndentStyle::TextAligned,
2028 style_explicit: false,
2029 indent_explicit: false, };
2031 let rule = MD007ULIndent::from_config_struct(config);
2032
2033 let content = "* Level 0\n * Level 1";
2036 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2037 let result = rule.check(&ctx).unwrap();
2038 assert!(
2040 result.is_empty(),
2041 "Smart detection should accept 4-space indent, got: {result:?}"
2042 );
2043 }
2044
2045 #[test]
2046 fn test_issue_273_exact_reproduction() {
2047 let config = MD007Config {
2050 indent: crate::types::IndentSize::from_const(4),
2051 start_indented: false,
2052 start_indent: crate::types::IndentSize::from_const(2),
2053 style: md007_config::IndentStyle::TextAligned, style_explicit: false,
2055 indent_explicit: true, };
2057 let rule = MD007ULIndent::from_config_struct(config);
2058
2059 let content = r#"* Item 1
2060 * Item 2
2061 * Item 3"#;
2062 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2063 let result = rule.check(&ctx).unwrap();
2064 assert!(
2065 result.is_empty(),
2066 "Issue #273: indent=4 should use 4-space increments, got: {result:?}"
2067 );
2068 }
2069
2070 #[test]
2071 fn test_indent_explicit_with_ordered_parent() {
2072 let config = MD007Config {
2076 indent: crate::types::IndentSize::from_const(4),
2077 start_indented: false,
2078 start_indent: crate::types::IndentSize::from_const(2),
2079 style: md007_config::IndentStyle::TextAligned,
2080 style_explicit: false,
2081 indent_explicit: true, };
2083 let rule = MD007ULIndent::from_config_struct(config);
2084
2085 let content = "1. Ordered\n * Bullet with 4-space indent";
2087 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2088 let result = rule.check(&ctx).unwrap();
2089 assert!(
2090 result.is_empty(),
2091 "4-space indent under ordered should pass with indent=4: {result:?}"
2092 );
2093
2094 let content_3 = "1. Ordered\n * Bullet with 3-space indent";
2096 let ctx = LintContext::new(content_3, crate::config::MarkdownFlavor::Standard, None);
2097 let result = rule.check(&ctx).unwrap();
2098 assert!(
2099 result.is_empty(),
2100 "3-space indent under ordered should pass (text-aligned): {result:?}"
2101 );
2102
2103 let wrong_content = "1. Ordered\n * Bullet with 2-space indent";
2105 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
2106 let result = rule.check(&ctx).unwrap();
2107 assert!(
2108 !result.is_empty(),
2109 "2-space indent under ordered list should be flagged when indent=4: {result:?}"
2110 );
2111 }
2112
2113 #[test]
2114 fn test_indent_explicit_mixed_list_deep_nesting() {
2115 let config = MD007Config {
2120 indent: crate::types::IndentSize::from_const(4),
2121 start_indented: false,
2122 start_indent: crate::types::IndentSize::from_const(2),
2123 style: md007_config::IndentStyle::TextAligned,
2124 style_explicit: false,
2125 indent_explicit: true,
2126 };
2127 let rule = MD007ULIndent::from_config_struct(config);
2128
2129 let content_text_aligned = r#"* Level 0
2135 * Level 1 (4-space indent from bullet parent)
2136 1. Level 2 ordered
2137 * Level 3 bullet (text-aligned under ordered)"#;
2138 let ctx = LintContext::new(content_text_aligned, crate::config::MarkdownFlavor::Standard, None);
2139 let result = rule.check(&ctx).unwrap();
2140 assert!(
2141 result.is_empty(),
2142 "Text-aligned nesting under ordered should pass: {result:?}"
2143 );
2144
2145 let content_fixed = r#"* Level 0
2146 * Level 1 (4-space indent from bullet parent)
2147 1. Level 2 ordered
2148 * Level 3 bullet (fixed indent under ordered)"#;
2149 let ctx = LintContext::new(content_fixed, crate::config::MarkdownFlavor::Standard, None);
2150 let result = rule.check(&ctx).unwrap();
2151 assert!(
2152 result.is_empty(),
2153 "Fixed indent nesting under ordered should also pass: {result:?}"
2154 );
2155 }
2156
2157 #[test]
2158 fn test_ordered_list_double_digit_markers() {
2159 let config = MD007Config {
2162 indent: crate::types::IndentSize::from_const(4),
2163 start_indented: false,
2164 start_indent: crate::types::IndentSize::from_const(2),
2165 style: md007_config::IndentStyle::TextAligned,
2166 style_explicit: false,
2167 indent_explicit: true,
2168 };
2169 let rule = MD007ULIndent::from_config_struct(config);
2170
2171 let content = "10. Double digit\n * Bullet at col 4";
2173 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2174 let result = rule.check(&ctx).unwrap();
2175 assert!(
2176 result.is_empty(),
2177 "Bullet under '10.' should align at column 4: {result:?}"
2178 );
2179
2180 let content_3 = "1. Single digit\n * Bullet at col 3";
2183 let ctx = LintContext::new(content_3, crate::config::MarkdownFlavor::Standard, None);
2184 let result = rule.check(&ctx).unwrap();
2185 assert!(
2186 result.is_empty(),
2187 "Bullet under '1.' with 3-space indent should pass (text-aligned): {result:?}"
2188 );
2189
2190 let content_4 = "1. Single digit\n * Bullet at col 4";
2191 let ctx = LintContext::new(content_4, crate::config::MarkdownFlavor::Standard, None);
2192 let result = rule.check(&ctx).unwrap();
2193 assert!(
2194 result.is_empty(),
2195 "Bullet under '1.' with 4-space indent should pass (fixed): {result:?}"
2196 );
2197 }
2198
2199 #[test]
2200 fn test_indent_explicit_pure_unordered_uses_fixed() {
2201 let config = MD007Config {
2204 indent: crate::types::IndentSize::from_const(4),
2205 start_indented: false,
2206 start_indent: crate::types::IndentSize::from_const(2),
2207 style: md007_config::IndentStyle::TextAligned,
2208 style_explicit: false,
2209 indent_explicit: true,
2210 };
2211 let rule = MD007ULIndent::from_config_struct(config);
2212
2213 let content = "* Level 0\n * Level 1\n * Level 2";
2215 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2216 let result = rule.check(&ctx).unwrap();
2217 assert!(
2218 result.is_empty(),
2219 "Pure unordered with indent=4 should use 4-space increments: {result:?}"
2220 );
2221
2222 let wrong_content = "* Level 0\n * Level 1\n * Level 2";
2224 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
2225 let result = rule.check(&ctx).unwrap();
2226 assert!(
2227 !result.is_empty(),
2228 "2-space indent should be flagged when indent=4 is configured"
2229 );
2230 }
2231
2232 #[test]
2233 fn test_mkdocs_ordered_list_with_4_space_nested_unordered() {
2234 let rule = MD007ULIndent::default();
2238 let content = "1. text\n\n - nested item";
2239 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2240 let result = rule.check(&ctx).unwrap();
2241 assert!(
2242 result.is_empty(),
2243 "4-space indent under ordered list should be valid in MkDocs flavor, got: {result:?}"
2244 );
2245 }
2246
2247 #[test]
2248 fn test_standard_flavor_ordered_list_with_3_space_nested_unordered() {
2249 let rule = MD007ULIndent::default();
2252 let content = "1. text\n\n - nested item";
2253 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2254 let result = rule.check(&ctx).unwrap();
2255 assert!(
2256 result.is_empty(),
2257 "3-space indent under ordered list should be valid in Standard flavor, got: {result:?}"
2258 );
2259 }
2260
2261 #[test]
2262 fn test_standard_flavor_ordered_list_under_ordered_is_exempt() {
2263 let rule = MD007ULIndent::default();
2268 let content = "1. text\n\n - nested item";
2269 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2270 let result = rule.check(&ctx).unwrap();
2271 assert!(
2272 result.is_empty(),
2273 "unordered sublist of an ordered list must be exempt in Standard flavor, got: {result:?}"
2274 );
2275 }
2276
2277 #[test]
2278 fn test_mkdocs_multi_digit_ordered_list() {
2279 let rule = MD007ULIndent::default();
2282 let content = "10. text\n\n - nested item";
2283 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2284 let result = rule.check(&ctx).unwrap();
2285 assert!(
2286 result.is_empty(),
2287 "4-space indent under `10.` should be valid in MkDocs flavor, got: {result:?}"
2288 );
2289 }
2290
2291 #[test]
2292 fn test_mkdocs_triple_digit_ordered_list() {
2293 let rule = MD007ULIndent::default();
2296 let content = "100. text\n\n - nested item";
2297 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2298 let result = rule.check(&ctx).unwrap();
2299 assert!(
2300 result.is_empty(),
2301 "5-space indent under `100.` should be valid in MkDocs flavor, got: {result:?}"
2302 );
2303 }
2304
2305 #[test]
2306 fn test_mkdocs_insufficient_indent_under_ordered() {
2307 let rule = MD007ULIndent::default();
2310 let content = "1. text\n\n - nested item";
2311 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2312 let result = rule.check(&ctx).unwrap();
2313 assert_eq!(
2314 result.len(),
2315 1,
2316 "2-space indent under ordered list should warn in MkDocs flavor"
2317 );
2318 assert!(
2319 result[0].message.contains("Expected 4"),
2320 "Warning should expect 4 spaces (MkDocs minimum), got: {}",
2321 result[0].message
2322 );
2323 }
2324
2325 #[test]
2326 fn test_mkdocs_deeper_nesting_under_ordered() {
2327 let rule = MD007ULIndent::default();
2332 let content = "1. text\n\n - sub\n - subsub";
2333 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2334 let result = rule.check(&ctx).unwrap();
2335 assert!(
2336 result.is_empty(),
2337 "Deeper nesting under ordered list should be valid in MkDocs flavor, got: {result:?}"
2338 );
2339 }
2340
2341 #[test]
2342 fn test_mkdocs_fix_adjusts_to_4_spaces() {
2343 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_eq!(result.len(), 1, "3-space indent should warn in MkDocs");
2349 let fixed = rule.fix(&ctx).unwrap();
2350 assert_eq!(
2351 fixed, "1. text\n\n - nested item",
2352 "Fix should adjust indent to 4 spaces in MkDocs"
2353 );
2354 }
2355
2356 #[test]
2357 fn test_mkdocs_start_indented_with_ordered_parent() {
2358 let config = MD007Config {
2361 start_indented: true,
2362 ..Default::default()
2363 };
2364 let rule = MD007ULIndent::from_config_struct(config);
2365 let content = "1. text\n\n - nested item";
2366 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2367 let result = rule.check(&ctx).unwrap();
2368 assert!(
2369 result.is_empty(),
2370 "4-space indent under ordered list with start_indented should be valid in MkDocs, got: {result:?}"
2371 );
2372 }
2373
2374 #[test]
2375 fn test_mkdocs_ordered_at_nonzero_indent() {
2376 let rule = MD007ULIndent::default();
2381 let content = "- outer\n 1. inner\n - deep";
2382 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2383 let result = rule.check(&ctx).unwrap();
2384 assert!(
2385 result.is_empty(),
2386 "6-space indent under nested ordered list should be valid in MkDocs, got: {result:?}"
2387 );
2388 }
2389
2390 #[test]
2391 fn test_mkdocs_blockquoted_ordered_list() {
2392 let rule = MD007ULIndent::default();
2396 let content = "> 1. text\n>\n> - nested item";
2397 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2398 let result = rule.check(&ctx).unwrap();
2399 assert!(
2400 result.is_empty(),
2401 "4-space indent under blockquoted ordered list should be valid in MkDocs, got: {result:?}"
2402 );
2403 }
2404
2405 #[test]
2406 fn test_mkdocs_ordered_at_nonzero_indent_insufficient() {
2407 let rule = MD007ULIndent::default();
2410 let content = "- outer\n 1. inner\n - deep";
2411 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2412 let result = rule.check(&ctx).unwrap();
2413 assert_eq!(
2414 result.len(),
2415 1,
2416 "5-space indent under nested ordered at col 2 should warn in MkDocs (needs 6)"
2417 );
2418 }
2419
2420 #[test]
2421 fn test_issue_504_indent4_ordered_parent() {
2422 let config = MD007Config {
2426 indent: crate::types::IndentSize::from_const(4),
2427 start_indented: false,
2428 start_indent: crate::types::IndentSize::from_const(2),
2429 style: md007_config::IndentStyle::TextAligned,
2430 style_explicit: false,
2431 indent_explicit: true,
2432 };
2433 let rule = MD007ULIndent::from_config_struct(config);
2434
2435 let content = r#"# Things
2436
2437+ An unordered list
2438 + An item with 4 spaces, ok.
2439
24401. A numbered list
2441 + A sublist with 4 spaces, not ok
2442 + A sub item with 4 spaces, ok
2443 + Why is rumdl expecting 3 spaces for a 4 space indent?
24442. Item 2
24453. Item 3"#;
2446 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2447 let result = rule.check(&ctx).unwrap();
2448 assert!(
2449 result.is_empty(),
2450 "Issue #504: indent=4 with ordered parent should accept 4-space indent: {result:?}"
2451 );
2452 }
2453
2454 #[test]
2455 fn test_indent2_explicit_with_ordered_parent() {
2456 let config = MD007Config {
2459 indent: crate::types::IndentSize::from_const(2),
2460 start_indented: false,
2461 start_indent: crate::types::IndentSize::from_const(2),
2462 style: md007_config::IndentStyle::TextAligned,
2463 style_explicit: false,
2464 indent_explicit: true,
2465 };
2466 let rule = MD007ULIndent::from_config_struct(config);
2467
2468 let content = "1. Ordered\n * Bullet at 3 spaces";
2470 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2471 let result = rule.check(&ctx).unwrap();
2472 assert!(
2473 result.is_empty(),
2474 "indent=2 under '1.' should accept text-aligned (3 spaces): {result:?}"
2475 );
2476
2477 let content_2 = "1. Ordered\n * Bullet at 2 spaces";
2479 let ctx = LintContext::new(content_2, crate::config::MarkdownFlavor::Standard, None);
2480 let result = rule.check(&ctx).unwrap();
2481 assert!(
2482 result.is_empty(),
2483 "indent=2 under '1.' should accept fixed indent (2 spaces): {result:?}"
2484 );
2485 }
2486
2487 const ISSUE_638_INPUT: &str = "# Title\n\n1. Some text\n - Indented text\n - more indented\n";
2491
2492 #[test]
2493 fn test_issue_638_unordered_under_ordered_smart_default() {
2494 let rule = MD007ULIndent::new(2);
2495 let ctx = LintContext::new(ISSUE_638_INPUT, crate::config::MarkdownFlavor::Standard, None);
2496 let result = rule.check(&ctx).unwrap();
2497 assert!(
2498 result.is_empty(),
2499 "smart default: unordered items under an ordered list must not be flagged, got: {result:?}"
2500 );
2501 }
2502
2503 #[test]
2504 fn test_issue_638_unordered_under_ordered_indent_explicit() {
2505 let config = MD007Config {
2506 indent: crate::types::IndentSize::from_const(2),
2507 start_indented: false,
2508 start_indent: crate::types::IndentSize::from_const(2),
2509 style: md007_config::IndentStyle::TextAligned,
2510 style_explicit: false,
2511 indent_explicit: true,
2512 };
2513 let rule = MD007ULIndent::from_config_struct(config);
2514 let ctx = LintContext::new(ISSUE_638_INPUT, crate::config::MarkdownFlavor::Standard, None);
2515 let result = rule.check(&ctx).unwrap();
2516 assert!(
2517 result.is_empty(),
2518 "indent=2 explicit: unordered items under an ordered list must not be flagged, got: {result:?}"
2519 );
2520 }
2521
2522 #[test]
2523 fn test_issue_638_unordered_under_ordered_style_fixed() {
2524 let config = MD007Config {
2526 indent: crate::types::IndentSize::from_const(2),
2527 start_indented: false,
2528 start_indent: crate::types::IndentSize::from_const(2),
2529 style: md007_config::IndentStyle::Fixed,
2530 style_explicit: true,
2531 indent_explicit: true,
2532 };
2533 let rule = MD007ULIndent::from_config_struct(config);
2534 let ctx = LintContext::new(ISSUE_638_INPUT, crate::config::MarkdownFlavor::Standard, None);
2535 let result = rule.check(&ctx).unwrap();
2536 assert!(
2537 result.is_empty(),
2538 "style=fixed: unordered items under an ordered list must not be flagged, got: {result:?}"
2539 );
2540 }
2541
2542 fn fixed_style_rule(indent: u8) -> MD007ULIndent {
2549 MD007ULIndent::from_config_struct(MD007Config {
2550 indent: crate::types::IndentSize::from_const(indent),
2551 start_indented: false,
2552 start_indent: crate::types::IndentSize::from_const(2),
2553 style: md007_config::IndentStyle::Fixed,
2554 style_explicit: true,
2555 indent_explicit: true,
2556 })
2557 }
2558
2559 #[test]
2560 fn test_fixed_style_clamp_flags_over_indented_bullet_under_ordered() {
2561 let rule = fixed_style_rule(2);
2562 let content = "1. Some text\n - four spaces\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 1,
2568 "a bullet at 4 under a content column of 3 is flagged: {result:?}"
2569 );
2570 assert!(
2571 result[0].message.contains("Expected 3") && result[0].message.contains("found 4"),
2572 "clamped expectation is the parent content column, got: {}",
2573 result[0].message
2574 );
2575 let fixed = rule.fix(&ctx).unwrap();
2576 assert_eq!(fixed, "1. Some text\n - four spaces\n");
2577 }
2578
2579 #[test]
2580 fn test_fixed_style_clamp_accepts_bullet_at_parent_content_column() {
2581 let rule = fixed_style_rule(2);
2582 let content = "1. Some text\n - three spaces\n";
2583 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2584 let result = rule.check(&ctx).unwrap();
2585 assert!(result.is_empty(), "the clamped expectation itself passes: {result:?}");
2586 }
2587
2588 #[test]
2589 fn test_fixed_style_clamp_pulls_five_spaces_to_content_column() {
2590 let rule = fixed_style_rule(2);
2591 let content = "1. Some text\n - five spaces\n";
2592 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2593 let result = rule.check(&ctx).unwrap();
2594 assert_eq!(result.len(), 1, "{result:?}");
2595 let fixed = rule.fix(&ctx).unwrap();
2596 assert_eq!(fixed, "1. Some text\n - five spaces\n");
2597 }
2598
2599 #[test]
2600 fn test_fixed_style_clamp_cascades_through_nested_bullets() {
2601 let rule = fixed_style_rule(2);
2605 let content = "1. Ordered\n - child\n - grandchild\n";
2606 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2607 let result = rule.check(&ctx).unwrap();
2608 assert_eq!(result.len(), 1, "only the grandchild is off: {result:?}");
2609 assert!(
2610 result[0].message.contains("Expected 5") && result[0].message.contains("found 6"),
2611 "got: {}",
2612 result[0].message
2613 );
2614 let fixed = rule.fix(&ctx).unwrap();
2615 assert_eq!(fixed, "1. Ordered\n - child\n - grandchild\n");
2616 let refixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
2617 assert!(rule.check(&refixed_ctx).unwrap().is_empty(), "fix is stable");
2618 }
2619
2620 #[test]
2621 fn test_fixed_style_clamp_respects_wider_fixed_indent() {
2622 let rule = fixed_style_rule(4);
2625 let content = "1. Some text\n - three spaces\n";
2626 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2627 let result = rule.check(&ctx).unwrap();
2628 assert_eq!(result.len(), 1, "{result:?}");
2629 assert!(
2630 result[0].message.contains("Expected 4") && result[0].message.contains("found 3"),
2631 "got: {}",
2632 result[0].message
2633 );
2634 let fixed = rule.fix(&ctx).unwrap();
2635 assert_eq!(fixed, "1. Some text\n - three spaces\n");
2636 }
2637
2638 #[test]
2639 fn test_fixed_style_clamp_uses_measured_content_column_of_wide_marker() {
2640 let rule = fixed_style_rule(2);
2643 let content = "1. Some text\n - five spaces\n";
2644 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2645 let result = rule.check(&ctx).unwrap();
2646 assert_eq!(result.len(), 1, "{result:?}");
2647 assert!(
2648 result[0].message.contains("Expected 4") && result[0].message.contains("found 5"),
2649 "got: {}",
2650 result[0].message
2651 );
2652 let fixed = rule.fix(&ctx).unwrap();
2653 assert_eq!(fixed, "1. Some text\n - five spaces\n");
2654
2655 let ok = "1. Some text\n - four spaces\n";
2656 let ok_ctx = LintContext::new(ok, crate::config::MarkdownFlavor::Standard, None);
2657 assert!(rule.check(&ok_ctx).unwrap().is_empty());
2658 }
2659
2660 #[test]
2661 fn test_fixed_style_clamp_in_blockquote() {
2662 let rule = fixed_style_rule(2);
2663 let content = "> 1. Some text\n> - four spaces\n";
2664 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2665 let result = rule.check(&ctx).unwrap();
2666 assert_eq!(result.len(), 1, "{result:?}");
2667 assert!(
2668 result[0].message.contains("Expected 3") && result[0].message.contains("found 4"),
2669 "got: {}",
2670 result[0].message
2671 );
2672 let fixed = rule.fix(&ctx).unwrap();
2673 assert_eq!(fixed, "> 1. Some text\n> - four spaces\n");
2674 }
2675
2676 #[test]
2677 fn test_fixed_style_clamp_treats_near_sibling_as_sibling() {
2678 let rule = fixed_style_rule(2);
2684 let content = "1. x\n - a\n - b\n";
2685 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2686 let result = rule.check(&ctx).unwrap();
2687 assert_eq!(result.len(), 1, "{result:?}");
2688 assert!(
2689 result[0].message.contains("Expected 3") && result[0].message.contains("found 4"),
2690 "near-sibling resolves against the ordered parent, got: {}",
2691 result[0].message
2692 );
2693 let fixed = rule.fix(&ctx).unwrap();
2694 assert_eq!(fixed, "1. x\n - a\n - b\n");
2695 }
2696
2697 #[test]
2698 fn test_fixed_style_clamp_child_after_near_sibling_resolves_against_it() {
2699 let rule = fixed_style_rule(2);
2703 let content = "1. x\n - a\n - b\n - c\n";
2704 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2705 let result = rule.check(&ctx).unwrap();
2706 assert_eq!(result.len(), 2, "b and c are both off: {result:?}");
2707 assert!(
2708 result[0].message.contains("Expected 3") && result[0].message.contains("found 4"),
2709 "got: {}",
2710 result[0].message
2711 );
2712 assert!(
2713 result[1].message.contains("Expected 5") && result[1].message.contains("found 6"),
2714 "got: {}",
2715 result[1].message
2716 );
2717 let fixed = rule.fix(&ctx).unwrap();
2718 assert_eq!(fixed, "1. x\n - a\n - b\n - c\n");
2719 }
2720
2721 #[test]
2722 fn test_fixed_style_clamp_pops_near_sibling_of_over_indented_bullet() {
2723 let rule = fixed_style_rule(2);
2728 let content = "1. x\n - a\n - b\n";
2729 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2730 let result = rule.check(&ctx).unwrap();
2731 assert_eq!(result.len(), 2, "a and b are both flagged: {result:?}");
2732 assert!(
2733 result[1].message.contains("Expected 3") && result[1].message.contains("found 5"),
2734 "b resolves against the ordered parent, got: {}",
2735 result[1].message
2736 );
2737 let fixed = rule.fix(&ctx).unwrap();
2738 assert_eq!(fixed, "1. x\n - a\n - b\n");
2739 }
2740
2741 #[test]
2742 fn test_fixed_style_clamp_keeps_child_of_over_indented_bullet() {
2743 let rule = fixed_style_rule(2);
2746 let content = "1. x\n - a\n - c\n";
2747 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2748 let result = rule.check(&ctx).unwrap();
2749 assert_eq!(result.len(), 2, "a and c are both flagged: {result:?}");
2750 assert!(
2751 result[1].message.contains("Expected 5") && result[1].message.contains("found 7"),
2752 "c's floor is a's corrected content column, got: {}",
2753 result[1].message
2754 );
2755 let fixed = rule.fix(&ctx).unwrap();
2756 assert_eq!(fixed, "1. x\n - a\n - c\n");
2757 }
2758
2759 #[test]
2760 fn test_fixed_style_clamp_pops_ordered_near_sibling() {
2761 let rule = fixed_style_rule(2);
2767 let content = "1. root\n - a\n 1. sub\n - b\n";
2768 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2769 let result = rule.check(&ctx).unwrap();
2770 assert_eq!(result.len(), 1, "only b is off: {result:?}");
2771 assert!(
2772 result[0].message.contains("Expected 5") && result[0].message.contains("found 6"),
2773 "b resolves against a, not the nested ordered sibling, got: {}",
2774 result[0].message
2775 );
2776 let fixed = rule.fix(&ctx).unwrap();
2777 assert_eq!(fixed, "1. root\n - a\n 1. sub\n - b\n");
2778 }
2779
2780 #[test]
2781 fn test_fixed_style_clamp_leaves_sibling_bullet_left_of_content_column() {
2782 let rule = fixed_style_rule(2);
2786 let content = "1. Some text\n - two spaces\n";
2787 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2788 let result = rule.check(&ctx).unwrap();
2789 assert!(
2790 result.is_empty(),
2791 "sibling bullet at the fixed indent stays silent: {result:?}"
2792 );
2793 }
2794
2795 #[test]
2796 fn test_fixed_style_clamp_requires_explicit_style() {
2797 let config = MD007Config {
2800 indent: crate::types::IndentSize::from_const(2),
2801 start_indented: false,
2802 start_indent: crate::types::IndentSize::from_const(2),
2803 style: md007_config::IndentStyle::TextAligned,
2804 style_explicit: false,
2805 indent_explicit: true,
2806 };
2807 let rule = MD007ULIndent::from_config_struct(config);
2808 let content = "1. Some text\n - four spaces\n";
2809 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2810 let result = rule.check(&ctx).unwrap();
2811 assert!(result.is_empty(), "no explicit style, exemption stays: {result:?}");
2812
2813 let smart = MD007ULIndent::new(2);
2814 assert!(
2815 smart.check(&ctx).unwrap().is_empty(),
2816 "smart default keeps the exemption too"
2817 );
2818 }
2819
2820 #[test]
2821 fn test_issue_638_deeper_unordered_chain_under_ordered() {
2822 let rule = MD007ULIndent::new(2);
2824 let content = "1. Ordered\n - child\n - grandchild\n - great-grandchild\n";
2825 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2826 let result = rule.check(&ctx).unwrap();
2827 assert!(
2828 result.is_empty(),
2829 "all unordered descendants of an ordered list are exempt, got: {result:?}"
2830 );
2831 }
2832
2833 #[test]
2834 fn test_issue_638_pure_unordered_still_checked() {
2835 let rule = MD007ULIndent::new(2);
2837 let content = "- Top\n - three spaces (wrong, expected 2)\n";
2838 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2839 let result = rule.check(&ctx).unwrap();
2840 assert_eq!(
2841 result.len(),
2842 1,
2843 "pure unordered nesting must still be checked, got: {result:?}"
2844 );
2845 }
2846
2847 #[test]
2848 fn test_issue_638_exemption_not_applied_after_list_terminated_by_paragraph() {
2849 let rule = MD007ULIndent::new(2);
2856 let content = "1. ordered\n\nparagraph\n\n - parent\n - child six\n";
2857 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2858 let result = rule.check(&ctx).unwrap();
2859 assert_eq!(
2860 result.len(),
2861 2,
2862 "the new top-level list following a terminated ordered list is checked at both levels, got: {result:?}"
2863 );
2864 assert!(
2865 result.iter().any(|w| w.line == 5 && w.message.contains("Expected 0")),
2866 "the misindented top-level item must be flagged with Expected 0, got: {result:?}"
2867 );
2868 assert!(
2869 result
2870 .iter()
2871 .any(|w| w.line == 6 && w.message.contains("Expected 2") && w.message.contains("found 6")),
2872 "the misindented child must be flagged with Expected 2, found 6, got: {result:?}"
2873 );
2874 }
2875
2876 #[test]
2877 fn test_issue_638_lazy_continuation_does_not_terminate_ordered_list() {
2878 let rule = MD007ULIndent::new(2);
2884 let content = "1. ordered\nlazy continuation\n - child\n - grandchild\n";
2885 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2886 let result = rule.check(&ctx).unwrap();
2887 assert!(
2888 result.is_empty(),
2889 "lazy continuation must not terminate the ordered list; sublist stays exempt, got: {result:?}"
2890 );
2891 }
2892
2893 #[test]
2894 fn test_issue_638_heading_interrupts_ordered_list_without_blank() {
2895 let rule = MD007ULIndent::new(2);
2902 let content = "1. ordered\n# heading\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_eq!(
2906 result.len(),
2907 2,
2908 "a heading terminates the ordered list, so the new top-level list and its child are both checked, got: {result:?}"
2909 );
2910 assert!(
2911 result.iter().any(|w| w.line == 3 && w.message.contains("Expected 0")),
2912 "the misindented top-level item must be flagged with Expected 0, got: {result:?}"
2913 );
2914 assert!(
2915 result.iter().any(|w| w.line == 4 && w.message.contains("Expected 2")),
2916 "the misindented child must be flagged with Expected 2, got: {result:?}"
2917 );
2918 }
2919
2920 #[test]
2921 fn test_issue_638_lazy_continuation_inside_blockquote_keeps_exemption() {
2922 let rule = MD007ULIndent::new(2);
2927 let content = "> 1. ordered\n> continuation\n>\n> - child\n> - grandchild\n";
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 "a lazy continuation within the same blockquote must keep the sublist exempt, got: {result:?}"
2933 );
2934 }
2935
2936 #[test]
2937 fn test_issue_638_indented_fence_inside_blockquoted_ordered_item_keeps_exemption() {
2938 let rule = MD007ULIndent::new(2);
2943 let content = "> 1. ordered\n> ```\n> code\n> ```\n> - child\n> - grandchild\n";
2944 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2945 let result = rule.check(&ctx).unwrap();
2946 assert!(
2947 result.is_empty(),
2948 "an indented fence inside a blockquoted ordered item must keep the sublist exempt, got: {result:?}"
2949 );
2950 }
2951
2952 #[test]
2953 fn test_issue_638_fenced_code_block_terminates_ordered_list() {
2954 let rule = MD007ULIndent::new(2);
2960 let content = "1. ordered\n```\ncode\n```\n\n - parent\n - child\n";
2961 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2962 let result = rule.check(&ctx).unwrap();
2963 assert!(
2964 result.iter().any(|w| w.line == 7),
2965 "a top-level fenced code block terminates the ordered list; the child must be flagged, got: {result:?}"
2966 );
2967 }
2968
2969 #[test]
2970 fn test_issue_638_fenced_code_block_inside_item_keeps_exemption() {
2971 let rule = MD007ULIndent::new(2);
2976 let content = "1. ordered\n ```\n code\n ```\n - child\n - grandchild\n";
2977 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2978 let result = rule.check(&ctx).unwrap();
2979 assert!(
2980 result.is_empty(),
2981 "a fenced code block nested inside the item must keep the sublist exempt, got: {result:?}"
2982 );
2983 }
2984
2985 #[test]
2986 fn test_issue_638_blockquote_terminates_ordered_list() {
2987 let rule = MD007ULIndent::new(2);
2994 let content = "1. ordered\n> quote\n\n - parent\n - child\n";
2995 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2996 let result = rule.check(&ctx).unwrap();
2997 assert!(
2998 result.iter().any(|w| w.line == 5),
2999 "blockquote terminates the ordered list, so the child must still be flagged, got: {result:?}"
3000 );
3001 }
3002
3003 #[test]
3004 fn test_issue_638_blockquote_inside_item_keeps_exemption() {
3005 let rule = MD007ULIndent::new(2);
3010 let content = "1. ordered\n > quote inside item\n - child\n - grandchild\n";
3011 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3012 let result = rule.check(&ctx).unwrap();
3013 assert!(
3014 result.is_empty(),
3015 "a blockquote nested inside the item must keep the sublist exempt, got: {result:?}"
3016 );
3017 }
3018
3019 #[test]
3020 fn test_issue_638_exemption_requires_genuine_nesting_under_ordered() {
3021 let rule = MD007ULIndent::new(2);
3030 let content = "100. ordered\n - parent\n - child\n";
3031 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3032 let result = rule.check(&ctx).unwrap();
3033 assert!(
3034 result.iter().any(|w| w.line == 3),
3035 "the child of a non-nested bullet must still be checked, not exempted; got: {result:?}"
3036 );
3037 }
3038
3039 #[test]
3040 fn test_issue_638_paragraph_after_fenced_code_closes_ordered_list() {
3041 let rule = MD007ULIndent::new(2);
3050 let content = "1. ordered\n ```\n code\n ```\nnot lazy text\n - parent\n - child\n";
3051 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3052 let result = rule.check(&ctx).unwrap();
3053 assert!(
3054 result.iter().any(|w| w.line == 7),
3055 "fenced code is not paragraph text, so the list closes and the nested child must still be checked, not exempted; got: {result:?}"
3056 );
3057 }
3058
3059 #[test]
3060 fn test_issue_638_overlong_ordered_marker_is_lazy_continuation() {
3061 let rule = MD007ULIndent::new(2);
3067 let content = "1. ordered\n1234567890. this is continuation text\n - child\n - grandchild\n";
3068 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3069 let result = rule.check(&ctx).unwrap();
3070 assert!(
3071 result.is_empty(),
3072 "an overlong digit run is not a valid ordered marker, so the list stays open and the nested bullets are exempt; got: {result:?}"
3073 );
3074 }
3075
3076 #[test]
3077 fn test_indented_top_level_list_item_is_flagged() {
3078 let rule = MD007ULIndent::new(2);
3084 for indent in 2..=3 {
3085 let pad = " ".repeat(indent);
3086 let content = format!("{pad}- parent\n{pad} - child\n");
3087 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
3088 let result = rule.check(&ctx).unwrap();
3089 assert!(
3090 result.iter().any(|w| w.line == 1),
3091 "a top-level item indented {indent} spaces must be flagged (Expected 0); got: {result:?}"
3092 );
3093 }
3094 }
3095
3096 #[test]
3097 fn test_indented_code_block_bullet_is_not_a_list_item() {
3098 let rule = MD007ULIndent::new(2);
3101 let content = " - not a list, this is code\n";
3102 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3103 let result = rule.check(&ctx).unwrap();
3104 assert!(
3105 result.is_empty(),
3106 "a 4-space-indented bullet is an indented code block, not a misindented list; got: {result:?}"
3107 );
3108 }
3109
3110 #[test]
3111 fn test_tab_indent_expands_to_four_column_tabstop() {
3112 let rule = MD007ULIndent::new(2);
3119 let content = "- a\n\t- b\n";
3120 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3121 let result = rule.check(&ctx).unwrap();
3122 let warning = result
3123 .iter()
3124 .find(|w| w.line == 2)
3125 .expect("a tab-indented sublist at column 4 is over-indented for depth 1 and must be flagged");
3126 assert!(
3127 warning.message.contains("found 4"),
3128 "the tab must expand to the 4-column tab stop (found 4), not be counted as one character; got: {}",
3129 warning.message
3130 );
3131 }
3132
3133 #[test]
3134 fn test_tab_completing_two_space_indent_to_tabstop_is_accepted() {
3135 let rule = MD007ULIndent::new(2);
3141 let content = "- a\n - b\n \t- c\n";
3142 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3143 let result = rule.check(&ctx).unwrap();
3144 assert!(
3145 result.is_empty(),
3146 "` \\t` expands to column 4, the correct depth-2 indent, so no MD007 warning is expected; got: {result:?}"
3147 );
3148 }
3149
3150 #[test]
3151 fn test_issue_638_html_comment_terminates_ordered_list() {
3152 let rule = MD007ULIndent::new(2);
3159 let content = "1. ordered\n<!-- comment -->\n\n - parent\n - child\n";
3160 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3161 let result = rule.check(&ctx).unwrap();
3162 assert!(
3163 result.iter().any(|w| w.line == 5),
3164 "an HTML comment terminates the ordered list, so the child must still be flagged, got: {result:?}"
3165 );
3166 }
3167
3168 #[test]
3169 fn test_issue_638_blockquoted_list_item_terminates_ordered_list() {
3170 let rule = MD007ULIndent::new(2);
3178 let content = "1. ordered\n> - quote list\n\n - parent\n - child\n";
3179 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3180 let result = rule.check(&ctx).unwrap();
3181 assert!(
3182 result.iter().any(|w| w.line == 5),
3183 "a blockquoted list item terminates the ordered list, so the child must still be flagged, got: {result:?}"
3184 );
3185 }
3186
3187 #[test]
3188 fn test_issue_638_deeper_nested_quote_terminates_blockquoted_ordered_list() {
3189 let rule = MD007ULIndent::new(2);
3199 let content = "> 1. ordered\n> > quote\n>\n> - parent\n> - child\n";
3200 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3201 let result = rule.check(&ctx).unwrap();
3202 assert!(
3203 result.iter().any(|w| w.line == 4),
3204 "deeper nested quote closes the ordered list, so the misindented parent must be flagged, got: {result:?}"
3205 );
3206 assert!(
3207 result.iter().any(|w| w.line == 5),
3208 "the child of the fresh unordered list must be flagged, not exempted, got: {result:?}"
3209 );
3210 }
3211
3212 #[test]
3213 fn test_issue_638_deeper_quote_list_item_terminates_blockquoted_ordered_list() {
3214 let rule = MD007ULIndent::new(2);
3222 let content = "> 1. ordered\n> > - quote list\n>\n> - parent\n> - child\n";
3223 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3224 let result = rule.check(&ctx).unwrap();
3225 assert!(
3226 result.iter().any(|w| w.line == 4),
3227 "a deeper-quote list item closes the ordered list, so the parent must be flagged, got: {result:?}"
3228 );
3229 assert!(
3230 result.iter().any(|w| w.line == 5),
3231 "the child of the fresh unordered list must be flagged, not exempted, got: {result:?}"
3232 );
3233 }
3234
3235 #[test]
3236 fn test_issue_638_deeper_quote_indented_into_item_keeps_exemption() {
3237 let rule = MD007ULIndent::new(2);
3242 let content = "> 1. ordered\n> > quote inside item\n> - child\n> - grandchild\n";
3243 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3244 let result = rule.check(&ctx).unwrap();
3245 assert!(
3246 result.is_empty(),
3247 "a deeper quote indented into the item must keep the sublist exempt, got: {result:?}"
3248 );
3249 }
3250
3251 #[test]
3252 fn test_indent4_explicit_with_wide_ordered_parent() {
3253 let config = MD007Config {
3257 indent: crate::types::IndentSize::from_const(4),
3258 start_indented: false,
3259 start_indent: crate::types::IndentSize::from_const(2),
3260 style: md007_config::IndentStyle::TextAligned,
3261 style_explicit: false,
3262 indent_explicit: true,
3263 };
3264 let rule = MD007ULIndent::from_config_struct(config);
3265
3266 let content = "100. Wide ordered\n * Bullet at 5 spaces";
3268 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3269 let result = rule.check(&ctx).unwrap();
3270 assert!(
3271 result.is_empty(),
3272 "indent=4 under '100.' should accept 5-space indent: {result:?}"
3273 );
3274
3275 let content_4 = "100. Wide ordered\n * Bullet at 4 spaces";
3277 let ctx = LintContext::new(content_4, crate::config::MarkdownFlavor::Standard, None);
3278 let result = rule.check(&ctx).unwrap();
3279 assert!(
3280 result.is_empty(),
3281 "indent=4 under '100.' should accept 4-space indent: {result:?}"
3282 );
3283 }
3284
3285 fn commonmark_max_list_depth(md: &str) -> usize {
3289 use pulldown_cmark::{Event, Parser, Tag, TagEnd};
3290 let (mut depth, mut max) = (0usize, 0usize);
3291 for event in Parser::new(md) {
3292 match event {
3293 Event::Start(Tag::List(_)) => {
3294 depth += 1;
3295 max = max.max(depth);
3296 }
3297 Event::End(TagEnd::List(_)) => depth = depth.saturating_sub(1),
3298 _ => {}
3299 }
3300 }
3301 max
3302 }
3303
3304 #[test]
3305 fn test_md007_widened_parent_marker_keeps_nested_child() {
3306 let rule = MD007ULIndent::default();
3312 let content = indoc! {"
3313 - Parent item
3314 - Nested item
3315 "};
3316 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3317 let result = rule.check(&ctx).unwrap();
3318 assert!(
3319 result.is_empty(),
3320 "a child aligned to a widened parent's content column must not be flagged: {result:?}"
3321 );
3322 assert_eq!(commonmark_max_list_depth(content), 2, "precondition: source is nested");
3323 assert_eq!(
3324 rule.fix(&ctx).unwrap(),
3325 content,
3326 "fix must be a no-op for an already correctly nested child"
3327 );
3328 }
3329
3330 #[test]
3331 fn test_md007_widened_parent_aligns_child_to_content_column() {
3332 let rule = MD007ULIndent::default();
3335 let content = indoc! {"
3336 - Parent item
3337 - Nested item
3338 "};
3339 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3340 let fixed = rule.fix(&ctx).unwrap();
3341 assert_eq!(
3342 fixed,
3343 indoc! {"
3344 - Parent item
3345 - Nested item
3346 "},
3347 "child must align to the parent's content column 4: {fixed:?}"
3348 );
3349 assert_eq!(
3350 commonmark_max_list_depth(&fixed),
3351 2,
3352 "fixed child must remain nested, not flattened to a sibling:\n{fixed}"
3353 );
3354 }
3355
3356 #[test]
3357 fn test_md007_widened_markers_nested_multiple_levels() {
3358 let rule = MD007ULIndent::default();
3361 let content = indoc! {"
3362 - Level 0
3363 - Level 1
3364 - Level 2
3365 "};
3366 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3367 let result = rule.check(&ctx).unwrap();
3368 assert!(
3369 result.is_empty(),
3370 "deeply nested widened markers must not be flagged: {result:?}"
3371 );
3372 assert_eq!(
3373 commonmark_max_list_depth(content),
3374 3,
3375 "three nesting levels are preserved"
3376 );
3377 }
3378
3379 #[test]
3380 fn test_md007_default_marker_indent_still_enforced() {
3381 let rule = MD007ULIndent::default();
3385 let content = indoc! {"
3386 - Parent item
3387 - Nested item
3388 "};
3389 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3390 let result = rule.check(&ctx).unwrap();
3391 assert_eq!(
3392 result.len(),
3393 1,
3394 "an over-indented child under a normal marker is still flagged: {result:?}"
3395 );
3396 assert_eq!(
3397 rule.fix(&ctx).unwrap(),
3398 indoc! {"
3399 - Parent item
3400 - Nested item
3401 "}
3402 );
3403 }
3404}