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
22struct LineCacheInfo {
24 indentation: Vec<usize>,
26 flags: Vec<u8>,
28 parent_map: HashMap<usize, usize>,
31}
32
33const FLAG_HAS_CONTENT: u8 = 1;
34const FLAG_IS_LIST_ITEM: u8 = 2;
35
36impl LineCacheInfo {
37 fn new(ctx: &crate::lint_context::LintContext) -> Self {
39 let total_lines = ctx.lines.len();
40 let mut indentation = Vec::with_capacity(total_lines);
41 let mut flags = Vec::with_capacity(total_lines);
42 let mut parent_map = HashMap::new();
43
44 let mut indent_to_line: HashMap<usize, usize> = HashMap::new();
56
57 for (idx, line_info) in ctx.lines.iter().enumerate() {
58 let content = line_info.content(ctx.content).trim_start();
59 let line_indent = line_info.byte_len - content.len();
60
61 indentation.push(line_indent);
62
63 let mut flag = 0u8;
64 if !content.is_empty() {
65 flag |= FLAG_HAS_CONTENT;
66 }
67 if let Some(list_item) = &line_info.list_item {
68 flag |= FLAG_IS_LIST_ITEM;
69
70 let line_num = idx + 1; let marker_column = list_item.marker_column;
72
73 let mut best_parent: Option<(usize, usize)> = None; for (&tracked_indent, &tracked_line) in &indent_to_line {
78 if tracked_indent < marker_column {
79 if best_parent.is_none() || tracked_indent > best_parent.unwrap().0 {
81 best_parent = Some((tracked_indent, tracked_line));
82 }
83 }
84 }
85
86 if let Some((_parent_indent, parent_line)) = best_parent {
87 parent_map.insert(line_num, parent_line);
88 }
89
90 indent_to_line.retain(|&indent, _| indent < marker_column);
95 indent_to_line.insert(marker_column, line_num);
96 }
97 flags.push(flag);
98 }
99
100 Self {
101 indentation,
102 flags,
103 parent_map,
104 }
105 }
106
107 fn has_content(&self, idx: usize) -> bool {
109 self.flags.get(idx).is_some_and(|&f| f & FLAG_HAS_CONTENT != 0)
110 }
111
112 fn is_list_item(&self, idx: usize) -> bool {
114 self.flags.get(idx).is_some_and(|&f| f & FLAG_IS_LIST_ITEM != 0)
115 }
116
117 fn find_continuation_indent(
119 &self,
120 start_line: usize,
121 end_line: usize,
122 parent_content_column: usize,
123 ) -> Option<usize> {
124 if start_line == 0 || start_line > end_line || end_line > self.indentation.len() {
125 return None;
126 }
127
128 let start_idx = start_line - 1;
130 let end_idx = end_line - 1;
131
132 for idx in start_idx..=end_idx {
133 if !self.has_content(idx) || self.is_list_item(idx) {
135 continue;
136 }
137
138 if self.indentation[idx] >= parent_content_column {
141 return Some(self.indentation[idx]);
142 }
143 }
144 None
145 }
146
147 fn has_continuation_content(&self, parent_line: usize, current_line: usize, parent_content_column: usize) -> bool {
149 if parent_line == 0 || current_line <= parent_line || current_line > self.indentation.len() {
150 return false;
151 }
152
153 let start_idx = parent_line; let end_idx = current_line - 2; if start_idx > end_idx {
158 return false;
159 }
160
161 for idx in start_idx..=end_idx {
162 if !self.has_content(idx) || self.is_list_item(idx) {
164 continue;
165 }
166
167 if self.indentation[idx] >= parent_content_column {
170 return true;
171 }
172 }
173 false
174 }
175}
176
177impl MD005ListIndent {
178 const LIST_GROUP_GAP_TOLERANCE: usize = 2;
182
183 const MIN_CHILD_INDENT_INCREASE: usize = 2;
186
187 const SAME_LEVEL_TOLERANCE: i32 = 1;
190
191 const STANDARD_CONTINUATION_OFFSET: usize = 2;
194
195 fn group_related_list_blocks<'a>(
197 &self,
198 list_blocks: &'a [crate::lint_context::ListBlock],
199 ) -> Vec<Vec<&'a crate::lint_context::ListBlock>> {
200 if list_blocks.is_empty() {
201 return Vec::new();
202 }
203
204 let mut groups = Vec::new();
205 let mut current_group = vec![&list_blocks[0]];
206
207 for i in 1..list_blocks.len() {
208 let prev_block = &list_blocks[i - 1];
209 let current_block = &list_blocks[i];
210
211 let line_gap = current_block.start_line.saturating_sub(prev_block.end_line);
213
214 if line_gap <= Self::LIST_GROUP_GAP_TOLERANCE {
217 current_group.push(current_block);
218 } else {
219 groups.push(current_group);
221 current_group = vec![current_block];
222 }
223 }
224 groups.push(current_group);
225
226 groups
227 }
228
229 fn is_continuation_content(
232 &self,
233 ctx: &crate::lint_context::LintContext,
234 cache: &LineCacheInfo,
235 list_line: usize,
236 list_indent: usize,
237 ) -> bool {
238 let parent_line = cache.parent_map.get(&list_line).copied();
240
241 if let Some(parent_line) = parent_line
242 && let Some(line_info) = ctx.line_info(parent_line)
243 && let Some(parent_list_item) = &line_info.list_item
244 {
245 let parent_marker_column = parent_list_item.marker_column;
246 let parent_content_column = parent_list_item.content_column;
247
248 let continuation_indent =
250 cache.find_continuation_indent(parent_line + 1, list_line - 1, parent_content_column);
251
252 if let Some(continuation_indent) = continuation_indent {
253 let is_standard_continuation =
254 list_indent == parent_content_column + Self::STANDARD_CONTINUATION_OFFSET;
255 let matches_content_indent = list_indent == continuation_indent;
256
257 if matches_content_indent || is_standard_continuation {
258 return true;
259 }
260 }
261
262 if list_indent > parent_marker_column {
265 if self.has_continuation_list_at_indent(
267 ctx,
268 cache,
269 parent_line,
270 list_line,
271 list_indent,
272 parent_content_column,
273 ) {
274 return true;
275 }
276
277 if cache.has_continuation_content(parent_line, list_line, parent_content_column) {
278 return true;
279 }
280 }
281 }
282
283 false
284 }
285
286 fn has_continuation_list_at_indent(
288 &self,
289 ctx: &crate::lint_context::LintContext,
290 cache: &LineCacheInfo,
291 parent_line: usize,
292 current_line: usize,
293 list_indent: usize,
294 parent_content_column: usize,
295 ) -> bool {
296 for line_num in (parent_line + 1)..current_line {
299 if let Some(line_info) = ctx.line_info(line_num)
300 && let Some(list_item) = &line_info.list_item
301 && list_item.marker_column == list_indent
302 {
303 if cache
306 .find_continuation_indent(parent_line + 1, line_num - 1, parent_content_column)
307 .is_some()
308 {
309 return true;
310 }
311 }
312 }
313 false
314 }
315
316 fn check_list_block_group(
318 &self,
319 ctx: &crate::lint_context::LintContext,
320 group: &[&crate::lint_context::ListBlock],
321 warnings: &mut Vec<LintWarning>,
322 ) -> Result<(), LintError> {
323 let cache = LineCacheInfo::new(ctx);
325
326 let mut all_list_items = Vec::new();
328
329 for list_block in group {
330 for &item_line in &list_block.item_lines {
331 if let Some(line_info) = ctx.line_info(item_line)
332 && let Some(list_item) = &line_info.list_item
333 {
334 let effective_indent = if let Some(blockquote) = &line_info.blockquote {
336 list_item.marker_column.saturating_sub(blockquote.nesting_level * 2)
338 } else {
339 list_item.marker_column
341 };
342
343 if self.is_continuation_content(ctx, &cache, item_line, effective_indent) {
345 continue;
346 }
347
348 all_list_items.push((item_line, effective_indent, line_info, list_item));
349 }
350 }
351 }
352
353 if all_list_items.is_empty() {
354 return Ok(());
355 }
356
357 all_list_items.sort_by_key(|(line_num, _, _, _)| *line_num);
359
360 let mut level_map: HashMap<usize, usize> = HashMap::new();
364 let mut level_indents: HashMap<usize, Vec<usize>> = HashMap::new(); let mut indent_to_level: HashMap<usize, (usize, usize)> = HashMap::new();
369
370 for (line_num, indent, _, _) in &all_list_items {
372 let level = if indent_to_level.is_empty() {
373 level_indents.entry(1).or_default().push(*indent);
375 1
376 } else {
377 let mut determined_level = 0;
379
380 if let Some(&(existing_level, _)) = indent_to_level.get(indent) {
382 determined_level = existing_level;
383 } else {
384 let mut best_parent: Option<(usize, usize, usize)> = None; for (&tracked_indent, &(tracked_level, tracked_line)) in &indent_to_level {
390 if tracked_indent < *indent {
391 if best_parent.is_none() || tracked_indent > best_parent.unwrap().0 {
394 best_parent = Some((tracked_indent, tracked_level, tracked_line));
395 }
396 }
397 }
398
399 if let Some((parent_indent, parent_level, _parent_line)) = best_parent {
400 if parent_indent + Self::MIN_CHILD_INDENT_INCREASE <= *indent {
402 determined_level = parent_level + 1;
404 } else if (*indent as i32 - parent_indent as i32).abs() <= Self::SAME_LEVEL_TOLERANCE {
405 determined_level = parent_level;
407 } else {
408 let mut found_similar = false;
412 if let Some(indents_at_level) = level_indents.get(&parent_level) {
413 for &level_indent in indents_at_level {
414 if (level_indent as i32 - *indent as i32).abs() <= Self::SAME_LEVEL_TOLERANCE {
415 determined_level = parent_level;
416 found_similar = true;
417 break;
418 }
419 }
420 }
421 if !found_similar {
422 determined_level = parent_level + 1;
424 }
425 }
426 }
427
428 if determined_level == 0 {
430 determined_level = 1;
431 }
432
433 level_indents.entry(determined_level).or_default().push(*indent);
435 }
436
437 determined_level
438 };
439
440 level_map.insert(*line_num, level);
441 indent_to_level.insert(*indent, (level, *line_num));
443 }
444
445 let mut level_groups: HashMap<usize, Vec<(usize, usize, &crate::lint_context::LineInfo)>> = HashMap::new();
447 for (line_num, indent, line_info, _) in &all_list_items {
448 let level = level_map[line_num];
449 level_groups
450 .entry(level)
451 .or_default()
452 .push((*line_num, *indent, *line_info));
453 }
454
455 for (level, group) in level_groups {
457 if level != 1 && group.len() < 2 {
460 continue;
461 }
462
463 let mut group = group;
465 group.sort_by_key(|(line_num, _, _)| *line_num);
466
467 let indents: std::collections::HashSet<usize> = group.iter().map(|(_, indent, _)| *indent).collect();
469
470 let has_issue = if level == 1 {
473 indents.iter().any(|&indent| indent != self.top_level_indent)
475 } else {
476 indents.len() > 1
478 };
479
480 if has_issue {
481 let expected_indent = if level == 1 {
487 self.top_level_indent
488 } else {
489 if self.md007_indent > 0 {
492 (level - 1) * self.md007_indent
495 } else {
496 let mut indent_counts: HashMap<usize, usize> = HashMap::new();
498 for (_, indent, _) in &group {
499 *indent_counts.entry(*indent).or_insert(0) += 1;
500 }
501
502 if indent_counts.len() == 1 {
503 *indent_counts.keys().next().unwrap()
505 } else {
506 indent_counts
510 .iter()
511 .max_by(|(indent_a, count_a), (indent_b, count_b)| {
512 count_a.cmp(count_b).then(indent_b.cmp(indent_a))
514 })
515 .map(|(indent, _)| *indent)
516 .unwrap()
517 }
518 }
519 };
520
521 for (line_num, indent, line_info) in &group {
523 if *indent != expected_indent {
524 let message = format!(
525 "Expected indentation of {} {}, found {}",
526 expected_indent,
527 if expected_indent == 1 { "space" } else { "spaces" },
528 indent
529 );
530
531 let (start_line, start_col, end_line, end_col) = if *indent > 0 {
532 calculate_match_range(*line_num, line_info.content(ctx.content), 0, *indent)
533 } else {
534 calculate_match_range(*line_num, line_info.content(ctx.content), 0, 1)
535 };
536
537 let fix_range = if *indent > 0 {
538 let start_byte = ctx.line_offsets.get(line_num - 1).copied().unwrap_or(0);
539 let end_byte = start_byte + *indent;
540 start_byte..end_byte
541 } else {
542 let byte_pos = ctx.line_offsets.get(line_num - 1).copied().unwrap_or(0);
543 byte_pos..byte_pos
544 };
545
546 let replacement = if expected_indent > 0 {
547 " ".repeat(expected_indent)
548 } else {
549 String::new()
550 };
551
552 warnings.push(LintWarning {
553 rule_name: Some(self.name().to_string()),
554 line: start_line,
555 column: start_col,
556 end_line,
557 end_column: end_col,
558 message,
559 severity: Severity::Warning,
560 fix: Some(Fix {
561 range: fix_range,
562 replacement,
563 }),
564 });
565 }
566 }
567 }
568 }
569
570 Ok(())
571 }
572
573 fn check_optimized(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
575 let content = ctx.content;
576
577 if content.is_empty() {
579 return Ok(Vec::new());
580 }
581
582 if ctx.list_blocks.is_empty() {
584 return Ok(Vec::new());
585 }
586
587 let mut warnings = Vec::new();
588
589 let block_groups = self.group_related_list_blocks(&ctx.list_blocks);
592
593 for group in block_groups {
594 self.check_list_block_group(ctx, &group, &mut warnings)?;
595 }
596
597 Ok(warnings)
598 }
599}
600
601impl Rule for MD005ListIndent {
602 fn name(&self) -> &'static str {
603 "MD005"
604 }
605
606 fn description(&self) -> &'static str {
607 "List indentation should be consistent"
608 }
609
610 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
611 self.check_optimized(ctx)
613 }
614
615 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
616 let warnings = self.check(ctx)?;
617 if warnings.is_empty() {
618 return Ok(ctx.content.to_string());
619 }
620
621 let mut warnings_with_fixes: Vec<_> = warnings
623 .into_iter()
624 .filter_map(|w| w.fix.clone().map(|fix| (w, fix)))
625 .collect();
626 warnings_with_fixes.sort_by_key(|(_, fix)| std::cmp::Reverse(fix.range.start));
627
628 let mut content = ctx.content.to_string();
630 for (_, fix) in warnings_with_fixes {
631 if fix.range.start <= content.len() && fix.range.end <= content.len() {
632 content.replace_range(fix.range, &fix.replacement);
633 }
634 }
635
636 Ok(content)
637 }
638
639 fn category(&self) -> RuleCategory {
640 RuleCategory::List
641 }
642
643 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
645 ctx.content.is_empty() || !ctx.lines.iter().any(|line| line.list_item.is_some())
647 }
648
649 fn as_any(&self) -> &dyn std::any::Any {
650 self
651 }
652
653 fn default_config_section(&self) -> Option<(String, toml::Value)> {
654 None
655 }
656
657 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
658 where
659 Self: Sized,
660 {
661 let mut top_level_indent = 0;
663 let mut md007_indent = 2; if let Some(md007_config) = config.rules.get("MD007") {
667 if let Some(start_indented) = md007_config.values.get("start-indented")
669 && let Some(start_indented_bool) = start_indented.as_bool()
670 && start_indented_bool
671 {
672 if let Some(start_indent) = md007_config.values.get("start-indent") {
674 if let Some(indent_value) = start_indent.as_integer() {
675 top_level_indent = indent_value as usize;
676 }
677 } else {
678 top_level_indent = 2;
680 }
681 }
682
683 if let Some(indent) = md007_config.values.get("indent")
685 && let Some(indent_value) = indent.as_integer()
686 {
687 md007_indent = indent_value as usize;
688 }
689 }
690
691 Box::new(MD005ListIndent {
692 top_level_indent,
693 md007_indent,
694 })
695 }
696}
697
698#[cfg(test)]
699mod tests {
700 use super::*;
701 use crate::lint_context::LintContext;
702
703 #[test]
704 fn test_valid_unordered_list() {
705 let rule = MD005ListIndent::default();
706 let content = "\
707* Item 1
708* Item 2
709 * Nested 1
710 * Nested 2
711* Item 3";
712 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
713 let result = rule.check(&ctx).unwrap();
714 assert!(result.is_empty());
715 }
716
717 #[test]
718 fn test_valid_ordered_list() {
719 let rule = MD005ListIndent::default();
720 let content = "\
7211. Item 1
7222. Item 2
723 1. Nested 1
724 2. Nested 2
7253. Item 3";
726 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
727 let result = rule.check(&ctx).unwrap();
728 assert!(result.is_empty());
731 }
732
733 #[test]
734 fn test_invalid_unordered_indent() {
735 let rule = MD005ListIndent::default();
736 let content = "\
737* Item 1
738 * Item 2
739 * Nested 1";
740 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
741 let result = rule.check(&ctx).unwrap();
742 assert_eq!(result.len(), 1);
745 let fixed = rule.fix(&ctx).unwrap();
746 assert_eq!(fixed, "* Item 1\n* Item 2\n * Nested 1");
747 }
748
749 #[test]
750 fn test_invalid_ordered_indent() {
751 let rule = MD005ListIndent::default();
752 let content = "\
7531. Item 1
754 2. Item 2
755 1. Nested 1";
756 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
757 let result = rule.check(&ctx).unwrap();
758 assert_eq!(result.len(), 1);
759 let fixed = rule.fix(&ctx).unwrap();
760 assert_eq!(fixed, "1. Item 1\n2. Item 2\n 1. Nested 1");
764 }
765
766 #[test]
767 fn test_mixed_list_types() {
768 let rule = MD005ListIndent::default();
769 let content = "\
770* Item 1
771 1. Nested ordered
772 * Nested unordered
773* Item 2";
774 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
775 let result = rule.check(&ctx).unwrap();
776 assert!(result.is_empty());
777 }
778
779 #[test]
780 fn test_multiple_levels() {
781 let rule = MD005ListIndent::default();
782 let content = "\
783* Level 1
784 * Level 2
785 * Level 3";
786 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
787 let result = rule.check(&ctx).unwrap();
788 assert!(result.is_empty(), "MD005 should accept consistent indentation pattern");
790 }
791
792 #[test]
793 fn test_empty_lines() {
794 let rule = MD005ListIndent::default();
795 let content = "\
796* Item 1
797
798 * Nested 1
799
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_no_lists() {
808 let rule = MD005ListIndent::default();
809 let content = "\
810Just some text
811More text
812Even more text";
813 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
814 let result = rule.check(&ctx).unwrap();
815 assert!(result.is_empty());
816 }
817
818 #[test]
819 fn test_complex_nesting() {
820 let rule = MD005ListIndent::default();
821 let content = "\
822* Level 1
823 * Level 2
824 * Level 3
825 * Back to 2
826 1. Ordered 3
827 2. Still 3
828* Back to 1";
829 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
830 let result = rule.check(&ctx).unwrap();
831 assert!(result.is_empty());
832 }
833
834 #[test]
835 fn test_invalid_complex_nesting() {
836 let rule = MD005ListIndent::default();
837 let content = "\
838* Level 1
839 * Level 2
840 * Level 3
841 * Back to 2
842 1. Ordered 3
843 2. Still 3
844* Back to 1";
845 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
846 let result = rule.check(&ctx).unwrap();
847 assert_eq!(result.len(), 1);
849 assert!(
850 result[0].message.contains("Expected indentation of 5 spaces, found 6")
851 || result[0].message.contains("Expected indentation of 6 spaces, found 5")
852 );
853 }
854
855 #[test]
856 fn test_with_lint_context() {
857 let rule = MD005ListIndent::default();
858
859 let content = "* Item 1\n* Item 2\n * Nested item\n * Another nested item";
861 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
862 let result = rule.check(&ctx).unwrap();
863 assert!(result.is_empty());
864
865 let content = "* Item 1\n* Item 2\n * Nested item\n * Another nested item";
867 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
868 let result = rule.check(&ctx).unwrap();
869 assert!(!result.is_empty()); let content = "* Item 1\n * Nested item\n * Another nested item with wrong indent";
873 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
874 let result = rule.check(&ctx).unwrap();
875 assert!(!result.is_empty()); }
877
878 #[test]
880 fn test_list_with_continuations() {
881 let rule = MD005ListIndent::default();
882 let content = "\
883* Item 1
884 This is a continuation
885 of the first item
886 * Nested item
887 with its own continuation
888* Item 2";
889 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
890 let result = rule.check(&ctx).unwrap();
891 assert!(result.is_empty());
892 }
893
894 #[test]
895 fn test_list_in_blockquote() {
896 let rule = MD005ListIndent::default();
897 let content = "\
898> * Item 1
899> * Nested 1
900> * Nested 2
901> * Item 2";
902 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
903 let result = rule.check(&ctx).unwrap();
904
905 assert!(
907 result.is_empty(),
908 "Expected no warnings for correctly indented blockquote list, got: {result:?}"
909 );
910 }
911
912 #[test]
913 fn test_list_with_code_blocks() {
914 let rule = MD005ListIndent::default();
915 let content = "\
916* Item 1
917 ```
918 code block
919 ```
920 * Nested item
921* Item 2";
922 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
923 let result = rule.check(&ctx).unwrap();
924 assert!(result.is_empty());
925 }
926
927 #[test]
928 fn test_list_with_tabs() {
929 let rule = MD005ListIndent::default();
930 let content = "* Item 1\n\t* Tab indented\n * Space indented";
931 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
932 let result = rule.check(&ctx).unwrap();
933 assert!(!result.is_empty());
935 }
936
937 #[test]
938 fn test_inconsistent_at_same_level() {
939 let rule = MD005ListIndent::default();
940 let content = "\
941* Item 1
942 * Nested 1
943 * Nested 2
944 * Wrong indent for same level
945 * Nested 3";
946 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
947 let result = rule.check(&ctx).unwrap();
948 assert!(!result.is_empty());
949 assert!(result.iter().any(|w| w.line == 4));
951 }
952
953 #[test]
954 fn test_zero_indent_top_level() {
955 let rule = MD005ListIndent::default();
956 let content = concat!(" * Wrong indent\n", "* Correct\n", " * Nested");
958 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
959 let result = rule.check(&ctx).unwrap();
960
961 assert!(!result.is_empty());
963 assert!(result.iter().any(|w| w.line == 1));
964 }
965
966 #[test]
967 fn test_fix_preserves_content() {
968 let rule = MD005ListIndent::default();
969 let content = "\
970* Item with **bold** and *italic*
971 * Wrong indent with `code`
972 * Also wrong with [link](url)";
973 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
974 let fixed = rule.fix(&ctx).unwrap();
975 assert!(fixed.contains("**bold**"));
976 assert!(fixed.contains("*italic*"));
977 assert!(fixed.contains("`code`"));
978 assert!(fixed.contains("[link](url)"));
979 }
980
981 #[test]
982 fn test_deeply_nested_lists() {
983 let rule = MD005ListIndent::default();
984 let content = "\
985* L1
986 * L2
987 * L3
988 * L4
989 * L5
990 * L6";
991 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
992 let result = rule.check(&ctx).unwrap();
993 assert!(result.is_empty());
994 }
995
996 #[test]
997 fn test_fix_multiple_issues() {
998 let rule = MD005ListIndent::default();
999 let content = "\
1000* Item 1
1001 * Wrong 1
1002 * Wrong 2
1003 * Wrong 3
1004 * Correct
1005 * Wrong 4";
1006 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1007 let fixed = rule.fix(&ctx).unwrap();
1008 let lines: Vec<&str> = fixed.lines().collect();
1010 assert_eq!(lines[0], "* Item 1");
1011 assert!(lines[1].starts_with(" * ") || lines[1].starts_with("* "));
1013 }
1014
1015 #[test]
1016 fn test_performance_large_document() {
1017 let rule = MD005ListIndent::default();
1018 let mut content = String::new();
1019 for i in 0..100 {
1020 content.push_str(&format!("* Item {i}\n"));
1021 content.push_str(&format!(" * Nested {i}\n"));
1022 }
1023 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard);
1024 let result = rule.check(&ctx).unwrap();
1025 assert!(result.is_empty());
1026 }
1027
1028 #[test]
1029 fn test_column_positions() {
1030 let rule = MD005ListIndent::default();
1031 let content = " * Wrong indent";
1032 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1033 let result = rule.check(&ctx).unwrap();
1034 assert_eq!(result.len(), 1);
1035 assert_eq!(result[0].column, 1, "Expected column 1, got {}", result[0].column);
1036 assert_eq!(
1037 result[0].end_column, 2,
1038 "Expected end_column 2, got {}",
1039 result[0].end_column
1040 );
1041 }
1042
1043 #[test]
1044 fn test_should_skip() {
1045 let rule = MD005ListIndent::default();
1046
1047 let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard);
1049 assert!(rule.should_skip(&ctx));
1050
1051 let ctx = LintContext::new("Just plain text", crate::config::MarkdownFlavor::Standard);
1053 assert!(rule.should_skip(&ctx));
1054
1055 let ctx = LintContext::new("* List item", crate::config::MarkdownFlavor::Standard);
1057 assert!(!rule.should_skip(&ctx));
1058
1059 let ctx = LintContext::new("1. Ordered list", crate::config::MarkdownFlavor::Standard);
1060 assert!(!rule.should_skip(&ctx));
1061 }
1062
1063 #[test]
1064 fn test_should_skip_validation() {
1065 let rule = MD005ListIndent::default();
1066 let content = "* List item";
1067 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1068 assert!(!rule.should_skip(&ctx));
1069
1070 let content = "No lists here";
1071 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1072 assert!(rule.should_skip(&ctx));
1073 }
1074
1075 #[test]
1076 fn test_edge_case_single_space_indent() {
1077 let rule = MD005ListIndent::default();
1078 let content = "\
1079* Item 1
1080 * Single space - wrong
1081 * Two spaces - correct";
1082 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1083 let result = rule.check(&ctx).unwrap();
1084 assert_eq!(result.len(), 2);
1087 assert!(result.iter().any(|w| w.line == 2 && w.message.contains("found 1")));
1088 }
1089
1090 #[test]
1091 fn test_edge_case_three_space_indent() {
1092 let rule = MD005ListIndent::default();
1093 let content = "\
1094* Item 1
1095 * Three spaces - wrong
1096 * Two spaces - correct";
1097 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1098 let result = rule.check(&ctx).unwrap();
1099 assert_eq!(result.len(), 1);
1101 assert!(result.iter().any(|w| w.line == 2 && w.message.contains("found 3")));
1102 }
1103
1104 #[test]
1105 fn test_nested_bullets_under_numbered_items() {
1106 let rule = MD005ListIndent::default();
1107 let content = "\
11081. **Active Directory/LDAP**
1109 - User authentication and directory services
1110 - LDAP for user information and validation
1111
11122. **Oracle Unified Directory (OUD)**
1113 - Extended user directory services
1114 - Verification of project account presence and changes";
1115 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1116 let result = rule.check(&ctx).unwrap();
1117 assert!(
1119 result.is_empty(),
1120 "Expected no warnings for bullets with 3 spaces under numbered items, got: {result:?}"
1121 );
1122 }
1123
1124 #[test]
1125 fn test_nested_bullets_under_numbered_items_wrong_indent() {
1126 let rule = MD005ListIndent::default();
1127 let content = "\
11281. **Active Directory/LDAP**
1129 - Wrong: only 2 spaces
1130 - Correct: 3 spaces";
1131 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1132 let result = rule.check(&ctx).unwrap();
1133 assert_eq!(
1135 result.len(),
1136 1,
1137 "Expected 1 warning, got {}. Warnings: {:?}",
1138 result.len(),
1139 result
1140 );
1141 assert!(
1143 result
1144 .iter()
1145 .any(|w| (w.line == 2 && w.message.contains("found 2"))
1146 || (w.line == 3 && w.message.contains("found 3")))
1147 );
1148 }
1149
1150 #[test]
1151 fn test_regular_nested_bullets_still_work() {
1152 let rule = MD005ListIndent::default();
1153 let content = "\
1154* Top level
1155 * Second level (2 spaces is correct for bullets under bullets)
1156 * Third level (4 spaces)";
1157 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1158 let result = rule.check(&ctx).unwrap();
1159 assert!(
1161 result.is_empty(),
1162 "Expected no warnings for regular bullet nesting, got: {result:?}"
1163 );
1164 }
1165
1166 #[test]
1167 fn test_fix_range_accuracy() {
1168 let rule = MD005ListIndent::default();
1169 let content = " * Wrong indent";
1170 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1171 let result = rule.check(&ctx).unwrap();
1172 assert_eq!(result.len(), 1);
1173
1174 let fix = result[0].fix.as_ref().unwrap();
1175 assert_eq!(fix.replacement, "");
1177 }
1178
1179 #[test]
1180 fn test_four_space_indent_pattern() {
1181 let rule = MD005ListIndent::default();
1182 let content = "\
1183* Item 1
1184 * Item 2 with 4 spaces
1185 * Item 3 with 8 spaces
1186 * Item 4 with 4 spaces";
1187 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1188 let result = rule.check(&ctx).unwrap();
1189 assert!(
1191 result.is_empty(),
1192 "MD005 should accept consistent 4-space indentation pattern, got {} warnings",
1193 result.len()
1194 );
1195 }
1196
1197 #[test]
1198 fn test_issue_64_scenario() {
1199 let rule = MD005ListIndent::default();
1201 let content = "\
1202* Top level item
1203 * Sub item with 4 spaces (as configured in MD007)
1204 * Nested sub item with 8 spaces
1205 * Another sub item with 4 spaces
1206* Another top level";
1207
1208 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1209 let result = rule.check(&ctx).unwrap();
1210
1211 assert!(
1213 result.is_empty(),
1214 "MD005 should accept 4-space indentation when that's the pattern being used. Got {} warnings",
1215 result.len()
1216 );
1217 }
1218
1219 #[test]
1220 fn test_continuation_content_scenario() {
1221 let rule = MD005ListIndent::default();
1222 let content = "\
1223- **Changes to how the Python version is inferred** ([#16319](example))
1224
1225 In previous versions of Ruff, you could specify your Python version with:
1226
1227 - The `target-version` option in a `ruff.toml` file
1228 - The `project.requires-python` field in a `pyproject.toml` file";
1229
1230 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1231
1232 let result = rule.check(&ctx).unwrap();
1233
1234 assert!(
1236 result.is_empty(),
1237 "MD005 should not flag continuation content lists, got {} warnings: {:?}",
1238 result.len(),
1239 result
1240 );
1241 }
1242
1243 #[test]
1244 fn test_multiple_continuation_lists_scenario() {
1245 let rule = MD005ListIndent::default();
1246 let content = "\
1247- **Changes to how the Python version is inferred** ([#16319](example))
1248
1249 In previous versions of Ruff, you could specify your Python version with:
1250
1251 - The `target-version` option in a `ruff.toml` file
1252 - The `project.requires-python` field in a `pyproject.toml` file
1253
1254 In v0.10, config discovery has been updated to address this issue:
1255
1256 - If Ruff finds a `ruff.toml` file without a `target-version`, it will check
1257 - If Ruff finds a user-level configuration, the `requires-python` field will take precedence
1258 - If there is no config file, Ruff will search for the closest `pyproject.toml`";
1259
1260 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1261
1262 let result = rule.check(&ctx).unwrap();
1263
1264 assert!(
1266 result.is_empty(),
1267 "MD005 should not flag continuation content lists, got {} warnings: {:?}",
1268 result.len(),
1269 result
1270 );
1271 }
1272
1273 #[test]
1274 fn test_issue_115_sublist_after_code_block() {
1275 let rule = MD005ListIndent::default();
1276 let content = "\
12771. List item 1
1278
1279 ```rust
1280 fn foo() {}
1281 ```
1282
1283 Sublist:
1284
1285 - A
1286 - B
1287";
1288 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1289 let result = rule.check(&ctx).unwrap();
1290 assert!(
1294 result.is_empty(),
1295 "Expected no warnings for sub-list after code block in list item, got {} warnings: {:?}",
1296 result.len(),
1297 result
1298 );
1299 }
1300
1301 #[test]
1302 fn test_edge_case_continuation_at_exact_boundary() {
1303 let rule = MD005ListIndent::default();
1304 let content = "\
1306* Item (content at column 2)
1307 Text at column 2 (exact boundary - continuation)
1308 * Sub at column 2";
1309 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1310 let result = rule.check(&ctx).unwrap();
1311 assert!(
1313 result.is_empty(),
1314 "Expected no warnings when text and sub-list are at exact parent content_column, got: {result:?}"
1315 );
1316 }
1317
1318 #[test]
1319 fn test_edge_case_unicode_in_continuation() {
1320 let rule = MD005ListIndent::default();
1321 let content = "\
1322* Parent
1323 Text with emoji 😀 and Unicode ñ characters
1324 * Sub-list should still work";
1325 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1326 let result = rule.check(&ctx).unwrap();
1327 assert!(
1329 result.is_empty(),
1330 "Expected no warnings with Unicode in continuation content, got: {result:?}"
1331 );
1332 }
1333
1334 #[test]
1335 fn test_edge_case_large_empty_line_gap() {
1336 let rule = MD005ListIndent::default();
1337 let content = "\
1338* Parent at line 1
1339 Continuation text
1340
1341
1342
1343 More continuation after many empty lines
1344
1345 * Child after gap
1346 * Another child";
1347 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1348 let result = rule.check(&ctx).unwrap();
1349 assert!(
1351 result.is_empty(),
1352 "Expected no warnings with large gaps in continuation content, got: {result:?}"
1353 );
1354 }
1355
1356 #[test]
1357 fn test_edge_case_multiple_continuation_blocks_varying_indent() {
1358 let rule = MD005ListIndent::default();
1359 let content = "\
1360* Parent (content at column 2)
1361 First paragraph at column 2
1362 Indented quote at column 4
1363 Back to column 2
1364 * Sub-list at column 2";
1365 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1366 let result = rule.check(&ctx).unwrap();
1367 assert!(
1369 result.is_empty(),
1370 "Expected no warnings with varying continuation indent, got: {result:?}"
1371 );
1372 }
1373
1374 #[test]
1375 fn test_edge_case_deep_nesting_no_continuation() {
1376 let rule = MD005ListIndent::default();
1377 let content = "\
1378* Parent
1379 * Immediate child (no continuation text before)
1380 * Grandchild
1381 * Great-grandchild
1382 * Great-great-grandchild
1383 * Another child at level 2";
1384 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1385 let result = rule.check(&ctx).unwrap();
1386 assert!(
1388 result.is_empty(),
1389 "Expected no warnings for deep nesting without continuation, got: {result:?}"
1390 );
1391 }
1392
1393 #[test]
1394 fn test_edge_case_blockquote_continuation_content() {
1395 let rule = MD005ListIndent::default();
1396 let content = "\
1397> * Parent in blockquote
1398> Continuation in blockquote
1399> * Sub-list in blockquote
1400> * Another sub-list";
1401 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1402 let result = rule.check(&ctx).unwrap();
1403 assert!(
1405 result.is_empty(),
1406 "Expected no warnings for blockquote continuation, got: {result:?}"
1407 );
1408 }
1409
1410 #[test]
1411 fn test_edge_case_one_space_less_than_content_column() {
1412 let rule = MD005ListIndent::default();
1413 let content = "\
1414* Parent (content at column 2)
1415 Text at column 1 (one less than content_column - NOT continuation)
1416 * Child";
1417 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1418 let result = rule.check(&ctx).unwrap();
1419 assert!(
1425 result.is_empty() || !result.is_empty(),
1426 "Test should complete without panic"
1427 );
1428 }
1429
1430 #[test]
1431 fn test_edge_case_multiple_code_blocks_different_indentation() {
1432 let rule = MD005ListIndent::default();
1433 let content = "\
1434* Parent
1435 ```
1436 code at 2 spaces
1437 ```
1438 ```
1439 code at 4 spaces
1440 ```
1441 * Sub-list should not be confused";
1442 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1443 let result = rule.check(&ctx).unwrap();
1444 assert!(
1446 result.is_empty(),
1447 "Expected no warnings with multiple code blocks, got: {result:?}"
1448 );
1449 }
1450
1451 #[test]
1452 fn test_performance_very_large_document() {
1453 let rule = MD005ListIndent::default();
1454 let mut content = String::new();
1455
1456 for i in 0..1000 {
1458 content.push_str(&format!("* Item {i}\n"));
1459 content.push_str(&format!(" * Nested {i}\n"));
1460 if i % 10 == 0 {
1461 content.push_str(" Some continuation text\n");
1462 }
1463 }
1464
1465 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard);
1466
1467 let start = std::time::Instant::now();
1469 let result = rule.check(&ctx).unwrap();
1470 let elapsed = start.elapsed();
1471
1472 assert!(result.is_empty());
1473 println!("Processed 1000 list items in {elapsed:?}");
1474 assert!(
1477 elapsed.as_secs() < 1,
1478 "Should complete in under 1 second, took {elapsed:?}"
1479 );
1480 }
1481}