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