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<'a> {
33 indentation: Vec<usize>,
35 blockquote_levels: Vec<usize>,
37 line_contents: &'a [&'a str],
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<'a> LineCacheInfo<'a> {
69 fn new(ctx: &'a 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 line_contents = ctx.raw_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 let mut flag = 0u8;
104 if !content.is_empty() {
105 flag |= FLAG_HAS_CONTENT;
106 }
107 if let Some(list_item) = ctx
108 .list_item_on_line(idx + 1)
109 .filter(|item| !is_inside_literal_code(ctx, item.marker_byte_offset()))
110 {
111 flag |= FLAG_IS_LIST_ITEM;
112
113 let line_num = idx + 1; let marker_column = list_item.marker_column();
115
116 while let Some(&(indent, popped_line)) = indent_stack.last() {
118 if indent < marker_column {
119 break;
120 }
121 item_end.insert(popped_line, line_num);
122 indent_stack.pop();
123 }
124
125 if let Some((_, parent_line)) = indent_stack.last() {
126 parent_map.insert(line_num, *parent_line);
127 }
128
129 indent_stack.push((marker_column, line_num));
130 }
131 flags.push(flag);
132 }
133
134 Self {
135 indentation,
136 blockquote_levels,
137 line_contents,
138 flags,
139 parent_map,
140 item_end,
141 decided: RefCell::default(),
142 first_item_at: RefCell::default(),
143 }
144 }
145
146 fn has_content(&self, idx: usize) -> bool {
148 self.flags.get(idx).is_some_and(|&f| f & FLAG_HAS_CONTENT != 0)
149 }
150
151 fn is_list_item(&self, idx: usize) -> bool {
153 self.flags.get(idx).is_some_and(|&f| f & FLAG_IS_LIST_ITEM != 0)
154 }
155
156 fn blockquote_info(&self, line: usize) -> (usize, usize) {
158 if line == 0 || line > self.line_contents.len() {
159 return (0, 0);
160 }
161 let idx = line - 1;
162 let bq_level = self.blockquote_levels.get(idx).copied().unwrap_or(0);
163 if bq_level == 0 {
164 return (0, 0);
165 }
166 let content = &self.line_contents[idx];
168 let mut prefix_len = 0;
169 let mut found = 0;
170 for c in content.chars() {
171 prefix_len += c.len_utf8();
172 if c == '>' {
173 found += 1;
174 if found == bq_level {
175 if content.get(prefix_len..prefix_len + 1) == Some(" ") {
177 prefix_len += 1;
178 }
179 break;
180 }
181 }
182 }
183 (bq_level, prefix_len)
184 }
185
186 fn scan_continuation(
203 &self,
204 start_line: usize,
205 end_line: usize,
206 tight_threshold: usize,
207 loose_threshold: usize,
208 parent_bq_level: usize,
209 parent_bq_prefix_len: usize,
210 ) -> Option<Decision> {
211 if start_line == 0 || start_line > end_line || end_line > self.indentation.len() {
212 return None;
213 }
214
215 let adjust = |t: usize| {
218 if parent_bq_level > 0 {
219 t.saturating_sub(parent_bq_prefix_len)
220 } else {
221 t
222 }
223 };
224 let tight = adjust(tight_threshold);
225 let loose = adjust(loose_threshold);
226
227 let mut seen_blank = false;
228 for idx in start_line - 1..end_line {
229 if !self.has_content(idx) {
230 seen_blank = true;
231 continue;
232 }
233 if self.is_list_item(idx) {
234 continue;
235 }
236
237 let line_bq_level = self.blockquote_levels.get(idx).copied().unwrap_or(0);
239 let raw_indent = self.indentation[idx];
240 let effective_indent = if line_bq_level == parent_bq_level && parent_bq_level > 0 {
241 effective_indent_in_blockquote(self.line_contents[idx], parent_bq_level, raw_indent)
242 } else {
243 raw_indent
244 };
245
246 let threshold = if seen_blank { loose } else { tight };
247 if effective_indent >= threshold {
248 return Some((idx + 1, Some(effective_indent)));
249 }
250 if seen_blank {
253 return Some((idx + 1, None));
254 }
255 }
256 None
257 }
258
259 fn continuation_decided(
263 &self,
264 parent_line: usize,
265 (tight_threshold, loose_threshold): (usize, usize),
266 (parent_bq_level, parent_bq_prefix_len): (usize, usize),
267 ) -> Option<Decision> {
268 let key = (
269 parent_line,
270 tight_threshold,
271 loose_threshold,
272 parent_bq_level,
273 parent_bq_prefix_len,
274 );
275 if let Some(&decision) = self.decided.borrow().get(&key) {
276 return decision;
277 }
278 let last_owned = self
279 .item_end
280 .get(&parent_line)
281 .map_or(self.indentation.len(), |&end| end - 1);
282 let decision = self.scan_continuation(
283 parent_line + 1,
284 last_owned,
285 tight_threshold,
286 loose_threshold,
287 parent_bq_level,
288 parent_bq_prefix_len,
289 );
290 self.decided.borrow_mut().insert(key, decision);
291 decision
292 }
293
294 fn continuation_before(
299 &self,
300 parent_line: usize,
301 before_line: usize,
302 thresholds: (usize, usize),
303 parent_bq: (usize, usize),
304 ) -> Option<usize> {
305 match self.continuation_decided(parent_line, thresholds, parent_bq) {
306 Some((decided_at, indent)) if decided_at < before_line => indent,
307 _ => None,
308 }
309 }
310
311 fn first_item_at_column(
314 &self,
315 ctx: &crate::lint_context::LintContext,
316 parent_line: usize,
317 from_line: usize,
318 marker_column: usize,
319 ) -> Option<usize> {
320 let key = (parent_line, from_line, marker_column);
321 if let Some(&line) = self.first_item_at.borrow().get(&key) {
322 return line;
323 }
324 let end = self
325 .item_end
326 .get(&parent_line)
327 .copied()
328 .unwrap_or(self.indentation.len() + 1);
329 let line = (from_line..end).find(|&line_num| {
330 self.is_list_item(line_num - 1)
331 && ctx
332 .list_item_on_line(line_num)
333 .is_some_and(|item| item.marker_column() == marker_column)
334 });
335 self.first_item_at.borrow_mut().insert(key, line);
336 line
337 }
338}
339
340impl MD005ListIndent {
341 const LIST_GROUP_GAP_TOLERANCE: usize = 2;
345
346 const MIN_CHILD_INDENT_INCREASE: usize = 2;
349
350 const SAME_LEVEL_TOLERANCE: i32 = 1;
353
354 const STANDARD_CONTINUATION_OFFSET: usize = 2;
357
358 fn create_indent_warning(
360 &self,
361 ctx: &crate::lint_context::LintContext,
362 line_num: usize,
363 line_info: &crate::lint_context::LineInfo,
364 actual_indent: usize,
365 expected_indent: usize,
366 ) -> LintWarning {
367 let message = format!(
368 "Expected indentation of {} {}, found {}",
369 expected_indent,
370 if expected_indent == 1 { "space" } else { "spaces" },
371 actual_indent
372 );
373
374 let (start_line, start_col, end_line, end_col) = if actual_indent > 0 {
375 calculate_match_range(line_num, line_info.content(ctx.content), 0, actual_indent)
376 } else {
377 calculate_match_range(line_num, line_info.content(ctx.content), 0, 1)
378 };
379
380 let (fix_range, replacement) = if line_info.blockquote.is_some() {
383 let start_byte = line_info.byte_offset;
385 let mut end_byte = line_info.byte_offset;
386
387 let marker_column = ctx
389 .list_item_on_line(line_num)
390 .map_or(actual_indent, ParsedListItem::marker_column);
391
392 for (i, ch) in line_info.content(ctx.content).chars().enumerate() {
394 if i >= marker_column {
395 break;
396 }
397 end_byte += ch.len_utf8();
398 }
399
400 let mut blockquote_count = 0;
402 for ch in line_info.content(ctx.content).chars() {
403 if ch == '>' {
404 blockquote_count += 1;
405 } else if ch != ' ' && ch != '\t' {
406 break;
407 }
408 }
409
410 let blockquote_prefix = if blockquote_count > 1 {
412 (0..blockquote_count)
413 .map(|_| "> ")
414 .collect::<String>()
415 .trim_end()
416 .to_string()
417 } else {
418 ">".to_string()
419 };
420
421 let correct_indent = " ".repeat(expected_indent);
423 let replacement = format!("{blockquote_prefix} {correct_indent}");
424
425 (start_byte..end_byte, replacement)
426 } else {
427 let fix_range = if actual_indent > 0 {
429 let start_byte = ctx.line_offsets.get(line_num - 1).copied().unwrap_or(0);
430 let end_byte = start_byte + actual_indent;
431 start_byte..end_byte
432 } else {
433 let byte_pos = ctx.line_offsets.get(line_num - 1).copied().unwrap_or(0);
434 byte_pos..byte_pos
435 };
436
437 let replacement = if expected_indent > 0 {
438 " ".repeat(expected_indent)
439 } else {
440 String::new()
441 };
442
443 (fix_range, replacement)
444 };
445
446 LintWarning {
447 rule_name: Some(self.name().to_string()),
448 line: start_line,
449 column: start_col,
450 end_line,
451 end_column: end_col,
452 message,
453 severity: Severity::Warning,
454 fix: Some(Fix::new(fix_range, replacement)),
455 }
456 }
457
458 fn check_indent_consistency(
461 &self,
462 ctx: &crate::lint_context::LintContext,
463 items: &[(usize, usize, &crate::lint_context::LineInfo)],
464 warnings: &mut Vec<(LintWarning, isize)>,
465 ) {
466 if items.len() < 2 {
467 return;
468 }
469
470 let mut sorted_items: Vec<_> = items.iter().collect();
472 sorted_items.sort_by_key(|(line_num, _, _)| *line_num);
473
474 let indents: std::collections::HashSet<usize> = sorted_items.iter().map(|(_, indent, _)| *indent).collect();
475
476 if indents.len() > 1 {
477 let expected_indent = sorted_items.first().map_or(0, |(_, i, _)| *i);
480
481 for (line_num, indent, line_info) in items {
482 if *indent != expected_indent {
483 warnings.push((
484 self.create_indent_warning(ctx, *line_num, line_info, *indent, expected_indent),
485 expected_indent as isize - *indent as isize,
486 ));
487 }
488 }
489 }
490 }
491
492 fn group_by_parent_content_column<'a>(
499 &self,
500 level: usize,
501 group: &[(usize, usize, &'a crate::lint_context::LineInfo)],
502 all_list_items: &[(usize, usize, &crate::lint_context::LineInfo, ParsedListItem<'_>)],
503 level_map: &HashMap<usize, usize>,
504 ) -> ParentContentGroups<'a> {
505 let parent_level = level - 1;
506
507 let is_ordered_map: HashMap<usize, bool> = all_list_items
509 .iter()
510 .map(|(ln, _, _, item)| (*ln, item.is_ordered()))
511 .collect();
512
513 let parent_items: Vec<(usize, usize)> = all_list_items
515 .iter()
516 .filter(|(ln, _, _, _)| level_map.get(ln) == Some(&parent_level))
517 .map(|(ln, _, _, item)| (*ln, item.content_column()))
518 .collect();
519
520 let mut parent_content_groups: ParentContentGroups<'a> = HashMap::new();
521
522 for (line_num, indent, line_info) in group {
523 let item_is_ordered = is_ordered_map.get(line_num).copied().unwrap_or(false);
524
525 let idx = parent_items.partition_point(|&(ln, _)| ln < *line_num);
527 let parent_content_col = if idx > 0 { Some(parent_items[idx - 1].1) } else { None };
528
529 if let Some(parent_col) = parent_content_col {
530 parent_content_groups
531 .entry((parent_col, item_is_ordered))
532 .or_default()
533 .push((*line_num, *indent, *line_info));
534 }
535 }
536
537 parent_content_groups
538 }
539
540 fn group_related_list_blocks<'a>(&self, list_blocks: ParsedListBlocks<'a>) -> Vec<Vec<ParsedListBlock<'a>>> {
542 let mut blocks = list_blocks.into_iter();
543 let Some(first_block) = blocks.next() else {
544 return Vec::new();
545 };
546
547 let mut groups = Vec::new();
548 let mut current_group = vec![first_block];
549 let mut prev_block = first_block;
550
551 for current_block in blocks {
552 let line_gap = current_block.start_line().saturating_sub(prev_block.end_line());
554
555 let separate_lists = current_block.is_ordered() != prev_block.is_ordered()
559 && current_block.nesting_level() == prev_block.nesting_level();
560 let same_quote = current_block.blockquote_prefix().matches('>').count()
563 == prev_block.blockquote_prefix().matches('>').count();
564 if same_quote && !separate_lists && 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 native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
905 (self.top_level_indent == 0).then_some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(5))
906 }
907
908 fn name(&self) -> &'static str {
909 "MD005"
910 }
911
912 fn description(&self) -> &'static str {
913 "List indentation should be consistent"
914 }
915
916 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
917 let mut warnings = self.check_optimized(ctx);
918 protect_literal_code(ctx, &mut warnings);
919 drop_structure_changing_fixes(ctx, &mut warnings, Allowed::Nothing);
920 Ok(warnings)
921 }
922
923 fn fixes_from_diagnostics(&self) -> bool {
924 true
925 }
926
927 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
928 let warnings = self.check(ctx)?;
929 let warnings =
930 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
931 if warnings.is_empty() {
932 return Ok(ctx.content.to_string());
933 }
934
935 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings).map_err(LintError::InvalidInput)
936 }
937
938 fn category(&self) -> RuleCategory {
939 RuleCategory::List
940 }
941
942 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
944 ctx.content.is_empty() || !ctx.has_list_items()
946 }
947
948 fn as_any(&self) -> &dyn std::any::Any {
949 self
950 }
951
952 fn default_config_section(&self) -> Option<(String, toml::Value)> {
953 None
954 }
955
956 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
957 where
958 Self: Sized,
959 {
960 let mut top_level_indent = 0;
962
963 if let Some(md007_config) = config.rules.get("MD007") {
965 if let Some(start_indented) = md007_config.values.get("start-indented")
967 && let Some(start_indented_bool) = start_indented.as_bool()
968 && start_indented_bool
969 {
970 if let Some(start_indent) = md007_config.values.get("start-indent") {
972 if let Some(indent_value) = start_indent.as_integer() {
973 top_level_indent = indent_value as usize;
974 }
975 } else {
976 top_level_indent = 2;
978 }
979 }
980 }
981
982 Box::new(MD005ListIndent { top_level_indent })
983 }
984}
985
986#[cfg(test)]
987mod tests {
988 use super::*;
989 use crate::lint_context::LintContext;
990
991 #[test]
992 fn test_valid_unordered_list() {
993 let rule = MD005ListIndent::default();
994 let content = "\
995* Item 1
996* Item 2
997 * Nested 1
998 * Nested 2
999* Item 3";
1000 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1001 let result = rule.check(&ctx).unwrap();
1002 assert!(result.is_empty());
1003 }
1004
1005 #[test]
1006 fn test_valid_ordered_list() {
1007 let rule = MD005ListIndent::default();
1008 let content = "\
10091. Item 1
10102. Item 2
1011 1. Nested 1
1012 2. Nested 2
10133. Item 3";
1014 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1015 let result = rule.check(&ctx).unwrap();
1016 assert!(result.is_empty());
1019 }
1020
1021 #[test]
1022 fn test_invalid_unordered_indent() {
1023 let rule = MD005ListIndent::default();
1024 let content = "\
1025* Item 1
1026 * Item 2
1027 * Nested 1";
1028 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1029 let result = rule.check(&ctx).unwrap();
1030 assert_eq!(result.len(), 1);
1033 let fixed = rule.fix(&ctx).unwrap();
1034 assert_eq!(fixed, "* Item 1\n* Item 2\n * Nested 1");
1035 }
1036
1037 #[test]
1038 fn test_invalid_ordered_indent() {
1039 let rule = MD005ListIndent::default();
1040 let content = "\
10411. Item 1
1042 2. Item 2
1043 1. Nested 1";
1044 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1045 let result = rule.check(&ctx).unwrap();
1046 assert_eq!(result.len(), 1);
1047 let fixed = rule.fix(&ctx).unwrap();
1048 assert_eq!(fixed, "1. Item 1\n2. Item 2\n 1. Nested 1");
1052 }
1053
1054 #[test]
1055 fn test_mixed_list_types() {
1056 let rule = MD005ListIndent::default();
1057 let content = "\
1058* Item 1
1059 1. Nested ordered
1060 * Nested unordered
1061* Item 2";
1062 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1063 let result = rule.check(&ctx).unwrap();
1064 assert!(result.is_empty());
1065 }
1066
1067 #[test]
1068 fn test_adjacent_ordered_and_bullet_lists_issue_970() {
1069 let rule = MD005ListIndent::default();
1070 for indent in [3, 4] {
1071 for marker in ["1.", "-"] {
1072 for deeper in ["", " - five\n"] {
1073 let ordered = format!("1. one\n2. two\n{}{marker} a\n", " ".repeat(indent));
1074 let bullets = format!("- one\n - three\n - four\n{deeper}");
1075 for (first, second) in [(&ordered, &bullets), (&bullets, &ordered)] {
1076 for gap in ["", "\n"] {
1077 let content = format!("{first}{gap}{second}");
1078 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1079 let result = rule.check(&ctx).unwrap();
1080 assert!(result.is_empty(), "{content}\nWarnings: {result:?}");
1081 assert_eq!(rule.fix(&ctx).unwrap(), content);
1082 }
1083 }
1084 }
1085 }
1086 }
1087 }
1088
1089 #[test]
1090 fn test_inconsistent_indent_after_list_type_change() {
1091 let rule = MD005ListIndent::default();
1092 let content = "1. one\n2. two\n 1. a\n\n- one\n - three\n - four\n";
1093 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1094 let result = rule.check(&ctx).unwrap();
1095 assert_eq!(result.len(), 1, "{result:?}");
1096 assert_eq!(result[0].line, 7);
1097 assert_eq!(result[0].message, "Expected indentation of 4 spaces, found 3");
1098 }
1099
1100 #[test]
1101 fn test_multiple_levels() {
1102 let rule = MD005ListIndent::default();
1103 let content = "\
1104* Level 1
1105 * Level 2
1106 * Level 3";
1107 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1108 let result = rule.check(&ctx).unwrap();
1109 assert!(result.is_empty(), "MD005 should accept consistent indentation pattern");
1111 }
1112
1113 #[test]
1114 fn test_empty_lines() {
1115 let rule = MD005ListIndent::default();
1116 let content = "\
1117* Item 1
1118
1119 * Nested 1
1120
1121* Item 2";
1122 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1123 let result = rule.check(&ctx).unwrap();
1124 assert!(result.is_empty());
1125 }
1126
1127 #[test]
1128 fn test_no_lists() {
1129 let rule = MD005ListIndent::default();
1130 let content = "\
1131Just some text
1132More text
1133Even more text";
1134 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1135 let result = rule.check(&ctx).unwrap();
1136 assert!(result.is_empty());
1137 }
1138
1139 #[test]
1140 fn test_complex_nesting() {
1141 let rule = MD005ListIndent::default();
1142 let content = "\
1143* Level 1
1144 * Level 2
1145 * Level 3
1146 * Back to 2
1147 1. Ordered 3
1148 2. Still 3
1149* Back to 1";
1150 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1151 let result = rule.check(&ctx).unwrap();
1152 assert!(result.is_empty());
1153 }
1154
1155 #[test]
1156 fn test_invalid_complex_nesting() {
1157 let rule = MD005ListIndent::default();
1158 let content = "\
1159* Level 1
1160 * Level 2
1161 * Level 3
1162 * Back to 2
1163 1. Ordered 3
1164 2. Still 3
1165* Back to 1";
1166 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1167 let result = rule.check(&ctx).unwrap();
1168 assert_eq!(result.len(), 1);
1170 assert!(
1171 result[0].message.contains("Expected indentation of 5 spaces, found 6")
1172 || result[0].message.contains("Expected indentation of 6 spaces, found 5")
1173 );
1174 }
1175
1176 #[test]
1177 fn test_with_lint_context() {
1178 let rule = MD005ListIndent::default();
1179
1180 let content = "* Item 1\n* Item 2\n * Nested item\n * Another nested item";
1182 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1183 let result = rule.check(&ctx).unwrap();
1184 assert!(result.is_empty());
1185
1186 let content = "* Item 1\n* Item 2\n * Nested item\n * Another nested item";
1188 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1189 let result = rule.check(&ctx).unwrap();
1190 assert!(!result.is_empty()); let content = "* Item 1\n * Nested item\n * Another nested item with wrong indent";
1194 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1195 let result = rule.check(&ctx).unwrap();
1196 assert!(!result.is_empty()); }
1198
1199 #[test]
1201 fn test_list_with_continuations() {
1202 let rule = MD005ListIndent::default();
1203 let content = "\
1204* Item 1
1205 This is a continuation
1206 of the first item
1207 * Nested item
1208 with its own continuation
1209* Item 2";
1210 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1211 let result = rule.check(&ctx).unwrap();
1212 assert!(result.is_empty());
1213 }
1214
1215 #[test]
1216 fn test_list_after_a_quoted_list_is_judged_on_its_own() {
1217 let rule = MD005ListIndent::default();
1220 let content = "> * item 49\n + item 50\n + item 52\n";
1221 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1222 let lines: Vec<usize> = rule.check(&ctx).unwrap().iter().map(|w| w.line).collect();
1223 assert_eq!(lines, vec![1, 2]);
1224 let fixed = rule.fix(&ctx).unwrap();
1225 assert_eq!(fixed, "> * item 49\n+ item 50\n + item 52\n");
1226 let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1227 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
1228 }
1229
1230 #[test]
1231 fn test_list_in_blockquote() {
1232 let rule = MD005ListIndent::default();
1233 let content = "\
1234> * Item 1
1235> * Nested 1
1236> * Nested 2
1237> * Item 2";
1238 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1239 let result = rule.check(&ctx).unwrap();
1240
1241 assert!(
1243 result.is_empty(),
1244 "Expected no warnings for correctly indented blockquote list, got: {result:?}"
1245 );
1246 }
1247
1248 #[test]
1249 fn test_list_with_code_blocks() {
1250 let rule = MD005ListIndent::default();
1251 let content = "\
1252* Item 1
1253 ```
1254 code block
1255 ```
1256 * Nested item
1257* Item 2";
1258 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1259 let result = rule.check(&ctx).unwrap();
1260 assert!(result.is_empty());
1261 }
1262
1263 #[test]
1264 fn test_list_with_tabs() {
1265 let rule = MD005ListIndent::default();
1266 let content = "* Item 1\n * Wrong indent (3 spaces)\n * Correct indent (2 spaces)";
1270 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1271 let result = rule.check(&ctx).unwrap();
1272 assert!(!result.is_empty());
1274 }
1275
1276 #[test]
1277 fn test_inconsistent_at_same_level() {
1278 let rule = MD005ListIndent::default();
1279 let content = "\
1280* Item 1
1281 * Nested 1
1282 * Nested 2
1283 * Wrong indent for same level
1284 * Nested 3";
1285 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1286 let result = rule.check(&ctx).unwrap();
1287 assert!(!result.is_empty());
1288 assert!(result.iter().any(|w| w.line == 4));
1290 }
1291
1292 #[test]
1293 fn test_zero_indent_top_level() {
1294 let rule = MD005ListIndent::default();
1295 let content = concat!(" * Wrong indent\n", "* Correct\n", " * Nested");
1297 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1298 let result = rule.check(&ctx).unwrap();
1299
1300 assert!(!result.is_empty());
1302 assert!(result.iter().any(|w| w.line == 1));
1303 }
1304
1305 #[test]
1306 fn test_fix_preserves_content() {
1307 let rule = MD005ListIndent::default();
1308 let content = "\
1309* Item with **bold** and *italic*
1310 * Wrong indent with `code`
1311 * Also wrong with [link](url)";
1312 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1313 let fixed = rule.fix(&ctx).unwrap();
1314 assert!(fixed.contains("**bold**"));
1315 assert!(fixed.contains("*italic*"));
1316 assert!(fixed.contains("`code`"));
1317 assert!(fixed.contains("[link](url)"));
1318 }
1319
1320 #[test]
1321 fn test_deeply_nested_lists() {
1322 let rule = MD005ListIndent::default();
1323 let content = "\
1324* L1
1325 * L2
1326 * L3
1327 * L4
1328 * L5
1329 * L6";
1330 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1331 let result = rule.check(&ctx).unwrap();
1332 assert!(result.is_empty());
1333 }
1334
1335 #[test]
1336 fn test_fix_multiple_issues() {
1337 let rule = MD005ListIndent::default();
1338 let content = "\
1339* Item 1
1340 * Wrong 1
1341 * Wrong 2
1342 * Wrong 3
1343 * Correct";
1344 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1345 let fixed = rule.fix(&ctx).unwrap();
1346 assert_eq!(fixed, "* Item 1\n* Wrong 1\n * Wrong 2\n * Wrong 3\n* Correct");
1347
1348 let content = format!("{content}\n * Wrong 4");
1352 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1353 assert!(!rule.check(&ctx).unwrap().is_empty());
1354 assert_eq!(rule.fix(&ctx).unwrap(), content);
1355 }
1356
1357 #[test]
1358 fn test_performance_large_document() {
1359 let rule = MD005ListIndent::default();
1360 let mut content = String::new();
1361 for i in 0..100 {
1362 content.push_str(&format!("* Item {i}\n"));
1363 content.push_str(&format!(" * Nested {i}\n"));
1364 }
1365 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1366 let result = rule.check(&ctx).unwrap();
1367 assert!(result.is_empty());
1368 }
1369
1370 #[test]
1371 fn test_column_positions() {
1372 let rule = MD005ListIndent::default();
1373 let content = " * Wrong indent";
1374 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1375 let result = rule.check(&ctx).unwrap();
1376 assert_eq!(result.len(), 1);
1377 assert_eq!(result[0].column, 1, "Expected column 1, got {}", result[0].column);
1378 assert_eq!(
1379 result[0].end_column, 2,
1380 "Expected end_column 2, got {}",
1381 result[0].end_column
1382 );
1383 }
1384
1385 #[test]
1386 fn test_should_skip() {
1387 let rule = MD005ListIndent::default();
1388
1389 let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard, None);
1391 assert!(rule.should_skip(&ctx));
1392
1393 let ctx = LintContext::new("Just plain text", crate::config::MarkdownFlavor::Standard, None);
1395 assert!(rule.should_skip(&ctx));
1396
1397 let ctx = LintContext::new("* List item", crate::config::MarkdownFlavor::Standard, None);
1399 assert!(!rule.should_skip(&ctx));
1400
1401 let ctx = LintContext::new("1. Ordered list", crate::config::MarkdownFlavor::Standard, None);
1402 assert!(!rule.should_skip(&ctx));
1403 }
1404
1405 #[test]
1406 fn test_should_skip_validation() {
1407 let rule = MD005ListIndent::default();
1408 let content = "* List item";
1409 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1410 assert!(!rule.should_skip(&ctx));
1411
1412 let content = "No lists here";
1413 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1414 assert!(rule.should_skip(&ctx));
1415 }
1416
1417 #[test]
1418 fn test_edge_case_single_space_indent() {
1419 let rule = MD005ListIndent::default();
1420 let content = "\
1421* Item 1
1422 * Single space - wrong
1423 * Two spaces - correct";
1424 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1425 let result = rule.check(&ctx).unwrap();
1426 assert_eq!(result.len(), 2);
1429 assert!(result.iter().any(|w| w.line == 2 && w.message.contains("found 1")));
1430 }
1431
1432 #[test]
1433 fn test_edge_case_three_space_indent() {
1434 let rule = MD005ListIndent::default();
1435 let content = "\
1436* Item 1
1437 * Three spaces - first establishes pattern
1438 * Two spaces - inconsistent with established pattern";
1439 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1440 let result = rule.check(&ctx).unwrap();
1441 assert_eq!(result.len(), 1);
1445 assert!(result.iter().any(|w| w.line == 3 && w.message.contains("found 2")));
1446 }
1447
1448 #[test]
1449 fn test_nested_bullets_under_numbered_items() {
1450 let rule = MD005ListIndent::default();
1451 let content = "\
14521. **Active Directory/LDAP**
1453 - User authentication and directory services
1454 - LDAP for user information and validation
1455
14562. **Oracle Unified Directory (OUD)**
1457 - Extended user directory services
1458 - Verification of project account presence and changes";
1459 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1460 let result = rule.check(&ctx).unwrap();
1461 assert!(
1463 result.is_empty(),
1464 "Expected no warnings for bullets with 3 spaces under numbered items, got: {result:?}"
1465 );
1466 }
1467
1468 #[test]
1469 fn test_nested_bullets_under_numbered_items_wrong_indent() {
1470 let rule = MD005ListIndent::default();
1471 let content = "\
14721. **Active Directory/LDAP**
1473 - Wrong: only 2 spaces
1474 - Correct: 3 spaces";
1475 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1476 let result = rule.check(&ctx).unwrap();
1477 assert_eq!(
1479 result.len(),
1480 1,
1481 "Expected 1 warning, got {}. Warnings: {:?}",
1482 result.len(),
1483 result
1484 );
1485 assert!(
1487 result
1488 .iter()
1489 .any(|w| (w.line == 2 && w.message.contains("found 2"))
1490 || (w.line == 3 && w.message.contains("found 3")))
1491 );
1492 }
1493
1494 #[test]
1495 fn test_regular_nested_bullets_still_work() {
1496 let rule = MD005ListIndent::default();
1497 let content = "\
1498* Top level
1499 * Second level (2 spaces is correct for bullets under bullets)
1500 * Third level (4 spaces)";
1501 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1502 let result = rule.check(&ctx).unwrap();
1503 assert!(
1505 result.is_empty(),
1506 "Expected no warnings for regular bullet nesting, got: {result:?}"
1507 );
1508 }
1509
1510 #[test]
1511 fn test_fix_range_accuracy() {
1512 let rule = MD005ListIndent::default();
1513 let content = " * Wrong indent";
1514 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1515 let result = rule.check(&ctx).unwrap();
1516 assert_eq!(result.len(), 1);
1517
1518 let fix = result[0].fix.as_ref().unwrap();
1519 assert_eq!(fix.replacement, "");
1521 }
1522
1523 #[test]
1524 fn test_four_space_indent_pattern() {
1525 let rule = MD005ListIndent::default();
1526 let content = "\
1527* Item 1
1528 * Item 2 with 4 spaces
1529 * Item 3 with 8 spaces
1530 * Item 4 with 4 spaces";
1531 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1532 let result = rule.check(&ctx).unwrap();
1533 assert!(
1535 result.is_empty(),
1536 "MD005 should accept consistent 4-space indentation pattern, got {} warnings",
1537 result.len()
1538 );
1539 }
1540
1541 #[test]
1542 fn test_issue_64_scenario() {
1543 let rule = MD005ListIndent::default();
1545 let content = "\
1546* Top level item
1547 * Sub item with 4 spaces (as configured in MD007)
1548 * Nested sub item with 8 spaces
1549 * Another sub item with 4 spaces
1550* Another top level";
1551
1552 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1553 let result = rule.check(&ctx).unwrap();
1554
1555 assert!(
1557 result.is_empty(),
1558 "MD005 should accept 4-space indentation when that's the pattern being used. Got {} warnings",
1559 result.len()
1560 );
1561 }
1562
1563 #[test]
1564 fn test_continuation_content_scenario() {
1565 let rule = MD005ListIndent::default();
1566 let content = "\
1567- **Changes to how the Python version is inferred** ([#16319](example))
1568
1569 In previous versions of Ruff, you could specify your Python version with:
1570
1571 - The `target-version` option in a `ruff.toml` file
1572 - The `project.requires-python` field in a `pyproject.toml` file";
1573
1574 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1575
1576 let result = rule.check(&ctx).unwrap();
1577
1578 assert!(
1580 result.is_empty(),
1581 "MD005 should not flag continuation content lists, got {} warnings: {:?}",
1582 result.len(),
1583 result
1584 );
1585 }
1586
1587 #[test]
1588 fn test_multiple_continuation_lists_scenario() {
1589 let rule = MD005ListIndent::default();
1590 let content = "\
1591- **Changes to how the Python version is inferred** ([#16319](example))
1592
1593 In previous versions of Ruff, you could specify your Python version with:
1594
1595 - The `target-version` option in a `ruff.toml` file
1596 - The `project.requires-python` field in a `pyproject.toml` file
1597
1598 In v0.10, config discovery has been updated to address this issue:
1599
1600 - If Ruff finds a `ruff.toml` file without a `target-version`, it will check
1601 - If Ruff finds a user-level configuration, the `requires-python` field will take precedence
1602 - If there is no config file, Ruff will search for the closest `pyproject.toml`";
1603
1604 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1605
1606 let result = rule.check(&ctx).unwrap();
1607
1608 assert!(
1610 result.is_empty(),
1611 "MD005 should not flag continuation content lists, got {} warnings: {:?}",
1612 result.len(),
1613 result
1614 );
1615 }
1616
1617 #[test]
1618 fn test_issue_115_sublist_after_code_block() {
1619 let rule = MD005ListIndent::default();
1620 let content = "\
16211. List item 1
1622
1623 ```rust
1624 fn foo() {}
1625 ```
1626
1627 Sublist:
1628
1629 - A
1630 - B
1631";
1632 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1633 let result = rule.check(&ctx).unwrap();
1634 assert!(
1638 result.is_empty(),
1639 "Expected no warnings for sub-list after code block in list item, got {} warnings: {:?}",
1640 result.len(),
1641 result
1642 );
1643 }
1644
1645 #[test]
1646 fn test_edge_case_continuation_at_exact_boundary() {
1647 let rule = MD005ListIndent::default();
1648 let content = "\
1650* Item (content at column 2)
1651 Text at column 2 (exact boundary - continuation)
1652 * Sub at column 2";
1653 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1654 let result = rule.check(&ctx).unwrap();
1655 assert!(
1657 result.is_empty(),
1658 "Expected no warnings when text and sub-list are at exact parent content_column, got: {result:?}"
1659 );
1660 }
1661
1662 #[test]
1663 fn test_edge_case_unicode_in_continuation() {
1664 let rule = MD005ListIndent::default();
1665 let content = "\
1666* Parent
1667 Text with emoji 😀 and Unicode ñ characters
1668 * Sub-list should still work";
1669 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1670 let result = rule.check(&ctx).unwrap();
1671 assert!(
1673 result.is_empty(),
1674 "Expected no warnings with Unicode in continuation content, got: {result:?}"
1675 );
1676 }
1677
1678 #[test]
1679 fn test_edge_case_large_empty_line_gap() {
1680 let rule = MD005ListIndent::default();
1681 let content = "\
1682* Parent at line 1
1683 Continuation text
1684
1685
1686
1687 More continuation after many empty lines
1688
1689 * Child after gap
1690 * Another child";
1691 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1692 let result = rule.check(&ctx).unwrap();
1693 assert!(
1695 result.is_empty(),
1696 "Expected no warnings with large gaps in continuation content, got: {result:?}"
1697 );
1698 }
1699
1700 #[test]
1701 fn consecutive_blank_continuations_preserve_line_endings_and_quotes() {
1702 let rule = MD005ListIndent::default();
1703 for content in ["* a\n b\n\n\n\n c\n", "> * a\n> b\n>\n>\n>\n> c\n"] {
1704 for ending in ["\n", "\r\n"] {
1705 let source = content.replace('\n', ending);
1706 let ctx = LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
1707 assert!(rule.check(&ctx).unwrap().is_empty(), "{source:?}");
1708 }
1709 }
1710 }
1711
1712 #[test]
1713 fn test_edge_case_multiple_continuation_blocks_varying_indent() {
1714 let rule = MD005ListIndent::default();
1715 let content = "\
1716* Parent (content at column 2)
1717 First paragraph at column 2
1718 Indented quote at column 4
1719 Back to column 2
1720 * Sub-list at column 2";
1721 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1722 let result = rule.check(&ctx).unwrap();
1723 assert!(
1725 result.is_empty(),
1726 "Expected no warnings with varying continuation indent, got: {result:?}"
1727 );
1728 }
1729
1730 #[test]
1731 fn test_edge_case_deep_nesting_no_continuation() {
1732 let rule = MD005ListIndent::default();
1733 let content = "\
1734* Parent
1735 * Immediate child (no continuation text before)
1736 * Grandchild
1737 * Great-grandchild
1738 * Great-great-grandchild
1739 * Another child at level 2";
1740 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1741 let result = rule.check(&ctx).unwrap();
1742 assert!(
1744 result.is_empty(),
1745 "Expected no warnings for deep nesting without continuation, got: {result:?}"
1746 );
1747 }
1748
1749 #[test]
1750 fn test_edge_case_blockquote_continuation_content() {
1751 let rule = MD005ListIndent::default();
1752 let content = "\
1753> * Parent in blockquote
1754> Continuation in blockquote
1755> * Sub-list in blockquote
1756> * Another sub-list";
1757 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1758 let result = rule.check(&ctx).unwrap();
1759 assert!(
1761 result.is_empty(),
1762 "Expected no warnings for blockquote continuation, got: {result:?}"
1763 );
1764 }
1765
1766 #[test]
1767 fn test_edge_case_one_space_less_than_content_column() {
1768 let rule = MD005ListIndent::default();
1769 let content = "\
1770* Parent (content at column 2)
1771 Text at column 1 (one less than content_column - NOT continuation)
1772 * Child";
1773 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1774 let result = rule.check(&ctx).unwrap();
1775 assert!(
1781 result.is_empty() || !result.is_empty(),
1782 "Test should complete without panic"
1783 );
1784 }
1785
1786 #[test]
1787 fn test_edge_case_multiple_code_blocks_different_indentation() {
1788 let rule = MD005ListIndent::default();
1789 let content = "\
1790* Parent
1791 ```
1792 code at 2 spaces
1793 ```
1794 ```
1795 code at 4 spaces
1796 ```
1797 * Sub-list should not be confused";
1798 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1799 let result = rule.check(&ctx).unwrap();
1800 assert!(
1802 result.is_empty(),
1803 "Expected no warnings with multiple code blocks, got: {result:?}"
1804 );
1805 }
1806
1807 #[test]
1808 fn test_performance_very_large_document() {
1809 let rule = MD005ListIndent::default();
1810 let mut content = String::new();
1811
1812 for i in 0..1000 {
1814 content.push_str(&format!("* Item {i}\n"));
1815 content.push_str(&format!(" * Nested {i}\n"));
1816 if i % 10 == 0 {
1817 content.push_str(" Some continuation text\n");
1818 }
1819 }
1820
1821 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1822
1823 let start = std::time::Instant::now();
1825 let result = rule.check(&ctx).unwrap();
1826 let elapsed = start.elapsed();
1827
1828 assert!(result.is_empty());
1829 println!("Processed 1000 list items in {elapsed:?}");
1830 assert!(
1833 elapsed.as_secs() < 1,
1834 "Should complete in under 1 second, took {elapsed:?}"
1835 );
1836 }
1837
1838 #[test]
1839 fn test_ordered_list_variable_marker_width() {
1840 let rule = MD005ListIndent::default();
1845 let content = "\
18461. One
1847 - One
1848 - Two
18492. Two
1850 - One
18513. Three
1852 - One
18534. Four
1854 - One
18555. Five
1856 - One
18576. Six
1858 - One
18597. Seven
1860 - One
18618. Eight
1862 - One
18639. Nine
1864 - One
186510. Ten
1866 - One";
1867 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1868 let result = rule.check(&ctx).unwrap();
1869 assert!(
1870 result.is_empty(),
1871 "Expected no warnings for ordered list with variable marker widths, got: {result:?}"
1872 );
1873 }
1874
1875 #[test]
1876 fn test_ordered_list_inconsistent_siblings() {
1877 let rule = MD005ListIndent::default();
1879 let content = "\
18801. Item one
1881 - First sublist at 3 spaces
1882 - Second sublist at 2 spaces (inconsistent)
1883 - Third sublist at 3 spaces";
1884 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1885 let result = rule.check(&ctx).unwrap();
1886 assert_eq!(
1888 result.len(),
1889 1,
1890 "Expected 1 warning for inconsistent sibling indent, got: {result:?}"
1891 );
1892 assert!(result[0].message.contains("Expected indentation of 3"));
1893 }
1894
1895 #[test]
1896 fn test_ordered_list_single_sublist_no_warning() {
1897 let rule = MD005ListIndent::default();
1900 let content = "\
190110. Item ten
1902 - Only sublist at 3 spaces";
1903 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1904 let result = rule.check(&ctx).unwrap();
1905 assert!(
1907 result.is_empty(),
1908 "Expected no warnings for single sublist item, got: {result:?}"
1909 );
1910 }
1911
1912 #[test]
1913 fn test_sublists_grouped_by_parent_content_column() {
1914 let rule = MD005ListIndent::default();
1918 let content = "\
19199. Item nine
1920 - First sublist at 3 spaces
1921 - Second sublist at 3 spaces
1922 - Third sublist at 3 spaces
192310. Item ten
1924 - First sublist at 4 spaces
1925 - Second sublist at 4 spaces
1926 - Third sublist at 4 spaces";
1927 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1928 let result = rule.check(&ctx).unwrap();
1929 assert!(
1932 result.is_empty(),
1933 "Expected no warnings for sublists grouped by parent, got: {result:?}"
1934 );
1935 }
1936
1937 #[test]
1938 fn test_inconsistent_indent_within_parent_group() {
1939 let rule = MD005ListIndent::default();
1941 let content = "\
194210. Item ten
1943 - First sublist at 4 spaces
1944 - Second sublist at 3 spaces (inconsistent!)
1945 - Third sublist at 4 spaces";
1946 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1947 let result = rule.check(&ctx).unwrap();
1948 assert_eq!(
1950 result.len(),
1951 1,
1952 "Expected 1 warning for inconsistent indent within parent group, got: {result:?}"
1953 );
1954 assert!(result[0].line == 3);
1955 assert!(result[0].message.contains("Expected indentation of 4"));
1956 }
1957
1958 #[test]
1959 fn test_blockquote_nested_list_fix_preserves_blockquote_prefix() {
1960 use crate::rule::Rule;
1964
1965 let rule = MD005ListIndent::default();
1966 let content = "> * Federation sender blacklists are now persisted.";
1967 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1968 let result = rule.check(&ctx).unwrap();
1969
1970 assert_eq!(result.len(), 1, "Expected 1 warning for extra indent");
1971
1972 assert!(result[0].fix.is_some(), "Should have a fix");
1974 let fixed = rule.fix(&ctx).expect("Fix should succeed");
1975
1976 assert!(
1978 fixed.starts_with("> "),
1979 "Fixed content should start with blockquote prefix '> ', got: {fixed:?}"
1980 );
1981 assert!(
1982 !fixed.starts_with("* "),
1983 "Fixed content should NOT start with just '* ' (blockquote removed), got: {fixed:?}"
1984 );
1985 assert_eq!(
1986 fixed.trim(),
1987 "> * Federation sender blacklists are now persisted.",
1988 "Fixed content should be '> * Federation sender...' with single space after >"
1989 );
1990 }
1991
1992 #[test]
1993 fn test_nested_blockquote_list_fix_preserves_prefix() {
1994 use crate::rule::Rule;
1996
1997 let rule = MD005ListIndent::default();
1998 let content = ">> * Nested blockquote list item";
1999 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2000 let result = rule.check(&ctx).unwrap();
2001
2002 assert_eq!(result.len(), 1, "Fixture must trigger an indentation warning");
2003 let fixed = rule.fix(&ctx).expect("Fix should succeed");
2004 assert_eq!(fixed, "> > * Nested blockquote list item");
2005 let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
2006 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
2007 }
2008}