1use crate::utils::blockquote::effective_indent_in_blockquote;
7use crate::utils::list_fix_guard::{Allowed, drop_structure_changing_fixes};
8use crate::utils::list_indent_shift::{Nesting, move_owned_lines};
9use crate::utils::range_utils::calculate_match_range;
10
11use crate::lint_context::{ParsedListBlock, ParsedListBlocks, ParsedListItem};
12use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
13use std::cell::RefCell;
15use std::collections::HashMap;
16use toml;
17
18type ParentContentGroups<'a> = HashMap<(usize, bool), Vec<(usize, usize, &'a crate::lint_context::LineInfo)>>;
21
22#[derive(Clone, Default)]
24pub struct MD005ListIndent {
25 top_level_indent: usize,
27}
28
29struct LineCacheInfo {
31 indentation: Vec<usize>,
33 blockquote_levels: Vec<usize>,
35 line_contents: Vec<String>,
37 flags: Vec<u8>,
39 parent_map: HashMap<usize, usize>,
42 item_end: HashMap<usize, usize>,
47 decided: RefCell<HashMap<ScanKey, Option<Decision>>>,
50 first_item_at: RefCell<HashMap<(usize, usize, usize), Option<usize>>>,
53}
54
55type ScanKey = (usize, usize, usize, usize, usize);
57
58type Decision = (usize, Option<usize>);
62
63const FLAG_HAS_CONTENT: u8 = 1;
64const FLAG_IS_LIST_ITEM: u8 = 2;
65
66impl LineCacheInfo {
67 fn new(ctx: &crate::lint_context::LintContext) -> Self {
69 let total_lines = ctx.lines.len();
70 let mut indentation = Vec::with_capacity(total_lines);
71 let mut blockquote_levels = Vec::with_capacity(total_lines);
72 let mut line_contents = Vec::with_capacity(total_lines);
73 let mut flags = Vec::with_capacity(total_lines);
74 let mut parent_map = HashMap::new();
75 let mut item_end = HashMap::new();
76
77 let mut indent_stack: Vec<(usize, usize)> = Vec::new();
89
90 for (idx, line_info) in ctx.lines.iter().enumerate() {
91 let line_content = line_info.content(ctx.content);
92 let content = line_content.trim_start();
93 let line_indent = line_info.byte_len - content.len();
94
95 indentation.push(line_indent);
96
97 let bq_level = line_info.blockquote.as_ref().map_or(0, |bq| bq.nesting_level);
99 blockquote_levels.push(bq_level);
100
101 line_contents.push(line_content.to_string());
103
104 let mut flag = 0u8;
105 if !content.is_empty() {
106 flag |= FLAG_HAS_CONTENT;
107 }
108 if let Some(list_item) = ctx.list_item_on_line(idx + 1) {
109 flag |= FLAG_IS_LIST_ITEM;
110
111 let line_num = idx + 1; let marker_column = list_item.marker_column();
113
114 while let Some(&(indent, popped_line)) = indent_stack.last() {
116 if indent < marker_column {
117 break;
118 }
119 item_end.insert(popped_line, line_num);
120 indent_stack.pop();
121 }
122
123 if let Some((_, parent_line)) = indent_stack.last() {
124 parent_map.insert(line_num, *parent_line);
125 }
126
127 indent_stack.push((marker_column, line_num));
128 }
129 flags.push(flag);
130 }
131
132 Self {
133 indentation,
134 blockquote_levels,
135 line_contents,
136 flags,
137 parent_map,
138 item_end,
139 decided: RefCell::default(),
140 first_item_at: RefCell::default(),
141 }
142 }
143
144 fn has_content(&self, idx: usize) -> bool {
146 self.flags.get(idx).is_some_and(|&f| f & FLAG_HAS_CONTENT != 0)
147 }
148
149 fn is_list_item(&self, idx: usize) -> bool {
151 self.flags.get(idx).is_some_and(|&f| f & FLAG_IS_LIST_ITEM != 0)
152 }
153
154 fn blockquote_info(&self, line: usize) -> (usize, usize) {
156 if line == 0 || line > self.line_contents.len() {
157 return (0, 0);
158 }
159 let idx = line - 1;
160 let bq_level = self.blockquote_levels.get(idx).copied().unwrap_or(0);
161 if bq_level == 0 {
162 return (0, 0);
163 }
164 let content = &self.line_contents[idx];
166 let mut prefix_len = 0;
167 let mut found = 0;
168 for c in content.chars() {
169 prefix_len += c.len_utf8();
170 if c == '>' {
171 found += 1;
172 if found == bq_level {
173 if content.get(prefix_len..prefix_len + 1) == Some(" ") {
175 prefix_len += 1;
176 }
177 break;
178 }
179 }
180 }
181 (bq_level, prefix_len)
182 }
183
184 fn scan_continuation(
201 &self,
202 start_line: usize,
203 end_line: usize,
204 tight_threshold: usize,
205 loose_threshold: usize,
206 parent_bq_level: usize,
207 parent_bq_prefix_len: usize,
208 ) -> Option<Decision> {
209 if start_line == 0 || start_line > end_line || end_line > self.indentation.len() {
210 return None;
211 }
212
213 let adjust = |t: usize| {
216 if parent_bq_level > 0 {
217 t.saturating_sub(parent_bq_prefix_len)
218 } else {
219 t
220 }
221 };
222 let tight = adjust(tight_threshold);
223 let loose = adjust(loose_threshold);
224
225 let mut seen_blank = false;
226 for idx in start_line - 1..end_line {
227 if !self.has_content(idx) {
228 seen_blank = true;
229 continue;
230 }
231 if self.is_list_item(idx) {
232 continue;
233 }
234
235 let line_bq_level = self.blockquote_levels.get(idx).copied().unwrap_or(0);
237 let raw_indent = self.indentation[idx];
238 let effective_indent = if line_bq_level == parent_bq_level && parent_bq_level > 0 {
239 effective_indent_in_blockquote(&self.line_contents[idx], parent_bq_level, raw_indent)
240 } else {
241 raw_indent
242 };
243
244 let threshold = if seen_blank { loose } else { tight };
245 if effective_indent >= threshold {
246 return Some((idx + 1, Some(effective_indent)));
247 }
248 if seen_blank {
251 return Some((idx + 1, None));
252 }
253 }
254 None
255 }
256
257 fn continuation_decided(
261 &self,
262 parent_line: usize,
263 (tight_threshold, loose_threshold): (usize, usize),
264 (parent_bq_level, parent_bq_prefix_len): (usize, usize),
265 ) -> Option<Decision> {
266 let key = (
267 parent_line,
268 tight_threshold,
269 loose_threshold,
270 parent_bq_level,
271 parent_bq_prefix_len,
272 );
273 if let Some(&decision) = self.decided.borrow().get(&key) {
274 return decision;
275 }
276 let last_owned = self
277 .item_end
278 .get(&parent_line)
279 .map_or(self.indentation.len(), |&end| end - 1);
280 let decision = self.scan_continuation(
281 parent_line + 1,
282 last_owned,
283 tight_threshold,
284 loose_threshold,
285 parent_bq_level,
286 parent_bq_prefix_len,
287 );
288 self.decided.borrow_mut().insert(key, decision);
289 decision
290 }
291
292 fn continuation_before(
297 &self,
298 parent_line: usize,
299 before_line: usize,
300 thresholds: (usize, usize),
301 parent_bq: (usize, usize),
302 ) -> Option<usize> {
303 match self.continuation_decided(parent_line, thresholds, parent_bq) {
304 Some((decided_at, indent)) if decided_at < before_line => indent,
305 _ => None,
306 }
307 }
308
309 fn first_item_at_column(
312 &self,
313 ctx: &crate::lint_context::LintContext,
314 parent_line: usize,
315 from_line: usize,
316 marker_column: usize,
317 ) -> Option<usize> {
318 let key = (parent_line, from_line, marker_column);
319 if let Some(&line) = self.first_item_at.borrow().get(&key) {
320 return line;
321 }
322 let end = self
323 .item_end
324 .get(&parent_line)
325 .copied()
326 .unwrap_or(self.indentation.len() + 1);
327 let line = (from_line..end).find(|&line_num| {
328 ctx.list_item_on_line(line_num)
329 .is_some_and(|item| item.marker_column() == marker_column)
330 });
331 self.first_item_at.borrow_mut().insert(key, line);
332 line
333 }
334}
335
336impl MD005ListIndent {
337 const LIST_GROUP_GAP_TOLERANCE: usize = 2;
341
342 const MIN_CHILD_INDENT_INCREASE: usize = 2;
345
346 const SAME_LEVEL_TOLERANCE: i32 = 1;
349
350 const STANDARD_CONTINUATION_OFFSET: usize = 2;
353
354 fn create_indent_warning(
356 &self,
357 ctx: &crate::lint_context::LintContext,
358 line_num: usize,
359 line_info: &crate::lint_context::LineInfo,
360 actual_indent: usize,
361 expected_indent: usize,
362 ) -> LintWarning {
363 let message = format!(
364 "Expected indentation of {} {}, found {}",
365 expected_indent,
366 if expected_indent == 1 { "space" } else { "spaces" },
367 actual_indent
368 );
369
370 let (start_line, start_col, end_line, end_col) = if actual_indent > 0 {
371 calculate_match_range(line_num, line_info.content(ctx.content), 0, actual_indent)
372 } else {
373 calculate_match_range(line_num, line_info.content(ctx.content), 0, 1)
374 };
375
376 let (fix_range, replacement) = if line_info.blockquote.is_some() {
379 let start_byte = line_info.byte_offset;
381 let mut end_byte = line_info.byte_offset;
382
383 let marker_column = ctx
385 .list_item_on_line(line_num)
386 .map_or(actual_indent, ParsedListItem::marker_column);
387
388 for (i, ch) in line_info.content(ctx.content).chars().enumerate() {
390 if i >= marker_column {
391 break;
392 }
393 end_byte += ch.len_utf8();
394 }
395
396 let mut blockquote_count = 0;
398 for ch in line_info.content(ctx.content).chars() {
399 if ch == '>' {
400 blockquote_count += 1;
401 } else if ch != ' ' && ch != '\t' {
402 break;
403 }
404 }
405
406 let blockquote_prefix = if blockquote_count > 1 {
408 (0..blockquote_count)
409 .map(|_| "> ")
410 .collect::<String>()
411 .trim_end()
412 .to_string()
413 } else {
414 ">".to_string()
415 };
416
417 let correct_indent = " ".repeat(expected_indent);
419 let replacement = format!("{blockquote_prefix} {correct_indent}");
420
421 (start_byte..end_byte, replacement)
422 } else {
423 let fix_range = if actual_indent > 0 {
425 let start_byte = ctx.line_offsets.get(line_num - 1).copied().unwrap_or(0);
426 let end_byte = start_byte + actual_indent;
427 start_byte..end_byte
428 } else {
429 let byte_pos = ctx.line_offsets.get(line_num - 1).copied().unwrap_or(0);
430 byte_pos..byte_pos
431 };
432
433 let replacement = if expected_indent > 0 {
434 " ".repeat(expected_indent)
435 } else {
436 String::new()
437 };
438
439 (fix_range, replacement)
440 };
441
442 LintWarning {
443 rule_name: Some(self.name().to_string()),
444 line: start_line,
445 column: start_col,
446 end_line,
447 end_column: end_col,
448 message,
449 severity: Severity::Warning,
450 fix: Some(Fix::new(fix_range, replacement)),
451 }
452 }
453
454 fn check_indent_consistency(
457 &self,
458 ctx: &crate::lint_context::LintContext,
459 items: &[(usize, usize, &crate::lint_context::LineInfo)],
460 warnings: &mut Vec<(LintWarning, isize)>,
461 ) {
462 if items.len() < 2 {
463 return;
464 }
465
466 let mut sorted_items: Vec<_> = items.iter().collect();
468 sorted_items.sort_by_key(|(line_num, _, _)| *line_num);
469
470 let indents: std::collections::HashSet<usize> = sorted_items.iter().map(|(_, indent, _)| *indent).collect();
471
472 if indents.len() > 1 {
473 let expected_indent = sorted_items.first().map_or(0, |(_, i, _)| *i);
476
477 for (line_num, indent, line_info) in items {
478 if *indent != expected_indent {
479 warnings.push((
480 self.create_indent_warning(ctx, *line_num, line_info, *indent, expected_indent),
481 expected_indent as isize - *indent as isize,
482 ));
483 }
484 }
485 }
486 }
487
488 fn group_by_parent_content_column<'a>(
495 &self,
496 level: usize,
497 group: &[(usize, usize, &'a crate::lint_context::LineInfo)],
498 all_list_items: &[(usize, usize, &crate::lint_context::LineInfo, ParsedListItem<'_>)],
499 level_map: &HashMap<usize, usize>,
500 ) -> ParentContentGroups<'a> {
501 let parent_level = level - 1;
502
503 let is_ordered_map: HashMap<usize, bool> = all_list_items
505 .iter()
506 .map(|(ln, _, _, item)| (*ln, item.is_ordered()))
507 .collect();
508
509 let parent_items: Vec<(usize, usize)> = all_list_items
511 .iter()
512 .filter(|(ln, _, _, _)| level_map.get(ln) == Some(&parent_level))
513 .map(|(ln, _, _, item)| (*ln, item.content_column()))
514 .collect();
515
516 let mut parent_content_groups: ParentContentGroups<'a> = HashMap::new();
517
518 for (line_num, indent, line_info) in group {
519 let item_is_ordered = is_ordered_map.get(line_num).copied().unwrap_or(false);
520
521 let idx = parent_items.partition_point(|&(ln, _)| ln < *line_num);
523 let parent_content_col = if idx > 0 { Some(parent_items[idx - 1].1) } else { None };
524
525 if let Some(parent_col) = parent_content_col {
526 parent_content_groups
527 .entry((parent_col, item_is_ordered))
528 .or_default()
529 .push((*line_num, *indent, *line_info));
530 }
531 }
532
533 parent_content_groups
534 }
535
536 fn group_related_list_blocks<'a>(&self, list_blocks: ParsedListBlocks<'a>) -> Vec<Vec<ParsedListBlock<'a>>> {
538 let mut blocks = list_blocks.into_iter();
539 let Some(first_block) = blocks.next() else {
540 return Vec::new();
541 };
542
543 let mut groups = Vec::new();
544 let mut current_group = vec![first_block];
545 let mut prev_block = first_block;
546
547 for current_block in blocks {
548 let line_gap = current_block.start_line().saturating_sub(prev_block.end_line());
550
551 let same_quote = current_block.blockquote_prefix().matches('>').count()
556 == prev_block.blockquote_prefix().matches('>').count();
557 if same_quote && line_gap <= Self::LIST_GROUP_GAP_TOLERANCE {
558 current_group.push(current_block);
559 } else {
560 groups.push(current_group);
562 current_group = vec![current_block];
563 }
564 prev_block = current_block;
565 }
566 groups.push(current_group);
567
568 groups
569 }
570
571 fn is_continuation_content(
574 &self,
575 ctx: &crate::lint_context::LintContext,
576 cache: &LineCacheInfo,
577 list_line: usize,
578 list_indent: usize,
579 ) -> bool {
580 let parent_line = cache.parent_map.get(&list_line).copied();
582
583 if let Some(parent_line) = parent_line
584 && let Some(parent_list_item) = ctx.list_item_on_line(parent_line)
585 {
586 let line_info = parent_list_item.line_info();
587 let parent_marker_column = parent_list_item.marker_column();
588 let parent_content_column = parent_list_item.content_column();
589
590 let parent_bq_level = line_info.blockquote.as_ref().map_or(0, |bq| bq.nesting_level);
592 let parent_bq_prefix_len = line_info.blockquote.as_ref().map_or(0, |bq| bq.prefix.len());
593
594 let thresholds = (parent_marker_column + 1, parent_content_column);
598 let continuation_indent = cache.continuation_before(
599 parent_line,
600 list_line,
601 thresholds,
602 (parent_bq_level, parent_bq_prefix_len),
603 );
604
605 if let Some(continuation_indent) = continuation_indent {
606 let is_standard_continuation =
607 list_indent == parent_content_column + Self::STANDARD_CONTINUATION_OFFSET;
608 let matches_content_indent = list_indent == continuation_indent;
609
610 if matches_content_indent || is_standard_continuation {
611 return true;
612 }
613 }
614
615 if list_indent > parent_marker_column {
618 if self.has_continuation_list_at_indent(ctx, cache, parent_line, list_line, list_indent, thresholds) {
620 return true;
621 }
622
623 let parent_bq = cache.blockquote_info(parent_line);
625 if cache
626 .continuation_before(parent_line, list_line, thresholds, parent_bq)
627 .is_some()
628 {
629 return true;
630 }
631 }
632 }
633
634 false
635 }
636
637 fn has_continuation_list_at_indent(
643 &self,
644 ctx: &crate::lint_context::LintContext,
645 cache: &LineCacheInfo,
646 parent_line: usize,
647 current_line: usize,
648 list_indent: usize,
649 thresholds: (usize, usize),
650 ) -> bool {
651 let parent_bq = cache.blockquote_info(parent_line);
652 match cache.continuation_decided(parent_line, thresholds, parent_bq) {
653 Some((decided_at, Some(_))) => cache
654 .first_item_at_column(ctx, parent_line, decided_at + 1, list_indent)
655 .is_some_and(|line_num| line_num < current_line),
656 _ => false,
657 }
658 }
659
660 fn check_list_block_group(
662 &self,
663 ctx: &crate::lint_context::LintContext,
664 cache: &LineCacheInfo,
665 group: &[ParsedListBlock<'_>],
666 warnings: &mut Vec<(LintWarning, isize)>,
667 ) {
668 let mut candidate_items: Vec<(usize, usize, &crate::lint_context::LineInfo, ParsedListItem<'_>)> = Vec::new();
671
672 for list_block in group {
673 for list_item in list_block.items() {
674 let item_line = list_item.line_num();
675 let line_info = list_item.line_info();
676 let effective_indent = if let Some(blockquote) = &line_info.blockquote {
678 list_item.marker_column().saturating_sub(blockquote.nesting_level * 2)
680 } else {
681 list_item.marker_column()
683 };
684
685 candidate_items.push((item_line, effective_indent, line_info, list_item));
686 }
687 }
688
689 candidate_items.sort_by_key(|(line_num, _, _, _)| *line_num);
691
692 let mut skipped_lines: std::collections::HashSet<usize> = std::collections::HashSet::new();
695 let mut all_list_items: Vec<(usize, usize, &crate::lint_context::LineInfo, ParsedListItem<'_>)> = Vec::new();
696
697 for (item_line, effective_indent, line_info, list_item) in candidate_items {
698 if line_info.in_footnote_definition {
700 skipped_lines.insert(item_line);
701 continue;
702 }
703 if self.is_continuation_content(ctx, cache, item_line, effective_indent) {
705 skipped_lines.insert(item_line);
706 continue;
707 }
708
709 if let Some(&parent_line) = cache.parent_map.get(&item_line)
711 && skipped_lines.contains(&parent_line)
712 {
713 skipped_lines.insert(item_line);
714 continue;
715 }
716
717 all_list_items.push((item_line, effective_indent, line_info, list_item));
718 }
719
720 if all_list_items.is_empty() {
721 return;
722 }
723
724 all_list_items.sort_by_key(|(line_num, _, _, _)| *line_num);
726
727 let mut level_map: HashMap<usize, usize> = HashMap::new();
731 let mut level_indents: HashMap<usize, Vec<usize>> = HashMap::new(); let mut indent_to_level: HashMap<usize, (usize, usize)> = HashMap::new();
736
737 for (line_num, indent, _, _) in &all_list_items {
739 let level = if indent_to_level.is_empty() {
740 level_indents.entry(1).or_default().push(*indent);
742 1
743 } else {
744 let mut determined_level = 0;
746
747 if let Some(&(existing_level, _)) = indent_to_level.get(indent) {
749 determined_level = existing_level;
750 } else {
751 let mut best_parent: Option<(usize, usize, usize)> = None; for (&tracked_indent, &(tracked_level, tracked_line)) in &indent_to_level {
757 if tracked_indent < *indent {
758 if best_parent.is_none() || tracked_indent > best_parent.unwrap().0 {
761 best_parent = Some((tracked_indent, tracked_level, tracked_line));
762 }
763 }
764 }
765
766 if let Some((parent_indent, parent_level, _parent_line)) = best_parent {
767 if parent_indent + Self::MIN_CHILD_INDENT_INCREASE <= *indent {
769 determined_level = parent_level + 1;
771 } else if (*indent as i32 - parent_indent as i32).abs() <= Self::SAME_LEVEL_TOLERANCE {
772 determined_level = parent_level;
774 } else {
775 let mut found_similar = false;
779 if let Some(indents_at_level) = level_indents.get(&parent_level) {
780 for &level_indent in indents_at_level {
781 if (level_indent as i32 - *indent as i32).abs() <= Self::SAME_LEVEL_TOLERANCE {
782 determined_level = parent_level;
783 found_similar = true;
784 break;
785 }
786 }
787 }
788 if !found_similar {
789 determined_level = parent_level + 1;
791 }
792 }
793 }
794
795 if determined_level == 0 {
797 determined_level = 1;
798 }
799
800 level_indents.entry(determined_level).or_default().push(*indent);
802 }
803
804 determined_level
805 };
806
807 level_map.insert(*line_num, level);
808 indent_to_level.insert(*indent, (level, *line_num));
810 }
811
812 let mut level_groups: HashMap<usize, Vec<(usize, usize, &crate::lint_context::LineInfo)>> = HashMap::new();
814 for (line_num, indent, line_info, _) in &all_list_items {
815 let level = level_map[line_num];
816 level_groups
817 .entry(level)
818 .or_default()
819 .push((*line_num, *indent, *line_info));
820 }
821
822 for (level, mut group) in level_groups {
824 group.sort_by_key(|(line_num, _, _)| *line_num);
825
826 if level == 1 {
827 for (line_num, indent, line_info) in &group {
829 if *indent != self.top_level_indent {
830 warnings.push((
831 self.create_indent_warning(ctx, *line_num, line_info, *indent, self.top_level_indent),
832 self.top_level_indent as isize - *indent as isize,
833 ));
834 }
835 }
836 } else {
837 let parent_content_groups =
840 self.group_by_parent_content_column(level, &group, &all_list_items, &level_map);
841
842 for items in parent_content_groups.values() {
844 self.check_indent_consistency(ctx, items, warnings);
845 }
846 }
847 }
848 }
849
850 fn check_optimized(&self, ctx: &crate::lint_context::LintContext) -> Vec<LintWarning> {
852 let content = ctx.content;
853
854 if content.is_empty() {
856 return Vec::new();
857 }
858
859 let list_blocks = ctx.parsed_list_blocks();
861 if list_blocks.is_empty() {
862 return Vec::new();
863 }
864
865 let mut warnings = Vec::new();
866
867 let cache = LineCacheInfo::new(ctx);
869
870 let block_groups = self.group_related_list_blocks(list_blocks);
873
874 for group in block_groups {
875 self.check_list_block_group(ctx, &cache, &group, &mut warnings);
876 }
877
878 let moves = warnings
882 .iter()
883 .enumerate()
884 .filter(|(_, (_, delta))| *delta != 0)
885 .map(|(index, (warning, delta))| (warning.line - 1, (index, *delta)))
886 .collect();
887 let mut warnings: Vec<LintWarning> = warnings.into_iter().map(|(warning, _)| warning).collect();
888 move_owned_lines(ctx, &moves, Nesting::Absolute, &mut warnings);
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 let mut warnings = self.check_optimized(ctx);
904 drop_structure_changing_fixes(ctx, &mut warnings, Allowed::Nothing);
905 Ok(warnings)
906 }
907
908 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
909 let warnings = self.check(ctx)?;
910 let warnings =
911 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
912 if warnings.is_empty() {
913 return Ok(ctx.content.to_string());
914 }
915
916 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings).map_err(LintError::InvalidInput)
917 }
918
919 fn category(&self) -> RuleCategory {
920 RuleCategory::List
921 }
922
923 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
925 ctx.content.is_empty() || !ctx.has_list_items()
927 }
928
929 fn as_any(&self) -> &dyn std::any::Any {
930 self
931 }
932
933 fn default_config_section(&self) -> Option<(String, toml::Value)> {
934 None
935 }
936
937 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
938 where
939 Self: Sized,
940 {
941 let mut top_level_indent = 0;
943
944 if let Some(md007_config) = config.rules.get("MD007") {
946 if let Some(start_indented) = md007_config.values.get("start-indented")
948 && let Some(start_indented_bool) = start_indented.as_bool()
949 && start_indented_bool
950 {
951 if let Some(start_indent) = md007_config.values.get("start-indent") {
953 if let Some(indent_value) = start_indent.as_integer() {
954 top_level_indent = indent_value as usize;
955 }
956 } else {
957 top_level_indent = 2;
959 }
960 }
961 }
962
963 Box::new(MD005ListIndent { top_level_indent })
964 }
965}
966
967#[cfg(test)]
968mod tests {
969 use super::*;
970 use crate::lint_context::LintContext;
971
972 #[test]
973 fn test_valid_unordered_list() {
974 let rule = MD005ListIndent::default();
975 let content = "\
976* Item 1
977* Item 2
978 * Nested 1
979 * Nested 2
980* Item 3";
981 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
982 let result = rule.check(&ctx).unwrap();
983 assert!(result.is_empty());
984 }
985
986 #[test]
987 fn test_valid_ordered_list() {
988 let rule = MD005ListIndent::default();
989 let content = "\
9901. Item 1
9912. Item 2
992 1. Nested 1
993 2. Nested 2
9943. 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());
1000 }
1001
1002 #[test]
1003 fn test_invalid_unordered_indent() {
1004 let rule = MD005ListIndent::default();
1005 let content = "\
1006* Item 1
1007 * Item 2
1008 * Nested 1";
1009 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1010 let result = rule.check(&ctx).unwrap();
1011 assert_eq!(result.len(), 1);
1014 let fixed = rule.fix(&ctx).unwrap();
1015 assert_eq!(fixed, "* Item 1\n* Item 2\n * Nested 1");
1016 }
1017
1018 #[test]
1019 fn test_invalid_ordered_indent() {
1020 let rule = MD005ListIndent::default();
1021 let content = "\
10221. Item 1
1023 2. Item 2
1024 1. Nested 1";
1025 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1026 let result = rule.check(&ctx).unwrap();
1027 assert_eq!(result.len(), 1);
1028 let fixed = rule.fix(&ctx).unwrap();
1029 assert_eq!(fixed, "1. Item 1\n2. Item 2\n 1. Nested 1");
1033 }
1034
1035 #[test]
1036 fn test_mixed_list_types() {
1037 let rule = MD005ListIndent::default();
1038 let content = "\
1039* Item 1
1040 1. Nested ordered
1041 * Nested unordered
1042* Item 2";
1043 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1044 let result = rule.check(&ctx).unwrap();
1045 assert!(result.is_empty());
1046 }
1047
1048 #[test]
1049 fn test_multiple_levels() {
1050 let rule = MD005ListIndent::default();
1051 let content = "\
1052* Level 1
1053 * Level 2
1054 * Level 3";
1055 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1056 let result = rule.check(&ctx).unwrap();
1057 assert!(result.is_empty(), "MD005 should accept consistent indentation pattern");
1059 }
1060
1061 #[test]
1062 fn test_empty_lines() {
1063 let rule = MD005ListIndent::default();
1064 let content = "\
1065* Item 1
1066
1067 * Nested 1
1068
1069* Item 2";
1070 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1071 let result = rule.check(&ctx).unwrap();
1072 assert!(result.is_empty());
1073 }
1074
1075 #[test]
1076 fn test_no_lists() {
1077 let rule = MD005ListIndent::default();
1078 let content = "\
1079Just some text
1080More text
1081Even more text";
1082 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1083 let result = rule.check(&ctx).unwrap();
1084 assert!(result.is_empty());
1085 }
1086
1087 #[test]
1088 fn test_complex_nesting() {
1089 let rule = MD005ListIndent::default();
1090 let content = "\
1091* Level 1
1092 * Level 2
1093 * Level 3
1094 * Back to 2
1095 1. Ordered 3
1096 2. Still 3
1097* Back to 1";
1098 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1099 let result = rule.check(&ctx).unwrap();
1100 assert!(result.is_empty());
1101 }
1102
1103 #[test]
1104 fn test_invalid_complex_nesting() {
1105 let rule = MD005ListIndent::default();
1106 let content = "\
1107* Level 1
1108 * Level 2
1109 * Level 3
1110 * Back to 2
1111 1. Ordered 3
1112 2. Still 3
1113* Back to 1";
1114 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1115 let result = rule.check(&ctx).unwrap();
1116 assert_eq!(result.len(), 1);
1118 assert!(
1119 result[0].message.contains("Expected indentation of 5 spaces, found 6")
1120 || result[0].message.contains("Expected indentation of 6 spaces, found 5")
1121 );
1122 }
1123
1124 #[test]
1125 fn test_with_lint_context() {
1126 let rule = MD005ListIndent::default();
1127
1128 let content = "* Item 1\n* Item 2\n * Nested item\n * Another nested item";
1130 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1131 let result = rule.check(&ctx).unwrap();
1132 assert!(result.is_empty());
1133
1134 let content = "* Item 1\n* Item 2\n * Nested item\n * Another nested item";
1136 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1137 let result = rule.check(&ctx).unwrap();
1138 assert!(!result.is_empty()); let content = "* Item 1\n * Nested item\n * Another nested item with wrong indent";
1142 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1143 let result = rule.check(&ctx).unwrap();
1144 assert!(!result.is_empty()); }
1146
1147 #[test]
1149 fn test_list_with_continuations() {
1150 let rule = MD005ListIndent::default();
1151 let content = "\
1152* Item 1
1153 This is a continuation
1154 of the first item
1155 * Nested item
1156 with its own continuation
1157* Item 2";
1158 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1159 let result = rule.check(&ctx).unwrap();
1160 assert!(result.is_empty());
1161 }
1162
1163 #[test]
1164 fn test_list_after_a_quoted_list_is_judged_on_its_own() {
1165 let rule = MD005ListIndent::default();
1168 let content = "> * item 49\n + item 50\n + item 52\n";
1169 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1170 let lines: Vec<usize> = rule.check(&ctx).unwrap().iter().map(|w| w.line).collect();
1171 assert_eq!(lines, vec![1, 2]);
1172 let fixed = rule.fix(&ctx).unwrap();
1173 assert_eq!(fixed, "> * item 49\n+ item 50\n + item 52\n");
1174 let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1175 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
1176 }
1177
1178 #[test]
1179 fn test_list_in_blockquote() {
1180 let rule = MD005ListIndent::default();
1181 let content = "\
1182> * Item 1
1183> * Nested 1
1184> * Nested 2
1185> * Item 2";
1186 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1187 let result = rule.check(&ctx).unwrap();
1188
1189 assert!(
1191 result.is_empty(),
1192 "Expected no warnings for correctly indented blockquote list, got: {result:?}"
1193 );
1194 }
1195
1196 #[test]
1197 fn test_list_with_code_blocks() {
1198 let rule = MD005ListIndent::default();
1199 let content = "\
1200* Item 1
1201 ```
1202 code block
1203 ```
1204 * Nested item
1205* Item 2";
1206 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1207 let result = rule.check(&ctx).unwrap();
1208 assert!(result.is_empty());
1209 }
1210
1211 #[test]
1212 fn test_list_with_tabs() {
1213 let rule = MD005ListIndent::default();
1214 let content = "* Item 1\n * Wrong indent (3 spaces)\n * Correct indent (2 spaces)";
1218 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1219 let result = rule.check(&ctx).unwrap();
1220 assert!(!result.is_empty());
1222 }
1223
1224 #[test]
1225 fn test_inconsistent_at_same_level() {
1226 let rule = MD005ListIndent::default();
1227 let content = "\
1228* Item 1
1229 * Nested 1
1230 * Nested 2
1231 * Wrong indent for same level
1232 * Nested 3";
1233 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1234 let result = rule.check(&ctx).unwrap();
1235 assert!(!result.is_empty());
1236 assert!(result.iter().any(|w| w.line == 4));
1238 }
1239
1240 #[test]
1241 fn test_zero_indent_top_level() {
1242 let rule = MD005ListIndent::default();
1243 let content = concat!(" * Wrong indent\n", "* Correct\n", " * Nested");
1245 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1246 let result = rule.check(&ctx).unwrap();
1247
1248 assert!(!result.is_empty());
1250 assert!(result.iter().any(|w| w.line == 1));
1251 }
1252
1253 #[test]
1254 fn test_fix_preserves_content() {
1255 let rule = MD005ListIndent::default();
1256 let content = "\
1257* Item with **bold** and *italic*
1258 * Wrong indent with `code`
1259 * Also wrong with [link](url)";
1260 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1261 let fixed = rule.fix(&ctx).unwrap();
1262 assert!(fixed.contains("**bold**"));
1263 assert!(fixed.contains("*italic*"));
1264 assert!(fixed.contains("`code`"));
1265 assert!(fixed.contains("[link](url)"));
1266 }
1267
1268 #[test]
1269 fn test_deeply_nested_lists() {
1270 let rule = MD005ListIndent::default();
1271 let content = "\
1272* L1
1273 * L2
1274 * L3
1275 * L4
1276 * L5
1277 * L6";
1278 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1279 let result = rule.check(&ctx).unwrap();
1280 assert!(result.is_empty());
1281 }
1282
1283 #[test]
1284 fn test_fix_multiple_issues() {
1285 let rule = MD005ListIndent::default();
1286 let content = "\
1287* Item 1
1288 * Wrong 1
1289 * Wrong 2
1290 * Wrong 3
1291 * Correct";
1292 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1293 let fixed = rule.fix(&ctx).unwrap();
1294 assert_eq!(fixed, "* Item 1\n* Wrong 1\n * Wrong 2\n * Wrong 3\n* Correct");
1295
1296 let content = format!("{content}\n * Wrong 4");
1300 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1301 assert!(!rule.check(&ctx).unwrap().is_empty());
1302 assert_eq!(rule.fix(&ctx).unwrap(), content);
1303 }
1304
1305 #[test]
1306 fn test_performance_large_document() {
1307 let rule = MD005ListIndent::default();
1308 let mut content = String::new();
1309 for i in 0..100 {
1310 content.push_str(&format!("* Item {i}\n"));
1311 content.push_str(&format!(" * Nested {i}\n"));
1312 }
1313 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1314 let result = rule.check(&ctx).unwrap();
1315 assert!(result.is_empty());
1316 }
1317
1318 #[test]
1319 fn test_column_positions() {
1320 let rule = MD005ListIndent::default();
1321 let content = " * Wrong indent";
1322 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1323 let result = rule.check(&ctx).unwrap();
1324 assert_eq!(result.len(), 1);
1325 assert_eq!(result[0].column, 1, "Expected column 1, got {}", result[0].column);
1326 assert_eq!(
1327 result[0].end_column, 2,
1328 "Expected end_column 2, got {}",
1329 result[0].end_column
1330 );
1331 }
1332
1333 #[test]
1334 fn test_should_skip() {
1335 let rule = MD005ListIndent::default();
1336
1337 let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard, None);
1339 assert!(rule.should_skip(&ctx));
1340
1341 let ctx = LintContext::new("Just plain text", crate::config::MarkdownFlavor::Standard, None);
1343 assert!(rule.should_skip(&ctx));
1344
1345 let ctx = LintContext::new("* List item", crate::config::MarkdownFlavor::Standard, None);
1347 assert!(!rule.should_skip(&ctx));
1348
1349 let ctx = LintContext::new("1. Ordered list", crate::config::MarkdownFlavor::Standard, None);
1350 assert!(!rule.should_skip(&ctx));
1351 }
1352
1353 #[test]
1354 fn test_should_skip_validation() {
1355 let rule = MD005ListIndent::default();
1356 let content = "* List item";
1357 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1358 assert!(!rule.should_skip(&ctx));
1359
1360 let content = "No lists here";
1361 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1362 assert!(rule.should_skip(&ctx));
1363 }
1364
1365 #[test]
1366 fn test_edge_case_single_space_indent() {
1367 let rule = MD005ListIndent::default();
1368 let content = "\
1369* Item 1
1370 * Single space - wrong
1371 * Two spaces - correct";
1372 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1373 let result = rule.check(&ctx).unwrap();
1374 assert_eq!(result.len(), 2);
1377 assert!(result.iter().any(|w| w.line == 2 && w.message.contains("found 1")));
1378 }
1379
1380 #[test]
1381 fn test_edge_case_three_space_indent() {
1382 let rule = MD005ListIndent::default();
1383 let content = "\
1384* Item 1
1385 * Three spaces - first establishes pattern
1386 * Two spaces - inconsistent with established pattern";
1387 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1388 let result = rule.check(&ctx).unwrap();
1389 assert_eq!(result.len(), 1);
1393 assert!(result.iter().any(|w| w.line == 3 && w.message.contains("found 2")));
1394 }
1395
1396 #[test]
1397 fn test_nested_bullets_under_numbered_items() {
1398 let rule = MD005ListIndent::default();
1399 let content = "\
14001. **Active Directory/LDAP**
1401 - User authentication and directory services
1402 - LDAP for user information and validation
1403
14042. **Oracle Unified Directory (OUD)**
1405 - Extended user directory services
1406 - Verification of project account presence and changes";
1407 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1408 let result = rule.check(&ctx).unwrap();
1409 assert!(
1411 result.is_empty(),
1412 "Expected no warnings for bullets with 3 spaces under numbered items, got: {result:?}"
1413 );
1414 }
1415
1416 #[test]
1417 fn test_nested_bullets_under_numbered_items_wrong_indent() {
1418 let rule = MD005ListIndent::default();
1419 let content = "\
14201. **Active Directory/LDAP**
1421 - Wrong: only 2 spaces
1422 - Correct: 3 spaces";
1423 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1424 let result = rule.check(&ctx).unwrap();
1425 assert_eq!(
1427 result.len(),
1428 1,
1429 "Expected 1 warning, got {}. Warnings: {:?}",
1430 result.len(),
1431 result
1432 );
1433 assert!(
1435 result
1436 .iter()
1437 .any(|w| (w.line == 2 && w.message.contains("found 2"))
1438 || (w.line == 3 && w.message.contains("found 3")))
1439 );
1440 }
1441
1442 #[test]
1443 fn test_regular_nested_bullets_still_work() {
1444 let rule = MD005ListIndent::default();
1445 let content = "\
1446* Top level
1447 * Second level (2 spaces is correct for bullets under bullets)
1448 * Third level (4 spaces)";
1449 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1450 let result = rule.check(&ctx).unwrap();
1451 assert!(
1453 result.is_empty(),
1454 "Expected no warnings for regular bullet nesting, got: {result:?}"
1455 );
1456 }
1457
1458 #[test]
1459 fn test_fix_range_accuracy() {
1460 let rule = MD005ListIndent::default();
1461 let content = " * Wrong indent";
1462 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1463 let result = rule.check(&ctx).unwrap();
1464 assert_eq!(result.len(), 1);
1465
1466 let fix = result[0].fix.as_ref().unwrap();
1467 assert_eq!(fix.replacement, "");
1469 }
1470
1471 #[test]
1472 fn test_four_space_indent_pattern() {
1473 let rule = MD005ListIndent::default();
1474 let content = "\
1475* Item 1
1476 * Item 2 with 4 spaces
1477 * Item 3 with 8 spaces
1478 * Item 4 with 4 spaces";
1479 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1480 let result = rule.check(&ctx).unwrap();
1481 assert!(
1483 result.is_empty(),
1484 "MD005 should accept consistent 4-space indentation pattern, got {} warnings",
1485 result.len()
1486 );
1487 }
1488
1489 #[test]
1490 fn test_issue_64_scenario() {
1491 let rule = MD005ListIndent::default();
1493 let content = "\
1494* Top level item
1495 * Sub item with 4 spaces (as configured in MD007)
1496 * Nested sub item with 8 spaces
1497 * Another sub item with 4 spaces
1498* Another top level";
1499
1500 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1501 let result = rule.check(&ctx).unwrap();
1502
1503 assert!(
1505 result.is_empty(),
1506 "MD005 should accept 4-space indentation when that's the pattern being used. Got {} warnings",
1507 result.len()
1508 );
1509 }
1510
1511 #[test]
1512 fn test_continuation_content_scenario() {
1513 let rule = MD005ListIndent::default();
1514 let content = "\
1515- **Changes to how the Python version is inferred** ([#16319](example))
1516
1517 In previous versions of Ruff, you could specify your Python version with:
1518
1519 - The `target-version` option in a `ruff.toml` file
1520 - The `project.requires-python` field in a `pyproject.toml` file";
1521
1522 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1523
1524 let result = rule.check(&ctx).unwrap();
1525
1526 assert!(
1528 result.is_empty(),
1529 "MD005 should not flag continuation content lists, got {} warnings: {:?}",
1530 result.len(),
1531 result
1532 );
1533 }
1534
1535 #[test]
1536 fn test_multiple_continuation_lists_scenario() {
1537 let rule = MD005ListIndent::default();
1538 let content = "\
1539- **Changes to how the Python version is inferred** ([#16319](example))
1540
1541 In previous versions of Ruff, you could specify your Python version with:
1542
1543 - The `target-version` option in a `ruff.toml` file
1544 - The `project.requires-python` field in a `pyproject.toml` file
1545
1546 In v0.10, config discovery has been updated to address this issue:
1547
1548 - If Ruff finds a `ruff.toml` file without a `target-version`, it will check
1549 - If Ruff finds a user-level configuration, the `requires-python` field will take precedence
1550 - If there is no config file, Ruff will search for the closest `pyproject.toml`";
1551
1552 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1553
1554 let result = rule.check(&ctx).unwrap();
1555
1556 assert!(
1558 result.is_empty(),
1559 "MD005 should not flag continuation content lists, got {} warnings: {:?}",
1560 result.len(),
1561 result
1562 );
1563 }
1564
1565 #[test]
1566 fn test_issue_115_sublist_after_code_block() {
1567 let rule = MD005ListIndent::default();
1568 let content = "\
15691. List item 1
1570
1571 ```rust
1572 fn foo() {}
1573 ```
1574
1575 Sublist:
1576
1577 - A
1578 - B
1579";
1580 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1581 let result = rule.check(&ctx).unwrap();
1582 assert!(
1586 result.is_empty(),
1587 "Expected no warnings for sub-list after code block in list item, got {} warnings: {:?}",
1588 result.len(),
1589 result
1590 );
1591 }
1592
1593 #[test]
1594 fn test_edge_case_continuation_at_exact_boundary() {
1595 let rule = MD005ListIndent::default();
1596 let content = "\
1598* Item (content at column 2)
1599 Text at column 2 (exact boundary - continuation)
1600 * Sub at column 2";
1601 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1602 let result = rule.check(&ctx).unwrap();
1603 assert!(
1605 result.is_empty(),
1606 "Expected no warnings when text and sub-list are at exact parent content_column, got: {result:?}"
1607 );
1608 }
1609
1610 #[test]
1611 fn test_edge_case_unicode_in_continuation() {
1612 let rule = MD005ListIndent::default();
1613 let content = "\
1614* Parent
1615 Text with emoji 😀 and Unicode ñ characters
1616 * Sub-list should still work";
1617 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1618 let result = rule.check(&ctx).unwrap();
1619 assert!(
1621 result.is_empty(),
1622 "Expected no warnings with Unicode in continuation content, got: {result:?}"
1623 );
1624 }
1625
1626 #[test]
1627 fn test_edge_case_large_empty_line_gap() {
1628 let rule = MD005ListIndent::default();
1629 let content = "\
1630* Parent at line 1
1631 Continuation text
1632
1633
1634
1635 More continuation after many empty lines
1636
1637 * Child after gap
1638 * Another child";
1639 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1640 let result = rule.check(&ctx).unwrap();
1641 assert!(
1643 result.is_empty(),
1644 "Expected no warnings with large gaps in continuation content, got: {result:?}"
1645 );
1646 }
1647
1648 #[test]
1649 fn test_edge_case_multiple_continuation_blocks_varying_indent() {
1650 let rule = MD005ListIndent::default();
1651 let content = "\
1652* Parent (content at column 2)
1653 First paragraph at column 2
1654 Indented quote at column 4
1655 Back to column 2
1656 * Sub-list at column 2";
1657 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1658 let result = rule.check(&ctx).unwrap();
1659 assert!(
1661 result.is_empty(),
1662 "Expected no warnings with varying continuation indent, got: {result:?}"
1663 );
1664 }
1665
1666 #[test]
1667 fn test_edge_case_deep_nesting_no_continuation() {
1668 let rule = MD005ListIndent::default();
1669 let content = "\
1670* Parent
1671 * Immediate child (no continuation text before)
1672 * Grandchild
1673 * Great-grandchild
1674 * Great-great-grandchild
1675 * Another child at level 2";
1676 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1677 let result = rule.check(&ctx).unwrap();
1678 assert!(
1680 result.is_empty(),
1681 "Expected no warnings for deep nesting without continuation, got: {result:?}"
1682 );
1683 }
1684
1685 #[test]
1686 fn test_edge_case_blockquote_continuation_content() {
1687 let rule = MD005ListIndent::default();
1688 let content = "\
1689> * Parent in blockquote
1690> Continuation in blockquote
1691> * Sub-list in blockquote
1692> * Another sub-list";
1693 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1694 let result = rule.check(&ctx).unwrap();
1695 assert!(
1697 result.is_empty(),
1698 "Expected no warnings for blockquote continuation, got: {result:?}"
1699 );
1700 }
1701
1702 #[test]
1703 fn test_edge_case_one_space_less_than_content_column() {
1704 let rule = MD005ListIndent::default();
1705 let content = "\
1706* Parent (content at column 2)
1707 Text at column 1 (one less than content_column - NOT continuation)
1708 * Child";
1709 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1710 let result = rule.check(&ctx).unwrap();
1711 assert!(
1717 result.is_empty() || !result.is_empty(),
1718 "Test should complete without panic"
1719 );
1720 }
1721
1722 #[test]
1723 fn test_edge_case_multiple_code_blocks_different_indentation() {
1724 let rule = MD005ListIndent::default();
1725 let content = "\
1726* Parent
1727 ```
1728 code at 2 spaces
1729 ```
1730 ```
1731 code at 4 spaces
1732 ```
1733 * Sub-list should not be confused";
1734 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1735 let result = rule.check(&ctx).unwrap();
1736 assert!(
1738 result.is_empty(),
1739 "Expected no warnings with multiple code blocks, got: {result:?}"
1740 );
1741 }
1742
1743 #[test]
1744 fn test_performance_very_large_document() {
1745 let rule = MD005ListIndent::default();
1746 let mut content = String::new();
1747
1748 for i in 0..1000 {
1750 content.push_str(&format!("* Item {i}\n"));
1751 content.push_str(&format!(" * Nested {i}\n"));
1752 if i % 10 == 0 {
1753 content.push_str(" Some continuation text\n");
1754 }
1755 }
1756
1757 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1758
1759 let start = std::time::Instant::now();
1761 let result = rule.check(&ctx).unwrap();
1762 let elapsed = start.elapsed();
1763
1764 assert!(result.is_empty());
1765 println!("Processed 1000 list items in {elapsed:?}");
1766 assert!(
1769 elapsed.as_secs() < 1,
1770 "Should complete in under 1 second, took {elapsed:?}"
1771 );
1772 }
1773
1774 #[test]
1775 fn test_ordered_list_variable_marker_width() {
1776 let rule = MD005ListIndent::default();
1781 let content = "\
17821. One
1783 - One
1784 - Two
17852. Two
1786 - One
17873. Three
1788 - One
17894. Four
1790 - One
17915. Five
1792 - One
17936. Six
1794 - One
17957. Seven
1796 - One
17978. Eight
1798 - One
17999. Nine
1800 - One
180110. Ten
1802 - One";
1803 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1804 let result = rule.check(&ctx).unwrap();
1805 assert!(
1806 result.is_empty(),
1807 "Expected no warnings for ordered list with variable marker widths, got: {result:?}"
1808 );
1809 }
1810
1811 #[test]
1812 fn test_ordered_list_inconsistent_siblings() {
1813 let rule = MD005ListIndent::default();
1815 let content = "\
18161. Item one
1817 - First sublist at 3 spaces
1818 - Second sublist at 2 spaces (inconsistent)
1819 - Third sublist at 3 spaces";
1820 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1821 let result = rule.check(&ctx).unwrap();
1822 assert_eq!(
1824 result.len(),
1825 1,
1826 "Expected 1 warning for inconsistent sibling indent, got: {result:?}"
1827 );
1828 assert!(result[0].message.contains("Expected indentation of 3"));
1829 }
1830
1831 #[test]
1832 fn test_ordered_list_single_sublist_no_warning() {
1833 let rule = MD005ListIndent::default();
1836 let content = "\
183710. Item ten
1838 - Only sublist at 3 spaces";
1839 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1840 let result = rule.check(&ctx).unwrap();
1841 assert!(
1843 result.is_empty(),
1844 "Expected no warnings for single sublist item, got: {result:?}"
1845 );
1846 }
1847
1848 #[test]
1849 fn test_sublists_grouped_by_parent_content_column() {
1850 let rule = MD005ListIndent::default();
1854 let content = "\
18559. Item nine
1856 - First sublist at 3 spaces
1857 - Second sublist at 3 spaces
1858 - Third sublist at 3 spaces
185910. Item ten
1860 - First sublist at 4 spaces
1861 - Second sublist at 4 spaces
1862 - Third sublist at 4 spaces";
1863 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1864 let result = rule.check(&ctx).unwrap();
1865 assert!(
1868 result.is_empty(),
1869 "Expected no warnings for sublists grouped by parent, got: {result:?}"
1870 );
1871 }
1872
1873 #[test]
1874 fn test_inconsistent_indent_within_parent_group() {
1875 let rule = MD005ListIndent::default();
1877 let content = "\
187810. Item ten
1879 - First sublist at 4 spaces
1880 - Second sublist at 3 spaces (inconsistent!)
1881 - Third sublist at 4 spaces";
1882 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1883 let result = rule.check(&ctx).unwrap();
1884 assert_eq!(
1886 result.len(),
1887 1,
1888 "Expected 1 warning for inconsistent indent within parent group, got: {result:?}"
1889 );
1890 assert!(result[0].line == 3);
1891 assert!(result[0].message.contains("Expected indentation of 4"));
1892 }
1893
1894 #[test]
1895 fn test_blockquote_nested_list_fix_preserves_blockquote_prefix() {
1896 use crate::rule::Rule;
1900
1901 let rule = MD005ListIndent::default();
1902 let content = "> * Federation sender blacklists are now persisted.";
1903 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1904 let result = rule.check(&ctx).unwrap();
1905
1906 assert_eq!(result.len(), 1, "Expected 1 warning for extra indent");
1907
1908 assert!(result[0].fix.is_some(), "Should have a fix");
1910 let fixed = rule.fix(&ctx).expect("Fix should succeed");
1911
1912 assert!(
1914 fixed.starts_with("> "),
1915 "Fixed content should start with blockquote prefix '> ', got: {fixed:?}"
1916 );
1917 assert!(
1918 !fixed.starts_with("* "),
1919 "Fixed content should NOT start with just '* ' (blockquote removed), got: {fixed:?}"
1920 );
1921 assert_eq!(
1922 fixed.trim(),
1923 "> * Federation sender blacklists are now persisted.",
1924 "Fixed content should be '> * Federation sender...' with single space after >"
1925 );
1926 }
1927
1928 #[test]
1929 fn test_nested_blockquote_list_fix_preserves_prefix() {
1930 use crate::rule::Rule;
1932
1933 let rule = MD005ListIndent::default();
1934 let content = ">> * Nested blockquote list item";
1935 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1936 let result = rule.check(&ctx).unwrap();
1937
1938 assert_eq!(result.len(), 1, "Fixture must trigger an indentation warning");
1939 let fixed = rule.fix(&ctx).expect("Fix should succeed");
1940 assert_eq!(fixed, "> > * Nested blockquote list item");
1941 let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1942 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
1943 }
1944}