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