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