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)]
16pub struct MD005ListIndent;
17
18impl MD005ListIndent {
19 fn group_related_list_blocks<'a>(
21 &self,
22 list_blocks: &'a [crate::lint_context::ListBlock],
23 ) -> Vec<Vec<&'a crate::lint_context::ListBlock>> {
24 if list_blocks.is_empty() {
25 return Vec::new();
26 }
27
28 let mut groups = Vec::new();
29 let mut current_group = vec![&list_blocks[0]];
30
31 for i in 1..list_blocks.len() {
32 let prev_block = &list_blocks[i - 1];
33 let current_block = &list_blocks[i];
34
35 let line_gap = current_block.start_line.saturating_sub(prev_block.end_line);
37
38 if line_gap <= 2 {
41 current_group.push(current_block);
42 } else {
43 groups.push(current_group);
45 current_group = vec![current_block];
46 }
47 }
48 groups.push(current_group);
49
50 groups
51 }
52
53 fn check_list_block_group(
55 &self,
56 ctx: &crate::lint_context::LintContext,
57 group: &[&crate::lint_context::ListBlock],
58 warnings: &mut Vec<LintWarning>,
59 ) -> Result<(), LintError> {
60 let line_index = LineIndex::new(ctx.content.to_string());
61
62 let mut all_list_items = Vec::new();
64
65 for list_block in group {
66 for &item_line in &list_block.item_lines {
67 if let Some(line_info) = ctx.line_info(item_line)
68 && let Some(list_item) = &line_info.list_item
69 {
70 let effective_indent = if let Some(blockquote) = &line_info.blockquote {
72 list_item.marker_column.saturating_sub(blockquote.nesting_level * 2)
74 } else {
75 list_item.marker_column
77 };
78
79 all_list_items.push((item_line, effective_indent, line_info, list_item));
80 }
81 }
82 }
83
84 if all_list_items.is_empty() {
85 return Ok(());
86 }
87
88 all_list_items.sort_by_key(|(line_num, _, _, _)| *line_num);
90
91 let mut level_map: HashMap<usize, usize> = HashMap::new();
95 let mut level_indents: HashMap<usize, Vec<usize>> = HashMap::new(); for i in 0..all_list_items.len() {
99 let (line_num, indent, _, _) = &all_list_items[i];
100
101 let level = if i == 0 {
102 level_indents.entry(1).or_default().push(*indent);
104 1
105 } else {
106 let mut determined_level = 0;
108
109 for (lvl, indents) in &level_indents {
111 if indents.contains(indent) {
112 determined_level = *lvl;
113 break;
114 }
115 }
116
117 if determined_level == 0 {
118 for j in (0..i).rev() {
121 let (prev_line, prev_indent, _, _) = &all_list_items[j];
122 let prev_level = level_map[prev_line];
123
124 if *prev_indent + 2 <= *indent {
126 determined_level = prev_level + 1;
128 break;
129 } else if (*prev_indent as i32 - *indent as i32).abs() <= 1 {
130 determined_level = prev_level;
132 break;
133 } else if *prev_indent < *indent {
134 if let Some(level_indents_list) = level_indents.get(&prev_level) {
139 for &lvl_indent in level_indents_list {
141 if (lvl_indent as i32 - *indent as i32).abs() <= 1 {
142 determined_level = prev_level;
144 break;
145 }
146 }
147 }
148 if determined_level == 0 {
149 determined_level = prev_level + 1;
151 }
152 break;
153 }
154 }
155
156 if determined_level == 0 {
158 determined_level = 1;
159 }
160
161 level_indents.entry(determined_level).or_default().push(*indent);
163 }
164
165 determined_level
166 };
167
168 level_map.insert(*line_num, level);
169 }
170
171 let mut level_groups: HashMap<usize, Vec<(usize, usize, &crate::lint_context::LineInfo)>> = HashMap::new();
173 for (line_num, indent, line_info, _) in &all_list_items {
174 let level = level_map[line_num];
175 level_groups
176 .entry(level)
177 .or_default()
178 .push((*line_num, *indent, *line_info));
179 }
180
181 for (level, group) in level_groups {
183 if level != 1 && group.len() < 2 {
186 continue;
187 }
188
189 let mut group = group;
191 group.sort_by_key(|(line_num, _, _)| *line_num);
192
193 let indents: std::collections::HashSet<usize> = group.iter().map(|(_, indent, _)| *indent).collect();
195
196 let has_issue = if level == 1 {
199 indents.iter().any(|&indent| indent != 0)
201 } else {
202 indents.len() > 1
204 };
205
206 if has_issue {
207 let expected_indent = if level == 1 {
213 0
214 } else {
215 let mut indent_counts: HashMap<usize, usize> = HashMap::new();
218 for (_, indent, _) in &group {
219 *indent_counts.entry(*indent).or_insert(0) += 1;
220 }
221
222 if indent_counts.len() == 1 {
223 *indent_counts.keys().next().unwrap()
225 } else {
226 let mut chosen_indent = None;
229 if level == 2 {
230 for &common_indent in &[4, 2, 3] {
232 if indent_counts.contains_key(&common_indent) {
233 chosen_indent = Some(common_indent);
234 break;
235 }
236 }
237 }
238
239 chosen_indent.unwrap_or_else(|| {
241 indent_counts
244 .iter()
245 .max_by(|(indent_a, count_a), (indent_b, count_b)| {
246 count_a.cmp(count_b).then(indent_b.cmp(indent_a))
248 })
249 .map(|(indent, _)| *indent)
250 .unwrap()
251 })
252 }
253 };
254
255 for (line_num, indent, line_info) in &group {
257 if *indent != expected_indent {
258 let message = format!(
259 "Expected indentation of {} {}, found {}",
260 expected_indent,
261 if expected_indent == 1 { "space" } else { "spaces" },
262 indent
263 );
264
265 let (start_line, start_col, end_line, end_col) = if *indent > 0 {
266 calculate_match_range(*line_num, &line_info.content, 0, *indent)
267 } else {
268 calculate_match_range(*line_num, &line_info.content, 0, 1)
269 };
270
271 let fix_range = if *indent > 0 {
272 let start_byte = line_index.line_col_to_byte_range(*line_num, 1).start;
273 let end_byte = line_index.line_col_to_byte_range(*line_num, *indent + 1).start;
274 start_byte..end_byte
275 } else {
276 let byte_pos = line_index.line_col_to_byte_range(*line_num, 1).start;
277 byte_pos..byte_pos
278 };
279
280 let replacement = if expected_indent > 0 {
281 " ".repeat(expected_indent)
282 } else {
283 String::new()
284 };
285
286 warnings.push(LintWarning {
287 rule_name: Some(self.name()),
288 line: start_line,
289 column: start_col,
290 end_line,
291 end_column: end_col,
292 message,
293 severity: Severity::Warning,
294 fix: Some(Fix {
295 range: fix_range,
296 replacement,
297 }),
298 });
299 }
300 }
301 }
302 }
303
304 Ok(())
305 }
306
307 fn check_optimized(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
309 let content = ctx.content;
310
311 if content.is_empty() {
313 return Ok(Vec::new());
314 }
315
316 if ctx.list_blocks.is_empty() {
318 return Ok(Vec::new());
319 }
320
321 let mut warnings = Vec::new();
322
323 let block_groups = self.group_related_list_blocks(&ctx.list_blocks);
326
327 for group in block_groups {
328 self.check_list_block_group(ctx, &group, &mut warnings)?;
329 }
330
331 Ok(warnings)
332 }
333}
334
335impl Default for MD005ListIndent {
336 fn default() -> Self {
337 Self
338 }
339}
340
341impl Rule for MD005ListIndent {
342 fn name(&self) -> &'static str {
343 "MD005"
344 }
345
346 fn description(&self) -> &'static str {
347 "List indentation should be consistent"
348 }
349
350 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
351 self.check_optimized(ctx)
353 }
354
355 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
356 let warnings = self.check(ctx)?;
357 if warnings.is_empty() {
358 return Ok(ctx.content.to_string());
359 }
360
361 let mut warnings_with_fixes: Vec<_> = warnings
363 .into_iter()
364 .filter_map(|w| w.fix.clone().map(|fix| (w, fix)))
365 .collect();
366 warnings_with_fixes.sort_by_key(|(_, fix)| std::cmp::Reverse(fix.range.start));
367
368 let mut content = ctx.content.to_string();
370 for (_, fix) in warnings_with_fixes {
371 if fix.range.start <= content.len() && fix.range.end <= content.len() {
372 content.replace_range(fix.range, &fix.replacement);
373 }
374 }
375
376 Ok(content)
377 }
378
379 fn category(&self) -> RuleCategory {
380 RuleCategory::List
381 }
382
383 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
385 ctx.content.is_empty() || !ctx.lines.iter().any(|line| line.list_item.is_some())
387 }
388
389 fn check_with_structure(
391 &self,
392 ctx: &crate::lint_context::LintContext,
393 structure: &DocumentStructure,
394 ) -> LintResult {
395 if structure.list_lines.is_empty() {
397 return Ok(Vec::new());
398 }
399
400 self.check_optimized(ctx)
402 }
403
404 fn as_any(&self) -> &dyn std::any::Any {
405 self
406 }
407
408 fn as_maybe_document_structure(&self) -> Option<&dyn crate::rule::MaybeDocumentStructure> {
409 Some(self)
410 }
411
412 fn default_config_section(&self) -> Option<(String, toml::Value)> {
413 None
414 }
415
416 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
417 where
418 Self: Sized,
419 {
420 Box::new(MD005ListIndent)
421 }
422}
423
424impl crate::utils::document_structure::DocumentStructureExtensions for MD005ListIndent {
425 fn has_relevant_elements(
426 &self,
427 _ctx: &crate::lint_context::LintContext,
428 doc_structure: &crate::utils::document_structure::DocumentStructure,
429 ) -> bool {
430 !doc_structure.list_lines.is_empty()
431 }
432}
433
434#[cfg(test)]
435mod tests {
436 use super::*;
437 use crate::lint_context::LintContext;
438 use crate::utils::document_structure::DocumentStructureExtensions;
439
440 #[test]
441 fn test_valid_unordered_list() {
442 let rule = MD005ListIndent;
443 let content = "\
444* Item 1
445* Item 2
446 * Nested 1
447 * Nested 2
448* Item 3";
449 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
450 let result = rule.check(&ctx).unwrap();
451 assert!(result.is_empty());
452 }
453
454 #[test]
455 fn test_valid_ordered_list() {
456 let rule = MD005ListIndent;
457 let content = "\
4581. Item 1
4592. Item 2
460 1. Nested 1
461 2. Nested 2
4623. Item 3";
463 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
464 let result = rule.check(&ctx).unwrap();
465 assert!(result.is_empty());
468 }
469
470 #[test]
471 fn test_invalid_unordered_indent() {
472 let rule = MD005ListIndent;
473 let content = "\
474* Item 1
475 * Item 2
476 * Nested 1";
477 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
478 let result = rule.check(&ctx).unwrap();
479 assert_eq!(result.len(), 1);
482 let fixed = rule.fix(&ctx).unwrap();
483 assert_eq!(fixed, "* Item 1\n* Item 2\n * Nested 1");
484 }
485
486 #[test]
487 fn test_invalid_ordered_indent() {
488 let rule = MD005ListIndent;
489 let content = "\
4901. Item 1
491 2. Item 2
492 1. Nested 1";
493 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
494 let result = rule.check(&ctx).unwrap();
495 assert_eq!(result.len(), 1);
496 let fixed = rule.fix(&ctx).unwrap();
497 assert_eq!(fixed, "1. Item 1\n2. Item 2\n 1. Nested 1");
501 }
502
503 #[test]
504 fn test_mixed_list_types() {
505 let rule = MD005ListIndent;
506 let content = "\
507* Item 1
508 1. Nested ordered
509 * Nested unordered
510* Item 2";
511 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
512 let result = rule.check(&ctx).unwrap();
513 assert!(result.is_empty());
514 }
515
516 #[test]
517 fn test_multiple_levels() {
518 let rule = MD005ListIndent;
519 let content = "\
520* Level 1
521 * Level 2
522 * Level 3";
523 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
524 let result = rule.check(&ctx).unwrap();
525 assert!(result.is_empty(), "MD005 should accept consistent indentation pattern");
527 }
528
529 #[test]
530 fn test_empty_lines() {
531 let rule = MD005ListIndent;
532 let content = "\
533* Item 1
534
535 * Nested 1
536
537* Item 2";
538 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
539 let result = rule.check(&ctx).unwrap();
540 assert!(result.is_empty());
541 }
542
543 #[test]
544 fn test_no_lists() {
545 let rule = MD005ListIndent;
546 let content = "\
547Just some text
548More text
549Even more text";
550 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
551 let result = rule.check(&ctx).unwrap();
552 assert!(result.is_empty());
553 }
554
555 #[test]
556 fn test_complex_nesting() {
557 let rule = MD005ListIndent;
558 let content = "\
559* Level 1
560 * Level 2
561 * Level 3
562 * Back to 2
563 1. Ordered 3
564 2. Still 3
565* Back to 1";
566 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
567 let result = rule.check(&ctx).unwrap();
568 assert!(result.is_empty());
569 }
570
571 #[test]
572 fn test_invalid_complex_nesting() {
573 let rule = MD005ListIndent;
574 let content = "\
575* Level 1
576 * Level 2
577 * Level 3
578 * Back to 2
579 1. Ordered 3
580 2. Still 3
581* Back to 1";
582 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
583 let result = rule.check(&ctx).unwrap();
584 assert_eq!(result.len(), 1);
586 assert!(
587 result[0].message.contains("Expected indentation of 5 spaces, found 6")
588 || result[0].message.contains("Expected indentation of 6 spaces, found 5")
589 );
590 }
591
592 #[test]
593 fn test_with_document_structure() {
594 let rule = MD005ListIndent;
595
596 let content = "* Item 1\n* Item 2\n * Nested item\n * Another nested item";
598 let structure = DocumentStructure::new(content);
599 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
600 let result = rule.check_with_structure(&ctx, &structure).unwrap();
601 assert!(result.is_empty());
602
603 let content = "* Item 1\n* Item 2\n * Nested item\n * Another nested item";
605 let structure = DocumentStructure::new(content);
606 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
607 let result = rule.check_with_structure(&ctx, &structure).unwrap();
608 assert!(!result.is_empty()); let content = "* Item 1\n * Nested item\n * Another nested item with wrong indent";
612 let structure = DocumentStructure::new(content);
613 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
614 let result = rule.check_with_structure(&ctx, &structure).unwrap();
615 assert!(!result.is_empty()); }
617
618 #[test]
620 fn test_list_with_continuations() {
621 let rule = MD005ListIndent;
622 let content = "\
623* Item 1
624 This is a continuation
625 of the first item
626 * Nested item
627 with its own continuation
628* Item 2";
629 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
630 let result = rule.check(&ctx).unwrap();
631 assert!(result.is_empty());
632 }
633
634 #[test]
635 fn test_list_in_blockquote() {
636 let rule = MD005ListIndent;
637 let content = "\
638> * Item 1
639> * Nested 1
640> * Nested 2
641> * Item 2";
642 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
643 let result = rule.check(&ctx).unwrap();
644
645 assert!(
647 result.is_empty(),
648 "Expected no warnings for correctly indented blockquote list, got: {result:?}"
649 );
650 }
651
652 #[test]
653 fn test_list_with_code_blocks() {
654 let rule = MD005ListIndent;
655 let content = "\
656* Item 1
657 ```
658 code block
659 ```
660 * Nested item
661* Item 2";
662 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
663 let result = rule.check(&ctx).unwrap();
664 assert!(result.is_empty());
665 }
666
667 #[test]
668 fn test_list_with_tabs() {
669 let rule = MD005ListIndent;
670 let content = "* Item 1\n\t* Tab indented\n * Space indented";
671 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
672 let result = rule.check(&ctx).unwrap();
673 assert!(!result.is_empty());
675 }
676
677 #[test]
678 fn test_inconsistent_at_same_level() {
679 let rule = MD005ListIndent;
680 let content = "\
681* Item 1
682 * Nested 1
683 * Nested 2
684 * Wrong indent for same level
685 * Nested 3";
686 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
687 let result = rule.check(&ctx).unwrap();
688 assert!(!result.is_empty());
689 assert!(result.iter().any(|w| w.line == 4));
691 }
692
693 #[test]
694 fn test_zero_indent_top_level() {
695 let rule = MD005ListIndent;
696 let content = concat!(" * Wrong indent\n", "* Correct\n", " * Nested");
698 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
699 let result = rule.check(&ctx).unwrap();
700
701 assert!(!result.is_empty());
703 assert!(result.iter().any(|w| w.line == 1));
704 }
705
706 #[test]
707 fn test_fix_preserves_content() {
708 let rule = MD005ListIndent;
709 let content = "\
710* Item with **bold** and *italic*
711 * Wrong indent with `code`
712 * Also wrong with [link](url)";
713 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
714 let fixed = rule.fix(&ctx).unwrap();
715 assert!(fixed.contains("**bold**"));
716 assert!(fixed.contains("*italic*"));
717 assert!(fixed.contains("`code`"));
718 assert!(fixed.contains("[link](url)"));
719 }
720
721 #[test]
722 fn test_deeply_nested_lists() {
723 let rule = MD005ListIndent;
724 let content = "\
725* L1
726 * L2
727 * L3
728 * L4
729 * L5
730 * L6";
731 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
732 let result = rule.check(&ctx).unwrap();
733 assert!(result.is_empty());
734 }
735
736 #[test]
737 fn test_fix_multiple_issues() {
738 let rule = MD005ListIndent;
739 let content = "\
740* Item 1
741 * Wrong 1
742 * Wrong 2
743 * Wrong 3
744 * Correct
745 * Wrong 4";
746 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
747 let fixed = rule.fix(&ctx).unwrap();
748 let lines: Vec<&str> = fixed.lines().collect();
750 assert_eq!(lines[0], "* Item 1");
751 assert!(lines[1].starts_with(" * ") || lines[1].starts_with("* "));
753 }
754
755 #[test]
756 fn test_performance_large_document() {
757 let rule = MD005ListIndent;
758 let mut content = String::new();
759 for i in 0..100 {
760 content.push_str(&format!("* Item {i}\n"));
761 content.push_str(&format!(" * Nested {i}\n"));
762 }
763 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard);
764 let result = rule.check(&ctx).unwrap();
765 assert!(result.is_empty());
766 }
767
768 #[test]
769 fn test_column_positions() {
770 let rule = MD005ListIndent;
771 let content = " * Wrong indent";
772 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
773 let result = rule.check(&ctx).unwrap();
774 assert_eq!(result.len(), 1);
775 assert_eq!(result[0].column, 1, "Expected column 1, got {}", result[0].column);
776 assert_eq!(
777 result[0].end_column, 2,
778 "Expected end_column 2, got {}",
779 result[0].end_column
780 );
781 }
782
783 #[test]
784 fn test_should_skip() {
785 let rule = MD005ListIndent;
786
787 let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard);
789 assert!(rule.should_skip(&ctx));
790
791 let ctx = LintContext::new("Just plain text", crate::config::MarkdownFlavor::Standard);
793 assert!(rule.should_skip(&ctx));
794
795 let ctx = LintContext::new("* List item", crate::config::MarkdownFlavor::Standard);
797 assert!(!rule.should_skip(&ctx));
798
799 let ctx = LintContext::new("1. Ordered list", crate::config::MarkdownFlavor::Standard);
800 assert!(!rule.should_skip(&ctx));
801 }
802
803 #[test]
804 fn test_has_relevant_elements() {
805 let rule = MD005ListIndent;
806 let content = "* List item";
807 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
808 let doc_structure = DocumentStructure::new(content);
809 assert!(rule.has_relevant_elements(&ctx, &doc_structure));
810
811 let content = "No lists here";
812 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
813 let doc_structure = DocumentStructure::new(content);
814 assert!(!rule.has_relevant_elements(&ctx, &doc_structure));
815 }
816
817 #[test]
818 fn test_edge_case_single_space_indent() {
819 let rule = MD005ListIndent;
820 let content = "\
821* Item 1
822 * Single space - wrong
823 * Two spaces - correct";
824 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
825 let result = rule.check(&ctx).unwrap();
826 assert_eq!(result.len(), 2);
829 assert!(result.iter().any(|w| w.line == 2 && w.message.contains("found 1")));
830 }
831
832 #[test]
833 fn test_edge_case_three_space_indent() {
834 let rule = MD005ListIndent;
835 let content = "\
836* Item 1
837 * Three spaces - wrong
838 * Two spaces - correct";
839 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
840 let result = rule.check(&ctx).unwrap();
841 assert_eq!(result.len(), 1);
843 assert!(result.iter().any(|w| w.line == 2 && w.message.contains("found 3")));
844 }
845
846 #[test]
847 fn test_nested_bullets_under_numbered_items() {
848 let rule = MD005ListIndent;
849 let content = "\
8501. **Active Directory/LDAP**
851 - User authentication and directory services
852 - LDAP for user information and validation
853
8542. **Oracle Unified Directory (OUD)**
855 - Extended user directory services
856 - Verification of project account presence and changes";
857 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
858 let result = rule.check(&ctx).unwrap();
859 assert!(
861 result.is_empty(),
862 "Expected no warnings for bullets with 3 spaces under numbered items, got: {result:?}"
863 );
864 }
865
866 #[test]
867 fn test_nested_bullets_under_numbered_items_wrong_indent() {
868 let rule = MD005ListIndent;
869 let content = "\
8701. **Active Directory/LDAP**
871 - Wrong: only 2 spaces
872 - Correct: 3 spaces";
873 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
874 let result = rule.check(&ctx).unwrap();
875 assert_eq!(
877 result.len(),
878 1,
879 "Expected 1 warning, got {}. Warnings: {:?}",
880 result.len(),
881 result
882 );
883 assert!(
885 result
886 .iter()
887 .any(|w| (w.line == 2 && w.message.contains("found 2"))
888 || (w.line == 3 && w.message.contains("found 3")))
889 );
890 }
891
892 #[test]
893 fn test_regular_nested_bullets_still_work() {
894 let rule = MD005ListIndent;
895 let content = "\
896* Top level
897 * Second level (2 spaces is correct for bullets under bullets)
898 * Third level (4 spaces)";
899 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
900 let result = rule.check(&ctx).unwrap();
901 assert!(
903 result.is_empty(),
904 "Expected no warnings for regular bullet nesting, got: {result:?}"
905 );
906 }
907
908 #[test]
909 fn test_fix_range_accuracy() {
910 let rule = MD005ListIndent;
911 let content = " * Wrong indent";
912 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
913 let result = rule.check(&ctx).unwrap();
914 assert_eq!(result.len(), 1);
915
916 let fix = result[0].fix.as_ref().unwrap();
917 assert_eq!(fix.replacement, "");
919 }
920
921 #[test]
922 fn test_four_space_indent_pattern() {
923 let rule = MD005ListIndent;
924 let content = "\
925* Item 1
926 * Item 2 with 4 spaces
927 * Item 3 with 8 spaces
928 * Item 4 with 4 spaces";
929 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
930 let result = rule.check(&ctx).unwrap();
931 assert!(
933 result.is_empty(),
934 "MD005 should accept consistent 4-space indentation pattern, got {} warnings",
935 result.len()
936 );
937 }
938
939 #[test]
940 fn test_issue_64_scenario() {
941 let rule = MD005ListIndent;
943 let content = "\
944* Top level item
945 * Sub item with 4 spaces (as configured in MD007)
946 * Nested sub item with 8 spaces
947 * Another sub item with 4 spaces
948* Another top level";
949
950 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
951 let result = rule.check(&ctx).unwrap();
952
953 assert!(
955 result.is_empty(),
956 "MD005 should accept 4-space indentation when that's the pattern being used. Got {} warnings",
957 result.len()
958 );
959 }
960}