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::new(fix_range, replacement)),
416 }
417 }
418
419 fn check_indent_consistency(
422 &self,
423 ctx: &crate::lint_context::LintContext,
424 items: &[(usize, usize, &crate::lint_context::LineInfo)],
425 warnings: &mut Vec<LintWarning>,
426 ) {
427 if items.len() < 2 {
428 return;
429 }
430
431 let mut sorted_items: Vec<_> = items.iter().collect();
433 sorted_items.sort_by_key(|(line_num, _, _)| *line_num);
434
435 let indents: std::collections::HashSet<usize> = sorted_items.iter().map(|(_, indent, _)| *indent).collect();
436
437 if indents.len() > 1 {
438 let expected_indent = sorted_items.first().map_or(0, |(_, i, _)| *i);
441
442 for (line_num, indent, line_info) in items {
443 if *indent != expected_indent {
444 warnings.push(self.create_indent_warning(ctx, *line_num, line_info, *indent, expected_indent));
445 }
446 }
447 }
448 }
449
450 fn group_by_parent_content_column<'a>(
457 &self,
458 level: usize,
459 group: &[(usize, usize, &'a crate::lint_context::LineInfo)],
460 all_list_items: &[(
461 usize,
462 usize,
463 &crate::lint_context::LineInfo,
464 &crate::lint_context::ListItemInfo,
465 )],
466 level_map: &HashMap<usize, usize>,
467 ) -> ParentContentGroups<'a> {
468 let parent_level = level - 1;
469
470 let is_ordered_map: HashMap<usize, bool> = all_list_items
472 .iter()
473 .map(|(ln, _, _, item)| (*ln, item.is_ordered))
474 .collect();
475
476 let parent_items: Vec<(usize, usize)> = all_list_items
478 .iter()
479 .filter(|(ln, _, _, _)| level_map.get(ln) == Some(&parent_level))
480 .map(|(ln, _, _, item)| (*ln, item.content_column))
481 .collect();
482
483 let mut parent_content_groups: ParentContentGroups<'a> = HashMap::new();
484
485 for (line_num, indent, line_info) in group {
486 let item_is_ordered = is_ordered_map.get(line_num).copied().unwrap_or(false);
487
488 let idx = parent_items.partition_point(|&(ln, _)| ln < *line_num);
490 let parent_content_col = if idx > 0 { Some(parent_items[idx - 1].1) } else { None };
491
492 if let Some(parent_col) = parent_content_col {
493 parent_content_groups
494 .entry((parent_col, item_is_ordered))
495 .or_default()
496 .push((*line_num, *indent, *line_info));
497 }
498 }
499
500 parent_content_groups
501 }
502
503 fn group_related_list_blocks<'a>(
505 &self,
506 list_blocks: &'a [crate::lint_context::ListBlock],
507 ) -> Vec<Vec<&'a crate::lint_context::ListBlock>> {
508 if list_blocks.is_empty() {
509 return Vec::new();
510 }
511
512 let mut groups = Vec::new();
513 let mut current_group = vec![&list_blocks[0]];
514
515 for i in 1..list_blocks.len() {
516 let prev_block = &list_blocks[i - 1];
517 let current_block = &list_blocks[i];
518
519 let line_gap = current_block.start_line.saturating_sub(prev_block.end_line);
521
522 if line_gap <= Self::LIST_GROUP_GAP_TOLERANCE {
525 current_group.push(current_block);
526 } else {
527 groups.push(current_group);
529 current_group = vec![current_block];
530 }
531 }
532 groups.push(current_group);
533
534 groups
535 }
536
537 fn is_continuation_content(
540 &self,
541 ctx: &crate::lint_context::LintContext,
542 cache: &LineCacheInfo,
543 list_line: usize,
544 list_indent: usize,
545 ) -> bool {
546 let parent_line = cache.parent_map.get(&list_line).copied();
548
549 if let Some(parent_line) = parent_line
550 && let Some(line_info) = ctx.line_info(parent_line)
551 && let Some(parent_list_item) = &line_info.list_item
552 {
553 let parent_marker_column = parent_list_item.marker_column;
554 let parent_content_column = parent_list_item.content_column;
555
556 let parent_bq_level = line_info.blockquote.as_ref().map_or(0, |bq| bq.nesting_level);
558 let parent_bq_prefix_len = line_info.blockquote.as_ref().map_or(0, |bq| bq.prefix.len());
559
560 let continuation_indent = cache.find_continuation_indent(
564 parent_line + 1,
565 list_line - 1,
566 parent_marker_column + 1,
567 parent_content_column,
568 parent_bq_level,
569 parent_bq_prefix_len,
570 );
571
572 if let Some(continuation_indent) = continuation_indent {
573 let is_standard_continuation =
574 list_indent == parent_content_column + Self::STANDARD_CONTINUATION_OFFSET;
575 let matches_content_indent = list_indent == continuation_indent;
576
577 if matches_content_indent || is_standard_continuation {
578 return true;
579 }
580 }
581
582 if list_indent > parent_marker_column {
585 if self.has_continuation_list_at_indent(
587 ctx,
588 cache,
589 parent_line,
590 list_line,
591 list_indent,
592 (parent_marker_column + 1, parent_content_column),
593 ) {
594 return true;
595 }
596
597 let (parent_bq_level, parent_bq_prefix_len) = cache.blockquote_info(parent_line);
599 if cache.has_continuation_content(
600 parent_line,
601 list_line,
602 parent_marker_column + 1,
603 parent_content_column,
604 parent_bq_level,
605 parent_bq_prefix_len,
606 ) {
607 return true;
608 }
609 }
610 }
611
612 false
613 }
614
615 fn has_continuation_list_at_indent(
619 &self,
620 ctx: &crate::lint_context::LintContext,
621 cache: &LineCacheInfo,
622 parent_line: usize,
623 current_line: usize,
624 list_indent: usize,
625 thresholds: (usize, usize),
626 ) -> bool {
627 let (parent_bq_level, parent_bq_prefix_len) = cache.blockquote_info(parent_line);
629 let (tight, loose) = thresholds;
630
631 for line_num in (parent_line + 1)..current_line {
634 if let Some(line_info) = ctx.line_info(line_num)
635 && let Some(list_item) = &line_info.list_item
636 && list_item.marker_column == list_indent
637 {
638 if cache
640 .find_continuation_indent(
641 parent_line + 1,
642 line_num - 1,
643 tight,
644 loose,
645 parent_bq_level,
646 parent_bq_prefix_len,
647 )
648 .is_some()
649 {
650 return true;
651 }
652 }
653 }
654 false
655 }
656
657 fn check_list_block_group(
659 &self,
660 ctx: &crate::lint_context::LintContext,
661 cache: &LineCacheInfo,
662 group: &[&crate::lint_context::ListBlock],
663 warnings: &mut Vec<LintWarning>,
664 ) {
665 let mut candidate_items: Vec<(
668 usize,
669 usize,
670 &crate::lint_context::LineInfo,
671 &crate::lint_context::ListItemInfo,
672 )> = Vec::new();
673
674 for list_block in group {
675 for &item_line in &list_block.item_lines {
676 if let Some(line_info) = ctx.line_info(item_line)
677 && let Some(list_item) = line_info.list_item.as_deref()
678 {
679 let effective_indent = if let Some(blockquote) = &line_info.blockquote {
681 list_item.marker_column.saturating_sub(blockquote.nesting_level * 2)
683 } else {
684 list_item.marker_column
686 };
687
688 candidate_items.push((item_line, effective_indent, line_info, list_item));
689 }
690 }
691 }
692
693 candidate_items.sort_by_key(|(line_num, _, _, _)| *line_num);
695
696 let mut skipped_lines: std::collections::HashSet<usize> = std::collections::HashSet::new();
699 let mut all_list_items: Vec<(
700 usize,
701 usize,
702 &crate::lint_context::LineInfo,
703 &crate::lint_context::ListItemInfo,
704 )> = Vec::new();
705
706 for (item_line, effective_indent, line_info, list_item) in candidate_items {
707 if line_info.in_footnote_definition {
709 skipped_lines.insert(item_line);
710 continue;
711 }
712 if self.is_continuation_content(ctx, cache, item_line, effective_indent) {
714 skipped_lines.insert(item_line);
715 continue;
716 }
717
718 if let Some(&parent_line) = cache.parent_map.get(&item_line)
720 && skipped_lines.contains(&parent_line)
721 {
722 skipped_lines.insert(item_line);
723 continue;
724 }
725
726 all_list_items.push((item_line, effective_indent, line_info, list_item));
727 }
728
729 if all_list_items.is_empty() {
730 return;
731 }
732
733 all_list_items.sort_by_key(|(line_num, _, _, _)| *line_num);
735
736 let mut level_map: HashMap<usize, usize> = HashMap::new();
740 let mut level_indents: HashMap<usize, Vec<usize>> = HashMap::new(); let mut indent_to_level: HashMap<usize, (usize, usize)> = HashMap::new();
745
746 for (line_num, indent, _, _) in &all_list_items {
748 let level = if indent_to_level.is_empty() {
749 level_indents.entry(1).or_default().push(*indent);
751 1
752 } else {
753 let mut determined_level = 0;
755
756 if let Some(&(existing_level, _)) = indent_to_level.get(indent) {
758 determined_level = existing_level;
759 } else {
760 let mut best_parent: Option<(usize, usize, usize)> = None; for (&tracked_indent, &(tracked_level, tracked_line)) in &indent_to_level {
766 if tracked_indent < *indent {
767 if best_parent.is_none() || tracked_indent > best_parent.unwrap().0 {
770 best_parent = Some((tracked_indent, tracked_level, tracked_line));
771 }
772 }
773 }
774
775 if let Some((parent_indent, parent_level, _parent_line)) = best_parent {
776 if parent_indent + Self::MIN_CHILD_INDENT_INCREASE <= *indent {
778 determined_level = parent_level + 1;
780 } else if (*indent as i32 - parent_indent as i32).abs() <= Self::SAME_LEVEL_TOLERANCE {
781 determined_level = parent_level;
783 } else {
784 let mut found_similar = false;
788 if let Some(indents_at_level) = level_indents.get(&parent_level) {
789 for &level_indent in indents_at_level {
790 if (level_indent as i32 - *indent as i32).abs() <= Self::SAME_LEVEL_TOLERANCE {
791 determined_level = parent_level;
792 found_similar = true;
793 break;
794 }
795 }
796 }
797 if !found_similar {
798 determined_level = parent_level + 1;
800 }
801 }
802 }
803
804 if determined_level == 0 {
806 determined_level = 1;
807 }
808
809 level_indents.entry(determined_level).or_default().push(*indent);
811 }
812
813 determined_level
814 };
815
816 level_map.insert(*line_num, level);
817 indent_to_level.insert(*indent, (level, *line_num));
819 }
820
821 let mut level_groups: HashMap<usize, Vec<(usize, usize, &crate::lint_context::LineInfo)>> = HashMap::new();
823 for (line_num, indent, line_info, _) in &all_list_items {
824 let level = level_map[line_num];
825 level_groups
826 .entry(level)
827 .or_default()
828 .push((*line_num, *indent, *line_info));
829 }
830
831 for (level, mut group) in level_groups {
833 group.sort_by_key(|(line_num, _, _)| *line_num);
834
835 if level == 1 {
836 for (line_num, indent, line_info) in &group {
838 if *indent != self.top_level_indent {
839 warnings.push(self.create_indent_warning(
840 ctx,
841 *line_num,
842 line_info,
843 *indent,
844 self.top_level_indent,
845 ));
846 }
847 }
848 } else {
849 let parent_content_groups =
852 self.group_by_parent_content_column(level, &group, &all_list_items, &level_map);
853
854 for items in parent_content_groups.values() {
856 self.check_indent_consistency(ctx, items, warnings);
857 }
858 }
859 }
860 }
861
862 fn check_optimized(&self, ctx: &crate::lint_context::LintContext) -> Vec<LintWarning> {
864 let content = ctx.content;
865
866 if content.is_empty() {
868 return Vec::new();
869 }
870
871 if ctx.list_blocks.is_empty() {
873 return Vec::new();
874 }
875
876 let mut warnings = Vec::new();
877
878 let cache = LineCacheInfo::new(ctx);
880
881 let block_groups = self.group_related_list_blocks(&ctx.list_blocks);
884
885 for group in block_groups {
886 self.check_list_block_group(ctx, &cache, &group, &mut warnings);
887 }
888
889 warnings
890 }
891}
892
893impl Rule for MD005ListIndent {
894 fn name(&self) -> &'static str {
895 "MD005"
896 }
897
898 fn description(&self) -> &'static str {
899 "List indentation should be consistent"
900 }
901
902 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
903 Ok(self.check_optimized(ctx))
905 }
906
907 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
908 let warnings = self.check(ctx)?;
909 let warnings =
910 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
911 if warnings.is_empty() {
912 return Ok(ctx.content.to_string());
913 }
914
915 let mut warnings_with_fixes: Vec<_> = warnings
917 .into_iter()
918 .filter_map(|w| w.fix.clone().map(|fix| (w, fix)))
919 .collect();
920 warnings_with_fixes.sort_by_key(|(_, fix)| std::cmp::Reverse(fix.range.start));
921
922 let mut content = ctx.content.to_string();
924 for (_, fix) in warnings_with_fixes {
925 if fix.range.start <= content.len() && fix.range.end <= content.len() {
926 content.replace_range(fix.range, &fix.replacement);
927 }
928 }
929
930 Ok(content)
931 }
932
933 fn category(&self) -> RuleCategory {
934 RuleCategory::List
935 }
936
937 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
939 ctx.content.is_empty() || !ctx.lines.iter().any(|line| line.list_item.is_some())
941 }
942
943 fn as_any(&self) -> &dyn std::any::Any {
944 self
945 }
946
947 fn default_config_section(&self) -> Option<(String, toml::Value)> {
948 None
949 }
950
951 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
952 where
953 Self: Sized,
954 {
955 let mut top_level_indent = 0;
957
958 if let Some(md007_config) = config.rules.get("MD007") {
960 if let Some(start_indented) = md007_config.values.get("start-indented")
962 && let Some(start_indented_bool) = start_indented.as_bool()
963 && start_indented_bool
964 {
965 if let Some(start_indent) = md007_config.values.get("start-indent") {
967 if let Some(indent_value) = start_indent.as_integer() {
968 top_level_indent = indent_value as usize;
969 }
970 } else {
971 top_level_indent = 2;
973 }
974 }
975 }
976
977 Box::new(MD005ListIndent { top_level_indent })
978 }
979}
980
981#[cfg(test)]
982mod tests {
983 use super::*;
984 use crate::lint_context::LintContext;
985
986 #[test]
987 fn test_valid_unordered_list() {
988 let rule = MD005ListIndent::default();
989 let content = "\
990* Item 1
991* Item 2
992 * Nested 1
993 * Nested 2
994* Item 3";
995 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
996 let result = rule.check(&ctx).unwrap();
997 assert!(result.is_empty());
998 }
999
1000 #[test]
1001 fn test_valid_ordered_list() {
1002 let rule = MD005ListIndent::default();
1003 let content = "\
10041. Item 1
10052. Item 2
1006 1. Nested 1
1007 2. Nested 2
10083. Item 3";
1009 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1010 let result = rule.check(&ctx).unwrap();
1011 assert!(result.is_empty());
1014 }
1015
1016 #[test]
1017 fn test_invalid_unordered_indent() {
1018 let rule = MD005ListIndent::default();
1019 let content = "\
1020* Item 1
1021 * Item 2
1022 * Nested 1";
1023 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1024 let result = rule.check(&ctx).unwrap();
1025 assert_eq!(result.len(), 1);
1028 let fixed = rule.fix(&ctx).unwrap();
1029 assert_eq!(fixed, "* Item 1\n* Item 2\n * Nested 1");
1030 }
1031
1032 #[test]
1033 fn test_invalid_ordered_indent() {
1034 let rule = MD005ListIndent::default();
1035 let content = "\
10361. Item 1
1037 2. Item 2
1038 1. Nested 1";
1039 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1040 let result = rule.check(&ctx).unwrap();
1041 assert_eq!(result.len(), 1);
1042 let fixed = rule.fix(&ctx).unwrap();
1043 assert_eq!(fixed, "1. Item 1\n2. Item 2\n 1. Nested 1");
1047 }
1048
1049 #[test]
1050 fn test_mixed_list_types() {
1051 let rule = MD005ListIndent::default();
1052 let content = "\
1053* Item 1
1054 1. Nested ordered
1055 * Nested unordered
1056* Item 2";
1057 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1058 let result = rule.check(&ctx).unwrap();
1059 assert!(result.is_empty());
1060 }
1061
1062 #[test]
1063 fn test_multiple_levels() {
1064 let rule = MD005ListIndent::default();
1065 let content = "\
1066* Level 1
1067 * Level 2
1068 * Level 3";
1069 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1070 let result = rule.check(&ctx).unwrap();
1071 assert!(result.is_empty(), "MD005 should accept consistent indentation pattern");
1073 }
1074
1075 #[test]
1076 fn test_empty_lines() {
1077 let rule = MD005ListIndent::default();
1078 let content = "\
1079* Item 1
1080
1081 * Nested 1
1082
1083* Item 2";
1084 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1085 let result = rule.check(&ctx).unwrap();
1086 assert!(result.is_empty());
1087 }
1088
1089 #[test]
1090 fn test_no_lists() {
1091 let rule = MD005ListIndent::default();
1092 let content = "\
1093Just some text
1094More text
1095Even more text";
1096 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1097 let result = rule.check(&ctx).unwrap();
1098 assert!(result.is_empty());
1099 }
1100
1101 #[test]
1102 fn test_complex_nesting() {
1103 let rule = MD005ListIndent::default();
1104 let content = "\
1105* Level 1
1106 * Level 2
1107 * Level 3
1108 * Back to 2
1109 1. Ordered 3
1110 2. Still 3
1111* Back to 1";
1112 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1113 let result = rule.check(&ctx).unwrap();
1114 assert!(result.is_empty());
1115 }
1116
1117 #[test]
1118 fn test_invalid_complex_nesting() {
1119 let rule = MD005ListIndent::default();
1120 let content = "\
1121* Level 1
1122 * Level 2
1123 * Level 3
1124 * Back to 2
1125 1. Ordered 3
1126 2. Still 3
1127* Back to 1";
1128 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1129 let result = rule.check(&ctx).unwrap();
1130 assert_eq!(result.len(), 1);
1132 assert!(
1133 result[0].message.contains("Expected indentation of 5 spaces, found 6")
1134 || result[0].message.contains("Expected indentation of 6 spaces, found 5")
1135 );
1136 }
1137
1138 #[test]
1139 fn test_with_lint_context() {
1140 let rule = MD005ListIndent::default();
1141
1142 let content = "* Item 1\n* Item 2\n * Nested item\n * Another nested item";
1144 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1145 let result = rule.check(&ctx).unwrap();
1146 assert!(result.is_empty());
1147
1148 let content = "* Item 1\n* Item 2\n * Nested item\n * Another nested item";
1150 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1151 let result = rule.check(&ctx).unwrap();
1152 assert!(!result.is_empty()); let content = "* Item 1\n * Nested item\n * Another nested item with wrong indent";
1156 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1157 let result = rule.check(&ctx).unwrap();
1158 assert!(!result.is_empty()); }
1160
1161 #[test]
1163 fn test_list_with_continuations() {
1164 let rule = MD005ListIndent::default();
1165 let content = "\
1166* Item 1
1167 This is a continuation
1168 of the first item
1169 * Nested item
1170 with its own continuation
1171* Item 2";
1172 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1173 let result = rule.check(&ctx).unwrap();
1174 assert!(result.is_empty());
1175 }
1176
1177 #[test]
1178 fn test_list_in_blockquote() {
1179 let rule = MD005ListIndent::default();
1180 let content = "\
1181> * Item 1
1182> * Nested 1
1183> * Nested 2
1184> * Item 2";
1185 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1186 let result = rule.check(&ctx).unwrap();
1187
1188 assert!(
1190 result.is_empty(),
1191 "Expected no warnings for correctly indented blockquote list, got: {result:?}"
1192 );
1193 }
1194
1195 #[test]
1196 fn test_list_with_code_blocks() {
1197 let rule = MD005ListIndent::default();
1198 let content = "\
1199* Item 1
1200 ```
1201 code block
1202 ```
1203 * Nested item
1204* Item 2";
1205 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1206 let result = rule.check(&ctx).unwrap();
1207 assert!(result.is_empty());
1208 }
1209
1210 #[test]
1211 fn test_list_with_tabs() {
1212 let rule = MD005ListIndent::default();
1213 let content = "* Item 1\n * Wrong indent (3 spaces)\n * Correct indent (2 spaces)";
1217 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1218 let result = rule.check(&ctx).unwrap();
1219 assert!(!result.is_empty());
1221 }
1222
1223 #[test]
1224 fn test_inconsistent_at_same_level() {
1225 let rule = MD005ListIndent::default();
1226 let content = "\
1227* Item 1
1228 * Nested 1
1229 * Nested 2
1230 * Wrong indent for same level
1231 * Nested 3";
1232 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1233 let result = rule.check(&ctx).unwrap();
1234 assert!(!result.is_empty());
1235 assert!(result.iter().any(|w| w.line == 4));
1237 }
1238
1239 #[test]
1240 fn test_zero_indent_top_level() {
1241 let rule = MD005ListIndent::default();
1242 let content = concat!(" * Wrong indent\n", "* Correct\n", " * Nested");
1244 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1245 let result = rule.check(&ctx).unwrap();
1246
1247 assert!(!result.is_empty());
1249 assert!(result.iter().any(|w| w.line == 1));
1250 }
1251
1252 #[test]
1253 fn test_fix_preserves_content() {
1254 let rule = MD005ListIndent::default();
1255 let content = "\
1256* Item with **bold** and *italic*
1257 * Wrong indent with `code`
1258 * Also wrong with [link](url)";
1259 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1260 let fixed = rule.fix(&ctx).unwrap();
1261 assert!(fixed.contains("**bold**"));
1262 assert!(fixed.contains("*italic*"));
1263 assert!(fixed.contains("`code`"));
1264 assert!(fixed.contains("[link](url)"));
1265 }
1266
1267 #[test]
1268 fn test_deeply_nested_lists() {
1269 let rule = MD005ListIndent::default();
1270 let content = "\
1271* L1
1272 * L2
1273 * L3
1274 * L4
1275 * L5
1276 * L6";
1277 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1278 let result = rule.check(&ctx).unwrap();
1279 assert!(result.is_empty());
1280 }
1281
1282 #[test]
1283 fn test_fix_multiple_issues() {
1284 let rule = MD005ListIndent::default();
1285 let content = "\
1286* Item 1
1287 * Wrong 1
1288 * Wrong 2
1289 * Wrong 3
1290 * Correct
1291 * Wrong 4";
1292 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1293 let fixed = rule.fix(&ctx).unwrap();
1294 let lines: Vec<&str> = fixed.lines().collect();
1296 assert_eq!(lines[0], "* Item 1");
1297 assert!(lines[1].starts_with(" * ") || lines[1].starts_with("* "));
1299 }
1300
1301 #[test]
1302 fn test_performance_large_document() {
1303 let rule = MD005ListIndent::default();
1304 let mut content = String::new();
1305 for i in 0..100 {
1306 content.push_str(&format!("* Item {i}\n"));
1307 content.push_str(&format!(" * Nested {i}\n"));
1308 }
1309 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1310 let result = rule.check(&ctx).unwrap();
1311 assert!(result.is_empty());
1312 }
1313
1314 #[test]
1315 fn test_column_positions() {
1316 let rule = MD005ListIndent::default();
1317 let content = " * Wrong indent";
1318 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1319 let result = rule.check(&ctx).unwrap();
1320 assert_eq!(result.len(), 1);
1321 assert_eq!(result[0].column, 1, "Expected column 1, got {}", result[0].column);
1322 assert_eq!(
1323 result[0].end_column, 2,
1324 "Expected end_column 2, got {}",
1325 result[0].end_column
1326 );
1327 }
1328
1329 #[test]
1330 fn test_should_skip() {
1331 let rule = MD005ListIndent::default();
1332
1333 let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard, None);
1335 assert!(rule.should_skip(&ctx));
1336
1337 let ctx = LintContext::new("Just plain text", crate::config::MarkdownFlavor::Standard, None);
1339 assert!(rule.should_skip(&ctx));
1340
1341 let ctx = LintContext::new("* List item", crate::config::MarkdownFlavor::Standard, None);
1343 assert!(!rule.should_skip(&ctx));
1344
1345 let ctx = LintContext::new("1. Ordered list", crate::config::MarkdownFlavor::Standard, None);
1346 assert!(!rule.should_skip(&ctx));
1347 }
1348
1349 #[test]
1350 fn test_should_skip_validation() {
1351 let rule = MD005ListIndent::default();
1352 let content = "* List item";
1353 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1354 assert!(!rule.should_skip(&ctx));
1355
1356 let content = "No lists here";
1357 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1358 assert!(rule.should_skip(&ctx));
1359 }
1360
1361 #[test]
1362 fn test_edge_case_single_space_indent() {
1363 let rule = MD005ListIndent::default();
1364 let content = "\
1365* Item 1
1366 * Single space - wrong
1367 * Two spaces - correct";
1368 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1369 let result = rule.check(&ctx).unwrap();
1370 assert_eq!(result.len(), 2);
1373 assert!(result.iter().any(|w| w.line == 2 && w.message.contains("found 1")));
1374 }
1375
1376 #[test]
1377 fn test_edge_case_three_space_indent() {
1378 let rule = MD005ListIndent::default();
1379 let content = "\
1380* Item 1
1381 * Three spaces - first establishes pattern
1382 * Two spaces - inconsistent with established pattern";
1383 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1384 let result = rule.check(&ctx).unwrap();
1385 assert_eq!(result.len(), 1);
1389 assert!(result.iter().any(|w| w.line == 3 && w.message.contains("found 2")));
1390 }
1391
1392 #[test]
1393 fn test_nested_bullets_under_numbered_items() {
1394 let rule = MD005ListIndent::default();
1395 let content = "\
13961. **Active Directory/LDAP**
1397 - User authentication and directory services
1398 - LDAP for user information and validation
1399
14002. **Oracle Unified Directory (OUD)**
1401 - Extended user directory services
1402 - Verification of project account presence and changes";
1403 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1404 let result = rule.check(&ctx).unwrap();
1405 assert!(
1407 result.is_empty(),
1408 "Expected no warnings for bullets with 3 spaces under numbered items, got: {result:?}"
1409 );
1410 }
1411
1412 #[test]
1413 fn test_nested_bullets_under_numbered_items_wrong_indent() {
1414 let rule = MD005ListIndent::default();
1415 let content = "\
14161. **Active Directory/LDAP**
1417 - Wrong: only 2 spaces
1418 - Correct: 3 spaces";
1419 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1420 let result = rule.check(&ctx).unwrap();
1421 assert_eq!(
1423 result.len(),
1424 1,
1425 "Expected 1 warning, got {}. Warnings: {:?}",
1426 result.len(),
1427 result
1428 );
1429 assert!(
1431 result
1432 .iter()
1433 .any(|w| (w.line == 2 && w.message.contains("found 2"))
1434 || (w.line == 3 && w.message.contains("found 3")))
1435 );
1436 }
1437
1438 #[test]
1439 fn test_regular_nested_bullets_still_work() {
1440 let rule = MD005ListIndent::default();
1441 let content = "\
1442* Top level
1443 * Second level (2 spaces is correct for bullets under bullets)
1444 * Third level (4 spaces)";
1445 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1446 let result = rule.check(&ctx).unwrap();
1447 assert!(
1449 result.is_empty(),
1450 "Expected no warnings for regular bullet nesting, got: {result:?}"
1451 );
1452 }
1453
1454 #[test]
1455 fn test_fix_range_accuracy() {
1456 let rule = MD005ListIndent::default();
1457 let content = " * Wrong indent";
1458 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1459 let result = rule.check(&ctx).unwrap();
1460 assert_eq!(result.len(), 1);
1461
1462 let fix = result[0].fix.as_ref().unwrap();
1463 assert_eq!(fix.replacement, "");
1465 }
1466
1467 #[test]
1468 fn test_four_space_indent_pattern() {
1469 let rule = MD005ListIndent::default();
1470 let content = "\
1471* Item 1
1472 * Item 2 with 4 spaces
1473 * Item 3 with 8 spaces
1474 * Item 4 with 4 spaces";
1475 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1476 let result = rule.check(&ctx).unwrap();
1477 assert!(
1479 result.is_empty(),
1480 "MD005 should accept consistent 4-space indentation pattern, got {} warnings",
1481 result.len()
1482 );
1483 }
1484
1485 #[test]
1486 fn test_issue_64_scenario() {
1487 let rule = MD005ListIndent::default();
1489 let content = "\
1490* Top level item
1491 * Sub item with 4 spaces (as configured in MD007)
1492 * Nested sub item with 8 spaces
1493 * Another sub item with 4 spaces
1494* Another top level";
1495
1496 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1497 let result = rule.check(&ctx).unwrap();
1498
1499 assert!(
1501 result.is_empty(),
1502 "MD005 should accept 4-space indentation when that's the pattern being used. Got {} warnings",
1503 result.len()
1504 );
1505 }
1506
1507 #[test]
1508 fn test_continuation_content_scenario() {
1509 let rule = MD005ListIndent::default();
1510 let content = "\
1511- **Changes to how the Python version is inferred** ([#16319](example))
1512
1513 In previous versions of Ruff, you could specify your Python version with:
1514
1515 - The `target-version` option in a `ruff.toml` file
1516 - The `project.requires-python` field in a `pyproject.toml` file";
1517
1518 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1519
1520 let result = rule.check(&ctx).unwrap();
1521
1522 assert!(
1524 result.is_empty(),
1525 "MD005 should not flag continuation content lists, got {} warnings: {:?}",
1526 result.len(),
1527 result
1528 );
1529 }
1530
1531 #[test]
1532 fn test_multiple_continuation_lists_scenario() {
1533 let rule = MD005ListIndent::default();
1534 let content = "\
1535- **Changes to how the Python version is inferred** ([#16319](example))
1536
1537 In previous versions of Ruff, you could specify your Python version with:
1538
1539 - The `target-version` option in a `ruff.toml` file
1540 - The `project.requires-python` field in a `pyproject.toml` file
1541
1542 In v0.10, config discovery has been updated to address this issue:
1543
1544 - If Ruff finds a `ruff.toml` file without a `target-version`, it will check
1545 - If Ruff finds a user-level configuration, the `requires-python` field will take precedence
1546 - If there is no config file, Ruff will search for the closest `pyproject.toml`";
1547
1548 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1549
1550 let result = rule.check(&ctx).unwrap();
1551
1552 assert!(
1554 result.is_empty(),
1555 "MD005 should not flag continuation content lists, got {} warnings: {:?}",
1556 result.len(),
1557 result
1558 );
1559 }
1560
1561 #[test]
1562 fn test_issue_115_sublist_after_code_block() {
1563 let rule = MD005ListIndent::default();
1564 let content = "\
15651. List item 1
1566
1567 ```rust
1568 fn foo() {}
1569 ```
1570
1571 Sublist:
1572
1573 - A
1574 - B
1575";
1576 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1577 let result = rule.check(&ctx).unwrap();
1578 assert!(
1582 result.is_empty(),
1583 "Expected no warnings for sub-list after code block in list item, got {} warnings: {:?}",
1584 result.len(),
1585 result
1586 );
1587 }
1588
1589 #[test]
1590 fn test_edge_case_continuation_at_exact_boundary() {
1591 let rule = MD005ListIndent::default();
1592 let content = "\
1594* Item (content at column 2)
1595 Text at column 2 (exact boundary - continuation)
1596 * Sub at column 2";
1597 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1598 let result = rule.check(&ctx).unwrap();
1599 assert!(
1601 result.is_empty(),
1602 "Expected no warnings when text and sub-list are at exact parent content_column, got: {result:?}"
1603 );
1604 }
1605
1606 #[test]
1607 fn test_edge_case_unicode_in_continuation() {
1608 let rule = MD005ListIndent::default();
1609 let content = "\
1610* Parent
1611 Text with emoji 😀 and Unicode ñ characters
1612 * Sub-list should still work";
1613 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1614 let result = rule.check(&ctx).unwrap();
1615 assert!(
1617 result.is_empty(),
1618 "Expected no warnings with Unicode in continuation content, got: {result:?}"
1619 );
1620 }
1621
1622 #[test]
1623 fn test_edge_case_large_empty_line_gap() {
1624 let rule = MD005ListIndent::default();
1625 let content = "\
1626* Parent at line 1
1627 Continuation text
1628
1629
1630
1631 More continuation after many empty lines
1632
1633 * Child after gap
1634 * Another child";
1635 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1636 let result = rule.check(&ctx).unwrap();
1637 assert!(
1639 result.is_empty(),
1640 "Expected no warnings with large gaps in continuation content, got: {result:?}"
1641 );
1642 }
1643
1644 #[test]
1645 fn test_edge_case_multiple_continuation_blocks_varying_indent() {
1646 let rule = MD005ListIndent::default();
1647 let content = "\
1648* Parent (content at column 2)
1649 First paragraph at column 2
1650 Indented quote at column 4
1651 Back to column 2
1652 * Sub-list at column 2";
1653 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1654 let result = rule.check(&ctx).unwrap();
1655 assert!(
1657 result.is_empty(),
1658 "Expected no warnings with varying continuation indent, got: {result:?}"
1659 );
1660 }
1661
1662 #[test]
1663 fn test_edge_case_deep_nesting_no_continuation() {
1664 let rule = MD005ListIndent::default();
1665 let content = "\
1666* Parent
1667 * Immediate child (no continuation text before)
1668 * Grandchild
1669 * Great-grandchild
1670 * Great-great-grandchild
1671 * Another child at level 2";
1672 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1673 let result = rule.check(&ctx).unwrap();
1674 assert!(
1676 result.is_empty(),
1677 "Expected no warnings for deep nesting without continuation, got: {result:?}"
1678 );
1679 }
1680
1681 #[test]
1682 fn test_edge_case_blockquote_continuation_content() {
1683 let rule = MD005ListIndent::default();
1684 let content = "\
1685> * Parent in blockquote
1686> Continuation in blockquote
1687> * Sub-list in blockquote
1688> * Another sub-list";
1689 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1690 let result = rule.check(&ctx).unwrap();
1691 assert!(
1693 result.is_empty(),
1694 "Expected no warnings for blockquote continuation, got: {result:?}"
1695 );
1696 }
1697
1698 #[test]
1699 fn test_edge_case_one_space_less_than_content_column() {
1700 let rule = MD005ListIndent::default();
1701 let content = "\
1702* Parent (content at column 2)
1703 Text at column 1 (one less than content_column - NOT continuation)
1704 * Child";
1705 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1706 let result = rule.check(&ctx).unwrap();
1707 assert!(
1713 result.is_empty() || !result.is_empty(),
1714 "Test should complete without panic"
1715 );
1716 }
1717
1718 #[test]
1719 fn test_edge_case_multiple_code_blocks_different_indentation() {
1720 let rule = MD005ListIndent::default();
1721 let content = "\
1722* Parent
1723 ```
1724 code at 2 spaces
1725 ```
1726 ```
1727 code at 4 spaces
1728 ```
1729 * Sub-list should not be confused";
1730 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1731 let result = rule.check(&ctx).unwrap();
1732 assert!(
1734 result.is_empty(),
1735 "Expected no warnings with multiple code blocks, got: {result:?}"
1736 );
1737 }
1738
1739 #[test]
1740 fn test_performance_very_large_document() {
1741 let rule = MD005ListIndent::default();
1742 let mut content = String::new();
1743
1744 for i in 0..1000 {
1746 content.push_str(&format!("* Item {i}\n"));
1747 content.push_str(&format!(" * Nested {i}\n"));
1748 if i % 10 == 0 {
1749 content.push_str(" Some continuation text\n");
1750 }
1751 }
1752
1753 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1754
1755 let start = std::time::Instant::now();
1757 let result = rule.check(&ctx).unwrap();
1758 let elapsed = start.elapsed();
1759
1760 assert!(result.is_empty());
1761 println!("Processed 1000 list items in {elapsed:?}");
1762 assert!(
1765 elapsed.as_secs() < 1,
1766 "Should complete in under 1 second, took {elapsed:?}"
1767 );
1768 }
1769
1770 #[test]
1771 fn test_ordered_list_variable_marker_width() {
1772 let rule = MD005ListIndent::default();
1777 let content = "\
17781. One
1779 - One
1780 - Two
17812. Two
1782 - One
17833. Three
1784 - One
17854. Four
1786 - One
17875. Five
1788 - One
17896. Six
1790 - One
17917. Seven
1792 - One
17938. Eight
1794 - One
17959. Nine
1796 - One
179710. Ten
1798 - One";
1799 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1800 let result = rule.check(&ctx).unwrap();
1801 assert!(
1802 result.is_empty(),
1803 "Expected no warnings for ordered list with variable marker widths, got: {result:?}"
1804 );
1805 }
1806
1807 #[test]
1808 fn test_ordered_list_inconsistent_siblings() {
1809 let rule = MD005ListIndent::default();
1811 let content = "\
18121. Item one
1813 - First sublist at 3 spaces
1814 - Second sublist at 2 spaces (inconsistent)
1815 - Third sublist at 3 spaces";
1816 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1817 let result = rule.check(&ctx).unwrap();
1818 assert_eq!(
1820 result.len(),
1821 1,
1822 "Expected 1 warning for inconsistent sibling indent, got: {result:?}"
1823 );
1824 assert!(result[0].message.contains("Expected indentation of 3"));
1825 }
1826
1827 #[test]
1828 fn test_ordered_list_single_sublist_no_warning() {
1829 let rule = MD005ListIndent::default();
1832 let content = "\
183310. Item ten
1834 - Only sublist at 3 spaces";
1835 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1836 let result = rule.check(&ctx).unwrap();
1837 assert!(
1839 result.is_empty(),
1840 "Expected no warnings for single sublist item, got: {result:?}"
1841 );
1842 }
1843
1844 #[test]
1845 fn test_sublists_grouped_by_parent_content_column() {
1846 let rule = MD005ListIndent::default();
1850 let content = "\
18519. Item nine
1852 - First sublist at 3 spaces
1853 - Second sublist at 3 spaces
1854 - Third sublist at 3 spaces
185510. Item ten
1856 - First sublist at 4 spaces
1857 - Second sublist at 4 spaces
1858 - Third sublist at 4 spaces";
1859 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1860 let result = rule.check(&ctx).unwrap();
1861 assert!(
1864 result.is_empty(),
1865 "Expected no warnings for sublists grouped by parent, got: {result:?}"
1866 );
1867 }
1868
1869 #[test]
1870 fn test_inconsistent_indent_within_parent_group() {
1871 let rule = MD005ListIndent::default();
1873 let content = "\
187410. Item ten
1875 - First sublist at 4 spaces
1876 - Second sublist at 3 spaces (inconsistent!)
1877 - Third sublist at 4 spaces";
1878 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1879 let result = rule.check(&ctx).unwrap();
1880 assert_eq!(
1882 result.len(),
1883 1,
1884 "Expected 1 warning for inconsistent indent within parent group, got: {result:?}"
1885 );
1886 assert!(result[0].line == 3);
1887 assert!(result[0].message.contains("Expected indentation of 4"));
1888 }
1889
1890 #[test]
1891 fn test_blockquote_nested_list_fix_preserves_blockquote_prefix() {
1892 use crate::rule::Rule;
1896
1897 let rule = MD005ListIndent::default();
1898 let content = "> * Federation sender blacklists are now persisted.";
1899 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1900 let result = rule.check(&ctx).unwrap();
1901
1902 assert_eq!(result.len(), 1, "Expected 1 warning for extra indent");
1903
1904 assert!(result[0].fix.is_some(), "Should have a fix");
1906 let fixed = rule.fix(&ctx).expect("Fix should succeed");
1907
1908 assert!(
1910 fixed.starts_with("> "),
1911 "Fixed content should start with blockquote prefix '> ', got: {fixed:?}"
1912 );
1913 assert!(
1914 !fixed.starts_with("* "),
1915 "Fixed content should NOT start with just '* ' (blockquote removed), got: {fixed:?}"
1916 );
1917 assert_eq!(
1918 fixed.trim(),
1919 "> * Federation sender blacklists are now persisted.",
1920 "Fixed content should be '> * Federation sender...' with single space after >"
1921 );
1922 }
1923
1924 #[test]
1925 fn test_nested_blockquote_list_fix_preserves_prefix() {
1926 use crate::rule::Rule;
1928
1929 let rule = MD005ListIndent::default();
1930 let content = ">> * Nested blockquote list item";
1931 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1932 let result = rule.check(&ctx).unwrap();
1933
1934 if !result.is_empty() {
1935 let fixed = rule.fix(&ctx).expect("Fix should succeed");
1936 assert!(
1938 fixed.contains(">>") || fixed.contains("> >"),
1939 "Fixed content should preserve nested blockquote prefix, got: {fixed:?}"
1940 );
1941 }
1942 }
1943}