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, },
25 }
26 }
27
28 pub fn from_config_struct(config: MD007Config) -> Self {
29 Self { config }
30 }
31
32 fn char_pos_to_visual_column(content: &str, char_pos: usize) -> usize {
34 let mut visual_col = 0;
35
36 for (current_pos, ch) in content.chars().enumerate() {
37 if current_pos >= char_pos {
38 break;
39 }
40 if ch == '\t' {
41 visual_col = (visual_col / 4 + 1) * 4;
43 } else {
44 visual_col += 1;
45 }
46 }
47 visual_col
48 }
49
50 fn has_mixed_list_nesting(ctx: &crate::lint_context::LintContext) -> bool {
54 let mut stack: Vec<(usize, bool)> = Vec::new();
59 let mut last_was_blank = false;
60
61 for line_info in &ctx.lines {
62 if line_info.in_code_block
64 || line_info.in_front_matter
65 || line_info.in_mkdocstrings
66 || line_info.in_html_comment
67 || line_info.in_esm_block
68 {
69 continue;
70 }
71
72 let content = line_info.content(ctx.content);
74 let is_blank = content.trim().is_empty();
75
76 if is_blank {
77 last_was_blank = true;
78 continue;
79 }
80
81 if let Some(list_item) = &line_info.list_item {
82 let current_pos = if list_item.marker_column == 1 {
84 0
85 } else {
86 list_item.marker_column
87 };
88
89 if last_was_blank && current_pos == 0 {
91 stack.clear();
92 }
93 last_was_blank = false;
94
95 while let Some(&(pos, _)) = stack.last() {
97 if pos >= current_pos {
98 stack.pop();
99 } else {
100 break;
101 }
102 }
103
104 if let Some(&(_, parent_is_ordered)) = stack.last()
106 && parent_is_ordered != list_item.is_ordered
107 {
108 return true; }
110
111 stack.push((current_pos, list_item.is_ordered));
112 } else {
113 last_was_blank = false;
115 }
116 }
117
118 false
119 }
120
121 fn effective_style(&self, ctx: &crate::lint_context::LintContext) -> md007_config::IndentStyle {
126 if self.config.style_explicit {
128 return self.config.style;
129 }
130
131 if self.config.indent.get() == 2 {
134 return self.config.style;
135 }
136
137 if Self::has_mixed_list_nesting(ctx) {
139 md007_config::IndentStyle::TextAligned
140 } else {
141 md007_config::IndentStyle::Fixed
142 }
143 }
144}
145
146impl Rule for MD007ULIndent {
147 fn name(&self) -> &'static str {
148 "MD007"
149 }
150
151 fn description(&self) -> &'static str {
152 "Unordered list indentation"
153 }
154
155 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
156 let mut warnings = Vec::new();
157 let mut list_stack: Vec<(usize, usize, bool, usize)> = Vec::new(); let effective_style = self.effective_style(ctx);
161
162 for (line_idx, line_info) in ctx.lines.iter().enumerate() {
163 if line_info.in_code_block || line_info.in_front_matter || line_info.in_mkdocstrings {
165 continue;
166 }
167
168 if let Some(list_item) = &line_info.list_item {
170 let (content_for_calculation, adjusted_marker_column) = if line_info.blockquote.is_some() {
174 let line_content = line_info.content(ctx.content);
176 let mut remaining = line_content;
177 let mut content_start = 0;
178
179 loop {
180 let trimmed = remaining.trim_start();
181 if !trimmed.starts_with('>') {
182 break;
183 }
184 content_start += remaining.len() - trimmed.len();
186 content_start += 1;
188 let after_gt = &trimmed[1..];
189 if let Some(stripped) = after_gt.strip_prefix(' ') {
191 content_start += 1;
192 remaining = stripped;
193 } else if let Some(stripped) = after_gt.strip_prefix('\t') {
194 content_start += 1;
195 remaining = stripped;
196 } else {
197 remaining = after_gt;
198 }
199 }
200
201 let content_after_prefix = &line_content[content_start..];
203 let adjusted_col = if list_item.marker_column >= content_start {
205 list_item.marker_column - content_start
206 } else {
207 list_item.marker_column
209 };
210 (content_after_prefix.to_string(), adjusted_col)
211 } else {
212 (line_info.content(ctx.content).to_string(), list_item.marker_column)
213 };
214
215 let visual_marker_column =
217 Self::char_pos_to_visual_column(&content_for_calculation, adjusted_marker_column);
218
219 let visual_content_column = if line_info.blockquote.is_some() {
221 let adjusted_content_col =
223 if list_item.content_column >= (line_info.byte_len - content_for_calculation.len()) {
224 list_item.content_column - (line_info.byte_len - content_for_calculation.len())
225 } else {
226 list_item.content_column
227 };
228 Self::char_pos_to_visual_column(&content_for_calculation, adjusted_content_col)
229 } else {
230 Self::char_pos_to_visual_column(line_info.content(ctx.content), list_item.content_column)
231 };
232
233 let visual_marker_for_nesting = if visual_marker_column == 1 {
236 0
237 } else {
238 visual_marker_column
239 };
240
241 while let Some(&(indent, _, _, _)) = list_stack.last() {
243 if indent >= visual_marker_for_nesting {
244 list_stack.pop();
245 } else {
246 break;
247 }
248 }
249
250 if list_item.is_ordered {
252 list_stack.push((visual_marker_column, line_idx, true, visual_content_column));
255 continue;
256 }
257
258 let nesting_level = list_stack.len();
261
262 let expected_indent = if self.config.start_indented {
264 self.config.start_indent.get() as usize + (nesting_level * self.config.indent.get() as usize)
265 } else {
266 match effective_style {
267 md007_config::IndentStyle::Fixed => {
268 nesting_level * self.config.indent.get() as usize
270 }
271 md007_config::IndentStyle::TextAligned => {
272 if nesting_level > 0 {
274 if let Some(&(_, _parent_line_idx, _is_ordered, parent_content_visual_col)) =
276 list_stack.get(nesting_level - 1)
277 {
278 parent_content_visual_col
280 } else {
281 nesting_level * 2
284 }
285 } else {
286 0 }
288 }
289 }
290 };
291
292 let expected_content_visual_col = expected_indent + 2; list_stack.push((visual_marker_column, line_idx, false, expected_content_visual_col));
298
299 if !self.config.start_indented && nesting_level == 0 && visual_marker_column != 1 {
302 continue;
303 }
304
305 if visual_marker_column != expected_indent {
306 let fix = {
308 let correct_indent = " ".repeat(expected_indent);
309
310 let replacement = if line_info.blockquote.is_some() {
313 let mut blockquote_count = 0;
315 for ch in line_info.content(ctx.content).chars() {
316 if ch == '>' {
317 blockquote_count += 1;
318 } else if ch != ' ' && ch != '\t' {
319 break;
320 }
321 }
322 let blockquote_prefix = if blockquote_count > 1 {
324 (0..blockquote_count)
325 .map(|_| "> ")
326 .collect::<String>()
327 .trim_end()
328 .to_string()
329 } else {
330 ">".to_string()
331 };
332 format!("{blockquote_prefix} {correct_indent}")
335 } else {
336 correct_indent
337 };
338
339 let start_byte = line_info.byte_offset;
342 let mut end_byte = line_info.byte_offset;
343
344 for (i, ch) in line_info.content(ctx.content).chars().enumerate() {
346 if i >= list_item.marker_column {
347 break;
348 }
349 end_byte += ch.len_utf8();
350 }
351
352 Some(crate::rule::Fix {
353 range: start_byte..end_byte,
354 replacement,
355 })
356 };
357
358 warnings.push(LintWarning {
359 rule_name: Some(self.name().to_string()),
360 message: format!(
361 "Expected {expected_indent} spaces for indent depth {nesting_level}, found {visual_marker_column}"
362 ),
363 line: line_idx + 1, column: 1, end_line: line_idx + 1,
366 end_column: visual_marker_column + 1, severity: Severity::Warning,
368 fix,
369 });
370 }
371 }
372 }
373 Ok(warnings)
374 }
375
376 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
378 let warnings = self.check(ctx)?;
380
381 if warnings.is_empty() {
383 return Ok(ctx.content.to_string());
384 }
385
386 let mut fixes: Vec<_> = warnings
388 .iter()
389 .filter_map(|w| w.fix.as_ref().map(|f| (f.range.start, f.range.end, &f.replacement)))
390 .collect();
391 fixes.sort_by(|a, b| b.0.cmp(&a.0));
392
393 let mut result = ctx.content.to_string();
395 for (start, end, replacement) in fixes {
396 if start < result.len() && end <= result.len() && start <= end {
397 result.replace_range(start..end, replacement);
398 }
399 }
400
401 Ok(result)
402 }
403
404 fn category(&self) -> RuleCategory {
406 RuleCategory::List
407 }
408
409 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
411 if ctx.content.is_empty() || !ctx.likely_has_lists() {
413 return true;
414 }
415 !ctx.lines
417 .iter()
418 .any(|line| line.list_item.as_ref().is_some_and(|item| !item.is_ordered))
419 }
420
421 fn as_any(&self) -> &dyn std::any::Any {
422 self
423 }
424
425 fn default_config_section(&self) -> Option<(String, toml::Value)> {
426 let default_config = MD007Config::default();
427 let json_value = serde_json::to_value(&default_config).ok()?;
428 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
429
430 if let toml::Value::Table(table) = toml_value {
431 if !table.is_empty() {
432 Some((MD007Config::RULE_NAME.to_string(), toml::Value::Table(table)))
433 } else {
434 None
435 }
436 } else {
437 None
438 }
439 }
440
441 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
442 where
443 Self: Sized,
444 {
445 let mut rule_config = crate::rule_config_serde::load_rule_config::<MD007Config>(config);
446
447 if let Some(rule_cfg) = config.rules.get("MD007") {
452 rule_config.style_explicit = rule_cfg.values.contains_key("style");
453 }
454
455 Box::new(Self::from_config_struct(rule_config))
456 }
457}
458
459#[cfg(test)]
460mod tests {
461 use super::*;
462 use crate::lint_context::LintContext;
463 use crate::rule::Rule;
464
465 #[test]
466 fn test_valid_list_indent() {
467 let rule = MD007ULIndent::default();
468 let content = "* Item 1\n * Item 2\n * Item 3";
469 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
470 let result = rule.check(&ctx).unwrap();
471 assert!(
472 result.is_empty(),
473 "Expected no warnings for valid indentation, but got {} warnings",
474 result.len()
475 );
476 }
477
478 #[test]
479 fn test_invalid_list_indent() {
480 let rule = MD007ULIndent::default();
481 let content = "* Item 1\n * Item 2\n * Item 3";
482 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
483 let result = rule.check(&ctx).unwrap();
484 assert_eq!(result.len(), 2);
485 assert_eq!(result[0].line, 2);
486 assert_eq!(result[0].column, 1);
487 assert_eq!(result[1].line, 3);
488 assert_eq!(result[1].column, 1);
489 }
490
491 #[test]
492 fn test_mixed_indentation() {
493 let rule = MD007ULIndent::default();
494 let content = "* Item 1\n * Item 2\n * Item 3\n * Item 4";
495 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
496 let result = rule.check(&ctx).unwrap();
497 assert_eq!(result.len(), 1);
498 assert_eq!(result[0].line, 3);
499 assert_eq!(result[0].column, 1);
500 }
501
502 #[test]
503 fn test_fix_indentation() {
504 let rule = MD007ULIndent::default();
505 let content = "* Item 1\n * Item 2\n * Item 3";
506 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
507 let result = rule.fix(&ctx).unwrap();
508 let expected = "* Item 1\n * Item 2\n * Item 3";
512 assert_eq!(result, expected);
513 }
514
515 #[test]
516 fn test_md007_in_yaml_code_block() {
517 let rule = MD007ULIndent::default();
518 let content = r#"```yaml
519repos:
520- repo: https://github.com/rvben/rumdl
521 rev: v0.5.0
522 hooks:
523 - id: rumdl-check
524```"#;
525 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
526 let result = rule.check(&ctx).unwrap();
527 assert!(
528 result.is_empty(),
529 "MD007 should not trigger inside a code block, but got warnings: {result:?}"
530 );
531 }
532
533 #[test]
534 fn test_blockquoted_list_indent() {
535 let rule = MD007ULIndent::default();
536 let content = "> * Item 1\n> * Item 2\n> * Item 3";
537 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
538 let result = rule.check(&ctx).unwrap();
539 assert!(
540 result.is_empty(),
541 "Expected no warnings for valid blockquoted list indentation, but got {result:?}"
542 );
543 }
544
545 #[test]
546 fn test_blockquoted_list_invalid_indent() {
547 let rule = MD007ULIndent::default();
548 let content = "> * Item 1\n> * Item 2\n> * Item 3";
549 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
550 let result = rule.check(&ctx).unwrap();
551 assert_eq!(
552 result.len(),
553 2,
554 "Expected 2 warnings for invalid blockquoted list indentation, got {result:?}"
555 );
556 assert_eq!(result[0].line, 2);
557 assert_eq!(result[1].line, 3);
558 }
559
560 #[test]
561 fn test_nested_blockquote_list_indent() {
562 let rule = MD007ULIndent::default();
563 let content = "> > * Item 1\n> > * Item 2\n> > * Item 3";
564 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
565 let result = rule.check(&ctx).unwrap();
566 assert!(
567 result.is_empty(),
568 "Expected no warnings for valid nested blockquoted list indentation, but got {result:?}"
569 );
570 }
571
572 #[test]
573 fn test_blockquote_list_with_code_block() {
574 let rule = MD007ULIndent::default();
575 let content = "> * Item 1\n> * Item 2\n> ```\n> code\n> ```\n> * Item 3";
576 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
577 let result = rule.check(&ctx).unwrap();
578 assert!(
579 result.is_empty(),
580 "MD007 should not trigger inside a code block within a blockquote, but got warnings: {result:?}"
581 );
582 }
583
584 #[test]
585 fn test_properly_indented_lists() {
586 let rule = MD007ULIndent::default();
587
588 let test_cases = vec![
590 "* Item 1\n* Item 2",
591 "* Item 1\n * Item 1.1\n * Item 1.1.1",
592 "- Item 1\n - Item 1.1",
593 "+ Item 1\n + Item 1.1",
594 "* Item 1\n * Item 1.1\n* Item 2\n * Item 2.1",
595 ];
596
597 for content in test_cases {
598 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
599 let result = rule.check(&ctx).unwrap();
600 assert!(
601 result.is_empty(),
602 "Expected no warnings for properly indented list:\n{}\nGot {} warnings",
603 content,
604 result.len()
605 );
606 }
607 }
608
609 #[test]
610 fn test_under_indented_lists() {
611 let rule = MD007ULIndent::default();
612
613 let test_cases = vec![
614 ("* Item 1\n * Item 1.1", 1, 2), ("* Item 1\n * Item 1.1\n * Item 1.1.1", 1, 3), ];
617
618 for (content, expected_warnings, line) in test_cases {
619 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
620 let result = rule.check(&ctx).unwrap();
621 assert_eq!(
622 result.len(),
623 expected_warnings,
624 "Expected {expected_warnings} warnings for under-indented list:\n{content}"
625 );
626 if expected_warnings > 0 {
627 assert_eq!(result[0].line, line);
628 }
629 }
630 }
631
632 #[test]
633 fn test_over_indented_lists() {
634 let rule = MD007ULIndent::default();
635
636 let test_cases = vec![
637 ("* 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), ];
641
642 for (content, expected_warnings, line) in test_cases {
643 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
644 let result = rule.check(&ctx).unwrap();
645 assert_eq!(
646 result.len(),
647 expected_warnings,
648 "Expected {expected_warnings} warnings for over-indented list:\n{content}"
649 );
650 if expected_warnings > 0 {
651 assert_eq!(result[0].line, line);
652 }
653 }
654 }
655
656 #[test]
657 fn test_custom_indent_2_spaces() {
658 let rule = MD007ULIndent::new(2); let content = "* Item 1\n * Item 2\n * Item 3";
660 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
661 let result = rule.check(&ctx).unwrap();
662 assert!(result.is_empty());
663 }
664
665 #[test]
666 fn test_custom_indent_3_spaces() {
667 let rule = MD007ULIndent::new(3);
670
671 let correct_content = "* Item 1\n * Item 2\n * Item 3";
673 let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard, None);
674 let result = rule.check(&ctx).unwrap();
675 assert!(
676 result.is_empty(),
677 "Fixed style expects 0, 3, 6 spaces but got: {result:?}"
678 );
679
680 let wrong_content = "* Item 1\n * Item 2\n * Item 3";
682 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
683 let result = rule.check(&ctx).unwrap();
684 assert!(!result.is_empty(), "Should warn: expected 3 spaces, found 2");
685 }
686
687 #[test]
688 fn test_custom_indent_4_spaces() {
689 let rule = MD007ULIndent::new(4);
692
693 let correct_content = "* Item 1\n * Item 2\n * Item 3";
695 let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard, None);
696 let result = rule.check(&ctx).unwrap();
697 assert!(
698 result.is_empty(),
699 "Fixed style expects 0, 4, 8 spaces but got: {result:?}"
700 );
701
702 let wrong_content = "* Item 1\n * Item 2\n * Item 3";
704 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
705 let result = rule.check(&ctx).unwrap();
706 assert!(!result.is_empty(), "Should warn: expected 4 spaces, found 2");
707 }
708
709 #[test]
710 fn test_tab_indentation() {
711 let rule = MD007ULIndent::default();
712
713 let content = "* Item 1\n\t* Item 2";
715 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
716 let result = rule.check(&ctx).unwrap();
717 assert_eq!(result.len(), 1, "Tab indentation should trigger warning");
718
719 let fixed = rule.fix(&ctx).unwrap();
721 assert_eq!(fixed, "* Item 1\n * Item 2");
722
723 let content_multi = "* Item 1\n\t* Item 2\n\t\t* Item 3";
725 let ctx = LintContext::new(content_multi, crate::config::MarkdownFlavor::Standard, None);
726 let fixed = rule.fix(&ctx).unwrap();
727 assert_eq!(fixed, "* Item 1\n * Item 2\n * Item 3");
730
731 let content_mixed = "* Item 1\n \t* Item 2\n\t * Item 3";
733 let ctx = LintContext::new(content_mixed, crate::config::MarkdownFlavor::Standard, None);
734 let fixed = rule.fix(&ctx).unwrap();
735 assert_eq!(fixed, "* Item 1\n * Item 2\n * Item 3");
738 }
739
740 #[test]
741 fn test_mixed_ordered_unordered_lists() {
742 let rule = MD007ULIndent::default();
743
744 let content = r#"1. Ordered item
747 * Unordered sub-item (correct - 3 spaces under ordered)
748 2. Ordered sub-item
749* Unordered item
750 1. Ordered sub-item
751 * Unordered sub-item"#;
752
753 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
754 let result = rule.check(&ctx).unwrap();
755 assert_eq!(result.len(), 0, "All unordered list indentation should be correct");
756
757 let fixed = rule.fix(&ctx).unwrap();
759 assert_eq!(fixed, content);
760 }
761
762 #[test]
763 fn test_list_markers_variety() {
764 let rule = MD007ULIndent::default();
765
766 let content = r#"* Asterisk
768 * Nested asterisk
769- Hyphen
770 - Nested hyphen
771+ Plus
772 + Nested plus"#;
773
774 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
775 let result = rule.check(&ctx).unwrap();
776 assert!(
777 result.is_empty(),
778 "All unordered list markers should work with proper indentation"
779 );
780
781 let wrong_content = r#"* Asterisk
783 * Wrong asterisk
784- Hyphen
785 - Wrong hyphen
786+ Plus
787 + Wrong plus"#;
788
789 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
790 let result = rule.check(&ctx).unwrap();
791 assert_eq!(result.len(), 3, "All marker types should be checked for indentation");
792 }
793
794 #[test]
795 fn test_empty_list_items() {
796 let rule = MD007ULIndent::default();
797 let content = "* Item 1\n* \n * Item 2";
798 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
799 let result = rule.check(&ctx).unwrap();
800 assert!(
801 result.is_empty(),
802 "Empty list items should not affect indentation checks"
803 );
804 }
805
806 #[test]
807 fn test_list_with_code_blocks() {
808 let rule = MD007ULIndent::default();
809 let content = r#"* Item 1
810 ```
811 code
812 ```
813 * Item 2
814 * Item 3"#;
815 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
816 let result = rule.check(&ctx).unwrap();
817 assert!(result.is_empty());
818 }
819
820 #[test]
821 fn test_list_in_front_matter() {
822 let rule = MD007ULIndent::default();
823 let content = r#"---
824tags:
825 - tag1
826 - tag2
827---
828* Item 1
829 * Item 2"#;
830 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
831 let result = rule.check(&ctx).unwrap();
832 assert!(result.is_empty(), "Lists in YAML front matter should be ignored");
833 }
834
835 #[test]
836 fn test_fix_preserves_content() {
837 let rule = MD007ULIndent::default();
838 let content = "* Item 1 with **bold** and *italic*\n * Item 2 with `code`\n * Item 3 with [link](url)";
839 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
840 let fixed = rule.fix(&ctx).unwrap();
841 let expected = "* Item 1 with **bold** and *italic*\n * Item 2 with `code`\n * Item 3 with [link](url)";
844 assert_eq!(fixed, expected, "Fix should only change indentation, not content");
845 }
846
847 #[test]
848 fn test_start_indented_config() {
849 let config = MD007Config {
850 start_indented: true,
851 start_indent: crate::types::IndentSize::from_const(4),
852 indent: crate::types::IndentSize::from_const(2),
853 style: md007_config::IndentStyle::TextAligned,
854 style_explicit: true, };
856 let rule = MD007ULIndent::from_config_struct(config);
857
858 let content = " * Item 1\n * Item 2\n * Item 3";
863 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
864 let result = rule.check(&ctx).unwrap();
865 assert!(result.is_empty(), "Expected no warnings with start_indented config");
866
867 let wrong_content = " * Item 1\n * Item 2";
869 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
870 let result = rule.check(&ctx).unwrap();
871 assert_eq!(result.len(), 2);
872 assert_eq!(result[0].line, 1);
873 assert_eq!(result[0].message, "Expected 4 spaces for indent depth 0, found 2");
874 assert_eq!(result[1].line, 2);
875 assert_eq!(result[1].message, "Expected 6 spaces for indent depth 1, found 4");
876
877 let fixed = rule.fix(&ctx).unwrap();
879 assert_eq!(fixed, " * Item 1\n * Item 2");
880 }
881
882 #[test]
883 fn test_start_indented_false_allows_any_first_level() {
884 let rule = MD007ULIndent::default(); let content = " * Item 1"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
889 let result = rule.check(&ctx).unwrap();
890 assert!(
891 result.is_empty(),
892 "First level at any indentation should be allowed when start_indented is false"
893 );
894
895 let content = "* Item 1\n * Item 2\n * Item 3"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
898 let result = rule.check(&ctx).unwrap();
899 assert!(
900 result.is_empty(),
901 "All first-level items should be allowed at any indentation"
902 );
903 }
904
905 #[test]
906 fn test_deeply_nested_lists() {
907 let rule = MD007ULIndent::default();
908 let content = r#"* L1
909 * L2
910 * L3
911 * L4
912 * L5
913 * L6"#;
914 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
915 let result = rule.check(&ctx).unwrap();
916 assert!(result.is_empty());
917
918 let wrong_content = r#"* L1
920 * L2
921 * L3
922 * L4
923 * L5
924 * L6"#;
925 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
926 let result = rule.check(&ctx).unwrap();
927 assert_eq!(result.len(), 2, "Deep nesting errors should be detected");
928 }
929
930 #[test]
931 fn test_excessive_indentation_detected() {
932 let rule = MD007ULIndent::default();
933
934 let content = "- Item 1\n - Item 2 with 5 spaces";
936 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
937 let result = rule.check(&ctx).unwrap();
938 assert_eq!(result.len(), 1, "Should detect excessive indentation (5 instead of 2)");
939 assert_eq!(result[0].line, 2);
940 assert!(result[0].message.contains("Expected 2 spaces"));
941 assert!(result[0].message.contains("found 5"));
942
943 let content = "- Item 1\n - Item 2 with 3 spaces";
945 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
946 let result = rule.check(&ctx).unwrap();
947 assert_eq!(
948 result.len(),
949 1,
950 "Should detect slightly excessive indentation (3 instead of 2)"
951 );
952 assert_eq!(result[0].line, 2);
953 assert!(result[0].message.contains("Expected 2 spaces"));
954 assert!(result[0].message.contains("found 3"));
955
956 let content = "- Item 1\n - Item 2 with 1 space";
958 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
959 let result = rule.check(&ctx).unwrap();
960 assert_eq!(
961 result.len(),
962 1,
963 "Should detect 1-space indent (insufficient for nesting, expected 0)"
964 );
965 assert_eq!(result[0].line, 2);
966 assert!(result[0].message.contains("Expected 0 spaces"));
967 assert!(result[0].message.contains("found 1"));
968 }
969
970 #[test]
971 fn test_excessive_indentation_with_4_space_config() {
972 let rule = MD007ULIndent::new(4);
975
976 let content = "- Formatter:\n - The stable style changed";
978 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
979 let result = rule.check(&ctx).unwrap();
980 assert!(
981 !result.is_empty(),
982 "Should detect 5 spaces when expecting 4 (fixed style)"
983 );
984
985 let correct_content = "- Formatter:\n - The stable style changed";
987 let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard, None);
988 let result = rule.check(&ctx).unwrap();
989 assert!(result.is_empty(), "Should accept correct fixed style indent (4 spaces)");
990 }
991
992 #[test]
993 fn test_bullets_nested_under_numbered_items() {
994 let rule = MD007ULIndent::default();
995 let content = "\
9961. **Active Directory/LDAP**
997 - User authentication and directory services
998 - LDAP for user information and validation
999
10002. **Oracle Unified Directory (OUD)**
1001 - Extended user directory services";
1002 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1003 let result = rule.check(&ctx).unwrap();
1004 assert!(
1006 result.is_empty(),
1007 "Expected no warnings for bullets with 3 spaces under numbered items, got: {result:?}"
1008 );
1009 }
1010
1011 #[test]
1012 fn test_bullets_nested_under_numbered_items_wrong_indent() {
1013 let rule = MD007ULIndent::default();
1014 let content = "\
10151. **Active Directory/LDAP**
1016 - Wrong: only 2 spaces";
1017 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1018 let result = rule.check(&ctx).unwrap();
1019 assert_eq!(
1021 result.len(),
1022 1,
1023 "Expected warning for incorrect indentation under numbered items"
1024 );
1025 assert!(
1026 result
1027 .iter()
1028 .any(|w| w.line == 2 && w.message.contains("Expected 3 spaces"))
1029 );
1030 }
1031
1032 #[test]
1033 fn test_regular_bullet_nesting_still_works() {
1034 let rule = MD007ULIndent::default();
1035 let content = "\
1036* Top level
1037 * Nested bullet (2 spaces is correct)
1038 * Deeply nested (4 spaces)";
1039 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1040 let result = rule.check(&ctx).unwrap();
1041 assert!(
1043 result.is_empty(),
1044 "Expected no warnings for standard bullet nesting, got: {result:?}"
1045 );
1046 }
1047
1048 #[test]
1049 fn test_blockquote_with_tab_after_marker() {
1050 let rule = MD007ULIndent::default();
1051 let content = ">\t* List item\n>\t * Nested\n";
1052 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1053 let result = rule.check(&ctx).unwrap();
1054 assert!(
1055 result.is_empty(),
1056 "Tab after blockquote marker should be handled correctly, got: {result:?}"
1057 );
1058 }
1059
1060 #[test]
1061 fn test_blockquote_with_space_then_tab_after_marker() {
1062 let rule = MD007ULIndent::default();
1063 let content = "> \t* List item\n";
1064 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1065 let result = rule.check(&ctx).unwrap();
1066 assert!(
1068 result.is_empty(),
1069 "First-level list item at any indentation is allowed when start_indented=false, got: {result:?}"
1070 );
1071 }
1072
1073 #[test]
1074 fn test_blockquote_with_multiple_tabs() {
1075 let rule = MD007ULIndent::default();
1076 let content = ">\t\t* List item\n";
1077 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1078 let result = rule.check(&ctx).unwrap();
1079 assert!(
1081 result.is_empty(),
1082 "First-level list item at any indentation is allowed when start_indented=false, got: {result:?}"
1083 );
1084 }
1085
1086 #[test]
1087 fn test_nested_blockquote_with_tab() {
1088 let rule = MD007ULIndent::default();
1089 let content = ">\t>\t* List item\n>\t>\t * Nested\n";
1090 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1091 let result = rule.check(&ctx).unwrap();
1092 assert!(
1093 result.is_empty(),
1094 "Nested blockquotes with tabs should work correctly, got: {result:?}"
1095 );
1096 }
1097
1098 #[test]
1101 fn test_smart_style_pure_unordered_uses_fixed() {
1102 let rule = MD007ULIndent::new(4);
1104
1105 let content = "* Level 0\n * Level 1\n * Level 2";
1107 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1108 let result = rule.check(&ctx).unwrap();
1109 assert!(
1110 result.is_empty(),
1111 "Pure unordered with indent=4 should use fixed style (0, 4, 8), got: {result:?}"
1112 );
1113 }
1114
1115 #[test]
1116 fn test_smart_style_mixed_lists_uses_text_aligned() {
1117 let rule = MD007ULIndent::new(4);
1119
1120 let content = "1. Ordered\n * Bullet aligns with 'Ordered' text (3 spaces)";
1122 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1123 let result = rule.check(&ctx).unwrap();
1124 assert!(
1125 result.is_empty(),
1126 "Mixed lists should use text-aligned style, got: {result:?}"
1127 );
1128 }
1129
1130 #[test]
1131 fn test_smart_style_explicit_fixed_overrides() {
1132 let config = MD007Config {
1134 indent: crate::types::IndentSize::from_const(4),
1135 start_indented: false,
1136 start_indent: crate::types::IndentSize::from_const(2),
1137 style: md007_config::IndentStyle::Fixed,
1138 style_explicit: true, };
1140 let rule = MD007ULIndent::from_config_struct(config);
1141
1142 let content = "1. Ordered\n * Should be at 4 spaces (fixed)";
1144 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1145 let result = rule.check(&ctx).unwrap();
1146 assert!(
1148 result.is_empty(),
1149 "Explicit fixed style should be respected, got: {result:?}"
1150 );
1151 }
1152
1153 #[test]
1154 fn test_smart_style_explicit_text_aligned_overrides() {
1155 let config = MD007Config {
1157 indent: crate::types::IndentSize::from_const(4),
1158 start_indented: false,
1159 start_indent: crate::types::IndentSize::from_const(2),
1160 style: md007_config::IndentStyle::TextAligned,
1161 style_explicit: true, };
1163 let rule = MD007ULIndent::from_config_struct(config);
1164
1165 let content = "* Level 0\n * Level 1 (aligned with 'Level 0' text)";
1167 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1168 let result = rule.check(&ctx).unwrap();
1169 assert!(
1170 result.is_empty(),
1171 "Explicit text-aligned should be respected, got: {result:?}"
1172 );
1173
1174 let fixed_style_content = "* Level 0\n * Level 1 (4 spaces - fixed style)";
1176 let ctx = LintContext::new(fixed_style_content, crate::config::MarkdownFlavor::Standard, None);
1177 let result = rule.check(&ctx).unwrap();
1178 assert!(
1179 !result.is_empty(),
1180 "With explicit text-aligned, 4-space indent should be wrong (expected 2)"
1181 );
1182 }
1183
1184 #[test]
1185 fn test_smart_style_default_indent_no_autoswitch() {
1186 let rule = MD007ULIndent::new(2);
1188
1189 let content = "* Level 0\n * Level 1\n * Level 2";
1190 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1191 let result = rule.check(&ctx).unwrap();
1192 assert!(
1193 result.is_empty(),
1194 "Default indent should work regardless of style, got: {result:?}"
1195 );
1196 }
1197
1198 #[test]
1199 fn test_has_mixed_list_nesting_detection() {
1200 let content = "* Item 1\n * Item 2\n * Item 3";
1204 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1205 assert!(
1206 !MD007ULIndent::has_mixed_list_nesting(&ctx),
1207 "Pure unordered should not be detected as mixed"
1208 );
1209
1210 let content = "1. Item 1\n 2. Item 2\n 3. Item 3";
1212 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1213 assert!(
1214 !MD007ULIndent::has_mixed_list_nesting(&ctx),
1215 "Pure ordered should not be detected as mixed"
1216 );
1217
1218 let content = "1. Ordered\n * Unordered child";
1220 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1221 assert!(
1222 MD007ULIndent::has_mixed_list_nesting(&ctx),
1223 "Unordered under ordered should be detected as mixed"
1224 );
1225
1226 let content = "* Unordered\n 1. Ordered child";
1228 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1229 assert!(
1230 MD007ULIndent::has_mixed_list_nesting(&ctx),
1231 "Ordered under unordered should be detected as mixed"
1232 );
1233
1234 let content = "* Unordered\n\n1. Ordered (separate list)";
1236 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1237 assert!(
1238 !MD007ULIndent::has_mixed_list_nesting(&ctx),
1239 "Separate lists should not be detected as mixed"
1240 );
1241
1242 let content = "> 1. Ordered in blockquote\n> * Unordered child";
1244 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1245 assert!(
1246 MD007ULIndent::has_mixed_list_nesting(&ctx),
1247 "Mixed lists in blockquotes should be detected"
1248 );
1249 }
1250
1251 #[test]
1252 fn test_issue_210_exact_reproduction() {
1253 let config = MD007Config {
1255 indent: crate::types::IndentSize::from_const(4),
1256 start_indented: false,
1257 start_indent: crate::types::IndentSize::from_const(2),
1258 style: md007_config::IndentStyle::TextAligned, style_explicit: false, };
1261 let rule = MD007ULIndent::from_config_struct(config);
1262
1263 let content = "# Title\n\n* some\n * list\n * items\n";
1264 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1265 let result = rule.check(&ctx).unwrap();
1266
1267 assert!(
1268 result.is_empty(),
1269 "Issue #210: indent=4 on pure unordered should work (auto-fixed style), got: {result:?}"
1270 );
1271 }
1272
1273 #[test]
1274 fn test_issue_209_still_fixed() {
1275 let config = MD007Config {
1277 indent: crate::types::IndentSize::from_const(3),
1278 start_indented: false,
1279 start_indent: crate::types::IndentSize::from_const(2),
1280 style: md007_config::IndentStyle::TextAligned, style_explicit: false, };
1283 let rule = MD007ULIndent::from_config_struct(config);
1284
1285 let content = r#"# Header 1
1287
1288- **Second item**:
1289 - **This is a nested list**:
1290 1. **First point**
1291 - First subpoint
1292"#;
1293 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1294 let result = rule.check(&ctx).unwrap();
1295
1296 assert!(
1297 result.is_empty(),
1298 "Issue #209: Mixed lists should use text-aligned and have no issues, got: {result:?}"
1299 );
1300 }
1301
1302 #[test]
1305 fn test_multi_level_mixed_detection_grandparent() {
1306 let content = "1. Ordered grandparent\n * Unordered child\n * Unordered grandchild";
1310 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1311 assert!(
1312 MD007ULIndent::has_mixed_list_nesting(&ctx),
1313 "Should detect mixed nesting when grandparent differs in type"
1314 );
1315
1316 let content = "* Unordered grandparent\n 1. Ordered child\n 2. Ordered grandchild";
1318 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1319 assert!(
1320 MD007ULIndent::has_mixed_list_nesting(&ctx),
1321 "Should detect mixed nesting for ordered descendants under unordered"
1322 );
1323 }
1324
1325 #[test]
1326 fn test_html_comments_skipped_in_detection() {
1327 let content = r#"* Unordered list
1329<!-- This is a comment
1330 1. This ordered list is inside a comment
1331 * This nested bullet is also inside
1332-->
1333 * Another unordered item"#;
1334 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1335 assert!(
1336 !MD007ULIndent::has_mixed_list_nesting(&ctx),
1337 "Lists in HTML comments should be ignored in mixed detection"
1338 );
1339 }
1340
1341 #[test]
1342 fn test_blank_lines_separate_lists() {
1343 let content = "* First unordered list\n\n1. Second list is ordered (separate)";
1345 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1346 assert!(
1347 !MD007ULIndent::has_mixed_list_nesting(&ctx),
1348 "Blank line at root should separate lists"
1349 );
1350
1351 let content = "1. Ordered parent\n\n * Still a child due to indentation";
1353 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1354 assert!(
1355 MD007ULIndent::has_mixed_list_nesting(&ctx),
1356 "Indented list after blank is still nested"
1357 );
1358 }
1359
1360 #[test]
1361 fn test_column_1_normalization() {
1362 let content = "* First item\n * Second item with 1 space (sibling)";
1365 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1366 let rule = MD007ULIndent::default();
1367 let result = rule.check(&ctx).unwrap();
1368 assert!(
1370 result.iter().any(|w| w.line == 2),
1371 "1-space indent should be flagged as incorrect"
1372 );
1373 }
1374
1375 #[test]
1376 fn test_code_blocks_skipped_in_detection() {
1377 let content = r#"* Unordered list
1379```
13801. This ordered list is inside a code block
1381 * This nested bullet is also inside
1382```
1383 * Another unordered item"#;
1384 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1385 assert!(
1386 !MD007ULIndent::has_mixed_list_nesting(&ctx),
1387 "Lists in code blocks should be ignored in mixed detection"
1388 );
1389 }
1390
1391 #[test]
1392 fn test_front_matter_skipped_in_detection() {
1393 let content = r#"---
1395items:
1396 - yaml list item
1397 - another item
1398---
1399* Unordered list after front matter"#;
1400 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1401 assert!(
1402 !MD007ULIndent::has_mixed_list_nesting(&ctx),
1403 "Lists in front matter should be ignored in mixed detection"
1404 );
1405 }
1406
1407 #[test]
1408 fn test_alternating_types_at_same_level() {
1409 let content = "* First bullet\n1. First number\n* Second bullet\n2. Second number";
1412 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1413 assert!(
1414 !MD007ULIndent::has_mixed_list_nesting(&ctx),
1415 "Alternating types at same level should not be detected as mixed"
1416 );
1417 }
1418
1419 #[test]
1420 fn test_five_level_deep_mixed_nesting() {
1421 let content = "* L0\n 1. L1\n * L2\n 1. L3\n * L4\n 1. L5";
1423 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1424 assert!(
1425 MD007ULIndent::has_mixed_list_nesting(&ctx),
1426 "Should detect mixed nesting at 5+ levels"
1427 );
1428 }
1429
1430 #[test]
1431 fn test_very_deep_pure_unordered_nesting() {
1432 let mut content = String::from("* L1");
1434 for level in 2..=12 {
1435 let indent = " ".repeat(level - 1);
1436 content.push_str(&format!("\n{indent}* L{level}"));
1437 }
1438
1439 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1440
1441 assert!(
1443 !MD007ULIndent::has_mixed_list_nesting(&ctx),
1444 "Pure unordered deep nesting should not be detected as mixed"
1445 );
1446
1447 let rule = MD007ULIndent::new(4);
1449 let result = rule.check(&ctx).unwrap();
1450 assert!(!result.is_empty(), "Should flag incorrect indentation for fixed style");
1453 }
1454
1455 #[test]
1456 fn test_interleaved_content_between_list_items() {
1457 let content = "1. Ordered parent\n\n Paragraph continuation\n\n * Unordered child";
1459 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1460 assert!(
1461 MD007ULIndent::has_mixed_list_nesting(&ctx),
1462 "Should detect mixed nesting even with interleaved paragraphs"
1463 );
1464 }
1465
1466 #[test]
1467 fn test_esm_blocks_skipped_in_detection() {
1468 let content = "* Unordered list\n * Nested unordered";
1471 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1472 assert!(
1473 !MD007ULIndent::has_mixed_list_nesting(&ctx),
1474 "Pure unordered should not be detected as mixed"
1475 );
1476 }
1477
1478 #[test]
1479 fn test_multiple_list_blocks_pure_then_mixed() {
1480 let content = r#"* Pure unordered
1483 * Nested unordered
1484
14851. Mixed section
1486 * Bullet under ordered"#;
1487 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1488 assert!(
1489 MD007ULIndent::has_mixed_list_nesting(&ctx),
1490 "Should detect mixed nesting in any part of document"
1491 );
1492 }
1493
1494 #[test]
1495 fn test_multiple_separate_pure_lists() {
1496 let content = r#"* First list
1499 * Nested
1500
1501* Second list
1502 * Also nested
1503
1504* Third list
1505 * Deeply
1506 * Nested"#;
1507 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1508 assert!(
1509 !MD007ULIndent::has_mixed_list_nesting(&ctx),
1510 "Multiple separate pure unordered lists should not be mixed"
1511 );
1512 }
1513
1514 #[test]
1515 fn test_code_block_between_list_items() {
1516 let content = r#"1. Ordered
1518 ```
1519 code
1520 ```
1521 * Still a mixed child"#;
1522 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1523 assert!(
1524 MD007ULIndent::has_mixed_list_nesting(&ctx),
1525 "Code block between items should not prevent mixed detection"
1526 );
1527 }
1528
1529 #[test]
1530 fn test_blockquoted_mixed_detection() {
1531 let content = "> 1. Ordered in blockquote\n> * Mixed child";
1533 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1534 assert!(
1537 MD007ULIndent::has_mixed_list_nesting(&ctx),
1538 "Should detect mixed nesting in blockquotes"
1539 );
1540 }
1541}