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 calculate_expected_indent(
60 &self,
61 nesting_level: usize,
62 parent_info: Option<(bool, usize)>, ) -> usize {
64 if nesting_level == 0 {
65 return 0;
66 }
67
68 if self.config.style_explicit {
70 return match self.config.style {
71 md007_config::IndentStyle::Fixed => nesting_level * self.config.indent.get() as usize,
72 md007_config::IndentStyle::TextAligned => {
73 parent_info.map_or(nesting_level * 2, |(_, content_col)| content_col)
74 }
75 };
76 }
77
78 if self.config.indent_explicit {
81 match parent_info {
82 Some((true, parent_content_col)) => {
83 return parent_content_col;
86 }
87 _ => {
88 return nesting_level * self.config.indent.get() as usize;
90 }
91 }
92 }
93
94 match parent_info {
96 Some((true, parent_content_col)) => {
97 parent_content_col
100 }
101 Some((false, parent_content_col)) => {
102 let parent_level = nesting_level.saturating_sub(1);
106 let expected_parent_marker = parent_level * self.config.indent.get() as usize;
107 let parent_marker_col = parent_content_col.saturating_sub(2);
109
110 if parent_marker_col == expected_parent_marker {
111 nesting_level * self.config.indent.get() as usize
113 } else {
114 parent_content_col
116 }
117 }
118 None => {
119 nesting_level * self.config.indent.get() as usize
121 }
122 }
123 }
124}
125
126impl Rule for MD007ULIndent {
127 fn name(&self) -> &'static str {
128 "MD007"
129 }
130
131 fn description(&self) -> &'static str {
132 "Unordered list indentation"
133 }
134
135 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
136 let mut warnings = Vec::new();
137 let mut list_stack: Vec<(usize, usize, bool, usize, usize)> = Vec::new(); for (line_idx, line_info) in ctx.lines.iter().enumerate() {
140 if line_info.in_code_block
142 || line_info.in_front_matter
143 || line_info.in_mkdocstrings
144 || line_info.in_footnote_definition
145 {
146 continue;
147 }
148
149 if let Some(list_item) = &line_info.list_item {
151 let (content_for_calculation, adjusted_marker_column) = if line_info.blockquote.is_some() {
155 let line_content = line_info.content(ctx.content);
157 let mut remaining = line_content;
158 let mut content_start = 0;
159
160 loop {
161 let trimmed = remaining.trim_start();
162 if !trimmed.starts_with('>') {
163 break;
164 }
165 content_start += remaining.len() - trimmed.len();
167 content_start += 1;
169 let after_gt = &trimmed[1..];
170 if let Some(stripped) = after_gt.strip_prefix(' ') {
172 content_start += 1;
173 remaining = stripped;
174 } else if let Some(stripped) = after_gt.strip_prefix('\t') {
175 content_start += 1;
176 remaining = stripped;
177 } else {
178 remaining = after_gt;
179 }
180 }
181
182 let content_after_prefix = &line_content[content_start..];
184 let adjusted_col = if list_item.marker_column >= content_start {
186 list_item.marker_column - content_start
187 } else {
188 list_item.marker_column
190 };
191 (content_after_prefix.to_string(), adjusted_col)
192 } else {
193 (line_info.content(ctx.content).to_string(), list_item.marker_column)
194 };
195
196 let visual_marker_column =
198 Self::char_pos_to_visual_column(&content_for_calculation, adjusted_marker_column);
199
200 let visual_content_column = if line_info.blockquote.is_some() {
202 let adjusted_content_col =
204 if list_item.content_column >= (line_info.byte_len - content_for_calculation.len()) {
205 list_item.content_column - (line_info.byte_len - content_for_calculation.len())
206 } else {
207 list_item.content_column
208 };
209 Self::char_pos_to_visual_column(&content_for_calculation, adjusted_content_col)
210 } else {
211 Self::char_pos_to_visual_column(line_info.content(ctx.content), list_item.content_column)
212 };
213
214 let visual_marker_for_nesting = if visual_marker_column == 1 && self.config.indent.get() != 1 {
218 0
219 } else {
220 visual_marker_column
221 };
222
223 let bq_depth = line_info.blockquote.as_ref().map_or(0, |bq| bq.nesting_level);
225
226 while let Some(&(indent, _, _, _, item_bq_depth)) = list_stack.last() {
229 if item_bq_depth == bq_depth && indent >= visual_marker_for_nesting {
230 list_stack.pop();
231 } else if item_bq_depth > bq_depth {
232 list_stack.pop();
234 } else {
235 break;
236 }
237 }
238
239 if list_item.is_ordered {
241 list_stack.push((visual_marker_column, line_idx, true, visual_content_column, bq_depth));
244 continue;
245 }
246
247 let nesting_level = list_stack.iter().filter(|item| item.4 == bq_depth).count();
250
251 let parent_info = list_stack
253 .iter()
254 .rev()
255 .find(|item| item.4 == bq_depth)
256 .map(|&(_, _, is_ordered, content_col, _)| (is_ordered, content_col));
257
258 let mut expected_indent = if self.config.start_indented && nesting_level == 0 {
264 self.config.start_indent.get() as usize
265 } else {
266 self.calculate_expected_indent(nesting_level, parent_info)
267 };
268
269 let also_acceptable =
273 if self.config.indent_explicit && parent_info.is_some_and(|(is_ordered, _)| is_ordered) {
274 Some(nesting_level * self.config.indent.get() as usize)
275 } else {
276 None
277 };
278
279 if ctx.flavor == crate::config::MarkdownFlavor::MkDocs
283 && let Some(&(parent_marker_col, _, true, _, _)) =
284 list_stack.iter().rev().find(|item| item.4 == bq_depth && item.2)
285 {
286 expected_indent = expected_indent.max(parent_marker_col + 4);
287 }
288
289 let accepted_indent = if also_acceptable.is_some_and(|alt| visual_marker_column == alt) {
295 visual_marker_column
296 } else {
297 expected_indent
298 };
299 let expected_content_visual_col = accepted_indent + 2;
300 list_stack.push((
301 visual_marker_column,
302 line_idx,
303 false,
304 expected_content_visual_col,
305 bq_depth,
306 ));
307
308 if !self.config.start_indented && nesting_level == 0 && visual_marker_column != 1 {
311 continue;
312 }
313
314 if visual_marker_column != expected_indent && also_acceptable != Some(visual_marker_column) {
315 if let Some(alt) = also_acceptable {
317 expected_indent = alt;
318 }
319 let fix = {
321 let correct_indent = " ".repeat(expected_indent);
322
323 let replacement = if line_info.blockquote.is_some() {
326 let mut blockquote_count = 0;
328 for ch in line_info.content(ctx.content).chars() {
329 if ch == '>' {
330 blockquote_count += 1;
331 } else if ch != ' ' && ch != '\t' {
332 break;
333 }
334 }
335 let blockquote_prefix = if blockquote_count > 1 {
337 (0..blockquote_count)
338 .map(|_| "> ")
339 .collect::<String>()
340 .trim_end()
341 .to_string()
342 } else {
343 ">".to_string()
344 };
345 format!("{blockquote_prefix} {correct_indent}")
348 } else {
349 correct_indent
350 };
351
352 let start_byte = line_info.byte_offset;
355 let mut end_byte = line_info.byte_offset;
356
357 for (i, ch) in line_info.content(ctx.content).chars().enumerate() {
359 if i >= list_item.marker_column {
360 break;
361 }
362 end_byte += ch.len_utf8();
363 }
364
365 Some(crate::rule::Fix::new(start_byte..end_byte, replacement))
366 };
367
368 warnings.push(LintWarning {
369 rule_name: Some(self.name().to_string()),
370 message: format!(
371 "Expected {expected_indent} spaces for indent depth {nesting_level}, found {visual_marker_column}"
372 ),
373 line: line_idx + 1, column: 1, end_line: line_idx + 1,
376 end_column: visual_marker_column + 1, severity: Severity::Warning,
378 fix,
379 });
380 }
381 }
382 }
383 Ok(warnings)
384 }
385
386 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
388 let warnings = self.check(ctx)?;
390 let warnings =
391 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
392
393 if warnings.is_empty() {
395 return Ok(ctx.content.to_string());
396 }
397
398 let mut fixes: Vec<_> = warnings
400 .iter()
401 .filter_map(|w| w.fix.as_ref().map(|f| (f.range.start, f.range.end, &f.replacement)))
402 .collect();
403 fixes.sort_by(|a, b| b.0.cmp(&a.0));
404
405 let mut result = ctx.content.to_string();
407 for (start, end, replacement) in fixes {
408 if start < result.len() && end <= result.len() && start <= end {
409 result.replace_range(start..end, replacement);
410 }
411 }
412
413 Ok(result)
414 }
415
416 fn category(&self) -> RuleCategory {
418 RuleCategory::List
419 }
420
421 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
423 if ctx.content.is_empty() || !ctx.likely_has_lists() {
425 return true;
426 }
427 !ctx.lines
429 .iter()
430 .any(|line| line.list_item.as_ref().is_some_and(|item| !item.is_ordered))
431 }
432
433 fn as_any(&self) -> &dyn std::any::Any {
434 self
435 }
436
437 fn default_config_section(&self) -> Option<(String, toml::Value)> {
438 let default_config = MD007Config::default();
439 let json_value = serde_json::to_value(&default_config).ok()?;
440 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
441
442 if let toml::Value::Table(table) = toml_value {
443 if !table.is_empty() {
444 Some((MD007Config::RULE_NAME.to_string(), toml::Value::Table(table)))
445 } else {
446 None
447 }
448 } else {
449 None
450 }
451 }
452
453 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
454 where
455 Self: Sized,
456 {
457 let mut rule_config = crate::rule_config_serde::load_rule_config::<MD007Config>(config);
458
459 if let Some(rule_cfg) = config.rules.get("MD007") {
461 rule_config.style_explicit = rule_cfg.values.contains_key("style");
462 rule_config.indent_explicit = rule_cfg.values.contains_key("indent");
463
464 if rule_config.indent_explicit
468 && rule_config.style_explicit
469 && rule_config.style == md007_config::IndentStyle::TextAligned
470 {
471 eprintln!(
472 "\x1b[33m[config warning]\x1b[0m MD007: 'indent' has no effect when 'style = \"text-aligned\"'. \
473 Text-aligned style ignores indent and aligns nested items with parent text. \
474 To use fixed {} space increments, either remove 'style' or set 'style = \"fixed\"'.",
475 rule_config.indent.get()
476 );
477 }
478 }
479
480 if config.markdown_flavor() == crate::config::MarkdownFlavor::MkDocs {
483 if rule_config.indent_explicit && rule_config.indent.get() < 4 {
484 eprintln!(
485 "\x1b[33m[config warning]\x1b[0m MD007: MkDocs flavor requires indent >= 4 \
486 (Python-Markdown enforces 4-space indentation). \
487 Overriding indent={} to indent=4.",
488 rule_config.indent.get()
489 );
490 }
491 if rule_config.style_explicit && rule_config.style == md007_config::IndentStyle::TextAligned {
492 eprintln!(
493 "\x1b[33m[config warning]\x1b[0m MD007: MkDocs flavor requires style=\"fixed\" \
494 (Python-Markdown uses fixed 4-space indentation). \
495 Overriding style=\"text-aligned\" to style=\"fixed\"."
496 );
497 }
498 if rule_config.indent.get() < 4 {
499 rule_config.indent = crate::types::IndentSize::from_const(4);
500 }
501 rule_config.style = md007_config::IndentStyle::Fixed;
502 }
503
504 Box::new(Self::from_config_struct(rule_config))
505 }
506}
507
508#[cfg(test)]
509mod tests {
510 use super::*;
511 use crate::lint_context::LintContext;
512 use crate::rule::Rule;
513
514 #[test]
515 fn test_valid_list_indent() {
516 let rule = MD007ULIndent::default();
517 let content = "* Item 1\n * Item 2\n * Item 3";
518 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
519 let result = rule.check(&ctx).unwrap();
520 assert!(
521 result.is_empty(),
522 "Expected no warnings for valid indentation, but got {} warnings",
523 result.len()
524 );
525 }
526
527 #[test]
528 fn test_invalid_list_indent() {
529 let rule = MD007ULIndent::default();
530 let content = "* Item 1\n * Item 2\n * Item 3";
531 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
532 let result = rule.check(&ctx).unwrap();
533 assert_eq!(result.len(), 2);
534 assert_eq!(result[0].line, 2);
535 assert_eq!(result[0].column, 1);
536 assert_eq!(result[1].line, 3);
537 assert_eq!(result[1].column, 1);
538 }
539
540 #[test]
541 fn test_mixed_indentation() {
542 let rule = MD007ULIndent::default();
543 let content = "* Item 1\n * Item 2\n * Item 3\n * Item 4";
544 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
545 let result = rule.check(&ctx).unwrap();
546 assert_eq!(result.len(), 1);
547 assert_eq!(result[0].line, 3);
548 assert_eq!(result[0].column, 1);
549 }
550
551 #[test]
552 fn test_fix_indentation() {
553 let rule = MD007ULIndent::default();
554 let content = "* Item 1\n * Item 2\n * Item 3";
555 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
556 let result = rule.fix(&ctx).unwrap();
557 let expected = "* Item 1\n * Item 2\n * Item 3";
561 assert_eq!(result, expected);
562 }
563
564 #[test]
565 fn test_md007_in_yaml_code_block() {
566 let rule = MD007ULIndent::default();
567 let content = r#"```yaml
568repos:
569- repo: https://github.com/rvben/rumdl
570 rev: v0.5.0
571 hooks:
572 - id: rumdl-check
573```"#;
574 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
575 let result = rule.check(&ctx).unwrap();
576 assert!(
577 result.is_empty(),
578 "MD007 should not trigger inside a code block, but got warnings: {result:?}"
579 );
580 }
581
582 #[test]
583 fn test_blockquoted_list_indent() {
584 let rule = MD007ULIndent::default();
585 let content = "> * Item 1\n> * Item 2\n> * Item 3";
586 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
587 let result = rule.check(&ctx).unwrap();
588 assert!(
589 result.is_empty(),
590 "Expected no warnings for valid blockquoted list indentation, but got {result:?}"
591 );
592 }
593
594 #[test]
595 fn test_blockquoted_list_invalid_indent() {
596 let rule = MD007ULIndent::default();
597 let content = "> * Item 1\n> * Item 2\n> * Item 3";
598 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
599 let result = rule.check(&ctx).unwrap();
600 assert_eq!(
601 result.len(),
602 2,
603 "Expected 2 warnings for invalid blockquoted list indentation, got {result:?}"
604 );
605 assert_eq!(result[0].line, 2);
606 assert_eq!(result[1].line, 3);
607 }
608
609 #[test]
610 fn test_nested_blockquote_list_indent() {
611 let rule = MD007ULIndent::default();
612 let content = "> > * Item 1\n> > * Item 2\n> > * Item 3";
613 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
614 let result = rule.check(&ctx).unwrap();
615 assert!(
616 result.is_empty(),
617 "Expected no warnings for valid nested blockquoted list indentation, but got {result:?}"
618 );
619 }
620
621 #[test]
622 fn test_blockquote_list_with_code_block() {
623 let rule = MD007ULIndent::default();
624 let content = "> * Item 1\n> * Item 2\n> ```\n> code\n> ```\n> * Item 3";
625 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
626 let result = rule.check(&ctx).unwrap();
627 assert!(
628 result.is_empty(),
629 "MD007 should not trigger inside a code block within a blockquote, but got warnings: {result:?}"
630 );
631 }
632
633 #[test]
634 fn test_properly_indented_lists() {
635 let rule = MD007ULIndent::default();
636
637 let test_cases = vec![
639 "* Item 1\n* Item 2",
640 "* Item 1\n * Item 1.1\n * Item 1.1.1",
641 "- Item 1\n - Item 1.1",
642 "+ Item 1\n + Item 1.1",
643 "* Item 1\n * Item 1.1\n* Item 2\n * Item 2.1",
644 ];
645
646 for content in test_cases {
647 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
648 let result = rule.check(&ctx).unwrap();
649 assert!(
650 result.is_empty(),
651 "Expected no warnings for properly indented list:\n{}\nGot {} warnings",
652 content,
653 result.len()
654 );
655 }
656 }
657
658 #[test]
659 fn test_under_indented_lists() {
660 let rule = MD007ULIndent::default();
661
662 let test_cases = vec![
663 ("* Item 1\n * Item 1.1", 1, 2), ("* Item 1\n * Item 1.1\n * Item 1.1.1", 1, 3), ];
666
667 for (content, expected_warnings, line) in test_cases {
668 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
669 let result = rule.check(&ctx).unwrap();
670 assert_eq!(
671 result.len(),
672 expected_warnings,
673 "Expected {expected_warnings} warnings for under-indented list:\n{content}"
674 );
675 if expected_warnings > 0 {
676 assert_eq!(result[0].line, line);
677 }
678 }
679 }
680
681 #[test]
682 fn test_over_indented_lists() {
683 let rule = MD007ULIndent::default();
684
685 let test_cases = vec![
686 ("* 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), ];
690
691 for (content, expected_warnings, line) in test_cases {
692 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
693 let result = rule.check(&ctx).unwrap();
694 assert_eq!(
695 result.len(),
696 expected_warnings,
697 "Expected {expected_warnings} warnings for over-indented list:\n{content}"
698 );
699 if expected_warnings > 0 {
700 assert_eq!(result[0].line, line);
701 }
702 }
703 }
704
705 #[test]
706 fn test_custom_indent_2_spaces() {
707 let rule = MD007ULIndent::new(2); let content = "* Item 1\n * Item 2\n * Item 3";
709 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
710 let result = rule.check(&ctx).unwrap();
711 assert!(result.is_empty());
712 }
713
714 #[test]
715 fn test_custom_indent_3_spaces() {
716 let rule = MD007ULIndent::new(3);
719
720 let correct_content = "* Item 1\n * Item 2\n * Item 3";
722 let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard, None);
723 let result = rule.check(&ctx).unwrap();
724 assert!(
725 result.is_empty(),
726 "Fixed style expects 0, 3, 6 spaces but got: {result:?}"
727 );
728
729 let wrong_content = "* Item 1\n * Item 2\n * Item 3";
731 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
732 let result = rule.check(&ctx).unwrap();
733 assert!(!result.is_empty(), "Should warn: expected 3 spaces, found 2");
734 }
735
736 #[test]
737 fn test_custom_indent_4_spaces() {
738 let rule = MD007ULIndent::new(4);
741
742 let correct_content = "* Item 1\n * Item 2\n * Item 3";
744 let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard, None);
745 let result = rule.check(&ctx).unwrap();
746 assert!(
747 result.is_empty(),
748 "Fixed style expects 0, 4, 8 spaces but got: {result:?}"
749 );
750
751 let wrong_content = "* Item 1\n * Item 2\n * Item 3";
753 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
754 let result = rule.check(&ctx).unwrap();
755 assert!(!result.is_empty(), "Should warn: expected 4 spaces, found 2");
756 }
757
758 #[test]
759 fn test_tab_indentation() {
760 let rule = MD007ULIndent::default();
761
762 let content = "* Item 1\n * Item 2";
768 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
769 let result = rule.check(&ctx).unwrap();
770 assert_eq!(result.len(), 1, "Wrong indentation should trigger warning");
771
772 let fixed = rule.fix(&ctx).unwrap();
774 assert_eq!(fixed, "* Item 1\n * Item 2");
775
776 let content_multi = "* Item 1\n * Item 2\n * Item 3";
778 let ctx = LintContext::new(content_multi, crate::config::MarkdownFlavor::Standard, None);
779 let fixed = rule.fix(&ctx).unwrap();
780 assert_eq!(fixed, "* Item 1\n * Item 2\n * Item 3");
783
784 let content_mixed = "* Item 1\n * Item 2\n * Item 3";
786 let ctx = LintContext::new(content_mixed, crate::config::MarkdownFlavor::Standard, None);
787 let fixed = rule.fix(&ctx).unwrap();
788 assert_eq!(fixed, "* Item 1\n * Item 2\n * Item 3");
791 }
792
793 #[test]
794 fn test_mixed_ordered_unordered_lists() {
795 let rule = MD007ULIndent::default();
796
797 let content = r#"1. Ordered item
800 * Unordered sub-item (correct - 3 spaces under ordered)
801 2. Ordered sub-item
802* Unordered item
803 1. Ordered sub-item
804 * Unordered sub-item"#;
805
806 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
807 let result = rule.check(&ctx).unwrap();
808 assert_eq!(result.len(), 0, "All unordered list indentation should be correct");
809
810 let fixed = rule.fix(&ctx).unwrap();
812 assert_eq!(fixed, content);
813 }
814
815 #[test]
816 fn test_list_markers_variety() {
817 let rule = MD007ULIndent::default();
818
819 let content = r#"* Asterisk
821 * Nested asterisk
822- Hyphen
823 - Nested hyphen
824+ Plus
825 + Nested plus"#;
826
827 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
828 let result = rule.check(&ctx).unwrap();
829 assert!(
830 result.is_empty(),
831 "All unordered list markers should work with proper indentation"
832 );
833
834 let wrong_content = r#"* Asterisk
836 * Wrong asterisk
837- Hyphen
838 - Wrong hyphen
839+ Plus
840 + Wrong plus"#;
841
842 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
843 let result = rule.check(&ctx).unwrap();
844 assert_eq!(result.len(), 3, "All marker types should be checked for indentation");
845 }
846
847 #[test]
848 fn test_empty_list_items() {
849 let rule = MD007ULIndent::default();
850 let content = "* Item 1\n* \n * Item 2";
851 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
852 let result = rule.check(&ctx).unwrap();
853 assert!(
854 result.is_empty(),
855 "Empty list items should not affect indentation checks"
856 );
857 }
858
859 #[test]
860 fn test_list_with_code_blocks() {
861 let rule = MD007ULIndent::default();
862 let content = r#"* Item 1
863 ```
864 code
865 ```
866 * Item 2
867 * Item 3"#;
868 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
869 let result = rule.check(&ctx).unwrap();
870 assert!(result.is_empty());
871 }
872
873 #[test]
874 fn test_list_in_front_matter() {
875 let rule = MD007ULIndent::default();
876 let content = r#"---
877tags:
878 - tag1
879 - tag2
880---
881* Item 1
882 * Item 2"#;
883 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
884 let result = rule.check(&ctx).unwrap();
885 assert!(result.is_empty(), "Lists in YAML front matter should be ignored");
886 }
887
888 #[test]
889 fn test_fix_preserves_content() {
890 let rule = MD007ULIndent::default();
891 let content = "* Item 1 with **bold** and *italic*\n * Item 2 with `code`\n * Item 3 with [link](url)";
892 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
893 let fixed = rule.fix(&ctx).unwrap();
894 let expected = "* Item 1 with **bold** and *italic*\n * Item 2 with `code`\n * Item 3 with [link](url)";
897 assert_eq!(fixed, expected, "Fix should only change indentation, not content");
898 }
899
900 #[test]
901 fn test_start_indented_config() {
902 let config = MD007Config {
903 start_indented: true,
904 start_indent: crate::types::IndentSize::from_const(4),
905 indent: crate::types::IndentSize::from_const(2),
906 style: md007_config::IndentStyle::TextAligned,
907 style_explicit: true, indent_explicit: false,
909 };
910 let rule = MD007ULIndent::from_config_struct(config);
911
912 let content = " * Item 1\n * Item 2\n * Item 3";
917 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
918 let result = rule.check(&ctx).unwrap();
919 assert!(result.is_empty(), "Expected no warnings with start_indented config");
920
921 let wrong_content = " * Item 1\n * Item 2";
923 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
924 let result = rule.check(&ctx).unwrap();
925 assert_eq!(result.len(), 2);
926 assert_eq!(result[0].line, 1);
927 assert_eq!(result[0].message, "Expected 4 spaces for indent depth 0, found 2");
928 assert_eq!(result[1].line, 2);
929 assert_eq!(result[1].message, "Expected 6 spaces for indent depth 1, found 4");
930
931 let fixed = rule.fix(&ctx).unwrap();
933 assert_eq!(fixed, " * Item 1\n * Item 2");
934 }
935
936 #[test]
937 fn test_start_indented_false_allows_any_first_level() {
938 let rule = MD007ULIndent::default(); let content = " * Item 1"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
943 let result = rule.check(&ctx).unwrap();
944 assert!(
945 result.is_empty(),
946 "First level at any indentation should be allowed when start_indented is false"
947 );
948
949 let content = "* Item 1\n * Item 2\n * Item 3"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
952 let result = rule.check(&ctx).unwrap();
953 assert!(
954 result.is_empty(),
955 "All first-level items should be allowed at any indentation"
956 );
957 }
958
959 #[test]
960 fn test_deeply_nested_lists() {
961 let rule = MD007ULIndent::default();
962 let content = r#"* L1
963 * L2
964 * L3
965 * L4
966 * L5
967 * L6"#;
968 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
969 let result = rule.check(&ctx).unwrap();
970 assert!(result.is_empty());
971
972 let wrong_content = r#"* L1
974 * L2
975 * L3
976 * L4
977 * L5
978 * L6"#;
979 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
980 let result = rule.check(&ctx).unwrap();
981 assert_eq!(result.len(), 2, "Deep nesting errors should be detected");
982 }
983
984 #[test]
985 fn test_excessive_indentation_detected() {
986 let rule = MD007ULIndent::default();
987
988 let content = "- Item 1\n - Item 2 with 5 spaces";
990 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
991 let result = rule.check(&ctx).unwrap();
992 assert_eq!(result.len(), 1, "Should detect excessive indentation (5 instead of 2)");
993 assert_eq!(result[0].line, 2);
994 assert!(result[0].message.contains("Expected 2 spaces"));
995 assert!(result[0].message.contains("found 5"));
996
997 let content = "- Item 1\n - Item 2 with 3 spaces";
999 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1000 let result = rule.check(&ctx).unwrap();
1001 assert_eq!(
1002 result.len(),
1003 1,
1004 "Should detect slightly excessive indentation (3 instead of 2)"
1005 );
1006 assert_eq!(result[0].line, 2);
1007 assert!(result[0].message.contains("Expected 2 spaces"));
1008 assert!(result[0].message.contains("found 3"));
1009
1010 let content = "- Item 1\n - Item 2 with 1 space";
1012 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1013 let result = rule.check(&ctx).unwrap();
1014 assert_eq!(
1015 result.len(),
1016 1,
1017 "Should detect 1-space indent (insufficient for nesting, expected 0)"
1018 );
1019 assert_eq!(result[0].line, 2);
1020 assert!(result[0].message.contains("Expected 0 spaces"));
1021 assert!(result[0].message.contains("found 1"));
1022 }
1023
1024 #[test]
1025 fn test_excessive_indentation_with_4_space_config() {
1026 let rule = MD007ULIndent::new(4);
1029
1030 let content = "- Formatter:\n - The stable style changed";
1032 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1033 let result = rule.check(&ctx).unwrap();
1034 assert!(
1035 !result.is_empty(),
1036 "Should detect 5 spaces when expecting 4 (fixed style)"
1037 );
1038
1039 let correct_content = "- Formatter:\n - The stable style changed";
1041 let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard, None);
1042 let result = rule.check(&ctx).unwrap();
1043 assert!(result.is_empty(), "Should accept correct fixed style indent (4 spaces)");
1044 }
1045
1046 #[test]
1047 fn test_bullets_nested_under_numbered_items() {
1048 let rule = MD007ULIndent::default();
1049 let content = "\
10501. **Active Directory/LDAP**
1051 - User authentication and directory services
1052 - LDAP for user information and validation
1053
10542. **Oracle Unified Directory (OUD)**
1055 - Extended user directory services";
1056 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1057 let result = rule.check(&ctx).unwrap();
1058 assert!(
1060 result.is_empty(),
1061 "Expected no warnings for bullets with 3 spaces under numbered items, got: {result:?}"
1062 );
1063 }
1064
1065 #[test]
1066 fn test_bullets_nested_under_numbered_items_wrong_indent() {
1067 let rule = MD007ULIndent::default();
1068 let content = "\
10691. **Active Directory/LDAP**
1070 - Wrong: only 2 spaces";
1071 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1072 let result = rule.check(&ctx).unwrap();
1073 assert_eq!(
1075 result.len(),
1076 1,
1077 "Expected warning for incorrect indentation under numbered items"
1078 );
1079 assert!(
1080 result
1081 .iter()
1082 .any(|w| w.line == 2 && w.message.contains("Expected 3 spaces"))
1083 );
1084 }
1085
1086 #[test]
1087 fn test_regular_bullet_nesting_still_works() {
1088 let rule = MD007ULIndent::default();
1089 let content = "\
1090* Top level
1091 * Nested bullet (2 spaces is correct)
1092 * Deeply nested (4 spaces)";
1093 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1094 let result = rule.check(&ctx).unwrap();
1095 assert!(
1097 result.is_empty(),
1098 "Expected no warnings for standard bullet nesting, got: {result:?}"
1099 );
1100 }
1101
1102 #[test]
1103 fn test_blockquote_with_tab_after_marker() {
1104 let rule = MD007ULIndent::default();
1105 let content = ">\t* List item\n>\t * Nested\n";
1106 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1107 let result = rule.check(&ctx).unwrap();
1108 assert!(
1109 result.is_empty(),
1110 "Tab after blockquote marker should be handled correctly, got: {result:?}"
1111 );
1112 }
1113
1114 #[test]
1115 fn test_blockquote_with_space_then_tab_after_marker() {
1116 let rule = MD007ULIndent::default();
1117 let content = "> \t* List item\n";
1118 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1119 let result = rule.check(&ctx).unwrap();
1120 assert!(
1122 result.is_empty(),
1123 "First-level list item at any indentation is allowed when start_indented=false, got: {result:?}"
1124 );
1125 }
1126
1127 #[test]
1128 fn test_blockquote_with_multiple_tabs() {
1129 let rule = MD007ULIndent::default();
1130 let content = ">\t\t* List item\n";
1131 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1132 let result = rule.check(&ctx).unwrap();
1133 assert!(
1135 result.is_empty(),
1136 "First-level list item at any indentation is allowed when start_indented=false, got: {result:?}"
1137 );
1138 }
1139
1140 #[test]
1141 fn test_nested_blockquote_with_tab() {
1142 let rule = MD007ULIndent::default();
1143 let content = ">\t>\t* List item\n>\t>\t * Nested\n";
1144 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1145 let result = rule.check(&ctx).unwrap();
1146 assert!(
1147 result.is_empty(),
1148 "Nested blockquotes with tabs should work correctly, got: {result:?}"
1149 );
1150 }
1151
1152 #[test]
1155 fn test_smart_style_pure_unordered_uses_fixed() {
1156 let rule = MD007ULIndent::new(4);
1158
1159 let content = "* Level 0\n * Level 1\n * Level 2";
1161 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1162 let result = rule.check(&ctx).unwrap();
1163 assert!(
1164 result.is_empty(),
1165 "Pure unordered with indent=4 should use fixed style (0, 4, 8), got: {result:?}"
1166 );
1167 }
1168
1169 #[test]
1170 fn test_smart_style_mixed_lists_uses_text_aligned() {
1171 let rule = MD007ULIndent::new(4);
1173
1174 let content = "1. Ordered\n * Bullet aligns with 'Ordered' text (3 spaces)";
1176 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1177 let result = rule.check(&ctx).unwrap();
1178 assert!(
1179 result.is_empty(),
1180 "Mixed lists should use text-aligned style, got: {result:?}"
1181 );
1182 }
1183
1184 #[test]
1185 fn test_smart_style_explicit_fixed_overrides() {
1186 let config = MD007Config {
1188 indent: crate::types::IndentSize::from_const(4),
1189 start_indented: false,
1190 start_indent: crate::types::IndentSize::from_const(2),
1191 style: md007_config::IndentStyle::Fixed,
1192 style_explicit: true, indent_explicit: false,
1194 };
1195 let rule = MD007ULIndent::from_config_struct(config);
1196
1197 let content = "1. Ordered\n * Should be at 4 spaces (fixed)";
1199 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1200 let result = rule.check(&ctx).unwrap();
1201 assert!(
1203 result.is_empty(),
1204 "Explicit fixed style should be respected, got: {result:?}"
1205 );
1206 }
1207
1208 #[test]
1209 fn test_smart_style_explicit_text_aligned_overrides() {
1210 let config = MD007Config {
1212 indent: crate::types::IndentSize::from_const(4),
1213 start_indented: false,
1214 start_indent: crate::types::IndentSize::from_const(2),
1215 style: md007_config::IndentStyle::TextAligned,
1216 style_explicit: true, indent_explicit: false,
1218 };
1219 let rule = MD007ULIndent::from_config_struct(config);
1220
1221 let content = "* Level 0\n * Level 1 (aligned with 'Level 0' text)";
1223 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1224 let result = rule.check(&ctx).unwrap();
1225 assert!(
1226 result.is_empty(),
1227 "Explicit text-aligned should be respected, got: {result:?}"
1228 );
1229
1230 let fixed_style_content = "* Level 0\n * Level 1 (4 spaces - fixed style)";
1232 let ctx = LintContext::new(fixed_style_content, crate::config::MarkdownFlavor::Standard, None);
1233 let result = rule.check(&ctx).unwrap();
1234 assert!(
1235 !result.is_empty(),
1236 "With explicit text-aligned, 4-space indent should be wrong (expected 2)"
1237 );
1238 }
1239
1240 #[test]
1241 fn test_smart_style_default_indent_no_autoswitch() {
1242 let rule = MD007ULIndent::new(2);
1244
1245 let content = "* Level 0\n * Level 1\n * Level 2";
1246 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1247 let result = rule.check(&ctx).unwrap();
1248 assert!(
1249 result.is_empty(),
1250 "Default indent should work regardless of style, got: {result:?}"
1251 );
1252 }
1253
1254 #[test]
1255 fn test_has_mixed_list_nesting_detection() {
1256 let content = "* Item 1\n * Item 2\n * Item 3";
1260 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1261 assert!(
1262 !ctx.has_mixed_list_nesting(),
1263 "Pure unordered should not be detected as mixed"
1264 );
1265
1266 let content = "1. Item 1\n 2. Item 2\n 3. Item 3";
1268 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1269 assert!(
1270 !ctx.has_mixed_list_nesting(),
1271 "Pure ordered should not be detected as mixed"
1272 );
1273
1274 let content = "1. Ordered\n * Unordered child";
1276 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1277 assert!(
1278 ctx.has_mixed_list_nesting(),
1279 "Unordered under ordered should be detected as mixed"
1280 );
1281
1282 let content = "* Unordered\n 1. Ordered child";
1284 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1285 assert!(
1286 ctx.has_mixed_list_nesting(),
1287 "Ordered under unordered should be detected as mixed"
1288 );
1289
1290 let content = "* Unordered\n\n1. Ordered (separate list)";
1292 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1293 assert!(
1294 !ctx.has_mixed_list_nesting(),
1295 "Separate lists should not be detected as mixed"
1296 );
1297
1298 let content = "> 1. Ordered in blockquote\n> * Unordered child";
1300 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1301 assert!(
1302 ctx.has_mixed_list_nesting(),
1303 "Mixed lists in blockquotes should be detected"
1304 );
1305 }
1306
1307 #[test]
1308 fn test_issue_210_exact_reproduction() {
1309 let config = MD007Config {
1311 indent: crate::types::IndentSize::from_const(4),
1312 start_indented: false,
1313 start_indent: crate::types::IndentSize::from_const(2),
1314 style: md007_config::IndentStyle::TextAligned, style_explicit: false, indent_explicit: false, };
1318 let rule = MD007ULIndent::from_config_struct(config);
1319
1320 let content = "# Title\n\n* some\n * list\n * items\n";
1321 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1322 let result = rule.check(&ctx).unwrap();
1323
1324 assert!(
1325 result.is_empty(),
1326 "Issue #210: indent=4 on pure unordered should work (auto-fixed style), got: {result:?}"
1327 );
1328 }
1329
1330 #[test]
1331 fn test_issue_209_still_fixed() {
1332 let config = MD007Config {
1335 indent: crate::types::IndentSize::from_const(3),
1336 start_indented: false,
1337 start_indent: crate::types::IndentSize::from_const(2),
1338 style: md007_config::IndentStyle::TextAligned,
1339 style_explicit: true, indent_explicit: false,
1341 };
1342 let rule = MD007ULIndent::from_config_struct(config);
1343
1344 let content = r#"# Header 1
1346
1347- **Second item**:
1348 - **This is a nested list**:
1349 1. **First point**
1350 - First subpoint
1351"#;
1352 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1353 let result = rule.check(&ctx).unwrap();
1354
1355 assert!(
1356 result.is_empty(),
1357 "Issue #209: With explicit text-aligned style, should have no issues, got: {result:?}"
1358 );
1359 }
1360
1361 #[test]
1364 fn test_multi_level_mixed_detection_grandparent() {
1365 let content = "1. Ordered grandparent\n * Unordered child\n * Unordered grandchild";
1369 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1370 assert!(
1371 ctx.has_mixed_list_nesting(),
1372 "Should detect mixed nesting when grandparent differs in type"
1373 );
1374
1375 let content = "* Unordered grandparent\n 1. Ordered child\n 2. Ordered grandchild";
1377 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1378 assert!(
1379 ctx.has_mixed_list_nesting(),
1380 "Should detect mixed nesting for ordered descendants under unordered"
1381 );
1382 }
1383
1384 #[test]
1385 fn test_html_comments_skipped_in_detection() {
1386 let content = r#"* Unordered list
1388<!-- This is a comment
1389 1. This ordered list is inside a comment
1390 * This nested bullet is also inside
1391-->
1392 * Another unordered item"#;
1393 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1394 assert!(
1395 !ctx.has_mixed_list_nesting(),
1396 "Lists in HTML comments should be ignored in mixed detection"
1397 );
1398 }
1399
1400 #[test]
1401 fn test_blank_lines_separate_lists() {
1402 let content = "* First unordered list\n\n1. Second list is ordered (separate)";
1404 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1405 assert!(
1406 !ctx.has_mixed_list_nesting(),
1407 "Blank line at root should separate lists"
1408 );
1409
1410 let content = "1. Ordered parent\n\n * Still a child due to indentation";
1412 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1413 assert!(
1414 ctx.has_mixed_list_nesting(),
1415 "Indented list after blank is still nested"
1416 );
1417 }
1418
1419 #[test]
1420 fn test_column_1_normalization() {
1421 let content = "* First item\n * Second item with 1 space (sibling)";
1424 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1425 let rule = MD007ULIndent::default();
1426 let result = rule.check(&ctx).unwrap();
1427 assert!(
1429 result.iter().any(|w| w.line == 2),
1430 "1-space indent should be flagged as incorrect"
1431 );
1432 }
1433
1434 #[test]
1435 fn test_code_blocks_skipped_in_detection() {
1436 let content = r#"* Unordered list
1438```
14391. This ordered list is inside a code block
1440 * This nested bullet is also inside
1441```
1442 * Another unordered item"#;
1443 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1444 assert!(
1445 !ctx.has_mixed_list_nesting(),
1446 "Lists in code blocks should be ignored in mixed detection"
1447 );
1448 }
1449
1450 #[test]
1451 fn test_front_matter_skipped_in_detection() {
1452 let content = r#"---
1454items:
1455 - yaml list item
1456 - another item
1457---
1458* Unordered list after front matter"#;
1459 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1460 assert!(
1461 !ctx.has_mixed_list_nesting(),
1462 "Lists in front matter should be ignored in mixed detection"
1463 );
1464 }
1465
1466 #[test]
1467 fn test_alternating_types_at_same_level() {
1468 let content = "* First bullet\n1. First number\n* Second bullet\n2. Second number";
1471 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1472 assert!(
1473 !ctx.has_mixed_list_nesting(),
1474 "Alternating types at same level should not be detected as mixed"
1475 );
1476 }
1477
1478 #[test]
1479 fn test_five_level_deep_mixed_nesting() {
1480 let content = "* L0\n 1. L1\n * L2\n 1. L3\n * L4\n 1. L5";
1482 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1483 assert!(ctx.has_mixed_list_nesting(), "Should detect mixed nesting at 5+ levels");
1484 }
1485
1486 #[test]
1487 fn test_very_deep_pure_unordered_nesting() {
1488 let mut content = String::from("* L1");
1490 for level in 2..=12 {
1491 let indent = " ".repeat(level - 1);
1492 content.push_str(&format!("\n{indent}* L{level}"));
1493 }
1494
1495 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1496
1497 assert!(
1499 !ctx.has_mixed_list_nesting(),
1500 "Pure unordered deep nesting should not be detected as mixed"
1501 );
1502
1503 let rule = MD007ULIndent::new(4);
1505 let result = rule.check(&ctx).unwrap();
1506 assert!(!result.is_empty(), "Should flag incorrect indentation for fixed style");
1509 }
1510
1511 #[test]
1512 fn test_interleaved_content_between_list_items() {
1513 let content = "1. Ordered parent\n\n Paragraph continuation\n\n * Unordered child";
1515 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1516 assert!(
1517 ctx.has_mixed_list_nesting(),
1518 "Should detect mixed nesting even with interleaved paragraphs"
1519 );
1520 }
1521
1522 #[test]
1523 fn test_esm_blocks_skipped_in_detection() {
1524 let content = "* Unordered list\n * Nested unordered";
1527 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1528 assert!(
1529 !ctx.has_mixed_list_nesting(),
1530 "Pure unordered should not be detected as mixed"
1531 );
1532 }
1533
1534 #[test]
1535 fn test_multiple_list_blocks_pure_then_mixed() {
1536 let content = r#"* Pure unordered
1539 * Nested unordered
1540
15411. Mixed section
1542 * Bullet under ordered"#;
1543 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1544 assert!(
1545 ctx.has_mixed_list_nesting(),
1546 "Should detect mixed nesting in any part of document"
1547 );
1548 }
1549
1550 #[test]
1551 fn test_multiple_separate_pure_lists() {
1552 let content = r#"* First list
1555 * Nested
1556
1557* Second list
1558 * Also nested
1559
1560* Third list
1561 * Deeply
1562 * Nested"#;
1563 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1564 assert!(
1565 !ctx.has_mixed_list_nesting(),
1566 "Multiple separate pure unordered lists should not be mixed"
1567 );
1568 }
1569
1570 #[test]
1571 fn test_code_block_between_list_items() {
1572 let content = r#"1. Ordered
1574 ```
1575 code
1576 ```
1577 * Still a mixed child"#;
1578 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1579 assert!(
1580 ctx.has_mixed_list_nesting(),
1581 "Code block between items should not prevent mixed detection"
1582 );
1583 }
1584
1585 #[test]
1586 fn test_blockquoted_mixed_detection() {
1587 let content = "> 1. Ordered in blockquote\n> * Mixed child";
1589 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1590 assert!(
1593 ctx.has_mixed_list_nesting(),
1594 "Should detect mixed nesting in blockquotes"
1595 );
1596 }
1597
1598 #[test]
1601 fn test_indent_explicit_uses_fixed_style() {
1602 let config = MD007Config {
1605 indent: crate::types::IndentSize::from_const(4),
1606 start_indented: false,
1607 start_indent: crate::types::IndentSize::from_const(2),
1608 style: md007_config::IndentStyle::TextAligned, style_explicit: false, indent_explicit: true, };
1612 let rule = MD007ULIndent::from_config_struct(config);
1613
1614 let content = "* Level 0\n * Level 1\n * Level 2";
1617 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1618 let result = rule.check(&ctx).unwrap();
1619 assert!(
1620 result.is_empty(),
1621 "With indent_explicit=true, should use fixed style (0, 4, 8), got: {result:?}"
1622 );
1623
1624 let wrong_content = "* Level 0\n * Level 1\n * Level 2";
1626 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
1627 let result = rule.check(&ctx).unwrap();
1628 assert!(
1629 !result.is_empty(),
1630 "Should flag text-aligned spacing when indent_explicit=true"
1631 );
1632 }
1633
1634 #[test]
1635 fn test_explicit_style_overrides_indent_explicit() {
1636 let config = MD007Config {
1639 indent: crate::types::IndentSize::from_const(4),
1640 start_indented: false,
1641 start_indent: crate::types::IndentSize::from_const(2),
1642 style: md007_config::IndentStyle::TextAligned,
1643 style_explicit: true, indent_explicit: true, };
1646 let rule = MD007ULIndent::from_config_struct(config);
1647
1648 let content = "* Level 0\n * Level 1\n * Level 2";
1650 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1651 let result = rule.check(&ctx).unwrap();
1652 assert!(
1653 result.is_empty(),
1654 "Explicit text-aligned style should be respected, got: {result:?}"
1655 );
1656 }
1657
1658 #[test]
1659 fn test_no_indent_explicit_uses_smart_detection() {
1660 let config = MD007Config {
1662 indent: crate::types::IndentSize::from_const(4),
1663 start_indented: false,
1664 start_indent: crate::types::IndentSize::from_const(2),
1665 style: md007_config::IndentStyle::TextAligned,
1666 style_explicit: false,
1667 indent_explicit: false, };
1669 let rule = MD007ULIndent::from_config_struct(config);
1670
1671 let content = "* Level 0\n * Level 1";
1674 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1675 let result = rule.check(&ctx).unwrap();
1676 assert!(
1678 result.is_empty(),
1679 "Smart detection should accept 4-space indent, got: {result:?}"
1680 );
1681 }
1682
1683 #[test]
1684 fn test_issue_273_exact_reproduction() {
1685 let config = MD007Config {
1688 indent: crate::types::IndentSize::from_const(4),
1689 start_indented: false,
1690 start_indent: crate::types::IndentSize::from_const(2),
1691 style: md007_config::IndentStyle::TextAligned, style_explicit: false,
1693 indent_explicit: true, };
1695 let rule = MD007ULIndent::from_config_struct(config);
1696
1697 let content = r#"* Item 1
1698 * Item 2
1699 * Item 3"#;
1700 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1701 let result = rule.check(&ctx).unwrap();
1702 assert!(
1703 result.is_empty(),
1704 "Issue #273: indent=4 should use 4-space increments, got: {result:?}"
1705 );
1706 }
1707
1708 #[test]
1709 fn test_indent_explicit_with_ordered_parent() {
1710 let config = MD007Config {
1714 indent: crate::types::IndentSize::from_const(4),
1715 start_indented: false,
1716 start_indent: crate::types::IndentSize::from_const(2),
1717 style: md007_config::IndentStyle::TextAligned,
1718 style_explicit: false,
1719 indent_explicit: true, };
1721 let rule = MD007ULIndent::from_config_struct(config);
1722
1723 let content = "1. Ordered\n * Bullet with 4-space indent";
1725 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1726 let result = rule.check(&ctx).unwrap();
1727 assert!(
1728 result.is_empty(),
1729 "4-space indent under ordered should pass with indent=4: {result:?}"
1730 );
1731
1732 let content_3 = "1. Ordered\n * Bullet with 3-space indent";
1734 let ctx = LintContext::new(content_3, crate::config::MarkdownFlavor::Standard, None);
1735 let result = rule.check(&ctx).unwrap();
1736 assert!(
1737 result.is_empty(),
1738 "3-space indent under ordered should pass (text-aligned): {result:?}"
1739 );
1740
1741 let wrong_content = "1. Ordered\n * Bullet with 2-space indent";
1743 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
1744 let result = rule.check(&ctx).unwrap();
1745 assert!(
1746 !result.is_empty(),
1747 "2-space indent under ordered list should be flagged when indent=4: {result:?}"
1748 );
1749 }
1750
1751 #[test]
1752 fn test_indent_explicit_mixed_list_deep_nesting() {
1753 let config = MD007Config {
1758 indent: crate::types::IndentSize::from_const(4),
1759 start_indented: false,
1760 start_indent: crate::types::IndentSize::from_const(2),
1761 style: md007_config::IndentStyle::TextAligned,
1762 style_explicit: false,
1763 indent_explicit: true,
1764 };
1765 let rule = MD007ULIndent::from_config_struct(config);
1766
1767 let content_text_aligned = r#"* Level 0
1773 * Level 1 (4-space indent from bullet parent)
1774 1. Level 2 ordered
1775 * Level 3 bullet (text-aligned under ordered)"#;
1776 let ctx = LintContext::new(content_text_aligned, crate::config::MarkdownFlavor::Standard, None);
1777 let result = rule.check(&ctx).unwrap();
1778 assert!(
1779 result.is_empty(),
1780 "Text-aligned nesting under ordered should pass: {result:?}"
1781 );
1782
1783 let content_fixed = r#"* Level 0
1784 * Level 1 (4-space indent from bullet parent)
1785 1. Level 2 ordered
1786 * Level 3 bullet (fixed indent under ordered)"#;
1787 let ctx = LintContext::new(content_fixed, crate::config::MarkdownFlavor::Standard, None);
1788 let result = rule.check(&ctx).unwrap();
1789 assert!(
1790 result.is_empty(),
1791 "Fixed indent nesting under ordered should also pass: {result:?}"
1792 );
1793 }
1794
1795 #[test]
1796 fn test_ordered_list_double_digit_markers() {
1797 let config = MD007Config {
1800 indent: crate::types::IndentSize::from_const(4),
1801 start_indented: false,
1802 start_indent: crate::types::IndentSize::from_const(2),
1803 style: md007_config::IndentStyle::TextAligned,
1804 style_explicit: false,
1805 indent_explicit: true,
1806 };
1807 let rule = MD007ULIndent::from_config_struct(config);
1808
1809 let content = "10. Double digit\n * Bullet at col 4";
1811 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1812 let result = rule.check(&ctx).unwrap();
1813 assert!(
1814 result.is_empty(),
1815 "Bullet under '10.' should align at column 4: {result:?}"
1816 );
1817
1818 let content_3 = "1. Single digit\n * Bullet at col 3";
1821 let ctx = LintContext::new(content_3, crate::config::MarkdownFlavor::Standard, None);
1822 let result = rule.check(&ctx).unwrap();
1823 assert!(
1824 result.is_empty(),
1825 "Bullet under '1.' with 3-space indent should pass (text-aligned): {result:?}"
1826 );
1827
1828 let content_4 = "1. Single digit\n * Bullet at col 4";
1829 let ctx = LintContext::new(content_4, crate::config::MarkdownFlavor::Standard, None);
1830 let result = rule.check(&ctx).unwrap();
1831 assert!(
1832 result.is_empty(),
1833 "Bullet under '1.' with 4-space indent should pass (fixed): {result:?}"
1834 );
1835 }
1836
1837 #[test]
1838 fn test_indent_explicit_pure_unordered_uses_fixed() {
1839 let config = MD007Config {
1842 indent: crate::types::IndentSize::from_const(4),
1843 start_indented: false,
1844 start_indent: crate::types::IndentSize::from_const(2),
1845 style: md007_config::IndentStyle::TextAligned,
1846 style_explicit: false,
1847 indent_explicit: true,
1848 };
1849 let rule = MD007ULIndent::from_config_struct(config);
1850
1851 let content = "* Level 0\n * Level 1\n * Level 2";
1853 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1854 let result = rule.check(&ctx).unwrap();
1855 assert!(
1856 result.is_empty(),
1857 "Pure unordered with indent=4 should use 4-space increments: {result:?}"
1858 );
1859
1860 let wrong_content = "* Level 0\n * Level 1\n * Level 2";
1862 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
1863 let result = rule.check(&ctx).unwrap();
1864 assert!(
1865 !result.is_empty(),
1866 "2-space indent should be flagged when indent=4 is configured"
1867 );
1868 }
1869
1870 #[test]
1871 fn test_mkdocs_ordered_list_with_4_space_nested_unordered() {
1872 let rule = MD007ULIndent::default();
1876 let content = "1. text\n\n - nested item";
1877 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1878 let result = rule.check(&ctx).unwrap();
1879 assert!(
1880 result.is_empty(),
1881 "4-space indent under ordered list should be valid in MkDocs flavor, got: {result:?}"
1882 );
1883 }
1884
1885 #[test]
1886 fn test_standard_flavor_ordered_list_with_3_space_nested_unordered() {
1887 let rule = MD007ULIndent::default();
1890 let content = "1. text\n\n - nested item";
1891 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1892 let result = rule.check(&ctx).unwrap();
1893 assert!(
1894 result.is_empty(),
1895 "3-space indent under ordered list should be valid in Standard flavor, got: {result:?}"
1896 );
1897 }
1898
1899 #[test]
1900 fn test_standard_flavor_ordered_list_with_4_space_warns() {
1901 let rule = MD007ULIndent::default();
1904 let content = "1. text\n\n - nested item";
1905 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1906 let result = rule.check(&ctx).unwrap();
1907 assert_eq!(
1908 result.len(),
1909 1,
1910 "4-space indent under ordered list should warn in Standard flavor"
1911 );
1912 }
1913
1914 #[test]
1915 fn test_mkdocs_multi_digit_ordered_list() {
1916 let rule = MD007ULIndent::default();
1919 let content = "10. text\n\n - nested item";
1920 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1921 let result = rule.check(&ctx).unwrap();
1922 assert!(
1923 result.is_empty(),
1924 "4-space indent under `10.` should be valid in MkDocs flavor, got: {result:?}"
1925 );
1926 }
1927
1928 #[test]
1929 fn test_mkdocs_triple_digit_ordered_list() {
1930 let rule = MD007ULIndent::default();
1933 let content = "100. text\n\n - nested item";
1934 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1935 let result = rule.check(&ctx).unwrap();
1936 assert!(
1937 result.is_empty(),
1938 "5-space indent under `100.` should be valid in MkDocs flavor, got: {result:?}"
1939 );
1940 }
1941
1942 #[test]
1943 fn test_mkdocs_insufficient_indent_under_ordered() {
1944 let rule = MD007ULIndent::default();
1947 let content = "1. text\n\n - nested item";
1948 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1949 let result = rule.check(&ctx).unwrap();
1950 assert_eq!(
1951 result.len(),
1952 1,
1953 "2-space indent under ordered list should warn in MkDocs flavor"
1954 );
1955 assert!(
1956 result[0].message.contains("Expected 4"),
1957 "Warning should expect 4 spaces (MkDocs minimum), got: {}",
1958 result[0].message
1959 );
1960 }
1961
1962 #[test]
1963 fn test_mkdocs_deeper_nesting_under_ordered() {
1964 let rule = MD007ULIndent::default();
1969 let content = "1. text\n\n - sub\n - subsub";
1970 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1971 let result = rule.check(&ctx).unwrap();
1972 assert!(
1973 result.is_empty(),
1974 "Deeper nesting under ordered list should be valid in MkDocs flavor, got: {result:?}"
1975 );
1976 }
1977
1978 #[test]
1979 fn test_mkdocs_fix_adjusts_to_4_spaces() {
1980 let rule = MD007ULIndent::default();
1982 let content = "1. text\n\n - nested item";
1983 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1984 let result = rule.check(&ctx).unwrap();
1985 assert_eq!(result.len(), 1, "3-space indent should warn in MkDocs");
1986 let fixed = rule.fix(&ctx).unwrap();
1987 assert_eq!(
1988 fixed, "1. text\n\n - nested item",
1989 "Fix should adjust indent to 4 spaces in MkDocs"
1990 );
1991 }
1992
1993 #[test]
1994 fn test_mkdocs_start_indented_with_ordered_parent() {
1995 let config = MD007Config {
1998 start_indented: true,
1999 ..Default::default()
2000 };
2001 let rule = MD007ULIndent::from_config_struct(config);
2002 let content = "1. text\n\n - nested item";
2003 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2004 let result = rule.check(&ctx).unwrap();
2005 assert!(
2006 result.is_empty(),
2007 "4-space indent under ordered list with start_indented should be valid in MkDocs, got: {result:?}"
2008 );
2009 }
2010
2011 #[test]
2012 fn test_mkdocs_ordered_at_nonzero_indent() {
2013 let rule = MD007ULIndent::default();
2018 let content = "- outer\n 1. inner\n - deep";
2019 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2020 let result = rule.check(&ctx).unwrap();
2021 assert!(
2022 result.is_empty(),
2023 "6-space indent under nested ordered list should be valid in MkDocs, got: {result:?}"
2024 );
2025 }
2026
2027 #[test]
2028 fn test_mkdocs_blockquoted_ordered_list() {
2029 let rule = MD007ULIndent::default();
2033 let content = "> 1. text\n>\n> - nested item";
2034 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2035 let result = rule.check(&ctx).unwrap();
2036 assert!(
2037 result.is_empty(),
2038 "4-space indent under blockquoted ordered list should be valid in MkDocs, got: {result:?}"
2039 );
2040 }
2041
2042 #[test]
2043 fn test_mkdocs_ordered_at_nonzero_indent_insufficient() {
2044 let rule = MD007ULIndent::default();
2047 let content = "- outer\n 1. inner\n - deep";
2048 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2049 let result = rule.check(&ctx).unwrap();
2050 assert_eq!(
2051 result.len(),
2052 1,
2053 "5-space indent under nested ordered at col 2 should warn in MkDocs (needs 6)"
2054 );
2055 }
2056
2057 #[test]
2058 fn test_issue_504_indent4_ordered_parent() {
2059 let config = MD007Config {
2063 indent: crate::types::IndentSize::from_const(4),
2064 start_indented: false,
2065 start_indent: crate::types::IndentSize::from_const(2),
2066 style: md007_config::IndentStyle::TextAligned,
2067 style_explicit: false,
2068 indent_explicit: true,
2069 };
2070 let rule = MD007ULIndent::from_config_struct(config);
2071
2072 let content = r#"# Things
2073
2074+ An unordered list
2075 + An item with 4 spaces, ok.
2076
20771. A numbered list
2078 + A sublist with 4 spaces, not ok
2079 + A sub item with 4 spaces, ok
2080 + Why is rumdl expecting 3 spaces for a 4 space indent?
20812. Item 2
20823. Item 3"#;
2083 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2084 let result = rule.check(&ctx).unwrap();
2085 assert!(
2086 result.is_empty(),
2087 "Issue #504: indent=4 with ordered parent should accept 4-space indent: {result:?}"
2088 );
2089 }
2090
2091 #[test]
2092 fn test_indent2_explicit_with_ordered_parent() {
2093 let config = MD007Config {
2096 indent: crate::types::IndentSize::from_const(2),
2097 start_indented: false,
2098 start_indent: crate::types::IndentSize::from_const(2),
2099 style: md007_config::IndentStyle::TextAligned,
2100 style_explicit: false,
2101 indent_explicit: true,
2102 };
2103 let rule = MD007ULIndent::from_config_struct(config);
2104
2105 let content = "1. Ordered\n * Bullet at 3 spaces";
2107 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2108 let result = rule.check(&ctx).unwrap();
2109 assert!(
2110 result.is_empty(),
2111 "indent=2 under '1.' should accept text-aligned (3 spaces): {result:?}"
2112 );
2113
2114 let content_2 = "1. Ordered\n * Bullet at 2 spaces";
2116 let ctx = LintContext::new(content_2, crate::config::MarkdownFlavor::Standard, None);
2117 let result = rule.check(&ctx).unwrap();
2118 assert!(
2119 result.is_empty(),
2120 "indent=2 under '1.' should accept fixed indent (2 spaces): {result:?}"
2121 );
2122 }
2123
2124 #[test]
2125 fn test_indent4_explicit_with_wide_ordered_parent() {
2126 let config = MD007Config {
2130 indent: crate::types::IndentSize::from_const(4),
2131 start_indented: false,
2132 start_indent: crate::types::IndentSize::from_const(2),
2133 style: md007_config::IndentStyle::TextAligned,
2134 style_explicit: false,
2135 indent_explicit: true,
2136 };
2137 let rule = MD007ULIndent::from_config_struct(config);
2138
2139 let content = "100. Wide ordered\n * Bullet at 5 spaces";
2141 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2142 let result = rule.check(&ctx).unwrap();
2143 assert!(
2144 result.is_empty(),
2145 "indent=4 under '100.' should accept 5-space indent: {result:?}"
2146 );
2147
2148 let content_4 = "100. Wide ordered\n * Bullet at 4 spaces";
2150 let ctx = LintContext::new(content_4, crate::config::MarkdownFlavor::Standard, None);
2151 let result = rule.check(&ctx).unwrap();
2152 assert!(
2153 result.is_empty(),
2154 "indent=4 under '100.' should accept 4-space indent: {result:?}"
2155 );
2156 }
2157}