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}
29
30const FLAG_HAS_CONTENT: u8 = 1;
31const FLAG_IS_LIST_ITEM: u8 = 2;
32
33impl LineCacheInfo {
34 fn new(ctx: &crate::lint_context::LintContext) -> Self {
36 let total_lines = ctx.lines.len();
37 let mut indentation = Vec::with_capacity(total_lines);
38 let mut flags = Vec::with_capacity(total_lines);
39
40 for line_info in &ctx.lines {
41 let content = line_info.content(ctx.content).trim_start();
42 let line_indent = line_info.byte_len - content.len();
43
44 indentation.push(line_indent);
45
46 let mut flag = 0u8;
47 if !content.is_empty() {
48 flag |= FLAG_HAS_CONTENT;
49 }
50 if line_info.list_item.is_some() {
51 flag |= FLAG_IS_LIST_ITEM;
52 }
53 flags.push(flag);
54 }
55
56 Self { indentation, flags }
57 }
58
59 fn has_content(&self, idx: usize) -> bool {
61 self.flags.get(idx).is_some_and(|&f| f & FLAG_HAS_CONTENT != 0)
62 }
63
64 fn is_list_item(&self, idx: usize) -> bool {
66 self.flags.get(idx).is_some_and(|&f| f & FLAG_IS_LIST_ITEM != 0)
67 }
68
69 fn find_continuation_indent(
71 &self,
72 start_line: usize,
73 end_line: usize,
74 parent_content_column: usize,
75 ) -> Option<usize> {
76 if start_line == 0 || start_line > end_line || end_line > self.indentation.len() {
77 return None;
78 }
79
80 let start_idx = start_line - 1;
82 let end_idx = end_line - 1;
83
84 for idx in start_idx..=end_idx {
85 if !self.has_content(idx) || self.is_list_item(idx) {
87 continue;
88 }
89
90 if self.indentation[idx] >= parent_content_column {
93 return Some(self.indentation[idx]);
94 }
95 }
96 None
97 }
98
99 fn has_continuation_content(&self, parent_line: usize, current_line: usize, parent_content_column: usize) -> bool {
101 if parent_line == 0 || current_line <= parent_line || current_line > self.indentation.len() {
102 return false;
103 }
104
105 let start_idx = parent_line; let end_idx = current_line - 2; if start_idx > end_idx {
110 return false;
111 }
112
113 for idx in start_idx..=end_idx {
114 if !self.has_content(idx) || self.is_list_item(idx) {
116 continue;
117 }
118
119 if self.indentation[idx] >= parent_content_column {
122 return true;
123 }
124 }
125 false
126 }
127}
128
129impl MD005ListIndent {
130 const LIST_GROUP_GAP_TOLERANCE: usize = 2;
134
135 const MIN_CHILD_INDENT_INCREASE: usize = 2;
138
139 const SAME_LEVEL_TOLERANCE: i32 = 1;
142
143 const STANDARD_CONTINUATION_OFFSET: usize = 2;
146
147 fn group_related_list_blocks<'a>(
149 &self,
150 list_blocks: &'a [crate::lint_context::ListBlock],
151 ) -> Vec<Vec<&'a crate::lint_context::ListBlock>> {
152 if list_blocks.is_empty() {
153 return Vec::new();
154 }
155
156 let mut groups = Vec::new();
157 let mut current_group = vec![&list_blocks[0]];
158
159 for i in 1..list_blocks.len() {
160 let prev_block = &list_blocks[i - 1];
161 let current_block = &list_blocks[i];
162
163 let line_gap = current_block.start_line.saturating_sub(prev_block.end_line);
165
166 if line_gap <= Self::LIST_GROUP_GAP_TOLERANCE {
169 current_group.push(current_block);
170 } else {
171 groups.push(current_group);
173 current_group = vec![current_block];
174 }
175 }
176 groups.push(current_group);
177
178 groups
179 }
180
181 fn is_continuation_content(
184 &self,
185 ctx: &crate::lint_context::LintContext,
186 cache: &LineCacheInfo,
187 list_line: usize,
188 list_indent: usize,
189 ) -> bool {
190 for line_num in (1..list_line).rev() {
192 if let Some(line_info) = ctx.line_info(line_num) {
193 if let Some(parent_list_item) = &line_info.list_item {
194 let parent_marker_column = parent_list_item.marker_column;
195 let parent_content_column = parent_list_item.content_column;
196
197 if parent_marker_column >= list_indent {
199 continue;
200 }
201
202 let continuation_indent =
205 cache.find_continuation_indent(line_num + 1, list_line - 1, parent_content_column);
206
207 if let Some(continuation_indent) = continuation_indent {
208 let is_standard_continuation =
209 list_indent == parent_content_column + Self::STANDARD_CONTINUATION_OFFSET;
210 let matches_content_indent = list_indent == continuation_indent;
211
212 if matches_content_indent || is_standard_continuation {
213 return true;
214 }
215 }
216
217 if list_indent > parent_marker_column {
220 if self.has_continuation_list_at_indent(
222 ctx,
223 cache,
224 line_num,
225 list_line,
226 list_indent,
227 parent_content_column,
228 ) {
229 return true;
230 }
231
232 if cache.has_continuation_content(line_num, list_line, parent_content_column) {
234 return true;
235 }
236 }
237
238 } else if !line_info.content(ctx.content).trim().is_empty() {
241 let content = line_info.content(ctx.content).trim_start();
243 let line_indent = line_info.byte_len - content.len();
244
245 if line_indent == 0 {
246 break;
247 }
248 }
249 }
250 }
251 false
252 }
253
254 fn has_continuation_list_at_indent(
256 &self,
257 ctx: &crate::lint_context::LintContext,
258 cache: &LineCacheInfo,
259 parent_line: usize,
260 current_line: usize,
261 list_indent: usize,
262 parent_content_column: usize,
263 ) -> bool {
264 for line_num in (parent_line + 1)..current_line {
267 if let Some(line_info) = ctx.line_info(line_num)
268 && let Some(list_item) = &line_info.list_item
269 && list_item.marker_column == list_indent
270 {
271 if cache
274 .find_continuation_indent(parent_line + 1, line_num - 1, parent_content_column)
275 .is_some()
276 {
277 return true;
278 }
279 }
280 }
281 false
282 }
283
284 fn check_list_block_group(
286 &self,
287 ctx: &crate::lint_context::LintContext,
288 group: &[&crate::lint_context::ListBlock],
289 warnings: &mut Vec<LintWarning>,
290 ) -> Result<(), LintError> {
291 let cache = LineCacheInfo::new(ctx);
293
294 let mut all_list_items = Vec::new();
296
297 for list_block in group {
298 for &item_line in &list_block.item_lines {
299 if let Some(line_info) = ctx.line_info(item_line)
300 && let Some(list_item) = &line_info.list_item
301 {
302 let effective_indent = if let Some(blockquote) = &line_info.blockquote {
304 list_item.marker_column.saturating_sub(blockquote.nesting_level * 2)
306 } else {
307 list_item.marker_column
309 };
310
311 if self.is_continuation_content(ctx, &cache, item_line, effective_indent) {
313 continue;
314 }
315
316 all_list_items.push((item_line, effective_indent, line_info, list_item));
317 }
318 }
319 }
320
321 if all_list_items.is_empty() {
322 return Ok(());
323 }
324
325 all_list_items.sort_by_key(|(line_num, _, _, _)| *line_num);
327
328 let mut level_map: HashMap<usize, usize> = HashMap::new();
332 let mut level_indents: HashMap<usize, Vec<usize>> = HashMap::new(); for i in 0..all_list_items.len() {
336 let (line_num, indent, _, _) = &all_list_items[i];
337
338 let level = if i == 0 {
339 level_indents.entry(1).or_default().push(*indent);
341 1
342 } else {
343 let mut determined_level = 0;
345
346 for (lvl, indents) in &level_indents {
348 if indents.contains(indent) {
349 determined_level = *lvl;
350 break;
351 }
352 }
353
354 if determined_level == 0 {
355 for j in (0..i).rev() {
358 let (prev_line, prev_indent, _, _) = &all_list_items[j];
359 let prev_level = level_map[prev_line];
360
361 if *prev_indent + Self::MIN_CHILD_INDENT_INCREASE <= *indent {
363 determined_level = prev_level + 1;
365 break;
366 } else if (*prev_indent as i32 - *indent as i32).abs() <= Self::SAME_LEVEL_TOLERANCE {
367 determined_level = prev_level;
369 break;
370 } else if *prev_indent < *indent {
371 if let Some(indents_at_level) = level_indents.get(&prev_level) {
376 for &level_indent in indents_at_level {
378 if (level_indent as i32 - *indent as i32).abs() <= Self::SAME_LEVEL_TOLERANCE {
379 determined_level = prev_level;
381 break;
382 }
383 }
384 }
385 if determined_level == 0 {
386 determined_level = prev_level + 1;
388 }
389 break;
390 }
391 }
392
393 if determined_level == 0 {
395 determined_level = 1;
396 }
397
398 level_indents.entry(determined_level).or_default().push(*indent);
400 }
401
402 determined_level
403 };
404
405 level_map.insert(*line_num, level);
406 }
407
408 let mut level_groups: HashMap<usize, Vec<(usize, usize, &crate::lint_context::LineInfo)>> = HashMap::new();
410 for (line_num, indent, line_info, _) in &all_list_items {
411 let level = level_map[line_num];
412 level_groups
413 .entry(level)
414 .or_default()
415 .push((*line_num, *indent, *line_info));
416 }
417
418 for (level, group) in level_groups {
420 if level != 1 && group.len() < 2 {
423 continue;
424 }
425
426 let mut group = group;
428 group.sort_by_key(|(line_num, _, _)| *line_num);
429
430 let indents: std::collections::HashSet<usize> = group.iter().map(|(_, indent, _)| *indent).collect();
432
433 let has_issue = if level == 1 {
436 indents.iter().any(|&indent| indent != self.top_level_indent)
438 } else {
439 indents.len() > 1
441 };
442
443 if has_issue {
444 let expected_indent = if level == 1 {
450 self.top_level_indent
451 } else {
452 if self.md007_indent > 0 {
455 (level - 1) * self.md007_indent
458 } else {
459 let mut indent_counts: HashMap<usize, usize> = HashMap::new();
461 for (_, indent, _) in &group {
462 *indent_counts.entry(*indent).or_insert(0) += 1;
463 }
464
465 if indent_counts.len() == 1 {
466 *indent_counts.keys().next().unwrap()
468 } else {
469 indent_counts
473 .iter()
474 .max_by(|(indent_a, count_a), (indent_b, count_b)| {
475 count_a.cmp(count_b).then(indent_b.cmp(indent_a))
477 })
478 .map(|(indent, _)| *indent)
479 .unwrap()
480 }
481 }
482 };
483
484 for (line_num, indent, line_info) in &group {
486 if *indent != expected_indent {
487 let message = format!(
488 "Expected indentation of {} {}, found {}",
489 expected_indent,
490 if expected_indent == 1 { "space" } else { "spaces" },
491 indent
492 );
493
494 let (start_line, start_col, end_line, end_col) = if *indent > 0 {
495 calculate_match_range(*line_num, line_info.content(ctx.content), 0, *indent)
496 } else {
497 calculate_match_range(*line_num, line_info.content(ctx.content), 0, 1)
498 };
499
500 let fix_range = if *indent > 0 {
501 let start_byte = ctx.line_offsets.get(line_num - 1).copied().unwrap_or(0);
502 let end_byte = start_byte + *indent;
503 start_byte..end_byte
504 } else {
505 let byte_pos = ctx.line_offsets.get(line_num - 1).copied().unwrap_or(0);
506 byte_pos..byte_pos
507 };
508
509 let replacement = if expected_indent > 0 {
510 " ".repeat(expected_indent)
511 } else {
512 String::new()
513 };
514
515 warnings.push(LintWarning {
516 rule_name: Some(self.name().to_string()),
517 line: start_line,
518 column: start_col,
519 end_line,
520 end_column: end_col,
521 message,
522 severity: Severity::Warning,
523 fix: Some(Fix {
524 range: fix_range,
525 replacement,
526 }),
527 });
528 }
529 }
530 }
531 }
532
533 Ok(())
534 }
535
536 fn check_optimized(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
538 let content = ctx.content;
539
540 if content.is_empty() {
542 return Ok(Vec::new());
543 }
544
545 if ctx.list_blocks.is_empty() {
547 return Ok(Vec::new());
548 }
549
550 let mut warnings = Vec::new();
551
552 let block_groups = self.group_related_list_blocks(&ctx.list_blocks);
555
556 for group in block_groups {
557 self.check_list_block_group(ctx, &group, &mut warnings)?;
558 }
559
560 Ok(warnings)
561 }
562}
563
564impl Rule for MD005ListIndent {
565 fn name(&self) -> &'static str {
566 "MD005"
567 }
568
569 fn description(&self) -> &'static str {
570 "List indentation should be consistent"
571 }
572
573 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
574 self.check_optimized(ctx)
576 }
577
578 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
579 let warnings = self.check(ctx)?;
580 if warnings.is_empty() {
581 return Ok(ctx.content.to_string());
582 }
583
584 let mut warnings_with_fixes: Vec<_> = warnings
586 .into_iter()
587 .filter_map(|w| w.fix.clone().map(|fix| (w, fix)))
588 .collect();
589 warnings_with_fixes.sort_by_key(|(_, fix)| std::cmp::Reverse(fix.range.start));
590
591 let mut content = ctx.content.to_string();
593 for (_, fix) in warnings_with_fixes {
594 if fix.range.start <= content.len() && fix.range.end <= content.len() {
595 content.replace_range(fix.range, &fix.replacement);
596 }
597 }
598
599 Ok(content)
600 }
601
602 fn category(&self) -> RuleCategory {
603 RuleCategory::List
604 }
605
606 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
608 ctx.content.is_empty() || !ctx.lines.iter().any(|line| line.list_item.is_some())
610 }
611
612 fn as_any(&self) -> &dyn std::any::Any {
613 self
614 }
615
616 fn default_config_section(&self) -> Option<(String, toml::Value)> {
617 None
618 }
619
620 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
621 where
622 Self: Sized,
623 {
624 let mut top_level_indent = 0;
626 let mut md007_indent = 2; if let Some(md007_config) = config.rules.get("MD007") {
630 if let Some(start_indented) = md007_config.values.get("start-indented")
632 && let Some(start_indented_bool) = start_indented.as_bool()
633 && start_indented_bool
634 {
635 if let Some(start_indent) = md007_config.values.get("start-indent") {
637 if let Some(indent_value) = start_indent.as_integer() {
638 top_level_indent = indent_value as usize;
639 }
640 } else {
641 top_level_indent = 2;
643 }
644 }
645
646 if let Some(indent) = md007_config.values.get("indent")
648 && let Some(indent_value) = indent.as_integer()
649 {
650 md007_indent = indent_value as usize;
651 }
652 }
653
654 Box::new(MD005ListIndent {
655 top_level_indent,
656 md007_indent,
657 })
658 }
659}
660
661#[cfg(test)]
662mod tests {
663 use super::*;
664 use crate::lint_context::LintContext;
665
666 #[test]
667 fn test_valid_unordered_list() {
668 let rule = MD005ListIndent::default();
669 let content = "\
670* Item 1
671* Item 2
672 * Nested 1
673 * Nested 2
674* Item 3";
675 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
676 let result = rule.check(&ctx).unwrap();
677 assert!(result.is_empty());
678 }
679
680 #[test]
681 fn test_valid_ordered_list() {
682 let rule = MD005ListIndent::default();
683 let content = "\
6841. Item 1
6852. Item 2
686 1. Nested 1
687 2. Nested 2
6883. Item 3";
689 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
690 let result = rule.check(&ctx).unwrap();
691 assert!(result.is_empty());
694 }
695
696 #[test]
697 fn test_invalid_unordered_indent() {
698 let rule = MD005ListIndent::default();
699 let content = "\
700* Item 1
701 * Item 2
702 * Nested 1";
703 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
704 let result = rule.check(&ctx).unwrap();
705 assert_eq!(result.len(), 1);
708 let fixed = rule.fix(&ctx).unwrap();
709 assert_eq!(fixed, "* Item 1\n* Item 2\n * Nested 1");
710 }
711
712 #[test]
713 fn test_invalid_ordered_indent() {
714 let rule = MD005ListIndent::default();
715 let content = "\
7161. Item 1
717 2. Item 2
718 1. Nested 1";
719 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
720 let result = rule.check(&ctx).unwrap();
721 assert_eq!(result.len(), 1);
722 let fixed = rule.fix(&ctx).unwrap();
723 assert_eq!(fixed, "1. Item 1\n2. Item 2\n 1. Nested 1");
727 }
728
729 #[test]
730 fn test_mixed_list_types() {
731 let rule = MD005ListIndent::default();
732 let content = "\
733* Item 1
734 1. Nested ordered
735 * Nested unordered
736* Item 2";
737 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
738 let result = rule.check(&ctx).unwrap();
739 assert!(result.is_empty());
740 }
741
742 #[test]
743 fn test_multiple_levels() {
744 let rule = MD005ListIndent::default();
745 let content = "\
746* Level 1
747 * Level 2
748 * Level 3";
749 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
750 let result = rule.check(&ctx).unwrap();
751 assert!(result.is_empty(), "MD005 should accept consistent indentation pattern");
753 }
754
755 #[test]
756 fn test_empty_lines() {
757 let rule = MD005ListIndent::default();
758 let content = "\
759* Item 1
760
761 * Nested 1
762
763* Item 2";
764 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
765 let result = rule.check(&ctx).unwrap();
766 assert!(result.is_empty());
767 }
768
769 #[test]
770 fn test_no_lists() {
771 let rule = MD005ListIndent::default();
772 let content = "\
773Just some text
774More text
775Even more text";
776 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
777 let result = rule.check(&ctx).unwrap();
778 assert!(result.is_empty());
779 }
780
781 #[test]
782 fn test_complex_nesting() {
783 let rule = MD005ListIndent::default();
784 let content = "\
785* Level 1
786 * Level 2
787 * Level 3
788 * Back to 2
789 1. Ordered 3
790 2. Still 3
791* Back to 1";
792 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
793 let result = rule.check(&ctx).unwrap();
794 assert!(result.is_empty());
795 }
796
797 #[test]
798 fn test_invalid_complex_nesting() {
799 let rule = MD005ListIndent::default();
800 let content = "\
801* Level 1
802 * Level 2
803 * Level 3
804 * Back to 2
805 1. Ordered 3
806 2. Still 3
807* Back to 1";
808 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
809 let result = rule.check(&ctx).unwrap();
810 assert_eq!(result.len(), 1);
812 assert!(
813 result[0].message.contains("Expected indentation of 5 spaces, found 6")
814 || result[0].message.contains("Expected indentation of 6 spaces, found 5")
815 );
816 }
817
818 #[test]
819 fn test_with_lint_context() {
820 let rule = MD005ListIndent::default();
821
822 let content = "* Item 1\n* Item 2\n * Nested item\n * Another nested item";
824 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
825 let result = rule.check(&ctx).unwrap();
826 assert!(result.is_empty());
827
828 let content = "* Item 1\n* Item 2\n * Nested item\n * Another nested item";
830 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
831 let result = rule.check(&ctx).unwrap();
832 assert!(!result.is_empty()); let content = "* Item 1\n * Nested item\n * Another nested item with wrong indent";
836 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
837 let result = rule.check(&ctx).unwrap();
838 assert!(!result.is_empty()); }
840
841 #[test]
843 fn test_list_with_continuations() {
844 let rule = MD005ListIndent::default();
845 let content = "\
846* Item 1
847 This is a continuation
848 of the first item
849 * Nested item
850 with its own continuation
851* Item 2";
852 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
853 let result = rule.check(&ctx).unwrap();
854 assert!(result.is_empty());
855 }
856
857 #[test]
858 fn test_list_in_blockquote() {
859 let rule = MD005ListIndent::default();
860 let content = "\
861> * Item 1
862> * Nested 1
863> * Nested 2
864> * Item 2";
865 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
866 let result = rule.check(&ctx).unwrap();
867
868 assert!(
870 result.is_empty(),
871 "Expected no warnings for correctly indented blockquote list, got: {result:?}"
872 );
873 }
874
875 #[test]
876 fn test_list_with_code_blocks() {
877 let rule = MD005ListIndent::default();
878 let content = "\
879* Item 1
880 ```
881 code block
882 ```
883 * Nested item
884* Item 2";
885 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
886 let result = rule.check(&ctx).unwrap();
887 assert!(result.is_empty());
888 }
889
890 #[test]
891 fn test_list_with_tabs() {
892 let rule = MD005ListIndent::default();
893 let content = "* Item 1\n\t* Tab indented\n * Space indented";
894 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
895 let result = rule.check(&ctx).unwrap();
896 assert!(!result.is_empty());
898 }
899
900 #[test]
901 fn test_inconsistent_at_same_level() {
902 let rule = MD005ListIndent::default();
903 let content = "\
904* Item 1
905 * Nested 1
906 * Nested 2
907 * Wrong indent for same level
908 * Nested 3";
909 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
910 let result = rule.check(&ctx).unwrap();
911 assert!(!result.is_empty());
912 assert!(result.iter().any(|w| w.line == 4));
914 }
915
916 #[test]
917 fn test_zero_indent_top_level() {
918 let rule = MD005ListIndent::default();
919 let content = concat!(" * Wrong indent\n", "* Correct\n", " * Nested");
921 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
922 let result = rule.check(&ctx).unwrap();
923
924 assert!(!result.is_empty());
926 assert!(result.iter().any(|w| w.line == 1));
927 }
928
929 #[test]
930 fn test_fix_preserves_content() {
931 let rule = MD005ListIndent::default();
932 let content = "\
933* Item with **bold** and *italic*
934 * Wrong indent with `code`
935 * Also wrong with [link](url)";
936 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
937 let fixed = rule.fix(&ctx).unwrap();
938 assert!(fixed.contains("**bold**"));
939 assert!(fixed.contains("*italic*"));
940 assert!(fixed.contains("`code`"));
941 assert!(fixed.contains("[link](url)"));
942 }
943
944 #[test]
945 fn test_deeply_nested_lists() {
946 let rule = MD005ListIndent::default();
947 let content = "\
948* L1
949 * L2
950 * L3
951 * L4
952 * L5
953 * L6";
954 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
955 let result = rule.check(&ctx).unwrap();
956 assert!(result.is_empty());
957 }
958
959 #[test]
960 fn test_fix_multiple_issues() {
961 let rule = MD005ListIndent::default();
962 let content = "\
963* Item 1
964 * Wrong 1
965 * Wrong 2
966 * Wrong 3
967 * Correct
968 * Wrong 4";
969 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
970 let fixed = rule.fix(&ctx).unwrap();
971 let lines: Vec<&str> = fixed.lines().collect();
973 assert_eq!(lines[0], "* Item 1");
974 assert!(lines[1].starts_with(" * ") || lines[1].starts_with("* "));
976 }
977
978 #[test]
979 fn test_performance_large_document() {
980 let rule = MD005ListIndent::default();
981 let mut content = String::new();
982 for i in 0..100 {
983 content.push_str(&format!("* Item {i}\n"));
984 content.push_str(&format!(" * Nested {i}\n"));
985 }
986 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard);
987 let result = rule.check(&ctx).unwrap();
988 assert!(result.is_empty());
989 }
990
991 #[test]
992 fn test_column_positions() {
993 let rule = MD005ListIndent::default();
994 let content = " * Wrong indent";
995 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
996 let result = rule.check(&ctx).unwrap();
997 assert_eq!(result.len(), 1);
998 assert_eq!(result[0].column, 1, "Expected column 1, got {}", result[0].column);
999 assert_eq!(
1000 result[0].end_column, 2,
1001 "Expected end_column 2, got {}",
1002 result[0].end_column
1003 );
1004 }
1005
1006 #[test]
1007 fn test_should_skip() {
1008 let rule = MD005ListIndent::default();
1009
1010 let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard);
1012 assert!(rule.should_skip(&ctx));
1013
1014 let ctx = LintContext::new("Just plain text", crate::config::MarkdownFlavor::Standard);
1016 assert!(rule.should_skip(&ctx));
1017
1018 let ctx = LintContext::new("* List item", crate::config::MarkdownFlavor::Standard);
1020 assert!(!rule.should_skip(&ctx));
1021
1022 let ctx = LintContext::new("1. Ordered list", crate::config::MarkdownFlavor::Standard);
1023 assert!(!rule.should_skip(&ctx));
1024 }
1025
1026 #[test]
1027 fn test_should_skip_validation() {
1028 let rule = MD005ListIndent::default();
1029 let content = "* List item";
1030 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1031 assert!(!rule.should_skip(&ctx));
1032
1033 let content = "No lists here";
1034 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1035 assert!(rule.should_skip(&ctx));
1036 }
1037
1038 #[test]
1039 fn test_edge_case_single_space_indent() {
1040 let rule = MD005ListIndent::default();
1041 let content = "\
1042* Item 1
1043 * Single space - 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(), 2);
1050 assert!(result.iter().any(|w| w.line == 2 && w.message.contains("found 1")));
1051 }
1052
1053 #[test]
1054 fn test_edge_case_three_space_indent() {
1055 let rule = MD005ListIndent::default();
1056 let content = "\
1057* Item 1
1058 * Three spaces - wrong
1059 * Two spaces - correct";
1060 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1061 let result = rule.check(&ctx).unwrap();
1062 assert_eq!(result.len(), 1);
1064 assert!(result.iter().any(|w| w.line == 2 && w.message.contains("found 3")));
1065 }
1066
1067 #[test]
1068 fn test_nested_bullets_under_numbered_items() {
1069 let rule = MD005ListIndent::default();
1070 let content = "\
10711. **Active Directory/LDAP**
1072 - User authentication and directory services
1073 - LDAP for user information and validation
1074
10752. **Oracle Unified Directory (OUD)**
1076 - Extended user directory services
1077 - Verification of project account presence and changes";
1078 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1079 let result = rule.check(&ctx).unwrap();
1080 assert!(
1082 result.is_empty(),
1083 "Expected no warnings for bullets with 3 spaces under numbered items, got: {result:?}"
1084 );
1085 }
1086
1087 #[test]
1088 fn test_nested_bullets_under_numbered_items_wrong_indent() {
1089 let rule = MD005ListIndent::default();
1090 let content = "\
10911. **Active Directory/LDAP**
1092 - Wrong: only 2 spaces
1093 - Correct: 3 spaces";
1094 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1095 let result = rule.check(&ctx).unwrap();
1096 assert_eq!(
1098 result.len(),
1099 1,
1100 "Expected 1 warning, got {}. Warnings: {:?}",
1101 result.len(),
1102 result
1103 );
1104 assert!(
1106 result
1107 .iter()
1108 .any(|w| (w.line == 2 && w.message.contains("found 2"))
1109 || (w.line == 3 && w.message.contains("found 3")))
1110 );
1111 }
1112
1113 #[test]
1114 fn test_regular_nested_bullets_still_work() {
1115 let rule = MD005ListIndent::default();
1116 let content = "\
1117* Top level
1118 * Second level (2 spaces is correct for bullets under bullets)
1119 * Third level (4 spaces)";
1120 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1121 let result = rule.check(&ctx).unwrap();
1122 assert!(
1124 result.is_empty(),
1125 "Expected no warnings for regular bullet nesting, got: {result:?}"
1126 );
1127 }
1128
1129 #[test]
1130 fn test_fix_range_accuracy() {
1131 let rule = MD005ListIndent::default();
1132 let content = " * Wrong indent";
1133 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1134 let result = rule.check(&ctx).unwrap();
1135 assert_eq!(result.len(), 1);
1136
1137 let fix = result[0].fix.as_ref().unwrap();
1138 assert_eq!(fix.replacement, "");
1140 }
1141
1142 #[test]
1143 fn test_four_space_indent_pattern() {
1144 let rule = MD005ListIndent::default();
1145 let content = "\
1146* Item 1
1147 * Item 2 with 4 spaces
1148 * Item 3 with 8 spaces
1149 * Item 4 with 4 spaces";
1150 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1151 let result = rule.check(&ctx).unwrap();
1152 assert!(
1154 result.is_empty(),
1155 "MD005 should accept consistent 4-space indentation pattern, got {} warnings",
1156 result.len()
1157 );
1158 }
1159
1160 #[test]
1161 fn test_issue_64_scenario() {
1162 let rule = MD005ListIndent::default();
1164 let content = "\
1165* Top level item
1166 * Sub item with 4 spaces (as configured in MD007)
1167 * Nested sub item with 8 spaces
1168 * Another sub item with 4 spaces
1169* Another top level";
1170
1171 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1172 let result = rule.check(&ctx).unwrap();
1173
1174 assert!(
1176 result.is_empty(),
1177 "MD005 should accept 4-space indentation when that's the pattern being used. Got {} warnings",
1178 result.len()
1179 );
1180 }
1181
1182 #[test]
1183 fn test_continuation_content_scenario() {
1184 let rule = MD005ListIndent::default();
1185 let content = "\
1186- **Changes to how the Python version is inferred** ([#16319](example))
1187
1188 In previous versions of Ruff, you could specify your Python version with:
1189
1190 - The `target-version` option in a `ruff.toml` file
1191 - The `project.requires-python` field in a `pyproject.toml` file";
1192
1193 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1194
1195 let result = rule.check(&ctx).unwrap();
1196
1197 assert!(
1199 result.is_empty(),
1200 "MD005 should not flag continuation content lists, got {} warnings: {:?}",
1201 result.len(),
1202 result
1203 );
1204 }
1205
1206 #[test]
1207 fn test_multiple_continuation_lists_scenario() {
1208 let rule = MD005ListIndent::default();
1209 let content = "\
1210- **Changes to how the Python version is inferred** ([#16319](example))
1211
1212 In previous versions of Ruff, you could specify your Python version with:
1213
1214 - The `target-version` option in a `ruff.toml` file
1215 - The `project.requires-python` field in a `pyproject.toml` file
1216
1217 In v0.10, config discovery has been updated to address this issue:
1218
1219 - If Ruff finds a `ruff.toml` file without a `target-version`, it will check
1220 - If Ruff finds a user-level configuration, the `requires-python` field will take precedence
1221 - If there is no config file, Ruff will search for the closest `pyproject.toml`";
1222
1223 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1224
1225 let result = rule.check(&ctx).unwrap();
1226
1227 assert!(
1229 result.is_empty(),
1230 "MD005 should not flag continuation content lists, got {} warnings: {:?}",
1231 result.len(),
1232 result
1233 );
1234 }
1235
1236 #[test]
1237 fn test_issue_115_sublist_after_code_block() {
1238 let rule = MD005ListIndent::default();
1239 let content = "\
12401. List item 1
1241
1242 ```rust
1243 fn foo() {}
1244 ```
1245
1246 Sublist:
1247
1248 - A
1249 - B
1250";
1251 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1252 let result = rule.check(&ctx).unwrap();
1253 assert!(
1257 result.is_empty(),
1258 "Expected no warnings for sub-list after code block in list item, got {} warnings: {:?}",
1259 result.len(),
1260 result
1261 );
1262 }
1263
1264 #[test]
1265 fn test_edge_case_continuation_at_exact_boundary() {
1266 let rule = MD005ListIndent::default();
1267 let content = "\
1269* Item (content at column 2)
1270 Text at column 2 (exact boundary - continuation)
1271 * Sub at column 2";
1272 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1273 let result = rule.check(&ctx).unwrap();
1274 assert!(
1276 result.is_empty(),
1277 "Expected no warnings when text and sub-list are at exact parent content_column, got: {result:?}"
1278 );
1279 }
1280
1281 #[test]
1282 fn test_edge_case_unicode_in_continuation() {
1283 let rule = MD005ListIndent::default();
1284 let content = "\
1285* Parent
1286 Text with emoji 😀 and Unicode ñ characters
1287 * Sub-list should still work";
1288 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1289 let result = rule.check(&ctx).unwrap();
1290 assert!(
1292 result.is_empty(),
1293 "Expected no warnings with Unicode in continuation content, got: {result:?}"
1294 );
1295 }
1296
1297 #[test]
1298 fn test_edge_case_large_empty_line_gap() {
1299 let rule = MD005ListIndent::default();
1300 let content = "\
1301* Parent at line 1
1302 Continuation text
1303
1304
1305
1306 More continuation after many empty lines
1307
1308 * Child after gap
1309 * Another child";
1310 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1311 let result = rule.check(&ctx).unwrap();
1312 assert!(
1314 result.is_empty(),
1315 "Expected no warnings with large gaps in continuation content, got: {result:?}"
1316 );
1317 }
1318
1319 #[test]
1320 fn test_edge_case_multiple_continuation_blocks_varying_indent() {
1321 let rule = MD005ListIndent::default();
1322 let content = "\
1323* Parent (content at column 2)
1324 First paragraph at column 2
1325 Indented quote at column 4
1326 Back to column 2
1327 * Sub-list at column 2";
1328 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1329 let result = rule.check(&ctx).unwrap();
1330 assert!(
1332 result.is_empty(),
1333 "Expected no warnings with varying continuation indent, got: {result:?}"
1334 );
1335 }
1336
1337 #[test]
1338 fn test_edge_case_deep_nesting_no_continuation() {
1339 let rule = MD005ListIndent::default();
1340 let content = "\
1341* Parent
1342 * Immediate child (no continuation text before)
1343 * Grandchild
1344 * Great-grandchild
1345 * Great-great-grandchild
1346 * Another child at level 2";
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 for deep nesting without continuation, got: {result:?}"
1353 );
1354 }
1355
1356 #[test]
1357 fn test_edge_case_blockquote_continuation_content() {
1358 let rule = MD005ListIndent::default();
1359 let content = "\
1360> * Parent in blockquote
1361> Continuation in blockquote
1362> * Sub-list in blockquote
1363> * Another sub-list";
1364 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1365 let result = rule.check(&ctx).unwrap();
1366 assert!(
1368 result.is_empty(),
1369 "Expected no warnings for blockquote continuation, got: {result:?}"
1370 );
1371 }
1372
1373 #[test]
1374 fn test_edge_case_one_space_less_than_content_column() {
1375 let rule = MD005ListIndent::default();
1376 let content = "\
1377* Parent (content at column 2)
1378 Text at column 1 (one less than content_column - NOT continuation)
1379 * Child";
1380 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1381 let result = rule.check(&ctx).unwrap();
1382 assert!(
1388 result.is_empty() || !result.is_empty(),
1389 "Test should complete without panic"
1390 );
1391 }
1392
1393 #[test]
1394 fn test_edge_case_multiple_code_blocks_different_indentation() {
1395 let rule = MD005ListIndent::default();
1396 let content = "\
1397* Parent
1398 ```
1399 code at 2 spaces
1400 ```
1401 ```
1402 code at 4 spaces
1403 ```
1404 * Sub-list should not be confused";
1405 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1406 let result = rule.check(&ctx).unwrap();
1407 assert!(
1409 result.is_empty(),
1410 "Expected no warnings with multiple code blocks, got: {result:?}"
1411 );
1412 }
1413
1414 #[test]
1415 fn test_performance_very_large_document() {
1416 let rule = MD005ListIndent::default();
1417 let mut content = String::new();
1418
1419 for i in 0..1000 {
1421 content.push_str(&format!("* Item {i}\n"));
1422 content.push_str(&format!(" * Nested {i}\n"));
1423 if i % 10 == 0 {
1424 content.push_str(" Some continuation text\n");
1425 }
1426 }
1427
1428 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard);
1429
1430 let start = std::time::Instant::now();
1432 let result = rule.check(&ctx).unwrap();
1433 let elapsed = start.elapsed();
1434
1435 assert!(result.is_empty());
1436 println!("Processed 1000 list items in {elapsed:?}");
1437 assert!(
1440 elapsed.as_secs() < 1,
1441 "Should complete in under 1 second, took {elapsed:?}"
1442 );
1443 }
1444}