1use crate::utils::blockquote::effective_indent_in_blockquote;
7use crate::utils::range_utils::calculate_match_range;
8
9use crate::lint_context::{ParsedListBlock, ParsedListBlocks, ParsedListItem};
10use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
11use std::collections::HashMap;
13use toml;
14
15type ParentContentGroups<'a> = HashMap<(usize, bool), Vec<(usize, usize, &'a crate::lint_context::LineInfo)>>;
18
19#[derive(Clone, Default)]
21pub struct MD005ListIndent {
22 top_level_indent: usize,
24}
25
26struct LineCacheInfo {
28 indentation: Vec<usize>,
30 blockquote_levels: Vec<usize>,
32 line_contents: Vec<String>,
34 flags: Vec<u8>,
36 parent_map: HashMap<usize, usize>,
39}
40
41const FLAG_HAS_CONTENT: u8 = 1;
42const FLAG_IS_LIST_ITEM: u8 = 2;
43
44impl LineCacheInfo {
45 fn new(ctx: &crate::lint_context::LintContext) -> Self {
47 let total_lines = ctx.lines.len();
48 let mut indentation = Vec::with_capacity(total_lines);
49 let mut blockquote_levels = Vec::with_capacity(total_lines);
50 let mut line_contents = Vec::with_capacity(total_lines);
51 let mut flags = Vec::with_capacity(total_lines);
52 let mut parent_map = HashMap::new();
53
54 let mut indent_stack: Vec<(usize, usize)> = Vec::new();
66
67 for (idx, line_info) in ctx.lines.iter().enumerate() {
68 let line_content = line_info.content(ctx.content);
69 let content = line_content.trim_start();
70 let line_indent = line_info.byte_len - content.len();
71
72 indentation.push(line_indent);
73
74 let bq_level = line_info.blockquote.as_ref().map_or(0, |bq| bq.nesting_level);
76 blockquote_levels.push(bq_level);
77
78 line_contents.push(line_content.to_string());
80
81 let mut flag = 0u8;
82 if !content.is_empty() {
83 flag |= FLAG_HAS_CONTENT;
84 }
85 if let Some(list_item) = ctx.list_item_on_line(idx + 1) {
86 flag |= FLAG_IS_LIST_ITEM;
87
88 let line_num = idx + 1; let marker_column = list_item.marker_column();
90
91 while let Some(&(indent, _)) = indent_stack.last() {
93 if indent < marker_column {
94 break;
95 }
96 indent_stack.pop();
97 }
98
99 if let Some((_, parent_line)) = indent_stack.last() {
100 parent_map.insert(line_num, *parent_line);
101 }
102
103 indent_stack.push((marker_column, line_num));
104 }
105 flags.push(flag);
106 }
107
108 Self {
109 indentation,
110 blockquote_levels,
111 line_contents,
112 flags,
113 parent_map,
114 }
115 }
116
117 fn has_content(&self, idx: usize) -> bool {
119 self.flags.get(idx).is_some_and(|&f| f & FLAG_HAS_CONTENT != 0)
120 }
121
122 fn is_list_item(&self, idx: usize) -> bool {
124 self.flags.get(idx).is_some_and(|&f| f & FLAG_IS_LIST_ITEM != 0)
125 }
126
127 fn blockquote_info(&self, line: usize) -> (usize, usize) {
129 if line == 0 || line > self.line_contents.len() {
130 return (0, 0);
131 }
132 let idx = line - 1;
133 let bq_level = self.blockquote_levels.get(idx).copied().unwrap_or(0);
134 if bq_level == 0 {
135 return (0, 0);
136 }
137 let content = &self.line_contents[idx];
139 let mut prefix_len = 0;
140 let mut found = 0;
141 for c in content.chars() {
142 prefix_len += c.len_utf8();
143 if c == '>' {
144 found += 1;
145 if found == bq_level {
146 if content.get(prefix_len..prefix_len + 1) == Some(" ") {
148 prefix_len += 1;
149 }
150 break;
151 }
152 }
153 }
154 (bq_level, prefix_len)
155 }
156
157 fn find_continuation_indent(
173 &self,
174 start_line: usize,
175 end_line: usize,
176 tight_threshold: usize,
177 loose_threshold: usize,
178 parent_bq_level: usize,
179 parent_bq_prefix_len: usize,
180 ) -> Option<usize> {
181 if start_line == 0 || start_line > end_line || end_line > self.indentation.len() {
182 return None;
183 }
184
185 let adjust = |t: usize| {
188 if parent_bq_level > 0 {
189 t.saturating_sub(parent_bq_prefix_len)
190 } else {
191 t
192 }
193 };
194 let tight = adjust(tight_threshold);
195 let loose = adjust(loose_threshold);
196
197 let start_idx = start_line - 1;
199 let end_idx = end_line - 1;
200 let mut seen_blank = false;
201
202 for idx in start_idx..=end_idx {
203 if !self.has_content(idx) {
204 seen_blank = true;
205 continue;
206 }
207 if self.is_list_item(idx) {
208 continue;
209 }
210
211 let line_bq_level = self.blockquote_levels.get(idx).copied().unwrap_or(0);
213 let raw_indent = self.indentation[idx];
214 let effective_indent = if line_bq_level == parent_bq_level && parent_bq_level > 0 {
215 effective_indent_in_blockquote(&self.line_contents[idx], parent_bq_level, raw_indent)
216 } else {
217 raw_indent
218 };
219
220 let threshold = if seen_blank { loose } else { tight };
221 if effective_indent >= threshold {
222 return Some(effective_indent);
223 }
224 if seen_blank {
227 return None;
228 }
229 }
230 None
231 }
232
233 fn has_continuation_content(
241 &self,
242 parent_line: usize,
243 current_line: usize,
244 tight_threshold: usize,
245 loose_threshold: usize,
246 parent_bq_level: usize,
247 parent_bq_prefix_len: usize,
248 ) -> bool {
249 if parent_line == 0 || current_line <= parent_line || current_line > self.indentation.len() {
250 return false;
251 }
252
253 let adjust = |t: usize| {
254 if parent_bq_level > 0 {
255 t.saturating_sub(parent_bq_prefix_len)
256 } else {
257 t
258 }
259 };
260 let tight = adjust(tight_threshold);
261 let loose = adjust(loose_threshold);
262
263 let start_idx = parent_line; let end_idx = current_line - 2; if start_idx > end_idx {
268 return false;
269 }
270
271 let mut seen_blank = false;
272 for idx in start_idx..=end_idx {
273 if !self.has_content(idx) {
274 seen_blank = true;
275 continue;
276 }
277 if self.is_list_item(idx) {
278 continue;
279 }
280
281 let line_bq_level = self.blockquote_levels.get(idx).copied().unwrap_or(0);
282 let raw_indent = self.indentation[idx];
283 let effective_indent = if line_bq_level == parent_bq_level && parent_bq_level > 0 {
284 effective_indent_in_blockquote(&self.line_contents[idx], parent_bq_level, raw_indent)
285 } else {
286 raw_indent
287 };
288
289 let threshold = if seen_blank { loose } else { tight };
290 if effective_indent >= threshold {
291 return true;
292 }
293 if seen_blank {
294 return false;
295 }
296 }
297 false
298 }
299}
300
301impl MD005ListIndent {
302 const LIST_GROUP_GAP_TOLERANCE: usize = 2;
306
307 const MIN_CHILD_INDENT_INCREASE: usize = 2;
310
311 const SAME_LEVEL_TOLERANCE: i32 = 1;
314
315 const STANDARD_CONTINUATION_OFFSET: usize = 2;
318
319 fn create_indent_warning(
321 &self,
322 ctx: &crate::lint_context::LintContext,
323 line_num: usize,
324 line_info: &crate::lint_context::LineInfo,
325 actual_indent: usize,
326 expected_indent: usize,
327 ) -> LintWarning {
328 let message = format!(
329 "Expected indentation of {} {}, found {}",
330 expected_indent,
331 if expected_indent == 1 { "space" } else { "spaces" },
332 actual_indent
333 );
334
335 let (start_line, start_col, end_line, end_col) = if actual_indent > 0 {
336 calculate_match_range(line_num, line_info.content(ctx.content), 0, actual_indent)
337 } else {
338 calculate_match_range(line_num, line_info.content(ctx.content), 0, 1)
339 };
340
341 let (fix_range, replacement) = if line_info.blockquote.is_some() {
344 let start_byte = line_info.byte_offset;
346 let mut end_byte = line_info.byte_offset;
347
348 let marker_column = ctx
350 .list_item_on_line(line_num)
351 .map_or(actual_indent, ParsedListItem::marker_column);
352
353 for (i, ch) in line_info.content(ctx.content).chars().enumerate() {
355 if i >= marker_column {
356 break;
357 }
358 end_byte += ch.len_utf8();
359 }
360
361 let mut blockquote_count = 0;
363 for ch in line_info.content(ctx.content).chars() {
364 if ch == '>' {
365 blockquote_count += 1;
366 } else if ch != ' ' && ch != '\t' {
367 break;
368 }
369 }
370
371 let blockquote_prefix = if blockquote_count > 1 {
373 (0..blockquote_count)
374 .map(|_| "> ")
375 .collect::<String>()
376 .trim_end()
377 .to_string()
378 } else {
379 ">".to_string()
380 };
381
382 let correct_indent = " ".repeat(expected_indent);
384 let replacement = format!("{blockquote_prefix} {correct_indent}");
385
386 (start_byte..end_byte, replacement)
387 } else {
388 let fix_range = if actual_indent > 0 {
390 let start_byte = ctx.line_offsets.get(line_num - 1).copied().unwrap_or(0);
391 let end_byte = start_byte + actual_indent;
392 start_byte..end_byte
393 } else {
394 let byte_pos = ctx.line_offsets.get(line_num - 1).copied().unwrap_or(0);
395 byte_pos..byte_pos
396 };
397
398 let replacement = if expected_indent > 0 {
399 " ".repeat(expected_indent)
400 } else {
401 String::new()
402 };
403
404 (fix_range, replacement)
405 };
406
407 LintWarning {
408 rule_name: Some(self.name().to_string()),
409 line: start_line,
410 column: start_col,
411 end_line,
412 end_column: end_col,
413 message,
414 severity: Severity::Warning,
415 fix: Some(Fix::new(fix_range, replacement)),
416 }
417 }
418
419 fn check_indent_consistency(
422 &self,
423 ctx: &crate::lint_context::LintContext,
424 items: &[(usize, usize, &crate::lint_context::LineInfo)],
425 warnings: &mut Vec<LintWarning>,
426 ) {
427 if items.len() < 2 {
428 return;
429 }
430
431 let mut sorted_items: Vec<_> = items.iter().collect();
433 sorted_items.sort_by_key(|(line_num, _, _)| *line_num);
434
435 let indents: std::collections::HashSet<usize> = sorted_items.iter().map(|(_, indent, _)| *indent).collect();
436
437 if indents.len() > 1 {
438 let expected_indent = sorted_items.first().map_or(0, |(_, i, _)| *i);
441
442 for (line_num, indent, line_info) in items {
443 if *indent != expected_indent {
444 warnings.push(self.create_indent_warning(ctx, *line_num, line_info, *indent, expected_indent));
445 }
446 }
447 }
448 }
449
450 fn group_by_parent_content_column<'a>(
457 &self,
458 level: usize,
459 group: &[(usize, usize, &'a crate::lint_context::LineInfo)],
460 all_list_items: &[(usize, usize, &crate::lint_context::LineInfo, ParsedListItem<'_>)],
461 level_map: &HashMap<usize, usize>,
462 ) -> ParentContentGroups<'a> {
463 let parent_level = level - 1;
464
465 let is_ordered_map: HashMap<usize, bool> = all_list_items
467 .iter()
468 .map(|(ln, _, _, item)| (*ln, item.is_ordered()))
469 .collect();
470
471 let parent_items: Vec<(usize, usize)> = all_list_items
473 .iter()
474 .filter(|(ln, _, _, _)| level_map.get(ln) == Some(&parent_level))
475 .map(|(ln, _, _, item)| (*ln, item.content_column()))
476 .collect();
477
478 let mut parent_content_groups: ParentContentGroups<'a> = HashMap::new();
479
480 for (line_num, indent, line_info) in group {
481 let item_is_ordered = is_ordered_map.get(line_num).copied().unwrap_or(false);
482
483 let idx = parent_items.partition_point(|&(ln, _)| ln < *line_num);
485 let parent_content_col = if idx > 0 { Some(parent_items[idx - 1].1) } else { None };
486
487 if let Some(parent_col) = parent_content_col {
488 parent_content_groups
489 .entry((parent_col, item_is_ordered))
490 .or_default()
491 .push((*line_num, *indent, *line_info));
492 }
493 }
494
495 parent_content_groups
496 }
497
498 fn group_related_list_blocks<'a>(&self, list_blocks: ParsedListBlocks<'a>) -> Vec<Vec<ParsedListBlock<'a>>> {
500 let mut blocks = list_blocks.into_iter();
501 let Some(first_block) = blocks.next() else {
502 return Vec::new();
503 };
504
505 let mut groups = Vec::new();
506 let mut current_group = vec![first_block];
507 let mut prev_block = first_block;
508
509 for current_block in blocks {
510 let line_gap = current_block.start_line().saturating_sub(prev_block.end_line());
512
513 if line_gap <= Self::LIST_GROUP_GAP_TOLERANCE {
516 current_group.push(current_block);
517 } else {
518 groups.push(current_group);
520 current_group = vec![current_block];
521 }
522 prev_block = current_block;
523 }
524 groups.push(current_group);
525
526 groups
527 }
528
529 fn is_continuation_content(
532 &self,
533 ctx: &crate::lint_context::LintContext,
534 cache: &LineCacheInfo,
535 list_line: usize,
536 list_indent: usize,
537 ) -> bool {
538 let parent_line = cache.parent_map.get(&list_line).copied();
540
541 if let Some(parent_line) = parent_line
542 && let Some(parent_list_item) = ctx.list_item_on_line(parent_line)
543 {
544 let line_info = parent_list_item.line_info();
545 let parent_marker_column = parent_list_item.marker_column();
546 let parent_content_column = parent_list_item.content_column();
547
548 let parent_bq_level = line_info.blockquote.as_ref().map_or(0, |bq| bq.nesting_level);
550 let parent_bq_prefix_len = line_info.blockquote.as_ref().map_or(0, |bq| bq.prefix.len());
551
552 let continuation_indent = cache.find_continuation_indent(
556 parent_line + 1,
557 list_line - 1,
558 parent_marker_column + 1,
559 parent_content_column,
560 parent_bq_level,
561 parent_bq_prefix_len,
562 );
563
564 if let Some(continuation_indent) = continuation_indent {
565 let is_standard_continuation =
566 list_indent == parent_content_column + Self::STANDARD_CONTINUATION_OFFSET;
567 let matches_content_indent = list_indent == continuation_indent;
568
569 if matches_content_indent || is_standard_continuation {
570 return true;
571 }
572 }
573
574 if list_indent > parent_marker_column {
577 if self.has_continuation_list_at_indent(
579 ctx,
580 cache,
581 parent_line,
582 list_line,
583 list_indent,
584 (parent_marker_column + 1, parent_content_column),
585 ) {
586 return true;
587 }
588
589 let (parent_bq_level, parent_bq_prefix_len) = cache.blockquote_info(parent_line);
591 if cache.has_continuation_content(
592 parent_line,
593 list_line,
594 parent_marker_column + 1,
595 parent_content_column,
596 parent_bq_level,
597 parent_bq_prefix_len,
598 ) {
599 return true;
600 }
601 }
602 }
603
604 false
605 }
606
607 fn has_continuation_list_at_indent(
611 &self,
612 ctx: &crate::lint_context::LintContext,
613 cache: &LineCacheInfo,
614 parent_line: usize,
615 current_line: usize,
616 list_indent: usize,
617 thresholds: (usize, usize),
618 ) -> bool {
619 let (parent_bq_level, parent_bq_prefix_len) = cache.blockquote_info(parent_line);
621 let (tight, loose) = thresholds;
622
623 for line_num in (parent_line + 1)..current_line {
626 if let Some(list_item) = ctx.list_item_on_line(line_num)
627 && list_item.marker_column() == list_indent
628 {
629 if cache
631 .find_continuation_indent(
632 parent_line + 1,
633 line_num - 1,
634 tight,
635 loose,
636 parent_bq_level,
637 parent_bq_prefix_len,
638 )
639 .is_some()
640 {
641 return true;
642 }
643 }
644 }
645 false
646 }
647
648 fn check_list_block_group(
650 &self,
651 ctx: &crate::lint_context::LintContext,
652 cache: &LineCacheInfo,
653 group: &[ParsedListBlock<'_>],
654 warnings: &mut Vec<LintWarning>,
655 ) {
656 let mut candidate_items: Vec<(usize, usize, &crate::lint_context::LineInfo, ParsedListItem<'_>)> = Vec::new();
659
660 for list_block in group {
661 for list_item in list_block.items() {
662 let item_line = list_item.line_num();
663 let line_info = list_item.line_info();
664 let effective_indent = if let Some(blockquote) = &line_info.blockquote {
666 list_item.marker_column().saturating_sub(blockquote.nesting_level * 2)
668 } else {
669 list_item.marker_column()
671 };
672
673 candidate_items.push((item_line, effective_indent, line_info, list_item));
674 }
675 }
676
677 candidate_items.sort_by_key(|(line_num, _, _, _)| *line_num);
679
680 let mut skipped_lines: std::collections::HashSet<usize> = std::collections::HashSet::new();
683 let mut all_list_items: Vec<(usize, usize, &crate::lint_context::LineInfo, ParsedListItem<'_>)> = Vec::new();
684
685 for (item_line, effective_indent, line_info, list_item) in candidate_items {
686 if line_info.in_footnote_definition {
688 skipped_lines.insert(item_line);
689 continue;
690 }
691 if self.is_continuation_content(ctx, cache, item_line, effective_indent) {
693 skipped_lines.insert(item_line);
694 continue;
695 }
696
697 if let Some(&parent_line) = cache.parent_map.get(&item_line)
699 && skipped_lines.contains(&parent_line)
700 {
701 skipped_lines.insert(item_line);
702 continue;
703 }
704
705 all_list_items.push((item_line, effective_indent, line_info, list_item));
706 }
707
708 if all_list_items.is_empty() {
709 return;
710 }
711
712 all_list_items.sort_by_key(|(line_num, _, _, _)| *line_num);
714
715 let mut level_map: HashMap<usize, usize> = HashMap::new();
719 let mut level_indents: HashMap<usize, Vec<usize>> = HashMap::new(); let mut indent_to_level: HashMap<usize, (usize, usize)> = HashMap::new();
724
725 for (line_num, indent, _, _) in &all_list_items {
727 let level = if indent_to_level.is_empty() {
728 level_indents.entry(1).or_default().push(*indent);
730 1
731 } else {
732 let mut determined_level = 0;
734
735 if let Some(&(existing_level, _)) = indent_to_level.get(indent) {
737 determined_level = existing_level;
738 } else {
739 let mut best_parent: Option<(usize, usize, usize)> = None; for (&tracked_indent, &(tracked_level, tracked_line)) in &indent_to_level {
745 if tracked_indent < *indent {
746 if best_parent.is_none() || tracked_indent > best_parent.unwrap().0 {
749 best_parent = Some((tracked_indent, tracked_level, tracked_line));
750 }
751 }
752 }
753
754 if let Some((parent_indent, parent_level, _parent_line)) = best_parent {
755 if parent_indent + Self::MIN_CHILD_INDENT_INCREASE <= *indent {
757 determined_level = parent_level + 1;
759 } else if (*indent as i32 - parent_indent as i32).abs() <= Self::SAME_LEVEL_TOLERANCE {
760 determined_level = parent_level;
762 } else {
763 let mut found_similar = false;
767 if let Some(indents_at_level) = level_indents.get(&parent_level) {
768 for &level_indent in indents_at_level {
769 if (level_indent as i32 - *indent as i32).abs() <= Self::SAME_LEVEL_TOLERANCE {
770 determined_level = parent_level;
771 found_similar = true;
772 break;
773 }
774 }
775 }
776 if !found_similar {
777 determined_level = parent_level + 1;
779 }
780 }
781 }
782
783 if determined_level == 0 {
785 determined_level = 1;
786 }
787
788 level_indents.entry(determined_level).or_default().push(*indent);
790 }
791
792 determined_level
793 };
794
795 level_map.insert(*line_num, level);
796 indent_to_level.insert(*indent, (level, *line_num));
798 }
799
800 let mut level_groups: HashMap<usize, Vec<(usize, usize, &crate::lint_context::LineInfo)>> = HashMap::new();
802 for (line_num, indent, line_info, _) in &all_list_items {
803 let level = level_map[line_num];
804 level_groups
805 .entry(level)
806 .or_default()
807 .push((*line_num, *indent, *line_info));
808 }
809
810 for (level, mut group) in level_groups {
812 group.sort_by_key(|(line_num, _, _)| *line_num);
813
814 if level == 1 {
815 for (line_num, indent, line_info) in &group {
817 if *indent != self.top_level_indent {
818 warnings.push(self.create_indent_warning(
819 ctx,
820 *line_num,
821 line_info,
822 *indent,
823 self.top_level_indent,
824 ));
825 }
826 }
827 } else {
828 let parent_content_groups =
831 self.group_by_parent_content_column(level, &group, &all_list_items, &level_map);
832
833 for items in parent_content_groups.values() {
835 self.check_indent_consistency(ctx, items, warnings);
836 }
837 }
838 }
839 }
840
841 fn check_optimized(&self, ctx: &crate::lint_context::LintContext) -> Vec<LintWarning> {
843 let content = ctx.content;
844
845 if content.is_empty() {
847 return Vec::new();
848 }
849
850 let list_blocks = ctx.parsed_list_blocks();
852 if list_blocks.is_empty() {
853 return Vec::new();
854 }
855
856 let mut warnings = Vec::new();
857
858 let cache = LineCacheInfo::new(ctx);
860
861 let block_groups = self.group_related_list_blocks(list_blocks);
864
865 for group in block_groups {
866 self.check_list_block_group(ctx, &cache, &group, &mut warnings);
867 }
868
869 warnings
870 }
871}
872
873impl Rule for MD005ListIndent {
874 fn name(&self) -> &'static str {
875 "MD005"
876 }
877
878 fn description(&self) -> &'static str {
879 "List indentation should be consistent"
880 }
881
882 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
883 Ok(self.check_optimized(ctx))
885 }
886
887 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
888 let warnings = self.check(ctx)?;
889 let warnings =
890 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
891 if warnings.is_empty() {
892 return Ok(ctx.content.to_string());
893 }
894
895 let mut warnings_with_fixes: Vec<_> = warnings
897 .into_iter()
898 .filter_map(|w| w.fix.clone().map(|fix| (w, fix)))
899 .collect();
900 warnings_with_fixes.sort_by_key(|(_, fix)| std::cmp::Reverse(fix.range.start));
901
902 let mut content = ctx.content.to_string();
904 for (_, fix) in warnings_with_fixes {
905 if fix.range.start <= content.len() && fix.range.end <= content.len() {
906 content.replace_range(fix.range, &fix.replacement);
907 }
908 }
909
910 Ok(content)
911 }
912
913 fn category(&self) -> RuleCategory {
914 RuleCategory::List
915 }
916
917 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
919 ctx.content.is_empty() || !ctx.has_list_items()
921 }
922
923 fn as_any(&self) -> &dyn std::any::Any {
924 self
925 }
926
927 fn default_config_section(&self) -> Option<(String, toml::Value)> {
928 None
929 }
930
931 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
932 where
933 Self: Sized,
934 {
935 let mut top_level_indent = 0;
937
938 if let Some(md007_config) = config.rules.get("MD007") {
940 if let Some(start_indented) = md007_config.values.get("start-indented")
942 && let Some(start_indented_bool) = start_indented.as_bool()
943 && start_indented_bool
944 {
945 if let Some(start_indent) = md007_config.values.get("start-indent") {
947 if let Some(indent_value) = start_indent.as_integer() {
948 top_level_indent = indent_value as usize;
949 }
950 } else {
951 top_level_indent = 2;
953 }
954 }
955 }
956
957 Box::new(MD005ListIndent { top_level_indent })
958 }
959}
960
961#[cfg(test)]
962mod tests {
963 use super::*;
964 use crate::lint_context::LintContext;
965
966 #[test]
967 fn test_valid_unordered_list() {
968 let rule = MD005ListIndent::default();
969 let content = "\
970* Item 1
971* Item 2
972 * Nested 1
973 * Nested 2
974* Item 3";
975 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
976 let result = rule.check(&ctx).unwrap();
977 assert!(result.is_empty());
978 }
979
980 #[test]
981 fn test_valid_ordered_list() {
982 let rule = MD005ListIndent::default();
983 let content = "\
9841. Item 1
9852. Item 2
986 1. Nested 1
987 2. Nested 2
9883. Item 3";
989 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
990 let result = rule.check(&ctx).unwrap();
991 assert!(result.is_empty());
994 }
995
996 #[test]
997 fn test_invalid_unordered_indent() {
998 let rule = MD005ListIndent::default();
999 let content = "\
1000* Item 1
1001 * Item 2
1002 * Nested 1";
1003 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1004 let result = rule.check(&ctx).unwrap();
1005 assert_eq!(result.len(), 1);
1008 let fixed = rule.fix(&ctx).unwrap();
1009 assert_eq!(fixed, "* Item 1\n* Item 2\n * Nested 1");
1010 }
1011
1012 #[test]
1013 fn test_invalid_ordered_indent() {
1014 let rule = MD005ListIndent::default();
1015 let content = "\
10161. Item 1
1017 2. Item 2
1018 1. Nested 1";
1019 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1020 let result = rule.check(&ctx).unwrap();
1021 assert_eq!(result.len(), 1);
1022 let fixed = rule.fix(&ctx).unwrap();
1023 assert_eq!(fixed, "1. Item 1\n2. Item 2\n 1. Nested 1");
1027 }
1028
1029 #[test]
1030 fn test_mixed_list_types() {
1031 let rule = MD005ListIndent::default();
1032 let content = "\
1033* Item 1
1034 1. Nested ordered
1035 * Nested unordered
1036* Item 2";
1037 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1038 let result = rule.check(&ctx).unwrap();
1039 assert!(result.is_empty());
1040 }
1041
1042 #[test]
1043 fn test_multiple_levels() {
1044 let rule = MD005ListIndent::default();
1045 let content = "\
1046* Level 1
1047 * Level 2
1048 * Level 3";
1049 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1050 let result = rule.check(&ctx).unwrap();
1051 assert!(result.is_empty(), "MD005 should accept consistent indentation pattern");
1053 }
1054
1055 #[test]
1056 fn test_empty_lines() {
1057 let rule = MD005ListIndent::default();
1058 let content = "\
1059* Item 1
1060
1061 * Nested 1
1062
1063* Item 2";
1064 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1065 let result = rule.check(&ctx).unwrap();
1066 assert!(result.is_empty());
1067 }
1068
1069 #[test]
1070 fn test_no_lists() {
1071 let rule = MD005ListIndent::default();
1072 let content = "\
1073Just some text
1074More text
1075Even more text";
1076 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1077 let result = rule.check(&ctx).unwrap();
1078 assert!(result.is_empty());
1079 }
1080
1081 #[test]
1082 fn test_complex_nesting() {
1083 let rule = MD005ListIndent::default();
1084 let content = "\
1085* Level 1
1086 * Level 2
1087 * Level 3
1088 * Back to 2
1089 1. Ordered 3
1090 2. Still 3
1091* Back to 1";
1092 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1093 let result = rule.check(&ctx).unwrap();
1094 assert!(result.is_empty());
1095 }
1096
1097 #[test]
1098 fn test_invalid_complex_nesting() {
1099 let rule = MD005ListIndent::default();
1100 let content = "\
1101* Level 1
1102 * Level 2
1103 * Level 3
1104 * Back to 2
1105 1. Ordered 3
1106 2. Still 3
1107* Back to 1";
1108 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1109 let result = rule.check(&ctx).unwrap();
1110 assert_eq!(result.len(), 1);
1112 assert!(
1113 result[0].message.contains("Expected indentation of 5 spaces, found 6")
1114 || result[0].message.contains("Expected indentation of 6 spaces, found 5")
1115 );
1116 }
1117
1118 #[test]
1119 fn test_with_lint_context() {
1120 let rule = MD005ListIndent::default();
1121
1122 let content = "* Item 1\n* Item 2\n * Nested item\n * Another nested item";
1124 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1125 let result = rule.check(&ctx).unwrap();
1126 assert!(result.is_empty());
1127
1128 let content = "* Item 1\n* Item 2\n * Nested item\n * Another nested item";
1130 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1131 let result = rule.check(&ctx).unwrap();
1132 assert!(!result.is_empty()); let content = "* Item 1\n * Nested item\n * Another nested item with wrong indent";
1136 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1137 let result = rule.check(&ctx).unwrap();
1138 assert!(!result.is_empty()); }
1140
1141 #[test]
1143 fn test_list_with_continuations() {
1144 let rule = MD005ListIndent::default();
1145 let content = "\
1146* Item 1
1147 This is a continuation
1148 of the first item
1149 * Nested item
1150 with its own continuation
1151* Item 2";
1152 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1153 let result = rule.check(&ctx).unwrap();
1154 assert!(result.is_empty());
1155 }
1156
1157 #[test]
1158 fn test_list_in_blockquote() {
1159 let rule = MD005ListIndent::default();
1160 let content = "\
1161> * Item 1
1162> * Nested 1
1163> * Nested 2
1164> * Item 2";
1165 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1166 let result = rule.check(&ctx).unwrap();
1167
1168 assert!(
1170 result.is_empty(),
1171 "Expected no warnings for correctly indented blockquote list, got: {result:?}"
1172 );
1173 }
1174
1175 #[test]
1176 fn test_list_with_code_blocks() {
1177 let rule = MD005ListIndent::default();
1178 let content = "\
1179* Item 1
1180 ```
1181 code block
1182 ```
1183 * Nested item
1184* Item 2";
1185 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1186 let result = rule.check(&ctx).unwrap();
1187 assert!(result.is_empty());
1188 }
1189
1190 #[test]
1191 fn test_list_with_tabs() {
1192 let rule = MD005ListIndent::default();
1193 let content = "* Item 1\n * Wrong indent (3 spaces)\n * Correct indent (2 spaces)";
1197 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1198 let result = rule.check(&ctx).unwrap();
1199 assert!(!result.is_empty());
1201 }
1202
1203 #[test]
1204 fn test_inconsistent_at_same_level() {
1205 let rule = MD005ListIndent::default();
1206 let content = "\
1207* Item 1
1208 * Nested 1
1209 * Nested 2
1210 * Wrong indent for same level
1211 * Nested 3";
1212 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1213 let result = rule.check(&ctx).unwrap();
1214 assert!(!result.is_empty());
1215 assert!(result.iter().any(|w| w.line == 4));
1217 }
1218
1219 #[test]
1220 fn test_zero_indent_top_level() {
1221 let rule = MD005ListIndent::default();
1222 let content = concat!(" * Wrong indent\n", "* Correct\n", " * Nested");
1224 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1225 let result = rule.check(&ctx).unwrap();
1226
1227 assert!(!result.is_empty());
1229 assert!(result.iter().any(|w| w.line == 1));
1230 }
1231
1232 #[test]
1233 fn test_fix_preserves_content() {
1234 let rule = MD005ListIndent::default();
1235 let content = "\
1236* Item with **bold** and *italic*
1237 * Wrong indent with `code`
1238 * Also wrong with [link](url)";
1239 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1240 let fixed = rule.fix(&ctx).unwrap();
1241 assert!(fixed.contains("**bold**"));
1242 assert!(fixed.contains("*italic*"));
1243 assert!(fixed.contains("`code`"));
1244 assert!(fixed.contains("[link](url)"));
1245 }
1246
1247 #[test]
1248 fn test_deeply_nested_lists() {
1249 let rule = MD005ListIndent::default();
1250 let content = "\
1251* L1
1252 * L2
1253 * L3
1254 * L4
1255 * L5
1256 * L6";
1257 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1258 let result = rule.check(&ctx).unwrap();
1259 assert!(result.is_empty());
1260 }
1261
1262 #[test]
1263 fn test_fix_multiple_issues() {
1264 let rule = MD005ListIndent::default();
1265 let content = "\
1266* Item 1
1267 * Wrong 1
1268 * Wrong 2
1269 * Wrong 3
1270 * Correct
1271 * Wrong 4";
1272 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1273 let fixed = rule.fix(&ctx).unwrap();
1274 let lines: Vec<&str> = fixed.lines().collect();
1276 assert_eq!(lines[0], "* Item 1");
1277 assert!(lines[1].starts_with(" * ") || lines[1].starts_with("* "));
1279 }
1280
1281 #[test]
1282 fn test_performance_large_document() {
1283 let rule = MD005ListIndent::default();
1284 let mut content = String::new();
1285 for i in 0..100 {
1286 content.push_str(&format!("* Item {i}\n"));
1287 content.push_str(&format!(" * Nested {i}\n"));
1288 }
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_column_positions() {
1296 let rule = MD005ListIndent::default();
1297 let content = " * Wrong indent";
1298 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1299 let result = rule.check(&ctx).unwrap();
1300 assert_eq!(result.len(), 1);
1301 assert_eq!(result[0].column, 1, "Expected column 1, got {}", result[0].column);
1302 assert_eq!(
1303 result[0].end_column, 2,
1304 "Expected end_column 2, got {}",
1305 result[0].end_column
1306 );
1307 }
1308
1309 #[test]
1310 fn test_should_skip() {
1311 let rule = MD005ListIndent::default();
1312
1313 let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard, None);
1315 assert!(rule.should_skip(&ctx));
1316
1317 let ctx = LintContext::new("Just plain text", crate::config::MarkdownFlavor::Standard, None);
1319 assert!(rule.should_skip(&ctx));
1320
1321 let ctx = LintContext::new("* List item", crate::config::MarkdownFlavor::Standard, None);
1323 assert!(!rule.should_skip(&ctx));
1324
1325 let ctx = LintContext::new("1. Ordered list", crate::config::MarkdownFlavor::Standard, None);
1326 assert!(!rule.should_skip(&ctx));
1327 }
1328
1329 #[test]
1330 fn test_should_skip_validation() {
1331 let rule = MD005ListIndent::default();
1332 let content = "* List item";
1333 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1334 assert!(!rule.should_skip(&ctx));
1335
1336 let content = "No lists here";
1337 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1338 assert!(rule.should_skip(&ctx));
1339 }
1340
1341 #[test]
1342 fn test_edge_case_single_space_indent() {
1343 let rule = MD005ListIndent::default();
1344 let content = "\
1345* Item 1
1346 * Single space - wrong
1347 * Two spaces - correct";
1348 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1349 let result = rule.check(&ctx).unwrap();
1350 assert_eq!(result.len(), 2);
1353 assert!(result.iter().any(|w| w.line == 2 && w.message.contains("found 1")));
1354 }
1355
1356 #[test]
1357 fn test_edge_case_three_space_indent() {
1358 let rule = MD005ListIndent::default();
1359 let content = "\
1360* Item 1
1361 * Three spaces - first establishes pattern
1362 * Two spaces - inconsistent with established pattern";
1363 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1364 let result = rule.check(&ctx).unwrap();
1365 assert_eq!(result.len(), 1);
1369 assert!(result.iter().any(|w| w.line == 3 && w.message.contains("found 2")));
1370 }
1371
1372 #[test]
1373 fn test_nested_bullets_under_numbered_items() {
1374 let rule = MD005ListIndent::default();
1375 let content = "\
13761. **Active Directory/LDAP**
1377 - User authentication and directory services
1378 - LDAP for user information and validation
1379
13802. **Oracle Unified Directory (OUD)**
1381 - Extended user directory services
1382 - Verification of project account presence and changes";
1383 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1384 let result = rule.check(&ctx).unwrap();
1385 assert!(
1387 result.is_empty(),
1388 "Expected no warnings for bullets with 3 spaces under numbered items, got: {result:?}"
1389 );
1390 }
1391
1392 #[test]
1393 fn test_nested_bullets_under_numbered_items_wrong_indent() {
1394 let rule = MD005ListIndent::default();
1395 let content = "\
13961. **Active Directory/LDAP**
1397 - Wrong: only 2 spaces
1398 - Correct: 3 spaces";
1399 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1400 let result = rule.check(&ctx).unwrap();
1401 assert_eq!(
1403 result.len(),
1404 1,
1405 "Expected 1 warning, got {}. Warnings: {:?}",
1406 result.len(),
1407 result
1408 );
1409 assert!(
1411 result
1412 .iter()
1413 .any(|w| (w.line == 2 && w.message.contains("found 2"))
1414 || (w.line == 3 && w.message.contains("found 3")))
1415 );
1416 }
1417
1418 #[test]
1419 fn test_regular_nested_bullets_still_work() {
1420 let rule = MD005ListIndent::default();
1421 let content = "\
1422* Top level
1423 * Second level (2 spaces is correct for bullets under bullets)
1424 * Third level (4 spaces)";
1425 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1426 let result = rule.check(&ctx).unwrap();
1427 assert!(
1429 result.is_empty(),
1430 "Expected no warnings for regular bullet nesting, got: {result:?}"
1431 );
1432 }
1433
1434 #[test]
1435 fn test_fix_range_accuracy() {
1436 let rule = MD005ListIndent::default();
1437 let content = " * Wrong indent";
1438 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1439 let result = rule.check(&ctx).unwrap();
1440 assert_eq!(result.len(), 1);
1441
1442 let fix = result[0].fix.as_ref().unwrap();
1443 assert_eq!(fix.replacement, "");
1445 }
1446
1447 #[test]
1448 fn test_four_space_indent_pattern() {
1449 let rule = MD005ListIndent::default();
1450 let content = "\
1451* Item 1
1452 * Item 2 with 4 spaces
1453 * Item 3 with 8 spaces
1454 * Item 4 with 4 spaces";
1455 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1456 let result = rule.check(&ctx).unwrap();
1457 assert!(
1459 result.is_empty(),
1460 "MD005 should accept consistent 4-space indentation pattern, got {} warnings",
1461 result.len()
1462 );
1463 }
1464
1465 #[test]
1466 fn test_issue_64_scenario() {
1467 let rule = MD005ListIndent::default();
1469 let content = "\
1470* Top level item
1471 * Sub item with 4 spaces (as configured in MD007)
1472 * Nested sub item with 8 spaces
1473 * Another sub item with 4 spaces
1474* Another top level";
1475
1476 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1477 let result = rule.check(&ctx).unwrap();
1478
1479 assert!(
1481 result.is_empty(),
1482 "MD005 should accept 4-space indentation when that's the pattern being used. Got {} warnings",
1483 result.len()
1484 );
1485 }
1486
1487 #[test]
1488 fn test_continuation_content_scenario() {
1489 let rule = MD005ListIndent::default();
1490 let content = "\
1491- **Changes to how the Python version is inferred** ([#16319](example))
1492
1493 In previous versions of Ruff, you could specify your Python version with:
1494
1495 - The `target-version` option in a `ruff.toml` file
1496 - The `project.requires-python` field in a `pyproject.toml` file";
1497
1498 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1499
1500 let result = rule.check(&ctx).unwrap();
1501
1502 assert!(
1504 result.is_empty(),
1505 "MD005 should not flag continuation content lists, got {} warnings: {:?}",
1506 result.len(),
1507 result
1508 );
1509 }
1510
1511 #[test]
1512 fn test_multiple_continuation_lists_scenario() {
1513 let rule = MD005ListIndent::default();
1514 let content = "\
1515- **Changes to how the Python version is inferred** ([#16319](example))
1516
1517 In previous versions of Ruff, you could specify your Python version with:
1518
1519 - The `target-version` option in a `ruff.toml` file
1520 - The `project.requires-python` field in a `pyproject.toml` file
1521
1522 In v0.10, config discovery has been updated to address this issue:
1523
1524 - If Ruff finds a `ruff.toml` file without a `target-version`, it will check
1525 - If Ruff finds a user-level configuration, the `requires-python` field will take precedence
1526 - If there is no config file, Ruff will search for the closest `pyproject.toml`";
1527
1528 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1529
1530 let result = rule.check(&ctx).unwrap();
1531
1532 assert!(
1534 result.is_empty(),
1535 "MD005 should not flag continuation content lists, got {} warnings: {:?}",
1536 result.len(),
1537 result
1538 );
1539 }
1540
1541 #[test]
1542 fn test_issue_115_sublist_after_code_block() {
1543 let rule = MD005ListIndent::default();
1544 let content = "\
15451. List item 1
1546
1547 ```rust
1548 fn foo() {}
1549 ```
1550
1551 Sublist:
1552
1553 - A
1554 - B
1555";
1556 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1557 let result = rule.check(&ctx).unwrap();
1558 assert!(
1562 result.is_empty(),
1563 "Expected no warnings for sub-list after code block in list item, got {} warnings: {:?}",
1564 result.len(),
1565 result
1566 );
1567 }
1568
1569 #[test]
1570 fn test_edge_case_continuation_at_exact_boundary() {
1571 let rule = MD005ListIndent::default();
1572 let content = "\
1574* Item (content at column 2)
1575 Text at column 2 (exact boundary - continuation)
1576 * Sub at column 2";
1577 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1578 let result = rule.check(&ctx).unwrap();
1579 assert!(
1581 result.is_empty(),
1582 "Expected no warnings when text and sub-list are at exact parent content_column, got: {result:?}"
1583 );
1584 }
1585
1586 #[test]
1587 fn test_edge_case_unicode_in_continuation() {
1588 let rule = MD005ListIndent::default();
1589 let content = "\
1590* Parent
1591 Text with emoji 😀 and Unicode ñ characters
1592 * Sub-list should still work";
1593 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1594 let result = rule.check(&ctx).unwrap();
1595 assert!(
1597 result.is_empty(),
1598 "Expected no warnings with Unicode in continuation content, got: {result:?}"
1599 );
1600 }
1601
1602 #[test]
1603 fn test_edge_case_large_empty_line_gap() {
1604 let rule = MD005ListIndent::default();
1605 let content = "\
1606* Parent at line 1
1607 Continuation text
1608
1609
1610
1611 More continuation after many empty lines
1612
1613 * Child after gap
1614 * Another child";
1615 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1616 let result = rule.check(&ctx).unwrap();
1617 assert!(
1619 result.is_empty(),
1620 "Expected no warnings with large gaps in continuation content, got: {result:?}"
1621 );
1622 }
1623
1624 #[test]
1625 fn test_edge_case_multiple_continuation_blocks_varying_indent() {
1626 let rule = MD005ListIndent::default();
1627 let content = "\
1628* Parent (content at column 2)
1629 First paragraph at column 2
1630 Indented quote at column 4
1631 Back to column 2
1632 * Sub-list at column 2";
1633 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1634 let result = rule.check(&ctx).unwrap();
1635 assert!(
1637 result.is_empty(),
1638 "Expected no warnings with varying continuation indent, got: {result:?}"
1639 );
1640 }
1641
1642 #[test]
1643 fn test_edge_case_deep_nesting_no_continuation() {
1644 let rule = MD005ListIndent::default();
1645 let content = "\
1646* Parent
1647 * Immediate child (no continuation text before)
1648 * Grandchild
1649 * Great-grandchild
1650 * Great-great-grandchild
1651 * Another child at level 2";
1652 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1653 let result = rule.check(&ctx).unwrap();
1654 assert!(
1656 result.is_empty(),
1657 "Expected no warnings for deep nesting without continuation, got: {result:?}"
1658 );
1659 }
1660
1661 #[test]
1662 fn test_edge_case_blockquote_continuation_content() {
1663 let rule = MD005ListIndent::default();
1664 let content = "\
1665> * Parent in blockquote
1666> Continuation in blockquote
1667> * Sub-list in blockquote
1668> * Another sub-list";
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 for blockquote continuation, got: {result:?}"
1675 );
1676 }
1677
1678 #[test]
1679 fn test_edge_case_one_space_less_than_content_column() {
1680 let rule = MD005ListIndent::default();
1681 let content = "\
1682* Parent (content at column 2)
1683 Text at column 1 (one less than content_column - NOT continuation)
1684 * Child";
1685 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1686 let result = rule.check(&ctx).unwrap();
1687 assert!(
1693 result.is_empty() || !result.is_empty(),
1694 "Test should complete without panic"
1695 );
1696 }
1697
1698 #[test]
1699 fn test_edge_case_multiple_code_blocks_different_indentation() {
1700 let rule = MD005ListIndent::default();
1701 let content = "\
1702* Parent
1703 ```
1704 code at 2 spaces
1705 ```
1706 ```
1707 code at 4 spaces
1708 ```
1709 * Sub-list should not be confused";
1710 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1711 let result = rule.check(&ctx).unwrap();
1712 assert!(
1714 result.is_empty(),
1715 "Expected no warnings with multiple code blocks, got: {result:?}"
1716 );
1717 }
1718
1719 #[test]
1720 fn test_performance_very_large_document() {
1721 let rule = MD005ListIndent::default();
1722 let mut content = String::new();
1723
1724 for i in 0..1000 {
1726 content.push_str(&format!("* Item {i}\n"));
1727 content.push_str(&format!(" * Nested {i}\n"));
1728 if i % 10 == 0 {
1729 content.push_str(" Some continuation text\n");
1730 }
1731 }
1732
1733 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1734
1735 let start = std::time::Instant::now();
1737 let result = rule.check(&ctx).unwrap();
1738 let elapsed = start.elapsed();
1739
1740 assert!(result.is_empty());
1741 println!("Processed 1000 list items in {elapsed:?}");
1742 assert!(
1745 elapsed.as_secs() < 1,
1746 "Should complete in under 1 second, took {elapsed:?}"
1747 );
1748 }
1749
1750 #[test]
1751 fn test_ordered_list_variable_marker_width() {
1752 let rule = MD005ListIndent::default();
1757 let content = "\
17581. One
1759 - One
1760 - Two
17612. Two
1762 - One
17633. Three
1764 - One
17654. Four
1766 - One
17675. Five
1768 - One
17696. Six
1770 - One
17717. Seven
1772 - One
17738. Eight
1774 - One
17759. Nine
1776 - One
177710. Ten
1778 - One";
1779 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1780 let result = rule.check(&ctx).unwrap();
1781 assert!(
1782 result.is_empty(),
1783 "Expected no warnings for ordered list with variable marker widths, got: {result:?}"
1784 );
1785 }
1786
1787 #[test]
1788 fn test_ordered_list_inconsistent_siblings() {
1789 let rule = MD005ListIndent::default();
1791 let content = "\
17921. Item one
1793 - First sublist at 3 spaces
1794 - Second sublist at 2 spaces (inconsistent)
1795 - Third sublist at 3 spaces";
1796 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1797 let result = rule.check(&ctx).unwrap();
1798 assert_eq!(
1800 result.len(),
1801 1,
1802 "Expected 1 warning for inconsistent sibling indent, got: {result:?}"
1803 );
1804 assert!(result[0].message.contains("Expected indentation of 3"));
1805 }
1806
1807 #[test]
1808 fn test_ordered_list_single_sublist_no_warning() {
1809 let rule = MD005ListIndent::default();
1812 let content = "\
181310. Item ten
1814 - Only sublist at 3 spaces";
1815 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1816 let result = rule.check(&ctx).unwrap();
1817 assert!(
1819 result.is_empty(),
1820 "Expected no warnings for single sublist item, got: {result:?}"
1821 );
1822 }
1823
1824 #[test]
1825 fn test_sublists_grouped_by_parent_content_column() {
1826 let rule = MD005ListIndent::default();
1830 let content = "\
18319. Item nine
1832 - First sublist at 3 spaces
1833 - Second sublist at 3 spaces
1834 - Third sublist at 3 spaces
183510. Item ten
1836 - First sublist at 4 spaces
1837 - Second sublist at 4 spaces
1838 - Third sublist at 4 spaces";
1839 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1840 let result = rule.check(&ctx).unwrap();
1841 assert!(
1844 result.is_empty(),
1845 "Expected no warnings for sublists grouped by parent, got: {result:?}"
1846 );
1847 }
1848
1849 #[test]
1850 fn test_inconsistent_indent_within_parent_group() {
1851 let rule = MD005ListIndent::default();
1853 let content = "\
185410. Item ten
1855 - First sublist at 4 spaces
1856 - Second sublist at 3 spaces (inconsistent!)
1857 - Third sublist at 4 spaces";
1858 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1859 let result = rule.check(&ctx).unwrap();
1860 assert_eq!(
1862 result.len(),
1863 1,
1864 "Expected 1 warning for inconsistent indent within parent group, got: {result:?}"
1865 );
1866 assert!(result[0].line == 3);
1867 assert!(result[0].message.contains("Expected indentation of 4"));
1868 }
1869
1870 #[test]
1871 fn test_blockquote_nested_list_fix_preserves_blockquote_prefix() {
1872 use crate::rule::Rule;
1876
1877 let rule = MD005ListIndent::default();
1878 let content = "> * Federation sender blacklists are now persisted.";
1879 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1880 let result = rule.check(&ctx).unwrap();
1881
1882 assert_eq!(result.len(), 1, "Expected 1 warning for extra indent");
1883
1884 assert!(result[0].fix.is_some(), "Should have a fix");
1886 let fixed = rule.fix(&ctx).expect("Fix should succeed");
1887
1888 assert!(
1890 fixed.starts_with("> "),
1891 "Fixed content should start with blockquote prefix '> ', got: {fixed:?}"
1892 );
1893 assert!(
1894 !fixed.starts_with("* "),
1895 "Fixed content should NOT start with just '* ' (blockquote removed), got: {fixed:?}"
1896 );
1897 assert_eq!(
1898 fixed.trim(),
1899 "> * Federation sender blacklists are now persisted.",
1900 "Fixed content should be '> * Federation sender...' with single space after >"
1901 );
1902 }
1903
1904 #[test]
1905 fn test_nested_blockquote_list_fix_preserves_prefix() {
1906 use crate::rule::Rule;
1908
1909 let rule = MD005ListIndent::default();
1910 let content = ">> * Nested blockquote list item";
1911 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1912 let result = rule.check(&ctx).unwrap();
1913
1914 if !result.is_empty() {
1915 let fixed = rule.fix(&ctx).expect("Fix should succeed");
1916 assert!(
1918 fixed.contains(">>") || fixed.contains("> >"),
1919 "Fixed content should preserve nested blockquote prefix, got: {fixed:?}"
1920 );
1921 }
1922 }
1923}