1use crate::rule::{LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
5use crate::rule_config_serde::RuleConfig;
6
7mod 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 {
82 match parent_info {
83 Some((true, parent_content_col)) => {
84 return parent_content_col;
87 }
88 _ => {
89 return nesting_level * self.config.indent.get() as usize;
91 }
92 }
93 }
94
95 match parent_info {
97 Some((true, parent_content_col)) => {
98 parent_content_col
101 }
102 Some((false, parent_content_col)) => {
103 let parent_level = nesting_level.saturating_sub(1);
107 let expected_parent_marker = parent_level * self.config.indent.get() as usize;
108 let parent_marker_col = parent_content_col.saturating_sub(2);
110
111 if parent_marker_col == expected_parent_marker {
112 nesting_level * self.config.indent.get() as usize
114 } else {
115 parent_content_col
117 }
118 }
119 None => {
120 nesting_level * self.config.indent.get() as usize
122 }
123 }
124 }
125}
126
127impl Rule for MD007ULIndent {
128 fn name(&self) -> &'static str {
129 "MD007"
130 }
131
132 fn description(&self) -> &'static str {
133 "Unordered list indentation"
134 }
135
136 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
137 let mut warnings = Vec::new();
138 let mut list_stack: Vec<(usize, usize, bool, usize)> = Vec::new(); for (line_idx, line_info) in ctx.lines.iter().enumerate() {
141 if line_info.in_code_block || line_info.in_front_matter || line_info.in_mkdocstrings {
143 continue;
144 }
145
146 if let Some(list_item) = &line_info.list_item {
148 let (content_for_calculation, adjusted_marker_column) = if line_info.blockquote.is_some() {
152 let line_content = line_info.content(ctx.content);
154 let mut remaining = line_content;
155 let mut content_start = 0;
156
157 loop {
158 let trimmed = remaining.trim_start();
159 if !trimmed.starts_with('>') {
160 break;
161 }
162 content_start += remaining.len() - trimmed.len();
164 content_start += 1;
166 let after_gt = &trimmed[1..];
167 if let Some(stripped) = after_gt.strip_prefix(' ') {
169 content_start += 1;
170 remaining = stripped;
171 } else if let Some(stripped) = after_gt.strip_prefix('\t') {
172 content_start += 1;
173 remaining = stripped;
174 } else {
175 remaining = after_gt;
176 }
177 }
178
179 let content_after_prefix = &line_content[content_start..];
181 let adjusted_col = if list_item.marker_column >= content_start {
183 list_item.marker_column - content_start
184 } else {
185 list_item.marker_column
187 };
188 (content_after_prefix.to_string(), adjusted_col)
189 } else {
190 (line_info.content(ctx.content).to_string(), list_item.marker_column)
191 };
192
193 let visual_marker_column =
195 Self::char_pos_to_visual_column(&content_for_calculation, adjusted_marker_column);
196
197 let visual_content_column = if line_info.blockquote.is_some() {
199 let adjusted_content_col =
201 if list_item.content_column >= (line_info.byte_len - content_for_calculation.len()) {
202 list_item.content_column - (line_info.byte_len - content_for_calculation.len())
203 } else {
204 list_item.content_column
205 };
206 Self::char_pos_to_visual_column(&content_for_calculation, adjusted_content_col)
207 } else {
208 Self::char_pos_to_visual_column(line_info.content(ctx.content), list_item.content_column)
209 };
210
211 let visual_marker_for_nesting = if visual_marker_column == 1 && self.config.indent.get() != 1 {
215 0
216 } else {
217 visual_marker_column
218 };
219
220 while let Some(&(indent, _, _, _)) = list_stack.last() {
222 if indent >= visual_marker_for_nesting {
223 list_stack.pop();
224 } else {
225 break;
226 }
227 }
228
229 if list_item.is_ordered {
231 list_stack.push((visual_marker_column, line_idx, true, visual_content_column));
234 continue;
235 }
236
237 let nesting_level = list_stack.len();
240
241 let parent_info = list_stack
243 .get(nesting_level.wrapping_sub(1))
244 .map(|&(_, _, is_ordered, content_col)| (is_ordered, content_col));
245
246 let mut expected_indent = if self.config.start_indented {
248 self.config.start_indent.get() as usize + (nesting_level * self.config.indent.get() as usize)
249 } else {
250 self.calculate_expected_indent(nesting_level, parent_info)
251 };
252
253 if ctx.flavor == crate::config::MarkdownFlavor::MkDocs
257 && let Some(&(parent_marker_col, _, true, _)) = list_stack.get(nesting_level.wrapping_sub(1))
258 {
259 expected_indent = expected_indent.max(parent_marker_col + 4);
260 }
261
262 let expected_content_visual_col = expected_indent + 2; list_stack.push((visual_marker_column, line_idx, false, expected_content_visual_col));
268
269 if !self.config.start_indented && nesting_level == 0 && visual_marker_column != 1 {
272 continue;
273 }
274
275 if visual_marker_column != expected_indent {
276 let fix = {
278 let correct_indent = " ".repeat(expected_indent);
279
280 let replacement = if line_info.blockquote.is_some() {
283 let mut blockquote_count = 0;
285 for ch in line_info.content(ctx.content).chars() {
286 if ch == '>' {
287 blockquote_count += 1;
288 } else if ch != ' ' && ch != '\t' {
289 break;
290 }
291 }
292 let blockquote_prefix = if blockquote_count > 1 {
294 (0..blockquote_count)
295 .map(|_| "> ")
296 .collect::<String>()
297 .trim_end()
298 .to_string()
299 } else {
300 ">".to_string()
301 };
302 format!("{blockquote_prefix} {correct_indent}")
305 } else {
306 correct_indent
307 };
308
309 let start_byte = line_info.byte_offset;
312 let mut end_byte = line_info.byte_offset;
313
314 for (i, ch) in line_info.content(ctx.content).chars().enumerate() {
316 if i >= list_item.marker_column {
317 break;
318 }
319 end_byte += ch.len_utf8();
320 }
321
322 Some(crate::rule::Fix {
323 range: start_byte..end_byte,
324 replacement,
325 })
326 };
327
328 warnings.push(LintWarning {
329 rule_name: Some(self.name().to_string()),
330 message: format!(
331 "Expected {expected_indent} spaces for indent depth {nesting_level}, found {visual_marker_column}"
332 ),
333 line: line_idx + 1, column: 1, end_line: line_idx + 1,
336 end_column: visual_marker_column + 1, severity: Severity::Warning,
338 fix,
339 });
340 }
341 }
342 }
343 Ok(warnings)
344 }
345
346 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
348 let warnings = self.check(ctx)?;
350
351 if warnings.is_empty() {
353 return Ok(ctx.content.to_string());
354 }
355
356 let mut fixes: Vec<_> = warnings
358 .iter()
359 .filter_map(|w| w.fix.as_ref().map(|f| (f.range.start, f.range.end, &f.replacement)))
360 .collect();
361 fixes.sort_by(|a, b| b.0.cmp(&a.0));
362
363 let mut result = ctx.content.to_string();
365 for (start, end, replacement) in fixes {
366 if start < result.len() && end <= result.len() && start <= end {
367 result.replace_range(start..end, replacement);
368 }
369 }
370
371 Ok(result)
372 }
373
374 fn category(&self) -> RuleCategory {
376 RuleCategory::List
377 }
378
379 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
381 if ctx.content.is_empty() || !ctx.likely_has_lists() {
383 return true;
384 }
385 !ctx.lines
387 .iter()
388 .any(|line| line.list_item.as_ref().is_some_and(|item| !item.is_ordered))
389 }
390
391 fn as_any(&self) -> &dyn std::any::Any {
392 self
393 }
394
395 fn default_config_section(&self) -> Option<(String, toml::Value)> {
396 let default_config = MD007Config::default();
397 let json_value = serde_json::to_value(&default_config).ok()?;
398 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
399
400 if let toml::Value::Table(table) = toml_value {
401 if !table.is_empty() {
402 Some((MD007Config::RULE_NAME.to_string(), toml::Value::Table(table)))
403 } else {
404 None
405 }
406 } else {
407 None
408 }
409 }
410
411 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
412 where
413 Self: Sized,
414 {
415 let mut rule_config = crate::rule_config_serde::load_rule_config::<MD007Config>(config);
416
417 if let Some(rule_cfg) = config.rules.get("MD007") {
419 rule_config.style_explicit = rule_cfg.values.contains_key("style");
420 rule_config.indent_explicit = rule_cfg.values.contains_key("indent");
421
422 if rule_config.indent_explicit
426 && rule_config.style_explicit
427 && rule_config.style == md007_config::IndentStyle::TextAligned
428 {
429 eprintln!(
430 "\x1b[33m[config warning]\x1b[0m MD007: 'indent' has no effect when 'style = \"text-aligned\"'. \
431 Text-aligned style ignores indent and aligns nested items with parent text. \
432 To use fixed {} space increments, either remove 'style' or set 'style = \"fixed\"'.",
433 rule_config.indent.get()
434 );
435 }
436 }
437
438 Box::new(Self::from_config_struct(rule_config))
439 }
440}
441
442#[cfg(test)]
443mod tests {
444 use super::*;
445 use crate::lint_context::LintContext;
446 use crate::rule::Rule;
447
448 #[test]
449 fn test_valid_list_indent() {
450 let rule = MD007ULIndent::default();
451 let content = "* Item 1\n * Item 2\n * Item 3";
452 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
453 let result = rule.check(&ctx).unwrap();
454 assert!(
455 result.is_empty(),
456 "Expected no warnings for valid indentation, but got {} warnings",
457 result.len()
458 );
459 }
460
461 #[test]
462 fn test_invalid_list_indent() {
463 let rule = MD007ULIndent::default();
464 let content = "* Item 1\n * Item 2\n * Item 3";
465 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
466 let result = rule.check(&ctx).unwrap();
467 assert_eq!(result.len(), 2);
468 assert_eq!(result[0].line, 2);
469 assert_eq!(result[0].column, 1);
470 assert_eq!(result[1].line, 3);
471 assert_eq!(result[1].column, 1);
472 }
473
474 #[test]
475 fn test_mixed_indentation() {
476 let rule = MD007ULIndent::default();
477 let content = "* Item 1\n * Item 2\n * Item 3\n * Item 4";
478 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
479 let result = rule.check(&ctx).unwrap();
480 assert_eq!(result.len(), 1);
481 assert_eq!(result[0].line, 3);
482 assert_eq!(result[0].column, 1);
483 }
484
485 #[test]
486 fn test_fix_indentation() {
487 let rule = MD007ULIndent::default();
488 let content = "* Item 1\n * Item 2\n * Item 3";
489 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
490 let result = rule.fix(&ctx).unwrap();
491 let expected = "* Item 1\n * Item 2\n * Item 3";
495 assert_eq!(result, expected);
496 }
497
498 #[test]
499 fn test_md007_in_yaml_code_block() {
500 let rule = MD007ULIndent::default();
501 let content = r#"```yaml
502repos:
503- repo: https://github.com/rvben/rumdl
504 rev: v0.5.0
505 hooks:
506 - id: rumdl-check
507```"#;
508 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
509 let result = rule.check(&ctx).unwrap();
510 assert!(
511 result.is_empty(),
512 "MD007 should not trigger inside a code block, but got warnings: {result:?}"
513 );
514 }
515
516 #[test]
517 fn test_blockquoted_list_indent() {
518 let rule = MD007ULIndent::default();
519 let content = "> * Item 1\n> * Item 2\n> * Item 3";
520 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
521 let result = rule.check(&ctx).unwrap();
522 assert!(
523 result.is_empty(),
524 "Expected no warnings for valid blockquoted list indentation, but got {result:?}"
525 );
526 }
527
528 #[test]
529 fn test_blockquoted_list_invalid_indent() {
530 let rule = MD007ULIndent::default();
531 let content = "> * Item 1\n> * Item 2\n> * Item 3";
532 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
533 let result = rule.check(&ctx).unwrap();
534 assert_eq!(
535 result.len(),
536 2,
537 "Expected 2 warnings for invalid blockquoted list indentation, got {result:?}"
538 );
539 assert_eq!(result[0].line, 2);
540 assert_eq!(result[1].line, 3);
541 }
542
543 #[test]
544 fn test_nested_blockquote_list_indent() {
545 let rule = MD007ULIndent::default();
546 let content = "> > * Item 1\n> > * Item 2\n> > * Item 3";
547 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
548 let result = rule.check(&ctx).unwrap();
549 assert!(
550 result.is_empty(),
551 "Expected no warnings for valid nested blockquoted list indentation, but got {result:?}"
552 );
553 }
554
555 #[test]
556 fn test_blockquote_list_with_code_block() {
557 let rule = MD007ULIndent::default();
558 let content = "> * Item 1\n> * Item 2\n> ```\n> code\n> ```\n> * Item 3";
559 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
560 let result = rule.check(&ctx).unwrap();
561 assert!(
562 result.is_empty(),
563 "MD007 should not trigger inside a code block within a blockquote, but got warnings: {result:?}"
564 );
565 }
566
567 #[test]
568 fn test_properly_indented_lists() {
569 let rule = MD007ULIndent::default();
570
571 let test_cases = vec![
573 "* Item 1\n* Item 2",
574 "* Item 1\n * Item 1.1\n * Item 1.1.1",
575 "- Item 1\n - Item 1.1",
576 "+ Item 1\n + Item 1.1",
577 "* Item 1\n * Item 1.1\n* Item 2\n * Item 2.1",
578 ];
579
580 for content in test_cases {
581 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
582 let result = rule.check(&ctx).unwrap();
583 assert!(
584 result.is_empty(),
585 "Expected no warnings for properly indented list:\n{}\nGot {} warnings",
586 content,
587 result.len()
588 );
589 }
590 }
591
592 #[test]
593 fn test_under_indented_lists() {
594 let rule = MD007ULIndent::default();
595
596 let test_cases = vec![
597 ("* Item 1\n * Item 1.1", 1, 2), ("* Item 1\n * Item 1.1\n * Item 1.1.1", 1, 3), ];
600
601 for (content, expected_warnings, line) in test_cases {
602 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
603 let result = rule.check(&ctx).unwrap();
604 assert_eq!(
605 result.len(),
606 expected_warnings,
607 "Expected {expected_warnings} warnings for under-indented list:\n{content}"
608 );
609 if expected_warnings > 0 {
610 assert_eq!(result[0].line, line);
611 }
612 }
613 }
614
615 #[test]
616 fn test_over_indented_lists() {
617 let rule = MD007ULIndent::default();
618
619 let test_cases = vec![
620 ("* 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), ];
624
625 for (content, expected_warnings, line) in test_cases {
626 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
627 let result = rule.check(&ctx).unwrap();
628 assert_eq!(
629 result.len(),
630 expected_warnings,
631 "Expected {expected_warnings} warnings for over-indented list:\n{content}"
632 );
633 if expected_warnings > 0 {
634 assert_eq!(result[0].line, line);
635 }
636 }
637 }
638
639 #[test]
640 fn test_custom_indent_2_spaces() {
641 let rule = MD007ULIndent::new(2); let content = "* Item 1\n * Item 2\n * Item 3";
643 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
644 let result = rule.check(&ctx).unwrap();
645 assert!(result.is_empty());
646 }
647
648 #[test]
649 fn test_custom_indent_3_spaces() {
650 let rule = MD007ULIndent::new(3);
653
654 let correct_content = "* Item 1\n * Item 2\n * Item 3";
656 let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard, None);
657 let result = rule.check(&ctx).unwrap();
658 assert!(
659 result.is_empty(),
660 "Fixed style expects 0, 3, 6 spaces but got: {result:?}"
661 );
662
663 let wrong_content = "* Item 1\n * Item 2\n * Item 3";
665 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
666 let result = rule.check(&ctx).unwrap();
667 assert!(!result.is_empty(), "Should warn: expected 3 spaces, found 2");
668 }
669
670 #[test]
671 fn test_custom_indent_4_spaces() {
672 let rule = MD007ULIndent::new(4);
675
676 let correct_content = "* Item 1\n * Item 2\n * Item 3";
678 let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard, None);
679 let result = rule.check(&ctx).unwrap();
680 assert!(
681 result.is_empty(),
682 "Fixed style expects 0, 4, 8 spaces but got: {result:?}"
683 );
684
685 let wrong_content = "* Item 1\n * Item 2\n * Item 3";
687 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
688 let result = rule.check(&ctx).unwrap();
689 assert!(!result.is_empty(), "Should warn: expected 4 spaces, found 2");
690 }
691
692 #[test]
693 fn test_tab_indentation() {
694 let rule = MD007ULIndent::default();
695
696 let content = "* Item 1\n * Item 2";
702 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
703 let result = rule.check(&ctx).unwrap();
704 assert_eq!(result.len(), 1, "Wrong indentation should trigger warning");
705
706 let fixed = rule.fix(&ctx).unwrap();
708 assert_eq!(fixed, "* Item 1\n * Item 2");
709
710 let content_multi = "* Item 1\n * Item 2\n * Item 3";
712 let ctx = LintContext::new(content_multi, crate::config::MarkdownFlavor::Standard, None);
713 let fixed = rule.fix(&ctx).unwrap();
714 assert_eq!(fixed, "* Item 1\n * Item 2\n * Item 3");
717
718 let content_mixed = "* Item 1\n * Item 2\n * Item 3";
720 let ctx = LintContext::new(content_mixed, crate::config::MarkdownFlavor::Standard, None);
721 let fixed = rule.fix(&ctx).unwrap();
722 assert_eq!(fixed, "* Item 1\n * Item 2\n * Item 3");
725 }
726
727 #[test]
728 fn test_mixed_ordered_unordered_lists() {
729 let rule = MD007ULIndent::default();
730
731 let content = r#"1. Ordered item
734 * Unordered sub-item (correct - 3 spaces under ordered)
735 2. Ordered sub-item
736* Unordered item
737 1. Ordered sub-item
738 * Unordered sub-item"#;
739
740 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
741 let result = rule.check(&ctx).unwrap();
742 assert_eq!(result.len(), 0, "All unordered list indentation should be correct");
743
744 let fixed = rule.fix(&ctx).unwrap();
746 assert_eq!(fixed, content);
747 }
748
749 #[test]
750 fn test_list_markers_variety() {
751 let rule = MD007ULIndent::default();
752
753 let content = r#"* Asterisk
755 * Nested asterisk
756- Hyphen
757 - Nested hyphen
758+ Plus
759 + Nested plus"#;
760
761 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
762 let result = rule.check(&ctx).unwrap();
763 assert!(
764 result.is_empty(),
765 "All unordered list markers should work with proper indentation"
766 );
767
768 let wrong_content = r#"* Asterisk
770 * Wrong asterisk
771- Hyphen
772 - Wrong hyphen
773+ Plus
774 + Wrong plus"#;
775
776 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
777 let result = rule.check(&ctx).unwrap();
778 assert_eq!(result.len(), 3, "All marker types should be checked for indentation");
779 }
780
781 #[test]
782 fn test_empty_list_items() {
783 let rule = MD007ULIndent::default();
784 let content = "* Item 1\n* \n * Item 2";
785 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
786 let result = rule.check(&ctx).unwrap();
787 assert!(
788 result.is_empty(),
789 "Empty list items should not affect indentation checks"
790 );
791 }
792
793 #[test]
794 fn test_list_with_code_blocks() {
795 let rule = MD007ULIndent::default();
796 let content = r#"* Item 1
797 ```
798 code
799 ```
800 * Item 2
801 * Item 3"#;
802 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
803 let result = rule.check(&ctx).unwrap();
804 assert!(result.is_empty());
805 }
806
807 #[test]
808 fn test_list_in_front_matter() {
809 let rule = MD007ULIndent::default();
810 let content = r#"---
811tags:
812 - tag1
813 - tag2
814---
815* Item 1
816 * Item 2"#;
817 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
818 let result = rule.check(&ctx).unwrap();
819 assert!(result.is_empty(), "Lists in YAML front matter should be ignored");
820 }
821
822 #[test]
823 fn test_fix_preserves_content() {
824 let rule = MD007ULIndent::default();
825 let content = "* Item 1 with **bold** and *italic*\n * Item 2 with `code`\n * Item 3 with [link](url)";
826 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
827 let fixed = rule.fix(&ctx).unwrap();
828 let expected = "* Item 1 with **bold** and *italic*\n * Item 2 with `code`\n * Item 3 with [link](url)";
831 assert_eq!(fixed, expected, "Fix should only change indentation, not content");
832 }
833
834 #[test]
835 fn test_start_indented_config() {
836 let config = MD007Config {
837 start_indented: true,
838 start_indent: crate::types::IndentSize::from_const(4),
839 indent: crate::types::IndentSize::from_const(2),
840 style: md007_config::IndentStyle::TextAligned,
841 style_explicit: true, indent_explicit: false,
843 };
844 let rule = MD007ULIndent::from_config_struct(config);
845
846 let content = " * Item 1\n * Item 2\n * Item 3";
851 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
852 let result = rule.check(&ctx).unwrap();
853 assert!(result.is_empty(), "Expected no warnings with start_indented config");
854
855 let wrong_content = " * Item 1\n * Item 2";
857 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
858 let result = rule.check(&ctx).unwrap();
859 assert_eq!(result.len(), 2);
860 assert_eq!(result[0].line, 1);
861 assert_eq!(result[0].message, "Expected 4 spaces for indent depth 0, found 2");
862 assert_eq!(result[1].line, 2);
863 assert_eq!(result[1].message, "Expected 6 spaces for indent depth 1, found 4");
864
865 let fixed = rule.fix(&ctx).unwrap();
867 assert_eq!(fixed, " * Item 1\n * Item 2");
868 }
869
870 #[test]
871 fn test_start_indented_false_allows_any_first_level() {
872 let rule = MD007ULIndent::default(); let content = " * Item 1"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
877 let result = rule.check(&ctx).unwrap();
878 assert!(
879 result.is_empty(),
880 "First level at any indentation should be allowed when start_indented is false"
881 );
882
883 let content = "* Item 1\n * Item 2\n * Item 3"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
886 let result = rule.check(&ctx).unwrap();
887 assert!(
888 result.is_empty(),
889 "All first-level items should be allowed at any indentation"
890 );
891 }
892
893 #[test]
894 fn test_deeply_nested_lists() {
895 let rule = MD007ULIndent::default();
896 let content = r#"* L1
897 * L2
898 * L3
899 * L4
900 * L5
901 * L6"#;
902 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
903 let result = rule.check(&ctx).unwrap();
904 assert!(result.is_empty());
905
906 let wrong_content = r#"* L1
908 * L2
909 * L3
910 * L4
911 * L5
912 * L6"#;
913 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
914 let result = rule.check(&ctx).unwrap();
915 assert_eq!(result.len(), 2, "Deep nesting errors should be detected");
916 }
917
918 #[test]
919 fn test_excessive_indentation_detected() {
920 let rule = MD007ULIndent::default();
921
922 let content = "- Item 1\n - Item 2 with 5 spaces";
924 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
925 let result = rule.check(&ctx).unwrap();
926 assert_eq!(result.len(), 1, "Should detect excessive indentation (5 instead of 2)");
927 assert_eq!(result[0].line, 2);
928 assert!(result[0].message.contains("Expected 2 spaces"));
929 assert!(result[0].message.contains("found 5"));
930
931 let content = "- Item 1\n - Item 2 with 3 spaces";
933 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
934 let result = rule.check(&ctx).unwrap();
935 assert_eq!(
936 result.len(),
937 1,
938 "Should detect slightly excessive indentation (3 instead of 2)"
939 );
940 assert_eq!(result[0].line, 2);
941 assert!(result[0].message.contains("Expected 2 spaces"));
942 assert!(result[0].message.contains("found 3"));
943
944 let content = "- Item 1\n - Item 2 with 1 space";
946 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
947 let result = rule.check(&ctx).unwrap();
948 assert_eq!(
949 result.len(),
950 1,
951 "Should detect 1-space indent (insufficient for nesting, expected 0)"
952 );
953 assert_eq!(result[0].line, 2);
954 assert!(result[0].message.contains("Expected 0 spaces"));
955 assert!(result[0].message.contains("found 1"));
956 }
957
958 #[test]
959 fn test_excessive_indentation_with_4_space_config() {
960 let rule = MD007ULIndent::new(4);
963
964 let content = "- Formatter:\n - The stable style changed";
966 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
967 let result = rule.check(&ctx).unwrap();
968 assert!(
969 !result.is_empty(),
970 "Should detect 5 spaces when expecting 4 (fixed style)"
971 );
972
973 let correct_content = "- Formatter:\n - The stable style changed";
975 let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard, None);
976 let result = rule.check(&ctx).unwrap();
977 assert!(result.is_empty(), "Should accept correct fixed style indent (4 spaces)");
978 }
979
980 #[test]
981 fn test_bullets_nested_under_numbered_items() {
982 let rule = MD007ULIndent::default();
983 let content = "\
9841. **Active Directory/LDAP**
985 - User authentication and directory services
986 - LDAP for user information and validation
987
9882. **Oracle Unified Directory (OUD)**
989 - Extended user directory services";
990 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
991 let result = rule.check(&ctx).unwrap();
992 assert!(
994 result.is_empty(),
995 "Expected no warnings for bullets with 3 spaces under numbered items, got: {result:?}"
996 );
997 }
998
999 #[test]
1000 fn test_bullets_nested_under_numbered_items_wrong_indent() {
1001 let rule = MD007ULIndent::default();
1002 let content = "\
10031. **Active Directory/LDAP**
1004 - Wrong: only 2 spaces";
1005 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1006 let result = rule.check(&ctx).unwrap();
1007 assert_eq!(
1009 result.len(),
1010 1,
1011 "Expected warning for incorrect indentation under numbered items"
1012 );
1013 assert!(
1014 result
1015 .iter()
1016 .any(|w| w.line == 2 && w.message.contains("Expected 3 spaces"))
1017 );
1018 }
1019
1020 #[test]
1021 fn test_regular_bullet_nesting_still_works() {
1022 let rule = MD007ULIndent::default();
1023 let content = "\
1024* Top level
1025 * Nested bullet (2 spaces is correct)
1026 * Deeply nested (4 spaces)";
1027 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1028 let result = rule.check(&ctx).unwrap();
1029 assert!(
1031 result.is_empty(),
1032 "Expected no warnings for standard bullet nesting, got: {result:?}"
1033 );
1034 }
1035
1036 #[test]
1037 fn test_blockquote_with_tab_after_marker() {
1038 let rule = MD007ULIndent::default();
1039 let content = ">\t* List item\n>\t * Nested\n";
1040 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1041 let result = rule.check(&ctx).unwrap();
1042 assert!(
1043 result.is_empty(),
1044 "Tab after blockquote marker should be handled correctly, got: {result:?}"
1045 );
1046 }
1047
1048 #[test]
1049 fn test_blockquote_with_space_then_tab_after_marker() {
1050 let rule = MD007ULIndent::default();
1051 let content = "> \t* List item\n";
1052 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1053 let result = rule.check(&ctx).unwrap();
1054 assert!(
1056 result.is_empty(),
1057 "First-level list item at any indentation is allowed when start_indented=false, got: {result:?}"
1058 );
1059 }
1060
1061 #[test]
1062 fn test_blockquote_with_multiple_tabs() {
1063 let rule = MD007ULIndent::default();
1064 let content = ">\t\t* List item\n";
1065 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1066 let result = rule.check(&ctx).unwrap();
1067 assert!(
1069 result.is_empty(),
1070 "First-level list item at any indentation is allowed when start_indented=false, got: {result:?}"
1071 );
1072 }
1073
1074 #[test]
1075 fn test_nested_blockquote_with_tab() {
1076 let rule = MD007ULIndent::default();
1077 let content = ">\t>\t* List item\n>\t>\t * Nested\n";
1078 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1079 let result = rule.check(&ctx).unwrap();
1080 assert!(
1081 result.is_empty(),
1082 "Nested blockquotes with tabs should work correctly, got: {result:?}"
1083 );
1084 }
1085
1086 #[test]
1089 fn test_smart_style_pure_unordered_uses_fixed() {
1090 let rule = MD007ULIndent::new(4);
1092
1093 let content = "* Level 0\n * Level 1\n * Level 2";
1095 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1096 let result = rule.check(&ctx).unwrap();
1097 assert!(
1098 result.is_empty(),
1099 "Pure unordered with indent=4 should use fixed style (0, 4, 8), got: {result:?}"
1100 );
1101 }
1102
1103 #[test]
1104 fn test_smart_style_mixed_lists_uses_text_aligned() {
1105 let rule = MD007ULIndent::new(4);
1107
1108 let content = "1. Ordered\n * Bullet aligns with 'Ordered' text (3 spaces)";
1110 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1111 let result = rule.check(&ctx).unwrap();
1112 assert!(
1113 result.is_empty(),
1114 "Mixed lists should use text-aligned style, got: {result:?}"
1115 );
1116 }
1117
1118 #[test]
1119 fn test_smart_style_explicit_fixed_overrides() {
1120 let config = MD007Config {
1122 indent: crate::types::IndentSize::from_const(4),
1123 start_indented: false,
1124 start_indent: crate::types::IndentSize::from_const(2),
1125 style: md007_config::IndentStyle::Fixed,
1126 style_explicit: true, indent_explicit: false,
1128 };
1129 let rule = MD007ULIndent::from_config_struct(config);
1130
1131 let content = "1. Ordered\n * Should be at 4 spaces (fixed)";
1133 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1134 let result = rule.check(&ctx).unwrap();
1135 assert!(
1137 result.is_empty(),
1138 "Explicit fixed style should be respected, got: {result:?}"
1139 );
1140 }
1141
1142 #[test]
1143 fn test_smart_style_explicit_text_aligned_overrides() {
1144 let config = MD007Config {
1146 indent: crate::types::IndentSize::from_const(4),
1147 start_indented: false,
1148 start_indent: crate::types::IndentSize::from_const(2),
1149 style: md007_config::IndentStyle::TextAligned,
1150 style_explicit: true, indent_explicit: false,
1152 };
1153 let rule = MD007ULIndent::from_config_struct(config);
1154
1155 let content = "* Level 0\n * Level 1 (aligned with 'Level 0' text)";
1157 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1158 let result = rule.check(&ctx).unwrap();
1159 assert!(
1160 result.is_empty(),
1161 "Explicit text-aligned should be respected, got: {result:?}"
1162 );
1163
1164 let fixed_style_content = "* Level 0\n * Level 1 (4 spaces - fixed style)";
1166 let ctx = LintContext::new(fixed_style_content, crate::config::MarkdownFlavor::Standard, None);
1167 let result = rule.check(&ctx).unwrap();
1168 assert!(
1169 !result.is_empty(),
1170 "With explicit text-aligned, 4-space indent should be wrong (expected 2)"
1171 );
1172 }
1173
1174 #[test]
1175 fn test_smart_style_default_indent_no_autoswitch() {
1176 let rule = MD007ULIndent::new(2);
1178
1179 let content = "* Level 0\n * Level 1\n * Level 2";
1180 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1181 let result = rule.check(&ctx).unwrap();
1182 assert!(
1183 result.is_empty(),
1184 "Default indent should work regardless of style, got: {result:?}"
1185 );
1186 }
1187
1188 #[test]
1189 fn test_has_mixed_list_nesting_detection() {
1190 let content = "* Item 1\n * Item 2\n * Item 3";
1194 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1195 assert!(
1196 !ctx.has_mixed_list_nesting(),
1197 "Pure unordered should not be detected as mixed"
1198 );
1199
1200 let content = "1. Item 1\n 2. Item 2\n 3. Item 3";
1202 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1203 assert!(
1204 !ctx.has_mixed_list_nesting(),
1205 "Pure ordered should not be detected as mixed"
1206 );
1207
1208 let content = "1. Ordered\n * Unordered child";
1210 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1211 assert!(
1212 ctx.has_mixed_list_nesting(),
1213 "Unordered under ordered should be detected as mixed"
1214 );
1215
1216 let content = "* Unordered\n 1. Ordered child";
1218 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1219 assert!(
1220 ctx.has_mixed_list_nesting(),
1221 "Ordered under unordered should be detected as mixed"
1222 );
1223
1224 let content = "* Unordered\n\n1. Ordered (separate list)";
1226 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1227 assert!(
1228 !ctx.has_mixed_list_nesting(),
1229 "Separate lists should not be detected as mixed"
1230 );
1231
1232 let content = "> 1. Ordered in blockquote\n> * Unordered child";
1234 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1235 assert!(
1236 ctx.has_mixed_list_nesting(),
1237 "Mixed lists in blockquotes should be detected"
1238 );
1239 }
1240
1241 #[test]
1242 fn test_issue_210_exact_reproduction() {
1243 let config = MD007Config {
1245 indent: crate::types::IndentSize::from_const(4),
1246 start_indented: false,
1247 start_indent: crate::types::IndentSize::from_const(2),
1248 style: md007_config::IndentStyle::TextAligned, style_explicit: false, indent_explicit: false, };
1252 let rule = MD007ULIndent::from_config_struct(config);
1253
1254 let content = "# Title\n\n* some\n * list\n * items\n";
1255 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1256 let result = rule.check(&ctx).unwrap();
1257
1258 assert!(
1259 result.is_empty(),
1260 "Issue #210: indent=4 on pure unordered should work (auto-fixed style), got: {result:?}"
1261 );
1262 }
1263
1264 #[test]
1265 fn test_issue_209_still_fixed() {
1266 let config = MD007Config {
1269 indent: crate::types::IndentSize::from_const(3),
1270 start_indented: false,
1271 start_indent: crate::types::IndentSize::from_const(2),
1272 style: md007_config::IndentStyle::TextAligned,
1273 style_explicit: true, indent_explicit: false,
1275 };
1276 let rule = MD007ULIndent::from_config_struct(config);
1277
1278 let content = r#"# Header 1
1280
1281- **Second item**:
1282 - **This is a nested list**:
1283 1. **First point**
1284 - First subpoint
1285"#;
1286 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1287 let result = rule.check(&ctx).unwrap();
1288
1289 assert!(
1290 result.is_empty(),
1291 "Issue #209: With explicit text-aligned style, should have no issues, got: {result:?}"
1292 );
1293 }
1294
1295 #[test]
1298 fn test_multi_level_mixed_detection_grandparent() {
1299 let content = "1. Ordered grandparent\n * Unordered child\n * Unordered grandchild";
1303 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1304 assert!(
1305 ctx.has_mixed_list_nesting(),
1306 "Should detect mixed nesting when grandparent differs in type"
1307 );
1308
1309 let content = "* Unordered grandparent\n 1. Ordered child\n 2. Ordered grandchild";
1311 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1312 assert!(
1313 ctx.has_mixed_list_nesting(),
1314 "Should detect mixed nesting for ordered descendants under unordered"
1315 );
1316 }
1317
1318 #[test]
1319 fn test_html_comments_skipped_in_detection() {
1320 let content = r#"* Unordered list
1322<!-- This is a comment
1323 1. This ordered list is inside a comment
1324 * This nested bullet is also inside
1325-->
1326 * Another unordered item"#;
1327 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1328 assert!(
1329 !ctx.has_mixed_list_nesting(),
1330 "Lists in HTML comments should be ignored in mixed detection"
1331 );
1332 }
1333
1334 #[test]
1335 fn test_blank_lines_separate_lists() {
1336 let content = "* First unordered list\n\n1. Second list is ordered (separate)";
1338 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1339 assert!(
1340 !ctx.has_mixed_list_nesting(),
1341 "Blank line at root should separate lists"
1342 );
1343
1344 let content = "1. Ordered parent\n\n * Still a child due to indentation";
1346 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1347 assert!(
1348 ctx.has_mixed_list_nesting(),
1349 "Indented list after blank is still nested"
1350 );
1351 }
1352
1353 #[test]
1354 fn test_column_1_normalization() {
1355 let content = "* First item\n * Second item with 1 space (sibling)";
1358 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1359 let rule = MD007ULIndent::default();
1360 let result = rule.check(&ctx).unwrap();
1361 assert!(
1363 result.iter().any(|w| w.line == 2),
1364 "1-space indent should be flagged as incorrect"
1365 );
1366 }
1367
1368 #[test]
1369 fn test_code_blocks_skipped_in_detection() {
1370 let content = r#"* Unordered list
1372```
13731. This ordered list is inside a code block
1374 * This nested bullet is also inside
1375```
1376 * Another unordered item"#;
1377 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1378 assert!(
1379 !ctx.has_mixed_list_nesting(),
1380 "Lists in code blocks should be ignored in mixed detection"
1381 );
1382 }
1383
1384 #[test]
1385 fn test_front_matter_skipped_in_detection() {
1386 let content = r#"---
1388items:
1389 - yaml list item
1390 - another item
1391---
1392* Unordered list after front matter"#;
1393 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1394 assert!(
1395 !ctx.has_mixed_list_nesting(),
1396 "Lists in front matter should be ignored in mixed detection"
1397 );
1398 }
1399
1400 #[test]
1401 fn test_alternating_types_at_same_level() {
1402 let content = "* First bullet\n1. First number\n* Second bullet\n2. Second number";
1405 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1406 assert!(
1407 !ctx.has_mixed_list_nesting(),
1408 "Alternating types at same level should not be detected as mixed"
1409 );
1410 }
1411
1412 #[test]
1413 fn test_five_level_deep_mixed_nesting() {
1414 let content = "* L0\n 1. L1\n * L2\n 1. L3\n * L4\n 1. L5";
1416 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1417 assert!(ctx.has_mixed_list_nesting(), "Should detect mixed nesting at 5+ levels");
1418 }
1419
1420 #[test]
1421 fn test_very_deep_pure_unordered_nesting() {
1422 let mut content = String::from("* L1");
1424 for level in 2..=12 {
1425 let indent = " ".repeat(level - 1);
1426 content.push_str(&format!("\n{indent}* L{level}"));
1427 }
1428
1429 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1430
1431 assert!(
1433 !ctx.has_mixed_list_nesting(),
1434 "Pure unordered deep nesting should not be detected as mixed"
1435 );
1436
1437 let rule = MD007ULIndent::new(4);
1439 let result = rule.check(&ctx).unwrap();
1440 assert!(!result.is_empty(), "Should flag incorrect indentation for fixed style");
1443 }
1444
1445 #[test]
1446 fn test_interleaved_content_between_list_items() {
1447 let content = "1. Ordered parent\n\n Paragraph continuation\n\n * Unordered child";
1449 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1450 assert!(
1451 ctx.has_mixed_list_nesting(),
1452 "Should detect mixed nesting even with interleaved paragraphs"
1453 );
1454 }
1455
1456 #[test]
1457 fn test_esm_blocks_skipped_in_detection() {
1458 let content = "* Unordered list\n * Nested unordered";
1461 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1462 assert!(
1463 !ctx.has_mixed_list_nesting(),
1464 "Pure unordered should not be detected as mixed"
1465 );
1466 }
1467
1468 #[test]
1469 fn test_multiple_list_blocks_pure_then_mixed() {
1470 let content = r#"* Pure unordered
1473 * Nested unordered
1474
14751. Mixed section
1476 * Bullet under ordered"#;
1477 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1478 assert!(
1479 ctx.has_mixed_list_nesting(),
1480 "Should detect mixed nesting in any part of document"
1481 );
1482 }
1483
1484 #[test]
1485 fn test_multiple_separate_pure_lists() {
1486 let content = r#"* First list
1489 * Nested
1490
1491* Second list
1492 * Also nested
1493
1494* Third list
1495 * Deeply
1496 * Nested"#;
1497 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1498 assert!(
1499 !ctx.has_mixed_list_nesting(),
1500 "Multiple separate pure unordered lists should not be mixed"
1501 );
1502 }
1503
1504 #[test]
1505 fn test_code_block_between_list_items() {
1506 let content = r#"1. Ordered
1508 ```
1509 code
1510 ```
1511 * Still a mixed child"#;
1512 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1513 assert!(
1514 ctx.has_mixed_list_nesting(),
1515 "Code block between items should not prevent mixed detection"
1516 );
1517 }
1518
1519 #[test]
1520 fn test_blockquoted_mixed_detection() {
1521 let content = "> 1. Ordered in blockquote\n> * Mixed child";
1523 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1524 assert!(
1527 ctx.has_mixed_list_nesting(),
1528 "Should detect mixed nesting in blockquotes"
1529 );
1530 }
1531
1532 #[test]
1535 fn test_indent_explicit_uses_fixed_style() {
1536 let config = MD007Config {
1539 indent: crate::types::IndentSize::from_const(4),
1540 start_indented: false,
1541 start_indent: crate::types::IndentSize::from_const(2),
1542 style: md007_config::IndentStyle::TextAligned, style_explicit: false, indent_explicit: true, };
1546 let rule = MD007ULIndent::from_config_struct(config);
1547
1548 let content = "* Level 0\n * Level 1\n * Level 2";
1551 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1552 let result = rule.check(&ctx).unwrap();
1553 assert!(
1554 result.is_empty(),
1555 "With indent_explicit=true, should use fixed style (0, 4, 8), got: {result:?}"
1556 );
1557
1558 let wrong_content = "* Level 0\n * Level 1\n * Level 2";
1560 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
1561 let result = rule.check(&ctx).unwrap();
1562 assert!(
1563 !result.is_empty(),
1564 "Should flag text-aligned spacing when indent_explicit=true"
1565 );
1566 }
1567
1568 #[test]
1569 fn test_explicit_style_overrides_indent_explicit() {
1570 let config = MD007Config {
1573 indent: crate::types::IndentSize::from_const(4),
1574 start_indented: false,
1575 start_indent: crate::types::IndentSize::from_const(2),
1576 style: md007_config::IndentStyle::TextAligned,
1577 style_explicit: true, indent_explicit: true, };
1580 let rule = MD007ULIndent::from_config_struct(config);
1581
1582 let content = "* Level 0\n * Level 1\n * Level 2";
1584 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1585 let result = rule.check(&ctx).unwrap();
1586 assert!(
1587 result.is_empty(),
1588 "Explicit text-aligned style should be respected, got: {result:?}"
1589 );
1590 }
1591
1592 #[test]
1593 fn test_no_indent_explicit_uses_smart_detection() {
1594 let config = MD007Config {
1596 indent: crate::types::IndentSize::from_const(4),
1597 start_indented: false,
1598 start_indent: crate::types::IndentSize::from_const(2),
1599 style: md007_config::IndentStyle::TextAligned,
1600 style_explicit: false,
1601 indent_explicit: false, };
1603 let rule = MD007ULIndent::from_config_struct(config);
1604
1605 let content = "* Level 0\n * Level 1";
1608 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1609 let result = rule.check(&ctx).unwrap();
1610 assert!(
1612 result.is_empty(),
1613 "Smart detection should accept 4-space indent, got: {result:?}"
1614 );
1615 }
1616
1617 #[test]
1618 fn test_issue_273_exact_reproduction() {
1619 let config = MD007Config {
1622 indent: crate::types::IndentSize::from_const(4),
1623 start_indented: false,
1624 start_indent: crate::types::IndentSize::from_const(2),
1625 style: md007_config::IndentStyle::TextAligned, style_explicit: false,
1627 indent_explicit: true, };
1629 let rule = MD007ULIndent::from_config_struct(config);
1630
1631 let content = r#"* Item 1
1632 * Item 2
1633 * Item 3"#;
1634 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1635 let result = rule.check(&ctx).unwrap();
1636 assert!(
1637 result.is_empty(),
1638 "Issue #273: indent=4 should use 4-space increments, got: {result:?}"
1639 );
1640 }
1641
1642 #[test]
1643 fn test_indent_explicit_with_ordered_parent() {
1644 let config = MD007Config {
1648 indent: crate::types::IndentSize::from_const(4),
1649 start_indented: false,
1650 start_indent: crate::types::IndentSize::from_const(2),
1651 style: md007_config::IndentStyle::TextAligned,
1652 style_explicit: false,
1653 indent_explicit: true, };
1655 let rule = MD007ULIndent::from_config_struct(config);
1656
1657 let content = "1. Ordered\n * Bullet aligned with ordered text";
1660 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1661 let result = rule.check(&ctx).unwrap();
1662 assert!(
1663 result.is_empty(),
1664 "Bullet under ordered must use text-aligned (3 spaces) even with indent=4: {result:?}"
1665 );
1666
1667 let wrong_content = "1. Ordered\n * Bullet with 4-space fixed indent";
1669 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
1670 let result = rule.check(&ctx).unwrap();
1671 assert!(
1672 !result.is_empty(),
1673 "4-space indent under ordered list should be flagged"
1674 );
1675 }
1676
1677 #[test]
1678 fn test_indent_explicit_mixed_list_deep_nesting() {
1679 let config = MD007Config {
1684 indent: crate::types::IndentSize::from_const(4),
1685 start_indented: false,
1686 start_indent: crate::types::IndentSize::from_const(2),
1687 style: md007_config::IndentStyle::TextAligned,
1688 style_explicit: false,
1689 indent_explicit: true,
1690 };
1691 let rule = MD007ULIndent::from_config_struct(config);
1692
1693 let content = r#"* Level 0
1698 * Level 1 (4-space indent from bullet parent)
1699 1. Level 2 ordered
1700 * Level 3 bullet (text-aligned under ordered)"#;
1701 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1702 let result = rule.check(&ctx).unwrap();
1703 assert!(
1704 result.is_empty(),
1705 "Mixed nesting should handle each parent type correctly: {result:?}"
1706 );
1707 }
1708
1709 #[test]
1710 fn test_ordered_list_double_digit_markers() {
1711 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,
1720 };
1721 let rule = MD007ULIndent::from_config_struct(config);
1722
1723 let content = "10. Double digit\n * Bullet at col 4";
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 "Bullet under '10.' should align at column 4: {result:?}"
1730 );
1731
1732 let content_single = "1. Single digit\n * Bullet at col 3";
1734 let ctx = LintContext::new(content_single, crate::config::MarkdownFlavor::Standard, None);
1735 let result = rule.check(&ctx).unwrap();
1736 assert!(
1737 result.is_empty(),
1738 "Bullet under '1.' should align at column 3: {result:?}"
1739 );
1740 }
1741
1742 #[test]
1743 fn test_indent_explicit_pure_unordered_uses_fixed() {
1744 let config = MD007Config {
1747 indent: crate::types::IndentSize::from_const(4),
1748 start_indented: false,
1749 start_indent: crate::types::IndentSize::from_const(2),
1750 style: md007_config::IndentStyle::TextAligned,
1751 style_explicit: false,
1752 indent_explicit: true,
1753 };
1754 let rule = MD007ULIndent::from_config_struct(config);
1755
1756 let content = "* Level 0\n * Level 1\n * Level 2";
1758 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1759 let result = rule.check(&ctx).unwrap();
1760 assert!(
1761 result.is_empty(),
1762 "Pure unordered with indent=4 should use 4-space increments: {result:?}"
1763 );
1764
1765 let wrong_content = "* Level 0\n * Level 1\n * Level 2";
1767 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
1768 let result = rule.check(&ctx).unwrap();
1769 assert!(
1770 !result.is_empty(),
1771 "2-space indent should be flagged when indent=4 is configured"
1772 );
1773 }
1774
1775 #[test]
1776 fn test_mkdocs_ordered_list_with_4_space_nested_unordered() {
1777 let rule = MD007ULIndent::default();
1781 let content = "1. text\n\n - nested item";
1782 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1783 let result = rule.check(&ctx).unwrap();
1784 assert!(
1785 result.is_empty(),
1786 "4-space indent under ordered list should be valid in MkDocs flavor, got: {result:?}"
1787 );
1788 }
1789
1790 #[test]
1791 fn test_standard_flavor_ordered_list_with_3_space_nested_unordered() {
1792 let rule = MD007ULIndent::default();
1795 let content = "1. text\n\n - nested item";
1796 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1797 let result = rule.check(&ctx).unwrap();
1798 assert!(
1799 result.is_empty(),
1800 "3-space indent under ordered list should be valid in Standard flavor, got: {result:?}"
1801 );
1802 }
1803
1804 #[test]
1805 fn test_standard_flavor_ordered_list_with_4_space_warns() {
1806 let rule = MD007ULIndent::default();
1809 let content = "1. text\n\n - nested item";
1810 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1811 let result = rule.check(&ctx).unwrap();
1812 assert_eq!(
1813 result.len(),
1814 1,
1815 "4-space indent under ordered list should warn in Standard flavor"
1816 );
1817 }
1818
1819 #[test]
1820 fn test_mkdocs_multi_digit_ordered_list() {
1821 let rule = MD007ULIndent::default();
1824 let content = "10. text\n\n - nested item";
1825 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1826 let result = rule.check(&ctx).unwrap();
1827 assert!(
1828 result.is_empty(),
1829 "4-space indent under `10.` should be valid in MkDocs flavor, got: {result:?}"
1830 );
1831 }
1832
1833 #[test]
1834 fn test_mkdocs_triple_digit_ordered_list() {
1835 let rule = MD007ULIndent::default();
1838 let content = "100. text\n\n - nested item";
1839 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1840 let result = rule.check(&ctx).unwrap();
1841 assert!(
1842 result.is_empty(),
1843 "5-space indent under `100.` should be valid in MkDocs flavor, got: {result:?}"
1844 );
1845 }
1846
1847 #[test]
1848 fn test_mkdocs_insufficient_indent_under_ordered() {
1849 let rule = MD007ULIndent::default();
1852 let content = "1. text\n\n - nested item";
1853 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1854 let result = rule.check(&ctx).unwrap();
1855 assert_eq!(
1856 result.len(),
1857 1,
1858 "2-space indent under ordered list should warn in MkDocs flavor"
1859 );
1860 assert!(
1861 result[0].message.contains("Expected 4"),
1862 "Warning should expect 4 spaces (MkDocs minimum), got: {}",
1863 result[0].message
1864 );
1865 }
1866
1867 #[test]
1868 fn test_mkdocs_deeper_nesting_under_ordered() {
1869 let rule = MD007ULIndent::default();
1874 let content = "1. text\n\n - sub\n - subsub";
1875 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1876 let result = rule.check(&ctx).unwrap();
1877 assert!(
1878 result.is_empty(),
1879 "Deeper nesting under ordered list should be valid in MkDocs flavor, got: {result:?}"
1880 );
1881 }
1882
1883 #[test]
1884 fn test_mkdocs_fix_adjusts_to_4_spaces() {
1885 let rule = MD007ULIndent::default();
1887 let content = "1. text\n\n - nested item";
1888 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1889 let result = rule.check(&ctx).unwrap();
1890 assert_eq!(result.len(), 1, "3-space indent should warn in MkDocs");
1891 let fixed = rule.fix(&ctx).unwrap();
1892 assert_eq!(
1893 fixed, "1. text\n\n - nested item",
1894 "Fix should adjust indent to 4 spaces in MkDocs"
1895 );
1896 }
1897
1898 #[test]
1899 fn test_mkdocs_start_indented_with_ordered_parent() {
1900 let config = MD007Config {
1903 start_indented: true,
1904 ..Default::default()
1905 };
1906 let rule = MD007ULIndent::from_config_struct(config);
1907 let content = "1. text\n\n - nested item";
1908 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1909 let result = rule.check(&ctx).unwrap();
1910 assert!(
1911 result.is_empty(),
1912 "4-space indent under ordered list with start_indented should be valid in MkDocs, got: {result:?}"
1913 );
1914 }
1915
1916 #[test]
1917 fn test_mkdocs_ordered_at_nonzero_indent() {
1918 let rule = MD007ULIndent::default();
1923 let content = "- outer\n 1. inner\n - deep";
1924 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1925 let result = rule.check(&ctx).unwrap();
1926 assert!(
1927 result.is_empty(),
1928 "6-space indent under nested ordered list should be valid in MkDocs, got: {result:?}"
1929 );
1930 }
1931
1932 #[test]
1933 fn test_mkdocs_blockquoted_ordered_list() {
1934 let rule = MD007ULIndent::default();
1938 let content = "> 1. text\n>\n> - nested item";
1939 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1940 let result = rule.check(&ctx).unwrap();
1941 assert!(
1942 result.is_empty(),
1943 "4-space indent under blockquoted ordered list should be valid in MkDocs, got: {result:?}"
1944 );
1945 }
1946
1947 #[test]
1948 fn test_mkdocs_ordered_at_nonzero_indent_insufficient() {
1949 let rule = MD007ULIndent::default();
1952 let content = "- outer\n 1. inner\n - deep";
1953 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1954 let result = rule.check(&ctx).unwrap();
1955 assert_eq!(
1956 result.len(),
1957 1,
1958 "5-space indent under nested ordered at col 2 should warn in MkDocs (needs 6)"
1959 );
1960 }
1961}