1use crate::utils::blockquote::effective_indent_in_blockquote;
7use crate::utils::range_utils::calculate_match_range;
8
9use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
10use std::collections::HashMap;
12use toml;
13
14type ParentContentGroups<'a> = HashMap<(usize, bool), Vec<(usize, usize, &'a crate::lint_context::LineInfo)>>;
17
18#[derive(Clone, Default)]
20pub struct MD005ListIndent {
21 top_level_indent: usize,
23}
24
25struct LineCacheInfo {
27 indentation: Vec<usize>,
29 blockquote_levels: Vec<usize>,
31 line_contents: Vec<String>,
33 flags: Vec<u8>,
35 parent_map: HashMap<usize, usize>,
38}
39
40const FLAG_HAS_CONTENT: u8 = 1;
41const FLAG_IS_LIST_ITEM: u8 = 2;
42
43impl LineCacheInfo {
44 fn new(ctx: &crate::lint_context::LintContext) -> Self {
46 let total_lines = ctx.lines.len();
47 let mut indentation = Vec::with_capacity(total_lines);
48 let mut blockquote_levels = Vec::with_capacity(total_lines);
49 let mut line_contents = Vec::with_capacity(total_lines);
50 let mut flags = Vec::with_capacity(total_lines);
51 let mut parent_map = HashMap::new();
52
53 let mut indent_stack: Vec<(usize, usize)> = Vec::new();
65
66 for (idx, line_info) in ctx.lines.iter().enumerate() {
67 let line_content = line_info.content(ctx.content);
68 let content = line_content.trim_start();
69 let line_indent = line_info.byte_len - content.len();
70
71 indentation.push(line_indent);
72
73 let bq_level = line_info.blockquote.as_ref().map_or(0, |bq| bq.nesting_level);
75 blockquote_levels.push(bq_level);
76
77 line_contents.push(line_content.to_string());
79
80 let mut flag = 0u8;
81 if !content.is_empty() {
82 flag |= FLAG_HAS_CONTENT;
83 }
84 if let Some(list_item) = &line_info.list_item {
85 flag |= FLAG_IS_LIST_ITEM;
86
87 let line_num = idx + 1; let marker_column = list_item.marker_column;
89
90 while let Some(&(indent, _)) = indent_stack.last() {
92 if indent < marker_column {
93 break;
94 }
95 indent_stack.pop();
96 }
97
98 if let Some((_, parent_line)) = indent_stack.last() {
99 parent_map.insert(line_num, *parent_line);
100 }
101
102 indent_stack.push((marker_column, line_num));
103 }
104 flags.push(flag);
105 }
106
107 Self {
108 indentation,
109 blockquote_levels,
110 line_contents,
111 flags,
112 parent_map,
113 }
114 }
115
116 fn has_content(&self, idx: usize) -> bool {
118 self.flags.get(idx).is_some_and(|&f| f & FLAG_HAS_CONTENT != 0)
119 }
120
121 fn is_list_item(&self, idx: usize) -> bool {
123 self.flags.get(idx).is_some_and(|&f| f & FLAG_IS_LIST_ITEM != 0)
124 }
125
126 fn blockquote_info(&self, line: usize) -> (usize, usize) {
128 if line == 0 || line > self.line_contents.len() {
129 return (0, 0);
130 }
131 let idx = line - 1;
132 let bq_level = self.blockquote_levels.get(idx).copied().unwrap_or(0);
133 if bq_level == 0 {
134 return (0, 0);
135 }
136 let content = &self.line_contents[idx];
138 let mut prefix_len = 0;
139 let mut found = 0;
140 for c in content.chars() {
141 prefix_len += c.len_utf8();
142 if c == '>' {
143 found += 1;
144 if found == bq_level {
145 if content.get(prefix_len..prefix_len + 1) == Some(" ") {
147 prefix_len += 1;
148 }
149 break;
150 }
151 }
152 }
153 (bq_level, prefix_len)
154 }
155
156 fn find_continuation_indent(
172 &self,
173 start_line: usize,
174 end_line: usize,
175 tight_threshold: usize,
176 loose_threshold: usize,
177 parent_bq_level: usize,
178 parent_bq_prefix_len: usize,
179 ) -> Option<usize> {
180 if start_line == 0 || start_line > end_line || end_line > self.indentation.len() {
181 return None;
182 }
183
184 let adjust = |t: usize| {
187 if parent_bq_level > 0 {
188 t.saturating_sub(parent_bq_prefix_len)
189 } else {
190 t
191 }
192 };
193 let tight = adjust(tight_threshold);
194 let loose = adjust(loose_threshold);
195
196 let start_idx = start_line - 1;
198 let end_idx = end_line - 1;
199 let mut seen_blank = false;
200
201 for idx in start_idx..=end_idx {
202 if !self.has_content(idx) {
203 seen_blank = true;
204 continue;
205 }
206 if self.is_list_item(idx) {
207 continue;
208 }
209
210 let line_bq_level = self.blockquote_levels.get(idx).copied().unwrap_or(0);
212 let raw_indent = self.indentation[idx];
213 let effective_indent = if line_bq_level == parent_bq_level && parent_bq_level > 0 {
214 effective_indent_in_blockquote(&self.line_contents[idx], parent_bq_level, raw_indent)
215 } else {
216 raw_indent
217 };
218
219 let threshold = if seen_blank { loose } else { tight };
220 if effective_indent >= threshold {
221 return Some(effective_indent);
222 }
223 if seen_blank {
226 return None;
227 }
228 }
229 None
230 }
231
232 fn has_continuation_content(
240 &self,
241 parent_line: usize,
242 current_line: usize,
243 tight_threshold: usize,
244 loose_threshold: usize,
245 parent_bq_level: usize,
246 parent_bq_prefix_len: usize,
247 ) -> bool {
248 if parent_line == 0 || current_line <= parent_line || current_line > self.indentation.len() {
249 return false;
250 }
251
252 let adjust = |t: usize| {
253 if parent_bq_level > 0 {
254 t.saturating_sub(parent_bq_prefix_len)
255 } else {
256 t
257 }
258 };
259 let tight = adjust(tight_threshold);
260 let loose = adjust(loose_threshold);
261
262 let start_idx = parent_line; let end_idx = current_line - 2; if start_idx > end_idx {
267 return false;
268 }
269
270 let mut seen_blank = false;
271 for idx in start_idx..=end_idx {
272 if !self.has_content(idx) {
273 seen_blank = true;
274 continue;
275 }
276 if self.is_list_item(idx) {
277 continue;
278 }
279
280 let line_bq_level = self.blockquote_levels.get(idx).copied().unwrap_or(0);
281 let raw_indent = self.indentation[idx];
282 let effective_indent = if line_bq_level == parent_bq_level && parent_bq_level > 0 {
283 effective_indent_in_blockquote(&self.line_contents[idx], parent_bq_level, raw_indent)
284 } else {
285 raw_indent
286 };
287
288 let threshold = if seen_blank { loose } else { tight };
289 if effective_indent >= threshold {
290 return true;
291 }
292 if seen_blank {
293 return false;
294 }
295 }
296 false
297 }
298}
299
300impl MD005ListIndent {
301 const LIST_GROUP_GAP_TOLERANCE: usize = 2;
305
306 const MIN_CHILD_INDENT_INCREASE: usize = 2;
309
310 const SAME_LEVEL_TOLERANCE: i32 = 1;
313
314 const STANDARD_CONTINUATION_OFFSET: usize = 2;
317
318 fn create_indent_warning(
320 &self,
321 ctx: &crate::lint_context::LintContext,
322 line_num: usize,
323 line_info: &crate::lint_context::LineInfo,
324 actual_indent: usize,
325 expected_indent: usize,
326 ) -> LintWarning {
327 let message = format!(
328 "Expected indentation of {} {}, found {}",
329 expected_indent,
330 if expected_indent == 1 { "space" } else { "spaces" },
331 actual_indent
332 );
333
334 let (start_line, start_col, end_line, end_col) = if actual_indent > 0 {
335 calculate_match_range(line_num, line_info.content(ctx.content), 0, actual_indent)
336 } else {
337 calculate_match_range(line_num, line_info.content(ctx.content), 0, 1)
338 };
339
340 let (fix_range, replacement) = if line_info.blockquote.is_some() {
343 let start_byte = line_info.byte_offset;
345 let mut end_byte = line_info.byte_offset;
346
347 let marker_column = line_info
349 .list_item
350 .as_ref()
351 .map_or(actual_indent, |li| li.marker_column);
352
353 for (i, ch) in line_info.content(ctx.content).chars().enumerate() {
355 if i >= marker_column {
356 break;
357 }
358 end_byte += ch.len_utf8();
359 }
360
361 let mut blockquote_count = 0;
363 for ch in line_info.content(ctx.content).chars() {
364 if ch == '>' {
365 blockquote_count += 1;
366 } else if ch != ' ' && ch != '\t' {
367 break;
368 }
369 }
370
371 let blockquote_prefix = if blockquote_count > 1 {
373 (0..blockquote_count)
374 .map(|_| "> ")
375 .collect::<String>()
376 .trim_end()
377 .to_string()
378 } else {
379 ">".to_string()
380 };
381
382 let correct_indent = " ".repeat(expected_indent);
384 let replacement = format!("{blockquote_prefix} {correct_indent}");
385
386 (start_byte..end_byte, replacement)
387 } else {
388 let fix_range = if actual_indent > 0 {
390 let start_byte = ctx.line_offsets.get(line_num - 1).copied().unwrap_or(0);
391 let end_byte = start_byte + actual_indent;
392 start_byte..end_byte
393 } else {
394 let byte_pos = ctx.line_offsets.get(line_num - 1).copied().unwrap_or(0);
395 byte_pos..byte_pos
396 };
397
398 let replacement = if expected_indent > 0 {
399 " ".repeat(expected_indent)
400 } else {
401 String::new()
402 };
403
404 (fix_range, replacement)
405 };
406
407 LintWarning {
408 rule_name: Some(self.name().to_string()),
409 line: start_line,
410 column: start_col,
411 end_line,
412 end_column: end_col,
413 message,
414 severity: Severity::Warning,
415 fix: Some(Fix {
416 range: fix_range,
417 replacement,
418 }),
419 }
420 }
421
422 fn check_indent_consistency(
425 &self,
426 ctx: &crate::lint_context::LintContext,
427 items: &[(usize, usize, &crate::lint_context::LineInfo)],
428 warnings: &mut Vec<LintWarning>,
429 ) {
430 if items.len() < 2 {
431 return;
432 }
433
434 let mut sorted_items: Vec<_> = items.iter().collect();
436 sorted_items.sort_by_key(|(line_num, _, _)| *line_num);
437
438 let indents: std::collections::HashSet<usize> = sorted_items.iter().map(|(_, indent, _)| *indent).collect();
439
440 if indents.len() > 1 {
441 let expected_indent = sorted_items.first().map_or(0, |(_, i, _)| *i);
444
445 for (line_num, indent, line_info) in items {
446 if *indent != expected_indent {
447 warnings.push(self.create_indent_warning(ctx, *line_num, line_info, *indent, expected_indent));
448 }
449 }
450 }
451 }
452
453 fn group_by_parent_content_column<'a>(
460 &self,
461 level: usize,
462 group: &[(usize, usize, &'a crate::lint_context::LineInfo)],
463 all_list_items: &[(
464 usize,
465 usize,
466 &crate::lint_context::LineInfo,
467 &crate::lint_context::ListItemInfo,
468 )],
469 level_map: &HashMap<usize, usize>,
470 ) -> ParentContentGroups<'a> {
471 let parent_level = level - 1;
472
473 let is_ordered_map: HashMap<usize, bool> = all_list_items
475 .iter()
476 .map(|(ln, _, _, item)| (*ln, item.is_ordered))
477 .collect();
478
479 let parent_items: Vec<(usize, usize)> = all_list_items
481 .iter()
482 .filter(|(ln, _, _, _)| level_map.get(ln) == Some(&parent_level))
483 .map(|(ln, _, _, item)| (*ln, item.content_column))
484 .collect();
485
486 let mut parent_content_groups: ParentContentGroups<'a> = HashMap::new();
487
488 for (line_num, indent, line_info) in group {
489 let item_is_ordered = is_ordered_map.get(line_num).copied().unwrap_or(false);
490
491 let idx = parent_items.partition_point(|&(ln, _)| ln < *line_num);
493 let parent_content_col = if idx > 0 { Some(parent_items[idx - 1].1) } else { None };
494
495 if let Some(parent_col) = parent_content_col {
496 parent_content_groups
497 .entry((parent_col, item_is_ordered))
498 .or_default()
499 .push((*line_num, *indent, *line_info));
500 }
501 }
502
503 parent_content_groups
504 }
505
506 fn group_related_list_blocks<'a>(
508 &self,
509 list_blocks: &'a [crate::lint_context::ListBlock],
510 ) -> Vec<Vec<&'a crate::lint_context::ListBlock>> {
511 if list_blocks.is_empty() {
512 return Vec::new();
513 }
514
515 let mut groups = Vec::new();
516 let mut current_group = vec![&list_blocks[0]];
517
518 for i in 1..list_blocks.len() {
519 let prev_block = &list_blocks[i - 1];
520 let current_block = &list_blocks[i];
521
522 let line_gap = current_block.start_line.saturating_sub(prev_block.end_line);
524
525 if line_gap <= Self::LIST_GROUP_GAP_TOLERANCE {
528 current_group.push(current_block);
529 } else {
530 groups.push(current_group);
532 current_group = vec![current_block];
533 }
534 }
535 groups.push(current_group);
536
537 groups
538 }
539
540 fn is_continuation_content(
543 &self,
544 ctx: &crate::lint_context::LintContext,
545 cache: &LineCacheInfo,
546 list_line: usize,
547 list_indent: usize,
548 ) -> bool {
549 let parent_line = cache.parent_map.get(&list_line).copied();
551
552 if let Some(parent_line) = parent_line
553 && let Some(line_info) = ctx.line_info(parent_line)
554 && let Some(parent_list_item) = &line_info.list_item
555 {
556 let parent_marker_column = parent_list_item.marker_column;
557 let parent_content_column = parent_list_item.content_column;
558
559 let parent_bq_level = line_info.blockquote.as_ref().map_or(0, |bq| bq.nesting_level);
561 let parent_bq_prefix_len = line_info.blockquote.as_ref().map_or(0, |bq| bq.prefix.len());
562
563 let continuation_indent = cache.find_continuation_indent(
567 parent_line + 1,
568 list_line - 1,
569 parent_marker_column + 1,
570 parent_content_column,
571 parent_bq_level,
572 parent_bq_prefix_len,
573 );
574
575 if let Some(continuation_indent) = continuation_indent {
576 let is_standard_continuation =
577 list_indent == parent_content_column + Self::STANDARD_CONTINUATION_OFFSET;
578 let matches_content_indent = list_indent == continuation_indent;
579
580 if matches_content_indent || is_standard_continuation {
581 return true;
582 }
583 }
584
585 if list_indent > parent_marker_column {
588 if self.has_continuation_list_at_indent(
590 ctx,
591 cache,
592 parent_line,
593 list_line,
594 list_indent,
595 (parent_marker_column + 1, parent_content_column),
596 ) {
597 return true;
598 }
599
600 let (parent_bq_level, parent_bq_prefix_len) = cache.blockquote_info(parent_line);
602 if cache.has_continuation_content(
603 parent_line,
604 list_line,
605 parent_marker_column + 1,
606 parent_content_column,
607 parent_bq_level,
608 parent_bq_prefix_len,
609 ) {
610 return true;
611 }
612 }
613 }
614
615 false
616 }
617
618 fn has_continuation_list_at_indent(
622 &self,
623 ctx: &crate::lint_context::LintContext,
624 cache: &LineCacheInfo,
625 parent_line: usize,
626 current_line: usize,
627 list_indent: usize,
628 thresholds: (usize, usize),
629 ) -> bool {
630 let (parent_bq_level, parent_bq_prefix_len) = cache.blockquote_info(parent_line);
632 let (tight, loose) = thresholds;
633
634 for line_num in (parent_line + 1)..current_line {
637 if let Some(line_info) = ctx.line_info(line_num)
638 && let Some(list_item) = &line_info.list_item
639 && list_item.marker_column == list_indent
640 {
641 if cache
643 .find_continuation_indent(
644 parent_line + 1,
645 line_num - 1,
646 tight,
647 loose,
648 parent_bq_level,
649 parent_bq_prefix_len,
650 )
651 .is_some()
652 {
653 return true;
654 }
655 }
656 }
657 false
658 }
659
660 fn check_list_block_group(
662 &self,
663 ctx: &crate::lint_context::LintContext,
664 cache: &LineCacheInfo,
665 group: &[&crate::lint_context::ListBlock],
666 warnings: &mut Vec<LintWarning>,
667 ) {
668 let mut candidate_items: Vec<(
671 usize,
672 usize,
673 &crate::lint_context::LineInfo,
674 &crate::lint_context::ListItemInfo,
675 )> = Vec::new();
676
677 for list_block in group {
678 for &item_line in &list_block.item_lines {
679 if let Some(line_info) = ctx.line_info(item_line)
680 && let Some(list_item) = line_info.list_item.as_deref()
681 {
682 let effective_indent = if let Some(blockquote) = &line_info.blockquote {
684 list_item.marker_column.saturating_sub(blockquote.nesting_level * 2)
686 } else {
687 list_item.marker_column
689 };
690
691 candidate_items.push((item_line, effective_indent, line_info, list_item));
692 }
693 }
694 }
695
696 candidate_items.sort_by_key(|(line_num, _, _, _)| *line_num);
698
699 let mut skipped_lines: std::collections::HashSet<usize> = std::collections::HashSet::new();
702 let mut all_list_items: Vec<(
703 usize,
704 usize,
705 &crate::lint_context::LineInfo,
706 &crate::lint_context::ListItemInfo,
707 )> = Vec::new();
708
709 for (item_line, effective_indent, line_info, list_item) in candidate_items {
710 if line_info.in_footnote_definition {
712 skipped_lines.insert(item_line);
713 continue;
714 }
715 if self.is_continuation_content(ctx, cache, item_line, effective_indent) {
717 skipped_lines.insert(item_line);
718 continue;
719 }
720
721 if let Some(&parent_line) = cache.parent_map.get(&item_line)
723 && skipped_lines.contains(&parent_line)
724 {
725 skipped_lines.insert(item_line);
726 continue;
727 }
728
729 all_list_items.push((item_line, effective_indent, line_info, list_item));
730 }
731
732 if all_list_items.is_empty() {
733 return;
734 }
735
736 all_list_items.sort_by_key(|(line_num, _, _, _)| *line_num);
738
739 let mut level_map: HashMap<usize, usize> = HashMap::new();
743 let mut level_indents: HashMap<usize, Vec<usize>> = HashMap::new(); let mut indent_to_level: HashMap<usize, (usize, usize)> = HashMap::new();
748
749 for (line_num, indent, _, _) in &all_list_items {
751 let level = if indent_to_level.is_empty() {
752 level_indents.entry(1).or_default().push(*indent);
754 1
755 } else {
756 let mut determined_level = 0;
758
759 if let Some(&(existing_level, _)) = indent_to_level.get(indent) {
761 determined_level = existing_level;
762 } else {
763 let mut best_parent: Option<(usize, usize, usize)> = None; for (&tracked_indent, &(tracked_level, tracked_line)) in &indent_to_level {
769 if tracked_indent < *indent {
770 if best_parent.is_none() || tracked_indent > best_parent.unwrap().0 {
773 best_parent = Some((tracked_indent, tracked_level, tracked_line));
774 }
775 }
776 }
777
778 if let Some((parent_indent, parent_level, _parent_line)) = best_parent {
779 if parent_indent + Self::MIN_CHILD_INDENT_INCREASE <= *indent {
781 determined_level = parent_level + 1;
783 } else if (*indent as i32 - parent_indent as i32).abs() <= Self::SAME_LEVEL_TOLERANCE {
784 determined_level = parent_level;
786 } else {
787 let mut found_similar = false;
791 if let Some(indents_at_level) = level_indents.get(&parent_level) {
792 for &level_indent in indents_at_level {
793 if (level_indent as i32 - *indent as i32).abs() <= Self::SAME_LEVEL_TOLERANCE {
794 determined_level = parent_level;
795 found_similar = true;
796 break;
797 }
798 }
799 }
800 if !found_similar {
801 determined_level = parent_level + 1;
803 }
804 }
805 }
806
807 if determined_level == 0 {
809 determined_level = 1;
810 }
811
812 level_indents.entry(determined_level).or_default().push(*indent);
814 }
815
816 determined_level
817 };
818
819 level_map.insert(*line_num, level);
820 indent_to_level.insert(*indent, (level, *line_num));
822 }
823
824 let mut level_groups: HashMap<usize, Vec<(usize, usize, &crate::lint_context::LineInfo)>> = HashMap::new();
826 for (line_num, indent, line_info, _) in &all_list_items {
827 let level = level_map[line_num];
828 level_groups
829 .entry(level)
830 .or_default()
831 .push((*line_num, *indent, *line_info));
832 }
833
834 for (level, mut group) in level_groups {
836 group.sort_by_key(|(line_num, _, _)| *line_num);
837
838 if level == 1 {
839 for (line_num, indent, line_info) in &group {
841 if *indent != self.top_level_indent {
842 warnings.push(self.create_indent_warning(
843 ctx,
844 *line_num,
845 line_info,
846 *indent,
847 self.top_level_indent,
848 ));
849 }
850 }
851 } else {
852 let parent_content_groups =
855 self.group_by_parent_content_column(level, &group, &all_list_items, &level_map);
856
857 for items in parent_content_groups.values() {
859 self.check_indent_consistency(ctx, items, warnings);
860 }
861 }
862 }
863 }
864
865 fn check_optimized(&self, ctx: &crate::lint_context::LintContext) -> Vec<LintWarning> {
867 let content = ctx.content;
868
869 if content.is_empty() {
871 return Vec::new();
872 }
873
874 if ctx.list_blocks.is_empty() {
876 return Vec::new();
877 }
878
879 let mut warnings = Vec::new();
880
881 let cache = LineCacheInfo::new(ctx);
883
884 let block_groups = self.group_related_list_blocks(&ctx.list_blocks);
887
888 for group in block_groups {
889 self.check_list_block_group(ctx, &cache, &group, &mut warnings);
890 }
891
892 warnings
893 }
894}
895
896impl Rule for MD005ListIndent {
897 fn name(&self) -> &'static str {
898 "MD005"
899 }
900
901 fn description(&self) -> &'static str {
902 "List indentation should be consistent"
903 }
904
905 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
906 Ok(self.check_optimized(ctx))
908 }
909
910 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
911 let warnings = self.check(ctx)?;
912 let warnings =
913 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
914 if warnings.is_empty() {
915 return Ok(ctx.content.to_string());
916 }
917
918 let mut warnings_with_fixes: Vec<_> = warnings
920 .into_iter()
921 .filter_map(|w| w.fix.clone().map(|fix| (w, fix)))
922 .collect();
923 warnings_with_fixes.sort_by_key(|(_, fix)| std::cmp::Reverse(fix.range.start));
924
925 let mut content = ctx.content.to_string();
927 for (_, fix) in warnings_with_fixes {
928 if fix.range.start <= content.len() && fix.range.end <= content.len() {
929 content.replace_range(fix.range, &fix.replacement);
930 }
931 }
932
933 Ok(content)
934 }
935
936 fn category(&self) -> RuleCategory {
937 RuleCategory::List
938 }
939
940 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
942 ctx.content.is_empty() || !ctx.lines.iter().any(|line| line.list_item.is_some())
944 }
945
946 fn as_any(&self) -> &dyn std::any::Any {
947 self
948 }
949
950 fn default_config_section(&self) -> Option<(String, toml::Value)> {
951 None
952 }
953
954 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
955 where
956 Self: Sized,
957 {
958 let mut top_level_indent = 0;
960
961 if let Some(md007_config) = config.rules.get("MD007") {
963 if let Some(start_indented) = md007_config.values.get("start-indented")
965 && let Some(start_indented_bool) = start_indented.as_bool()
966 && start_indented_bool
967 {
968 if let Some(start_indent) = md007_config.values.get("start-indent") {
970 if let Some(indent_value) = start_indent.as_integer() {
971 top_level_indent = indent_value as usize;
972 }
973 } else {
974 top_level_indent = 2;
976 }
977 }
978 }
979
980 Box::new(MD005ListIndent { top_level_indent })
981 }
982}
983
984#[cfg(test)]
985mod tests {
986 use super::*;
987 use crate::lint_context::LintContext;
988
989 #[test]
990 fn test_valid_unordered_list() {
991 let rule = MD005ListIndent::default();
992 let content = "\
993* Item 1
994* Item 2
995 * Nested 1
996 * Nested 2
997* Item 3";
998 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
999 let result = rule.check(&ctx).unwrap();
1000 assert!(result.is_empty());
1001 }
1002
1003 #[test]
1004 fn test_valid_ordered_list() {
1005 let rule = MD005ListIndent::default();
1006 let content = "\
10071. Item 1
10082. Item 2
1009 1. Nested 1
1010 2. Nested 2
10113. Item 3";
1012 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1013 let result = rule.check(&ctx).unwrap();
1014 assert!(result.is_empty());
1017 }
1018
1019 #[test]
1020 fn test_invalid_unordered_indent() {
1021 let rule = MD005ListIndent::default();
1022 let content = "\
1023* Item 1
1024 * Item 2
1025 * Nested 1";
1026 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1027 let result = rule.check(&ctx).unwrap();
1028 assert_eq!(result.len(), 1);
1031 let fixed = rule.fix(&ctx).unwrap();
1032 assert_eq!(fixed, "* Item 1\n* Item 2\n * Nested 1");
1033 }
1034
1035 #[test]
1036 fn test_invalid_ordered_indent() {
1037 let rule = MD005ListIndent::default();
1038 let content = "\
10391. Item 1
1040 2. Item 2
1041 1. Nested 1";
1042 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1043 let result = rule.check(&ctx).unwrap();
1044 assert_eq!(result.len(), 1);
1045 let fixed = rule.fix(&ctx).unwrap();
1046 assert_eq!(fixed, "1. Item 1\n2. Item 2\n 1. Nested 1");
1050 }
1051
1052 #[test]
1053 fn test_mixed_list_types() {
1054 let rule = MD005ListIndent::default();
1055 let content = "\
1056* Item 1
1057 1. Nested ordered
1058 * Nested unordered
1059* Item 2";
1060 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1061 let result = rule.check(&ctx).unwrap();
1062 assert!(result.is_empty());
1063 }
1064
1065 #[test]
1066 fn test_multiple_levels() {
1067 let rule = MD005ListIndent::default();
1068 let content = "\
1069* Level 1
1070 * Level 2
1071 * Level 3";
1072 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1073 let result = rule.check(&ctx).unwrap();
1074 assert!(result.is_empty(), "MD005 should accept consistent indentation pattern");
1076 }
1077
1078 #[test]
1079 fn test_empty_lines() {
1080 let rule = MD005ListIndent::default();
1081 let content = "\
1082* Item 1
1083
1084 * Nested 1
1085
1086* Item 2";
1087 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1088 let result = rule.check(&ctx).unwrap();
1089 assert!(result.is_empty());
1090 }
1091
1092 #[test]
1093 fn test_no_lists() {
1094 let rule = MD005ListIndent::default();
1095 let content = "\
1096Just some text
1097More text
1098Even more text";
1099 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1100 let result = rule.check(&ctx).unwrap();
1101 assert!(result.is_empty());
1102 }
1103
1104 #[test]
1105 fn test_complex_nesting() {
1106 let rule = MD005ListIndent::default();
1107 let content = "\
1108* Level 1
1109 * Level 2
1110 * Level 3
1111 * Back to 2
1112 1. Ordered 3
1113 2. Still 3
1114* Back to 1";
1115 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1116 let result = rule.check(&ctx).unwrap();
1117 assert!(result.is_empty());
1118 }
1119
1120 #[test]
1121 fn test_invalid_complex_nesting() {
1122 let rule = MD005ListIndent::default();
1123 let content = "\
1124* Level 1
1125 * Level 2
1126 * Level 3
1127 * Back to 2
1128 1. Ordered 3
1129 2. Still 3
1130* Back to 1";
1131 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1132 let result = rule.check(&ctx).unwrap();
1133 assert_eq!(result.len(), 1);
1135 assert!(
1136 result[0].message.contains("Expected indentation of 5 spaces, found 6")
1137 || result[0].message.contains("Expected indentation of 6 spaces, found 5")
1138 );
1139 }
1140
1141 #[test]
1142 fn test_with_lint_context() {
1143 let rule = MD005ListIndent::default();
1144
1145 let content = "* Item 1\n* Item 2\n * Nested item\n * Another nested item";
1147 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1148 let result = rule.check(&ctx).unwrap();
1149 assert!(result.is_empty());
1150
1151 let content = "* Item 1\n* Item 2\n * Nested item\n * Another nested item";
1153 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1154 let result = rule.check(&ctx).unwrap();
1155 assert!(!result.is_empty()); let content = "* Item 1\n * Nested item\n * Another nested item with wrong indent";
1159 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1160 let result = rule.check(&ctx).unwrap();
1161 assert!(!result.is_empty()); }
1163
1164 #[test]
1166 fn test_list_with_continuations() {
1167 let rule = MD005ListIndent::default();
1168 let content = "\
1169* Item 1
1170 This is a continuation
1171 of the first item
1172 * Nested item
1173 with its own continuation
1174* Item 2";
1175 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1176 let result = rule.check(&ctx).unwrap();
1177 assert!(result.is_empty());
1178 }
1179
1180 #[test]
1181 fn test_list_in_blockquote() {
1182 let rule = MD005ListIndent::default();
1183 let content = "\
1184> * Item 1
1185> * Nested 1
1186> * Nested 2
1187> * Item 2";
1188 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1189 let result = rule.check(&ctx).unwrap();
1190
1191 assert!(
1193 result.is_empty(),
1194 "Expected no warnings for correctly indented blockquote list, got: {result:?}"
1195 );
1196 }
1197
1198 #[test]
1199 fn test_list_with_code_blocks() {
1200 let rule = MD005ListIndent::default();
1201 let content = "\
1202* Item 1
1203 ```
1204 code block
1205 ```
1206 * Nested item
1207* Item 2";
1208 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1209 let result = rule.check(&ctx).unwrap();
1210 assert!(result.is_empty());
1211 }
1212
1213 #[test]
1214 fn test_list_with_tabs() {
1215 let rule = MD005ListIndent::default();
1216 let content = "* Item 1\n * Wrong indent (3 spaces)\n * Correct indent (2 spaces)";
1220 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1221 let result = rule.check(&ctx).unwrap();
1222 assert!(!result.is_empty());
1224 }
1225
1226 #[test]
1227 fn test_inconsistent_at_same_level() {
1228 let rule = MD005ListIndent::default();
1229 let content = "\
1230* Item 1
1231 * Nested 1
1232 * Nested 2
1233 * Wrong indent for same level
1234 * Nested 3";
1235 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1236 let result = rule.check(&ctx).unwrap();
1237 assert!(!result.is_empty());
1238 assert!(result.iter().any(|w| w.line == 4));
1240 }
1241
1242 #[test]
1243 fn test_zero_indent_top_level() {
1244 let rule = MD005ListIndent::default();
1245 let content = concat!(" * Wrong indent\n", "* Correct\n", " * Nested");
1247 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1248 let result = rule.check(&ctx).unwrap();
1249
1250 assert!(!result.is_empty());
1252 assert!(result.iter().any(|w| w.line == 1));
1253 }
1254
1255 #[test]
1256 fn test_fix_preserves_content() {
1257 let rule = MD005ListIndent::default();
1258 let content = "\
1259* Item with **bold** and *italic*
1260 * Wrong indent with `code`
1261 * Also wrong with [link](url)";
1262 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1263 let fixed = rule.fix(&ctx).unwrap();
1264 assert!(fixed.contains("**bold**"));
1265 assert!(fixed.contains("*italic*"));
1266 assert!(fixed.contains("`code`"));
1267 assert!(fixed.contains("[link](url)"));
1268 }
1269
1270 #[test]
1271 fn test_deeply_nested_lists() {
1272 let rule = MD005ListIndent::default();
1273 let content = "\
1274* L1
1275 * L2
1276 * L3
1277 * L4
1278 * L5
1279 * L6";
1280 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1281 let result = rule.check(&ctx).unwrap();
1282 assert!(result.is_empty());
1283 }
1284
1285 #[test]
1286 fn test_fix_multiple_issues() {
1287 let rule = MD005ListIndent::default();
1288 let content = "\
1289* Item 1
1290 * Wrong 1
1291 * Wrong 2
1292 * Wrong 3
1293 * Correct
1294 * Wrong 4";
1295 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1296 let fixed = rule.fix(&ctx).unwrap();
1297 let lines: Vec<&str> = fixed.lines().collect();
1299 assert_eq!(lines[0], "* Item 1");
1300 assert!(lines[1].starts_with(" * ") || lines[1].starts_with("* "));
1302 }
1303
1304 #[test]
1305 fn test_performance_large_document() {
1306 let rule = MD005ListIndent::default();
1307 let mut content = String::new();
1308 for i in 0..100 {
1309 content.push_str(&format!("* Item {i}\n"));
1310 content.push_str(&format!(" * Nested {i}\n"));
1311 }
1312 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1313 let result = rule.check(&ctx).unwrap();
1314 assert!(result.is_empty());
1315 }
1316
1317 #[test]
1318 fn test_column_positions() {
1319 let rule = MD005ListIndent::default();
1320 let content = " * Wrong indent";
1321 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1322 let result = rule.check(&ctx).unwrap();
1323 assert_eq!(result.len(), 1);
1324 assert_eq!(result[0].column, 1, "Expected column 1, got {}", result[0].column);
1325 assert_eq!(
1326 result[0].end_column, 2,
1327 "Expected end_column 2, got {}",
1328 result[0].end_column
1329 );
1330 }
1331
1332 #[test]
1333 fn test_should_skip() {
1334 let rule = MD005ListIndent::default();
1335
1336 let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard, None);
1338 assert!(rule.should_skip(&ctx));
1339
1340 let ctx = LintContext::new("Just plain text", crate::config::MarkdownFlavor::Standard, None);
1342 assert!(rule.should_skip(&ctx));
1343
1344 let ctx = LintContext::new("* List item", crate::config::MarkdownFlavor::Standard, None);
1346 assert!(!rule.should_skip(&ctx));
1347
1348 let ctx = LintContext::new("1. Ordered list", crate::config::MarkdownFlavor::Standard, None);
1349 assert!(!rule.should_skip(&ctx));
1350 }
1351
1352 #[test]
1353 fn test_should_skip_validation() {
1354 let rule = MD005ListIndent::default();
1355 let content = "* List item";
1356 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1357 assert!(!rule.should_skip(&ctx));
1358
1359 let content = "No lists here";
1360 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1361 assert!(rule.should_skip(&ctx));
1362 }
1363
1364 #[test]
1365 fn test_edge_case_single_space_indent() {
1366 let rule = MD005ListIndent::default();
1367 let content = "\
1368* Item 1
1369 * Single space - wrong
1370 * Two spaces - correct";
1371 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1372 let result = rule.check(&ctx).unwrap();
1373 assert_eq!(result.len(), 2);
1376 assert!(result.iter().any(|w| w.line == 2 && w.message.contains("found 1")));
1377 }
1378
1379 #[test]
1380 fn test_edge_case_three_space_indent() {
1381 let rule = MD005ListIndent::default();
1382 let content = "\
1383* Item 1
1384 * Three spaces - first establishes pattern
1385 * Two spaces - inconsistent with established pattern";
1386 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1387 let result = rule.check(&ctx).unwrap();
1388 assert_eq!(result.len(), 1);
1392 assert!(result.iter().any(|w| w.line == 3 && w.message.contains("found 2")));
1393 }
1394
1395 #[test]
1396 fn test_nested_bullets_under_numbered_items() {
1397 let rule = MD005ListIndent::default();
1398 let content = "\
13991. **Active Directory/LDAP**
1400 - User authentication and directory services
1401 - LDAP for user information and validation
1402
14032. **Oracle Unified Directory (OUD)**
1404 - Extended user directory services
1405 - Verification of project account presence and changes";
1406 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1407 let result = rule.check(&ctx).unwrap();
1408 assert!(
1410 result.is_empty(),
1411 "Expected no warnings for bullets with 3 spaces under numbered items, got: {result:?}"
1412 );
1413 }
1414
1415 #[test]
1416 fn test_nested_bullets_under_numbered_items_wrong_indent() {
1417 let rule = MD005ListIndent::default();
1418 let content = "\
14191. **Active Directory/LDAP**
1420 - Wrong: only 2 spaces
1421 - Correct: 3 spaces";
1422 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1423 let result = rule.check(&ctx).unwrap();
1424 assert_eq!(
1426 result.len(),
1427 1,
1428 "Expected 1 warning, got {}. Warnings: {:?}",
1429 result.len(),
1430 result
1431 );
1432 assert!(
1434 result
1435 .iter()
1436 .any(|w| (w.line == 2 && w.message.contains("found 2"))
1437 || (w.line == 3 && w.message.contains("found 3")))
1438 );
1439 }
1440
1441 #[test]
1442 fn test_regular_nested_bullets_still_work() {
1443 let rule = MD005ListIndent::default();
1444 let content = "\
1445* Top level
1446 * Second level (2 spaces is correct for bullets under bullets)
1447 * Third level (4 spaces)";
1448 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1449 let result = rule.check(&ctx).unwrap();
1450 assert!(
1452 result.is_empty(),
1453 "Expected no warnings for regular bullet nesting, got: {result:?}"
1454 );
1455 }
1456
1457 #[test]
1458 fn test_fix_range_accuracy() {
1459 let rule = MD005ListIndent::default();
1460 let content = " * Wrong indent";
1461 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1462 let result = rule.check(&ctx).unwrap();
1463 assert_eq!(result.len(), 1);
1464
1465 let fix = result[0].fix.as_ref().unwrap();
1466 assert_eq!(fix.replacement, "");
1468 }
1469
1470 #[test]
1471 fn test_four_space_indent_pattern() {
1472 let rule = MD005ListIndent::default();
1473 let content = "\
1474* Item 1
1475 * Item 2 with 4 spaces
1476 * Item 3 with 8 spaces
1477 * Item 4 with 4 spaces";
1478 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1479 let result = rule.check(&ctx).unwrap();
1480 assert!(
1482 result.is_empty(),
1483 "MD005 should accept consistent 4-space indentation pattern, got {} warnings",
1484 result.len()
1485 );
1486 }
1487
1488 #[test]
1489 fn test_issue_64_scenario() {
1490 let rule = MD005ListIndent::default();
1492 let content = "\
1493* Top level item
1494 * Sub item with 4 spaces (as configured in MD007)
1495 * Nested sub item with 8 spaces
1496 * Another sub item with 4 spaces
1497* Another top level";
1498
1499 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1500 let result = rule.check(&ctx).unwrap();
1501
1502 assert!(
1504 result.is_empty(),
1505 "MD005 should accept 4-space indentation when that's the pattern being used. Got {} warnings",
1506 result.len()
1507 );
1508 }
1509
1510 #[test]
1511 fn test_continuation_content_scenario() {
1512 let rule = MD005ListIndent::default();
1513 let content = "\
1514- **Changes to how the Python version is inferred** ([#16319](example))
1515
1516 In previous versions of Ruff, you could specify your Python version with:
1517
1518 - The `target-version` option in a `ruff.toml` file
1519 - The `project.requires-python` field in a `pyproject.toml` file";
1520
1521 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1522
1523 let result = rule.check(&ctx).unwrap();
1524
1525 assert!(
1527 result.is_empty(),
1528 "MD005 should not flag continuation content lists, got {} warnings: {:?}",
1529 result.len(),
1530 result
1531 );
1532 }
1533
1534 #[test]
1535 fn test_multiple_continuation_lists_scenario() {
1536 let rule = MD005ListIndent::default();
1537 let content = "\
1538- **Changes to how the Python version is inferred** ([#16319](example))
1539
1540 In previous versions of Ruff, you could specify your Python version with:
1541
1542 - The `target-version` option in a `ruff.toml` file
1543 - The `project.requires-python` field in a `pyproject.toml` file
1544
1545 In v0.10, config discovery has been updated to address this issue:
1546
1547 - If Ruff finds a `ruff.toml` file without a `target-version`, it will check
1548 - If Ruff finds a user-level configuration, the `requires-python` field will take precedence
1549 - If there is no config file, Ruff will search for the closest `pyproject.toml`";
1550
1551 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1552
1553 let result = rule.check(&ctx).unwrap();
1554
1555 assert!(
1557 result.is_empty(),
1558 "MD005 should not flag continuation content lists, got {} warnings: {:?}",
1559 result.len(),
1560 result
1561 );
1562 }
1563
1564 #[test]
1565 fn test_issue_115_sublist_after_code_block() {
1566 let rule = MD005ListIndent::default();
1567 let content = "\
15681. List item 1
1569
1570 ```rust
1571 fn foo() {}
1572 ```
1573
1574 Sublist:
1575
1576 - A
1577 - B
1578";
1579 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1580 let result = rule.check(&ctx).unwrap();
1581 assert!(
1585 result.is_empty(),
1586 "Expected no warnings for sub-list after code block in list item, got {} warnings: {:?}",
1587 result.len(),
1588 result
1589 );
1590 }
1591
1592 #[test]
1593 fn test_edge_case_continuation_at_exact_boundary() {
1594 let rule = MD005ListIndent::default();
1595 let content = "\
1597* Item (content at column 2)
1598 Text at column 2 (exact boundary - continuation)
1599 * Sub at column 2";
1600 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1601 let result = rule.check(&ctx).unwrap();
1602 assert!(
1604 result.is_empty(),
1605 "Expected no warnings when text and sub-list are at exact parent content_column, got: {result:?}"
1606 );
1607 }
1608
1609 #[test]
1610 fn test_edge_case_unicode_in_continuation() {
1611 let rule = MD005ListIndent::default();
1612 let content = "\
1613* Parent
1614 Text with emoji 😀 and Unicode ñ characters
1615 * Sub-list should still work";
1616 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1617 let result = rule.check(&ctx).unwrap();
1618 assert!(
1620 result.is_empty(),
1621 "Expected no warnings with Unicode in continuation content, got: {result:?}"
1622 );
1623 }
1624
1625 #[test]
1626 fn test_edge_case_large_empty_line_gap() {
1627 let rule = MD005ListIndent::default();
1628 let content = "\
1629* Parent at line 1
1630 Continuation text
1631
1632
1633
1634 More continuation after many empty lines
1635
1636 * Child after gap
1637 * Another child";
1638 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1639 let result = rule.check(&ctx).unwrap();
1640 assert!(
1642 result.is_empty(),
1643 "Expected no warnings with large gaps in continuation content, got: {result:?}"
1644 );
1645 }
1646
1647 #[test]
1648 fn test_edge_case_multiple_continuation_blocks_varying_indent() {
1649 let rule = MD005ListIndent::default();
1650 let content = "\
1651* Parent (content at column 2)
1652 First paragraph at column 2
1653 Indented quote at column 4
1654 Back to column 2
1655 * Sub-list at column 2";
1656 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1657 let result = rule.check(&ctx).unwrap();
1658 assert!(
1660 result.is_empty(),
1661 "Expected no warnings with varying continuation indent, got: {result:?}"
1662 );
1663 }
1664
1665 #[test]
1666 fn test_edge_case_deep_nesting_no_continuation() {
1667 let rule = MD005ListIndent::default();
1668 let content = "\
1669* Parent
1670 * Immediate child (no continuation text before)
1671 * Grandchild
1672 * Great-grandchild
1673 * Great-great-grandchild
1674 * Another child at level 2";
1675 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1676 let result = rule.check(&ctx).unwrap();
1677 assert!(
1679 result.is_empty(),
1680 "Expected no warnings for deep nesting without continuation, got: {result:?}"
1681 );
1682 }
1683
1684 #[test]
1685 fn test_edge_case_blockquote_continuation_content() {
1686 let rule = MD005ListIndent::default();
1687 let content = "\
1688> * Parent in blockquote
1689> Continuation in blockquote
1690> * Sub-list in blockquote
1691> * Another sub-list";
1692 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1693 let result = rule.check(&ctx).unwrap();
1694 assert!(
1696 result.is_empty(),
1697 "Expected no warnings for blockquote continuation, got: {result:?}"
1698 );
1699 }
1700
1701 #[test]
1702 fn test_edge_case_one_space_less_than_content_column() {
1703 let rule = MD005ListIndent::default();
1704 let content = "\
1705* Parent (content at column 2)
1706 Text at column 1 (one less than content_column - NOT continuation)
1707 * Child";
1708 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1709 let result = rule.check(&ctx).unwrap();
1710 assert!(
1716 result.is_empty() || !result.is_empty(),
1717 "Test should complete without panic"
1718 );
1719 }
1720
1721 #[test]
1722 fn test_edge_case_multiple_code_blocks_different_indentation() {
1723 let rule = MD005ListIndent::default();
1724 let content = "\
1725* Parent
1726 ```
1727 code at 2 spaces
1728 ```
1729 ```
1730 code at 4 spaces
1731 ```
1732 * Sub-list should not be confused";
1733 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1734 let result = rule.check(&ctx).unwrap();
1735 assert!(
1737 result.is_empty(),
1738 "Expected no warnings with multiple code blocks, got: {result:?}"
1739 );
1740 }
1741
1742 #[test]
1743 fn test_performance_very_large_document() {
1744 let rule = MD005ListIndent::default();
1745 let mut content = String::new();
1746
1747 for i in 0..1000 {
1749 content.push_str(&format!("* Item {i}\n"));
1750 content.push_str(&format!(" * Nested {i}\n"));
1751 if i % 10 == 0 {
1752 content.push_str(" Some continuation text\n");
1753 }
1754 }
1755
1756 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1757
1758 let start = std::time::Instant::now();
1760 let result = rule.check(&ctx).unwrap();
1761 let elapsed = start.elapsed();
1762
1763 assert!(result.is_empty());
1764 println!("Processed 1000 list items in {elapsed:?}");
1765 assert!(
1768 elapsed.as_secs() < 1,
1769 "Should complete in under 1 second, took {elapsed:?}"
1770 );
1771 }
1772
1773 #[test]
1774 fn test_ordered_list_variable_marker_width() {
1775 let rule = MD005ListIndent::default();
1780 let content = "\
17811. One
1782 - One
1783 - Two
17842. Two
1785 - One
17863. Three
1787 - One
17884. Four
1789 - One
17905. Five
1791 - One
17926. Six
1793 - One
17947. Seven
1795 - One
17968. Eight
1797 - One
17989. Nine
1799 - One
180010. Ten
1801 - One";
1802 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1803 let result = rule.check(&ctx).unwrap();
1804 assert!(
1805 result.is_empty(),
1806 "Expected no warnings for ordered list with variable marker widths, got: {result:?}"
1807 );
1808 }
1809
1810 #[test]
1811 fn test_ordered_list_inconsistent_siblings() {
1812 let rule = MD005ListIndent::default();
1814 let content = "\
18151. Item one
1816 - First sublist at 3 spaces
1817 - Second sublist at 2 spaces (inconsistent)
1818 - Third sublist at 3 spaces";
1819 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1820 let result = rule.check(&ctx).unwrap();
1821 assert_eq!(
1823 result.len(),
1824 1,
1825 "Expected 1 warning for inconsistent sibling indent, got: {result:?}"
1826 );
1827 assert!(result[0].message.contains("Expected indentation of 3"));
1828 }
1829
1830 #[test]
1831 fn test_ordered_list_single_sublist_no_warning() {
1832 let rule = MD005ListIndent::default();
1835 let content = "\
183610. Item ten
1837 - Only sublist at 3 spaces";
1838 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1839 let result = rule.check(&ctx).unwrap();
1840 assert!(
1842 result.is_empty(),
1843 "Expected no warnings for single sublist item, got: {result:?}"
1844 );
1845 }
1846
1847 #[test]
1848 fn test_sublists_grouped_by_parent_content_column() {
1849 let rule = MD005ListIndent::default();
1853 let content = "\
18549. Item nine
1855 - First sublist at 3 spaces
1856 - Second sublist at 3 spaces
1857 - Third sublist at 3 spaces
185810. Item ten
1859 - First sublist at 4 spaces
1860 - Second sublist at 4 spaces
1861 - Third sublist at 4 spaces";
1862 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1863 let result = rule.check(&ctx).unwrap();
1864 assert!(
1867 result.is_empty(),
1868 "Expected no warnings for sublists grouped by parent, got: {result:?}"
1869 );
1870 }
1871
1872 #[test]
1873 fn test_inconsistent_indent_within_parent_group() {
1874 let rule = MD005ListIndent::default();
1876 let content = "\
187710. Item ten
1878 - First sublist at 4 spaces
1879 - Second sublist at 3 spaces (inconsistent!)
1880 - Third sublist at 4 spaces";
1881 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1882 let result = rule.check(&ctx).unwrap();
1883 assert_eq!(
1885 result.len(),
1886 1,
1887 "Expected 1 warning for inconsistent indent within parent group, got: {result:?}"
1888 );
1889 assert!(result[0].line == 3);
1890 assert!(result[0].message.contains("Expected indentation of 4"));
1891 }
1892
1893 #[test]
1894 fn test_blockquote_nested_list_fix_preserves_blockquote_prefix() {
1895 use crate::rule::Rule;
1899
1900 let rule = MD005ListIndent::default();
1901 let content = "> * Federation sender blacklists are now persisted.";
1902 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1903 let result = rule.check(&ctx).unwrap();
1904
1905 assert_eq!(result.len(), 1, "Expected 1 warning for extra indent");
1906
1907 assert!(result[0].fix.is_some(), "Should have a fix");
1909 let fixed = rule.fix(&ctx).expect("Fix should succeed");
1910
1911 assert!(
1913 fixed.starts_with("> "),
1914 "Fixed content should start with blockquote prefix '> ', got: {fixed:?}"
1915 );
1916 assert!(
1917 !fixed.starts_with("* "),
1918 "Fixed content should NOT start with just '* ' (blockquote removed), got: {fixed:?}"
1919 );
1920 assert_eq!(
1921 fixed.trim(),
1922 "> * Federation sender blacklists are now persisted.",
1923 "Fixed content should be '> * Federation sender...' with single space after >"
1924 );
1925 }
1926
1927 #[test]
1928 fn test_nested_blockquote_list_fix_preserves_prefix() {
1929 use crate::rule::Rule;
1931
1932 let rule = MD005ListIndent::default();
1933 let content = ">> * Nested blockquote list item";
1934 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1935 let result = rule.check(&ctx).unwrap();
1936
1937 if !result.is_empty() {
1938 let fixed = rule.fix(&ctx).expect("Fix should succeed");
1939 assert!(
1941 fixed.contains(">>") || fixed.contains("> >"),
1942 "Fixed content should preserve nested blockquote prefix, got: {fixed:?}"
1943 );
1944 }
1945 }
1946}