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}
19
20struct LineCacheInfo {
22 indentation: Vec<usize>,
24 flags: Vec<u8>,
26 parent_map: HashMap<usize, usize>,
29}
30
31const FLAG_HAS_CONTENT: u8 = 1;
32const FLAG_IS_LIST_ITEM: u8 = 2;
33
34impl LineCacheInfo {
35 fn new(ctx: &crate::lint_context::LintContext) -> Self {
37 let total_lines = ctx.lines.len();
38 let mut indentation = Vec::with_capacity(total_lines);
39 let mut flags = Vec::with_capacity(total_lines);
40 let mut parent_map = HashMap::new();
41
42 let mut indent_to_line: HashMap<usize, usize> = HashMap::new();
54
55 for (idx, line_info) in ctx.lines.iter().enumerate() {
56 let content = line_info.content(ctx.content).trim_start();
57 let line_indent = line_info.byte_len - content.len();
58
59 indentation.push(line_indent);
60
61 let mut flag = 0u8;
62 if !content.is_empty() {
63 flag |= FLAG_HAS_CONTENT;
64 }
65 if let Some(list_item) = &line_info.list_item {
66 flag |= FLAG_IS_LIST_ITEM;
67
68 let line_num = idx + 1; let marker_column = list_item.marker_column;
70
71 let mut best_parent: Option<(usize, usize)> = None; for (&tracked_indent, &tracked_line) in &indent_to_line {
76 if tracked_indent < marker_column {
77 if best_parent.is_none() || tracked_indent > best_parent.unwrap().0 {
79 best_parent = Some((tracked_indent, tracked_line));
80 }
81 }
82 }
83
84 if let Some((_parent_indent, parent_line)) = best_parent {
85 parent_map.insert(line_num, parent_line);
86 }
87
88 indent_to_line.retain(|&indent, _| indent < marker_column);
93 indent_to_line.insert(marker_column, line_num);
94 }
95 flags.push(flag);
96 }
97
98 Self {
99 indentation,
100 flags,
101 parent_map,
102 }
103 }
104
105 fn has_content(&self, idx: usize) -> bool {
107 self.flags.get(idx).is_some_and(|&f| f & FLAG_HAS_CONTENT != 0)
108 }
109
110 fn is_list_item(&self, idx: usize) -> bool {
112 self.flags.get(idx).is_some_and(|&f| f & FLAG_IS_LIST_ITEM != 0)
113 }
114
115 fn find_continuation_indent(
117 &self,
118 start_line: usize,
119 end_line: usize,
120 parent_content_column: usize,
121 ) -> Option<usize> {
122 if start_line == 0 || start_line > end_line || end_line > self.indentation.len() {
123 return None;
124 }
125
126 let start_idx = start_line - 1;
128 let end_idx = end_line - 1;
129
130 for idx in start_idx..=end_idx {
131 if !self.has_content(idx) || self.is_list_item(idx) {
133 continue;
134 }
135
136 if self.indentation[idx] >= parent_content_column {
139 return Some(self.indentation[idx]);
140 }
141 }
142 None
143 }
144
145 fn has_continuation_content(&self, parent_line: usize, current_line: usize, parent_content_column: usize) -> bool {
147 if parent_line == 0 || current_line <= parent_line || current_line > self.indentation.len() {
148 return false;
149 }
150
151 let start_idx = parent_line; let end_idx = current_line - 2; if start_idx > end_idx {
156 return false;
157 }
158
159 for idx in start_idx..=end_idx {
160 if !self.has_content(idx) || self.is_list_item(idx) {
162 continue;
163 }
164
165 if self.indentation[idx] >= parent_content_column {
168 return true;
169 }
170 }
171 false
172 }
173}
174
175impl MD005ListIndent {
176 const LIST_GROUP_GAP_TOLERANCE: usize = 2;
180
181 const MIN_CHILD_INDENT_INCREASE: usize = 2;
184
185 const SAME_LEVEL_TOLERANCE: i32 = 1;
188
189 const STANDARD_CONTINUATION_OFFSET: usize = 2;
192
193 fn create_indent_warning(
195 &self,
196 ctx: &crate::lint_context::LintContext,
197 line_num: usize,
198 line_info: &crate::lint_context::LineInfo,
199 actual_indent: usize,
200 expected_indent: usize,
201 ) -> LintWarning {
202 let message = format!(
203 "Expected indentation of {} {}, found {}",
204 expected_indent,
205 if expected_indent == 1 { "space" } else { "spaces" },
206 actual_indent
207 );
208
209 let (start_line, start_col, end_line, end_col) = if actual_indent > 0 {
210 calculate_match_range(line_num, line_info.content(ctx.content), 0, actual_indent)
211 } else {
212 calculate_match_range(line_num, line_info.content(ctx.content), 0, 1)
213 };
214
215 let fix_range = if actual_indent > 0 {
216 let start_byte = ctx.line_offsets.get(line_num - 1).copied().unwrap_or(0);
217 let end_byte = start_byte + actual_indent;
218 start_byte..end_byte
219 } else {
220 let byte_pos = ctx.line_offsets.get(line_num - 1).copied().unwrap_or(0);
221 byte_pos..byte_pos
222 };
223
224 let replacement = if expected_indent > 0 {
225 " ".repeat(expected_indent)
226 } else {
227 String::new()
228 };
229
230 LintWarning {
231 rule_name: Some(self.name().to_string()),
232 line: start_line,
233 column: start_col,
234 end_line,
235 end_column: end_col,
236 message,
237 severity: Severity::Warning,
238 fix: Some(Fix {
239 range: fix_range,
240 replacement,
241 }),
242 }
243 }
244
245 fn check_indent_consistency(
248 &self,
249 ctx: &crate::lint_context::LintContext,
250 items: &[(usize, usize, &crate::lint_context::LineInfo)],
251 warnings: &mut Vec<LintWarning>,
252 ) {
253 if items.len() < 2 {
254 return;
255 }
256
257 let mut sorted_items: Vec<_> = items.iter().collect();
259 sorted_items.sort_by_key(|(line_num, _, _)| *line_num);
260
261 let indents: std::collections::HashSet<usize> = sorted_items.iter().map(|(_, indent, _)| *indent).collect();
262
263 if indents.len() > 1 {
264 let expected_indent = sorted_items.first().map(|(_, i, _)| *i).unwrap_or(0);
267
268 for (line_num, indent, line_info) in items {
269 if *indent != expected_indent {
270 warnings.push(self.create_indent_warning(ctx, *line_num, line_info, *indent, expected_indent));
271 }
272 }
273 }
274 }
275
276 fn group_by_parent_content_column<'a>(
279 &self,
280 level: usize,
281 group: &[(usize, usize, &'a crate::lint_context::LineInfo)],
282 all_list_items: &[(
283 usize,
284 usize,
285 &crate::lint_context::LineInfo,
286 &crate::lint_context::ListItemInfo,
287 )],
288 level_map: &HashMap<usize, usize>,
289 ) -> HashMap<usize, Vec<(usize, usize, &'a crate::lint_context::LineInfo)>> {
290 let parent_level = level - 1;
291 let mut parent_content_groups: HashMap<usize, Vec<(usize, usize, &'a crate::lint_context::LineInfo)>> =
292 HashMap::new();
293
294 for (line_num, indent, line_info) in group {
295 let mut parent_content_col: Option<usize> = None;
297
298 for (prev_line, _, _, list_item) in all_list_items.iter().rev() {
299 if *prev_line >= *line_num {
300 continue;
301 }
302 if let Some(&prev_level) = level_map.get(prev_line)
303 && prev_level == parent_level
304 {
305 parent_content_col = Some(list_item.content_column);
306 break;
307 }
308 }
309
310 if let Some(parent_col) = parent_content_col {
311 parent_content_groups
312 .entry(parent_col)
313 .or_default()
314 .push((*line_num, *indent, *line_info));
315 }
316 }
317
318 parent_content_groups
319 }
320
321 fn group_related_list_blocks<'a>(
323 &self,
324 list_blocks: &'a [crate::lint_context::ListBlock],
325 ) -> Vec<Vec<&'a crate::lint_context::ListBlock>> {
326 if list_blocks.is_empty() {
327 return Vec::new();
328 }
329
330 let mut groups = Vec::new();
331 let mut current_group = vec![&list_blocks[0]];
332
333 for i in 1..list_blocks.len() {
334 let prev_block = &list_blocks[i - 1];
335 let current_block = &list_blocks[i];
336
337 let line_gap = current_block.start_line.saturating_sub(prev_block.end_line);
339
340 if line_gap <= Self::LIST_GROUP_GAP_TOLERANCE {
343 current_group.push(current_block);
344 } else {
345 groups.push(current_group);
347 current_group = vec![current_block];
348 }
349 }
350 groups.push(current_group);
351
352 groups
353 }
354
355 fn is_continuation_content(
358 &self,
359 ctx: &crate::lint_context::LintContext,
360 cache: &LineCacheInfo,
361 list_line: usize,
362 list_indent: usize,
363 ) -> bool {
364 let parent_line = cache.parent_map.get(&list_line).copied();
366
367 if let Some(parent_line) = parent_line
368 && let Some(line_info) = ctx.line_info(parent_line)
369 && let Some(parent_list_item) = &line_info.list_item
370 {
371 let parent_marker_column = parent_list_item.marker_column;
372 let parent_content_column = parent_list_item.content_column;
373
374 let continuation_indent =
376 cache.find_continuation_indent(parent_line + 1, list_line - 1, parent_content_column);
377
378 if let Some(continuation_indent) = continuation_indent {
379 let is_standard_continuation =
380 list_indent == parent_content_column + Self::STANDARD_CONTINUATION_OFFSET;
381 let matches_content_indent = list_indent == continuation_indent;
382
383 if matches_content_indent || is_standard_continuation {
384 return true;
385 }
386 }
387
388 if list_indent > parent_marker_column {
391 if self.has_continuation_list_at_indent(
393 ctx,
394 cache,
395 parent_line,
396 list_line,
397 list_indent,
398 parent_content_column,
399 ) {
400 return true;
401 }
402
403 if cache.has_continuation_content(parent_line, list_line, parent_content_column) {
404 return true;
405 }
406 }
407 }
408
409 false
410 }
411
412 fn has_continuation_list_at_indent(
414 &self,
415 ctx: &crate::lint_context::LintContext,
416 cache: &LineCacheInfo,
417 parent_line: usize,
418 current_line: usize,
419 list_indent: usize,
420 parent_content_column: usize,
421 ) -> bool {
422 for line_num in (parent_line + 1)..current_line {
425 if let Some(line_info) = ctx.line_info(line_num)
426 && let Some(list_item) = &line_info.list_item
427 && list_item.marker_column == list_indent
428 {
429 if cache
432 .find_continuation_indent(parent_line + 1, line_num - 1, parent_content_column)
433 .is_some()
434 {
435 return true;
436 }
437 }
438 }
439 false
440 }
441
442 fn check_list_block_group(
444 &self,
445 ctx: &crate::lint_context::LintContext,
446 group: &[&crate::lint_context::ListBlock],
447 warnings: &mut Vec<LintWarning>,
448 ) -> Result<(), LintError> {
449 let cache = LineCacheInfo::new(ctx);
451
452 let mut candidate_items: Vec<(
455 usize,
456 usize,
457 &crate::lint_context::LineInfo,
458 &crate::lint_context::ListItemInfo,
459 )> = Vec::new();
460
461 for list_block in group {
462 for &item_line in &list_block.item_lines {
463 if let Some(line_info) = ctx.line_info(item_line)
464 && let Some(list_item) = &line_info.list_item
465 {
466 let effective_indent = if let Some(blockquote) = &line_info.blockquote {
468 list_item.marker_column.saturating_sub(blockquote.nesting_level * 2)
470 } else {
471 list_item.marker_column
473 };
474
475 candidate_items.push((item_line, effective_indent, line_info, list_item));
476 }
477 }
478 }
479
480 candidate_items.sort_by_key(|(line_num, _, _, _)| *line_num);
482
483 let mut skipped_lines: std::collections::HashSet<usize> = std::collections::HashSet::new();
486 let mut all_list_items: Vec<(
487 usize,
488 usize,
489 &crate::lint_context::LineInfo,
490 &crate::lint_context::ListItemInfo,
491 )> = Vec::new();
492
493 for (item_line, effective_indent, line_info, list_item) in candidate_items {
494 if self.is_continuation_content(ctx, &cache, item_line, effective_indent) {
496 skipped_lines.insert(item_line);
497 continue;
498 }
499
500 if let Some(&parent_line) = cache.parent_map.get(&item_line)
502 && skipped_lines.contains(&parent_line)
503 {
504 skipped_lines.insert(item_line);
505 continue;
506 }
507
508 all_list_items.push((item_line, effective_indent, line_info, list_item));
509 }
510
511 if all_list_items.is_empty() {
512 return Ok(());
513 }
514
515 all_list_items.sort_by_key(|(line_num, _, _, _)| *line_num);
517
518 let mut level_map: HashMap<usize, usize> = HashMap::new();
522 let mut level_indents: HashMap<usize, Vec<usize>> = HashMap::new(); let mut indent_to_level: HashMap<usize, (usize, usize)> = HashMap::new();
527
528 for (line_num, indent, _, _) in &all_list_items {
530 let level = if indent_to_level.is_empty() {
531 level_indents.entry(1).or_default().push(*indent);
533 1
534 } else {
535 let mut determined_level = 0;
537
538 if let Some(&(existing_level, _)) = indent_to_level.get(indent) {
540 determined_level = existing_level;
541 } else {
542 let mut best_parent: Option<(usize, usize, usize)> = None; for (&tracked_indent, &(tracked_level, tracked_line)) in &indent_to_level {
548 if tracked_indent < *indent {
549 if best_parent.is_none() || tracked_indent > best_parent.unwrap().0 {
552 best_parent = Some((tracked_indent, tracked_level, tracked_line));
553 }
554 }
555 }
556
557 if let Some((parent_indent, parent_level, _parent_line)) = best_parent {
558 if parent_indent + Self::MIN_CHILD_INDENT_INCREASE <= *indent {
560 determined_level = parent_level + 1;
562 } else if (*indent as i32 - parent_indent as i32).abs() <= Self::SAME_LEVEL_TOLERANCE {
563 determined_level = parent_level;
565 } else {
566 let mut found_similar = false;
570 if let Some(indents_at_level) = level_indents.get(&parent_level) {
571 for &level_indent in indents_at_level {
572 if (level_indent as i32 - *indent as i32).abs() <= Self::SAME_LEVEL_TOLERANCE {
573 determined_level = parent_level;
574 found_similar = true;
575 break;
576 }
577 }
578 }
579 if !found_similar {
580 determined_level = parent_level + 1;
582 }
583 }
584 }
585
586 if determined_level == 0 {
588 determined_level = 1;
589 }
590
591 level_indents.entry(determined_level).or_default().push(*indent);
593 }
594
595 determined_level
596 };
597
598 level_map.insert(*line_num, level);
599 indent_to_level.insert(*indent, (level, *line_num));
601 }
602
603 let mut level_groups: HashMap<usize, Vec<(usize, usize, &crate::lint_context::LineInfo)>> = HashMap::new();
605 for (line_num, indent, line_info, _) in &all_list_items {
606 let level = level_map[line_num];
607 level_groups
608 .entry(level)
609 .or_default()
610 .push((*line_num, *indent, *line_info));
611 }
612
613 for (level, mut group) in level_groups {
615 group.sort_by_key(|(line_num, _, _)| *line_num);
616
617 if level == 1 {
618 for (line_num, indent, line_info) in &group {
620 if *indent != self.top_level_indent {
621 warnings.push(self.create_indent_warning(
622 ctx,
623 *line_num,
624 line_info,
625 *indent,
626 self.top_level_indent,
627 ));
628 }
629 }
630 } else {
631 let parent_content_groups =
634 self.group_by_parent_content_column(level, &group, &all_list_items, &level_map);
635
636 for items in parent_content_groups.values() {
638 self.check_indent_consistency(ctx, items, warnings);
639 }
640 }
641 }
642
643 Ok(())
644 }
645
646 fn check_optimized(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
648 let content = ctx.content;
649
650 if content.is_empty() {
652 return Ok(Vec::new());
653 }
654
655 if ctx.list_blocks.is_empty() {
657 return Ok(Vec::new());
658 }
659
660 let mut warnings = Vec::new();
661
662 let block_groups = self.group_related_list_blocks(&ctx.list_blocks);
665
666 for group in block_groups {
667 self.check_list_block_group(ctx, &group, &mut warnings)?;
668 }
669
670 Ok(warnings)
671 }
672}
673
674impl Rule for MD005ListIndent {
675 fn name(&self) -> &'static str {
676 "MD005"
677 }
678
679 fn description(&self) -> &'static str {
680 "List indentation should be consistent"
681 }
682
683 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
684 self.check_optimized(ctx)
686 }
687
688 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
689 let warnings = self.check(ctx)?;
690 if warnings.is_empty() {
691 return Ok(ctx.content.to_string());
692 }
693
694 let mut warnings_with_fixes: Vec<_> = warnings
696 .into_iter()
697 .filter_map(|w| w.fix.clone().map(|fix| (w, fix)))
698 .collect();
699 warnings_with_fixes.sort_by_key(|(_, fix)| std::cmp::Reverse(fix.range.start));
700
701 let mut content = ctx.content.to_string();
703 for (_, fix) in warnings_with_fixes {
704 if fix.range.start <= content.len() && fix.range.end <= content.len() {
705 content.replace_range(fix.range, &fix.replacement);
706 }
707 }
708
709 Ok(content)
710 }
711
712 fn category(&self) -> RuleCategory {
713 RuleCategory::List
714 }
715
716 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
718 ctx.content.is_empty() || !ctx.lines.iter().any(|line| line.list_item.is_some())
720 }
721
722 fn as_any(&self) -> &dyn std::any::Any {
723 self
724 }
725
726 fn default_config_section(&self) -> Option<(String, toml::Value)> {
727 None
728 }
729
730 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
731 where
732 Self: Sized,
733 {
734 let mut top_level_indent = 0;
736
737 if let Some(md007_config) = config.rules.get("MD007") {
739 if let Some(start_indented) = md007_config.values.get("start-indented")
741 && let Some(start_indented_bool) = start_indented.as_bool()
742 && start_indented_bool
743 {
744 if let Some(start_indent) = md007_config.values.get("start-indent") {
746 if let Some(indent_value) = start_indent.as_integer() {
747 top_level_indent = indent_value as usize;
748 }
749 } else {
750 top_level_indent = 2;
752 }
753 }
754 }
755
756 Box::new(MD005ListIndent { top_level_indent })
757 }
758}
759
760#[cfg(test)]
761mod tests {
762 use super::*;
763 use crate::lint_context::LintContext;
764
765 #[test]
766 fn test_valid_unordered_list() {
767 let rule = MD005ListIndent::default();
768 let content = "\
769* Item 1
770* Item 2
771 * Nested 1
772 * Nested 2
773* Item 3";
774 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
775 let result = rule.check(&ctx).unwrap();
776 assert!(result.is_empty());
777 }
778
779 #[test]
780 fn test_valid_ordered_list() {
781 let rule = MD005ListIndent::default();
782 let content = "\
7831. Item 1
7842. Item 2
785 1. Nested 1
786 2. Nested 2
7873. Item 3";
788 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
789 let result = rule.check(&ctx).unwrap();
790 assert!(result.is_empty());
793 }
794
795 #[test]
796 fn test_invalid_unordered_indent() {
797 let rule = MD005ListIndent::default();
798 let content = "\
799* Item 1
800 * Item 2
801 * Nested 1";
802 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
803 let result = rule.check(&ctx).unwrap();
804 assert_eq!(result.len(), 1);
807 let fixed = rule.fix(&ctx).unwrap();
808 assert_eq!(fixed, "* Item 1\n* Item 2\n * Nested 1");
809 }
810
811 #[test]
812 fn test_invalid_ordered_indent() {
813 let rule = MD005ListIndent::default();
814 let content = "\
8151. Item 1
816 2. Item 2
817 1. Nested 1";
818 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
819 let result = rule.check(&ctx).unwrap();
820 assert_eq!(result.len(), 1);
821 let fixed = rule.fix(&ctx).unwrap();
822 assert_eq!(fixed, "1. Item 1\n2. Item 2\n 1. Nested 1");
826 }
827
828 #[test]
829 fn test_mixed_list_types() {
830 let rule = MD005ListIndent::default();
831 let content = "\
832* Item 1
833 1. Nested ordered
834 * Nested unordered
835* Item 2";
836 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
837 let result = rule.check(&ctx).unwrap();
838 assert!(result.is_empty());
839 }
840
841 #[test]
842 fn test_multiple_levels() {
843 let rule = MD005ListIndent::default();
844 let content = "\
845* Level 1
846 * Level 2
847 * Level 3";
848 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
849 let result = rule.check(&ctx).unwrap();
850 assert!(result.is_empty(), "MD005 should accept consistent indentation pattern");
852 }
853
854 #[test]
855 fn test_empty_lines() {
856 let rule = MD005ListIndent::default();
857 let content = "\
858* Item 1
859
860 * Nested 1
861
862* Item 2";
863 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
864 let result = rule.check(&ctx).unwrap();
865 assert!(result.is_empty());
866 }
867
868 #[test]
869 fn test_no_lists() {
870 let rule = MD005ListIndent::default();
871 let content = "\
872Just some text
873More text
874Even more text";
875 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
876 let result = rule.check(&ctx).unwrap();
877 assert!(result.is_empty());
878 }
879
880 #[test]
881 fn test_complex_nesting() {
882 let rule = MD005ListIndent::default();
883 let content = "\
884* Level 1
885 * Level 2
886 * Level 3
887 * Back to 2
888 1. Ordered 3
889 2. Still 3
890* Back to 1";
891 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
892 let result = rule.check(&ctx).unwrap();
893 assert!(result.is_empty());
894 }
895
896 #[test]
897 fn test_invalid_complex_nesting() {
898 let rule = MD005ListIndent::default();
899 let content = "\
900* Level 1
901 * Level 2
902 * Level 3
903 * Back to 2
904 1. Ordered 3
905 2. Still 3
906* Back to 1";
907 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
908 let result = rule.check(&ctx).unwrap();
909 assert_eq!(result.len(), 1);
911 assert!(
912 result[0].message.contains("Expected indentation of 5 spaces, found 6")
913 || result[0].message.contains("Expected indentation of 6 spaces, found 5")
914 );
915 }
916
917 #[test]
918 fn test_with_lint_context() {
919 let rule = MD005ListIndent::default();
920
921 let content = "* Item 1\n* Item 2\n * Nested item\n * Another nested item";
923 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
924 let result = rule.check(&ctx).unwrap();
925 assert!(result.is_empty());
926
927 let content = "* Item 1\n* Item 2\n * Nested item\n * Another nested item";
929 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
930 let result = rule.check(&ctx).unwrap();
931 assert!(!result.is_empty()); let content = "* Item 1\n * Nested item\n * Another nested item with wrong indent";
935 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
936 let result = rule.check(&ctx).unwrap();
937 assert!(!result.is_empty()); }
939
940 #[test]
942 fn test_list_with_continuations() {
943 let rule = MD005ListIndent::default();
944 let content = "\
945* Item 1
946 This is a continuation
947 of the first item
948 * Nested item
949 with its own continuation
950* Item 2";
951 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
952 let result = rule.check(&ctx).unwrap();
953 assert!(result.is_empty());
954 }
955
956 #[test]
957 fn test_list_in_blockquote() {
958 let rule = MD005ListIndent::default();
959 let content = "\
960> * Item 1
961> * Nested 1
962> * Nested 2
963> * Item 2";
964 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
965 let result = rule.check(&ctx).unwrap();
966
967 assert!(
969 result.is_empty(),
970 "Expected no warnings for correctly indented blockquote list, got: {result:?}"
971 );
972 }
973
974 #[test]
975 fn test_list_with_code_blocks() {
976 let rule = MD005ListIndent::default();
977 let content = "\
978* Item 1
979 ```
980 code block
981 ```
982 * Nested item
983* Item 2";
984 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
985 let result = rule.check(&ctx).unwrap();
986 assert!(result.is_empty());
987 }
988
989 #[test]
990 fn test_list_with_tabs() {
991 let rule = MD005ListIndent::default();
992 let content = "* Item 1\n * Wrong indent (3 spaces)\n * Correct indent (2 spaces)";
996 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
997 let result = rule.check(&ctx).unwrap();
998 assert!(!result.is_empty());
1000 }
1001
1002 #[test]
1003 fn test_inconsistent_at_same_level() {
1004 let rule = MD005ListIndent::default();
1005 let content = "\
1006* Item 1
1007 * Nested 1
1008 * Nested 2
1009 * Wrong indent for same level
1010 * Nested 3";
1011 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1012 let result = rule.check(&ctx).unwrap();
1013 assert!(!result.is_empty());
1014 assert!(result.iter().any(|w| w.line == 4));
1016 }
1017
1018 #[test]
1019 fn test_zero_indent_top_level() {
1020 let rule = MD005ListIndent::default();
1021 let content = concat!(" * Wrong indent\n", "* Correct\n", " * Nested");
1023 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1024 let result = rule.check(&ctx).unwrap();
1025
1026 assert!(!result.is_empty());
1028 assert!(result.iter().any(|w| w.line == 1));
1029 }
1030
1031 #[test]
1032 fn test_fix_preserves_content() {
1033 let rule = MD005ListIndent::default();
1034 let content = "\
1035* Item with **bold** and *italic*
1036 * Wrong indent with `code`
1037 * Also wrong with [link](url)";
1038 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1039 let fixed = rule.fix(&ctx).unwrap();
1040 assert!(fixed.contains("**bold**"));
1041 assert!(fixed.contains("*italic*"));
1042 assert!(fixed.contains("`code`"));
1043 assert!(fixed.contains("[link](url)"));
1044 }
1045
1046 #[test]
1047 fn test_deeply_nested_lists() {
1048 let rule = MD005ListIndent::default();
1049 let content = "\
1050* L1
1051 * L2
1052 * L3
1053 * L4
1054 * L5
1055 * L6";
1056 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1057 let result = rule.check(&ctx).unwrap();
1058 assert!(result.is_empty());
1059 }
1060
1061 #[test]
1062 fn test_fix_multiple_issues() {
1063 let rule = MD005ListIndent::default();
1064 let content = "\
1065* Item 1
1066 * Wrong 1
1067 * Wrong 2
1068 * Wrong 3
1069 * Correct
1070 * Wrong 4";
1071 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1072 let fixed = rule.fix(&ctx).unwrap();
1073 let lines: Vec<&str> = fixed.lines().collect();
1075 assert_eq!(lines[0], "* Item 1");
1076 assert!(lines[1].starts_with(" * ") || lines[1].starts_with("* "));
1078 }
1079
1080 #[test]
1081 fn test_performance_large_document() {
1082 let rule = MD005ListIndent::default();
1083 let mut content = String::new();
1084 for i in 0..100 {
1085 content.push_str(&format!("* Item {i}\n"));
1086 content.push_str(&format!(" * Nested {i}\n"));
1087 }
1088 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1089 let result = rule.check(&ctx).unwrap();
1090 assert!(result.is_empty());
1091 }
1092
1093 #[test]
1094 fn test_column_positions() {
1095 let rule = MD005ListIndent::default();
1096 let content = " * Wrong indent";
1097 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1098 let result = rule.check(&ctx).unwrap();
1099 assert_eq!(result.len(), 1);
1100 assert_eq!(result[0].column, 1, "Expected column 1, got {}", result[0].column);
1101 assert_eq!(
1102 result[0].end_column, 2,
1103 "Expected end_column 2, got {}",
1104 result[0].end_column
1105 );
1106 }
1107
1108 #[test]
1109 fn test_should_skip() {
1110 let rule = MD005ListIndent::default();
1111
1112 let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard, None);
1114 assert!(rule.should_skip(&ctx));
1115
1116 let ctx = LintContext::new("Just plain text", crate::config::MarkdownFlavor::Standard, None);
1118 assert!(rule.should_skip(&ctx));
1119
1120 let ctx = LintContext::new("* List item", crate::config::MarkdownFlavor::Standard, None);
1122 assert!(!rule.should_skip(&ctx));
1123
1124 let ctx = LintContext::new("1. Ordered list", crate::config::MarkdownFlavor::Standard, None);
1125 assert!(!rule.should_skip(&ctx));
1126 }
1127
1128 #[test]
1129 fn test_should_skip_validation() {
1130 let rule = MD005ListIndent::default();
1131 let content = "* List item";
1132 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1133 assert!(!rule.should_skip(&ctx));
1134
1135 let content = "No lists here";
1136 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1137 assert!(rule.should_skip(&ctx));
1138 }
1139
1140 #[test]
1141 fn test_edge_case_single_space_indent() {
1142 let rule = MD005ListIndent::default();
1143 let content = "\
1144* Item 1
1145 * Single space - wrong
1146 * Two spaces - correct";
1147 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1148 let result = rule.check(&ctx).unwrap();
1149 assert_eq!(result.len(), 2);
1152 assert!(result.iter().any(|w| w.line == 2 && w.message.contains("found 1")));
1153 }
1154
1155 #[test]
1156 fn test_edge_case_three_space_indent() {
1157 let rule = MD005ListIndent::default();
1158 let content = "\
1159* Item 1
1160 * Three spaces - first establishes pattern
1161 * Two spaces - inconsistent with established pattern";
1162 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1163 let result = rule.check(&ctx).unwrap();
1164 assert_eq!(result.len(), 1);
1168 assert!(result.iter().any(|w| w.line == 3 && w.message.contains("found 2")));
1169 }
1170
1171 #[test]
1172 fn test_nested_bullets_under_numbered_items() {
1173 let rule = MD005ListIndent::default();
1174 let content = "\
11751. **Active Directory/LDAP**
1176 - User authentication and directory services
1177 - LDAP for user information and validation
1178
11792. **Oracle Unified Directory (OUD)**
1180 - Extended user directory services
1181 - Verification of project account presence and changes";
1182 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1183 let result = rule.check(&ctx).unwrap();
1184 assert!(
1186 result.is_empty(),
1187 "Expected no warnings for bullets with 3 spaces under numbered items, got: {result:?}"
1188 );
1189 }
1190
1191 #[test]
1192 fn test_nested_bullets_under_numbered_items_wrong_indent() {
1193 let rule = MD005ListIndent::default();
1194 let content = "\
11951. **Active Directory/LDAP**
1196 - Wrong: only 2 spaces
1197 - Correct: 3 spaces";
1198 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1199 let result = rule.check(&ctx).unwrap();
1200 assert_eq!(
1202 result.len(),
1203 1,
1204 "Expected 1 warning, got {}. Warnings: {:?}",
1205 result.len(),
1206 result
1207 );
1208 assert!(
1210 result
1211 .iter()
1212 .any(|w| (w.line == 2 && w.message.contains("found 2"))
1213 || (w.line == 3 && w.message.contains("found 3")))
1214 );
1215 }
1216
1217 #[test]
1218 fn test_regular_nested_bullets_still_work() {
1219 let rule = MD005ListIndent::default();
1220 let content = "\
1221* Top level
1222 * Second level (2 spaces is correct for bullets under bullets)
1223 * Third level (4 spaces)";
1224 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1225 let result = rule.check(&ctx).unwrap();
1226 assert!(
1228 result.is_empty(),
1229 "Expected no warnings for regular bullet nesting, got: {result:?}"
1230 );
1231 }
1232
1233 #[test]
1234 fn test_fix_range_accuracy() {
1235 let rule = MD005ListIndent::default();
1236 let content = " * Wrong indent";
1237 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1238 let result = rule.check(&ctx).unwrap();
1239 assert_eq!(result.len(), 1);
1240
1241 let fix = result[0].fix.as_ref().unwrap();
1242 assert_eq!(fix.replacement, "");
1244 }
1245
1246 #[test]
1247 fn test_four_space_indent_pattern() {
1248 let rule = MD005ListIndent::default();
1249 let content = "\
1250* Item 1
1251 * Item 2 with 4 spaces
1252 * Item 3 with 8 spaces
1253 * Item 4 with 4 spaces";
1254 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1255 let result = rule.check(&ctx).unwrap();
1256 assert!(
1258 result.is_empty(),
1259 "MD005 should accept consistent 4-space indentation pattern, got {} warnings",
1260 result.len()
1261 );
1262 }
1263
1264 #[test]
1265 fn test_issue_64_scenario() {
1266 let rule = MD005ListIndent::default();
1268 let content = "\
1269* Top level item
1270 * Sub item with 4 spaces (as configured in MD007)
1271 * Nested sub item with 8 spaces
1272 * Another sub item with 4 spaces
1273* Another top level";
1274
1275 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1276 let result = rule.check(&ctx).unwrap();
1277
1278 assert!(
1280 result.is_empty(),
1281 "MD005 should accept 4-space indentation when that's the pattern being used. Got {} warnings",
1282 result.len()
1283 );
1284 }
1285
1286 #[test]
1287 fn test_continuation_content_scenario() {
1288 let rule = MD005ListIndent::default();
1289 let content = "\
1290- **Changes to how the Python version is inferred** ([#16319](example))
1291
1292 In previous versions of Ruff, you could specify your Python version with:
1293
1294 - The `target-version` option in a `ruff.toml` file
1295 - The `project.requires-python` field in a `pyproject.toml` file";
1296
1297 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1298
1299 let result = rule.check(&ctx).unwrap();
1300
1301 assert!(
1303 result.is_empty(),
1304 "MD005 should not flag continuation content lists, got {} warnings: {:?}",
1305 result.len(),
1306 result
1307 );
1308 }
1309
1310 #[test]
1311 fn test_multiple_continuation_lists_scenario() {
1312 let rule = MD005ListIndent::default();
1313 let content = "\
1314- **Changes to how the Python version is inferred** ([#16319](example))
1315
1316 In previous versions of Ruff, you could specify your Python version with:
1317
1318 - The `target-version` option in a `ruff.toml` file
1319 - The `project.requires-python` field in a `pyproject.toml` file
1320
1321 In v0.10, config discovery has been updated to address this issue:
1322
1323 - If Ruff finds a `ruff.toml` file without a `target-version`, it will check
1324 - If Ruff finds a user-level configuration, the `requires-python` field will take precedence
1325 - If there is no config file, Ruff will search for the closest `pyproject.toml`";
1326
1327 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1328
1329 let result = rule.check(&ctx).unwrap();
1330
1331 assert!(
1333 result.is_empty(),
1334 "MD005 should not flag continuation content lists, got {} warnings: {:?}",
1335 result.len(),
1336 result
1337 );
1338 }
1339
1340 #[test]
1341 fn test_issue_115_sublist_after_code_block() {
1342 let rule = MD005ListIndent::default();
1343 let content = "\
13441. List item 1
1345
1346 ```rust
1347 fn foo() {}
1348 ```
1349
1350 Sublist:
1351
1352 - A
1353 - B
1354";
1355 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1356 let result = rule.check(&ctx).unwrap();
1357 assert!(
1361 result.is_empty(),
1362 "Expected no warnings for sub-list after code block in list item, got {} warnings: {:?}",
1363 result.len(),
1364 result
1365 );
1366 }
1367
1368 #[test]
1369 fn test_edge_case_continuation_at_exact_boundary() {
1370 let rule = MD005ListIndent::default();
1371 let content = "\
1373* Item (content at column 2)
1374 Text at column 2 (exact boundary - continuation)
1375 * Sub at column 2";
1376 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1377 let result = rule.check(&ctx).unwrap();
1378 assert!(
1380 result.is_empty(),
1381 "Expected no warnings when text and sub-list are at exact parent content_column, got: {result:?}"
1382 );
1383 }
1384
1385 #[test]
1386 fn test_edge_case_unicode_in_continuation() {
1387 let rule = MD005ListIndent::default();
1388 let content = "\
1389* Parent
1390 Text with emoji 😀 and Unicode ñ characters
1391 * Sub-list should still work";
1392 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1393 let result = rule.check(&ctx).unwrap();
1394 assert!(
1396 result.is_empty(),
1397 "Expected no warnings with Unicode in continuation content, got: {result:?}"
1398 );
1399 }
1400
1401 #[test]
1402 fn test_edge_case_large_empty_line_gap() {
1403 let rule = MD005ListIndent::default();
1404 let content = "\
1405* Parent at line 1
1406 Continuation text
1407
1408
1409
1410 More continuation after many empty lines
1411
1412 * Child after gap
1413 * Another child";
1414 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1415 let result = rule.check(&ctx).unwrap();
1416 assert!(
1418 result.is_empty(),
1419 "Expected no warnings with large gaps in continuation content, got: {result:?}"
1420 );
1421 }
1422
1423 #[test]
1424 fn test_edge_case_multiple_continuation_blocks_varying_indent() {
1425 let rule = MD005ListIndent::default();
1426 let content = "\
1427* Parent (content at column 2)
1428 First paragraph at column 2
1429 Indented quote at column 4
1430 Back to column 2
1431 * Sub-list at column 2";
1432 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1433 let result = rule.check(&ctx).unwrap();
1434 assert!(
1436 result.is_empty(),
1437 "Expected no warnings with varying continuation indent, got: {result:?}"
1438 );
1439 }
1440
1441 #[test]
1442 fn test_edge_case_deep_nesting_no_continuation() {
1443 let rule = MD005ListIndent::default();
1444 let content = "\
1445* Parent
1446 * Immediate child (no continuation text before)
1447 * Grandchild
1448 * Great-grandchild
1449 * Great-great-grandchild
1450 * Another child at level 2";
1451 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1452 let result = rule.check(&ctx).unwrap();
1453 assert!(
1455 result.is_empty(),
1456 "Expected no warnings for deep nesting without continuation, got: {result:?}"
1457 );
1458 }
1459
1460 #[test]
1461 fn test_edge_case_blockquote_continuation_content() {
1462 let rule = MD005ListIndent::default();
1463 let content = "\
1464> * Parent in blockquote
1465> Continuation in blockquote
1466> * Sub-list in blockquote
1467> * Another sub-list";
1468 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1469 let result = rule.check(&ctx).unwrap();
1470 assert!(
1472 result.is_empty(),
1473 "Expected no warnings for blockquote continuation, got: {result:?}"
1474 );
1475 }
1476
1477 #[test]
1478 fn test_edge_case_one_space_less_than_content_column() {
1479 let rule = MD005ListIndent::default();
1480 let content = "\
1481* Parent (content at column 2)
1482 Text at column 1 (one less than content_column - NOT continuation)
1483 * Child";
1484 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1485 let result = rule.check(&ctx).unwrap();
1486 assert!(
1492 result.is_empty() || !result.is_empty(),
1493 "Test should complete without panic"
1494 );
1495 }
1496
1497 #[test]
1498 fn test_edge_case_multiple_code_blocks_different_indentation() {
1499 let rule = MD005ListIndent::default();
1500 let content = "\
1501* Parent
1502 ```
1503 code at 2 spaces
1504 ```
1505 ```
1506 code at 4 spaces
1507 ```
1508 * Sub-list should not be confused";
1509 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1510 let result = rule.check(&ctx).unwrap();
1511 assert!(
1513 result.is_empty(),
1514 "Expected no warnings with multiple code blocks, got: {result:?}"
1515 );
1516 }
1517
1518 #[test]
1519 fn test_performance_very_large_document() {
1520 let rule = MD005ListIndent::default();
1521 let mut content = String::new();
1522
1523 for i in 0..1000 {
1525 content.push_str(&format!("* Item {i}\n"));
1526 content.push_str(&format!(" * Nested {i}\n"));
1527 if i % 10 == 0 {
1528 content.push_str(" Some continuation text\n");
1529 }
1530 }
1531
1532 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1533
1534 let start = std::time::Instant::now();
1536 let result = rule.check(&ctx).unwrap();
1537 let elapsed = start.elapsed();
1538
1539 assert!(result.is_empty());
1540 println!("Processed 1000 list items in {elapsed:?}");
1541 assert!(
1544 elapsed.as_secs() < 1,
1545 "Should complete in under 1 second, took {elapsed:?}"
1546 );
1547 }
1548
1549 #[test]
1550 fn test_ordered_list_variable_marker_width() {
1551 let rule = MD005ListIndent::default();
1556 let content = "\
15571. One
1558 - One
1559 - Two
15602. Two
1561 - One
15623. Three
1563 - One
15644. Four
1565 - One
15665. Five
1567 - One
15686. Six
1569 - One
15707. Seven
1571 - One
15728. Eight
1573 - One
15749. Nine
1575 - One
157610. Ten
1577 - One";
1578 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1579 let result = rule.check(&ctx).unwrap();
1580 assert!(
1581 result.is_empty(),
1582 "Expected no warnings for ordered list with variable marker widths, got: {result:?}"
1583 );
1584 }
1585
1586 #[test]
1587 fn test_ordered_list_inconsistent_siblings() {
1588 let rule = MD005ListIndent::default();
1590 let content = "\
15911. Item one
1592 - First sublist at 3 spaces
1593 - Second sublist at 2 spaces (inconsistent)
1594 - Third sublist at 3 spaces";
1595 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1596 let result = rule.check(&ctx).unwrap();
1597 assert_eq!(
1599 result.len(),
1600 1,
1601 "Expected 1 warning for inconsistent sibling indent, got: {result:?}"
1602 );
1603 assert!(result[0].message.contains("Expected indentation of 3"));
1604 }
1605
1606 #[test]
1607 fn test_ordered_list_single_sublist_no_warning() {
1608 let rule = MD005ListIndent::default();
1611 let content = "\
161210. Item ten
1613 - Only sublist at 3 spaces";
1614 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1615 let result = rule.check(&ctx).unwrap();
1616 assert!(
1618 result.is_empty(),
1619 "Expected no warnings for single sublist item, got: {result:?}"
1620 );
1621 }
1622
1623 #[test]
1624 fn test_sublists_grouped_by_parent_content_column() {
1625 let rule = MD005ListIndent::default();
1629 let content = "\
16309. Item nine
1631 - First sublist at 3 spaces
1632 - Second sublist at 3 spaces
1633 - Third sublist at 3 spaces
163410. Item ten
1635 - First sublist at 4 spaces
1636 - Second sublist at 4 spaces
1637 - Third sublist at 4 spaces";
1638 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1639 let result = rule.check(&ctx).unwrap();
1640 assert!(
1643 result.is_empty(),
1644 "Expected no warnings for sublists grouped by parent, got: {result:?}"
1645 );
1646 }
1647
1648 #[test]
1649 fn test_inconsistent_indent_within_parent_group() {
1650 let rule = MD005ListIndent::default();
1652 let content = "\
165310. Item ten
1654 - First sublist at 4 spaces
1655 - Second sublist at 3 spaces (inconsistent!)
1656 - Third sublist at 4 spaces";
1657 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1658 let result = rule.check(&ctx).unwrap();
1659 assert_eq!(
1661 result.len(),
1662 1,
1663 "Expected 1 warning for inconsistent indent within parent group, got: {result:?}"
1664 );
1665 assert!(result[0].line == 3);
1666 assert!(result[0].message.contains("Expected indentation of 4"));
1667 }
1668}