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 all_list_items = Vec::new();
454
455 for list_block in group {
456 for &item_line in &list_block.item_lines {
457 if let Some(line_info) = ctx.line_info(item_line)
458 && let Some(list_item) = &line_info.list_item
459 {
460 let effective_indent = if let Some(blockquote) = &line_info.blockquote {
462 list_item.marker_column.saturating_sub(blockquote.nesting_level * 2)
464 } else {
465 list_item.marker_column
467 };
468
469 if self.is_continuation_content(ctx, &cache, item_line, effective_indent) {
471 continue;
472 }
473
474 all_list_items.push((item_line, effective_indent, line_info, list_item));
475 }
476 }
477 }
478
479 if all_list_items.is_empty() {
480 return Ok(());
481 }
482
483 all_list_items.sort_by_key(|(line_num, _, _, _)| *line_num);
485
486 let mut level_map: HashMap<usize, usize> = HashMap::new();
490 let mut level_indents: HashMap<usize, Vec<usize>> = HashMap::new(); let mut indent_to_level: HashMap<usize, (usize, usize)> = HashMap::new();
495
496 for (line_num, indent, _, _) in &all_list_items {
498 let level = if indent_to_level.is_empty() {
499 level_indents.entry(1).or_default().push(*indent);
501 1
502 } else {
503 let mut determined_level = 0;
505
506 if let Some(&(existing_level, _)) = indent_to_level.get(indent) {
508 determined_level = existing_level;
509 } else {
510 let mut best_parent: Option<(usize, usize, usize)> = None; for (&tracked_indent, &(tracked_level, tracked_line)) in &indent_to_level {
516 if tracked_indent < *indent {
517 if best_parent.is_none() || tracked_indent > best_parent.unwrap().0 {
520 best_parent = Some((tracked_indent, tracked_level, tracked_line));
521 }
522 }
523 }
524
525 if let Some((parent_indent, parent_level, _parent_line)) = best_parent {
526 if parent_indent + Self::MIN_CHILD_INDENT_INCREASE <= *indent {
528 determined_level = parent_level + 1;
530 } else if (*indent as i32 - parent_indent as i32).abs() <= Self::SAME_LEVEL_TOLERANCE {
531 determined_level = parent_level;
533 } else {
534 let mut found_similar = false;
538 if let Some(indents_at_level) = level_indents.get(&parent_level) {
539 for &level_indent in indents_at_level {
540 if (level_indent as i32 - *indent as i32).abs() <= Self::SAME_LEVEL_TOLERANCE {
541 determined_level = parent_level;
542 found_similar = true;
543 break;
544 }
545 }
546 }
547 if !found_similar {
548 determined_level = parent_level + 1;
550 }
551 }
552 }
553
554 if determined_level == 0 {
556 determined_level = 1;
557 }
558
559 level_indents.entry(determined_level).or_default().push(*indent);
561 }
562
563 determined_level
564 };
565
566 level_map.insert(*line_num, level);
567 indent_to_level.insert(*indent, (level, *line_num));
569 }
570
571 let mut level_groups: HashMap<usize, Vec<(usize, usize, &crate::lint_context::LineInfo)>> = HashMap::new();
573 for (line_num, indent, line_info, _) in &all_list_items {
574 let level = level_map[line_num];
575 level_groups
576 .entry(level)
577 .or_default()
578 .push((*line_num, *indent, *line_info));
579 }
580
581 for (level, mut group) in level_groups {
583 group.sort_by_key(|(line_num, _, _)| *line_num);
584
585 if level == 1 {
586 for (line_num, indent, line_info) in &group {
588 if *indent != self.top_level_indent {
589 warnings.push(self.create_indent_warning(
590 ctx,
591 *line_num,
592 line_info,
593 *indent,
594 self.top_level_indent,
595 ));
596 }
597 }
598 } else {
599 let parent_content_groups =
602 self.group_by_parent_content_column(level, &group, &all_list_items, &level_map);
603
604 for items in parent_content_groups.values() {
606 self.check_indent_consistency(ctx, items, warnings);
607 }
608 }
609 }
610
611 Ok(())
612 }
613
614 fn check_optimized(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
616 let content = ctx.content;
617
618 if content.is_empty() {
620 return Ok(Vec::new());
621 }
622
623 if ctx.list_blocks.is_empty() {
625 return Ok(Vec::new());
626 }
627
628 let mut warnings = Vec::new();
629
630 let block_groups = self.group_related_list_blocks(&ctx.list_blocks);
633
634 for group in block_groups {
635 self.check_list_block_group(ctx, &group, &mut warnings)?;
636 }
637
638 Ok(warnings)
639 }
640}
641
642impl Rule for MD005ListIndent {
643 fn name(&self) -> &'static str {
644 "MD005"
645 }
646
647 fn description(&self) -> &'static str {
648 "List indentation should be consistent"
649 }
650
651 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
652 self.check_optimized(ctx)
654 }
655
656 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
657 let warnings = self.check(ctx)?;
658 if warnings.is_empty() {
659 return Ok(ctx.content.to_string());
660 }
661
662 let mut warnings_with_fixes: Vec<_> = warnings
664 .into_iter()
665 .filter_map(|w| w.fix.clone().map(|fix| (w, fix)))
666 .collect();
667 warnings_with_fixes.sort_by_key(|(_, fix)| std::cmp::Reverse(fix.range.start));
668
669 let mut content = ctx.content.to_string();
671 for (_, fix) in warnings_with_fixes {
672 if fix.range.start <= content.len() && fix.range.end <= content.len() {
673 content.replace_range(fix.range, &fix.replacement);
674 }
675 }
676
677 Ok(content)
678 }
679
680 fn category(&self) -> RuleCategory {
681 RuleCategory::List
682 }
683
684 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
686 ctx.content.is_empty() || !ctx.lines.iter().any(|line| line.list_item.is_some())
688 }
689
690 fn as_any(&self) -> &dyn std::any::Any {
691 self
692 }
693
694 fn default_config_section(&self) -> Option<(String, toml::Value)> {
695 None
696 }
697
698 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
699 where
700 Self: Sized,
701 {
702 let mut top_level_indent = 0;
704
705 if let Some(md007_config) = config.rules.get("MD007") {
707 if let Some(start_indented) = md007_config.values.get("start-indented")
709 && let Some(start_indented_bool) = start_indented.as_bool()
710 && start_indented_bool
711 {
712 if let Some(start_indent) = md007_config.values.get("start-indent") {
714 if let Some(indent_value) = start_indent.as_integer() {
715 top_level_indent = indent_value as usize;
716 }
717 } else {
718 top_level_indent = 2;
720 }
721 }
722 }
723
724 Box::new(MD005ListIndent { top_level_indent })
725 }
726}
727
728#[cfg(test)]
729mod tests {
730 use super::*;
731 use crate::lint_context::LintContext;
732
733 #[test]
734 fn test_valid_unordered_list() {
735 let rule = MD005ListIndent::default();
736 let content = "\
737* Item 1
738* Item 2
739 * Nested 1
740 * Nested 2
741* Item 3";
742 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
743 let result = rule.check(&ctx).unwrap();
744 assert!(result.is_empty());
745 }
746
747 #[test]
748 fn test_valid_ordered_list() {
749 let rule = MD005ListIndent::default();
750 let content = "\
7511. Item 1
7522. Item 2
753 1. Nested 1
754 2. Nested 2
7553. Item 3";
756 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
757 let result = rule.check(&ctx).unwrap();
758 assert!(result.is_empty());
761 }
762
763 #[test]
764 fn test_invalid_unordered_indent() {
765 let rule = MD005ListIndent::default();
766 let content = "\
767* Item 1
768 * Item 2
769 * Nested 1";
770 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
771 let result = rule.check(&ctx).unwrap();
772 assert_eq!(result.len(), 1);
775 let fixed = rule.fix(&ctx).unwrap();
776 assert_eq!(fixed, "* Item 1\n* Item 2\n * Nested 1");
777 }
778
779 #[test]
780 fn test_invalid_ordered_indent() {
781 let rule = MD005ListIndent::default();
782 let content = "\
7831. Item 1
784 2. Item 2
785 1. Nested 1";
786 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
787 let result = rule.check(&ctx).unwrap();
788 assert_eq!(result.len(), 1);
789 let fixed = rule.fix(&ctx).unwrap();
790 assert_eq!(fixed, "1. Item 1\n2. Item 2\n 1. Nested 1");
794 }
795
796 #[test]
797 fn test_mixed_list_types() {
798 let rule = MD005ListIndent::default();
799 let content = "\
800* Item 1
801 1. Nested ordered
802 * Nested unordered
803* Item 2";
804 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
805 let result = rule.check(&ctx).unwrap();
806 assert!(result.is_empty());
807 }
808
809 #[test]
810 fn test_multiple_levels() {
811 let rule = MD005ListIndent::default();
812 let content = "\
813* Level 1
814 * Level 2
815 * Level 3";
816 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
817 let result = rule.check(&ctx).unwrap();
818 assert!(result.is_empty(), "MD005 should accept consistent indentation pattern");
820 }
821
822 #[test]
823 fn test_empty_lines() {
824 let rule = MD005ListIndent::default();
825 let content = "\
826* Item 1
827
828 * Nested 1
829
830* Item 2";
831 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
832 let result = rule.check(&ctx).unwrap();
833 assert!(result.is_empty());
834 }
835
836 #[test]
837 fn test_no_lists() {
838 let rule = MD005ListIndent::default();
839 let content = "\
840Just some text
841More text
842Even more text";
843 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
844 let result = rule.check(&ctx).unwrap();
845 assert!(result.is_empty());
846 }
847
848 #[test]
849 fn test_complex_nesting() {
850 let rule = MD005ListIndent::default();
851 let content = "\
852* Level 1
853 * Level 2
854 * Level 3
855 * Back to 2
856 1. Ordered 3
857 2. Still 3
858* Back to 1";
859 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
860 let result = rule.check(&ctx).unwrap();
861 assert!(result.is_empty());
862 }
863
864 #[test]
865 fn test_invalid_complex_nesting() {
866 let rule = MD005ListIndent::default();
867 let content = "\
868* Level 1
869 * Level 2
870 * Level 3
871 * Back to 2
872 1. Ordered 3
873 2. Still 3
874* Back to 1";
875 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
876 let result = rule.check(&ctx).unwrap();
877 assert_eq!(result.len(), 1);
879 assert!(
880 result[0].message.contains("Expected indentation of 5 spaces, found 6")
881 || result[0].message.contains("Expected indentation of 6 spaces, found 5")
882 );
883 }
884
885 #[test]
886 fn test_with_lint_context() {
887 let rule = MD005ListIndent::default();
888
889 let content = "* Item 1\n* Item 2\n * Nested item\n * Another nested item";
891 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
892 let result = rule.check(&ctx).unwrap();
893 assert!(result.is_empty());
894
895 let content = "* Item 1\n* Item 2\n * Nested item\n * Another nested item";
897 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
898 let result = rule.check(&ctx).unwrap();
899 assert!(!result.is_empty()); let content = "* Item 1\n * Nested item\n * Another nested item with wrong indent";
903 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
904 let result = rule.check(&ctx).unwrap();
905 assert!(!result.is_empty()); }
907
908 #[test]
910 fn test_list_with_continuations() {
911 let rule = MD005ListIndent::default();
912 let content = "\
913* Item 1
914 This is a continuation
915 of the first item
916 * Nested item
917 with its own continuation
918* Item 2";
919 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
920 let result = rule.check(&ctx).unwrap();
921 assert!(result.is_empty());
922 }
923
924 #[test]
925 fn test_list_in_blockquote() {
926 let rule = MD005ListIndent::default();
927 let content = "\
928> * Item 1
929> * Nested 1
930> * Nested 2
931> * Item 2";
932 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
933 let result = rule.check(&ctx).unwrap();
934
935 assert!(
937 result.is_empty(),
938 "Expected no warnings for correctly indented blockquote list, got: {result:?}"
939 );
940 }
941
942 #[test]
943 fn test_list_with_code_blocks() {
944 let rule = MD005ListIndent::default();
945 let content = "\
946* Item 1
947 ```
948 code block
949 ```
950 * Nested item
951* Item 2";
952 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
953 let result = rule.check(&ctx).unwrap();
954 assert!(result.is_empty());
955 }
956
957 #[test]
958 fn test_list_with_tabs() {
959 let rule = MD005ListIndent::default();
960 let content = "* Item 1\n\t* Tab indented\n * Space indented";
961 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
962 let result = rule.check(&ctx).unwrap();
963 assert!(!result.is_empty());
965 }
966
967 #[test]
968 fn test_inconsistent_at_same_level() {
969 let rule = MD005ListIndent::default();
970 let content = "\
971* Item 1
972 * Nested 1
973 * Nested 2
974 * Wrong indent for same level
975 * Nested 3";
976 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
977 let result = rule.check(&ctx).unwrap();
978 assert!(!result.is_empty());
979 assert!(result.iter().any(|w| w.line == 4));
981 }
982
983 #[test]
984 fn test_zero_indent_top_level() {
985 let rule = MD005ListIndent::default();
986 let content = concat!(" * Wrong indent\n", "* Correct\n", " * Nested");
988 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
989 let result = rule.check(&ctx).unwrap();
990
991 assert!(!result.is_empty());
993 assert!(result.iter().any(|w| w.line == 1));
994 }
995
996 #[test]
997 fn test_fix_preserves_content() {
998 let rule = MD005ListIndent::default();
999 let content = "\
1000* Item with **bold** and *italic*
1001 * Wrong indent with `code`
1002 * Also wrong with [link](url)";
1003 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1004 let fixed = rule.fix(&ctx).unwrap();
1005 assert!(fixed.contains("**bold**"));
1006 assert!(fixed.contains("*italic*"));
1007 assert!(fixed.contains("`code`"));
1008 assert!(fixed.contains("[link](url)"));
1009 }
1010
1011 #[test]
1012 fn test_deeply_nested_lists() {
1013 let rule = MD005ListIndent::default();
1014 let content = "\
1015* L1
1016 * L2
1017 * L3
1018 * L4
1019 * L5
1020 * L6";
1021 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1022 let result = rule.check(&ctx).unwrap();
1023 assert!(result.is_empty());
1024 }
1025
1026 #[test]
1027 fn test_fix_multiple_issues() {
1028 let rule = MD005ListIndent::default();
1029 let content = "\
1030* Item 1
1031 * Wrong 1
1032 * Wrong 2
1033 * Wrong 3
1034 * Correct
1035 * Wrong 4";
1036 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1037 let fixed = rule.fix(&ctx).unwrap();
1038 let lines: Vec<&str> = fixed.lines().collect();
1040 assert_eq!(lines[0], "* Item 1");
1041 assert!(lines[1].starts_with(" * ") || lines[1].starts_with("* "));
1043 }
1044
1045 #[test]
1046 fn test_performance_large_document() {
1047 let rule = MD005ListIndent::default();
1048 let mut content = String::new();
1049 for i in 0..100 {
1050 content.push_str(&format!("* Item {i}\n"));
1051 content.push_str(&format!(" * Nested {i}\n"));
1052 }
1053 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard);
1054 let result = rule.check(&ctx).unwrap();
1055 assert!(result.is_empty());
1056 }
1057
1058 #[test]
1059 fn test_column_positions() {
1060 let rule = MD005ListIndent::default();
1061 let content = " * Wrong indent";
1062 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1063 let result = rule.check(&ctx).unwrap();
1064 assert_eq!(result.len(), 1);
1065 assert_eq!(result[0].column, 1, "Expected column 1, got {}", result[0].column);
1066 assert_eq!(
1067 result[0].end_column, 2,
1068 "Expected end_column 2, got {}",
1069 result[0].end_column
1070 );
1071 }
1072
1073 #[test]
1074 fn test_should_skip() {
1075 let rule = MD005ListIndent::default();
1076
1077 let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard);
1079 assert!(rule.should_skip(&ctx));
1080
1081 let ctx = LintContext::new("Just plain text", crate::config::MarkdownFlavor::Standard);
1083 assert!(rule.should_skip(&ctx));
1084
1085 let ctx = LintContext::new("* List item", crate::config::MarkdownFlavor::Standard);
1087 assert!(!rule.should_skip(&ctx));
1088
1089 let ctx = LintContext::new("1. Ordered list", crate::config::MarkdownFlavor::Standard);
1090 assert!(!rule.should_skip(&ctx));
1091 }
1092
1093 #[test]
1094 fn test_should_skip_validation() {
1095 let rule = MD005ListIndent::default();
1096 let content = "* List item";
1097 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1098 assert!(!rule.should_skip(&ctx));
1099
1100 let content = "No lists here";
1101 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1102 assert!(rule.should_skip(&ctx));
1103 }
1104
1105 #[test]
1106 fn test_edge_case_single_space_indent() {
1107 let rule = MD005ListIndent::default();
1108 let content = "\
1109* Item 1
1110 * Single space - wrong
1111 * Two spaces - correct";
1112 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1113 let result = rule.check(&ctx).unwrap();
1114 assert_eq!(result.len(), 2);
1117 assert!(result.iter().any(|w| w.line == 2 && w.message.contains("found 1")));
1118 }
1119
1120 #[test]
1121 fn test_edge_case_three_space_indent() {
1122 let rule = MD005ListIndent::default();
1123 let content = "\
1124* Item 1
1125 * Three spaces - first establishes pattern
1126 * Two spaces - inconsistent with established pattern";
1127 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1128 let result = rule.check(&ctx).unwrap();
1129 assert_eq!(result.len(), 1);
1133 assert!(result.iter().any(|w| w.line == 3 && w.message.contains("found 2")));
1134 }
1135
1136 #[test]
1137 fn test_nested_bullets_under_numbered_items() {
1138 let rule = MD005ListIndent::default();
1139 let content = "\
11401. **Active Directory/LDAP**
1141 - User authentication and directory services
1142 - LDAP for user information and validation
1143
11442. **Oracle Unified Directory (OUD)**
1145 - Extended user directory services
1146 - Verification of project account presence and changes";
1147 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1148 let result = rule.check(&ctx).unwrap();
1149 assert!(
1151 result.is_empty(),
1152 "Expected no warnings for bullets with 3 spaces under numbered items, got: {result:?}"
1153 );
1154 }
1155
1156 #[test]
1157 fn test_nested_bullets_under_numbered_items_wrong_indent() {
1158 let rule = MD005ListIndent::default();
1159 let content = "\
11601. **Active Directory/LDAP**
1161 - Wrong: only 2 spaces
1162 - Correct: 3 spaces";
1163 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1164 let result = rule.check(&ctx).unwrap();
1165 assert_eq!(
1167 result.len(),
1168 1,
1169 "Expected 1 warning, got {}. Warnings: {:?}",
1170 result.len(),
1171 result
1172 );
1173 assert!(
1175 result
1176 .iter()
1177 .any(|w| (w.line == 2 && w.message.contains("found 2"))
1178 || (w.line == 3 && w.message.contains("found 3")))
1179 );
1180 }
1181
1182 #[test]
1183 fn test_regular_nested_bullets_still_work() {
1184 let rule = MD005ListIndent::default();
1185 let content = "\
1186* Top level
1187 * Second level (2 spaces is correct for bullets under bullets)
1188 * Third level (4 spaces)";
1189 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1190 let result = rule.check(&ctx).unwrap();
1191 assert!(
1193 result.is_empty(),
1194 "Expected no warnings for regular bullet nesting, got: {result:?}"
1195 );
1196 }
1197
1198 #[test]
1199 fn test_fix_range_accuracy() {
1200 let rule = MD005ListIndent::default();
1201 let content = " * Wrong indent";
1202 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1203 let result = rule.check(&ctx).unwrap();
1204 assert_eq!(result.len(), 1);
1205
1206 let fix = result[0].fix.as_ref().unwrap();
1207 assert_eq!(fix.replacement, "");
1209 }
1210
1211 #[test]
1212 fn test_four_space_indent_pattern() {
1213 let rule = MD005ListIndent::default();
1214 let content = "\
1215* Item 1
1216 * Item 2 with 4 spaces
1217 * Item 3 with 8 spaces
1218 * Item 4 with 4 spaces";
1219 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1220 let result = rule.check(&ctx).unwrap();
1221 assert!(
1223 result.is_empty(),
1224 "MD005 should accept consistent 4-space indentation pattern, got {} warnings",
1225 result.len()
1226 );
1227 }
1228
1229 #[test]
1230 fn test_issue_64_scenario() {
1231 let rule = MD005ListIndent::default();
1233 let content = "\
1234* Top level item
1235 * Sub item with 4 spaces (as configured in MD007)
1236 * Nested sub item with 8 spaces
1237 * Another sub item with 4 spaces
1238* Another top level";
1239
1240 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1241 let result = rule.check(&ctx).unwrap();
1242
1243 assert!(
1245 result.is_empty(),
1246 "MD005 should accept 4-space indentation when that's the pattern being used. Got {} warnings",
1247 result.len()
1248 );
1249 }
1250
1251 #[test]
1252 fn test_continuation_content_scenario() {
1253 let rule = MD005ListIndent::default();
1254 let content = "\
1255- **Changes to how the Python version is inferred** ([#16319](example))
1256
1257 In previous versions of Ruff, you could specify your Python version with:
1258
1259 - The `target-version` option in a `ruff.toml` file
1260 - The `project.requires-python` field in a `pyproject.toml` file";
1261
1262 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1263
1264 let result = rule.check(&ctx).unwrap();
1265
1266 assert!(
1268 result.is_empty(),
1269 "MD005 should not flag continuation content lists, got {} warnings: {:?}",
1270 result.len(),
1271 result
1272 );
1273 }
1274
1275 #[test]
1276 fn test_multiple_continuation_lists_scenario() {
1277 let rule = MD005ListIndent::default();
1278 let content = "\
1279- **Changes to how the Python version is inferred** ([#16319](example))
1280
1281 In previous versions of Ruff, you could specify your Python version with:
1282
1283 - The `target-version` option in a `ruff.toml` file
1284 - The `project.requires-python` field in a `pyproject.toml` file
1285
1286 In v0.10, config discovery has been updated to address this issue:
1287
1288 - If Ruff finds a `ruff.toml` file without a `target-version`, it will check
1289 - If Ruff finds a user-level configuration, the `requires-python` field will take precedence
1290 - If there is no config file, Ruff will search for the closest `pyproject.toml`";
1291
1292 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1293
1294 let result = rule.check(&ctx).unwrap();
1295
1296 assert!(
1298 result.is_empty(),
1299 "MD005 should not flag continuation content lists, got {} warnings: {:?}",
1300 result.len(),
1301 result
1302 );
1303 }
1304
1305 #[test]
1306 fn test_issue_115_sublist_after_code_block() {
1307 let rule = MD005ListIndent::default();
1308 let content = "\
13091. List item 1
1310
1311 ```rust
1312 fn foo() {}
1313 ```
1314
1315 Sublist:
1316
1317 - A
1318 - B
1319";
1320 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1321 let result = rule.check(&ctx).unwrap();
1322 assert!(
1326 result.is_empty(),
1327 "Expected no warnings for sub-list after code block in list item, got {} warnings: {:?}",
1328 result.len(),
1329 result
1330 );
1331 }
1332
1333 #[test]
1334 fn test_edge_case_continuation_at_exact_boundary() {
1335 let rule = MD005ListIndent::default();
1336 let content = "\
1338* Item (content at column 2)
1339 Text at column 2 (exact boundary - continuation)
1340 * Sub at column 2";
1341 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1342 let result = rule.check(&ctx).unwrap();
1343 assert!(
1345 result.is_empty(),
1346 "Expected no warnings when text and sub-list are at exact parent content_column, got: {result:?}"
1347 );
1348 }
1349
1350 #[test]
1351 fn test_edge_case_unicode_in_continuation() {
1352 let rule = MD005ListIndent::default();
1353 let content = "\
1354* Parent
1355 Text with emoji 😀 and Unicode ñ characters
1356 * Sub-list should still work";
1357 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1358 let result = rule.check(&ctx).unwrap();
1359 assert!(
1361 result.is_empty(),
1362 "Expected no warnings with Unicode in continuation content, got: {result:?}"
1363 );
1364 }
1365
1366 #[test]
1367 fn test_edge_case_large_empty_line_gap() {
1368 let rule = MD005ListIndent::default();
1369 let content = "\
1370* Parent at line 1
1371 Continuation text
1372
1373
1374
1375 More continuation after many empty lines
1376
1377 * Child after gap
1378 * Another child";
1379 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1380 let result = rule.check(&ctx).unwrap();
1381 assert!(
1383 result.is_empty(),
1384 "Expected no warnings with large gaps in continuation content, got: {result:?}"
1385 );
1386 }
1387
1388 #[test]
1389 fn test_edge_case_multiple_continuation_blocks_varying_indent() {
1390 let rule = MD005ListIndent::default();
1391 let content = "\
1392* Parent (content at column 2)
1393 First paragraph at column 2
1394 Indented quote at column 4
1395 Back to column 2
1396 * Sub-list at column 2";
1397 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1398 let result = rule.check(&ctx).unwrap();
1399 assert!(
1401 result.is_empty(),
1402 "Expected no warnings with varying continuation indent, got: {result:?}"
1403 );
1404 }
1405
1406 #[test]
1407 fn test_edge_case_deep_nesting_no_continuation() {
1408 let rule = MD005ListIndent::default();
1409 let content = "\
1410* Parent
1411 * Immediate child (no continuation text before)
1412 * Grandchild
1413 * Great-grandchild
1414 * Great-great-grandchild
1415 * Another child at level 2";
1416 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1417 let result = rule.check(&ctx).unwrap();
1418 assert!(
1420 result.is_empty(),
1421 "Expected no warnings for deep nesting without continuation, got: {result:?}"
1422 );
1423 }
1424
1425 #[test]
1426 fn test_edge_case_blockquote_continuation_content() {
1427 let rule = MD005ListIndent::default();
1428 let content = "\
1429> * Parent in blockquote
1430> Continuation in blockquote
1431> * Sub-list in blockquote
1432> * Another sub-list";
1433 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1434 let result = rule.check(&ctx).unwrap();
1435 assert!(
1437 result.is_empty(),
1438 "Expected no warnings for blockquote continuation, got: {result:?}"
1439 );
1440 }
1441
1442 #[test]
1443 fn test_edge_case_one_space_less_than_content_column() {
1444 let rule = MD005ListIndent::default();
1445 let content = "\
1446* Parent (content at column 2)
1447 Text at column 1 (one less than content_column - NOT continuation)
1448 * Child";
1449 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1450 let result = rule.check(&ctx).unwrap();
1451 assert!(
1457 result.is_empty() || !result.is_empty(),
1458 "Test should complete without panic"
1459 );
1460 }
1461
1462 #[test]
1463 fn test_edge_case_multiple_code_blocks_different_indentation() {
1464 let rule = MD005ListIndent::default();
1465 let content = "\
1466* Parent
1467 ```
1468 code at 2 spaces
1469 ```
1470 ```
1471 code at 4 spaces
1472 ```
1473 * Sub-list should not be confused";
1474 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1475 let result = rule.check(&ctx).unwrap();
1476 assert!(
1478 result.is_empty(),
1479 "Expected no warnings with multiple code blocks, got: {result:?}"
1480 );
1481 }
1482
1483 #[test]
1484 fn test_performance_very_large_document() {
1485 let rule = MD005ListIndent::default();
1486 let mut content = String::new();
1487
1488 for i in 0..1000 {
1490 content.push_str(&format!("* Item {i}\n"));
1491 content.push_str(&format!(" * Nested {i}\n"));
1492 if i % 10 == 0 {
1493 content.push_str(" Some continuation text\n");
1494 }
1495 }
1496
1497 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard);
1498
1499 let start = std::time::Instant::now();
1501 let result = rule.check(&ctx).unwrap();
1502 let elapsed = start.elapsed();
1503
1504 assert!(result.is_empty());
1505 println!("Processed 1000 list items in {elapsed:?}");
1506 assert!(
1509 elapsed.as_secs() < 1,
1510 "Should complete in under 1 second, took {elapsed:?}"
1511 );
1512 }
1513
1514 #[test]
1515 fn test_ordered_list_variable_marker_width() {
1516 let rule = MD005ListIndent::default();
1521 let content = "\
15221. One
1523 - One
1524 - Two
15252. Two
1526 - One
15273. Three
1528 - One
15294. Four
1530 - One
15315. Five
1532 - One
15336. Six
1534 - One
15357. Seven
1536 - One
15378. Eight
1538 - One
15399. Nine
1540 - One
154110. Ten
1542 - One";
1543 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1544 let result = rule.check(&ctx).unwrap();
1545 assert!(
1546 result.is_empty(),
1547 "Expected no warnings for ordered list with variable marker widths, got: {result:?}"
1548 );
1549 }
1550
1551 #[test]
1552 fn test_ordered_list_inconsistent_siblings() {
1553 let rule = MD005ListIndent::default();
1555 let content = "\
15561. Item one
1557 - First sublist at 3 spaces
1558 - Second sublist at 2 spaces (inconsistent)
1559 - Third sublist at 3 spaces";
1560 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1561 let result = rule.check(&ctx).unwrap();
1562 assert_eq!(
1564 result.len(),
1565 1,
1566 "Expected 1 warning for inconsistent sibling indent, got: {result:?}"
1567 );
1568 assert!(result[0].message.contains("Expected indentation of 3"));
1569 }
1570
1571 #[test]
1572 fn test_ordered_list_single_sublist_no_warning() {
1573 let rule = MD005ListIndent::default();
1576 let content = "\
157710. Item ten
1578 - Only sublist at 3 spaces";
1579 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1580 let result = rule.check(&ctx).unwrap();
1581 assert!(
1583 result.is_empty(),
1584 "Expected no warnings for single sublist item, got: {result:?}"
1585 );
1586 }
1587
1588 #[test]
1589 fn test_sublists_grouped_by_parent_content_column() {
1590 let rule = MD005ListIndent::default();
1594 let content = "\
15959. Item nine
1596 - First sublist at 3 spaces
1597 - Second sublist at 3 spaces
1598 - Third sublist at 3 spaces
159910. Item ten
1600 - First sublist at 4 spaces
1601 - Second sublist at 4 spaces
1602 - Third sublist at 4 spaces";
1603 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1604 let result = rule.check(&ctx).unwrap();
1605 assert!(
1608 result.is_empty(),
1609 "Expected no warnings for sublists grouped by parent, got: {result:?}"
1610 );
1611 }
1612
1613 #[test]
1614 fn test_inconsistent_indent_within_parent_group() {
1615 let rule = MD005ListIndent::default();
1617 let content = "\
161810. Item ten
1619 - First sublist at 4 spaces
1620 - Second sublist at 3 spaces (inconsistent!)
1621 - Third sublist at 4 spaces";
1622 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1623 let result = rule.check(&ctx).unwrap();
1624 assert_eq!(
1626 result.len(),
1627 1,
1628 "Expected 1 warning for inconsistent indent within parent group, got: {result:?}"
1629 );
1630 assert!(result[0].line == 3);
1631 assert!(result[0].message.contains("Expected indentation of 4"));
1632 }
1633}