1use crate::utils::blockquote::effective_indent_in_blockquote;
7use crate::utils::range_utils::calculate_match_range;
8
9use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
10use std::collections::HashMap;
12use toml;
13
14type ParentContentGroups<'a> = HashMap<(usize, bool), Vec<(usize, usize, &'a crate::lint_context::LineInfo)>>;
17
18#[derive(Clone, Default)]
20pub struct MD005ListIndent {
21 top_level_indent: usize,
23}
24
25struct LineCacheInfo {
27 indentation: Vec<usize>,
29 blockquote_levels: Vec<usize>,
31 line_contents: Vec<String>,
33 flags: Vec<u8>,
35 parent_map: HashMap<usize, usize>,
38}
39
40const FLAG_HAS_CONTENT: u8 = 1;
41const FLAG_IS_LIST_ITEM: u8 = 2;
42
43impl LineCacheInfo {
44 fn new(ctx: &crate::lint_context::LintContext) -> Self {
46 let total_lines = ctx.lines.len();
47 let mut indentation = Vec::with_capacity(total_lines);
48 let mut blockquote_levels = Vec::with_capacity(total_lines);
49 let mut line_contents = Vec::with_capacity(total_lines);
50 let mut flags = Vec::with_capacity(total_lines);
51 let mut parent_map = HashMap::new();
52
53 let mut indent_stack: Vec<(usize, usize)> = Vec::new();
65
66 for (idx, line_info) in ctx.lines.iter().enumerate() {
67 let line_content = line_info.content(ctx.content);
68 let content = line_content.trim_start();
69 let line_indent = line_info.byte_len - content.len();
70
71 indentation.push(line_indent);
72
73 let bq_level = line_info.blockquote.as_ref().map(|bq| bq.nesting_level).unwrap_or(0);
75 blockquote_levels.push(bq_level);
76
77 line_contents.push(line_content.to_string());
79
80 let mut flag = 0u8;
81 if !content.is_empty() {
82 flag |= FLAG_HAS_CONTENT;
83 }
84 if let Some(list_item) = &line_info.list_item {
85 flag |= FLAG_IS_LIST_ITEM;
86
87 let line_num = idx + 1; let marker_column = list_item.marker_column;
89
90 while let Some(&(indent, _)) = indent_stack.last() {
92 if indent < marker_column {
93 break;
94 }
95 indent_stack.pop();
96 }
97
98 if let Some((_, parent_line)) = indent_stack.last() {
99 parent_map.insert(line_num, *parent_line);
100 }
101
102 indent_stack.push((marker_column, line_num));
103 }
104 flags.push(flag);
105 }
106
107 Self {
108 indentation,
109 blockquote_levels,
110 line_contents,
111 flags,
112 parent_map,
113 }
114 }
115
116 fn has_content(&self, idx: usize) -> bool {
118 self.flags.get(idx).is_some_and(|&f| f & FLAG_HAS_CONTENT != 0)
119 }
120
121 fn is_list_item(&self, idx: usize) -> bool {
123 self.flags.get(idx).is_some_and(|&f| f & FLAG_IS_LIST_ITEM != 0)
124 }
125
126 fn blockquote_info(&self, line: usize) -> (usize, usize) {
128 if line == 0 || line > self.line_contents.len() {
129 return (0, 0);
130 }
131 let idx = line - 1;
132 let bq_level = self.blockquote_levels.get(idx).copied().unwrap_or(0);
133 if bq_level == 0 {
134 return (0, 0);
135 }
136 let content = &self.line_contents[idx];
138 let mut prefix_len = 0;
139 let mut found = 0;
140 for c in content.chars() {
141 prefix_len += c.len_utf8();
142 if c == '>' {
143 found += 1;
144 if found == bq_level {
145 if content.get(prefix_len..prefix_len + 1) == Some(" ") {
147 prefix_len += 1;
148 }
149 break;
150 }
151 }
152 }
153 (bq_level, prefix_len)
154 }
155
156 fn find_continuation_indent(
162 &self,
163 start_line: usize,
164 end_line: usize,
165 parent_content_column: usize,
166 parent_bq_level: usize,
167 parent_bq_prefix_len: usize,
168 ) -> Option<usize> {
169 if start_line == 0 || start_line > end_line || end_line > self.indentation.len() {
170 return None;
171 }
172
173 let min_continuation_indent = if parent_bq_level > 0 {
176 parent_content_column.saturating_sub(parent_bq_prefix_len)
177 } else {
178 parent_content_column
179 };
180
181 let start_idx = start_line - 1;
183 let end_idx = end_line - 1;
184
185 for idx in start_idx..=end_idx {
186 if !self.has_content(idx) || self.is_list_item(idx) {
188 continue;
189 }
190
191 let line_bq_level = self.blockquote_levels.get(idx).copied().unwrap_or(0);
193 let raw_indent = self.indentation[idx];
194 let effective_indent = if line_bq_level == parent_bq_level && parent_bq_level > 0 {
195 effective_indent_in_blockquote(&self.line_contents[idx], parent_bq_level, raw_indent)
196 } else {
197 raw_indent
198 };
199
200 if effective_indent >= min_continuation_indent {
203 return Some(effective_indent);
204 }
205 }
206 None
207 }
208
209 fn has_continuation_content(
214 &self,
215 parent_line: usize,
216 current_line: usize,
217 parent_content_column: usize,
218 parent_bq_level: usize,
219 parent_bq_prefix_len: usize,
220 ) -> bool {
221 if parent_line == 0 || current_line <= parent_line || current_line > self.indentation.len() {
222 return false;
223 }
224
225 let min_continuation_indent = if parent_bq_level > 0 {
228 parent_content_column.saturating_sub(parent_bq_prefix_len)
229 } else {
230 parent_content_column
231 };
232
233 let start_idx = parent_line; let end_idx = current_line - 2; if start_idx > end_idx {
238 return false;
239 }
240
241 for idx in start_idx..=end_idx {
242 if !self.has_content(idx) || self.is_list_item(idx) {
244 continue;
245 }
246
247 let line_bq_level = self.blockquote_levels.get(idx).copied().unwrap_or(0);
249 let raw_indent = self.indentation[idx];
250 let effective_indent = if line_bq_level == parent_bq_level && parent_bq_level > 0 {
251 effective_indent_in_blockquote(&self.line_contents[idx], parent_bq_level, raw_indent)
252 } else {
253 raw_indent
254 };
255
256 if effective_indent >= min_continuation_indent {
259 return true;
260 }
261 }
262 false
263 }
264}
265
266impl MD005ListIndent {
267 const LIST_GROUP_GAP_TOLERANCE: usize = 2;
271
272 const MIN_CHILD_INDENT_INCREASE: usize = 2;
275
276 const SAME_LEVEL_TOLERANCE: i32 = 1;
279
280 const STANDARD_CONTINUATION_OFFSET: usize = 2;
283
284 fn create_indent_warning(
286 &self,
287 ctx: &crate::lint_context::LintContext,
288 line_num: usize,
289 line_info: &crate::lint_context::LineInfo,
290 actual_indent: usize,
291 expected_indent: usize,
292 ) -> LintWarning {
293 let message = format!(
294 "Expected indentation of {} {}, found {}",
295 expected_indent,
296 if expected_indent == 1 { "space" } else { "spaces" },
297 actual_indent
298 );
299
300 let (start_line, start_col, end_line, end_col) = if actual_indent > 0 {
301 calculate_match_range(line_num, line_info.content(ctx.content), 0, actual_indent)
302 } else {
303 calculate_match_range(line_num, line_info.content(ctx.content), 0, 1)
304 };
305
306 let (fix_range, replacement) = if line_info.blockquote.is_some() {
309 let start_byte = line_info.byte_offset;
311 let mut end_byte = line_info.byte_offset;
312
313 let marker_column = line_info
315 .list_item
316 .as_ref()
317 .map(|li| li.marker_column)
318 .unwrap_or(actual_indent);
319
320 for (i, ch) in line_info.content(ctx.content).chars().enumerate() {
322 if i >= marker_column {
323 break;
324 }
325 end_byte += ch.len_utf8();
326 }
327
328 let mut blockquote_count = 0;
330 for ch in line_info.content(ctx.content).chars() {
331 if ch == '>' {
332 blockquote_count += 1;
333 } else if ch != ' ' && ch != '\t' {
334 break;
335 }
336 }
337
338 let blockquote_prefix = if blockquote_count > 1 {
340 (0..blockquote_count)
341 .map(|_| "> ")
342 .collect::<String>()
343 .trim_end()
344 .to_string()
345 } else {
346 ">".to_string()
347 };
348
349 let correct_indent = " ".repeat(expected_indent);
351 let replacement = format!("{blockquote_prefix} {correct_indent}");
352
353 (start_byte..end_byte, replacement)
354 } else {
355 let fix_range = if actual_indent > 0 {
357 let start_byte = ctx.line_offsets.get(line_num - 1).copied().unwrap_or(0);
358 let end_byte = start_byte + actual_indent;
359 start_byte..end_byte
360 } else {
361 let byte_pos = ctx.line_offsets.get(line_num - 1).copied().unwrap_or(0);
362 byte_pos..byte_pos
363 };
364
365 let replacement = if expected_indent > 0 {
366 " ".repeat(expected_indent)
367 } else {
368 String::new()
369 };
370
371 (fix_range, replacement)
372 };
373
374 LintWarning {
375 rule_name: Some(self.name().to_string()),
376 line: start_line,
377 column: start_col,
378 end_line,
379 end_column: end_col,
380 message,
381 severity: Severity::Warning,
382 fix: Some(Fix {
383 range: fix_range,
384 replacement,
385 }),
386 }
387 }
388
389 fn check_indent_consistency(
392 &self,
393 ctx: &crate::lint_context::LintContext,
394 items: &[(usize, usize, &crate::lint_context::LineInfo)],
395 warnings: &mut Vec<LintWarning>,
396 ) {
397 if items.len() < 2 {
398 return;
399 }
400
401 let mut sorted_items: Vec<_> = items.iter().collect();
403 sorted_items.sort_by_key(|(line_num, _, _)| *line_num);
404
405 let indents: std::collections::HashSet<usize> = sorted_items.iter().map(|(_, indent, _)| *indent).collect();
406
407 if indents.len() > 1 {
408 let expected_indent = sorted_items.first().map(|(_, i, _)| *i).unwrap_or(0);
411
412 for (line_num, indent, line_info) in items {
413 if *indent != expected_indent {
414 warnings.push(self.create_indent_warning(ctx, *line_num, line_info, *indent, expected_indent));
415 }
416 }
417 }
418 }
419
420 fn group_by_parent_content_column<'a>(
427 &self,
428 level: usize,
429 group: &[(usize, usize, &'a crate::lint_context::LineInfo)],
430 all_list_items: &[(
431 usize,
432 usize,
433 &crate::lint_context::LineInfo,
434 &crate::lint_context::ListItemInfo,
435 )],
436 level_map: &HashMap<usize, usize>,
437 ) -> ParentContentGroups<'a> {
438 let parent_level = level - 1;
439
440 let is_ordered_map: HashMap<usize, bool> = all_list_items
442 .iter()
443 .map(|(ln, _, _, item)| (*ln, item.is_ordered))
444 .collect();
445
446 let mut parent_content_groups: ParentContentGroups<'a> = HashMap::new();
447
448 for (line_num, indent, line_info) in group {
449 let item_is_ordered = is_ordered_map.get(line_num).copied().unwrap_or(false);
450
451 let mut parent_content_col: Option<usize> = None;
453
454 for (prev_line, _, _, list_item) in all_list_items.iter().rev() {
455 if *prev_line >= *line_num {
456 continue;
457 }
458 if let Some(&prev_level) = level_map.get(prev_line)
459 && prev_level == parent_level
460 {
461 parent_content_col = Some(list_item.content_column);
462 break;
463 }
464 }
465
466 if let Some(parent_col) = parent_content_col {
467 parent_content_groups
468 .entry((parent_col, item_is_ordered))
469 .or_default()
470 .push((*line_num, *indent, *line_info));
471 }
472 }
473
474 parent_content_groups
475 }
476
477 fn group_related_list_blocks<'a>(
479 &self,
480 list_blocks: &'a [crate::lint_context::ListBlock],
481 ) -> Vec<Vec<&'a crate::lint_context::ListBlock>> {
482 if list_blocks.is_empty() {
483 return Vec::new();
484 }
485
486 let mut groups = Vec::new();
487 let mut current_group = vec![&list_blocks[0]];
488
489 for i in 1..list_blocks.len() {
490 let prev_block = &list_blocks[i - 1];
491 let current_block = &list_blocks[i];
492
493 let line_gap = current_block.start_line.saturating_sub(prev_block.end_line);
495
496 if line_gap <= Self::LIST_GROUP_GAP_TOLERANCE {
499 current_group.push(current_block);
500 } else {
501 groups.push(current_group);
503 current_group = vec![current_block];
504 }
505 }
506 groups.push(current_group);
507
508 groups
509 }
510
511 fn is_continuation_content(
514 &self,
515 ctx: &crate::lint_context::LintContext,
516 cache: &LineCacheInfo,
517 list_line: usize,
518 list_indent: usize,
519 ) -> bool {
520 let parent_line = cache.parent_map.get(&list_line).copied();
522
523 if let Some(parent_line) = parent_line
524 && let Some(line_info) = ctx.line_info(parent_line)
525 && let Some(parent_list_item) = &line_info.list_item
526 {
527 let parent_marker_column = parent_list_item.marker_column;
528 let parent_content_column = parent_list_item.content_column;
529
530 let parent_bq_level = line_info.blockquote.as_ref().map(|bq| bq.nesting_level).unwrap_or(0);
532 let parent_bq_prefix_len = line_info.blockquote.as_ref().map(|bq| bq.prefix.len()).unwrap_or(0);
533
534 let continuation_indent = cache.find_continuation_indent(
536 parent_line + 1,
537 list_line - 1,
538 parent_content_column,
539 parent_bq_level,
540 parent_bq_prefix_len,
541 );
542
543 if let Some(continuation_indent) = continuation_indent {
544 let is_standard_continuation =
545 list_indent == parent_content_column + Self::STANDARD_CONTINUATION_OFFSET;
546 let matches_content_indent = list_indent == continuation_indent;
547
548 if matches_content_indent || is_standard_continuation {
549 return true;
550 }
551 }
552
553 if list_indent > parent_marker_column {
556 if self.has_continuation_list_at_indent(
558 ctx,
559 cache,
560 parent_line,
561 list_line,
562 list_indent,
563 parent_content_column,
564 ) {
565 return true;
566 }
567
568 let (parent_bq_level, parent_bq_prefix_len) = cache.blockquote_info(parent_line);
570 if cache.has_continuation_content(
571 parent_line,
572 list_line,
573 parent_content_column,
574 parent_bq_level,
575 parent_bq_prefix_len,
576 ) {
577 return true;
578 }
579 }
580 }
581
582 false
583 }
584
585 fn has_continuation_list_at_indent(
587 &self,
588 ctx: &crate::lint_context::LintContext,
589 cache: &LineCacheInfo,
590 parent_line: usize,
591 current_line: usize,
592 list_indent: usize,
593 parent_content_column: usize,
594 ) -> bool {
595 let (parent_bq_level, parent_bq_prefix_len) = cache.blockquote_info(parent_line);
597
598 for line_num in (parent_line + 1)..current_line {
601 if let Some(line_info) = ctx.line_info(line_num)
602 && let Some(list_item) = &line_info.list_item
603 && list_item.marker_column == list_indent
604 {
605 if cache
607 .find_continuation_indent(
608 parent_line + 1,
609 line_num - 1,
610 parent_content_column,
611 parent_bq_level,
612 parent_bq_prefix_len,
613 )
614 .is_some()
615 {
616 return true;
617 }
618 }
619 }
620 false
621 }
622
623 fn check_list_block_group(
625 &self,
626 ctx: &crate::lint_context::LintContext,
627 group: &[&crate::lint_context::ListBlock],
628 warnings: &mut Vec<LintWarning>,
629 ) -> Result<(), LintError> {
630 let cache = LineCacheInfo::new(ctx);
632
633 let mut candidate_items: Vec<(
636 usize,
637 usize,
638 &crate::lint_context::LineInfo,
639 &crate::lint_context::ListItemInfo,
640 )> = Vec::new();
641
642 for list_block in group {
643 for &item_line in &list_block.item_lines {
644 if let Some(line_info) = ctx.line_info(item_line)
645 && let Some(list_item) = &line_info.list_item
646 {
647 let effective_indent = if let Some(blockquote) = &line_info.blockquote {
649 list_item.marker_column.saturating_sub(blockquote.nesting_level * 2)
651 } else {
652 list_item.marker_column
654 };
655
656 candidate_items.push((item_line, effective_indent, line_info, list_item));
657 }
658 }
659 }
660
661 candidate_items.sort_by_key(|(line_num, _, _, _)| *line_num);
663
664 let mut skipped_lines: std::collections::HashSet<usize> = std::collections::HashSet::new();
667 let mut all_list_items: Vec<(
668 usize,
669 usize,
670 &crate::lint_context::LineInfo,
671 &crate::lint_context::ListItemInfo,
672 )> = Vec::new();
673
674 for (item_line, effective_indent, line_info, list_item) in candidate_items {
675 if self.is_continuation_content(ctx, &cache, item_line, effective_indent) {
677 skipped_lines.insert(item_line);
678 continue;
679 }
680
681 if let Some(&parent_line) = cache.parent_map.get(&item_line)
683 && skipped_lines.contains(&parent_line)
684 {
685 skipped_lines.insert(item_line);
686 continue;
687 }
688
689 all_list_items.push((item_line, effective_indent, line_info, list_item));
690 }
691
692 if all_list_items.is_empty() {
693 return Ok(());
694 }
695
696 all_list_items.sort_by_key(|(line_num, _, _, _)| *line_num);
698
699 let mut level_map: HashMap<usize, usize> = HashMap::new();
703 let mut level_indents: HashMap<usize, Vec<usize>> = HashMap::new(); let mut indent_to_level: HashMap<usize, (usize, usize)> = HashMap::new();
708
709 for (line_num, indent, _, _) in &all_list_items {
711 let level = if indent_to_level.is_empty() {
712 level_indents.entry(1).or_default().push(*indent);
714 1
715 } else {
716 let mut determined_level = 0;
718
719 if let Some(&(existing_level, _)) = indent_to_level.get(indent) {
721 determined_level = existing_level;
722 } else {
723 let mut best_parent: Option<(usize, usize, usize)> = None; for (&tracked_indent, &(tracked_level, tracked_line)) in &indent_to_level {
729 if tracked_indent < *indent {
730 if best_parent.is_none() || tracked_indent > best_parent.unwrap().0 {
733 best_parent = Some((tracked_indent, tracked_level, tracked_line));
734 }
735 }
736 }
737
738 if let Some((parent_indent, parent_level, _parent_line)) = best_parent {
739 if parent_indent + Self::MIN_CHILD_INDENT_INCREASE <= *indent {
741 determined_level = parent_level + 1;
743 } else if (*indent as i32 - parent_indent as i32).abs() <= Self::SAME_LEVEL_TOLERANCE {
744 determined_level = parent_level;
746 } else {
747 let mut found_similar = false;
751 if let Some(indents_at_level) = level_indents.get(&parent_level) {
752 for &level_indent in indents_at_level {
753 if (level_indent as i32 - *indent as i32).abs() <= Self::SAME_LEVEL_TOLERANCE {
754 determined_level = parent_level;
755 found_similar = true;
756 break;
757 }
758 }
759 }
760 if !found_similar {
761 determined_level = parent_level + 1;
763 }
764 }
765 }
766
767 if determined_level == 0 {
769 determined_level = 1;
770 }
771
772 level_indents.entry(determined_level).or_default().push(*indent);
774 }
775
776 determined_level
777 };
778
779 level_map.insert(*line_num, level);
780 indent_to_level.insert(*indent, (level, *line_num));
782 }
783
784 let mut level_groups: HashMap<usize, Vec<(usize, usize, &crate::lint_context::LineInfo)>> = HashMap::new();
786 for (line_num, indent, line_info, _) in &all_list_items {
787 let level = level_map[line_num];
788 level_groups
789 .entry(level)
790 .or_default()
791 .push((*line_num, *indent, *line_info));
792 }
793
794 for (level, mut group) in level_groups {
796 group.sort_by_key(|(line_num, _, _)| *line_num);
797
798 if level == 1 {
799 for (line_num, indent, line_info) in &group {
801 if *indent != self.top_level_indent {
802 warnings.push(self.create_indent_warning(
803 ctx,
804 *line_num,
805 line_info,
806 *indent,
807 self.top_level_indent,
808 ));
809 }
810 }
811 } else {
812 let parent_content_groups =
815 self.group_by_parent_content_column(level, &group, &all_list_items, &level_map);
816
817 for items in parent_content_groups.values() {
819 self.check_indent_consistency(ctx, items, warnings);
820 }
821 }
822 }
823
824 Ok(())
825 }
826
827 fn check_optimized(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
829 let content = ctx.content;
830
831 if content.is_empty() {
833 return Ok(Vec::new());
834 }
835
836 if ctx.list_blocks.is_empty() {
838 return Ok(Vec::new());
839 }
840
841 let mut warnings = Vec::new();
842
843 let block_groups = self.group_related_list_blocks(&ctx.list_blocks);
846
847 for group in block_groups {
848 self.check_list_block_group(ctx, &group, &mut warnings)?;
849 }
850
851 Ok(warnings)
852 }
853}
854
855impl Rule for MD005ListIndent {
856 fn name(&self) -> &'static str {
857 "MD005"
858 }
859
860 fn description(&self) -> &'static str {
861 "List indentation should be consistent"
862 }
863
864 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
865 self.check_optimized(ctx)
867 }
868
869 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
870 let warnings = self.check(ctx)?;
871 if warnings.is_empty() {
872 return Ok(ctx.content.to_string());
873 }
874
875 let mut warnings_with_fixes: Vec<_> = warnings
877 .into_iter()
878 .filter_map(|w| w.fix.clone().map(|fix| (w, fix)))
879 .collect();
880 warnings_with_fixes.sort_by_key(|(_, fix)| std::cmp::Reverse(fix.range.start));
881
882 let mut content = ctx.content.to_string();
884 for (_, fix) in warnings_with_fixes {
885 if fix.range.start <= content.len() && fix.range.end <= content.len() {
886 content.replace_range(fix.range, &fix.replacement);
887 }
888 }
889
890 Ok(content)
891 }
892
893 fn category(&self) -> RuleCategory {
894 RuleCategory::List
895 }
896
897 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
899 ctx.content.is_empty() || !ctx.lines.iter().any(|line| line.list_item.is_some())
901 }
902
903 fn as_any(&self) -> &dyn std::any::Any {
904 self
905 }
906
907 fn default_config_section(&self) -> Option<(String, toml::Value)> {
908 None
909 }
910
911 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
912 where
913 Self: Sized,
914 {
915 let mut top_level_indent = 0;
917
918 if let Some(md007_config) = config.rules.get("MD007") {
920 if let Some(start_indented) = md007_config.values.get("start-indented")
922 && let Some(start_indented_bool) = start_indented.as_bool()
923 && start_indented_bool
924 {
925 if let Some(start_indent) = md007_config.values.get("start-indent") {
927 if let Some(indent_value) = start_indent.as_integer() {
928 top_level_indent = indent_value as usize;
929 }
930 } else {
931 top_level_indent = 2;
933 }
934 }
935 }
936
937 Box::new(MD005ListIndent { top_level_indent })
938 }
939}
940
941#[cfg(test)]
942mod tests {
943 use super::*;
944 use crate::lint_context::LintContext;
945
946 #[test]
947 fn test_valid_unordered_list() {
948 let rule = MD005ListIndent::default();
949 let content = "\
950* Item 1
951* Item 2
952 * Nested 1
953 * Nested 2
954* Item 3";
955 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
956 let result = rule.check(&ctx).unwrap();
957 assert!(result.is_empty());
958 }
959
960 #[test]
961 fn test_valid_ordered_list() {
962 let rule = MD005ListIndent::default();
963 let content = "\
9641. Item 1
9652. Item 2
966 1. Nested 1
967 2. Nested 2
9683. Item 3";
969 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
970 let result = rule.check(&ctx).unwrap();
971 assert!(result.is_empty());
974 }
975
976 #[test]
977 fn test_invalid_unordered_indent() {
978 let rule = MD005ListIndent::default();
979 let content = "\
980* Item 1
981 * Item 2
982 * Nested 1";
983 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
984 let result = rule.check(&ctx).unwrap();
985 assert_eq!(result.len(), 1);
988 let fixed = rule.fix(&ctx).unwrap();
989 assert_eq!(fixed, "* Item 1\n* Item 2\n * Nested 1");
990 }
991
992 #[test]
993 fn test_invalid_ordered_indent() {
994 let rule = MD005ListIndent::default();
995 let content = "\
9961. Item 1
997 2. Item 2
998 1. Nested 1";
999 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1000 let result = rule.check(&ctx).unwrap();
1001 assert_eq!(result.len(), 1);
1002 let fixed = rule.fix(&ctx).unwrap();
1003 assert_eq!(fixed, "1. Item 1\n2. Item 2\n 1. Nested 1");
1007 }
1008
1009 #[test]
1010 fn test_mixed_list_types() {
1011 let rule = MD005ListIndent::default();
1012 let content = "\
1013* Item 1
1014 1. Nested ordered
1015 * Nested unordered
1016* Item 2";
1017 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1018 let result = rule.check(&ctx).unwrap();
1019 assert!(result.is_empty());
1020 }
1021
1022 #[test]
1023 fn test_multiple_levels() {
1024 let rule = MD005ListIndent::default();
1025 let content = "\
1026* Level 1
1027 * Level 2
1028 * Level 3";
1029 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1030 let result = rule.check(&ctx).unwrap();
1031 assert!(result.is_empty(), "MD005 should accept consistent indentation pattern");
1033 }
1034
1035 #[test]
1036 fn test_empty_lines() {
1037 let rule = MD005ListIndent::default();
1038 let content = "\
1039* Item 1
1040
1041 * Nested 1
1042
1043* Item 2";
1044 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1045 let result = rule.check(&ctx).unwrap();
1046 assert!(result.is_empty());
1047 }
1048
1049 #[test]
1050 fn test_no_lists() {
1051 let rule = MD005ListIndent::default();
1052 let content = "\
1053Just some text
1054More text
1055Even more text";
1056 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1057 let result = rule.check(&ctx).unwrap();
1058 assert!(result.is_empty());
1059 }
1060
1061 #[test]
1062 fn test_complex_nesting() {
1063 let rule = MD005ListIndent::default();
1064 let content = "\
1065* Level 1
1066 * Level 2
1067 * Level 3
1068 * Back to 2
1069 1. Ordered 3
1070 2. Still 3
1071* Back to 1";
1072 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1073 let result = rule.check(&ctx).unwrap();
1074 assert!(result.is_empty());
1075 }
1076
1077 #[test]
1078 fn test_invalid_complex_nesting() {
1079 let rule = MD005ListIndent::default();
1080 let content = "\
1081* Level 1
1082 * Level 2
1083 * Level 3
1084 * Back to 2
1085 1. Ordered 3
1086 2. Still 3
1087* Back to 1";
1088 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1089 let result = rule.check(&ctx).unwrap();
1090 assert_eq!(result.len(), 1);
1092 assert!(
1093 result[0].message.contains("Expected indentation of 5 spaces, found 6")
1094 || result[0].message.contains("Expected indentation of 6 spaces, found 5")
1095 );
1096 }
1097
1098 #[test]
1099 fn test_with_lint_context() {
1100 let rule = MD005ListIndent::default();
1101
1102 let content = "* Item 1\n* Item 2\n * Nested item\n * Another nested item";
1104 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1105 let result = rule.check(&ctx).unwrap();
1106 assert!(result.is_empty());
1107
1108 let content = "* Item 1\n* Item 2\n * Nested item\n * Another nested item";
1110 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1111 let result = rule.check(&ctx).unwrap();
1112 assert!(!result.is_empty()); let content = "* Item 1\n * Nested item\n * Another nested item with wrong indent";
1116 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1117 let result = rule.check(&ctx).unwrap();
1118 assert!(!result.is_empty()); }
1120
1121 #[test]
1123 fn test_list_with_continuations() {
1124 let rule = MD005ListIndent::default();
1125 let content = "\
1126* Item 1
1127 This is a continuation
1128 of the first item
1129 * Nested item
1130 with its own continuation
1131* Item 2";
1132 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1133 let result = rule.check(&ctx).unwrap();
1134 assert!(result.is_empty());
1135 }
1136
1137 #[test]
1138 fn test_list_in_blockquote() {
1139 let rule = MD005ListIndent::default();
1140 let content = "\
1141> * Item 1
1142> * Nested 1
1143> * Nested 2
1144> * Item 2";
1145 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1146 let result = rule.check(&ctx).unwrap();
1147
1148 assert!(
1150 result.is_empty(),
1151 "Expected no warnings for correctly indented blockquote list, got: {result:?}"
1152 );
1153 }
1154
1155 #[test]
1156 fn test_list_with_code_blocks() {
1157 let rule = MD005ListIndent::default();
1158 let content = "\
1159* Item 1
1160 ```
1161 code block
1162 ```
1163 * Nested item
1164* Item 2";
1165 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1166 let result = rule.check(&ctx).unwrap();
1167 assert!(result.is_empty());
1168 }
1169
1170 #[test]
1171 fn test_list_with_tabs() {
1172 let rule = MD005ListIndent::default();
1173 let content = "* Item 1\n * Wrong indent (3 spaces)\n * Correct indent (2 spaces)";
1177 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1178 let result = rule.check(&ctx).unwrap();
1179 assert!(!result.is_empty());
1181 }
1182
1183 #[test]
1184 fn test_inconsistent_at_same_level() {
1185 let rule = MD005ListIndent::default();
1186 let content = "\
1187* Item 1
1188 * Nested 1
1189 * Nested 2
1190 * Wrong indent for same level
1191 * Nested 3";
1192 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1193 let result = rule.check(&ctx).unwrap();
1194 assert!(!result.is_empty());
1195 assert!(result.iter().any(|w| w.line == 4));
1197 }
1198
1199 #[test]
1200 fn test_zero_indent_top_level() {
1201 let rule = MD005ListIndent::default();
1202 let content = concat!(" * Wrong indent\n", "* Correct\n", " * Nested");
1204 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1205 let result = rule.check(&ctx).unwrap();
1206
1207 assert!(!result.is_empty());
1209 assert!(result.iter().any(|w| w.line == 1));
1210 }
1211
1212 #[test]
1213 fn test_fix_preserves_content() {
1214 let rule = MD005ListIndent::default();
1215 let content = "\
1216* Item with **bold** and *italic*
1217 * Wrong indent with `code`
1218 * Also wrong with [link](url)";
1219 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1220 let fixed = rule.fix(&ctx).unwrap();
1221 assert!(fixed.contains("**bold**"));
1222 assert!(fixed.contains("*italic*"));
1223 assert!(fixed.contains("`code`"));
1224 assert!(fixed.contains("[link](url)"));
1225 }
1226
1227 #[test]
1228 fn test_deeply_nested_lists() {
1229 let rule = MD005ListIndent::default();
1230 let content = "\
1231* L1
1232 * L2
1233 * L3
1234 * L4
1235 * L5
1236 * L6";
1237 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1238 let result = rule.check(&ctx).unwrap();
1239 assert!(result.is_empty());
1240 }
1241
1242 #[test]
1243 fn test_fix_multiple_issues() {
1244 let rule = MD005ListIndent::default();
1245 let content = "\
1246* Item 1
1247 * Wrong 1
1248 * Wrong 2
1249 * Wrong 3
1250 * Correct
1251 * Wrong 4";
1252 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1253 let fixed = rule.fix(&ctx).unwrap();
1254 let lines: Vec<&str> = fixed.lines().collect();
1256 assert_eq!(lines[0], "* Item 1");
1257 assert!(lines[1].starts_with(" * ") || lines[1].starts_with("* "));
1259 }
1260
1261 #[test]
1262 fn test_performance_large_document() {
1263 let rule = MD005ListIndent::default();
1264 let mut content = String::new();
1265 for i in 0..100 {
1266 content.push_str(&format!("* Item {i}\n"));
1267 content.push_str(&format!(" * Nested {i}\n"));
1268 }
1269 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1270 let result = rule.check(&ctx).unwrap();
1271 assert!(result.is_empty());
1272 }
1273
1274 #[test]
1275 fn test_column_positions() {
1276 let rule = MD005ListIndent::default();
1277 let content = " * Wrong indent";
1278 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1279 let result = rule.check(&ctx).unwrap();
1280 assert_eq!(result.len(), 1);
1281 assert_eq!(result[0].column, 1, "Expected column 1, got {}", result[0].column);
1282 assert_eq!(
1283 result[0].end_column, 2,
1284 "Expected end_column 2, got {}",
1285 result[0].end_column
1286 );
1287 }
1288
1289 #[test]
1290 fn test_should_skip() {
1291 let rule = MD005ListIndent::default();
1292
1293 let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard, None);
1295 assert!(rule.should_skip(&ctx));
1296
1297 let ctx = LintContext::new("Just plain text", crate::config::MarkdownFlavor::Standard, None);
1299 assert!(rule.should_skip(&ctx));
1300
1301 let ctx = LintContext::new("* List item", crate::config::MarkdownFlavor::Standard, None);
1303 assert!(!rule.should_skip(&ctx));
1304
1305 let ctx = LintContext::new("1. Ordered list", crate::config::MarkdownFlavor::Standard, None);
1306 assert!(!rule.should_skip(&ctx));
1307 }
1308
1309 #[test]
1310 fn test_should_skip_validation() {
1311 let rule = MD005ListIndent::default();
1312 let content = "* List item";
1313 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1314 assert!(!rule.should_skip(&ctx));
1315
1316 let content = "No lists here";
1317 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1318 assert!(rule.should_skip(&ctx));
1319 }
1320
1321 #[test]
1322 fn test_edge_case_single_space_indent() {
1323 let rule = MD005ListIndent::default();
1324 let content = "\
1325* Item 1
1326 * Single space - wrong
1327 * Two spaces - correct";
1328 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1329 let result = rule.check(&ctx).unwrap();
1330 assert_eq!(result.len(), 2);
1333 assert!(result.iter().any(|w| w.line == 2 && w.message.contains("found 1")));
1334 }
1335
1336 #[test]
1337 fn test_edge_case_three_space_indent() {
1338 let rule = MD005ListIndent::default();
1339 let content = "\
1340* Item 1
1341 * Three spaces - first establishes pattern
1342 * Two spaces - inconsistent with established pattern";
1343 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1344 let result = rule.check(&ctx).unwrap();
1345 assert_eq!(result.len(), 1);
1349 assert!(result.iter().any(|w| w.line == 3 && w.message.contains("found 2")));
1350 }
1351
1352 #[test]
1353 fn test_nested_bullets_under_numbered_items() {
1354 let rule = MD005ListIndent::default();
1355 let content = "\
13561. **Active Directory/LDAP**
1357 - User authentication and directory services
1358 - LDAP for user information and validation
1359
13602. **Oracle Unified Directory (OUD)**
1361 - Extended user directory services
1362 - Verification of project account presence and changes";
1363 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1364 let result = rule.check(&ctx).unwrap();
1365 assert!(
1367 result.is_empty(),
1368 "Expected no warnings for bullets with 3 spaces under numbered items, got: {result:?}"
1369 );
1370 }
1371
1372 #[test]
1373 fn test_nested_bullets_under_numbered_items_wrong_indent() {
1374 let rule = MD005ListIndent::default();
1375 let content = "\
13761. **Active Directory/LDAP**
1377 - Wrong: only 2 spaces
1378 - Correct: 3 spaces";
1379 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1380 let result = rule.check(&ctx).unwrap();
1381 assert_eq!(
1383 result.len(),
1384 1,
1385 "Expected 1 warning, got {}. Warnings: {:?}",
1386 result.len(),
1387 result
1388 );
1389 assert!(
1391 result
1392 .iter()
1393 .any(|w| (w.line == 2 && w.message.contains("found 2"))
1394 || (w.line == 3 && w.message.contains("found 3")))
1395 );
1396 }
1397
1398 #[test]
1399 fn test_regular_nested_bullets_still_work() {
1400 let rule = MD005ListIndent::default();
1401 let content = "\
1402* Top level
1403 * Second level (2 spaces is correct for bullets under bullets)
1404 * Third level (4 spaces)";
1405 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1406 let result = rule.check(&ctx).unwrap();
1407 assert!(
1409 result.is_empty(),
1410 "Expected no warnings for regular bullet nesting, got: {result:?}"
1411 );
1412 }
1413
1414 #[test]
1415 fn test_fix_range_accuracy() {
1416 let rule = MD005ListIndent::default();
1417 let content = " * Wrong indent";
1418 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1419 let result = rule.check(&ctx).unwrap();
1420 assert_eq!(result.len(), 1);
1421
1422 let fix = result[0].fix.as_ref().unwrap();
1423 assert_eq!(fix.replacement, "");
1425 }
1426
1427 #[test]
1428 fn test_four_space_indent_pattern() {
1429 let rule = MD005ListIndent::default();
1430 let content = "\
1431* Item 1
1432 * Item 2 with 4 spaces
1433 * Item 3 with 8 spaces
1434 * Item 4 with 4 spaces";
1435 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1436 let result = rule.check(&ctx).unwrap();
1437 assert!(
1439 result.is_empty(),
1440 "MD005 should accept consistent 4-space indentation pattern, got {} warnings",
1441 result.len()
1442 );
1443 }
1444
1445 #[test]
1446 fn test_issue_64_scenario() {
1447 let rule = MD005ListIndent::default();
1449 let content = "\
1450* Top level item
1451 * Sub item with 4 spaces (as configured in MD007)
1452 * Nested sub item with 8 spaces
1453 * Another sub item with 4 spaces
1454* Another top level";
1455
1456 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1457 let result = rule.check(&ctx).unwrap();
1458
1459 assert!(
1461 result.is_empty(),
1462 "MD005 should accept 4-space indentation when that's the pattern being used. Got {} warnings",
1463 result.len()
1464 );
1465 }
1466
1467 #[test]
1468 fn test_continuation_content_scenario() {
1469 let rule = MD005ListIndent::default();
1470 let content = "\
1471- **Changes to how the Python version is inferred** ([#16319](example))
1472
1473 In previous versions of Ruff, you could specify your Python version with:
1474
1475 - The `target-version` option in a `ruff.toml` file
1476 - The `project.requires-python` field in a `pyproject.toml` file";
1477
1478 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1479
1480 let result = rule.check(&ctx).unwrap();
1481
1482 assert!(
1484 result.is_empty(),
1485 "MD005 should not flag continuation content lists, got {} warnings: {:?}",
1486 result.len(),
1487 result
1488 );
1489 }
1490
1491 #[test]
1492 fn test_multiple_continuation_lists_scenario() {
1493 let rule = MD005ListIndent::default();
1494 let content = "\
1495- **Changes to how the Python version is inferred** ([#16319](example))
1496
1497 In previous versions of Ruff, you could specify your Python version with:
1498
1499 - The `target-version` option in a `ruff.toml` file
1500 - The `project.requires-python` field in a `pyproject.toml` file
1501
1502 In v0.10, config discovery has been updated to address this issue:
1503
1504 - If Ruff finds a `ruff.toml` file without a `target-version`, it will check
1505 - If Ruff finds a user-level configuration, the `requires-python` field will take precedence
1506 - If there is no config file, Ruff will search for the closest `pyproject.toml`";
1507
1508 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1509
1510 let result = rule.check(&ctx).unwrap();
1511
1512 assert!(
1514 result.is_empty(),
1515 "MD005 should not flag continuation content lists, got {} warnings: {:?}",
1516 result.len(),
1517 result
1518 );
1519 }
1520
1521 #[test]
1522 fn test_issue_115_sublist_after_code_block() {
1523 let rule = MD005ListIndent::default();
1524 let content = "\
15251. List item 1
1526
1527 ```rust
1528 fn foo() {}
1529 ```
1530
1531 Sublist:
1532
1533 - A
1534 - B
1535";
1536 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1537 let result = rule.check(&ctx).unwrap();
1538 assert!(
1542 result.is_empty(),
1543 "Expected no warnings for sub-list after code block in list item, got {} warnings: {:?}",
1544 result.len(),
1545 result
1546 );
1547 }
1548
1549 #[test]
1550 fn test_edge_case_continuation_at_exact_boundary() {
1551 let rule = MD005ListIndent::default();
1552 let content = "\
1554* Item (content at column 2)
1555 Text at column 2 (exact boundary - continuation)
1556 * Sub at column 2";
1557 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1558 let result = rule.check(&ctx).unwrap();
1559 assert!(
1561 result.is_empty(),
1562 "Expected no warnings when text and sub-list are at exact parent content_column, got: {result:?}"
1563 );
1564 }
1565
1566 #[test]
1567 fn test_edge_case_unicode_in_continuation() {
1568 let rule = MD005ListIndent::default();
1569 let content = "\
1570* Parent
1571 Text with emoji 😀 and Unicode ñ characters
1572 * Sub-list should still work";
1573 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1574 let result = rule.check(&ctx).unwrap();
1575 assert!(
1577 result.is_empty(),
1578 "Expected no warnings with Unicode in continuation content, got: {result:?}"
1579 );
1580 }
1581
1582 #[test]
1583 fn test_edge_case_large_empty_line_gap() {
1584 let rule = MD005ListIndent::default();
1585 let content = "\
1586* Parent at line 1
1587 Continuation text
1588
1589
1590
1591 More continuation after many empty lines
1592
1593 * Child after gap
1594 * Another child";
1595 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1596 let result = rule.check(&ctx).unwrap();
1597 assert!(
1599 result.is_empty(),
1600 "Expected no warnings with large gaps in continuation content, got: {result:?}"
1601 );
1602 }
1603
1604 #[test]
1605 fn test_edge_case_multiple_continuation_blocks_varying_indent() {
1606 let rule = MD005ListIndent::default();
1607 let content = "\
1608* Parent (content at column 2)
1609 First paragraph at column 2
1610 Indented quote at column 4
1611 Back to column 2
1612 * Sub-list at column 2";
1613 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1614 let result = rule.check(&ctx).unwrap();
1615 assert!(
1617 result.is_empty(),
1618 "Expected no warnings with varying continuation indent, got: {result:?}"
1619 );
1620 }
1621
1622 #[test]
1623 fn test_edge_case_deep_nesting_no_continuation() {
1624 let rule = MD005ListIndent::default();
1625 let content = "\
1626* Parent
1627 * Immediate child (no continuation text before)
1628 * Grandchild
1629 * Great-grandchild
1630 * Great-great-grandchild
1631 * Another child at level 2";
1632 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1633 let result = rule.check(&ctx).unwrap();
1634 assert!(
1636 result.is_empty(),
1637 "Expected no warnings for deep nesting without continuation, got: {result:?}"
1638 );
1639 }
1640
1641 #[test]
1642 fn test_edge_case_blockquote_continuation_content() {
1643 let rule = MD005ListIndent::default();
1644 let content = "\
1645> * Parent in blockquote
1646> Continuation in blockquote
1647> * Sub-list in blockquote
1648> * Another sub-list";
1649 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1650 let result = rule.check(&ctx).unwrap();
1651 assert!(
1653 result.is_empty(),
1654 "Expected no warnings for blockquote continuation, got: {result:?}"
1655 );
1656 }
1657
1658 #[test]
1659 fn test_edge_case_one_space_less_than_content_column() {
1660 let rule = MD005ListIndent::default();
1661 let content = "\
1662* Parent (content at column 2)
1663 Text at column 1 (one less than content_column - NOT continuation)
1664 * Child";
1665 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1666 let result = rule.check(&ctx).unwrap();
1667 assert!(
1673 result.is_empty() || !result.is_empty(),
1674 "Test should complete without panic"
1675 );
1676 }
1677
1678 #[test]
1679 fn test_edge_case_multiple_code_blocks_different_indentation() {
1680 let rule = MD005ListIndent::default();
1681 let content = "\
1682* Parent
1683 ```
1684 code at 2 spaces
1685 ```
1686 ```
1687 code at 4 spaces
1688 ```
1689 * Sub-list should not be confused";
1690 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1691 let result = rule.check(&ctx).unwrap();
1692 assert!(
1694 result.is_empty(),
1695 "Expected no warnings with multiple code blocks, got: {result:?}"
1696 );
1697 }
1698
1699 #[test]
1700 fn test_performance_very_large_document() {
1701 let rule = MD005ListIndent::default();
1702 let mut content = String::new();
1703
1704 for i in 0..1000 {
1706 content.push_str(&format!("* Item {i}\n"));
1707 content.push_str(&format!(" * Nested {i}\n"));
1708 if i % 10 == 0 {
1709 content.push_str(" Some continuation text\n");
1710 }
1711 }
1712
1713 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1714
1715 let start = std::time::Instant::now();
1717 let result = rule.check(&ctx).unwrap();
1718 let elapsed = start.elapsed();
1719
1720 assert!(result.is_empty());
1721 println!("Processed 1000 list items in {elapsed:?}");
1722 assert!(
1725 elapsed.as_secs() < 1,
1726 "Should complete in under 1 second, took {elapsed:?}"
1727 );
1728 }
1729
1730 #[test]
1731 fn test_ordered_list_variable_marker_width() {
1732 let rule = MD005ListIndent::default();
1737 let content = "\
17381. One
1739 - One
1740 - Two
17412. Two
1742 - One
17433. Three
1744 - One
17454. Four
1746 - One
17475. Five
1748 - One
17496. Six
1750 - One
17517. Seven
1752 - One
17538. Eight
1754 - One
17559. Nine
1756 - One
175710. Ten
1758 - One";
1759 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1760 let result = rule.check(&ctx).unwrap();
1761 assert!(
1762 result.is_empty(),
1763 "Expected no warnings for ordered list with variable marker widths, got: {result:?}"
1764 );
1765 }
1766
1767 #[test]
1768 fn test_ordered_list_inconsistent_siblings() {
1769 let rule = MD005ListIndent::default();
1771 let content = "\
17721. Item one
1773 - First sublist at 3 spaces
1774 - Second sublist at 2 spaces (inconsistent)
1775 - Third sublist at 3 spaces";
1776 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1777 let result = rule.check(&ctx).unwrap();
1778 assert_eq!(
1780 result.len(),
1781 1,
1782 "Expected 1 warning for inconsistent sibling indent, got: {result:?}"
1783 );
1784 assert!(result[0].message.contains("Expected indentation of 3"));
1785 }
1786
1787 #[test]
1788 fn test_ordered_list_single_sublist_no_warning() {
1789 let rule = MD005ListIndent::default();
1792 let content = "\
179310. Item ten
1794 - Only sublist at 3 spaces";
1795 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1796 let result = rule.check(&ctx).unwrap();
1797 assert!(
1799 result.is_empty(),
1800 "Expected no warnings for single sublist item, got: {result:?}"
1801 );
1802 }
1803
1804 #[test]
1805 fn test_sublists_grouped_by_parent_content_column() {
1806 let rule = MD005ListIndent::default();
1810 let content = "\
18119. Item nine
1812 - First sublist at 3 spaces
1813 - Second sublist at 3 spaces
1814 - Third sublist at 3 spaces
181510. Item ten
1816 - First sublist at 4 spaces
1817 - Second sublist at 4 spaces
1818 - Third sublist at 4 spaces";
1819 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1820 let result = rule.check(&ctx).unwrap();
1821 assert!(
1824 result.is_empty(),
1825 "Expected no warnings for sublists grouped by parent, got: {result:?}"
1826 );
1827 }
1828
1829 #[test]
1830 fn test_inconsistent_indent_within_parent_group() {
1831 let rule = MD005ListIndent::default();
1833 let content = "\
183410. Item ten
1835 - First sublist at 4 spaces
1836 - Second sublist at 3 spaces (inconsistent!)
1837 - Third sublist at 4 spaces";
1838 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1839 let result = rule.check(&ctx).unwrap();
1840 assert_eq!(
1842 result.len(),
1843 1,
1844 "Expected 1 warning for inconsistent indent within parent group, got: {result:?}"
1845 );
1846 assert!(result[0].line == 3);
1847 assert!(result[0].message.contains("Expected indentation of 4"));
1848 }
1849
1850 #[test]
1851 fn test_blockquote_nested_list_fix_preserves_blockquote_prefix() {
1852 use crate::rule::Rule;
1856
1857 let rule = MD005ListIndent::default();
1858 let content = "> * Federation sender blacklists are now persisted.";
1859 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1860 let result = rule.check(&ctx).unwrap();
1861
1862 assert_eq!(result.len(), 1, "Expected 1 warning for extra indent");
1863
1864 assert!(result[0].fix.is_some(), "Should have a fix");
1866 let fixed = rule.fix(&ctx).expect("Fix should succeed");
1867
1868 assert!(
1870 fixed.starts_with("> "),
1871 "Fixed content should start with blockquote prefix '> ', got: {fixed:?}"
1872 );
1873 assert!(
1874 !fixed.starts_with("* "),
1875 "Fixed content should NOT start with just '* ' (blockquote removed), got: {fixed:?}"
1876 );
1877 assert_eq!(
1878 fixed.trim(),
1879 "> * Federation sender blacklists are now persisted.",
1880 "Fixed content should be '> * Federation sender...' with single space after >"
1881 );
1882 }
1883
1884 #[test]
1885 fn test_nested_blockquote_list_fix_preserves_prefix() {
1886 use crate::rule::Rule;
1888
1889 let rule = MD005ListIndent::default();
1890 let content = ">> * Nested blockquote list item";
1891 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1892 let result = rule.check(&ctx).unwrap();
1893
1894 if !result.is_empty() {
1895 let fixed = rule.fix(&ctx).expect("Fix should succeed");
1896 assert!(
1898 fixed.contains(">>") || fixed.contains("> >"),
1899 "Fixed content should preserve nested blockquote prefix, got: {fixed:?}"
1900 );
1901 }
1902 }
1903}