1use crate::utils::range_utils::calculate_match_range;
7
8use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
9use std::collections::HashMap;
11use toml;
12
13#[derive(Clone, Default)]
15pub struct MD005ListIndent {
16 top_level_indent: usize,
18 md007_indent: usize,
20}
21
22impl MD005ListIndent {
23 fn group_related_list_blocks<'a>(
25 &self,
26 list_blocks: &'a [crate::lint_context::ListBlock],
27 ) -> Vec<Vec<&'a crate::lint_context::ListBlock>> {
28 if list_blocks.is_empty() {
29 return Vec::new();
30 }
31
32 let mut groups = Vec::new();
33 let mut current_group = vec![&list_blocks[0]];
34
35 for i in 1..list_blocks.len() {
36 let prev_block = &list_blocks[i - 1];
37 let current_block = &list_blocks[i];
38
39 let line_gap = current_block.start_line.saturating_sub(prev_block.end_line);
41
42 if line_gap <= 2 {
45 current_group.push(current_block);
46 } else {
47 groups.push(current_group);
49 current_group = vec![current_block];
50 }
51 }
52 groups.push(current_group);
53
54 groups
55 }
56
57 fn is_continuation_content(
59 &self,
60 ctx: &crate::lint_context::LintContext,
61 list_line: usize,
62 list_indent: usize,
63 ) -> bool {
64 for line_num in (1..list_line).rev() {
66 if let Some(line_info) = ctx.line_info(line_num) {
67 if let Some(parent_list_item) = &line_info.list_item {
68 let parent_marker_column = parent_list_item.marker_column;
69 let parent_content_column = parent_list_item.content_column;
70
71 if parent_marker_column >= list_indent {
73 continue;
74 }
75
76 let continuation_indent =
79 self.find_continuation_indent_between(ctx, line_num + 1, list_line - 1, parent_content_column);
80
81 if let Some(cont_indent) = continuation_indent {
82 let is_standard_continuation = list_indent == parent_content_column + 2;
86 let matches_content_indent = list_indent == cont_indent;
87
88 if matches_content_indent || is_standard_continuation {
89 return true;
90 }
91 }
92
93 if list_indent > parent_marker_column {
96 if self.has_continuation_list_at_indent(
98 ctx,
99 line_num,
100 list_line,
101 list_indent,
102 parent_content_column,
103 ) {
104 return true;
105 }
106
107 if self.has_any_continuation_content_after_parent(
110 ctx,
111 line_num,
112 list_line,
113 parent_content_column,
114 ) {
115 return true;
116 }
117 }
118
119 } else if !line_info.content.trim().is_empty() {
122 let content = line_info.content.trim_start();
125 let line_indent = line_info.content.len() - content.len();
126
127 if line_indent == 0 {
128 break;
129 }
130 }
131 }
132 }
133 false
134 }
135
136 fn has_continuation_list_at_indent(
138 &self,
139 ctx: &crate::lint_context::LintContext,
140 parent_line: usize,
141 current_line: usize,
142 list_indent: usize,
143 parent_content_column: usize,
144 ) -> bool {
145 for line_num in (parent_line + 1)..current_line {
148 if let Some(line_info) = ctx.line_info(line_num)
149 && let Some(list_item) = &line_info.list_item
150 && list_item.marker_column == list_indent
151 {
152 if self
154 .find_continuation_indent_between(ctx, parent_line + 1, line_num - 1, parent_content_column)
155 .is_some()
156 {
157 return true;
158 }
159 }
160 }
161 false
162 }
163
164 fn has_any_continuation_content_after_parent(
166 &self,
167 ctx: &crate::lint_context::LintContext,
168 parent_line: usize,
169 current_line: usize,
170 parent_content_column: usize,
171 ) -> bool {
172 for line_num in (parent_line + 1)..current_line {
174 if let Some(line_info) = ctx.line_info(line_num) {
175 let content = line_info.content.trim_start();
176
177 if content.is_empty() || line_info.list_item.is_some() {
179 continue;
180 }
181
182 let line_indent = line_info.content.len() - content.len();
184
185 if line_indent > parent_content_column {
188 return true;
189 }
190 }
191 }
192 false
193 }
194
195 fn find_continuation_indent_between(
197 &self,
198 ctx: &crate::lint_context::LintContext,
199 start_line: usize,
200 end_line: usize,
201 parent_content_column: usize,
202 ) -> Option<usize> {
203 if start_line > end_line {
204 return None;
205 }
206
207 for line_num in start_line..=end_line {
208 if let Some(line_info) = ctx.line_info(line_num) {
209 let content = line_info.content.trim_start();
210
211 if content.is_empty() {
213 continue;
214 }
215
216 if line_info.list_item.is_some() {
218 continue;
219 }
220
221 let line_indent = line_info.content.len() - content.len();
223
224 if line_indent > parent_content_column {
227 return Some(line_indent);
228 }
229 }
230 }
231 None
232 }
233
234 fn check_list_block_group(
236 &self,
237 ctx: &crate::lint_context::LintContext,
238 group: &[&crate::lint_context::ListBlock],
239 warnings: &mut Vec<LintWarning>,
240 ) -> Result<(), LintError> {
241 let mut all_list_items = Vec::new();
245
246 for list_block in group {
247 for &item_line in &list_block.item_lines {
248 if let Some(line_info) = ctx.line_info(item_line)
249 && let Some(list_item) = &line_info.list_item
250 {
251 let effective_indent = if let Some(blockquote) = &line_info.blockquote {
253 list_item.marker_column.saturating_sub(blockquote.nesting_level * 2)
255 } else {
256 list_item.marker_column
258 };
259
260 if self.is_continuation_content(ctx, item_line, effective_indent) {
262 continue;
263 }
264
265 all_list_items.push((item_line, effective_indent, line_info, list_item));
266 }
267 }
268 }
269
270 if all_list_items.is_empty() {
271 return Ok(());
272 }
273
274 all_list_items.sort_by_key(|(line_num, _, _, _)| *line_num);
276
277 let mut level_map: HashMap<usize, usize> = HashMap::new();
281 let mut level_indents: HashMap<usize, Vec<usize>> = HashMap::new(); for i in 0..all_list_items.len() {
285 let (line_num, indent, _, _) = &all_list_items[i];
286
287 let level = if i == 0 {
288 level_indents.entry(1).or_default().push(*indent);
290 1
291 } else {
292 let mut determined_level = 0;
294
295 for (lvl, indents) in &level_indents {
297 if indents.contains(indent) {
298 determined_level = *lvl;
299 break;
300 }
301 }
302
303 if determined_level == 0 {
304 for j in (0..i).rev() {
307 let (prev_line, prev_indent, _, _) = &all_list_items[j];
308 let prev_level = level_map[prev_line];
309
310 if *prev_indent + 2 <= *indent {
312 determined_level = prev_level + 1;
314 break;
315 } else if (*prev_indent as i32 - *indent as i32).abs() <= 1 {
316 determined_level = prev_level;
318 break;
319 } else if *prev_indent < *indent {
320 if let Some(level_indents_list) = level_indents.get(&prev_level) {
325 for &lvl_indent in level_indents_list {
327 if (lvl_indent as i32 - *indent as i32).abs() <= 1 {
328 determined_level = prev_level;
330 break;
331 }
332 }
333 }
334 if determined_level == 0 {
335 determined_level = prev_level + 1;
337 }
338 break;
339 }
340 }
341
342 if determined_level == 0 {
344 determined_level = 1;
345 }
346
347 level_indents.entry(determined_level).or_default().push(*indent);
349 }
350
351 determined_level
352 };
353
354 level_map.insert(*line_num, level);
355 }
356
357 let mut level_groups: HashMap<usize, Vec<(usize, usize, &crate::lint_context::LineInfo)>> = HashMap::new();
359 for (line_num, indent, line_info, _) in &all_list_items {
360 let level = level_map[line_num];
361 level_groups
362 .entry(level)
363 .or_default()
364 .push((*line_num, *indent, *line_info));
365 }
366
367 for (level, group) in level_groups {
369 if level != 1 && group.len() < 2 {
372 continue;
373 }
374
375 let mut group = group;
377 group.sort_by_key(|(line_num, _, _)| *line_num);
378
379 let indents: std::collections::HashSet<usize> = group.iter().map(|(_, indent, _)| *indent).collect();
381
382 let has_issue = if level == 1 {
385 indents.iter().any(|&indent| indent != self.top_level_indent)
387 } else {
388 indents.len() > 1
390 };
391
392 if has_issue {
393 let expected_indent = if level == 1 {
399 self.top_level_indent
400 } else {
401 if self.md007_indent > 0 {
404 (level - 1) * self.md007_indent
407 } else {
408 let mut indent_counts: HashMap<usize, usize> = HashMap::new();
410 for (_, indent, _) in &group {
411 *indent_counts.entry(*indent).or_insert(0) += 1;
412 }
413
414 if indent_counts.len() == 1 {
415 *indent_counts.keys().next().unwrap()
417 } else {
418 indent_counts
422 .iter()
423 .max_by(|(indent_a, count_a), (indent_b, count_b)| {
424 count_a.cmp(count_b).then(indent_b.cmp(indent_a))
426 })
427 .map(|(indent, _)| *indent)
428 .unwrap()
429 }
430 }
431 };
432
433 for (line_num, indent, line_info) in &group {
435 if *indent != expected_indent {
436 let message = format!(
437 "Expected indentation of {} {}, found {}",
438 expected_indent,
439 if expected_indent == 1 { "space" } else { "spaces" },
440 indent
441 );
442
443 let (start_line, start_col, end_line, end_col) = if *indent > 0 {
444 calculate_match_range(*line_num, &line_info.content, 0, *indent)
445 } else {
446 calculate_match_range(*line_num, &line_info.content, 0, 1)
447 };
448
449 let fix_range = if *indent > 0 {
450 let start_byte = ctx.line_offsets.get(line_num - 1).copied().unwrap_or(0);
451 let end_byte = start_byte + *indent;
452 start_byte..end_byte
453 } else {
454 let byte_pos = ctx.line_offsets.get(line_num - 1).copied().unwrap_or(0);
455 byte_pos..byte_pos
456 };
457
458 let replacement = if expected_indent > 0 {
459 " ".repeat(expected_indent)
460 } else {
461 String::new()
462 };
463
464 warnings.push(LintWarning {
465 rule_name: Some(self.name()),
466 line: start_line,
467 column: start_col,
468 end_line,
469 end_column: end_col,
470 message,
471 severity: Severity::Warning,
472 fix: Some(Fix {
473 range: fix_range,
474 replacement,
475 }),
476 });
477 }
478 }
479 }
480 }
481
482 Ok(())
483 }
484
485 fn check_optimized(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
487 let content = ctx.content;
488
489 if content.is_empty() {
491 return Ok(Vec::new());
492 }
493
494 if ctx.list_blocks.is_empty() {
496 return Ok(Vec::new());
497 }
498
499 let mut warnings = Vec::new();
500
501 let block_groups = self.group_related_list_blocks(&ctx.list_blocks);
504
505 for group in block_groups {
506 self.check_list_block_group(ctx, &group, &mut warnings)?;
507 }
508
509 Ok(warnings)
510 }
511}
512
513impl Rule for MD005ListIndent {
514 fn name(&self) -> &'static str {
515 "MD005"
516 }
517
518 fn description(&self) -> &'static str {
519 "List indentation should be consistent"
520 }
521
522 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
523 self.check_optimized(ctx)
525 }
526
527 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
528 let warnings = self.check(ctx)?;
529 if warnings.is_empty() {
530 return Ok(ctx.content.to_string());
531 }
532
533 let mut warnings_with_fixes: Vec<_> = warnings
535 .into_iter()
536 .filter_map(|w| w.fix.clone().map(|fix| (w, fix)))
537 .collect();
538 warnings_with_fixes.sort_by_key(|(_, fix)| std::cmp::Reverse(fix.range.start));
539
540 let mut content = ctx.content.to_string();
542 for (_, fix) in warnings_with_fixes {
543 if fix.range.start <= content.len() && fix.range.end <= content.len() {
544 content.replace_range(fix.range, &fix.replacement);
545 }
546 }
547
548 Ok(content)
549 }
550
551 fn category(&self) -> RuleCategory {
552 RuleCategory::List
553 }
554
555 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
557 ctx.content.is_empty() || !ctx.lines.iter().any(|line| line.list_item.is_some())
559 }
560
561 fn as_any(&self) -> &dyn std::any::Any {
562 self
563 }
564
565 fn default_config_section(&self) -> Option<(String, toml::Value)> {
566 None
567 }
568
569 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
570 where
571 Self: Sized,
572 {
573 let mut top_level_indent = 0;
575 let mut md007_indent = 2; if let Some(md007_config) = config.rules.get("MD007") {
579 if let Some(start_indented) = md007_config.values.get("start-indented")
581 && let Some(start_indented_bool) = start_indented.as_bool()
582 && start_indented_bool
583 {
584 if let Some(start_indent) = md007_config.values.get("start-indent") {
586 if let Some(indent_value) = start_indent.as_integer() {
587 top_level_indent = indent_value as usize;
588 }
589 } else {
590 top_level_indent = 2;
592 }
593 }
594
595 if let Some(indent) = md007_config.values.get("indent")
597 && let Some(indent_value) = indent.as_integer()
598 {
599 md007_indent = indent_value as usize;
600 }
601 }
602
603 Box::new(MD005ListIndent {
604 top_level_indent,
605 md007_indent,
606 })
607 }
608}
609
610#[cfg(test)]
611mod tests {
612 use super::*;
613 use crate::lint_context::LintContext;
614
615 #[test]
616 fn test_valid_unordered_list() {
617 let rule = MD005ListIndent::default();
618 let content = "\
619* Item 1
620* Item 2
621 * Nested 1
622 * Nested 2
623* Item 3";
624 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
625 let result = rule.check(&ctx).unwrap();
626 assert!(result.is_empty());
627 }
628
629 #[test]
630 fn test_valid_ordered_list() {
631 let rule = MD005ListIndent::default();
632 let content = "\
6331. Item 1
6342. Item 2
635 1. Nested 1
636 2. Nested 2
6373. Item 3";
638 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
639 let result = rule.check(&ctx).unwrap();
640 assert!(result.is_empty());
643 }
644
645 #[test]
646 fn test_invalid_unordered_indent() {
647 let rule = MD005ListIndent::default();
648 let content = "\
649* Item 1
650 * Item 2
651 * Nested 1";
652 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
653 let result = rule.check(&ctx).unwrap();
654 assert_eq!(result.len(), 1);
657 let fixed = rule.fix(&ctx).unwrap();
658 assert_eq!(fixed, "* Item 1\n* Item 2\n * Nested 1");
659 }
660
661 #[test]
662 fn test_invalid_ordered_indent() {
663 let rule = MD005ListIndent::default();
664 let content = "\
6651. Item 1
666 2. Item 2
667 1. Nested 1";
668 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
669 let result = rule.check(&ctx).unwrap();
670 assert_eq!(result.len(), 1);
671 let fixed = rule.fix(&ctx).unwrap();
672 assert_eq!(fixed, "1. Item 1\n2. Item 2\n 1. Nested 1");
676 }
677
678 #[test]
679 fn test_mixed_list_types() {
680 let rule = MD005ListIndent::default();
681 let content = "\
682* Item 1
683 1. Nested ordered
684 * Nested unordered
685* Item 2";
686 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
687 let result = rule.check(&ctx).unwrap();
688 assert!(result.is_empty());
689 }
690
691 #[test]
692 fn test_multiple_levels() {
693 let rule = MD005ListIndent::default();
694 let content = "\
695* Level 1
696 * Level 2
697 * Level 3";
698 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
699 let result = rule.check(&ctx).unwrap();
700 assert!(result.is_empty(), "MD005 should accept consistent indentation pattern");
702 }
703
704 #[test]
705 fn test_empty_lines() {
706 let rule = MD005ListIndent::default();
707 let content = "\
708* Item 1
709
710 * Nested 1
711
712* Item 2";
713 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
714 let result = rule.check(&ctx).unwrap();
715 assert!(result.is_empty());
716 }
717
718 #[test]
719 fn test_no_lists() {
720 let rule = MD005ListIndent::default();
721 let content = "\
722Just some text
723More text
724Even more text";
725 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
726 let result = rule.check(&ctx).unwrap();
727 assert!(result.is_empty());
728 }
729
730 #[test]
731 fn test_complex_nesting() {
732 let rule = MD005ListIndent::default();
733 let content = "\
734* Level 1
735 * Level 2
736 * Level 3
737 * Back to 2
738 1. Ordered 3
739 2. Still 3
740* Back to 1";
741 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
742 let result = rule.check(&ctx).unwrap();
743 assert!(result.is_empty());
744 }
745
746 #[test]
747 fn test_invalid_complex_nesting() {
748 let rule = MD005ListIndent::default();
749 let content = "\
750* Level 1
751 * Level 2
752 * Level 3
753 * Back to 2
754 1. Ordered 3
755 2. Still 3
756* Back to 1";
757 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
758 let result = rule.check(&ctx).unwrap();
759 assert_eq!(result.len(), 1);
761 assert!(
762 result[0].message.contains("Expected indentation of 5 spaces, found 6")
763 || result[0].message.contains("Expected indentation of 6 spaces, found 5")
764 );
765 }
766
767 #[test]
768 fn test_with_lint_context() {
769 let rule = MD005ListIndent::default();
770
771 let content = "* Item 1\n* Item 2\n * Nested item\n * Another nested item";
773 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
774 let result = rule.check(&ctx).unwrap();
775 assert!(result.is_empty());
776
777 let content = "* Item 1\n* Item 2\n * Nested item\n * Another nested item";
779 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
780 let result = rule.check(&ctx).unwrap();
781 assert!(!result.is_empty()); let content = "* Item 1\n * Nested item\n * Another nested item with wrong indent";
785 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
786 let result = rule.check(&ctx).unwrap();
787 assert!(!result.is_empty()); }
789
790 #[test]
792 fn test_list_with_continuations() {
793 let rule = MD005ListIndent::default();
794 let content = "\
795* Item 1
796 This is a continuation
797 of the first item
798 * Nested item
799 with its own continuation
800* Item 2";
801 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
802 let result = rule.check(&ctx).unwrap();
803 assert!(result.is_empty());
804 }
805
806 #[test]
807 fn test_list_in_blockquote() {
808 let rule = MD005ListIndent::default();
809 let content = "\
810> * Item 1
811> * Nested 1
812> * Nested 2
813> * Item 2";
814 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
815 let result = rule.check(&ctx).unwrap();
816
817 assert!(
819 result.is_empty(),
820 "Expected no warnings for correctly indented blockquote list, got: {result:?}"
821 );
822 }
823
824 #[test]
825 fn test_list_with_code_blocks() {
826 let rule = MD005ListIndent::default();
827 let content = "\
828* Item 1
829 ```
830 code block
831 ```
832 * Nested item
833* Item 2";
834 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
835 let result = rule.check(&ctx).unwrap();
836 assert!(result.is_empty());
837 }
838
839 #[test]
840 fn test_list_with_tabs() {
841 let rule = MD005ListIndent::default();
842 let content = "* Item 1\n\t* Tab indented\n * Space indented";
843 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
844 let result = rule.check(&ctx).unwrap();
845 assert!(!result.is_empty());
847 }
848
849 #[test]
850 fn test_inconsistent_at_same_level() {
851 let rule = MD005ListIndent::default();
852 let content = "\
853* Item 1
854 * Nested 1
855 * Nested 2
856 * Wrong indent for same level
857 * Nested 3";
858 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
859 let result = rule.check(&ctx).unwrap();
860 assert!(!result.is_empty());
861 assert!(result.iter().any(|w| w.line == 4));
863 }
864
865 #[test]
866 fn test_zero_indent_top_level() {
867 let rule = MD005ListIndent::default();
868 let content = concat!(" * Wrong indent\n", "* Correct\n", " * Nested");
870 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
871 let result = rule.check(&ctx).unwrap();
872
873 assert!(!result.is_empty());
875 assert!(result.iter().any(|w| w.line == 1));
876 }
877
878 #[test]
879 fn test_fix_preserves_content() {
880 let rule = MD005ListIndent::default();
881 let content = "\
882* Item with **bold** and *italic*
883 * Wrong indent with `code`
884 * Also wrong with [link](url)";
885 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
886 let fixed = rule.fix(&ctx).unwrap();
887 assert!(fixed.contains("**bold**"));
888 assert!(fixed.contains("*italic*"));
889 assert!(fixed.contains("`code`"));
890 assert!(fixed.contains("[link](url)"));
891 }
892
893 #[test]
894 fn test_deeply_nested_lists() {
895 let rule = MD005ListIndent::default();
896 let content = "\
897* L1
898 * L2
899 * L3
900 * L4
901 * L5
902 * L6";
903 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
904 let result = rule.check(&ctx).unwrap();
905 assert!(result.is_empty());
906 }
907
908 #[test]
909 fn test_fix_multiple_issues() {
910 let rule = MD005ListIndent::default();
911 let content = "\
912* Item 1
913 * Wrong 1
914 * Wrong 2
915 * Wrong 3
916 * Correct
917 * Wrong 4";
918 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
919 let fixed = rule.fix(&ctx).unwrap();
920 let lines: Vec<&str> = fixed.lines().collect();
922 assert_eq!(lines[0], "* Item 1");
923 assert!(lines[1].starts_with(" * ") || lines[1].starts_with("* "));
925 }
926
927 #[test]
928 fn test_performance_large_document() {
929 let rule = MD005ListIndent::default();
930 let mut content = String::new();
931 for i in 0..100 {
932 content.push_str(&format!("* Item {i}\n"));
933 content.push_str(&format!(" * Nested {i}\n"));
934 }
935 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard);
936 let result = rule.check(&ctx).unwrap();
937 assert!(result.is_empty());
938 }
939
940 #[test]
941 fn test_column_positions() {
942 let rule = MD005ListIndent::default();
943 let content = " * Wrong indent";
944 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
945 let result = rule.check(&ctx).unwrap();
946 assert_eq!(result.len(), 1);
947 assert_eq!(result[0].column, 1, "Expected column 1, got {}", result[0].column);
948 assert_eq!(
949 result[0].end_column, 2,
950 "Expected end_column 2, got {}",
951 result[0].end_column
952 );
953 }
954
955 #[test]
956 fn test_should_skip() {
957 let rule = MD005ListIndent::default();
958
959 let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard);
961 assert!(rule.should_skip(&ctx));
962
963 let ctx = LintContext::new("Just plain text", crate::config::MarkdownFlavor::Standard);
965 assert!(rule.should_skip(&ctx));
966
967 let ctx = LintContext::new("* List item", crate::config::MarkdownFlavor::Standard);
969 assert!(!rule.should_skip(&ctx));
970
971 let ctx = LintContext::new("1. Ordered list", crate::config::MarkdownFlavor::Standard);
972 assert!(!rule.should_skip(&ctx));
973 }
974
975 #[test]
976 fn test_should_skip_validation() {
977 let rule = MD005ListIndent::default();
978 let content = "* List item";
979 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
980 assert!(!rule.should_skip(&ctx));
981
982 let content = "No lists here";
983 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
984 assert!(rule.should_skip(&ctx));
985 }
986
987 #[test]
988 fn test_edge_case_single_space_indent() {
989 let rule = MD005ListIndent::default();
990 let content = "\
991* Item 1
992 * Single space - wrong
993 * Two spaces - correct";
994 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
995 let result = rule.check(&ctx).unwrap();
996 assert_eq!(result.len(), 2);
999 assert!(result.iter().any(|w| w.line == 2 && w.message.contains("found 1")));
1000 }
1001
1002 #[test]
1003 fn test_edge_case_three_space_indent() {
1004 let rule = MD005ListIndent::default();
1005 let content = "\
1006* Item 1
1007 * Three spaces - wrong
1008 * Two spaces - correct";
1009 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1010 let result = rule.check(&ctx).unwrap();
1011 assert_eq!(result.len(), 1);
1013 assert!(result.iter().any(|w| w.line == 2 && w.message.contains("found 3")));
1014 }
1015
1016 #[test]
1017 fn test_nested_bullets_under_numbered_items() {
1018 let rule = MD005ListIndent::default();
1019 let content = "\
10201. **Active Directory/LDAP**
1021 - User authentication and directory services
1022 - LDAP for user information and validation
1023
10242. **Oracle Unified Directory (OUD)**
1025 - Extended user directory services
1026 - Verification of project account presence and changes";
1027 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1028 let result = rule.check(&ctx).unwrap();
1029 assert!(
1031 result.is_empty(),
1032 "Expected no warnings for bullets with 3 spaces under numbered items, got: {result:?}"
1033 );
1034 }
1035
1036 #[test]
1037 fn test_nested_bullets_under_numbered_items_wrong_indent() {
1038 let rule = MD005ListIndent::default();
1039 let content = "\
10401. **Active Directory/LDAP**
1041 - Wrong: only 2 spaces
1042 - Correct: 3 spaces";
1043 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1044 let result = rule.check(&ctx).unwrap();
1045 assert_eq!(
1047 result.len(),
1048 1,
1049 "Expected 1 warning, got {}. Warnings: {:?}",
1050 result.len(),
1051 result
1052 );
1053 assert!(
1055 result
1056 .iter()
1057 .any(|w| (w.line == 2 && w.message.contains("found 2"))
1058 || (w.line == 3 && w.message.contains("found 3")))
1059 );
1060 }
1061
1062 #[test]
1063 fn test_regular_nested_bullets_still_work() {
1064 let rule = MD005ListIndent::default();
1065 let content = "\
1066* Top level
1067 * Second level (2 spaces is correct for bullets under bullets)
1068 * Third level (4 spaces)";
1069 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1070 let result = rule.check(&ctx).unwrap();
1071 assert!(
1073 result.is_empty(),
1074 "Expected no warnings for regular bullet nesting, got: {result:?}"
1075 );
1076 }
1077
1078 #[test]
1079 fn test_fix_range_accuracy() {
1080 let rule = MD005ListIndent::default();
1081 let content = " * Wrong indent";
1082 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1083 let result = rule.check(&ctx).unwrap();
1084 assert_eq!(result.len(), 1);
1085
1086 let fix = result[0].fix.as_ref().unwrap();
1087 assert_eq!(fix.replacement, "");
1089 }
1090
1091 #[test]
1092 fn test_four_space_indent_pattern() {
1093 let rule = MD005ListIndent::default();
1094 let content = "\
1095* Item 1
1096 * Item 2 with 4 spaces
1097 * Item 3 with 8 spaces
1098 * Item 4 with 4 spaces";
1099 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1100 let result = rule.check(&ctx).unwrap();
1101 assert!(
1103 result.is_empty(),
1104 "MD005 should accept consistent 4-space indentation pattern, got {} warnings",
1105 result.len()
1106 );
1107 }
1108
1109 #[test]
1110 fn test_issue_64_scenario() {
1111 let rule = MD005ListIndent::default();
1113 let content = "\
1114* Top level item
1115 * Sub item with 4 spaces (as configured in MD007)
1116 * Nested sub item with 8 spaces
1117 * Another sub item with 4 spaces
1118* Another top level";
1119
1120 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1121 let result = rule.check(&ctx).unwrap();
1122
1123 assert!(
1125 result.is_empty(),
1126 "MD005 should accept 4-space indentation when that's the pattern being used. Got {} warnings",
1127 result.len()
1128 );
1129 }
1130
1131 #[test]
1132 fn test_continuation_content_scenario() {
1133 let rule = MD005ListIndent::default();
1134 let content = "\
1135- **Changes to how the Python version is inferred** ([#16319](example))
1136
1137 In previous versions of Ruff, you could specify your Python version with:
1138
1139 - The `target-version` option in a `ruff.toml` file
1140 - The `project.requires-python` field in a `pyproject.toml` file";
1141
1142 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1143
1144 let result = rule.check(&ctx).unwrap();
1145
1146 assert!(
1148 result.is_empty(),
1149 "MD005 should not flag continuation content lists, got {} warnings: {:?}",
1150 result.len(),
1151 result
1152 );
1153 }
1154
1155 #[test]
1156 fn test_multiple_continuation_lists_scenario() {
1157 let rule = MD005ListIndent::default();
1158 let content = "\
1159- **Changes to how the Python version is inferred** ([#16319](example))
1160
1161 In previous versions of Ruff, you could specify your Python version with:
1162
1163 - The `target-version` option in a `ruff.toml` file
1164 - The `project.requires-python` field in a `pyproject.toml` file
1165
1166 In v0.10, config discovery has been updated to address this issue:
1167
1168 - If Ruff finds a `ruff.toml` file without a `target-version`, it will check
1169 - If Ruff finds a user-level configuration, the `requires-python` field will take precedence
1170 - If there is no config file, Ruff will search for the closest `pyproject.toml`";
1171
1172 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1173
1174 let result = rule.check(&ctx).unwrap();
1175
1176 assert!(
1178 result.is_empty(),
1179 "MD005 should not flag continuation content lists, got {} warnings: {:?}",
1180 result.len(),
1181 result
1182 );
1183 }
1184}