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
13#[derive(Clone, Default)]
15pub struct MD005ListIndent {
16 top_level_indent: usize,
18}
19
20struct LineCacheInfo {
22 indentation: Vec<usize>,
24 flags: Vec<u8>,
26 parent_map: HashMap<usize, usize>,
29}
30
31const FLAG_HAS_CONTENT: u8 = 1;
32const FLAG_IS_LIST_ITEM: u8 = 2;
33
34impl LineCacheInfo {
35 fn new(ctx: &crate::lint_context::LintContext) -> Self {
37 let total_lines = ctx.lines.len();
38 let mut indentation = Vec::with_capacity(total_lines);
39 let mut flags = Vec::with_capacity(total_lines);
40 let mut parent_map = HashMap::new();
41
42 let mut indent_to_line: HashMap<usize, usize> = HashMap::new();
54
55 for (idx, line_info) in ctx.lines.iter().enumerate() {
56 let content = line_info.content(ctx.content).trim_start();
57 let line_indent = line_info.byte_len - content.len();
58
59 indentation.push(line_indent);
60
61 let mut flag = 0u8;
62 if !content.is_empty() {
63 flag |= FLAG_HAS_CONTENT;
64 }
65 if let Some(list_item) = &line_info.list_item {
66 flag |= FLAG_IS_LIST_ITEM;
67
68 let line_num = idx + 1; let marker_column = list_item.marker_column;
70
71 let mut best_parent: Option<(usize, usize)> = None; for (&tracked_indent, &tracked_line) in &indent_to_line {
76 if tracked_indent < marker_column {
77 if best_parent.is_none() || tracked_indent > best_parent.unwrap().0 {
79 best_parent = Some((tracked_indent, tracked_line));
80 }
81 }
82 }
83
84 if let Some((_parent_indent, parent_line)) = best_parent {
85 parent_map.insert(line_num, parent_line);
86 }
87
88 indent_to_line.retain(|&indent, _| indent < marker_column);
93 indent_to_line.insert(marker_column, line_num);
94 }
95 flags.push(flag);
96 }
97
98 Self {
99 indentation,
100 flags,
101 parent_map,
102 }
103 }
104
105 fn has_content(&self, idx: usize) -> bool {
107 self.flags.get(idx).is_some_and(|&f| f & FLAG_HAS_CONTENT != 0)
108 }
109
110 fn is_list_item(&self, idx: usize) -> bool {
112 self.flags.get(idx).is_some_and(|&f| f & FLAG_IS_LIST_ITEM != 0)
113 }
114
115 fn find_continuation_indent(
117 &self,
118 start_line: usize,
119 end_line: usize,
120 parent_content_column: usize,
121 ) -> Option<usize> {
122 if start_line == 0 || start_line > end_line || end_line > self.indentation.len() {
123 return None;
124 }
125
126 let start_idx = start_line - 1;
128 let end_idx = end_line - 1;
129
130 for idx in start_idx..=end_idx {
131 if !self.has_content(idx) || self.is_list_item(idx) {
133 continue;
134 }
135
136 if self.indentation[idx] >= parent_content_column {
139 return Some(self.indentation[idx]);
140 }
141 }
142 None
143 }
144
145 fn has_continuation_content(&self, parent_line: usize, current_line: usize, parent_content_column: usize) -> bool {
147 if parent_line == 0 || current_line <= parent_line || current_line > self.indentation.len() {
148 return false;
149 }
150
151 let start_idx = parent_line; let end_idx = current_line - 2; if start_idx > end_idx {
156 return false;
157 }
158
159 for idx in start_idx..=end_idx {
160 if !self.has_content(idx) || self.is_list_item(idx) {
162 continue;
163 }
164
165 if self.indentation[idx] >= parent_content_column {
168 return true;
169 }
170 }
171 false
172 }
173}
174
175impl MD005ListIndent {
176 const LIST_GROUP_GAP_TOLERANCE: usize = 2;
180
181 const MIN_CHILD_INDENT_INCREASE: usize = 2;
184
185 const SAME_LEVEL_TOLERANCE: i32 = 1;
188
189 const STANDARD_CONTINUATION_OFFSET: usize = 2;
192
193 fn create_indent_warning(
195 &self,
196 ctx: &crate::lint_context::LintContext,
197 line_num: usize,
198 line_info: &crate::lint_context::LineInfo,
199 actual_indent: usize,
200 expected_indent: usize,
201 ) -> LintWarning {
202 let message = format!(
203 "Expected indentation of {} {}, found {}",
204 expected_indent,
205 if expected_indent == 1 { "space" } else { "spaces" },
206 actual_indent
207 );
208
209 let (start_line, start_col, end_line, end_col) = if actual_indent > 0 {
210 calculate_match_range(line_num, line_info.content(ctx.content), 0, actual_indent)
211 } else {
212 calculate_match_range(line_num, line_info.content(ctx.content), 0, 1)
213 };
214
215 let (fix_range, replacement) = if line_info.blockquote.is_some() {
218 let start_byte = line_info.byte_offset;
220 let mut end_byte = line_info.byte_offset;
221
222 let marker_column = line_info
224 .list_item
225 .as_ref()
226 .map(|li| li.marker_column)
227 .unwrap_or(actual_indent);
228
229 for (i, ch) in line_info.content(ctx.content).chars().enumerate() {
231 if i >= marker_column {
232 break;
233 }
234 end_byte += ch.len_utf8();
235 }
236
237 let mut blockquote_count = 0;
239 for ch in line_info.content(ctx.content).chars() {
240 if ch == '>' {
241 blockquote_count += 1;
242 } else if ch != ' ' && ch != '\t' {
243 break;
244 }
245 }
246
247 let blockquote_prefix = if blockquote_count > 1 {
249 (0..blockquote_count)
250 .map(|_| "> ")
251 .collect::<String>()
252 .trim_end()
253 .to_string()
254 } else {
255 ">".to_string()
256 };
257
258 let correct_indent = " ".repeat(expected_indent);
260 let replacement = format!("{blockquote_prefix} {correct_indent}");
261
262 (start_byte..end_byte, replacement)
263 } else {
264 let fix_range = if actual_indent > 0 {
266 let start_byte = ctx.line_offsets.get(line_num - 1).copied().unwrap_or(0);
267 let end_byte = start_byte + actual_indent;
268 start_byte..end_byte
269 } else {
270 let byte_pos = ctx.line_offsets.get(line_num - 1).copied().unwrap_or(0);
271 byte_pos..byte_pos
272 };
273
274 let replacement = if expected_indent > 0 {
275 " ".repeat(expected_indent)
276 } else {
277 String::new()
278 };
279
280 (fix_range, replacement)
281 };
282
283 LintWarning {
284 rule_name: Some(self.name().to_string()),
285 line: start_line,
286 column: start_col,
287 end_line,
288 end_column: end_col,
289 message,
290 severity: Severity::Warning,
291 fix: Some(Fix {
292 range: fix_range,
293 replacement,
294 }),
295 }
296 }
297
298 fn check_indent_consistency(
301 &self,
302 ctx: &crate::lint_context::LintContext,
303 items: &[(usize, usize, &crate::lint_context::LineInfo)],
304 warnings: &mut Vec<LintWarning>,
305 ) {
306 if items.len() < 2 {
307 return;
308 }
309
310 let mut sorted_items: Vec<_> = items.iter().collect();
312 sorted_items.sort_by_key(|(line_num, _, _)| *line_num);
313
314 let indents: std::collections::HashSet<usize> = sorted_items.iter().map(|(_, indent, _)| *indent).collect();
315
316 if indents.len() > 1 {
317 let expected_indent = sorted_items.first().map(|(_, i, _)| *i).unwrap_or(0);
320
321 for (line_num, indent, line_info) in items {
322 if *indent != expected_indent {
323 warnings.push(self.create_indent_warning(ctx, *line_num, line_info, *indent, expected_indent));
324 }
325 }
326 }
327 }
328
329 fn group_by_parent_content_column<'a>(
332 &self,
333 level: usize,
334 group: &[(usize, usize, &'a crate::lint_context::LineInfo)],
335 all_list_items: &[(
336 usize,
337 usize,
338 &crate::lint_context::LineInfo,
339 &crate::lint_context::ListItemInfo,
340 )],
341 level_map: &HashMap<usize, usize>,
342 ) -> HashMap<usize, Vec<(usize, usize, &'a crate::lint_context::LineInfo)>> {
343 let parent_level = level - 1;
344 let mut parent_content_groups: HashMap<usize, Vec<(usize, usize, &'a crate::lint_context::LineInfo)>> =
345 HashMap::new();
346
347 for (line_num, indent, line_info) in group {
348 let mut parent_content_col: Option<usize> = None;
350
351 for (prev_line, _, _, list_item) in all_list_items.iter().rev() {
352 if *prev_line >= *line_num {
353 continue;
354 }
355 if let Some(&prev_level) = level_map.get(prev_line)
356 && prev_level == parent_level
357 {
358 parent_content_col = Some(list_item.content_column);
359 break;
360 }
361 }
362
363 if let Some(parent_col) = parent_content_col {
364 parent_content_groups
365 .entry(parent_col)
366 .or_default()
367 .push((*line_num, *indent, *line_info));
368 }
369 }
370
371 parent_content_groups
372 }
373
374 fn group_related_list_blocks<'a>(
376 &self,
377 list_blocks: &'a [crate::lint_context::ListBlock],
378 ) -> Vec<Vec<&'a crate::lint_context::ListBlock>> {
379 if list_blocks.is_empty() {
380 return Vec::new();
381 }
382
383 let mut groups = Vec::new();
384 let mut current_group = vec![&list_blocks[0]];
385
386 for i in 1..list_blocks.len() {
387 let prev_block = &list_blocks[i - 1];
388 let current_block = &list_blocks[i];
389
390 let line_gap = current_block.start_line.saturating_sub(prev_block.end_line);
392
393 if line_gap <= Self::LIST_GROUP_GAP_TOLERANCE {
396 current_group.push(current_block);
397 } else {
398 groups.push(current_group);
400 current_group = vec![current_block];
401 }
402 }
403 groups.push(current_group);
404
405 groups
406 }
407
408 fn is_continuation_content(
411 &self,
412 ctx: &crate::lint_context::LintContext,
413 cache: &LineCacheInfo,
414 list_line: usize,
415 list_indent: usize,
416 ) -> bool {
417 let parent_line = cache.parent_map.get(&list_line).copied();
419
420 if let Some(parent_line) = parent_line
421 && let Some(line_info) = ctx.line_info(parent_line)
422 && let Some(parent_list_item) = &line_info.list_item
423 {
424 let parent_marker_column = parent_list_item.marker_column;
425 let parent_content_column = parent_list_item.content_column;
426
427 let continuation_indent =
429 cache.find_continuation_indent(parent_line + 1, list_line - 1, parent_content_column);
430
431 if let Some(continuation_indent) = continuation_indent {
432 let is_standard_continuation =
433 list_indent == parent_content_column + Self::STANDARD_CONTINUATION_OFFSET;
434 let matches_content_indent = list_indent == continuation_indent;
435
436 if matches_content_indent || is_standard_continuation {
437 return true;
438 }
439 }
440
441 if list_indent > parent_marker_column {
444 if self.has_continuation_list_at_indent(
446 ctx,
447 cache,
448 parent_line,
449 list_line,
450 list_indent,
451 parent_content_column,
452 ) {
453 return true;
454 }
455
456 if cache.has_continuation_content(parent_line, list_line, parent_content_column) {
457 return true;
458 }
459 }
460 }
461
462 false
463 }
464
465 fn has_continuation_list_at_indent(
467 &self,
468 ctx: &crate::lint_context::LintContext,
469 cache: &LineCacheInfo,
470 parent_line: usize,
471 current_line: usize,
472 list_indent: usize,
473 parent_content_column: usize,
474 ) -> bool {
475 for line_num in (parent_line + 1)..current_line {
478 if let Some(line_info) = ctx.line_info(line_num)
479 && let Some(list_item) = &line_info.list_item
480 && list_item.marker_column == list_indent
481 {
482 if cache
485 .find_continuation_indent(parent_line + 1, line_num - 1, parent_content_column)
486 .is_some()
487 {
488 return true;
489 }
490 }
491 }
492 false
493 }
494
495 fn check_list_block_group(
497 &self,
498 ctx: &crate::lint_context::LintContext,
499 group: &[&crate::lint_context::ListBlock],
500 warnings: &mut Vec<LintWarning>,
501 ) -> Result<(), LintError> {
502 let cache = LineCacheInfo::new(ctx);
504
505 let mut candidate_items: Vec<(
508 usize,
509 usize,
510 &crate::lint_context::LineInfo,
511 &crate::lint_context::ListItemInfo,
512 )> = Vec::new();
513
514 for list_block in group {
515 for &item_line in &list_block.item_lines {
516 if let Some(line_info) = ctx.line_info(item_line)
517 && let Some(list_item) = &line_info.list_item
518 {
519 let effective_indent = if let Some(blockquote) = &line_info.blockquote {
521 list_item.marker_column.saturating_sub(blockquote.nesting_level * 2)
523 } else {
524 list_item.marker_column
526 };
527
528 candidate_items.push((item_line, effective_indent, line_info, list_item));
529 }
530 }
531 }
532
533 candidate_items.sort_by_key(|(line_num, _, _, _)| *line_num);
535
536 let mut skipped_lines: std::collections::HashSet<usize> = std::collections::HashSet::new();
539 let mut all_list_items: Vec<(
540 usize,
541 usize,
542 &crate::lint_context::LineInfo,
543 &crate::lint_context::ListItemInfo,
544 )> = Vec::new();
545
546 for (item_line, effective_indent, line_info, list_item) in candidate_items {
547 if self.is_continuation_content(ctx, &cache, item_line, effective_indent) {
549 skipped_lines.insert(item_line);
550 continue;
551 }
552
553 if let Some(&parent_line) = cache.parent_map.get(&item_line)
555 && skipped_lines.contains(&parent_line)
556 {
557 skipped_lines.insert(item_line);
558 continue;
559 }
560
561 all_list_items.push((item_line, effective_indent, line_info, list_item));
562 }
563
564 if all_list_items.is_empty() {
565 return Ok(());
566 }
567
568 all_list_items.sort_by_key(|(line_num, _, _, _)| *line_num);
570
571 let mut level_map: HashMap<usize, usize> = HashMap::new();
575 let mut level_indents: HashMap<usize, Vec<usize>> = HashMap::new(); let mut indent_to_level: HashMap<usize, (usize, usize)> = HashMap::new();
580
581 for (line_num, indent, _, _) in &all_list_items {
583 let level = if indent_to_level.is_empty() {
584 level_indents.entry(1).or_default().push(*indent);
586 1
587 } else {
588 let mut determined_level = 0;
590
591 if let Some(&(existing_level, _)) = indent_to_level.get(indent) {
593 determined_level = existing_level;
594 } else {
595 let mut best_parent: Option<(usize, usize, usize)> = None; for (&tracked_indent, &(tracked_level, tracked_line)) in &indent_to_level {
601 if tracked_indent < *indent {
602 if best_parent.is_none() || tracked_indent > best_parent.unwrap().0 {
605 best_parent = Some((tracked_indent, tracked_level, tracked_line));
606 }
607 }
608 }
609
610 if let Some((parent_indent, parent_level, _parent_line)) = best_parent {
611 if parent_indent + Self::MIN_CHILD_INDENT_INCREASE <= *indent {
613 determined_level = parent_level + 1;
615 } else if (*indent as i32 - parent_indent as i32).abs() <= Self::SAME_LEVEL_TOLERANCE {
616 determined_level = parent_level;
618 } else {
619 let mut found_similar = false;
623 if let Some(indents_at_level) = level_indents.get(&parent_level) {
624 for &level_indent in indents_at_level {
625 if (level_indent as i32 - *indent as i32).abs() <= Self::SAME_LEVEL_TOLERANCE {
626 determined_level = parent_level;
627 found_similar = true;
628 break;
629 }
630 }
631 }
632 if !found_similar {
633 determined_level = parent_level + 1;
635 }
636 }
637 }
638
639 if determined_level == 0 {
641 determined_level = 1;
642 }
643
644 level_indents.entry(determined_level).or_default().push(*indent);
646 }
647
648 determined_level
649 };
650
651 level_map.insert(*line_num, level);
652 indent_to_level.insert(*indent, (level, *line_num));
654 }
655
656 let mut level_groups: HashMap<usize, Vec<(usize, usize, &crate::lint_context::LineInfo)>> = HashMap::new();
658 for (line_num, indent, line_info, _) in &all_list_items {
659 let level = level_map[line_num];
660 level_groups
661 .entry(level)
662 .or_default()
663 .push((*line_num, *indent, *line_info));
664 }
665
666 for (level, mut group) in level_groups {
668 group.sort_by_key(|(line_num, _, _)| *line_num);
669
670 if level == 1 {
671 for (line_num, indent, line_info) in &group {
673 if *indent != self.top_level_indent {
674 warnings.push(self.create_indent_warning(
675 ctx,
676 *line_num,
677 line_info,
678 *indent,
679 self.top_level_indent,
680 ));
681 }
682 }
683 } else {
684 let parent_content_groups =
687 self.group_by_parent_content_column(level, &group, &all_list_items, &level_map);
688
689 for items in parent_content_groups.values() {
691 self.check_indent_consistency(ctx, items, warnings);
692 }
693 }
694 }
695
696 Ok(())
697 }
698
699 fn check_optimized(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
701 let content = ctx.content;
702
703 if content.is_empty() {
705 return Ok(Vec::new());
706 }
707
708 if ctx.list_blocks.is_empty() {
710 return Ok(Vec::new());
711 }
712
713 let mut warnings = Vec::new();
714
715 let block_groups = self.group_related_list_blocks(&ctx.list_blocks);
718
719 for group in block_groups {
720 self.check_list_block_group(ctx, &group, &mut warnings)?;
721 }
722
723 Ok(warnings)
724 }
725}
726
727impl Rule for MD005ListIndent {
728 fn name(&self) -> &'static str {
729 "MD005"
730 }
731
732 fn description(&self) -> &'static str {
733 "List indentation should be consistent"
734 }
735
736 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
737 self.check_optimized(ctx)
739 }
740
741 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
742 let warnings = self.check(ctx)?;
743 if warnings.is_empty() {
744 return Ok(ctx.content.to_string());
745 }
746
747 let mut warnings_with_fixes: Vec<_> = warnings
749 .into_iter()
750 .filter_map(|w| w.fix.clone().map(|fix| (w, fix)))
751 .collect();
752 warnings_with_fixes.sort_by_key(|(_, fix)| std::cmp::Reverse(fix.range.start));
753
754 let mut content = ctx.content.to_string();
756 for (_, fix) in warnings_with_fixes {
757 if fix.range.start <= content.len() && fix.range.end <= content.len() {
758 content.replace_range(fix.range, &fix.replacement);
759 }
760 }
761
762 Ok(content)
763 }
764
765 fn category(&self) -> RuleCategory {
766 RuleCategory::List
767 }
768
769 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
771 ctx.content.is_empty() || !ctx.lines.iter().any(|line| line.list_item.is_some())
773 }
774
775 fn as_any(&self) -> &dyn std::any::Any {
776 self
777 }
778
779 fn default_config_section(&self) -> Option<(String, toml::Value)> {
780 None
781 }
782
783 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
784 where
785 Self: Sized,
786 {
787 let mut top_level_indent = 0;
789
790 if let Some(md007_config) = config.rules.get("MD007") {
792 if let Some(start_indented) = md007_config.values.get("start-indented")
794 && let Some(start_indented_bool) = start_indented.as_bool()
795 && start_indented_bool
796 {
797 if let Some(start_indent) = md007_config.values.get("start-indent") {
799 if let Some(indent_value) = start_indent.as_integer() {
800 top_level_indent = indent_value as usize;
801 }
802 } else {
803 top_level_indent = 2;
805 }
806 }
807 }
808
809 Box::new(MD005ListIndent { top_level_indent })
810 }
811}
812
813#[cfg(test)]
814mod tests {
815 use super::*;
816 use crate::lint_context::LintContext;
817
818 #[test]
819 fn test_valid_unordered_list() {
820 let rule = MD005ListIndent::default();
821 let content = "\
822* Item 1
823* Item 2
824 * Nested 1
825 * Nested 2
826* Item 3";
827 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
828 let result = rule.check(&ctx).unwrap();
829 assert!(result.is_empty());
830 }
831
832 #[test]
833 fn test_valid_ordered_list() {
834 let rule = MD005ListIndent::default();
835 let content = "\
8361. Item 1
8372. Item 2
838 1. Nested 1
839 2. Nested 2
8403. Item 3";
841 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
842 let result = rule.check(&ctx).unwrap();
843 assert!(result.is_empty());
846 }
847
848 #[test]
849 fn test_invalid_unordered_indent() {
850 let rule = MD005ListIndent::default();
851 let content = "\
852* Item 1
853 * Item 2
854 * Nested 1";
855 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
856 let result = rule.check(&ctx).unwrap();
857 assert_eq!(result.len(), 1);
860 let fixed = rule.fix(&ctx).unwrap();
861 assert_eq!(fixed, "* Item 1\n* Item 2\n * Nested 1");
862 }
863
864 #[test]
865 fn test_invalid_ordered_indent() {
866 let rule = MD005ListIndent::default();
867 let content = "\
8681. Item 1
869 2. Item 2
870 1. 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);
874 let fixed = rule.fix(&ctx).unwrap();
875 assert_eq!(fixed, "1. Item 1\n2. Item 2\n 1. Nested 1");
879 }
880
881 #[test]
882 fn test_mixed_list_types() {
883 let rule = MD005ListIndent::default();
884 let content = "\
885* Item 1
886 1. Nested ordered
887 * Nested unordered
888* Item 2";
889 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
890 let result = rule.check(&ctx).unwrap();
891 assert!(result.is_empty());
892 }
893
894 #[test]
895 fn test_multiple_levels() {
896 let rule = MD005ListIndent::default();
897 let content = "\
898* Level 1
899 * Level 2
900 * Level 3";
901 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
902 let result = rule.check(&ctx).unwrap();
903 assert!(result.is_empty(), "MD005 should accept consistent indentation pattern");
905 }
906
907 #[test]
908 fn test_empty_lines() {
909 let rule = MD005ListIndent::default();
910 let content = "\
911* Item 1
912
913 * Nested 1
914
915* Item 2";
916 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
917 let result = rule.check(&ctx).unwrap();
918 assert!(result.is_empty());
919 }
920
921 #[test]
922 fn test_no_lists() {
923 let rule = MD005ListIndent::default();
924 let content = "\
925Just some text
926More text
927Even more text";
928 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
929 let result = rule.check(&ctx).unwrap();
930 assert!(result.is_empty());
931 }
932
933 #[test]
934 fn test_complex_nesting() {
935 let rule = MD005ListIndent::default();
936 let content = "\
937* Level 1
938 * Level 2
939 * Level 3
940 * Back to 2
941 1. Ordered 3
942 2. Still 3
943* Back to 1";
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_invalid_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_eq!(result.len(), 1);
964 assert!(
965 result[0].message.contains("Expected indentation of 5 spaces, found 6")
966 || result[0].message.contains("Expected indentation of 6 spaces, found 5")
967 );
968 }
969
970 #[test]
971 fn test_with_lint_context() {
972 let rule = MD005ListIndent::default();
973
974 let content = "* Item 1\n* Item 2\n * Nested item\n * Another nested item";
976 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
977 let result = rule.check(&ctx).unwrap();
978 assert!(result.is_empty());
979
980 let content = "* Item 1\n* Item 2\n * Nested item\n * Another nested item";
982 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
983 let result = rule.check(&ctx).unwrap();
984 assert!(!result.is_empty()); let content = "* Item 1\n * Nested item\n * Another nested item with wrong indent";
988 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
989 let result = rule.check(&ctx).unwrap();
990 assert!(!result.is_empty()); }
992
993 #[test]
995 fn test_list_with_continuations() {
996 let rule = MD005ListIndent::default();
997 let content = "\
998* Item 1
999 This is a continuation
1000 of the first item
1001 * Nested item
1002 with its own continuation
1003* Item 2";
1004 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1005 let result = rule.check(&ctx).unwrap();
1006 assert!(result.is_empty());
1007 }
1008
1009 #[test]
1010 fn test_list_in_blockquote() {
1011 let rule = MD005ListIndent::default();
1012 let content = "\
1013> * Item 1
1014> * Nested 1
1015> * Nested 2
1016> * Item 2";
1017 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1018 let result = rule.check(&ctx).unwrap();
1019
1020 assert!(
1022 result.is_empty(),
1023 "Expected no warnings for correctly indented blockquote list, got: {result:?}"
1024 );
1025 }
1026
1027 #[test]
1028 fn test_list_with_code_blocks() {
1029 let rule = MD005ListIndent::default();
1030 let content = "\
1031* Item 1
1032 ```
1033 code block
1034 ```
1035 * Nested item
1036* Item 2";
1037 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1038 let result = rule.check(&ctx).unwrap();
1039 assert!(result.is_empty());
1040 }
1041
1042 #[test]
1043 fn test_list_with_tabs() {
1044 let rule = MD005ListIndent::default();
1045 let content = "* Item 1\n * Wrong indent (3 spaces)\n * Correct indent (2 spaces)";
1049 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1050 let result = rule.check(&ctx).unwrap();
1051 assert!(!result.is_empty());
1053 }
1054
1055 #[test]
1056 fn test_inconsistent_at_same_level() {
1057 let rule = MD005ListIndent::default();
1058 let content = "\
1059* Item 1
1060 * Nested 1
1061 * Nested 2
1062 * Wrong indent for same level
1063 * Nested 3";
1064 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1065 let result = rule.check(&ctx).unwrap();
1066 assert!(!result.is_empty());
1067 assert!(result.iter().any(|w| w.line == 4));
1069 }
1070
1071 #[test]
1072 fn test_zero_indent_top_level() {
1073 let rule = MD005ListIndent::default();
1074 let content = concat!(" * Wrong indent\n", "* Correct\n", " * Nested");
1076 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1077 let result = rule.check(&ctx).unwrap();
1078
1079 assert!(!result.is_empty());
1081 assert!(result.iter().any(|w| w.line == 1));
1082 }
1083
1084 #[test]
1085 fn test_fix_preserves_content() {
1086 let rule = MD005ListIndent::default();
1087 let content = "\
1088* Item with **bold** and *italic*
1089 * Wrong indent with `code`
1090 * Also wrong with [link](url)";
1091 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1092 let fixed = rule.fix(&ctx).unwrap();
1093 assert!(fixed.contains("**bold**"));
1094 assert!(fixed.contains("*italic*"));
1095 assert!(fixed.contains("`code`"));
1096 assert!(fixed.contains("[link](url)"));
1097 }
1098
1099 #[test]
1100 fn test_deeply_nested_lists() {
1101 let rule = MD005ListIndent::default();
1102 let content = "\
1103* L1
1104 * L2
1105 * L3
1106 * L4
1107 * L5
1108 * L6";
1109 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1110 let result = rule.check(&ctx).unwrap();
1111 assert!(result.is_empty());
1112 }
1113
1114 #[test]
1115 fn test_fix_multiple_issues() {
1116 let rule = MD005ListIndent::default();
1117 let content = "\
1118* Item 1
1119 * Wrong 1
1120 * Wrong 2
1121 * Wrong 3
1122 * Correct
1123 * Wrong 4";
1124 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1125 let fixed = rule.fix(&ctx).unwrap();
1126 let lines: Vec<&str> = fixed.lines().collect();
1128 assert_eq!(lines[0], "* Item 1");
1129 assert!(lines[1].starts_with(" * ") || lines[1].starts_with("* "));
1131 }
1132
1133 #[test]
1134 fn test_performance_large_document() {
1135 let rule = MD005ListIndent::default();
1136 let mut content = String::new();
1137 for i in 0..100 {
1138 content.push_str(&format!("* Item {i}\n"));
1139 content.push_str(&format!(" * Nested {i}\n"));
1140 }
1141 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1142 let result = rule.check(&ctx).unwrap();
1143 assert!(result.is_empty());
1144 }
1145
1146 #[test]
1147 fn test_column_positions() {
1148 let rule = MD005ListIndent::default();
1149 let content = " * Wrong indent";
1150 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1151 let result = rule.check(&ctx).unwrap();
1152 assert_eq!(result.len(), 1);
1153 assert_eq!(result[0].column, 1, "Expected column 1, got {}", result[0].column);
1154 assert_eq!(
1155 result[0].end_column, 2,
1156 "Expected end_column 2, got {}",
1157 result[0].end_column
1158 );
1159 }
1160
1161 #[test]
1162 fn test_should_skip() {
1163 let rule = MD005ListIndent::default();
1164
1165 let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard, None);
1167 assert!(rule.should_skip(&ctx));
1168
1169 let ctx = LintContext::new("Just plain text", crate::config::MarkdownFlavor::Standard, None);
1171 assert!(rule.should_skip(&ctx));
1172
1173 let ctx = LintContext::new("* List item", crate::config::MarkdownFlavor::Standard, None);
1175 assert!(!rule.should_skip(&ctx));
1176
1177 let ctx = LintContext::new("1. Ordered list", crate::config::MarkdownFlavor::Standard, None);
1178 assert!(!rule.should_skip(&ctx));
1179 }
1180
1181 #[test]
1182 fn test_should_skip_validation() {
1183 let rule = MD005ListIndent::default();
1184 let content = "* List item";
1185 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1186 assert!(!rule.should_skip(&ctx));
1187
1188 let content = "No lists here";
1189 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1190 assert!(rule.should_skip(&ctx));
1191 }
1192
1193 #[test]
1194 fn test_edge_case_single_space_indent() {
1195 let rule = MD005ListIndent::default();
1196 let content = "\
1197* Item 1
1198 * Single space - wrong
1199 * Two spaces - correct";
1200 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1201 let result = rule.check(&ctx).unwrap();
1202 assert_eq!(result.len(), 2);
1205 assert!(result.iter().any(|w| w.line == 2 && w.message.contains("found 1")));
1206 }
1207
1208 #[test]
1209 fn test_edge_case_three_space_indent() {
1210 let rule = MD005ListIndent::default();
1211 let content = "\
1212* Item 1
1213 * Three spaces - first establishes pattern
1214 * Two spaces - inconsistent with established pattern";
1215 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1216 let result = rule.check(&ctx).unwrap();
1217 assert_eq!(result.len(), 1);
1221 assert!(result.iter().any(|w| w.line == 3 && w.message.contains("found 2")));
1222 }
1223
1224 #[test]
1225 fn test_nested_bullets_under_numbered_items() {
1226 let rule = MD005ListIndent::default();
1227 let content = "\
12281. **Active Directory/LDAP**
1229 - User authentication and directory services
1230 - LDAP for user information and validation
1231
12322. **Oracle Unified Directory (OUD)**
1233 - Extended user directory services
1234 - Verification of project account presence and changes";
1235 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1236 let result = rule.check(&ctx).unwrap();
1237 assert!(
1239 result.is_empty(),
1240 "Expected no warnings for bullets with 3 spaces under numbered items, got: {result:?}"
1241 );
1242 }
1243
1244 #[test]
1245 fn test_nested_bullets_under_numbered_items_wrong_indent() {
1246 let rule = MD005ListIndent::default();
1247 let content = "\
12481. **Active Directory/LDAP**
1249 - Wrong: only 2 spaces
1250 - Correct: 3 spaces";
1251 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1252 let result = rule.check(&ctx).unwrap();
1253 assert_eq!(
1255 result.len(),
1256 1,
1257 "Expected 1 warning, got {}. Warnings: {:?}",
1258 result.len(),
1259 result
1260 );
1261 assert!(
1263 result
1264 .iter()
1265 .any(|w| (w.line == 2 && w.message.contains("found 2"))
1266 || (w.line == 3 && w.message.contains("found 3")))
1267 );
1268 }
1269
1270 #[test]
1271 fn test_regular_nested_bullets_still_work() {
1272 let rule = MD005ListIndent::default();
1273 let content = "\
1274* Top level
1275 * Second level (2 spaces is correct for bullets under bullets)
1276 * Third level (4 spaces)";
1277 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1278 let result = rule.check(&ctx).unwrap();
1279 assert!(
1281 result.is_empty(),
1282 "Expected no warnings for regular bullet nesting, got: {result:?}"
1283 );
1284 }
1285
1286 #[test]
1287 fn test_fix_range_accuracy() {
1288 let rule = MD005ListIndent::default();
1289 let content = " * Wrong indent";
1290 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1291 let result = rule.check(&ctx).unwrap();
1292 assert_eq!(result.len(), 1);
1293
1294 let fix = result[0].fix.as_ref().unwrap();
1295 assert_eq!(fix.replacement, "");
1297 }
1298
1299 #[test]
1300 fn test_four_space_indent_pattern() {
1301 let rule = MD005ListIndent::default();
1302 let content = "\
1303* Item 1
1304 * Item 2 with 4 spaces
1305 * Item 3 with 8 spaces
1306 * Item 4 with 4 spaces";
1307 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1308 let result = rule.check(&ctx).unwrap();
1309 assert!(
1311 result.is_empty(),
1312 "MD005 should accept consistent 4-space indentation pattern, got {} warnings",
1313 result.len()
1314 );
1315 }
1316
1317 #[test]
1318 fn test_issue_64_scenario() {
1319 let rule = MD005ListIndent::default();
1321 let content = "\
1322* Top level item
1323 * Sub item with 4 spaces (as configured in MD007)
1324 * Nested sub item with 8 spaces
1325 * Another sub item with 4 spaces
1326* Another top level";
1327
1328 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1329 let result = rule.check(&ctx).unwrap();
1330
1331 assert!(
1333 result.is_empty(),
1334 "MD005 should accept 4-space indentation when that's the pattern being used. Got {} warnings",
1335 result.len()
1336 );
1337 }
1338
1339 #[test]
1340 fn test_continuation_content_scenario() {
1341 let rule = MD005ListIndent::default();
1342 let content = "\
1343- **Changes to how the Python version is inferred** ([#16319](example))
1344
1345 In previous versions of Ruff, you could specify your Python version with:
1346
1347 - The `target-version` option in a `ruff.toml` file
1348 - The `project.requires-python` field in a `pyproject.toml` file";
1349
1350 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1351
1352 let result = rule.check(&ctx).unwrap();
1353
1354 assert!(
1356 result.is_empty(),
1357 "MD005 should not flag continuation content lists, got {} warnings: {:?}",
1358 result.len(),
1359 result
1360 );
1361 }
1362
1363 #[test]
1364 fn test_multiple_continuation_lists_scenario() {
1365 let rule = MD005ListIndent::default();
1366 let content = "\
1367- **Changes to how the Python version is inferred** ([#16319](example))
1368
1369 In previous versions of Ruff, you could specify your Python version with:
1370
1371 - The `target-version` option in a `ruff.toml` file
1372 - The `project.requires-python` field in a `pyproject.toml` file
1373
1374 In v0.10, config discovery has been updated to address this issue:
1375
1376 - If Ruff finds a `ruff.toml` file without a `target-version`, it will check
1377 - If Ruff finds a user-level configuration, the `requires-python` field will take precedence
1378 - If there is no config file, Ruff will search for the closest `pyproject.toml`";
1379
1380 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1381
1382 let result = rule.check(&ctx).unwrap();
1383
1384 assert!(
1386 result.is_empty(),
1387 "MD005 should not flag continuation content lists, got {} warnings: {:?}",
1388 result.len(),
1389 result
1390 );
1391 }
1392
1393 #[test]
1394 fn test_issue_115_sublist_after_code_block() {
1395 let rule = MD005ListIndent::default();
1396 let content = "\
13971. List item 1
1398
1399 ```rust
1400 fn foo() {}
1401 ```
1402
1403 Sublist:
1404
1405 - A
1406 - B
1407";
1408 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1409 let result = rule.check(&ctx).unwrap();
1410 assert!(
1414 result.is_empty(),
1415 "Expected no warnings for sub-list after code block in list item, got {} warnings: {:?}",
1416 result.len(),
1417 result
1418 );
1419 }
1420
1421 #[test]
1422 fn test_edge_case_continuation_at_exact_boundary() {
1423 let rule = MD005ListIndent::default();
1424 let content = "\
1426* Item (content at column 2)
1427 Text at column 2 (exact boundary - continuation)
1428 * Sub at column 2";
1429 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1430 let result = rule.check(&ctx).unwrap();
1431 assert!(
1433 result.is_empty(),
1434 "Expected no warnings when text and sub-list are at exact parent content_column, got: {result:?}"
1435 );
1436 }
1437
1438 #[test]
1439 fn test_edge_case_unicode_in_continuation() {
1440 let rule = MD005ListIndent::default();
1441 let content = "\
1442* Parent
1443 Text with emoji 😀 and Unicode ñ characters
1444 * Sub-list should still work";
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 with Unicode in continuation content, got: {result:?}"
1451 );
1452 }
1453
1454 #[test]
1455 fn test_edge_case_large_empty_line_gap() {
1456 let rule = MD005ListIndent::default();
1457 let content = "\
1458* Parent at line 1
1459 Continuation text
1460
1461
1462
1463 More continuation after many empty lines
1464
1465 * Child after gap
1466 * Another child";
1467 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1468 let result = rule.check(&ctx).unwrap();
1469 assert!(
1471 result.is_empty(),
1472 "Expected no warnings with large gaps in continuation content, got: {result:?}"
1473 );
1474 }
1475
1476 #[test]
1477 fn test_edge_case_multiple_continuation_blocks_varying_indent() {
1478 let rule = MD005ListIndent::default();
1479 let content = "\
1480* Parent (content at column 2)
1481 First paragraph at column 2
1482 Indented quote at column 4
1483 Back to column 2
1484 * Sub-list at column 2";
1485 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1486 let result = rule.check(&ctx).unwrap();
1487 assert!(
1489 result.is_empty(),
1490 "Expected no warnings with varying continuation indent, got: {result:?}"
1491 );
1492 }
1493
1494 #[test]
1495 fn test_edge_case_deep_nesting_no_continuation() {
1496 let rule = MD005ListIndent::default();
1497 let content = "\
1498* Parent
1499 * Immediate child (no continuation text before)
1500 * Grandchild
1501 * Great-grandchild
1502 * Great-great-grandchild
1503 * Another child at level 2";
1504 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1505 let result = rule.check(&ctx).unwrap();
1506 assert!(
1508 result.is_empty(),
1509 "Expected no warnings for deep nesting without continuation, got: {result:?}"
1510 );
1511 }
1512
1513 #[test]
1514 fn test_edge_case_blockquote_continuation_content() {
1515 let rule = MD005ListIndent::default();
1516 let content = "\
1517> * Parent in blockquote
1518> Continuation in blockquote
1519> * Sub-list in blockquote
1520> * Another sub-list";
1521 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1522 let result = rule.check(&ctx).unwrap();
1523 assert!(
1525 result.is_empty(),
1526 "Expected no warnings for blockquote continuation, got: {result:?}"
1527 );
1528 }
1529
1530 #[test]
1531 fn test_edge_case_one_space_less_than_content_column() {
1532 let rule = MD005ListIndent::default();
1533 let content = "\
1534* Parent (content at column 2)
1535 Text at column 1 (one less than content_column - NOT continuation)
1536 * Child";
1537 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1538 let result = rule.check(&ctx).unwrap();
1539 assert!(
1545 result.is_empty() || !result.is_empty(),
1546 "Test should complete without panic"
1547 );
1548 }
1549
1550 #[test]
1551 fn test_edge_case_multiple_code_blocks_different_indentation() {
1552 let rule = MD005ListIndent::default();
1553 let content = "\
1554* Parent
1555 ```
1556 code at 2 spaces
1557 ```
1558 ```
1559 code at 4 spaces
1560 ```
1561 * Sub-list should not be confused";
1562 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1563 let result = rule.check(&ctx).unwrap();
1564 assert!(
1566 result.is_empty(),
1567 "Expected no warnings with multiple code blocks, got: {result:?}"
1568 );
1569 }
1570
1571 #[test]
1572 fn test_performance_very_large_document() {
1573 let rule = MD005ListIndent::default();
1574 let mut content = String::new();
1575
1576 for i in 0..1000 {
1578 content.push_str(&format!("* Item {i}\n"));
1579 content.push_str(&format!(" * Nested {i}\n"));
1580 if i % 10 == 0 {
1581 content.push_str(" Some continuation text\n");
1582 }
1583 }
1584
1585 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1586
1587 let start = std::time::Instant::now();
1589 let result = rule.check(&ctx).unwrap();
1590 let elapsed = start.elapsed();
1591
1592 assert!(result.is_empty());
1593 println!("Processed 1000 list items in {elapsed:?}");
1594 assert!(
1597 elapsed.as_secs() < 1,
1598 "Should complete in under 1 second, took {elapsed:?}"
1599 );
1600 }
1601
1602 #[test]
1603 fn test_ordered_list_variable_marker_width() {
1604 let rule = MD005ListIndent::default();
1609 let content = "\
16101. One
1611 - One
1612 - Two
16132. Two
1614 - One
16153. Three
1616 - One
16174. Four
1618 - One
16195. Five
1620 - One
16216. Six
1622 - One
16237. Seven
1624 - One
16258. Eight
1626 - One
16279. Nine
1628 - One
162910. Ten
1630 - One";
1631 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1632 let result = rule.check(&ctx).unwrap();
1633 assert!(
1634 result.is_empty(),
1635 "Expected no warnings for ordered list with variable marker widths, got: {result:?}"
1636 );
1637 }
1638
1639 #[test]
1640 fn test_ordered_list_inconsistent_siblings() {
1641 let rule = MD005ListIndent::default();
1643 let content = "\
16441. Item one
1645 - First sublist at 3 spaces
1646 - Second sublist at 2 spaces (inconsistent)
1647 - Third sublist at 3 spaces";
1648 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1649 let result = rule.check(&ctx).unwrap();
1650 assert_eq!(
1652 result.len(),
1653 1,
1654 "Expected 1 warning for inconsistent sibling indent, got: {result:?}"
1655 );
1656 assert!(result[0].message.contains("Expected indentation of 3"));
1657 }
1658
1659 #[test]
1660 fn test_ordered_list_single_sublist_no_warning() {
1661 let rule = MD005ListIndent::default();
1664 let content = "\
166510. Item ten
1666 - Only sublist at 3 spaces";
1667 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1668 let result = rule.check(&ctx).unwrap();
1669 assert!(
1671 result.is_empty(),
1672 "Expected no warnings for single sublist item, got: {result:?}"
1673 );
1674 }
1675
1676 #[test]
1677 fn test_sublists_grouped_by_parent_content_column() {
1678 let rule = MD005ListIndent::default();
1682 let content = "\
16839. Item nine
1684 - First sublist at 3 spaces
1685 - Second sublist at 3 spaces
1686 - Third sublist at 3 spaces
168710. Item ten
1688 - First sublist at 4 spaces
1689 - Second sublist at 4 spaces
1690 - Third sublist at 4 spaces";
1691 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1692 let result = rule.check(&ctx).unwrap();
1693 assert!(
1696 result.is_empty(),
1697 "Expected no warnings for sublists grouped by parent, got: {result:?}"
1698 );
1699 }
1700
1701 #[test]
1702 fn test_inconsistent_indent_within_parent_group() {
1703 let rule = MD005ListIndent::default();
1705 let content = "\
170610. Item ten
1707 - First sublist at 4 spaces
1708 - Second sublist at 3 spaces (inconsistent!)
1709 - Third sublist at 4 spaces";
1710 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1711 let result = rule.check(&ctx).unwrap();
1712 assert_eq!(
1714 result.len(),
1715 1,
1716 "Expected 1 warning for inconsistent indent within parent group, got: {result:?}"
1717 );
1718 assert!(result[0].line == 3);
1719 assert!(result[0].message.contains("Expected indentation of 4"));
1720 }
1721
1722 #[test]
1723 fn test_blockquote_nested_list_fix_preserves_blockquote_prefix() {
1724 use crate::rule::Rule;
1728
1729 let rule = MD005ListIndent::default();
1730 let content = "> * Federation sender blacklists are now persisted.";
1731 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1732 let result = rule.check(&ctx).unwrap();
1733
1734 assert_eq!(result.len(), 1, "Expected 1 warning for extra indent");
1735
1736 assert!(result[0].fix.is_some(), "Should have a fix");
1738 let fixed = rule.fix(&ctx).expect("Fix should succeed");
1739
1740 assert!(
1742 fixed.starts_with("> "),
1743 "Fixed content should start with blockquote prefix '> ', got: {fixed:?}"
1744 );
1745 assert!(
1746 !fixed.starts_with("* "),
1747 "Fixed content should NOT start with just '* ' (blockquote removed), got: {fixed:?}"
1748 );
1749 assert_eq!(
1750 fixed.trim(),
1751 "> * Federation sender blacklists are now persisted.",
1752 "Fixed content should be '> * Federation sender...' with single space after >"
1753 );
1754 }
1755
1756 #[test]
1757 fn test_nested_blockquote_list_fix_preserves_prefix() {
1758 use crate::rule::Rule;
1760
1761 let rule = MD005ListIndent::default();
1762 let content = ">> * Nested blockquote list item";
1763 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1764 let result = rule.check(&ctx).unwrap();
1765
1766 if !result.is_empty() {
1767 let fixed = rule.fix(&ctx).expect("Fix should succeed");
1768 assert!(
1770 fixed.contains(">>") || fixed.contains("> >"),
1771 "Fixed content should preserve nested blockquote prefix, got: {fixed:?}"
1772 );
1773 }
1774 }
1775}