1use crate::utils::blockquote::effective_indent_in_blockquote;
7use crate::utils::list_fix_guard::{
8 Allowed, drop_structure_changing_fixes, is_inside_literal_code, protect_literal_code,
9};
10use crate::utils::list_indent_shift::{Nesting, move_owned_lines};
11use crate::utils::range_utils::calculate_match_range;
12
13use crate::lint_context::{ParsedListBlock, ParsedListBlocks, ParsedListItem};
14use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
15use std::cell::RefCell;
17use std::collections::HashMap;
18use toml;
19
20type ParentContentGroups<'a> = HashMap<(usize, bool), Vec<(usize, usize, &'a crate::lint_context::LineInfo)>>;
23
24#[derive(Clone, Default)]
26pub struct MD005ListIndent {
27 top_level_indent: usize,
29}
30
31struct LineCacheInfo {
33 indentation: Vec<usize>,
35 blockquote_levels: Vec<usize>,
37 line_contents: Vec<String>,
39 flags: Vec<u8>,
41 parent_map: HashMap<usize, usize>,
44 item_end: HashMap<usize, usize>,
49 decided: RefCell<HashMap<ScanKey, Option<Decision>>>,
52 first_item_at: RefCell<HashMap<(usize, usize, usize), Option<usize>>>,
55}
56
57type ScanKey = (usize, usize, usize, usize, usize);
59
60type Decision = (usize, Option<usize>);
64
65const FLAG_HAS_CONTENT: u8 = 1;
66const FLAG_IS_LIST_ITEM: u8 = 2;
67
68impl LineCacheInfo {
69 fn new(ctx: &crate::lint_context::LintContext) -> Self {
71 let total_lines = ctx.lines.len();
72 let mut indentation = Vec::with_capacity(total_lines);
73 let mut blockquote_levels = Vec::with_capacity(total_lines);
74 let mut line_contents = Vec::with_capacity(total_lines);
75 let mut flags = Vec::with_capacity(total_lines);
76 let mut parent_map = HashMap::new();
77 let mut item_end = HashMap::new();
78
79 let mut indent_stack: Vec<(usize, usize)> = Vec::new();
91
92 for (idx, line_info) in ctx.lines.iter().enumerate() {
93 let line_content = line_info.content(ctx.content);
94 let content = line_content.trim_start();
95 let line_indent = line_info.byte_len - content.len();
96
97 indentation.push(line_indent);
98
99 let bq_level = line_info.blockquote.as_ref().map_or(0, |bq| bq.nesting_level);
101 blockquote_levels.push(bq_level);
102
103 line_contents.push(line_content.to_string());
105
106 let mut flag = 0u8;
107 if !content.is_empty() {
108 flag |= FLAG_HAS_CONTENT;
109 }
110 if let Some(list_item) = ctx
111 .list_item_on_line(idx + 1)
112 .filter(|item| !is_inside_literal_code(ctx, item.marker_byte_offset()))
113 {
114 flag |= FLAG_IS_LIST_ITEM;
115
116 let line_num = idx + 1; let marker_column = list_item.marker_column();
118
119 while let Some(&(indent, popped_line)) = indent_stack.last() {
121 if indent < marker_column {
122 break;
123 }
124 item_end.insert(popped_line, line_num);
125 indent_stack.pop();
126 }
127
128 if let Some((_, parent_line)) = indent_stack.last() {
129 parent_map.insert(line_num, *parent_line);
130 }
131
132 indent_stack.push((marker_column, line_num));
133 }
134 flags.push(flag);
135 }
136
137 Self {
138 indentation,
139 blockquote_levels,
140 line_contents,
141 flags,
142 parent_map,
143 item_end,
144 decided: RefCell::default(),
145 first_item_at: RefCell::default(),
146 }
147 }
148
149 fn has_content(&self, idx: usize) -> bool {
151 self.flags.get(idx).is_some_and(|&f| f & FLAG_HAS_CONTENT != 0)
152 }
153
154 fn is_list_item(&self, idx: usize) -> bool {
156 self.flags.get(idx).is_some_and(|&f| f & FLAG_IS_LIST_ITEM != 0)
157 }
158
159 fn blockquote_info(&self, line: usize) -> (usize, usize) {
161 if line == 0 || line > self.line_contents.len() {
162 return (0, 0);
163 }
164 let idx = line - 1;
165 let bq_level = self.blockquote_levels.get(idx).copied().unwrap_or(0);
166 if bq_level == 0 {
167 return (0, 0);
168 }
169 let content = &self.line_contents[idx];
171 let mut prefix_len = 0;
172 let mut found = 0;
173 for c in content.chars() {
174 prefix_len += c.len_utf8();
175 if c == '>' {
176 found += 1;
177 if found == bq_level {
178 if content.get(prefix_len..prefix_len + 1) == Some(" ") {
180 prefix_len += 1;
181 }
182 break;
183 }
184 }
185 }
186 (bq_level, prefix_len)
187 }
188
189 fn scan_continuation(
206 &self,
207 start_line: usize,
208 end_line: usize,
209 tight_threshold: usize,
210 loose_threshold: usize,
211 parent_bq_level: usize,
212 parent_bq_prefix_len: usize,
213 ) -> Option<Decision> {
214 if start_line == 0 || start_line > end_line || end_line > self.indentation.len() {
215 return None;
216 }
217
218 let adjust = |t: usize| {
221 if parent_bq_level > 0 {
222 t.saturating_sub(parent_bq_prefix_len)
223 } else {
224 t
225 }
226 };
227 let tight = adjust(tight_threshold);
228 let loose = adjust(loose_threshold);
229
230 let mut seen_blank = false;
231 for idx in start_line - 1..end_line {
232 if !self.has_content(idx) {
233 seen_blank = true;
234 continue;
235 }
236 if self.is_list_item(idx) {
237 continue;
238 }
239
240 let line_bq_level = self.blockquote_levels.get(idx).copied().unwrap_or(0);
242 let raw_indent = self.indentation[idx];
243 let effective_indent = if line_bq_level == parent_bq_level && parent_bq_level > 0 {
244 effective_indent_in_blockquote(&self.line_contents[idx], parent_bq_level, raw_indent)
245 } else {
246 raw_indent
247 };
248
249 let threshold = if seen_blank { loose } else { tight };
250 if effective_indent >= threshold {
251 return Some((idx + 1, Some(effective_indent)));
252 }
253 if seen_blank {
256 return Some((idx + 1, None));
257 }
258 }
259 None
260 }
261
262 fn continuation_decided(
266 &self,
267 parent_line: usize,
268 (tight_threshold, loose_threshold): (usize, usize),
269 (parent_bq_level, parent_bq_prefix_len): (usize, usize),
270 ) -> Option<Decision> {
271 let key = (
272 parent_line,
273 tight_threshold,
274 loose_threshold,
275 parent_bq_level,
276 parent_bq_prefix_len,
277 );
278 if let Some(&decision) = self.decided.borrow().get(&key) {
279 return decision;
280 }
281 let last_owned = self
282 .item_end
283 .get(&parent_line)
284 .map_or(self.indentation.len(), |&end| end - 1);
285 let decision = self.scan_continuation(
286 parent_line + 1,
287 last_owned,
288 tight_threshold,
289 loose_threshold,
290 parent_bq_level,
291 parent_bq_prefix_len,
292 );
293 self.decided.borrow_mut().insert(key, decision);
294 decision
295 }
296
297 fn continuation_before(
302 &self,
303 parent_line: usize,
304 before_line: usize,
305 thresholds: (usize, usize),
306 parent_bq: (usize, usize),
307 ) -> Option<usize> {
308 match self.continuation_decided(parent_line, thresholds, parent_bq) {
309 Some((decided_at, indent)) if decided_at < before_line => indent,
310 _ => None,
311 }
312 }
313
314 fn first_item_at_column(
317 &self,
318 ctx: &crate::lint_context::LintContext,
319 parent_line: usize,
320 from_line: usize,
321 marker_column: usize,
322 ) -> Option<usize> {
323 let key = (parent_line, from_line, marker_column);
324 if let Some(&line) = self.first_item_at.borrow().get(&key) {
325 return line;
326 }
327 let end = self
328 .item_end
329 .get(&parent_line)
330 .copied()
331 .unwrap_or(self.indentation.len() + 1);
332 let line = (from_line..end).find(|&line_num| {
333 self.is_list_item(line_num - 1)
334 && ctx
335 .list_item_on_line(line_num)
336 .is_some_and(|item| item.marker_column() == marker_column)
337 });
338 self.first_item_at.borrow_mut().insert(key, line);
339 line
340 }
341}
342
343impl MD005ListIndent {
344 const LIST_GROUP_GAP_TOLERANCE: usize = 2;
348
349 const MIN_CHILD_INDENT_INCREASE: usize = 2;
352
353 const SAME_LEVEL_TOLERANCE: i32 = 1;
356
357 const STANDARD_CONTINUATION_OFFSET: usize = 2;
360
361 fn create_indent_warning(
363 &self,
364 ctx: &crate::lint_context::LintContext,
365 line_num: usize,
366 line_info: &crate::lint_context::LineInfo,
367 actual_indent: usize,
368 expected_indent: usize,
369 ) -> LintWarning {
370 let message = format!(
371 "Expected indentation of {} {}, found {}",
372 expected_indent,
373 if expected_indent == 1 { "space" } else { "spaces" },
374 actual_indent
375 );
376
377 let (start_line, start_col, end_line, end_col) = if actual_indent > 0 {
378 calculate_match_range(line_num, line_info.content(ctx.content), 0, actual_indent)
379 } else {
380 calculate_match_range(line_num, line_info.content(ctx.content), 0, 1)
381 };
382
383 let (fix_range, replacement) = if line_info.blockquote.is_some() {
386 let start_byte = line_info.byte_offset;
388 let mut end_byte = line_info.byte_offset;
389
390 let marker_column = ctx
392 .list_item_on_line(line_num)
393 .map_or(actual_indent, ParsedListItem::marker_column);
394
395 for (i, ch) in line_info.content(ctx.content).chars().enumerate() {
397 if i >= marker_column {
398 break;
399 }
400 end_byte += ch.len_utf8();
401 }
402
403 let mut blockquote_count = 0;
405 for ch in line_info.content(ctx.content).chars() {
406 if ch == '>' {
407 blockquote_count += 1;
408 } else if ch != ' ' && ch != '\t' {
409 break;
410 }
411 }
412
413 let blockquote_prefix = if blockquote_count > 1 {
415 (0..blockquote_count)
416 .map(|_| "> ")
417 .collect::<String>()
418 .trim_end()
419 .to_string()
420 } else {
421 ">".to_string()
422 };
423
424 let correct_indent = " ".repeat(expected_indent);
426 let replacement = format!("{blockquote_prefix} {correct_indent}");
427
428 (start_byte..end_byte, replacement)
429 } else {
430 let fix_range = if actual_indent > 0 {
432 let start_byte = ctx.line_offsets.get(line_num - 1).copied().unwrap_or(0);
433 let end_byte = start_byte + actual_indent;
434 start_byte..end_byte
435 } else {
436 let byte_pos = ctx.line_offsets.get(line_num - 1).copied().unwrap_or(0);
437 byte_pos..byte_pos
438 };
439
440 let replacement = if expected_indent > 0 {
441 " ".repeat(expected_indent)
442 } else {
443 String::new()
444 };
445
446 (fix_range, replacement)
447 };
448
449 LintWarning {
450 rule_name: Some(self.name().to_string()),
451 line: start_line,
452 column: start_col,
453 end_line,
454 end_column: end_col,
455 message,
456 severity: Severity::Warning,
457 fix: Some(Fix::new(fix_range, replacement)),
458 }
459 }
460
461 fn check_indent_consistency(
464 &self,
465 ctx: &crate::lint_context::LintContext,
466 items: &[(usize, usize, &crate::lint_context::LineInfo)],
467 warnings: &mut Vec<(LintWarning, isize)>,
468 ) {
469 if items.len() < 2 {
470 return;
471 }
472
473 let mut sorted_items: Vec<_> = items.iter().collect();
475 sorted_items.sort_by_key(|(line_num, _, _)| *line_num);
476
477 let indents: std::collections::HashSet<usize> = sorted_items.iter().map(|(_, indent, _)| *indent).collect();
478
479 if indents.len() > 1 {
480 let expected_indent = sorted_items.first().map_or(0, |(_, i, _)| *i);
483
484 for (line_num, indent, line_info) in items {
485 if *indent != expected_indent {
486 warnings.push((
487 self.create_indent_warning(ctx, *line_num, line_info, *indent, expected_indent),
488 expected_indent as isize - *indent as isize,
489 ));
490 }
491 }
492 }
493 }
494
495 fn group_by_parent_content_column<'a>(
502 &self,
503 level: usize,
504 group: &[(usize, usize, &'a crate::lint_context::LineInfo)],
505 all_list_items: &[(usize, usize, &crate::lint_context::LineInfo, ParsedListItem<'_>)],
506 level_map: &HashMap<usize, usize>,
507 ) -> ParentContentGroups<'a> {
508 let parent_level = level - 1;
509
510 let is_ordered_map: HashMap<usize, bool> = all_list_items
512 .iter()
513 .map(|(ln, _, _, item)| (*ln, item.is_ordered()))
514 .collect();
515
516 let parent_items: Vec<(usize, usize)> = all_list_items
518 .iter()
519 .filter(|(ln, _, _, _)| level_map.get(ln) == Some(&parent_level))
520 .map(|(ln, _, _, item)| (*ln, item.content_column()))
521 .collect();
522
523 let mut parent_content_groups: ParentContentGroups<'a> = HashMap::new();
524
525 for (line_num, indent, line_info) in group {
526 let item_is_ordered = is_ordered_map.get(line_num).copied().unwrap_or(false);
527
528 let idx = parent_items.partition_point(|&(ln, _)| ln < *line_num);
530 let parent_content_col = if idx > 0 { Some(parent_items[idx - 1].1) } else { None };
531
532 if let Some(parent_col) = parent_content_col {
533 parent_content_groups
534 .entry((parent_col, item_is_ordered))
535 .or_default()
536 .push((*line_num, *indent, *line_info));
537 }
538 }
539
540 parent_content_groups
541 }
542
543 fn group_related_list_blocks<'a>(&self, list_blocks: ParsedListBlocks<'a>) -> Vec<Vec<ParsedListBlock<'a>>> {
545 let mut blocks = list_blocks.into_iter();
546 let Some(first_block) = blocks.next() else {
547 return Vec::new();
548 };
549
550 let mut groups = Vec::new();
551 let mut current_group = vec![first_block];
552 let mut prev_block = first_block;
553
554 for current_block in blocks {
555 let line_gap = current_block.start_line().saturating_sub(prev_block.end_line());
557
558 let same_quote = current_block.blockquote_prefix().matches('>').count()
563 == prev_block.blockquote_prefix().matches('>').count();
564 if same_quote && line_gap <= Self::LIST_GROUP_GAP_TOLERANCE {
565 current_group.push(current_block);
566 } else {
567 groups.push(current_group);
569 current_group = vec![current_block];
570 }
571 prev_block = current_block;
572 }
573 groups.push(current_group);
574
575 groups
576 }
577
578 fn is_continuation_content(
581 &self,
582 ctx: &crate::lint_context::LintContext,
583 cache: &LineCacheInfo,
584 list_line: usize,
585 list_indent: usize,
586 ) -> bool {
587 let parent_line = cache.parent_map.get(&list_line).copied();
589
590 if let Some(parent_line) = parent_line
591 && let Some(parent_list_item) = ctx.list_item_on_line(parent_line)
592 {
593 let line_info = parent_list_item.line_info();
594 let parent_marker_column = parent_list_item.marker_column();
595 let parent_content_column = parent_list_item.content_column();
596
597 let parent_bq_level = line_info.blockquote.as_ref().map_or(0, |bq| bq.nesting_level);
599 let parent_bq_prefix_len = line_info.blockquote.as_ref().map_or(0, |bq| bq.prefix.len());
600
601 let thresholds = (parent_marker_column + 1, parent_content_column);
605 let continuation_indent = cache.continuation_before(
606 parent_line,
607 list_line,
608 thresholds,
609 (parent_bq_level, parent_bq_prefix_len),
610 );
611
612 if let Some(continuation_indent) = continuation_indent {
613 let is_standard_continuation =
614 list_indent == parent_content_column + Self::STANDARD_CONTINUATION_OFFSET;
615 let matches_content_indent = list_indent == continuation_indent;
616
617 if matches_content_indent || is_standard_continuation {
618 return true;
619 }
620 }
621
622 if list_indent > parent_marker_column {
625 if self.has_continuation_list_at_indent(ctx, cache, parent_line, list_line, list_indent, thresholds) {
627 return true;
628 }
629
630 let parent_bq = cache.blockquote_info(parent_line);
632 if cache
633 .continuation_before(parent_line, list_line, thresholds, parent_bq)
634 .is_some()
635 {
636 return true;
637 }
638 }
639 }
640
641 false
642 }
643
644 fn has_continuation_list_at_indent(
650 &self,
651 ctx: &crate::lint_context::LintContext,
652 cache: &LineCacheInfo,
653 parent_line: usize,
654 current_line: usize,
655 list_indent: usize,
656 thresholds: (usize, usize),
657 ) -> bool {
658 let parent_bq = cache.blockquote_info(parent_line);
659 match cache.continuation_decided(parent_line, thresholds, parent_bq) {
660 Some((decided_at, Some(_))) => cache
661 .first_item_at_column(ctx, parent_line, decided_at + 1, list_indent)
662 .is_some_and(|line_num| line_num < current_line),
663 _ => false,
664 }
665 }
666
667 fn check_list_block_group(
669 &self,
670 ctx: &crate::lint_context::LintContext,
671 cache: &LineCacheInfo,
672 group: &[ParsedListBlock<'_>],
673 warnings: &mut Vec<(LintWarning, isize)>,
674 ) {
675 let mut candidate_items: Vec<(usize, usize, &crate::lint_context::LineInfo, ParsedListItem<'_>)> = Vec::new();
678
679 for list_block in group {
680 for list_item in list_block.items() {
681 if is_inside_literal_code(ctx, list_item.marker_byte_offset()) {
682 continue;
683 }
684 let item_line = list_item.line_num();
685 let line_info = list_item.line_info();
686 let effective_indent = if let Some(blockquote) = &line_info.blockquote {
688 list_item.marker_column().saturating_sub(blockquote.nesting_level * 2)
690 } else {
691 list_item.marker_column()
693 };
694
695 candidate_items.push((item_line, effective_indent, line_info, list_item));
696 }
697 }
698
699 candidate_items.sort_by_key(|(line_num, _, _, _)| *line_num);
701
702 let mut skipped_lines: std::collections::HashSet<usize> = std::collections::HashSet::new();
705 let mut all_list_items: Vec<(usize, usize, &crate::lint_context::LineInfo, ParsedListItem<'_>)> = Vec::new();
706
707 for (item_line, effective_indent, line_info, list_item) in candidate_items {
708 if line_info.in_footnote_definition {
710 skipped_lines.insert(item_line);
711 continue;
712 }
713 if self.is_continuation_content(ctx, cache, item_line, effective_indent) {
715 skipped_lines.insert(item_line);
716 continue;
717 }
718
719 if let Some(&parent_line) = cache.parent_map.get(&item_line)
721 && skipped_lines.contains(&parent_line)
722 {
723 skipped_lines.insert(item_line);
724 continue;
725 }
726
727 all_list_items.push((item_line, effective_indent, line_info, list_item));
728 }
729
730 if all_list_items.is_empty() {
731 return;
732 }
733
734 all_list_items.sort_by_key(|(line_num, _, _, _)| *line_num);
736
737 let mut level_map: HashMap<usize, usize> = HashMap::new();
741 let mut level_indents: HashMap<usize, Vec<usize>> = HashMap::new(); let mut indent_to_level: HashMap<usize, (usize, usize)> = HashMap::new();
746
747 for (line_num, indent, _, _) in &all_list_items {
749 let level = if indent_to_level.is_empty() {
750 level_indents.entry(1).or_default().push(*indent);
752 1
753 } else {
754 let mut determined_level = 0;
756
757 if let Some(&(existing_level, _)) = indent_to_level.get(indent) {
759 determined_level = existing_level;
760 } else {
761 let mut best_parent: Option<(usize, usize, usize)> = None; for (&tracked_indent, &(tracked_level, tracked_line)) in &indent_to_level {
767 if tracked_indent < *indent {
768 if best_parent.is_none() || tracked_indent > best_parent.unwrap().0 {
771 best_parent = Some((tracked_indent, tracked_level, tracked_line));
772 }
773 }
774 }
775
776 if let Some((parent_indent, parent_level, _parent_line)) = best_parent {
777 if parent_indent + Self::MIN_CHILD_INDENT_INCREASE <= *indent {
779 determined_level = parent_level + 1;
781 } else if (*indent as i32 - parent_indent as i32).abs() <= Self::SAME_LEVEL_TOLERANCE {
782 determined_level = parent_level;
784 } else {
785 let mut found_similar = false;
789 if let Some(indents_at_level) = level_indents.get(&parent_level) {
790 for &level_indent in indents_at_level {
791 if (level_indent as i32 - *indent as i32).abs() <= Self::SAME_LEVEL_TOLERANCE {
792 determined_level = parent_level;
793 found_similar = true;
794 break;
795 }
796 }
797 }
798 if !found_similar {
799 determined_level = parent_level + 1;
801 }
802 }
803 }
804
805 if determined_level == 0 {
807 determined_level = 1;
808 }
809
810 level_indents.entry(determined_level).or_default().push(*indent);
812 }
813
814 determined_level
815 };
816
817 level_map.insert(*line_num, level);
818 indent_to_level.insert(*indent, (level, *line_num));
820 }
821
822 let mut level_groups: HashMap<usize, Vec<(usize, usize, &crate::lint_context::LineInfo)>> = HashMap::new();
824 for (line_num, indent, line_info, _) in &all_list_items {
825 let level = level_map[line_num];
826 level_groups
827 .entry(level)
828 .or_default()
829 .push((*line_num, *indent, *line_info));
830 }
831
832 for (level, mut group) in level_groups {
834 group.sort_by_key(|(line_num, _, _)| *line_num);
835
836 if level == 1 {
837 for (line_num, indent, line_info) in &group {
839 if *indent != self.top_level_indent {
840 warnings.push((
841 self.create_indent_warning(ctx, *line_num, line_info, *indent, self.top_level_indent),
842 self.top_level_indent as isize - *indent as isize,
843 ));
844 }
845 }
846 } else {
847 let parent_content_groups =
850 self.group_by_parent_content_column(level, &group, &all_list_items, &level_map);
851
852 for items in parent_content_groups.values() {
854 self.check_indent_consistency(ctx, items, warnings);
855 }
856 }
857 }
858 }
859
860 fn check_optimized(&self, ctx: &crate::lint_context::LintContext) -> Vec<LintWarning> {
862 let content = ctx.content;
863
864 if content.is_empty() {
866 return Vec::new();
867 }
868
869 let list_blocks = ctx.parsed_list_blocks();
871 if list_blocks.is_empty() {
872 return Vec::new();
873 }
874
875 let mut warnings = Vec::new();
876
877 let cache = LineCacheInfo::new(ctx);
879
880 let block_groups = self.group_related_list_blocks(list_blocks);
883
884 for group in block_groups {
885 self.check_list_block_group(ctx, &cache, &group, &mut warnings);
886 }
887
888 let moves = warnings
892 .iter()
893 .enumerate()
894 .filter(|(_, (_, delta))| *delta != 0)
895 .map(|(index, (warning, delta))| (warning.line - 1, (index, *delta)))
896 .collect();
897 let mut warnings: Vec<LintWarning> = warnings.into_iter().map(|(warning, _)| warning).collect();
898 move_owned_lines(ctx, &moves, Nesting::Absolute, &mut warnings);
899 warnings
900 }
901}
902
903impl Rule for MD005ListIndent {
904 fn name(&self) -> &'static str {
905 "MD005"
906 }
907
908 fn description(&self) -> &'static str {
909 "List indentation should be consistent"
910 }
911
912 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
913 let mut warnings = self.check_optimized(ctx);
914 protect_literal_code(ctx, &mut warnings);
915 drop_structure_changing_fixes(ctx, &mut warnings, Allowed::Nothing);
916 Ok(warnings)
917 }
918
919 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
920 let warnings = self.check(ctx)?;
921 let warnings =
922 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
923 if warnings.is_empty() {
924 return Ok(ctx.content.to_string());
925 }
926
927 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings).map_err(LintError::InvalidInput)
928 }
929
930 fn category(&self) -> RuleCategory {
931 RuleCategory::List
932 }
933
934 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
936 ctx.content.is_empty() || !ctx.has_list_items()
938 }
939
940 fn as_any(&self) -> &dyn std::any::Any {
941 self
942 }
943
944 fn default_config_section(&self) -> Option<(String, toml::Value)> {
945 None
946 }
947
948 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
949 where
950 Self: Sized,
951 {
952 let mut top_level_indent = 0;
954
955 if let Some(md007_config) = config.rules.get("MD007") {
957 if let Some(start_indented) = md007_config.values.get("start-indented")
959 && let Some(start_indented_bool) = start_indented.as_bool()
960 && start_indented_bool
961 {
962 if let Some(start_indent) = md007_config.values.get("start-indent") {
964 if let Some(indent_value) = start_indent.as_integer() {
965 top_level_indent = indent_value as usize;
966 }
967 } else {
968 top_level_indent = 2;
970 }
971 }
972 }
973
974 Box::new(MD005ListIndent { top_level_indent })
975 }
976}
977
978#[cfg(test)]
979mod tests {
980 use super::*;
981 use crate::lint_context::LintContext;
982
983 #[test]
984 fn test_valid_unordered_list() {
985 let rule = MD005ListIndent::default();
986 let content = "\
987* Item 1
988* Item 2
989 * Nested 1
990 * Nested 2
991* Item 3";
992 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
993 let result = rule.check(&ctx).unwrap();
994 assert!(result.is_empty());
995 }
996
997 #[test]
998 fn test_valid_ordered_list() {
999 let rule = MD005ListIndent::default();
1000 let content = "\
10011. Item 1
10022. Item 2
1003 1. Nested 1
1004 2. Nested 2
10053. Item 3";
1006 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1007 let result = rule.check(&ctx).unwrap();
1008 assert!(result.is_empty());
1011 }
1012
1013 #[test]
1014 fn test_invalid_unordered_indent() {
1015 let rule = MD005ListIndent::default();
1016 let content = "\
1017* Item 1
1018 * Item 2
1019 * Nested 1";
1020 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1021 let result = rule.check(&ctx).unwrap();
1022 assert_eq!(result.len(), 1);
1025 let fixed = rule.fix(&ctx).unwrap();
1026 assert_eq!(fixed, "* Item 1\n* Item 2\n * Nested 1");
1027 }
1028
1029 #[test]
1030 fn test_invalid_ordered_indent() {
1031 let rule = MD005ListIndent::default();
1032 let content = "\
10331. Item 1
1034 2. Item 2
1035 1. Nested 1";
1036 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1037 let result = rule.check(&ctx).unwrap();
1038 assert_eq!(result.len(), 1);
1039 let fixed = rule.fix(&ctx).unwrap();
1040 assert_eq!(fixed, "1. Item 1\n2. Item 2\n 1. Nested 1");
1044 }
1045
1046 #[test]
1047 fn test_mixed_list_types() {
1048 let rule = MD005ListIndent::default();
1049 let content = "\
1050* Item 1
1051 1. Nested ordered
1052 * Nested unordered
1053* Item 2";
1054 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1055 let result = rule.check(&ctx).unwrap();
1056 assert!(result.is_empty());
1057 }
1058
1059 #[test]
1060 fn test_multiple_levels() {
1061 let rule = MD005ListIndent::default();
1062 let content = "\
1063* Level 1
1064 * Level 2
1065 * Level 3";
1066 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1067 let result = rule.check(&ctx).unwrap();
1068 assert!(result.is_empty(), "MD005 should accept consistent indentation pattern");
1070 }
1071
1072 #[test]
1073 fn test_empty_lines() {
1074 let rule = MD005ListIndent::default();
1075 let content = "\
1076* Item 1
1077
1078 * Nested 1
1079
1080* Item 2";
1081 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1082 let result = rule.check(&ctx).unwrap();
1083 assert!(result.is_empty());
1084 }
1085
1086 #[test]
1087 fn test_no_lists() {
1088 let rule = MD005ListIndent::default();
1089 let content = "\
1090Just some text
1091More text
1092Even more text";
1093 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1094 let result = rule.check(&ctx).unwrap();
1095 assert!(result.is_empty());
1096 }
1097
1098 #[test]
1099 fn test_complex_nesting() {
1100 let rule = MD005ListIndent::default();
1101 let content = "\
1102* Level 1
1103 * Level 2
1104 * Level 3
1105 * Back to 2
1106 1. Ordered 3
1107 2. Still 3
1108* Back to 1";
1109 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1110 let result = rule.check(&ctx).unwrap();
1111 assert!(result.is_empty());
1112 }
1113
1114 #[test]
1115 fn test_invalid_complex_nesting() {
1116 let rule = MD005ListIndent::default();
1117 let content = "\
1118* Level 1
1119 * Level 2
1120 * Level 3
1121 * Back to 2
1122 1. Ordered 3
1123 2. Still 3
1124* Back to 1";
1125 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1126 let result = rule.check(&ctx).unwrap();
1127 assert_eq!(result.len(), 1);
1129 assert!(
1130 result[0].message.contains("Expected indentation of 5 spaces, found 6")
1131 || result[0].message.contains("Expected indentation of 6 spaces, found 5")
1132 );
1133 }
1134
1135 #[test]
1136 fn test_with_lint_context() {
1137 let rule = MD005ListIndent::default();
1138
1139 let content = "* Item 1\n* Item 2\n * Nested item\n * Another nested item";
1141 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1142 let result = rule.check(&ctx).unwrap();
1143 assert!(result.is_empty());
1144
1145 let content = "* Item 1\n* Item 2\n * Nested item\n * Another nested item";
1147 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1148 let result = rule.check(&ctx).unwrap();
1149 assert!(!result.is_empty()); let content = "* Item 1\n * Nested item\n * Another nested item with wrong indent";
1153 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1154 let result = rule.check(&ctx).unwrap();
1155 assert!(!result.is_empty()); }
1157
1158 #[test]
1160 fn test_list_with_continuations() {
1161 let rule = MD005ListIndent::default();
1162 let content = "\
1163* Item 1
1164 This is a continuation
1165 of the first item
1166 * Nested item
1167 with its own continuation
1168* Item 2";
1169 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1170 let result = rule.check(&ctx).unwrap();
1171 assert!(result.is_empty());
1172 }
1173
1174 #[test]
1175 fn test_list_after_a_quoted_list_is_judged_on_its_own() {
1176 let rule = MD005ListIndent::default();
1179 let content = "> * item 49\n + item 50\n + item 52\n";
1180 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1181 let lines: Vec<usize> = rule.check(&ctx).unwrap().iter().map(|w| w.line).collect();
1182 assert_eq!(lines, vec![1, 2]);
1183 let fixed = rule.fix(&ctx).unwrap();
1184 assert_eq!(fixed, "> * item 49\n+ item 50\n + item 52\n");
1185 let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1186 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
1187 }
1188
1189 #[test]
1190 fn test_list_in_blockquote() {
1191 let rule = MD005ListIndent::default();
1192 let content = "\
1193> * Item 1
1194> * Nested 1
1195> * Nested 2
1196> * Item 2";
1197 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1198 let result = rule.check(&ctx).unwrap();
1199
1200 assert!(
1202 result.is_empty(),
1203 "Expected no warnings for correctly indented blockquote list, got: {result:?}"
1204 );
1205 }
1206
1207 #[test]
1208 fn test_list_with_code_blocks() {
1209 let rule = MD005ListIndent::default();
1210 let content = "\
1211* Item 1
1212 ```
1213 code block
1214 ```
1215 * Nested item
1216* Item 2";
1217 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1218 let result = rule.check(&ctx).unwrap();
1219 assert!(result.is_empty());
1220 }
1221
1222 #[test]
1223 fn test_list_with_tabs() {
1224 let rule = MD005ListIndent::default();
1225 let content = "* Item 1\n * Wrong indent (3 spaces)\n * Correct indent (2 spaces)";
1229 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1230 let result = rule.check(&ctx).unwrap();
1231 assert!(!result.is_empty());
1233 }
1234
1235 #[test]
1236 fn test_inconsistent_at_same_level() {
1237 let rule = MD005ListIndent::default();
1238 let content = "\
1239* Item 1
1240 * Nested 1
1241 * Nested 2
1242 * Wrong indent for same level
1243 * Nested 3";
1244 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1245 let result = rule.check(&ctx).unwrap();
1246 assert!(!result.is_empty());
1247 assert!(result.iter().any(|w| w.line == 4));
1249 }
1250
1251 #[test]
1252 fn test_zero_indent_top_level() {
1253 let rule = MD005ListIndent::default();
1254 let content = concat!(" * Wrong indent\n", "* Correct\n", " * Nested");
1256 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1257 let result = rule.check(&ctx).unwrap();
1258
1259 assert!(!result.is_empty());
1261 assert!(result.iter().any(|w| w.line == 1));
1262 }
1263
1264 #[test]
1265 fn test_fix_preserves_content() {
1266 let rule = MD005ListIndent::default();
1267 let content = "\
1268* Item with **bold** and *italic*
1269 * Wrong indent with `code`
1270 * Also wrong with [link](url)";
1271 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1272 let fixed = rule.fix(&ctx).unwrap();
1273 assert!(fixed.contains("**bold**"));
1274 assert!(fixed.contains("*italic*"));
1275 assert!(fixed.contains("`code`"));
1276 assert!(fixed.contains("[link](url)"));
1277 }
1278
1279 #[test]
1280 fn test_deeply_nested_lists() {
1281 let rule = MD005ListIndent::default();
1282 let content = "\
1283* L1
1284 * L2
1285 * L3
1286 * L4
1287 * L5
1288 * L6";
1289 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1290 let result = rule.check(&ctx).unwrap();
1291 assert!(result.is_empty());
1292 }
1293
1294 #[test]
1295 fn test_fix_multiple_issues() {
1296 let rule = MD005ListIndent::default();
1297 let content = "\
1298* Item 1
1299 * Wrong 1
1300 * Wrong 2
1301 * Wrong 3
1302 * Correct";
1303 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1304 let fixed = rule.fix(&ctx).unwrap();
1305 assert_eq!(fixed, "* Item 1\n* Wrong 1\n * Wrong 2\n * Wrong 3\n* Correct");
1306
1307 let content = format!("{content}\n * Wrong 4");
1311 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1312 assert!(!rule.check(&ctx).unwrap().is_empty());
1313 assert_eq!(rule.fix(&ctx).unwrap(), content);
1314 }
1315
1316 #[test]
1317 fn test_performance_large_document() {
1318 let rule = MD005ListIndent::default();
1319 let mut content = String::new();
1320 for i in 0..100 {
1321 content.push_str(&format!("* Item {i}\n"));
1322 content.push_str(&format!(" * Nested {i}\n"));
1323 }
1324 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1325 let result = rule.check(&ctx).unwrap();
1326 assert!(result.is_empty());
1327 }
1328
1329 #[test]
1330 fn test_column_positions() {
1331 let rule = MD005ListIndent::default();
1332 let content = " * Wrong indent";
1333 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1334 let result = rule.check(&ctx).unwrap();
1335 assert_eq!(result.len(), 1);
1336 assert_eq!(result[0].column, 1, "Expected column 1, got {}", result[0].column);
1337 assert_eq!(
1338 result[0].end_column, 2,
1339 "Expected end_column 2, got {}",
1340 result[0].end_column
1341 );
1342 }
1343
1344 #[test]
1345 fn test_should_skip() {
1346 let rule = MD005ListIndent::default();
1347
1348 let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard, None);
1350 assert!(rule.should_skip(&ctx));
1351
1352 let ctx = LintContext::new("Just plain text", crate::config::MarkdownFlavor::Standard, None);
1354 assert!(rule.should_skip(&ctx));
1355
1356 let ctx = LintContext::new("* List item", crate::config::MarkdownFlavor::Standard, None);
1358 assert!(!rule.should_skip(&ctx));
1359
1360 let ctx = LintContext::new("1. Ordered list", crate::config::MarkdownFlavor::Standard, None);
1361 assert!(!rule.should_skip(&ctx));
1362 }
1363
1364 #[test]
1365 fn test_should_skip_validation() {
1366 let rule = MD005ListIndent::default();
1367 let content = "* List item";
1368 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1369 assert!(!rule.should_skip(&ctx));
1370
1371 let content = "No lists here";
1372 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1373 assert!(rule.should_skip(&ctx));
1374 }
1375
1376 #[test]
1377 fn test_edge_case_single_space_indent() {
1378 let rule = MD005ListIndent::default();
1379 let content = "\
1380* Item 1
1381 * Single space - wrong
1382 * Two spaces - correct";
1383 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1384 let result = rule.check(&ctx).unwrap();
1385 assert_eq!(result.len(), 2);
1388 assert!(result.iter().any(|w| w.line == 2 && w.message.contains("found 1")));
1389 }
1390
1391 #[test]
1392 fn test_edge_case_three_space_indent() {
1393 let rule = MD005ListIndent::default();
1394 let content = "\
1395* Item 1
1396 * Three spaces - first establishes pattern
1397 * Two spaces - inconsistent with established pattern";
1398 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1399 let result = rule.check(&ctx).unwrap();
1400 assert_eq!(result.len(), 1);
1404 assert!(result.iter().any(|w| w.line == 3 && w.message.contains("found 2")));
1405 }
1406
1407 #[test]
1408 fn test_nested_bullets_under_numbered_items() {
1409 let rule = MD005ListIndent::default();
1410 let content = "\
14111. **Active Directory/LDAP**
1412 - User authentication and directory services
1413 - LDAP for user information and validation
1414
14152. **Oracle Unified Directory (OUD)**
1416 - Extended user directory services
1417 - Verification of project account presence and changes";
1418 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1419 let result = rule.check(&ctx).unwrap();
1420 assert!(
1422 result.is_empty(),
1423 "Expected no warnings for bullets with 3 spaces under numbered items, got: {result:?}"
1424 );
1425 }
1426
1427 #[test]
1428 fn test_nested_bullets_under_numbered_items_wrong_indent() {
1429 let rule = MD005ListIndent::default();
1430 let content = "\
14311. **Active Directory/LDAP**
1432 - Wrong: only 2 spaces
1433 - Correct: 3 spaces";
1434 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1435 let result = rule.check(&ctx).unwrap();
1436 assert_eq!(
1438 result.len(),
1439 1,
1440 "Expected 1 warning, got {}. Warnings: {:?}",
1441 result.len(),
1442 result
1443 );
1444 assert!(
1446 result
1447 .iter()
1448 .any(|w| (w.line == 2 && w.message.contains("found 2"))
1449 || (w.line == 3 && w.message.contains("found 3")))
1450 );
1451 }
1452
1453 #[test]
1454 fn test_regular_nested_bullets_still_work() {
1455 let rule = MD005ListIndent::default();
1456 let content = "\
1457* Top level
1458 * Second level (2 spaces is correct for bullets under bullets)
1459 * Third level (4 spaces)";
1460 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1461 let result = rule.check(&ctx).unwrap();
1462 assert!(
1464 result.is_empty(),
1465 "Expected no warnings for regular bullet nesting, got: {result:?}"
1466 );
1467 }
1468
1469 #[test]
1470 fn test_fix_range_accuracy() {
1471 let rule = MD005ListIndent::default();
1472 let content = " * Wrong indent";
1473 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1474 let result = rule.check(&ctx).unwrap();
1475 assert_eq!(result.len(), 1);
1476
1477 let fix = result[0].fix.as_ref().unwrap();
1478 assert_eq!(fix.replacement, "");
1480 }
1481
1482 #[test]
1483 fn test_four_space_indent_pattern() {
1484 let rule = MD005ListIndent::default();
1485 let content = "\
1486* Item 1
1487 * Item 2 with 4 spaces
1488 * Item 3 with 8 spaces
1489 * Item 4 with 4 spaces";
1490 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1491 let result = rule.check(&ctx).unwrap();
1492 assert!(
1494 result.is_empty(),
1495 "MD005 should accept consistent 4-space indentation pattern, got {} warnings",
1496 result.len()
1497 );
1498 }
1499
1500 #[test]
1501 fn test_issue_64_scenario() {
1502 let rule = MD005ListIndent::default();
1504 let content = "\
1505* Top level item
1506 * Sub item with 4 spaces (as configured in MD007)
1507 * Nested sub item with 8 spaces
1508 * Another sub item with 4 spaces
1509* Another top level";
1510
1511 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1512 let result = rule.check(&ctx).unwrap();
1513
1514 assert!(
1516 result.is_empty(),
1517 "MD005 should accept 4-space indentation when that's the pattern being used. Got {} warnings",
1518 result.len()
1519 );
1520 }
1521
1522 #[test]
1523 fn test_continuation_content_scenario() {
1524 let rule = MD005ListIndent::default();
1525 let content = "\
1526- **Changes to how the Python version is inferred** ([#16319](example))
1527
1528 In previous versions of Ruff, you could specify your Python version with:
1529
1530 - The `target-version` option in a `ruff.toml` file
1531 - The `project.requires-python` field in a `pyproject.toml` file";
1532
1533 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1534
1535 let result = rule.check(&ctx).unwrap();
1536
1537 assert!(
1539 result.is_empty(),
1540 "MD005 should not flag continuation content lists, got {} warnings: {:?}",
1541 result.len(),
1542 result
1543 );
1544 }
1545
1546 #[test]
1547 fn test_multiple_continuation_lists_scenario() {
1548 let rule = MD005ListIndent::default();
1549 let content = "\
1550- **Changes to how the Python version is inferred** ([#16319](example))
1551
1552 In previous versions of Ruff, you could specify your Python version with:
1553
1554 - The `target-version` option in a `ruff.toml` file
1555 - The `project.requires-python` field in a `pyproject.toml` file
1556
1557 In v0.10, config discovery has been updated to address this issue:
1558
1559 - If Ruff finds a `ruff.toml` file without a `target-version`, it will check
1560 - If Ruff finds a user-level configuration, the `requires-python` field will take precedence
1561 - If there is no config file, Ruff will search for the closest `pyproject.toml`";
1562
1563 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1564
1565 let result = rule.check(&ctx).unwrap();
1566
1567 assert!(
1569 result.is_empty(),
1570 "MD005 should not flag continuation content lists, got {} warnings: {:?}",
1571 result.len(),
1572 result
1573 );
1574 }
1575
1576 #[test]
1577 fn test_issue_115_sublist_after_code_block() {
1578 let rule = MD005ListIndent::default();
1579 let content = "\
15801. List item 1
1581
1582 ```rust
1583 fn foo() {}
1584 ```
1585
1586 Sublist:
1587
1588 - A
1589 - B
1590";
1591 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1592 let result = rule.check(&ctx).unwrap();
1593 assert!(
1597 result.is_empty(),
1598 "Expected no warnings for sub-list after code block in list item, got {} warnings: {:?}",
1599 result.len(),
1600 result
1601 );
1602 }
1603
1604 #[test]
1605 fn test_edge_case_continuation_at_exact_boundary() {
1606 let rule = MD005ListIndent::default();
1607 let content = "\
1609* Item (content at column 2)
1610 Text at column 2 (exact boundary - continuation)
1611 * Sub at column 2";
1612 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1613 let result = rule.check(&ctx).unwrap();
1614 assert!(
1616 result.is_empty(),
1617 "Expected no warnings when text and sub-list are at exact parent content_column, got: {result:?}"
1618 );
1619 }
1620
1621 #[test]
1622 fn test_edge_case_unicode_in_continuation() {
1623 let rule = MD005ListIndent::default();
1624 let content = "\
1625* Parent
1626 Text with emoji 😀 and Unicode ñ characters
1627 * Sub-list should still work";
1628 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1629 let result = rule.check(&ctx).unwrap();
1630 assert!(
1632 result.is_empty(),
1633 "Expected no warnings with Unicode in continuation content, got: {result:?}"
1634 );
1635 }
1636
1637 #[test]
1638 fn test_edge_case_large_empty_line_gap() {
1639 let rule = MD005ListIndent::default();
1640 let content = "\
1641* Parent at line 1
1642 Continuation text
1643
1644
1645
1646 More continuation after many empty lines
1647
1648 * Child after gap
1649 * Another child";
1650 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1651 let result = rule.check(&ctx).unwrap();
1652 assert!(
1654 result.is_empty(),
1655 "Expected no warnings with large gaps in continuation content, got: {result:?}"
1656 );
1657 }
1658
1659 #[test]
1660 fn test_edge_case_multiple_continuation_blocks_varying_indent() {
1661 let rule = MD005ListIndent::default();
1662 let content = "\
1663* Parent (content at column 2)
1664 First paragraph at column 2
1665 Indented quote at column 4
1666 Back to column 2
1667 * Sub-list at column 2";
1668 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1669 let result = rule.check(&ctx).unwrap();
1670 assert!(
1672 result.is_empty(),
1673 "Expected no warnings with varying continuation indent, got: {result:?}"
1674 );
1675 }
1676
1677 #[test]
1678 fn test_edge_case_deep_nesting_no_continuation() {
1679 let rule = MD005ListIndent::default();
1680 let content = "\
1681* Parent
1682 * Immediate child (no continuation text before)
1683 * Grandchild
1684 * Great-grandchild
1685 * Great-great-grandchild
1686 * Another child at level 2";
1687 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1688 let result = rule.check(&ctx).unwrap();
1689 assert!(
1691 result.is_empty(),
1692 "Expected no warnings for deep nesting without continuation, got: {result:?}"
1693 );
1694 }
1695
1696 #[test]
1697 fn test_edge_case_blockquote_continuation_content() {
1698 let rule = MD005ListIndent::default();
1699 let content = "\
1700> * Parent in blockquote
1701> Continuation in blockquote
1702> * Sub-list in blockquote
1703> * Another sub-list";
1704 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1705 let result = rule.check(&ctx).unwrap();
1706 assert!(
1708 result.is_empty(),
1709 "Expected no warnings for blockquote continuation, got: {result:?}"
1710 );
1711 }
1712
1713 #[test]
1714 fn test_edge_case_one_space_less_than_content_column() {
1715 let rule = MD005ListIndent::default();
1716 let content = "\
1717* Parent (content at column 2)
1718 Text at column 1 (one less than content_column - NOT continuation)
1719 * Child";
1720 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1721 let result = rule.check(&ctx).unwrap();
1722 assert!(
1728 result.is_empty() || !result.is_empty(),
1729 "Test should complete without panic"
1730 );
1731 }
1732
1733 #[test]
1734 fn test_edge_case_multiple_code_blocks_different_indentation() {
1735 let rule = MD005ListIndent::default();
1736 let content = "\
1737* Parent
1738 ```
1739 code at 2 spaces
1740 ```
1741 ```
1742 code at 4 spaces
1743 ```
1744 * Sub-list should not be confused";
1745 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1746 let result = rule.check(&ctx).unwrap();
1747 assert!(
1749 result.is_empty(),
1750 "Expected no warnings with multiple code blocks, got: {result:?}"
1751 );
1752 }
1753
1754 #[test]
1755 fn test_performance_very_large_document() {
1756 let rule = MD005ListIndent::default();
1757 let mut content = String::new();
1758
1759 for i in 0..1000 {
1761 content.push_str(&format!("* Item {i}\n"));
1762 content.push_str(&format!(" * Nested {i}\n"));
1763 if i % 10 == 0 {
1764 content.push_str(" Some continuation text\n");
1765 }
1766 }
1767
1768 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1769
1770 let start = std::time::Instant::now();
1772 let result = rule.check(&ctx).unwrap();
1773 let elapsed = start.elapsed();
1774
1775 assert!(result.is_empty());
1776 println!("Processed 1000 list items in {elapsed:?}");
1777 assert!(
1780 elapsed.as_secs() < 1,
1781 "Should complete in under 1 second, took {elapsed:?}"
1782 );
1783 }
1784
1785 #[test]
1786 fn test_ordered_list_variable_marker_width() {
1787 let rule = MD005ListIndent::default();
1792 let content = "\
17931. One
1794 - One
1795 - Two
17962. Two
1797 - One
17983. Three
1799 - One
18004. Four
1801 - One
18025. Five
1803 - One
18046. Six
1805 - One
18067. Seven
1807 - One
18088. Eight
1809 - One
18109. Nine
1811 - One
181210. Ten
1813 - One";
1814 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1815 let result = rule.check(&ctx).unwrap();
1816 assert!(
1817 result.is_empty(),
1818 "Expected no warnings for ordered list with variable marker widths, got: {result:?}"
1819 );
1820 }
1821
1822 #[test]
1823 fn test_ordered_list_inconsistent_siblings() {
1824 let rule = MD005ListIndent::default();
1826 let content = "\
18271. Item one
1828 - First sublist at 3 spaces
1829 - Second sublist at 2 spaces (inconsistent)
1830 - Third sublist at 3 spaces";
1831 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1832 let result = rule.check(&ctx).unwrap();
1833 assert_eq!(
1835 result.len(),
1836 1,
1837 "Expected 1 warning for inconsistent sibling indent, got: {result:?}"
1838 );
1839 assert!(result[0].message.contains("Expected indentation of 3"));
1840 }
1841
1842 #[test]
1843 fn test_ordered_list_single_sublist_no_warning() {
1844 let rule = MD005ListIndent::default();
1847 let content = "\
184810. Item ten
1849 - Only sublist at 3 spaces";
1850 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1851 let result = rule.check(&ctx).unwrap();
1852 assert!(
1854 result.is_empty(),
1855 "Expected no warnings for single sublist item, got: {result:?}"
1856 );
1857 }
1858
1859 #[test]
1860 fn test_sublists_grouped_by_parent_content_column() {
1861 let rule = MD005ListIndent::default();
1865 let content = "\
18669. Item nine
1867 - First sublist at 3 spaces
1868 - Second sublist at 3 spaces
1869 - Third sublist at 3 spaces
187010. Item ten
1871 - First sublist at 4 spaces
1872 - Second sublist at 4 spaces
1873 - Third sublist at 4 spaces";
1874 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1875 let result = rule.check(&ctx).unwrap();
1876 assert!(
1879 result.is_empty(),
1880 "Expected no warnings for sublists grouped by parent, got: {result:?}"
1881 );
1882 }
1883
1884 #[test]
1885 fn test_inconsistent_indent_within_parent_group() {
1886 let rule = MD005ListIndent::default();
1888 let content = "\
188910. Item ten
1890 - First sublist at 4 spaces
1891 - Second sublist at 3 spaces (inconsistent!)
1892 - Third sublist at 4 spaces";
1893 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1894 let result = rule.check(&ctx).unwrap();
1895 assert_eq!(
1897 result.len(),
1898 1,
1899 "Expected 1 warning for inconsistent indent within parent group, got: {result:?}"
1900 );
1901 assert!(result[0].line == 3);
1902 assert!(result[0].message.contains("Expected indentation of 4"));
1903 }
1904
1905 #[test]
1906 fn test_blockquote_nested_list_fix_preserves_blockquote_prefix() {
1907 use crate::rule::Rule;
1911
1912 let rule = MD005ListIndent::default();
1913 let content = "> * Federation sender blacklists are now persisted.";
1914 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1915 let result = rule.check(&ctx).unwrap();
1916
1917 assert_eq!(result.len(), 1, "Expected 1 warning for extra indent");
1918
1919 assert!(result[0].fix.is_some(), "Should have a fix");
1921 let fixed = rule.fix(&ctx).expect("Fix should succeed");
1922
1923 assert!(
1925 fixed.starts_with("> "),
1926 "Fixed content should start with blockquote prefix '> ', got: {fixed:?}"
1927 );
1928 assert!(
1929 !fixed.starts_with("* "),
1930 "Fixed content should NOT start with just '* ' (blockquote removed), got: {fixed:?}"
1931 );
1932 assert_eq!(
1933 fixed.trim(),
1934 "> * Federation sender blacklists are now persisted.",
1935 "Fixed content should be '> * Federation sender...' with single space after >"
1936 );
1937 }
1938
1939 #[test]
1940 fn test_nested_blockquote_list_fix_preserves_prefix() {
1941 use crate::rule::Rule;
1943
1944 let rule = MD005ListIndent::default();
1945 let content = ">> * Nested blockquote list item";
1946 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1947 let result = rule.check(&ctx).unwrap();
1948
1949 assert_eq!(result.len(), 1, "Fixture must trigger an indentation warning");
1950 let fixed = rule.fix(&ctx).expect("Fix should succeed");
1951 assert_eq!(fixed, "> > * Nested blockquote list item");
1952 let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1953 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
1954 }
1955}