1use crate::utils::range_utils::calculate_match_range;
7
8use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
9use std::collections::HashMap;
11use toml;
12
13type ParentContentGroups<'a> = HashMap<(usize, bool), Vec<(usize, usize, &'a crate::lint_context::LineInfo)>>;
16
17#[derive(Clone, Default)]
19pub struct MD005ListIndent {
20 top_level_indent: usize,
22}
23
24struct LineCacheInfo {
26 indentation: Vec<usize>,
28 flags: Vec<u8>,
30 parent_map: HashMap<usize, usize>,
33}
34
35const FLAG_HAS_CONTENT: u8 = 1;
36const FLAG_IS_LIST_ITEM: u8 = 2;
37
38impl LineCacheInfo {
39 fn new(ctx: &crate::lint_context::LintContext) -> Self {
41 let total_lines = ctx.lines.len();
42 let mut indentation = Vec::with_capacity(total_lines);
43 let mut flags = Vec::with_capacity(total_lines);
44 let mut parent_map = HashMap::new();
45
46 let mut indent_to_line: HashMap<usize, usize> = HashMap::new();
58
59 for (idx, line_info) in ctx.lines.iter().enumerate() {
60 let content = line_info.content(ctx.content).trim_start();
61 let line_indent = line_info.byte_len - content.len();
62
63 indentation.push(line_indent);
64
65 let mut flag = 0u8;
66 if !content.is_empty() {
67 flag |= FLAG_HAS_CONTENT;
68 }
69 if let Some(list_item) = &line_info.list_item {
70 flag |= FLAG_IS_LIST_ITEM;
71
72 let line_num = idx + 1; let marker_column = list_item.marker_column;
74
75 let mut best_parent: Option<(usize, usize)> = None; for (&tracked_indent, &tracked_line) in &indent_to_line {
80 if tracked_indent < marker_column {
81 if best_parent.is_none() || tracked_indent > best_parent.unwrap().0 {
83 best_parent = Some((tracked_indent, tracked_line));
84 }
85 }
86 }
87
88 if let Some((_parent_indent, parent_line)) = best_parent {
89 parent_map.insert(line_num, parent_line);
90 }
91
92 indent_to_line.retain(|&indent, _| indent < marker_column);
97 indent_to_line.insert(marker_column, line_num);
98 }
99 flags.push(flag);
100 }
101
102 Self {
103 indentation,
104 flags,
105 parent_map,
106 }
107 }
108
109 fn has_content(&self, idx: usize) -> bool {
111 self.flags.get(idx).is_some_and(|&f| f & FLAG_HAS_CONTENT != 0)
112 }
113
114 fn is_list_item(&self, idx: usize) -> bool {
116 self.flags.get(idx).is_some_and(|&f| f & FLAG_IS_LIST_ITEM != 0)
117 }
118
119 fn find_continuation_indent(
121 &self,
122 start_line: usize,
123 end_line: usize,
124 parent_content_column: usize,
125 ) -> Option<usize> {
126 if start_line == 0 || start_line > end_line || end_line > self.indentation.len() {
127 return None;
128 }
129
130 let start_idx = start_line - 1;
132 let end_idx = end_line - 1;
133
134 for idx in start_idx..=end_idx {
135 if !self.has_content(idx) || self.is_list_item(idx) {
137 continue;
138 }
139
140 if self.indentation[idx] >= parent_content_column {
143 return Some(self.indentation[idx]);
144 }
145 }
146 None
147 }
148
149 fn has_continuation_content(&self, parent_line: usize, current_line: usize, parent_content_column: usize) -> bool {
151 if parent_line == 0 || current_line <= parent_line || current_line > self.indentation.len() {
152 return false;
153 }
154
155 let start_idx = parent_line; let end_idx = current_line - 2; if start_idx > end_idx {
160 return false;
161 }
162
163 for idx in start_idx..=end_idx {
164 if !self.has_content(idx) || self.is_list_item(idx) {
166 continue;
167 }
168
169 if self.indentation[idx] >= parent_content_column {
172 return true;
173 }
174 }
175 false
176 }
177}
178
179impl MD005ListIndent {
180 const LIST_GROUP_GAP_TOLERANCE: usize = 2;
184
185 const MIN_CHILD_INDENT_INCREASE: usize = 2;
188
189 const SAME_LEVEL_TOLERANCE: i32 = 1;
192
193 const STANDARD_CONTINUATION_OFFSET: usize = 2;
196
197 fn create_indent_warning(
199 &self,
200 ctx: &crate::lint_context::LintContext,
201 line_num: usize,
202 line_info: &crate::lint_context::LineInfo,
203 actual_indent: usize,
204 expected_indent: usize,
205 ) -> LintWarning {
206 let message = format!(
207 "Expected indentation of {} {}, found {}",
208 expected_indent,
209 if expected_indent == 1 { "space" } else { "spaces" },
210 actual_indent
211 );
212
213 let (start_line, start_col, end_line, end_col) = if actual_indent > 0 {
214 calculate_match_range(line_num, line_info.content(ctx.content), 0, actual_indent)
215 } else {
216 calculate_match_range(line_num, line_info.content(ctx.content), 0, 1)
217 };
218
219 let (fix_range, replacement) = if line_info.blockquote.is_some() {
222 let start_byte = line_info.byte_offset;
224 let mut end_byte = line_info.byte_offset;
225
226 let marker_column = line_info
228 .list_item
229 .as_ref()
230 .map(|li| li.marker_column)
231 .unwrap_or(actual_indent);
232
233 for (i, ch) in line_info.content(ctx.content).chars().enumerate() {
235 if i >= marker_column {
236 break;
237 }
238 end_byte += ch.len_utf8();
239 }
240
241 let mut blockquote_count = 0;
243 for ch in line_info.content(ctx.content).chars() {
244 if ch == '>' {
245 blockquote_count += 1;
246 } else if ch != ' ' && ch != '\t' {
247 break;
248 }
249 }
250
251 let blockquote_prefix = if blockquote_count > 1 {
253 (0..blockquote_count)
254 .map(|_| "> ")
255 .collect::<String>()
256 .trim_end()
257 .to_string()
258 } else {
259 ">".to_string()
260 };
261
262 let correct_indent = " ".repeat(expected_indent);
264 let replacement = format!("{blockquote_prefix} {correct_indent}");
265
266 (start_byte..end_byte, replacement)
267 } else {
268 let fix_range = if actual_indent > 0 {
270 let start_byte = ctx.line_offsets.get(line_num - 1).copied().unwrap_or(0);
271 let end_byte = start_byte + actual_indent;
272 start_byte..end_byte
273 } else {
274 let byte_pos = ctx.line_offsets.get(line_num - 1).copied().unwrap_or(0);
275 byte_pos..byte_pos
276 };
277
278 let replacement = if expected_indent > 0 {
279 " ".repeat(expected_indent)
280 } else {
281 String::new()
282 };
283
284 (fix_range, replacement)
285 };
286
287 LintWarning {
288 rule_name: Some(self.name().to_string()),
289 line: start_line,
290 column: start_col,
291 end_line,
292 end_column: end_col,
293 message,
294 severity: Severity::Warning,
295 fix: Some(Fix {
296 range: fix_range,
297 replacement,
298 }),
299 }
300 }
301
302 fn check_indent_consistency(
305 &self,
306 ctx: &crate::lint_context::LintContext,
307 items: &[(usize, usize, &crate::lint_context::LineInfo)],
308 warnings: &mut Vec<LintWarning>,
309 ) {
310 if items.len() < 2 {
311 return;
312 }
313
314 let mut sorted_items: Vec<_> = items.iter().collect();
316 sorted_items.sort_by_key(|(line_num, _, _)| *line_num);
317
318 let indents: std::collections::HashSet<usize> = sorted_items.iter().map(|(_, indent, _)| *indent).collect();
319
320 if indents.len() > 1 {
321 let expected_indent = sorted_items.first().map(|(_, i, _)| *i).unwrap_or(0);
324
325 for (line_num, indent, line_info) in items {
326 if *indent != expected_indent {
327 warnings.push(self.create_indent_warning(ctx, *line_num, line_info, *indent, expected_indent));
328 }
329 }
330 }
331 }
332
333 fn group_by_parent_content_column<'a>(
340 &self,
341 level: usize,
342 group: &[(usize, usize, &'a crate::lint_context::LineInfo)],
343 all_list_items: &[(
344 usize,
345 usize,
346 &crate::lint_context::LineInfo,
347 &crate::lint_context::ListItemInfo,
348 )],
349 level_map: &HashMap<usize, usize>,
350 ) -> ParentContentGroups<'a> {
351 let parent_level = level - 1;
352
353 let is_ordered_map: HashMap<usize, bool> = all_list_items
355 .iter()
356 .map(|(ln, _, _, item)| (*ln, item.is_ordered))
357 .collect();
358
359 let mut parent_content_groups: ParentContentGroups<'a> = HashMap::new();
360
361 for (line_num, indent, line_info) in group {
362 let item_is_ordered = is_ordered_map.get(line_num).copied().unwrap_or(false);
363
364 let mut parent_content_col: Option<usize> = None;
366
367 for (prev_line, _, _, list_item) in all_list_items.iter().rev() {
368 if *prev_line >= *line_num {
369 continue;
370 }
371 if let Some(&prev_level) = level_map.get(prev_line)
372 && prev_level == parent_level
373 {
374 parent_content_col = Some(list_item.content_column);
375 break;
376 }
377 }
378
379 if let Some(parent_col) = parent_content_col {
380 parent_content_groups
381 .entry((parent_col, item_is_ordered))
382 .or_default()
383 .push((*line_num, *indent, *line_info));
384 }
385 }
386
387 parent_content_groups
388 }
389
390 fn group_related_list_blocks<'a>(
392 &self,
393 list_blocks: &'a [crate::lint_context::ListBlock],
394 ) -> Vec<Vec<&'a crate::lint_context::ListBlock>> {
395 if list_blocks.is_empty() {
396 return Vec::new();
397 }
398
399 let mut groups = Vec::new();
400 let mut current_group = vec![&list_blocks[0]];
401
402 for i in 1..list_blocks.len() {
403 let prev_block = &list_blocks[i - 1];
404 let current_block = &list_blocks[i];
405
406 let line_gap = current_block.start_line.saturating_sub(prev_block.end_line);
408
409 if line_gap <= Self::LIST_GROUP_GAP_TOLERANCE {
412 current_group.push(current_block);
413 } else {
414 groups.push(current_group);
416 current_group = vec![current_block];
417 }
418 }
419 groups.push(current_group);
420
421 groups
422 }
423
424 fn is_continuation_content(
427 &self,
428 ctx: &crate::lint_context::LintContext,
429 cache: &LineCacheInfo,
430 list_line: usize,
431 list_indent: usize,
432 ) -> bool {
433 let parent_line = cache.parent_map.get(&list_line).copied();
435
436 if let Some(parent_line) = parent_line
437 && let Some(line_info) = ctx.line_info(parent_line)
438 && let Some(parent_list_item) = &line_info.list_item
439 {
440 let parent_marker_column = parent_list_item.marker_column;
441 let parent_content_column = parent_list_item.content_column;
442
443 let continuation_indent =
445 cache.find_continuation_indent(parent_line + 1, list_line - 1, parent_content_column);
446
447 if let Some(continuation_indent) = continuation_indent {
448 let is_standard_continuation =
449 list_indent == parent_content_column + Self::STANDARD_CONTINUATION_OFFSET;
450 let matches_content_indent = list_indent == continuation_indent;
451
452 if matches_content_indent || is_standard_continuation {
453 return true;
454 }
455 }
456
457 if list_indent > parent_marker_column {
460 if self.has_continuation_list_at_indent(
462 ctx,
463 cache,
464 parent_line,
465 list_line,
466 list_indent,
467 parent_content_column,
468 ) {
469 return true;
470 }
471
472 if cache.has_continuation_content(parent_line, list_line, parent_content_column) {
473 return true;
474 }
475 }
476 }
477
478 false
479 }
480
481 fn has_continuation_list_at_indent(
483 &self,
484 ctx: &crate::lint_context::LintContext,
485 cache: &LineCacheInfo,
486 parent_line: usize,
487 current_line: usize,
488 list_indent: usize,
489 parent_content_column: usize,
490 ) -> bool {
491 for line_num in (parent_line + 1)..current_line {
494 if let Some(line_info) = ctx.line_info(line_num)
495 && let Some(list_item) = &line_info.list_item
496 && list_item.marker_column == list_indent
497 {
498 if cache
501 .find_continuation_indent(parent_line + 1, line_num - 1, parent_content_column)
502 .is_some()
503 {
504 return true;
505 }
506 }
507 }
508 false
509 }
510
511 fn check_list_block_group(
513 &self,
514 ctx: &crate::lint_context::LintContext,
515 group: &[&crate::lint_context::ListBlock],
516 warnings: &mut Vec<LintWarning>,
517 ) -> Result<(), LintError> {
518 let cache = LineCacheInfo::new(ctx);
520
521 let mut candidate_items: Vec<(
524 usize,
525 usize,
526 &crate::lint_context::LineInfo,
527 &crate::lint_context::ListItemInfo,
528 )> = Vec::new();
529
530 for list_block in group {
531 for &item_line in &list_block.item_lines {
532 if let Some(line_info) = ctx.line_info(item_line)
533 && let Some(list_item) = &line_info.list_item
534 {
535 let effective_indent = if let Some(blockquote) = &line_info.blockquote {
537 list_item.marker_column.saturating_sub(blockquote.nesting_level * 2)
539 } else {
540 list_item.marker_column
542 };
543
544 candidate_items.push((item_line, effective_indent, line_info, list_item));
545 }
546 }
547 }
548
549 candidate_items.sort_by_key(|(line_num, _, _, _)| *line_num);
551
552 let mut skipped_lines: std::collections::HashSet<usize> = std::collections::HashSet::new();
555 let mut all_list_items: Vec<(
556 usize,
557 usize,
558 &crate::lint_context::LineInfo,
559 &crate::lint_context::ListItemInfo,
560 )> = Vec::new();
561
562 for (item_line, effective_indent, line_info, list_item) in candidate_items {
563 if self.is_continuation_content(ctx, &cache, item_line, effective_indent) {
565 skipped_lines.insert(item_line);
566 continue;
567 }
568
569 if let Some(&parent_line) = cache.parent_map.get(&item_line)
571 && skipped_lines.contains(&parent_line)
572 {
573 skipped_lines.insert(item_line);
574 continue;
575 }
576
577 all_list_items.push((item_line, effective_indent, line_info, list_item));
578 }
579
580 if all_list_items.is_empty() {
581 return Ok(());
582 }
583
584 all_list_items.sort_by_key(|(line_num, _, _, _)| *line_num);
586
587 let mut level_map: HashMap<usize, usize> = HashMap::new();
591 let mut level_indents: HashMap<usize, Vec<usize>> = HashMap::new(); let mut indent_to_level: HashMap<usize, (usize, usize)> = HashMap::new();
596
597 for (line_num, indent, _, _) in &all_list_items {
599 let level = if indent_to_level.is_empty() {
600 level_indents.entry(1).or_default().push(*indent);
602 1
603 } else {
604 let mut determined_level = 0;
606
607 if let Some(&(existing_level, _)) = indent_to_level.get(indent) {
609 determined_level = existing_level;
610 } else {
611 let mut best_parent: Option<(usize, usize, usize)> = None; for (&tracked_indent, &(tracked_level, tracked_line)) in &indent_to_level {
617 if tracked_indent < *indent {
618 if best_parent.is_none() || tracked_indent > best_parent.unwrap().0 {
621 best_parent = Some((tracked_indent, tracked_level, tracked_line));
622 }
623 }
624 }
625
626 if let Some((parent_indent, parent_level, _parent_line)) = best_parent {
627 if parent_indent + Self::MIN_CHILD_INDENT_INCREASE <= *indent {
629 determined_level = parent_level + 1;
631 } else if (*indent as i32 - parent_indent as i32).abs() <= Self::SAME_LEVEL_TOLERANCE {
632 determined_level = parent_level;
634 } else {
635 let mut found_similar = false;
639 if let Some(indents_at_level) = level_indents.get(&parent_level) {
640 for &level_indent in indents_at_level {
641 if (level_indent as i32 - *indent as i32).abs() <= Self::SAME_LEVEL_TOLERANCE {
642 determined_level = parent_level;
643 found_similar = true;
644 break;
645 }
646 }
647 }
648 if !found_similar {
649 determined_level = parent_level + 1;
651 }
652 }
653 }
654
655 if determined_level == 0 {
657 determined_level = 1;
658 }
659
660 level_indents.entry(determined_level).or_default().push(*indent);
662 }
663
664 determined_level
665 };
666
667 level_map.insert(*line_num, level);
668 indent_to_level.insert(*indent, (level, *line_num));
670 }
671
672 let mut level_groups: HashMap<usize, Vec<(usize, usize, &crate::lint_context::LineInfo)>> = HashMap::new();
674 for (line_num, indent, line_info, _) in &all_list_items {
675 let level = level_map[line_num];
676 level_groups
677 .entry(level)
678 .or_default()
679 .push((*line_num, *indent, *line_info));
680 }
681
682 for (level, mut group) in level_groups {
684 group.sort_by_key(|(line_num, _, _)| *line_num);
685
686 if level == 1 {
687 for (line_num, indent, line_info) in &group {
689 if *indent != self.top_level_indent {
690 warnings.push(self.create_indent_warning(
691 ctx,
692 *line_num,
693 line_info,
694 *indent,
695 self.top_level_indent,
696 ));
697 }
698 }
699 } else {
700 let parent_content_groups =
703 self.group_by_parent_content_column(level, &group, &all_list_items, &level_map);
704
705 for items in parent_content_groups.values() {
707 self.check_indent_consistency(ctx, items, warnings);
708 }
709 }
710 }
711
712 Ok(())
713 }
714
715 fn check_optimized(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
717 let content = ctx.content;
718
719 if content.is_empty() {
721 return Ok(Vec::new());
722 }
723
724 if ctx.list_blocks.is_empty() {
726 return Ok(Vec::new());
727 }
728
729 let mut warnings = Vec::new();
730
731 let block_groups = self.group_related_list_blocks(&ctx.list_blocks);
734
735 for group in block_groups {
736 self.check_list_block_group(ctx, &group, &mut warnings)?;
737 }
738
739 Ok(warnings)
740 }
741}
742
743impl Rule for MD005ListIndent {
744 fn name(&self) -> &'static str {
745 "MD005"
746 }
747
748 fn description(&self) -> &'static str {
749 "List indentation should be consistent"
750 }
751
752 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
753 self.check_optimized(ctx)
755 }
756
757 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
758 let warnings = self.check(ctx)?;
759 if warnings.is_empty() {
760 return Ok(ctx.content.to_string());
761 }
762
763 let mut warnings_with_fixes: Vec<_> = warnings
765 .into_iter()
766 .filter_map(|w| w.fix.clone().map(|fix| (w, fix)))
767 .collect();
768 warnings_with_fixes.sort_by_key(|(_, fix)| std::cmp::Reverse(fix.range.start));
769
770 let mut content = ctx.content.to_string();
772 for (_, fix) in warnings_with_fixes {
773 if fix.range.start <= content.len() && fix.range.end <= content.len() {
774 content.replace_range(fix.range, &fix.replacement);
775 }
776 }
777
778 Ok(content)
779 }
780
781 fn category(&self) -> RuleCategory {
782 RuleCategory::List
783 }
784
785 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
787 ctx.content.is_empty() || !ctx.lines.iter().any(|line| line.list_item.is_some())
789 }
790
791 fn as_any(&self) -> &dyn std::any::Any {
792 self
793 }
794
795 fn default_config_section(&self) -> Option<(String, toml::Value)> {
796 None
797 }
798
799 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
800 where
801 Self: Sized,
802 {
803 let mut top_level_indent = 0;
805
806 if let Some(md007_config) = config.rules.get("MD007") {
808 if let Some(start_indented) = md007_config.values.get("start-indented")
810 && let Some(start_indented_bool) = start_indented.as_bool()
811 && start_indented_bool
812 {
813 if let Some(start_indent) = md007_config.values.get("start-indent") {
815 if let Some(indent_value) = start_indent.as_integer() {
816 top_level_indent = indent_value as usize;
817 }
818 } else {
819 top_level_indent = 2;
821 }
822 }
823 }
824
825 Box::new(MD005ListIndent { top_level_indent })
826 }
827}
828
829#[cfg(test)]
830mod tests {
831 use super::*;
832 use crate::lint_context::LintContext;
833
834 #[test]
835 fn test_valid_unordered_list() {
836 let rule = MD005ListIndent::default();
837 let content = "\
838* Item 1
839* Item 2
840 * Nested 1
841 * Nested 2
842* Item 3";
843 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
844 let result = rule.check(&ctx).unwrap();
845 assert!(result.is_empty());
846 }
847
848 #[test]
849 fn test_valid_ordered_list() {
850 let rule = MD005ListIndent::default();
851 let content = "\
8521. Item 1
8532. Item 2
854 1. Nested 1
855 2. Nested 2
8563. Item 3";
857 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
858 let result = rule.check(&ctx).unwrap();
859 assert!(result.is_empty());
862 }
863
864 #[test]
865 fn test_invalid_unordered_indent() {
866 let rule = MD005ListIndent::default();
867 let content = "\
868* Item 1
869 * Item 2
870 * Nested 1";
871 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
872 let result = rule.check(&ctx).unwrap();
873 assert_eq!(result.len(), 1);
876 let fixed = rule.fix(&ctx).unwrap();
877 assert_eq!(fixed, "* Item 1\n* Item 2\n * Nested 1");
878 }
879
880 #[test]
881 fn test_invalid_ordered_indent() {
882 let rule = MD005ListIndent::default();
883 let content = "\
8841. Item 1
885 2. Item 2
886 1. Nested 1";
887 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
888 let result = rule.check(&ctx).unwrap();
889 assert_eq!(result.len(), 1);
890 let fixed = rule.fix(&ctx).unwrap();
891 assert_eq!(fixed, "1. Item 1\n2. Item 2\n 1. Nested 1");
895 }
896
897 #[test]
898 fn test_mixed_list_types() {
899 let rule = MD005ListIndent::default();
900 let content = "\
901* Item 1
902 1. Nested ordered
903 * Nested unordered
904* Item 2";
905 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
906 let result = rule.check(&ctx).unwrap();
907 assert!(result.is_empty());
908 }
909
910 #[test]
911 fn test_multiple_levels() {
912 let rule = MD005ListIndent::default();
913 let content = "\
914* Level 1
915 * Level 2
916 * Level 3";
917 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
918 let result = rule.check(&ctx).unwrap();
919 assert!(result.is_empty(), "MD005 should accept consistent indentation pattern");
921 }
922
923 #[test]
924 fn test_empty_lines() {
925 let rule = MD005ListIndent::default();
926 let content = "\
927* Item 1
928
929 * Nested 1
930
931* Item 2";
932 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
933 let result = rule.check(&ctx).unwrap();
934 assert!(result.is_empty());
935 }
936
937 #[test]
938 fn test_no_lists() {
939 let rule = MD005ListIndent::default();
940 let content = "\
941Just some text
942More text
943Even more text";
944 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
945 let result = rule.check(&ctx).unwrap();
946 assert!(result.is_empty());
947 }
948
949 #[test]
950 fn test_complex_nesting() {
951 let rule = MD005ListIndent::default();
952 let content = "\
953* Level 1
954 * Level 2
955 * Level 3
956 * Back to 2
957 1. Ordered 3
958 2. Still 3
959* Back to 1";
960 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
961 let result = rule.check(&ctx).unwrap();
962 assert!(result.is_empty());
963 }
964
965 #[test]
966 fn test_invalid_complex_nesting() {
967 let rule = MD005ListIndent::default();
968 let content = "\
969* Level 1
970 * Level 2
971 * Level 3
972 * Back to 2
973 1. Ordered 3
974 2. Still 3
975* Back to 1";
976 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
977 let result = rule.check(&ctx).unwrap();
978 assert_eq!(result.len(), 1);
980 assert!(
981 result[0].message.contains("Expected indentation of 5 spaces, found 6")
982 || result[0].message.contains("Expected indentation of 6 spaces, found 5")
983 );
984 }
985
986 #[test]
987 fn test_with_lint_context() {
988 let rule = MD005ListIndent::default();
989
990 let content = "* Item 1\n* Item 2\n * Nested item\n * Another nested item";
992 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
993 let result = rule.check(&ctx).unwrap();
994 assert!(result.is_empty());
995
996 let content = "* Item 1\n* Item 2\n * Nested item\n * Another nested item";
998 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
999 let result = rule.check(&ctx).unwrap();
1000 assert!(!result.is_empty()); let content = "* Item 1\n * Nested item\n * Another nested item with wrong indent";
1004 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1005 let result = rule.check(&ctx).unwrap();
1006 assert!(!result.is_empty()); }
1008
1009 #[test]
1011 fn test_list_with_continuations() {
1012 let rule = MD005ListIndent::default();
1013 let content = "\
1014* Item 1
1015 This is a continuation
1016 of the first item
1017 * Nested item
1018 with its own continuation
1019* Item 2";
1020 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1021 let result = rule.check(&ctx).unwrap();
1022 assert!(result.is_empty());
1023 }
1024
1025 #[test]
1026 fn test_list_in_blockquote() {
1027 let rule = MD005ListIndent::default();
1028 let content = "\
1029> * Item 1
1030> * Nested 1
1031> * Nested 2
1032> * Item 2";
1033 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1034 let result = rule.check(&ctx).unwrap();
1035
1036 assert!(
1038 result.is_empty(),
1039 "Expected no warnings for correctly indented blockquote list, got: {result:?}"
1040 );
1041 }
1042
1043 #[test]
1044 fn test_list_with_code_blocks() {
1045 let rule = MD005ListIndent::default();
1046 let content = "\
1047* Item 1
1048 ```
1049 code block
1050 ```
1051 * Nested item
1052* Item 2";
1053 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1054 let result = rule.check(&ctx).unwrap();
1055 assert!(result.is_empty());
1056 }
1057
1058 #[test]
1059 fn test_list_with_tabs() {
1060 let rule = MD005ListIndent::default();
1061 let content = "* Item 1\n * Wrong indent (3 spaces)\n * Correct indent (2 spaces)";
1065 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1066 let result = rule.check(&ctx).unwrap();
1067 assert!(!result.is_empty());
1069 }
1070
1071 #[test]
1072 fn test_inconsistent_at_same_level() {
1073 let rule = MD005ListIndent::default();
1074 let content = "\
1075* Item 1
1076 * Nested 1
1077 * Nested 2
1078 * Wrong indent for same level
1079 * Nested 3";
1080 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1081 let result = rule.check(&ctx).unwrap();
1082 assert!(!result.is_empty());
1083 assert!(result.iter().any(|w| w.line == 4));
1085 }
1086
1087 #[test]
1088 fn test_zero_indent_top_level() {
1089 let rule = MD005ListIndent::default();
1090 let content = concat!(" * Wrong indent\n", "* Correct\n", " * Nested");
1092 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1093 let result = rule.check(&ctx).unwrap();
1094
1095 assert!(!result.is_empty());
1097 assert!(result.iter().any(|w| w.line == 1));
1098 }
1099
1100 #[test]
1101 fn test_fix_preserves_content() {
1102 let rule = MD005ListIndent::default();
1103 let content = "\
1104* Item with **bold** and *italic*
1105 * Wrong indent with `code`
1106 * Also wrong with [link](url)";
1107 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1108 let fixed = rule.fix(&ctx).unwrap();
1109 assert!(fixed.contains("**bold**"));
1110 assert!(fixed.contains("*italic*"));
1111 assert!(fixed.contains("`code`"));
1112 assert!(fixed.contains("[link](url)"));
1113 }
1114
1115 #[test]
1116 fn test_deeply_nested_lists() {
1117 let rule = MD005ListIndent::default();
1118 let content = "\
1119* L1
1120 * L2
1121 * L3
1122 * L4
1123 * L5
1124 * L6";
1125 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1126 let result = rule.check(&ctx).unwrap();
1127 assert!(result.is_empty());
1128 }
1129
1130 #[test]
1131 fn test_fix_multiple_issues() {
1132 let rule = MD005ListIndent::default();
1133 let content = "\
1134* Item 1
1135 * Wrong 1
1136 * Wrong 2
1137 * Wrong 3
1138 * Correct
1139 * Wrong 4";
1140 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1141 let fixed = rule.fix(&ctx).unwrap();
1142 let lines: Vec<&str> = fixed.lines().collect();
1144 assert_eq!(lines[0], "* Item 1");
1145 assert!(lines[1].starts_with(" * ") || lines[1].starts_with("* "));
1147 }
1148
1149 #[test]
1150 fn test_performance_large_document() {
1151 let rule = MD005ListIndent::default();
1152 let mut content = String::new();
1153 for i in 0..100 {
1154 content.push_str(&format!("* Item {i}\n"));
1155 content.push_str(&format!(" * Nested {i}\n"));
1156 }
1157 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1158 let result = rule.check(&ctx).unwrap();
1159 assert!(result.is_empty());
1160 }
1161
1162 #[test]
1163 fn test_column_positions() {
1164 let rule = MD005ListIndent::default();
1165 let content = " * Wrong indent";
1166 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1167 let result = rule.check(&ctx).unwrap();
1168 assert_eq!(result.len(), 1);
1169 assert_eq!(result[0].column, 1, "Expected column 1, got {}", result[0].column);
1170 assert_eq!(
1171 result[0].end_column, 2,
1172 "Expected end_column 2, got {}",
1173 result[0].end_column
1174 );
1175 }
1176
1177 #[test]
1178 fn test_should_skip() {
1179 let rule = MD005ListIndent::default();
1180
1181 let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard, None);
1183 assert!(rule.should_skip(&ctx));
1184
1185 let ctx = LintContext::new("Just plain text", crate::config::MarkdownFlavor::Standard, None);
1187 assert!(rule.should_skip(&ctx));
1188
1189 let ctx = LintContext::new("* List item", crate::config::MarkdownFlavor::Standard, None);
1191 assert!(!rule.should_skip(&ctx));
1192
1193 let ctx = LintContext::new("1. Ordered list", crate::config::MarkdownFlavor::Standard, None);
1194 assert!(!rule.should_skip(&ctx));
1195 }
1196
1197 #[test]
1198 fn test_should_skip_validation() {
1199 let rule = MD005ListIndent::default();
1200 let content = "* List item";
1201 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1202 assert!(!rule.should_skip(&ctx));
1203
1204 let content = "No lists here";
1205 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1206 assert!(rule.should_skip(&ctx));
1207 }
1208
1209 #[test]
1210 fn test_edge_case_single_space_indent() {
1211 let rule = MD005ListIndent::default();
1212 let content = "\
1213* Item 1
1214 * Single space - wrong
1215 * Two spaces - correct";
1216 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1217 let result = rule.check(&ctx).unwrap();
1218 assert_eq!(result.len(), 2);
1221 assert!(result.iter().any(|w| w.line == 2 && w.message.contains("found 1")));
1222 }
1223
1224 #[test]
1225 fn test_edge_case_three_space_indent() {
1226 let rule = MD005ListIndent::default();
1227 let content = "\
1228* Item 1
1229 * Three spaces - first establishes pattern
1230 * Two spaces - inconsistent with established pattern";
1231 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1232 let result = rule.check(&ctx).unwrap();
1233 assert_eq!(result.len(), 1);
1237 assert!(result.iter().any(|w| w.line == 3 && w.message.contains("found 2")));
1238 }
1239
1240 #[test]
1241 fn test_nested_bullets_under_numbered_items() {
1242 let rule = MD005ListIndent::default();
1243 let content = "\
12441. **Active Directory/LDAP**
1245 - User authentication and directory services
1246 - LDAP for user information and validation
1247
12482. **Oracle Unified Directory (OUD)**
1249 - Extended user directory services
1250 - Verification of project account presence and changes";
1251 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1252 let result = rule.check(&ctx).unwrap();
1253 assert!(
1255 result.is_empty(),
1256 "Expected no warnings for bullets with 3 spaces under numbered items, got: {result:?}"
1257 );
1258 }
1259
1260 #[test]
1261 fn test_nested_bullets_under_numbered_items_wrong_indent() {
1262 let rule = MD005ListIndent::default();
1263 let content = "\
12641. **Active Directory/LDAP**
1265 - Wrong: only 2 spaces
1266 - Correct: 3 spaces";
1267 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1268 let result = rule.check(&ctx).unwrap();
1269 assert_eq!(
1271 result.len(),
1272 1,
1273 "Expected 1 warning, got {}. Warnings: {:?}",
1274 result.len(),
1275 result
1276 );
1277 assert!(
1279 result
1280 .iter()
1281 .any(|w| (w.line == 2 && w.message.contains("found 2"))
1282 || (w.line == 3 && w.message.contains("found 3")))
1283 );
1284 }
1285
1286 #[test]
1287 fn test_regular_nested_bullets_still_work() {
1288 let rule = MD005ListIndent::default();
1289 let content = "\
1290* Top level
1291 * Second level (2 spaces is correct for bullets under bullets)
1292 * Third level (4 spaces)";
1293 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1294 let result = rule.check(&ctx).unwrap();
1295 assert!(
1297 result.is_empty(),
1298 "Expected no warnings for regular bullet nesting, got: {result:?}"
1299 );
1300 }
1301
1302 #[test]
1303 fn test_fix_range_accuracy() {
1304 let rule = MD005ListIndent::default();
1305 let content = " * Wrong indent";
1306 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1307 let result = rule.check(&ctx).unwrap();
1308 assert_eq!(result.len(), 1);
1309
1310 let fix = result[0].fix.as_ref().unwrap();
1311 assert_eq!(fix.replacement, "");
1313 }
1314
1315 #[test]
1316 fn test_four_space_indent_pattern() {
1317 let rule = MD005ListIndent::default();
1318 let content = "\
1319* Item 1
1320 * Item 2 with 4 spaces
1321 * Item 3 with 8 spaces
1322 * Item 4 with 4 spaces";
1323 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1324 let result = rule.check(&ctx).unwrap();
1325 assert!(
1327 result.is_empty(),
1328 "MD005 should accept consistent 4-space indentation pattern, got {} warnings",
1329 result.len()
1330 );
1331 }
1332
1333 #[test]
1334 fn test_issue_64_scenario() {
1335 let rule = MD005ListIndent::default();
1337 let content = "\
1338* Top level item
1339 * Sub item with 4 spaces (as configured in MD007)
1340 * Nested sub item with 8 spaces
1341 * Another sub item with 4 spaces
1342* Another top level";
1343
1344 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1345 let result = rule.check(&ctx).unwrap();
1346
1347 assert!(
1349 result.is_empty(),
1350 "MD005 should accept 4-space indentation when that's the pattern being used. Got {} warnings",
1351 result.len()
1352 );
1353 }
1354
1355 #[test]
1356 fn test_continuation_content_scenario() {
1357 let rule = MD005ListIndent::default();
1358 let content = "\
1359- **Changes to how the Python version is inferred** ([#16319](example))
1360
1361 In previous versions of Ruff, you could specify your Python version with:
1362
1363 - The `target-version` option in a `ruff.toml` file
1364 - The `project.requires-python` field in a `pyproject.toml` file";
1365
1366 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1367
1368 let result = rule.check(&ctx).unwrap();
1369
1370 assert!(
1372 result.is_empty(),
1373 "MD005 should not flag continuation content lists, got {} warnings: {:?}",
1374 result.len(),
1375 result
1376 );
1377 }
1378
1379 #[test]
1380 fn test_multiple_continuation_lists_scenario() {
1381 let rule = MD005ListIndent::default();
1382 let content = "\
1383- **Changes to how the Python version is inferred** ([#16319](example))
1384
1385 In previous versions of Ruff, you could specify your Python version with:
1386
1387 - The `target-version` option in a `ruff.toml` file
1388 - The `project.requires-python` field in a `pyproject.toml` file
1389
1390 In v0.10, config discovery has been updated to address this issue:
1391
1392 - If Ruff finds a `ruff.toml` file without a `target-version`, it will check
1393 - If Ruff finds a user-level configuration, the `requires-python` field will take precedence
1394 - If there is no config file, Ruff will search for the closest `pyproject.toml`";
1395
1396 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1397
1398 let result = rule.check(&ctx).unwrap();
1399
1400 assert!(
1402 result.is_empty(),
1403 "MD005 should not flag continuation content lists, got {} warnings: {:?}",
1404 result.len(),
1405 result
1406 );
1407 }
1408
1409 #[test]
1410 fn test_issue_115_sublist_after_code_block() {
1411 let rule = MD005ListIndent::default();
1412 let content = "\
14131. List item 1
1414
1415 ```rust
1416 fn foo() {}
1417 ```
1418
1419 Sublist:
1420
1421 - A
1422 - B
1423";
1424 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1425 let result = rule.check(&ctx).unwrap();
1426 assert!(
1430 result.is_empty(),
1431 "Expected no warnings for sub-list after code block in list item, got {} warnings: {:?}",
1432 result.len(),
1433 result
1434 );
1435 }
1436
1437 #[test]
1438 fn test_edge_case_continuation_at_exact_boundary() {
1439 let rule = MD005ListIndent::default();
1440 let content = "\
1442* Item (content at column 2)
1443 Text at column 2 (exact boundary - continuation)
1444 * Sub at column 2";
1445 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1446 let result = rule.check(&ctx).unwrap();
1447 assert!(
1449 result.is_empty(),
1450 "Expected no warnings when text and sub-list are at exact parent content_column, got: {result:?}"
1451 );
1452 }
1453
1454 #[test]
1455 fn test_edge_case_unicode_in_continuation() {
1456 let rule = MD005ListIndent::default();
1457 let content = "\
1458* Parent
1459 Text with emoji 😀 and Unicode ñ characters
1460 * Sub-list should still work";
1461 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1462 let result = rule.check(&ctx).unwrap();
1463 assert!(
1465 result.is_empty(),
1466 "Expected no warnings with Unicode in continuation content, got: {result:?}"
1467 );
1468 }
1469
1470 #[test]
1471 fn test_edge_case_large_empty_line_gap() {
1472 let rule = MD005ListIndent::default();
1473 let content = "\
1474* Parent at line 1
1475 Continuation text
1476
1477
1478
1479 More continuation after many empty lines
1480
1481 * Child after gap
1482 * Another child";
1483 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1484 let result = rule.check(&ctx).unwrap();
1485 assert!(
1487 result.is_empty(),
1488 "Expected no warnings with large gaps in continuation content, got: {result:?}"
1489 );
1490 }
1491
1492 #[test]
1493 fn test_edge_case_multiple_continuation_blocks_varying_indent() {
1494 let rule = MD005ListIndent::default();
1495 let content = "\
1496* Parent (content at column 2)
1497 First paragraph at column 2
1498 Indented quote at column 4
1499 Back to column 2
1500 * Sub-list at column 2";
1501 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1502 let result = rule.check(&ctx).unwrap();
1503 assert!(
1505 result.is_empty(),
1506 "Expected no warnings with varying continuation indent, got: {result:?}"
1507 );
1508 }
1509
1510 #[test]
1511 fn test_edge_case_deep_nesting_no_continuation() {
1512 let rule = MD005ListIndent::default();
1513 let content = "\
1514* Parent
1515 * Immediate child (no continuation text before)
1516 * Grandchild
1517 * Great-grandchild
1518 * Great-great-grandchild
1519 * Another child at level 2";
1520 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1521 let result = rule.check(&ctx).unwrap();
1522 assert!(
1524 result.is_empty(),
1525 "Expected no warnings for deep nesting without continuation, got: {result:?}"
1526 );
1527 }
1528
1529 #[test]
1530 fn test_edge_case_blockquote_continuation_content() {
1531 let rule = MD005ListIndent::default();
1532 let content = "\
1533> * Parent in blockquote
1534> Continuation in blockquote
1535> * Sub-list in blockquote
1536> * Another sub-list";
1537 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1538 let result = rule.check(&ctx).unwrap();
1539 assert!(
1541 result.is_empty(),
1542 "Expected no warnings for blockquote continuation, got: {result:?}"
1543 );
1544 }
1545
1546 #[test]
1547 fn test_edge_case_one_space_less_than_content_column() {
1548 let rule = MD005ListIndent::default();
1549 let content = "\
1550* Parent (content at column 2)
1551 Text at column 1 (one less than content_column - NOT continuation)
1552 * Child";
1553 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1554 let result = rule.check(&ctx).unwrap();
1555 assert!(
1561 result.is_empty() || !result.is_empty(),
1562 "Test should complete without panic"
1563 );
1564 }
1565
1566 #[test]
1567 fn test_edge_case_multiple_code_blocks_different_indentation() {
1568 let rule = MD005ListIndent::default();
1569 let content = "\
1570* Parent
1571 ```
1572 code at 2 spaces
1573 ```
1574 ```
1575 code at 4 spaces
1576 ```
1577 * Sub-list should not be confused";
1578 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1579 let result = rule.check(&ctx).unwrap();
1580 assert!(
1582 result.is_empty(),
1583 "Expected no warnings with multiple code blocks, got: {result:?}"
1584 );
1585 }
1586
1587 #[test]
1588 fn test_performance_very_large_document() {
1589 let rule = MD005ListIndent::default();
1590 let mut content = String::new();
1591
1592 for i in 0..1000 {
1594 content.push_str(&format!("* Item {i}\n"));
1595 content.push_str(&format!(" * Nested {i}\n"));
1596 if i % 10 == 0 {
1597 content.push_str(" Some continuation text\n");
1598 }
1599 }
1600
1601 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1602
1603 let start = std::time::Instant::now();
1605 let result = rule.check(&ctx).unwrap();
1606 let elapsed = start.elapsed();
1607
1608 assert!(result.is_empty());
1609 println!("Processed 1000 list items in {elapsed:?}");
1610 assert!(
1613 elapsed.as_secs() < 1,
1614 "Should complete in under 1 second, took {elapsed:?}"
1615 );
1616 }
1617
1618 #[test]
1619 fn test_ordered_list_variable_marker_width() {
1620 let rule = MD005ListIndent::default();
1625 let content = "\
16261. One
1627 - One
1628 - Two
16292. Two
1630 - One
16313. Three
1632 - One
16334. Four
1634 - One
16355. Five
1636 - One
16376. Six
1638 - One
16397. Seven
1640 - One
16418. Eight
1642 - One
16439. Nine
1644 - One
164510. Ten
1646 - One";
1647 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1648 let result = rule.check(&ctx).unwrap();
1649 assert!(
1650 result.is_empty(),
1651 "Expected no warnings for ordered list with variable marker widths, got: {result:?}"
1652 );
1653 }
1654
1655 #[test]
1656 fn test_ordered_list_inconsistent_siblings() {
1657 let rule = MD005ListIndent::default();
1659 let content = "\
16601. Item one
1661 - First sublist at 3 spaces
1662 - Second sublist at 2 spaces (inconsistent)
1663 - Third sublist at 3 spaces";
1664 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1665 let result = rule.check(&ctx).unwrap();
1666 assert_eq!(
1668 result.len(),
1669 1,
1670 "Expected 1 warning for inconsistent sibling indent, got: {result:?}"
1671 );
1672 assert!(result[0].message.contains("Expected indentation of 3"));
1673 }
1674
1675 #[test]
1676 fn test_ordered_list_single_sublist_no_warning() {
1677 let rule = MD005ListIndent::default();
1680 let content = "\
168110. Item ten
1682 - Only sublist at 3 spaces";
1683 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1684 let result = rule.check(&ctx).unwrap();
1685 assert!(
1687 result.is_empty(),
1688 "Expected no warnings for single sublist item, got: {result:?}"
1689 );
1690 }
1691
1692 #[test]
1693 fn test_sublists_grouped_by_parent_content_column() {
1694 let rule = MD005ListIndent::default();
1698 let content = "\
16999. Item nine
1700 - First sublist at 3 spaces
1701 - Second sublist at 3 spaces
1702 - Third sublist at 3 spaces
170310. Item ten
1704 - First sublist at 4 spaces
1705 - Second sublist at 4 spaces
1706 - Third sublist at 4 spaces";
1707 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1708 let result = rule.check(&ctx).unwrap();
1709 assert!(
1712 result.is_empty(),
1713 "Expected no warnings for sublists grouped by parent, got: {result:?}"
1714 );
1715 }
1716
1717 #[test]
1718 fn test_inconsistent_indent_within_parent_group() {
1719 let rule = MD005ListIndent::default();
1721 let content = "\
172210. Item ten
1723 - First sublist at 4 spaces
1724 - Second sublist at 3 spaces (inconsistent!)
1725 - Third sublist at 4 spaces";
1726 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1727 let result = rule.check(&ctx).unwrap();
1728 assert_eq!(
1730 result.len(),
1731 1,
1732 "Expected 1 warning for inconsistent indent within parent group, got: {result:?}"
1733 );
1734 assert!(result[0].line == 3);
1735 assert!(result[0].message.contains("Expected indentation of 4"));
1736 }
1737
1738 #[test]
1739 fn test_blockquote_nested_list_fix_preserves_blockquote_prefix() {
1740 use crate::rule::Rule;
1744
1745 let rule = MD005ListIndent::default();
1746 let content = "> * Federation sender blacklists are now persisted.";
1747 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1748 let result = rule.check(&ctx).unwrap();
1749
1750 assert_eq!(result.len(), 1, "Expected 1 warning for extra indent");
1751
1752 assert!(result[0].fix.is_some(), "Should have a fix");
1754 let fixed = rule.fix(&ctx).expect("Fix should succeed");
1755
1756 assert!(
1758 fixed.starts_with("> "),
1759 "Fixed content should start with blockquote prefix '> ', got: {fixed:?}"
1760 );
1761 assert!(
1762 !fixed.starts_with("* "),
1763 "Fixed content should NOT start with just '* ' (blockquote removed), got: {fixed:?}"
1764 );
1765 assert_eq!(
1766 fixed.trim(),
1767 "> * Federation sender blacklists are now persisted.",
1768 "Fixed content should be '> * Federation sender...' with single space after >"
1769 );
1770 }
1771
1772 #[test]
1773 fn test_nested_blockquote_list_fix_preserves_prefix() {
1774 use crate::rule::Rule;
1776
1777 let rule = MD005ListIndent::default();
1778 let content = ">> * Nested blockquote list item";
1779 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1780 let result = rule.check(&ctx).unwrap();
1781
1782 if !result.is_empty() {
1783 let fixed = rule.fix(&ctx).expect("Fix should succeed");
1784 assert!(
1786 fixed.contains(">>") || fixed.contains("> >"),
1787 "Fixed content should preserve nested blockquote prefix, got: {fixed:?}"
1788 );
1789 }
1790 }
1791}