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(ctx.content).trim_start();
42 let line_indent = line_info.byte_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 cache,
211 line_num,
212 list_line,
213 list_indent,
214 parent_content_column,
215 ) {
216 return true;
217 }
218
219 if cache.has_continuation_content(line_num, list_line, parent_content_column) {
221 return true;
222 }
223 }
224
225 } else if !line_info.content(ctx.content).trim().is_empty() {
228 let content = line_info.content(ctx.content).trim_start();
230 let line_indent = line_info.byte_len - content.len();
231
232 if line_indent == 0 {
233 break;
234 }
235 }
236 }
237 }
238 false
239 }
240
241 fn has_continuation_list_at_indent(
243 &self,
244 ctx: &crate::lint_context::LintContext,
245 cache: &LineCacheInfo,
246 parent_line: usize,
247 current_line: usize,
248 list_indent: usize,
249 parent_content_column: usize,
250 ) -> bool {
251 for line_num in (parent_line + 1)..current_line {
254 if let Some(line_info) = ctx.line_info(line_num)
255 && let Some(list_item) = &line_info.list_item
256 && list_item.marker_column == list_indent
257 {
258 if cache
261 .find_continuation_indent(parent_line + 1, line_num - 1, parent_content_column)
262 .is_some()
263 {
264 return true;
265 }
266 }
267 }
268 false
269 }
270
271 fn check_list_block_group(
273 &self,
274 ctx: &crate::lint_context::LintContext,
275 group: &[&crate::lint_context::ListBlock],
276 warnings: &mut Vec<LintWarning>,
277 ) -> Result<(), LintError> {
278 let cache = LineCacheInfo::new(ctx);
280
281 let mut all_list_items = Vec::new();
283
284 for list_block in group {
285 for &item_line in &list_block.item_lines {
286 if let Some(line_info) = ctx.line_info(item_line)
287 && let Some(list_item) = &line_info.list_item
288 {
289 let effective_indent = if let Some(blockquote) = &line_info.blockquote {
291 list_item.marker_column.saturating_sub(blockquote.nesting_level * 2)
293 } else {
294 list_item.marker_column
296 };
297
298 if self.is_continuation_content(ctx, &cache, item_line, effective_indent) {
300 continue;
301 }
302
303 all_list_items.push((item_line, effective_indent, line_info, list_item));
304 }
305 }
306 }
307
308 if all_list_items.is_empty() {
309 return Ok(());
310 }
311
312 all_list_items.sort_by_key(|(line_num, _, _, _)| *line_num);
314
315 let mut level_map: HashMap<usize, usize> = HashMap::new();
319 let mut level_indents: HashMap<usize, Vec<usize>> = HashMap::new(); for i in 0..all_list_items.len() {
323 let (line_num, indent, _, _) = &all_list_items[i];
324
325 let level = if i == 0 {
326 level_indents.entry(1).or_default().push(*indent);
328 1
329 } else {
330 let mut determined_level = 0;
332
333 for (lvl, indents) in &level_indents {
335 if indents.contains(indent) {
336 determined_level = *lvl;
337 break;
338 }
339 }
340
341 if determined_level == 0 {
342 for j in (0..i).rev() {
345 let (prev_line, prev_indent, _, _) = &all_list_items[j];
346 let prev_level = level_map[prev_line];
347
348 if *prev_indent + Self::MIN_CHILD_INDENT_INCREASE <= *indent {
350 determined_level = prev_level + 1;
352 break;
353 } else if (*prev_indent as i32 - *indent as i32).abs() <= Self::SAME_LEVEL_TOLERANCE {
354 determined_level = prev_level;
356 break;
357 } else if *prev_indent < *indent {
358 if let Some(indents_at_level) = level_indents.get(&prev_level) {
363 for &level_indent in indents_at_level {
365 if (level_indent as i32 - *indent as i32).abs() <= Self::SAME_LEVEL_TOLERANCE {
366 determined_level = prev_level;
368 break;
369 }
370 }
371 }
372 if determined_level == 0 {
373 determined_level = prev_level + 1;
375 }
376 break;
377 }
378 }
379
380 if determined_level == 0 {
382 determined_level = 1;
383 }
384
385 level_indents.entry(determined_level).or_default().push(*indent);
387 }
388
389 determined_level
390 };
391
392 level_map.insert(*line_num, level);
393 }
394
395 let mut level_groups: HashMap<usize, Vec<(usize, usize, &crate::lint_context::LineInfo)>> = HashMap::new();
397 for (line_num, indent, line_info, _) in &all_list_items {
398 let level = level_map[line_num];
399 level_groups
400 .entry(level)
401 .or_default()
402 .push((*line_num, *indent, *line_info));
403 }
404
405 for (level, group) in level_groups {
407 if level != 1 && group.len() < 2 {
410 continue;
411 }
412
413 let mut group = group;
415 group.sort_by_key(|(line_num, _, _)| *line_num);
416
417 let indents: std::collections::HashSet<usize> = group.iter().map(|(_, indent, _)| *indent).collect();
419
420 let has_issue = if level == 1 {
423 indents.iter().any(|&indent| indent != self.top_level_indent)
425 } else {
426 indents.len() > 1
428 };
429
430 if has_issue {
431 let expected_indent = if level == 1 {
437 self.top_level_indent
438 } else {
439 if self.md007_indent > 0 {
442 (level - 1) * self.md007_indent
445 } else {
446 let mut indent_counts: HashMap<usize, usize> = HashMap::new();
448 for (_, indent, _) in &group {
449 *indent_counts.entry(*indent).or_insert(0) += 1;
450 }
451
452 if indent_counts.len() == 1 {
453 *indent_counts.keys().next().unwrap()
455 } else {
456 indent_counts
460 .iter()
461 .max_by(|(indent_a, count_a), (indent_b, count_b)| {
462 count_a.cmp(count_b).then(indent_b.cmp(indent_a))
464 })
465 .map(|(indent, _)| *indent)
466 .unwrap()
467 }
468 }
469 };
470
471 for (line_num, indent, line_info) in &group {
473 if *indent != expected_indent {
474 let message = format!(
475 "Expected indentation of {} {}, found {}",
476 expected_indent,
477 if expected_indent == 1 { "space" } else { "spaces" },
478 indent
479 );
480
481 let (start_line, start_col, end_line, end_col) = if *indent > 0 {
482 calculate_match_range(*line_num, line_info.content(ctx.content), 0, *indent)
483 } else {
484 calculate_match_range(*line_num, line_info.content(ctx.content), 0, 1)
485 };
486
487 let fix_range = if *indent > 0 {
488 let start_byte = ctx.line_offsets.get(line_num - 1).copied().unwrap_or(0);
489 let end_byte = start_byte + *indent;
490 start_byte..end_byte
491 } else {
492 let byte_pos = ctx.line_offsets.get(line_num - 1).copied().unwrap_or(0);
493 byte_pos..byte_pos
494 };
495
496 let replacement = if expected_indent > 0 {
497 " ".repeat(expected_indent)
498 } else {
499 String::new()
500 };
501
502 warnings.push(LintWarning {
503 rule_name: Some(self.name().to_string()),
504 line: start_line,
505 column: start_col,
506 end_line,
507 end_column: end_col,
508 message,
509 severity: Severity::Warning,
510 fix: Some(Fix {
511 range: fix_range,
512 replacement,
513 }),
514 });
515 }
516 }
517 }
518 }
519
520 Ok(())
521 }
522
523 fn check_optimized(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
525 let content = ctx.content;
526
527 if content.is_empty() {
529 return Ok(Vec::new());
530 }
531
532 if ctx.list_blocks.is_empty() {
534 return Ok(Vec::new());
535 }
536
537 let mut warnings = Vec::new();
538
539 let block_groups = self.group_related_list_blocks(&ctx.list_blocks);
542
543 for group in block_groups {
544 self.check_list_block_group(ctx, &group, &mut warnings)?;
545 }
546
547 Ok(warnings)
548 }
549}
550
551impl Rule for MD005ListIndent {
552 fn name(&self) -> &'static str {
553 "MD005"
554 }
555
556 fn description(&self) -> &'static str {
557 "List indentation should be consistent"
558 }
559
560 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
561 self.check_optimized(ctx)
563 }
564
565 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
566 let warnings = self.check(ctx)?;
567 if warnings.is_empty() {
568 return Ok(ctx.content.to_string());
569 }
570
571 let mut warnings_with_fixes: Vec<_> = warnings
573 .into_iter()
574 .filter_map(|w| w.fix.clone().map(|fix| (w, fix)))
575 .collect();
576 warnings_with_fixes.sort_by_key(|(_, fix)| std::cmp::Reverse(fix.range.start));
577
578 let mut content = ctx.content.to_string();
580 for (_, fix) in warnings_with_fixes {
581 if fix.range.start <= content.len() && fix.range.end <= content.len() {
582 content.replace_range(fix.range, &fix.replacement);
583 }
584 }
585
586 Ok(content)
587 }
588
589 fn category(&self) -> RuleCategory {
590 RuleCategory::List
591 }
592
593 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
595 ctx.content.is_empty() || !ctx.lines.iter().any(|line| line.list_item.is_some())
597 }
598
599 fn as_any(&self) -> &dyn std::any::Any {
600 self
601 }
602
603 fn default_config_section(&self) -> Option<(String, toml::Value)> {
604 None
605 }
606
607 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
608 where
609 Self: Sized,
610 {
611 let mut top_level_indent = 0;
613 let mut md007_indent = 2; if let Some(md007_config) = config.rules.get("MD007") {
617 if let Some(start_indented) = md007_config.values.get("start-indented")
619 && let Some(start_indented_bool) = start_indented.as_bool()
620 && start_indented_bool
621 {
622 if let Some(start_indent) = md007_config.values.get("start-indent") {
624 if let Some(indent_value) = start_indent.as_integer() {
625 top_level_indent = indent_value as usize;
626 }
627 } else {
628 top_level_indent = 2;
630 }
631 }
632
633 if let Some(indent) = md007_config.values.get("indent")
635 && let Some(indent_value) = indent.as_integer()
636 {
637 md007_indent = indent_value as usize;
638 }
639 }
640
641 Box::new(MD005ListIndent {
642 top_level_indent,
643 md007_indent,
644 })
645 }
646}
647
648#[cfg(test)]
649mod tests {
650 use super::*;
651 use crate::lint_context::LintContext;
652
653 #[test]
654 fn test_valid_unordered_list() {
655 let rule = MD005ListIndent::default();
656 let content = "\
657* Item 1
658* Item 2
659 * Nested 1
660 * Nested 2
661* Item 3";
662 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
663 let result = rule.check(&ctx).unwrap();
664 assert!(result.is_empty());
665 }
666
667 #[test]
668 fn test_valid_ordered_list() {
669 let rule = MD005ListIndent::default();
670 let content = "\
6711. Item 1
6722. Item 2
673 1. Nested 1
674 2. Nested 2
6753. Item 3";
676 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
677 let result = rule.check(&ctx).unwrap();
678 assert!(result.is_empty());
681 }
682
683 #[test]
684 fn test_invalid_unordered_indent() {
685 let rule = MD005ListIndent::default();
686 let content = "\
687* Item 1
688 * Item 2
689 * Nested 1";
690 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
691 let result = rule.check(&ctx).unwrap();
692 assert_eq!(result.len(), 1);
695 let fixed = rule.fix(&ctx).unwrap();
696 assert_eq!(fixed, "* Item 1\n* Item 2\n * Nested 1");
697 }
698
699 #[test]
700 fn test_invalid_ordered_indent() {
701 let rule = MD005ListIndent::default();
702 let content = "\
7031. Item 1
704 2. Item 2
705 1. Nested 1";
706 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
707 let result = rule.check(&ctx).unwrap();
708 assert_eq!(result.len(), 1);
709 let fixed = rule.fix(&ctx).unwrap();
710 assert_eq!(fixed, "1. Item 1\n2. Item 2\n 1. Nested 1");
714 }
715
716 #[test]
717 fn test_mixed_list_types() {
718 let rule = MD005ListIndent::default();
719 let content = "\
720* Item 1
721 1. Nested ordered
722 * Nested unordered
723* Item 2";
724 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
725 let result = rule.check(&ctx).unwrap();
726 assert!(result.is_empty());
727 }
728
729 #[test]
730 fn test_multiple_levels() {
731 let rule = MD005ListIndent::default();
732 let content = "\
733* Level 1
734 * Level 2
735 * Level 3";
736 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
737 let result = rule.check(&ctx).unwrap();
738 assert!(result.is_empty(), "MD005 should accept consistent indentation pattern");
740 }
741
742 #[test]
743 fn test_empty_lines() {
744 let rule = MD005ListIndent::default();
745 let content = "\
746* Item 1
747
748 * Nested 1
749
750* Item 2";
751 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
752 let result = rule.check(&ctx).unwrap();
753 assert!(result.is_empty());
754 }
755
756 #[test]
757 fn test_no_lists() {
758 let rule = MD005ListIndent::default();
759 let content = "\
760Just some text
761More text
762Even more text";
763 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
764 let result = rule.check(&ctx).unwrap();
765 assert!(result.is_empty());
766 }
767
768 #[test]
769 fn test_complex_nesting() {
770 let rule = MD005ListIndent::default();
771 let content = "\
772* Level 1
773 * Level 2
774 * Level 3
775 * Back to 2
776 1. Ordered 3
777 2. Still 3
778* Back to 1";
779 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
780 let result = rule.check(&ctx).unwrap();
781 assert!(result.is_empty());
782 }
783
784 #[test]
785 fn test_invalid_complex_nesting() {
786 let rule = MD005ListIndent::default();
787 let content = "\
788* Level 1
789 * Level 2
790 * Level 3
791 * Back to 2
792 1. Ordered 3
793 2. Still 3
794* Back to 1";
795 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
796 let result = rule.check(&ctx).unwrap();
797 assert_eq!(result.len(), 1);
799 assert!(
800 result[0].message.contains("Expected indentation of 5 spaces, found 6")
801 || result[0].message.contains("Expected indentation of 6 spaces, found 5")
802 );
803 }
804
805 #[test]
806 fn test_with_lint_context() {
807 let rule = MD005ListIndent::default();
808
809 let content = "* Item 1\n* Item 2\n * Nested item\n * Another nested item";
811 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
812 let result = rule.check(&ctx).unwrap();
813 assert!(result.is_empty());
814
815 let content = "* Item 1\n* Item 2\n * Nested item\n * Another nested item";
817 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
818 let result = rule.check(&ctx).unwrap();
819 assert!(!result.is_empty()); let content = "* Item 1\n * Nested item\n * Another nested item with wrong indent";
823 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
824 let result = rule.check(&ctx).unwrap();
825 assert!(!result.is_empty()); }
827
828 #[test]
830 fn test_list_with_continuations() {
831 let rule = MD005ListIndent::default();
832 let content = "\
833* Item 1
834 This is a continuation
835 of the first item
836 * Nested item
837 with its own continuation
838* Item 2";
839 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
840 let result = rule.check(&ctx).unwrap();
841 assert!(result.is_empty());
842 }
843
844 #[test]
845 fn test_list_in_blockquote() {
846 let rule = MD005ListIndent::default();
847 let content = "\
848> * Item 1
849> * Nested 1
850> * Nested 2
851> * Item 2";
852 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
853 let result = rule.check(&ctx).unwrap();
854
855 assert!(
857 result.is_empty(),
858 "Expected no warnings for correctly indented blockquote list, got: {result:?}"
859 );
860 }
861
862 #[test]
863 fn test_list_with_code_blocks() {
864 let rule = MD005ListIndent::default();
865 let content = "\
866* Item 1
867 ```
868 code block
869 ```
870 * Nested item
871* Item 2";
872 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
873 let result = rule.check(&ctx).unwrap();
874 assert!(result.is_empty());
875 }
876
877 #[test]
878 fn test_list_with_tabs() {
879 let rule = MD005ListIndent::default();
880 let content = "* Item 1\n\t* Tab indented\n * Space indented";
881 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
882 let result = rule.check(&ctx).unwrap();
883 assert!(!result.is_empty());
885 }
886
887 #[test]
888 fn test_inconsistent_at_same_level() {
889 let rule = MD005ListIndent::default();
890 let content = "\
891* Item 1
892 * Nested 1
893 * Nested 2
894 * Wrong indent for same level
895 * Nested 3";
896 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
897 let result = rule.check(&ctx).unwrap();
898 assert!(!result.is_empty());
899 assert!(result.iter().any(|w| w.line == 4));
901 }
902
903 #[test]
904 fn test_zero_indent_top_level() {
905 let rule = MD005ListIndent::default();
906 let content = concat!(" * Wrong indent\n", "* Correct\n", " * Nested");
908 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
909 let result = rule.check(&ctx).unwrap();
910
911 assert!(!result.is_empty());
913 assert!(result.iter().any(|w| w.line == 1));
914 }
915
916 #[test]
917 fn test_fix_preserves_content() {
918 let rule = MD005ListIndent::default();
919 let content = "\
920* Item with **bold** and *italic*
921 * Wrong indent with `code`
922 * Also wrong with [link](url)";
923 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
924 let fixed = rule.fix(&ctx).unwrap();
925 assert!(fixed.contains("**bold**"));
926 assert!(fixed.contains("*italic*"));
927 assert!(fixed.contains("`code`"));
928 assert!(fixed.contains("[link](url)"));
929 }
930
931 #[test]
932 fn test_deeply_nested_lists() {
933 let rule = MD005ListIndent::default();
934 let content = "\
935* L1
936 * L2
937 * L3
938 * L4
939 * L5
940 * L6";
941 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
942 let result = rule.check(&ctx).unwrap();
943 assert!(result.is_empty());
944 }
945
946 #[test]
947 fn test_fix_multiple_issues() {
948 let rule = MD005ListIndent::default();
949 let content = "\
950* Item 1
951 * Wrong 1
952 * Wrong 2
953 * Wrong 3
954 * Correct
955 * Wrong 4";
956 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
957 let fixed = rule.fix(&ctx).unwrap();
958 let lines: Vec<&str> = fixed.lines().collect();
960 assert_eq!(lines[0], "* Item 1");
961 assert!(lines[1].starts_with(" * ") || lines[1].starts_with("* "));
963 }
964
965 #[test]
966 fn test_performance_large_document() {
967 let rule = MD005ListIndent::default();
968 let mut content = String::new();
969 for i in 0..100 {
970 content.push_str(&format!("* Item {i}\n"));
971 content.push_str(&format!(" * Nested {i}\n"));
972 }
973 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard);
974 let result = rule.check(&ctx).unwrap();
975 assert!(result.is_empty());
976 }
977
978 #[test]
979 fn test_column_positions() {
980 let rule = MD005ListIndent::default();
981 let content = " * Wrong indent";
982 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
983 let result = rule.check(&ctx).unwrap();
984 assert_eq!(result.len(), 1);
985 assert_eq!(result[0].column, 1, "Expected column 1, got {}", result[0].column);
986 assert_eq!(
987 result[0].end_column, 2,
988 "Expected end_column 2, got {}",
989 result[0].end_column
990 );
991 }
992
993 #[test]
994 fn test_should_skip() {
995 let rule = MD005ListIndent::default();
996
997 let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard);
999 assert!(rule.should_skip(&ctx));
1000
1001 let ctx = LintContext::new("Just plain text", crate::config::MarkdownFlavor::Standard);
1003 assert!(rule.should_skip(&ctx));
1004
1005 let ctx = LintContext::new("* List item", crate::config::MarkdownFlavor::Standard);
1007 assert!(!rule.should_skip(&ctx));
1008
1009 let ctx = LintContext::new("1. Ordered list", crate::config::MarkdownFlavor::Standard);
1010 assert!(!rule.should_skip(&ctx));
1011 }
1012
1013 #[test]
1014 fn test_should_skip_validation() {
1015 let rule = MD005ListIndent::default();
1016 let content = "* List item";
1017 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1018 assert!(!rule.should_skip(&ctx));
1019
1020 let content = "No lists here";
1021 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1022 assert!(rule.should_skip(&ctx));
1023 }
1024
1025 #[test]
1026 fn test_edge_case_single_space_indent() {
1027 let rule = MD005ListIndent::default();
1028 let content = "\
1029* Item 1
1030 * Single space - wrong
1031 * Two spaces - correct";
1032 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1033 let result = rule.check(&ctx).unwrap();
1034 assert_eq!(result.len(), 2);
1037 assert!(result.iter().any(|w| w.line == 2 && w.message.contains("found 1")));
1038 }
1039
1040 #[test]
1041 fn test_edge_case_three_space_indent() {
1042 let rule = MD005ListIndent::default();
1043 let content = "\
1044* Item 1
1045 * Three spaces - wrong
1046 * Two spaces - correct";
1047 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1048 let result = rule.check(&ctx).unwrap();
1049 assert_eq!(result.len(), 1);
1051 assert!(result.iter().any(|w| w.line == 2 && w.message.contains("found 3")));
1052 }
1053
1054 #[test]
1055 fn test_nested_bullets_under_numbered_items() {
1056 let rule = MD005ListIndent::default();
1057 let content = "\
10581. **Active Directory/LDAP**
1059 - User authentication and directory services
1060 - LDAP for user information and validation
1061
10622. **Oracle Unified Directory (OUD)**
1063 - Extended user directory services
1064 - Verification of project account presence and changes";
1065 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1066 let result = rule.check(&ctx).unwrap();
1067 assert!(
1069 result.is_empty(),
1070 "Expected no warnings for bullets with 3 spaces under numbered items, got: {result:?}"
1071 );
1072 }
1073
1074 #[test]
1075 fn test_nested_bullets_under_numbered_items_wrong_indent() {
1076 let rule = MD005ListIndent::default();
1077 let content = "\
10781. **Active Directory/LDAP**
1079 - Wrong: only 2 spaces
1080 - Correct: 3 spaces";
1081 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1082 let result = rule.check(&ctx).unwrap();
1083 assert_eq!(
1085 result.len(),
1086 1,
1087 "Expected 1 warning, got {}. Warnings: {:?}",
1088 result.len(),
1089 result
1090 );
1091 assert!(
1093 result
1094 .iter()
1095 .any(|w| (w.line == 2 && w.message.contains("found 2"))
1096 || (w.line == 3 && w.message.contains("found 3")))
1097 );
1098 }
1099
1100 #[test]
1101 fn test_regular_nested_bullets_still_work() {
1102 let rule = MD005ListIndent::default();
1103 let content = "\
1104* Top level
1105 * Second level (2 spaces is correct for bullets under bullets)
1106 * Third level (4 spaces)";
1107 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1108 let result = rule.check(&ctx).unwrap();
1109 assert!(
1111 result.is_empty(),
1112 "Expected no warnings for regular bullet nesting, got: {result:?}"
1113 );
1114 }
1115
1116 #[test]
1117 fn test_fix_range_accuracy() {
1118 let rule = MD005ListIndent::default();
1119 let content = " * Wrong indent";
1120 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1121 let result = rule.check(&ctx).unwrap();
1122 assert_eq!(result.len(), 1);
1123
1124 let fix = result[0].fix.as_ref().unwrap();
1125 assert_eq!(fix.replacement, "");
1127 }
1128
1129 #[test]
1130 fn test_four_space_indent_pattern() {
1131 let rule = MD005ListIndent::default();
1132 let content = "\
1133* Item 1
1134 * Item 2 with 4 spaces
1135 * Item 3 with 8 spaces
1136 * Item 4 with 4 spaces";
1137 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1138 let result = rule.check(&ctx).unwrap();
1139 assert!(
1141 result.is_empty(),
1142 "MD005 should accept consistent 4-space indentation pattern, got {} warnings",
1143 result.len()
1144 );
1145 }
1146
1147 #[test]
1148 fn test_issue_64_scenario() {
1149 let rule = MD005ListIndent::default();
1151 let content = "\
1152* Top level item
1153 * Sub item with 4 spaces (as configured in MD007)
1154 * Nested sub item with 8 spaces
1155 * Another sub item with 4 spaces
1156* Another top level";
1157
1158 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1159 let result = rule.check(&ctx).unwrap();
1160
1161 assert!(
1163 result.is_empty(),
1164 "MD005 should accept 4-space indentation when that's the pattern being used. Got {} warnings",
1165 result.len()
1166 );
1167 }
1168
1169 #[test]
1170 fn test_continuation_content_scenario() {
1171 let rule = MD005ListIndent::default();
1172 let content = "\
1173- **Changes to how the Python version is inferred** ([#16319](example))
1174
1175 In previous versions of Ruff, you could specify your Python version with:
1176
1177 - The `target-version` option in a `ruff.toml` file
1178 - The `project.requires-python` field in a `pyproject.toml` file";
1179
1180 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1181
1182 let result = rule.check(&ctx).unwrap();
1183
1184 assert!(
1186 result.is_empty(),
1187 "MD005 should not flag continuation content lists, got {} warnings: {:?}",
1188 result.len(),
1189 result
1190 );
1191 }
1192
1193 #[test]
1194 fn test_multiple_continuation_lists_scenario() {
1195 let rule = MD005ListIndent::default();
1196 let content = "\
1197- **Changes to how the Python version is inferred** ([#16319](example))
1198
1199 In previous versions of Ruff, you could specify your Python version with:
1200
1201 - The `target-version` option in a `ruff.toml` file
1202 - The `project.requires-python` field in a `pyproject.toml` file
1203
1204 In v0.10, config discovery has been updated to address this issue:
1205
1206 - If Ruff finds a `ruff.toml` file without a `target-version`, it will check
1207 - If Ruff finds a user-level configuration, the `requires-python` field will take precedence
1208 - If there is no config file, Ruff will search for the closest `pyproject.toml`";
1209
1210 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1211
1212 let result = rule.check(&ctx).unwrap();
1213
1214 assert!(
1216 result.is_empty(),
1217 "MD005 should not flag continuation content lists, got {} warnings: {:?}",
1218 result.len(),
1219 result
1220 );
1221 }
1222
1223 #[test]
1224 fn test_issue_115_sublist_after_code_block() {
1225 let rule = MD005ListIndent::default();
1226 let content = "\
12271. List item 1
1228
1229 ```rust
1230 fn foo() {}
1231 ```
1232
1233 Sublist:
1234
1235 - A
1236 - B
1237";
1238 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1239 let result = rule.check(&ctx).unwrap();
1240 assert!(
1244 result.is_empty(),
1245 "Expected no warnings for sub-list after code block in list item, got {} warnings: {:?}",
1246 result.len(),
1247 result
1248 );
1249 }
1250
1251 #[test]
1252 fn test_edge_case_continuation_at_exact_boundary() {
1253 let rule = MD005ListIndent::default();
1254 let content = "\
1256* Item (content at column 2)
1257 Text at column 2 (exact boundary - continuation)
1258 * Sub at column 2";
1259 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1260 let result = rule.check(&ctx).unwrap();
1261 assert!(
1263 result.is_empty(),
1264 "Expected no warnings when text and sub-list are at exact parent content_column, got: {result:?}"
1265 );
1266 }
1267
1268 #[test]
1269 fn test_edge_case_unicode_in_continuation() {
1270 let rule = MD005ListIndent::default();
1271 let content = "\
1272* Parent
1273 Text with emoji 😀 and Unicode ñ characters
1274 * Sub-list should still work";
1275 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1276 let result = rule.check(&ctx).unwrap();
1277 assert!(
1279 result.is_empty(),
1280 "Expected no warnings with Unicode in continuation content, got: {result:?}"
1281 );
1282 }
1283
1284 #[test]
1285 fn test_edge_case_large_empty_line_gap() {
1286 let rule = MD005ListIndent::default();
1287 let content = "\
1288* Parent at line 1
1289 Continuation text
1290
1291
1292
1293 More continuation after many empty lines
1294
1295 * Child after gap
1296 * Another child";
1297 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1298 let result = rule.check(&ctx).unwrap();
1299 assert!(
1301 result.is_empty(),
1302 "Expected no warnings with large gaps in continuation content, got: {result:?}"
1303 );
1304 }
1305
1306 #[test]
1307 fn test_edge_case_multiple_continuation_blocks_varying_indent() {
1308 let rule = MD005ListIndent::default();
1309 let content = "\
1310* Parent (content at column 2)
1311 First paragraph at column 2
1312 Indented quote at column 4
1313 Back to column 2
1314 * Sub-list at column 2";
1315 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1316 let result = rule.check(&ctx).unwrap();
1317 assert!(
1319 result.is_empty(),
1320 "Expected no warnings with varying continuation indent, got: {result:?}"
1321 );
1322 }
1323
1324 #[test]
1325 fn test_edge_case_deep_nesting_no_continuation() {
1326 let rule = MD005ListIndent::default();
1327 let content = "\
1328* Parent
1329 * Immediate child (no continuation text before)
1330 * Grandchild
1331 * Great-grandchild
1332 * Great-great-grandchild
1333 * Another child at level 2";
1334 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1335 let result = rule.check(&ctx).unwrap();
1336 assert!(
1338 result.is_empty(),
1339 "Expected no warnings for deep nesting without continuation, got: {result:?}"
1340 );
1341 }
1342
1343 #[test]
1344 fn test_edge_case_blockquote_continuation_content() {
1345 let rule = MD005ListIndent::default();
1346 let content = "\
1347> * Parent in blockquote
1348> Continuation in blockquote
1349> * Sub-list in blockquote
1350> * Another sub-list";
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 for blockquote continuation, got: {result:?}"
1357 );
1358 }
1359
1360 #[test]
1361 fn test_edge_case_one_space_less_than_content_column() {
1362 let rule = MD005ListIndent::default();
1363 let content = "\
1364* Parent (content at column 2)
1365 Text at column 1 (one less than content_column - NOT continuation)
1366 * Child";
1367 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1368 let result = rule.check(&ctx).unwrap();
1369 assert!(
1375 result.is_empty() || !result.is_empty(),
1376 "Test should complete without panic"
1377 );
1378 }
1379
1380 #[test]
1381 fn test_edge_case_multiple_code_blocks_different_indentation() {
1382 let rule = MD005ListIndent::default();
1383 let content = "\
1384* Parent
1385 ```
1386 code at 2 spaces
1387 ```
1388 ```
1389 code at 4 spaces
1390 ```
1391 * Sub-list should not be confused";
1392 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1393 let result = rule.check(&ctx).unwrap();
1394 assert!(
1396 result.is_empty(),
1397 "Expected no warnings with multiple code blocks, got: {result:?}"
1398 );
1399 }
1400
1401 #[test]
1402 fn test_performance_very_large_document() {
1403 let rule = MD005ListIndent::default();
1404 let mut content = String::new();
1405
1406 for i in 0..1000 {
1408 content.push_str(&format!("* Item {i}\n"));
1409 content.push_str(&format!(" * Nested {i}\n"));
1410 if i % 10 == 0 {
1411 content.push_str(" Some continuation text\n");
1412 }
1413 }
1414
1415 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard);
1416
1417 let start = std::time::Instant::now();
1419 let result = rule.check(&ctx).unwrap();
1420 let elapsed = start.elapsed();
1421
1422 assert!(result.is_empty());
1423 println!("Processed 1000 list items in {elapsed:?}");
1424 assert!(
1427 elapsed.as_secs() < 1,
1428 "Should complete in under 1 second, took {elapsed:?}"
1429 );
1430 }
1431}