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_stack: Vec<(usize, usize)> = Vec::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 while let Some(&(indent, _)) = indent_stack.last() {
77 if indent < marker_column {
78 break;
79 }
80 indent_stack.pop();
81 }
82
83 if let Some((_, parent_line)) = indent_stack.last() {
84 parent_map.insert(line_num, *parent_line);
85 }
86
87 indent_stack.push((marker_column, line_num));
88 }
89 flags.push(flag);
90 }
91
92 Self {
93 indentation,
94 flags,
95 parent_map,
96 }
97 }
98
99 fn has_content(&self, idx: usize) -> bool {
101 self.flags.get(idx).is_some_and(|&f| f & FLAG_HAS_CONTENT != 0)
102 }
103
104 fn is_list_item(&self, idx: usize) -> bool {
106 self.flags.get(idx).is_some_and(|&f| f & FLAG_IS_LIST_ITEM != 0)
107 }
108
109 fn find_continuation_indent(
111 &self,
112 start_line: usize,
113 end_line: usize,
114 parent_content_column: usize,
115 ) -> Option<usize> {
116 if start_line == 0 || start_line > end_line || end_line > self.indentation.len() {
117 return None;
118 }
119
120 let start_idx = start_line - 1;
122 let end_idx = end_line - 1;
123
124 for idx in start_idx..=end_idx {
125 if !self.has_content(idx) || self.is_list_item(idx) {
127 continue;
128 }
129
130 if self.indentation[idx] >= parent_content_column {
133 return Some(self.indentation[idx]);
134 }
135 }
136 None
137 }
138
139 fn has_continuation_content(&self, parent_line: usize, current_line: usize, parent_content_column: usize) -> bool {
141 if parent_line == 0 || current_line <= parent_line || current_line > self.indentation.len() {
142 return false;
143 }
144
145 let start_idx = parent_line; let end_idx = current_line - 2; if start_idx > end_idx {
150 return false;
151 }
152
153 for idx in start_idx..=end_idx {
154 if !self.has_content(idx) || self.is_list_item(idx) {
156 continue;
157 }
158
159 if self.indentation[idx] >= parent_content_column {
162 return true;
163 }
164 }
165 false
166 }
167}
168
169impl MD005ListIndent {
170 const LIST_GROUP_GAP_TOLERANCE: usize = 2;
174
175 const MIN_CHILD_INDENT_INCREASE: usize = 2;
178
179 const SAME_LEVEL_TOLERANCE: i32 = 1;
182
183 const STANDARD_CONTINUATION_OFFSET: usize = 2;
186
187 fn create_indent_warning(
189 &self,
190 ctx: &crate::lint_context::LintContext,
191 line_num: usize,
192 line_info: &crate::lint_context::LineInfo,
193 actual_indent: usize,
194 expected_indent: usize,
195 ) -> LintWarning {
196 let message = format!(
197 "Expected indentation of {} {}, found {}",
198 expected_indent,
199 if expected_indent == 1 { "space" } else { "spaces" },
200 actual_indent
201 );
202
203 let (start_line, start_col, end_line, end_col) = if actual_indent > 0 {
204 calculate_match_range(line_num, line_info.content(ctx.content), 0, actual_indent)
205 } else {
206 calculate_match_range(line_num, line_info.content(ctx.content), 0, 1)
207 };
208
209 let (fix_range, replacement) = if line_info.blockquote.is_some() {
212 let start_byte = line_info.byte_offset;
214 let mut end_byte = line_info.byte_offset;
215
216 let marker_column = line_info
218 .list_item
219 .as_ref()
220 .map(|li| li.marker_column)
221 .unwrap_or(actual_indent);
222
223 for (i, ch) in line_info.content(ctx.content).chars().enumerate() {
225 if i >= marker_column {
226 break;
227 }
228 end_byte += ch.len_utf8();
229 }
230
231 let mut blockquote_count = 0;
233 for ch in line_info.content(ctx.content).chars() {
234 if ch == '>' {
235 blockquote_count += 1;
236 } else if ch != ' ' && ch != '\t' {
237 break;
238 }
239 }
240
241 let blockquote_prefix = if blockquote_count > 1 {
243 (0..blockquote_count)
244 .map(|_| "> ")
245 .collect::<String>()
246 .trim_end()
247 .to_string()
248 } else {
249 ">".to_string()
250 };
251
252 let correct_indent = " ".repeat(expected_indent);
254 let replacement = format!("{blockquote_prefix} {correct_indent}");
255
256 (start_byte..end_byte, replacement)
257 } else {
258 let fix_range = if actual_indent > 0 {
260 let start_byte = ctx.line_offsets.get(line_num - 1).copied().unwrap_or(0);
261 let end_byte = start_byte + actual_indent;
262 start_byte..end_byte
263 } else {
264 let byte_pos = ctx.line_offsets.get(line_num - 1).copied().unwrap_or(0);
265 byte_pos..byte_pos
266 };
267
268 let replacement = if expected_indent > 0 {
269 " ".repeat(expected_indent)
270 } else {
271 String::new()
272 };
273
274 (fix_range, replacement)
275 };
276
277 LintWarning {
278 rule_name: Some(self.name().to_string()),
279 line: start_line,
280 column: start_col,
281 end_line,
282 end_column: end_col,
283 message,
284 severity: Severity::Warning,
285 fix: Some(Fix {
286 range: fix_range,
287 replacement,
288 }),
289 }
290 }
291
292 fn check_indent_consistency(
295 &self,
296 ctx: &crate::lint_context::LintContext,
297 items: &[(usize, usize, &crate::lint_context::LineInfo)],
298 warnings: &mut Vec<LintWarning>,
299 ) {
300 if items.len() < 2 {
301 return;
302 }
303
304 let mut sorted_items: Vec<_> = items.iter().collect();
306 sorted_items.sort_by_key(|(line_num, _, _)| *line_num);
307
308 let indents: std::collections::HashSet<usize> = sorted_items.iter().map(|(_, indent, _)| *indent).collect();
309
310 if indents.len() > 1 {
311 let expected_indent = sorted_items.first().map(|(_, i, _)| *i).unwrap_or(0);
314
315 for (line_num, indent, line_info) in items {
316 if *indent != expected_indent {
317 warnings.push(self.create_indent_warning(ctx, *line_num, line_info, *indent, expected_indent));
318 }
319 }
320 }
321 }
322
323 fn group_by_parent_content_column<'a>(
330 &self,
331 level: usize,
332 group: &[(usize, usize, &'a crate::lint_context::LineInfo)],
333 all_list_items: &[(
334 usize,
335 usize,
336 &crate::lint_context::LineInfo,
337 &crate::lint_context::ListItemInfo,
338 )],
339 level_map: &HashMap<usize, usize>,
340 ) -> ParentContentGroups<'a> {
341 let parent_level = level - 1;
342
343 let is_ordered_map: HashMap<usize, bool> = all_list_items
345 .iter()
346 .map(|(ln, _, _, item)| (*ln, item.is_ordered))
347 .collect();
348
349 let mut parent_content_groups: ParentContentGroups<'a> = HashMap::new();
350
351 for (line_num, indent, line_info) in group {
352 let item_is_ordered = is_ordered_map.get(line_num).copied().unwrap_or(false);
353
354 let mut parent_content_col: Option<usize> = None;
356
357 for (prev_line, _, _, list_item) in all_list_items.iter().rev() {
358 if *prev_line >= *line_num {
359 continue;
360 }
361 if let Some(&prev_level) = level_map.get(prev_line)
362 && prev_level == parent_level
363 {
364 parent_content_col = Some(list_item.content_column);
365 break;
366 }
367 }
368
369 if let Some(parent_col) = parent_content_col {
370 parent_content_groups
371 .entry((parent_col, item_is_ordered))
372 .or_default()
373 .push((*line_num, *indent, *line_info));
374 }
375 }
376
377 parent_content_groups
378 }
379
380 fn group_related_list_blocks<'a>(
382 &self,
383 list_blocks: &'a [crate::lint_context::ListBlock],
384 ) -> Vec<Vec<&'a crate::lint_context::ListBlock>> {
385 if list_blocks.is_empty() {
386 return Vec::new();
387 }
388
389 let mut groups = Vec::new();
390 let mut current_group = vec![&list_blocks[0]];
391
392 for i in 1..list_blocks.len() {
393 let prev_block = &list_blocks[i - 1];
394 let current_block = &list_blocks[i];
395
396 let line_gap = current_block.start_line.saturating_sub(prev_block.end_line);
398
399 if line_gap <= Self::LIST_GROUP_GAP_TOLERANCE {
402 current_group.push(current_block);
403 } else {
404 groups.push(current_group);
406 current_group = vec![current_block];
407 }
408 }
409 groups.push(current_group);
410
411 groups
412 }
413
414 fn is_continuation_content(
417 &self,
418 ctx: &crate::lint_context::LintContext,
419 cache: &LineCacheInfo,
420 list_line: usize,
421 list_indent: usize,
422 ) -> bool {
423 let parent_line = cache.parent_map.get(&list_line).copied();
425
426 if let Some(parent_line) = parent_line
427 && let Some(line_info) = ctx.line_info(parent_line)
428 && let Some(parent_list_item) = &line_info.list_item
429 {
430 let parent_marker_column = parent_list_item.marker_column;
431 let parent_content_column = parent_list_item.content_column;
432
433 let continuation_indent =
435 cache.find_continuation_indent(parent_line + 1, list_line - 1, parent_content_column);
436
437 if let Some(continuation_indent) = continuation_indent {
438 let is_standard_continuation =
439 list_indent == parent_content_column + Self::STANDARD_CONTINUATION_OFFSET;
440 let matches_content_indent = list_indent == continuation_indent;
441
442 if matches_content_indent || is_standard_continuation {
443 return true;
444 }
445 }
446
447 if list_indent > parent_marker_column {
450 if self.has_continuation_list_at_indent(
452 ctx,
453 cache,
454 parent_line,
455 list_line,
456 list_indent,
457 parent_content_column,
458 ) {
459 return true;
460 }
461
462 if cache.has_continuation_content(parent_line, list_line, parent_content_column) {
463 return true;
464 }
465 }
466 }
467
468 false
469 }
470
471 fn has_continuation_list_at_indent(
473 &self,
474 ctx: &crate::lint_context::LintContext,
475 cache: &LineCacheInfo,
476 parent_line: usize,
477 current_line: usize,
478 list_indent: usize,
479 parent_content_column: usize,
480 ) -> bool {
481 for line_num in (parent_line + 1)..current_line {
484 if let Some(line_info) = ctx.line_info(line_num)
485 && let Some(list_item) = &line_info.list_item
486 && list_item.marker_column == list_indent
487 {
488 if cache
491 .find_continuation_indent(parent_line + 1, line_num - 1, parent_content_column)
492 .is_some()
493 {
494 return true;
495 }
496 }
497 }
498 false
499 }
500
501 fn check_list_block_group(
503 &self,
504 ctx: &crate::lint_context::LintContext,
505 group: &[&crate::lint_context::ListBlock],
506 warnings: &mut Vec<LintWarning>,
507 ) -> Result<(), LintError> {
508 let cache = LineCacheInfo::new(ctx);
510
511 let mut candidate_items: Vec<(
514 usize,
515 usize,
516 &crate::lint_context::LineInfo,
517 &crate::lint_context::ListItemInfo,
518 )> = Vec::new();
519
520 for list_block in group {
521 for &item_line in &list_block.item_lines {
522 if let Some(line_info) = ctx.line_info(item_line)
523 && let Some(list_item) = &line_info.list_item
524 {
525 let effective_indent = if let Some(blockquote) = &line_info.blockquote {
527 list_item.marker_column.saturating_sub(blockquote.nesting_level * 2)
529 } else {
530 list_item.marker_column
532 };
533
534 candidate_items.push((item_line, effective_indent, line_info, list_item));
535 }
536 }
537 }
538
539 candidate_items.sort_by_key(|(line_num, _, _, _)| *line_num);
541
542 let mut skipped_lines: std::collections::HashSet<usize> = std::collections::HashSet::new();
545 let mut all_list_items: Vec<(
546 usize,
547 usize,
548 &crate::lint_context::LineInfo,
549 &crate::lint_context::ListItemInfo,
550 )> = Vec::new();
551
552 for (item_line, effective_indent, line_info, list_item) in candidate_items {
553 if self.is_continuation_content(ctx, &cache, item_line, effective_indent) {
555 skipped_lines.insert(item_line);
556 continue;
557 }
558
559 if let Some(&parent_line) = cache.parent_map.get(&item_line)
561 && skipped_lines.contains(&parent_line)
562 {
563 skipped_lines.insert(item_line);
564 continue;
565 }
566
567 all_list_items.push((item_line, effective_indent, line_info, list_item));
568 }
569
570 if all_list_items.is_empty() {
571 return Ok(());
572 }
573
574 all_list_items.sort_by_key(|(line_num, _, _, _)| *line_num);
576
577 let mut level_map: HashMap<usize, usize> = HashMap::new();
581 let mut level_indents: HashMap<usize, Vec<usize>> = HashMap::new(); let mut indent_to_level: HashMap<usize, (usize, usize)> = HashMap::new();
586
587 for (line_num, indent, _, _) in &all_list_items {
589 let level = if indent_to_level.is_empty() {
590 level_indents.entry(1).or_default().push(*indent);
592 1
593 } else {
594 let mut determined_level = 0;
596
597 if let Some(&(existing_level, _)) = indent_to_level.get(indent) {
599 determined_level = existing_level;
600 } else {
601 let mut best_parent: Option<(usize, usize, usize)> = None; for (&tracked_indent, &(tracked_level, tracked_line)) in &indent_to_level {
607 if tracked_indent < *indent {
608 if best_parent.is_none() || tracked_indent > best_parent.unwrap().0 {
611 best_parent = Some((tracked_indent, tracked_level, tracked_line));
612 }
613 }
614 }
615
616 if let Some((parent_indent, parent_level, _parent_line)) = best_parent {
617 if parent_indent + Self::MIN_CHILD_INDENT_INCREASE <= *indent {
619 determined_level = parent_level + 1;
621 } else if (*indent as i32 - parent_indent as i32).abs() <= Self::SAME_LEVEL_TOLERANCE {
622 determined_level = parent_level;
624 } else {
625 let mut found_similar = false;
629 if let Some(indents_at_level) = level_indents.get(&parent_level) {
630 for &level_indent in indents_at_level {
631 if (level_indent as i32 - *indent as i32).abs() <= Self::SAME_LEVEL_TOLERANCE {
632 determined_level = parent_level;
633 found_similar = true;
634 break;
635 }
636 }
637 }
638 if !found_similar {
639 determined_level = parent_level + 1;
641 }
642 }
643 }
644
645 if determined_level == 0 {
647 determined_level = 1;
648 }
649
650 level_indents.entry(determined_level).or_default().push(*indent);
652 }
653
654 determined_level
655 };
656
657 level_map.insert(*line_num, level);
658 indent_to_level.insert(*indent, (level, *line_num));
660 }
661
662 let mut level_groups: HashMap<usize, Vec<(usize, usize, &crate::lint_context::LineInfo)>> = HashMap::new();
664 for (line_num, indent, line_info, _) in &all_list_items {
665 let level = level_map[line_num];
666 level_groups
667 .entry(level)
668 .or_default()
669 .push((*line_num, *indent, *line_info));
670 }
671
672 for (level, mut group) in level_groups {
674 group.sort_by_key(|(line_num, _, _)| *line_num);
675
676 if level == 1 {
677 for (line_num, indent, line_info) in &group {
679 if *indent != self.top_level_indent {
680 warnings.push(self.create_indent_warning(
681 ctx,
682 *line_num,
683 line_info,
684 *indent,
685 self.top_level_indent,
686 ));
687 }
688 }
689 } else {
690 let parent_content_groups =
693 self.group_by_parent_content_column(level, &group, &all_list_items, &level_map);
694
695 for items in parent_content_groups.values() {
697 self.check_indent_consistency(ctx, items, warnings);
698 }
699 }
700 }
701
702 Ok(())
703 }
704
705 fn check_optimized(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
707 let content = ctx.content;
708
709 if content.is_empty() {
711 return Ok(Vec::new());
712 }
713
714 if ctx.list_blocks.is_empty() {
716 return Ok(Vec::new());
717 }
718
719 let mut warnings = Vec::new();
720
721 let block_groups = self.group_related_list_blocks(&ctx.list_blocks);
724
725 for group in block_groups {
726 self.check_list_block_group(ctx, &group, &mut warnings)?;
727 }
728
729 Ok(warnings)
730 }
731}
732
733impl Rule for MD005ListIndent {
734 fn name(&self) -> &'static str {
735 "MD005"
736 }
737
738 fn description(&self) -> &'static str {
739 "List indentation should be consistent"
740 }
741
742 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
743 self.check_optimized(ctx)
745 }
746
747 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
748 let warnings = self.check(ctx)?;
749 if warnings.is_empty() {
750 return Ok(ctx.content.to_string());
751 }
752
753 let mut warnings_with_fixes: Vec<_> = warnings
755 .into_iter()
756 .filter_map(|w| w.fix.clone().map(|fix| (w, fix)))
757 .collect();
758 warnings_with_fixes.sort_by_key(|(_, fix)| std::cmp::Reverse(fix.range.start));
759
760 let mut content = ctx.content.to_string();
762 for (_, fix) in warnings_with_fixes {
763 if fix.range.start <= content.len() && fix.range.end <= content.len() {
764 content.replace_range(fix.range, &fix.replacement);
765 }
766 }
767
768 Ok(content)
769 }
770
771 fn category(&self) -> RuleCategory {
772 RuleCategory::List
773 }
774
775 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
777 ctx.content.is_empty() || !ctx.lines.iter().any(|line| line.list_item.is_some())
779 }
780
781 fn as_any(&self) -> &dyn std::any::Any {
782 self
783 }
784
785 fn default_config_section(&self) -> Option<(String, toml::Value)> {
786 None
787 }
788
789 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
790 where
791 Self: Sized,
792 {
793 let mut top_level_indent = 0;
795
796 if let Some(md007_config) = config.rules.get("MD007") {
798 if let Some(start_indented) = md007_config.values.get("start-indented")
800 && let Some(start_indented_bool) = start_indented.as_bool()
801 && start_indented_bool
802 {
803 if let Some(start_indent) = md007_config.values.get("start-indent") {
805 if let Some(indent_value) = start_indent.as_integer() {
806 top_level_indent = indent_value as usize;
807 }
808 } else {
809 top_level_indent = 2;
811 }
812 }
813 }
814
815 Box::new(MD005ListIndent { top_level_indent })
816 }
817}
818
819#[cfg(test)]
820mod tests {
821 use super::*;
822 use crate::lint_context::LintContext;
823
824 #[test]
825 fn test_valid_unordered_list() {
826 let rule = MD005ListIndent::default();
827 let content = "\
828* Item 1
829* Item 2
830 * Nested 1
831 * Nested 2
832* Item 3";
833 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
834 let result = rule.check(&ctx).unwrap();
835 assert!(result.is_empty());
836 }
837
838 #[test]
839 fn test_valid_ordered_list() {
840 let rule = MD005ListIndent::default();
841 let content = "\
8421. Item 1
8432. Item 2
844 1. Nested 1
845 2. Nested 2
8463. Item 3";
847 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
848 let result = rule.check(&ctx).unwrap();
849 assert!(result.is_empty());
852 }
853
854 #[test]
855 fn test_invalid_unordered_indent() {
856 let rule = MD005ListIndent::default();
857 let content = "\
858* Item 1
859 * Item 2
860 * Nested 1";
861 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
862 let result = rule.check(&ctx).unwrap();
863 assert_eq!(result.len(), 1);
866 let fixed = rule.fix(&ctx).unwrap();
867 assert_eq!(fixed, "* Item 1\n* Item 2\n * Nested 1");
868 }
869
870 #[test]
871 fn test_invalid_ordered_indent() {
872 let rule = MD005ListIndent::default();
873 let content = "\
8741. Item 1
875 2. Item 2
876 1. Nested 1";
877 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
878 let result = rule.check(&ctx).unwrap();
879 assert_eq!(result.len(), 1);
880 let fixed = rule.fix(&ctx).unwrap();
881 assert_eq!(fixed, "1. Item 1\n2. Item 2\n 1. Nested 1");
885 }
886
887 #[test]
888 fn test_mixed_list_types() {
889 let rule = MD005ListIndent::default();
890 let content = "\
891* Item 1
892 1. Nested ordered
893 * Nested unordered
894* Item 2";
895 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
896 let result = rule.check(&ctx).unwrap();
897 assert!(result.is_empty());
898 }
899
900 #[test]
901 fn test_multiple_levels() {
902 let rule = MD005ListIndent::default();
903 let content = "\
904* Level 1
905 * Level 2
906 * Level 3";
907 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
908 let result = rule.check(&ctx).unwrap();
909 assert!(result.is_empty(), "MD005 should accept consistent indentation pattern");
911 }
912
913 #[test]
914 fn test_empty_lines() {
915 let rule = MD005ListIndent::default();
916 let content = "\
917* Item 1
918
919 * Nested 1
920
921* Item 2";
922 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
923 let result = rule.check(&ctx).unwrap();
924 assert!(result.is_empty());
925 }
926
927 #[test]
928 fn test_no_lists() {
929 let rule = MD005ListIndent::default();
930 let content = "\
931Just some text
932More text
933Even more text";
934 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
935 let result = rule.check(&ctx).unwrap();
936 assert!(result.is_empty());
937 }
938
939 #[test]
940 fn test_complex_nesting() {
941 let rule = MD005ListIndent::default();
942 let content = "\
943* Level 1
944 * Level 2
945 * Level 3
946 * Back to 2
947 1. Ordered 3
948 2. Still 3
949* Back to 1";
950 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
951 let result = rule.check(&ctx).unwrap();
952 assert!(result.is_empty());
953 }
954
955 #[test]
956 fn test_invalid_complex_nesting() {
957 let rule = MD005ListIndent::default();
958 let content = "\
959* Level 1
960 * Level 2
961 * Level 3
962 * Back to 2
963 1. Ordered 3
964 2. Still 3
965* Back to 1";
966 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
967 let result = rule.check(&ctx).unwrap();
968 assert_eq!(result.len(), 1);
970 assert!(
971 result[0].message.contains("Expected indentation of 5 spaces, found 6")
972 || result[0].message.contains("Expected indentation of 6 spaces, found 5")
973 );
974 }
975
976 #[test]
977 fn test_with_lint_context() {
978 let rule = MD005ListIndent::default();
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());
985
986 let content = "* Item 1\n* Item 2\n * Nested item\n * Another nested item";
988 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
989 let result = rule.check(&ctx).unwrap();
990 assert!(!result.is_empty()); let content = "* Item 1\n * Nested item\n * Another nested item with wrong indent";
994 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
995 let result = rule.check(&ctx).unwrap();
996 assert!(!result.is_empty()); }
998
999 #[test]
1001 fn test_list_with_continuations() {
1002 let rule = MD005ListIndent::default();
1003 let content = "\
1004* Item 1
1005 This is a continuation
1006 of the first item
1007 * Nested item
1008 with its own continuation
1009* Item 2";
1010 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1011 let result = rule.check(&ctx).unwrap();
1012 assert!(result.is_empty());
1013 }
1014
1015 #[test]
1016 fn test_list_in_blockquote() {
1017 let rule = MD005ListIndent::default();
1018 let content = "\
1019> * Item 1
1020> * Nested 1
1021> * Nested 2
1022> * Item 2";
1023 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1024 let result = rule.check(&ctx).unwrap();
1025
1026 assert!(
1028 result.is_empty(),
1029 "Expected no warnings for correctly indented blockquote list, got: {result:?}"
1030 );
1031 }
1032
1033 #[test]
1034 fn test_list_with_code_blocks() {
1035 let rule = MD005ListIndent::default();
1036 let content = "\
1037* Item 1
1038 ```
1039 code block
1040 ```
1041 * Nested item
1042* Item 2";
1043 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1044 let result = rule.check(&ctx).unwrap();
1045 assert!(result.is_empty());
1046 }
1047
1048 #[test]
1049 fn test_list_with_tabs() {
1050 let rule = MD005ListIndent::default();
1051 let content = "* Item 1\n * Wrong indent (3 spaces)\n * Correct indent (2 spaces)";
1055 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1056 let result = rule.check(&ctx).unwrap();
1057 assert!(!result.is_empty());
1059 }
1060
1061 #[test]
1062 fn test_inconsistent_at_same_level() {
1063 let rule = MD005ListIndent::default();
1064 let content = "\
1065* Item 1
1066 * Nested 1
1067 * Nested 2
1068 * Wrong indent for same level
1069 * Nested 3";
1070 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1071 let result = rule.check(&ctx).unwrap();
1072 assert!(!result.is_empty());
1073 assert!(result.iter().any(|w| w.line == 4));
1075 }
1076
1077 #[test]
1078 fn test_zero_indent_top_level() {
1079 let rule = MD005ListIndent::default();
1080 let content = concat!(" * Wrong indent\n", "* Correct\n", " * Nested");
1082 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1083 let result = rule.check(&ctx).unwrap();
1084
1085 assert!(!result.is_empty());
1087 assert!(result.iter().any(|w| w.line == 1));
1088 }
1089
1090 #[test]
1091 fn test_fix_preserves_content() {
1092 let rule = MD005ListIndent::default();
1093 let content = "\
1094* Item with **bold** and *italic*
1095 * Wrong indent with `code`
1096 * Also wrong with [link](url)";
1097 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1098 let fixed = rule.fix(&ctx).unwrap();
1099 assert!(fixed.contains("**bold**"));
1100 assert!(fixed.contains("*italic*"));
1101 assert!(fixed.contains("`code`"));
1102 assert!(fixed.contains("[link](url)"));
1103 }
1104
1105 #[test]
1106 fn test_deeply_nested_lists() {
1107 let rule = MD005ListIndent::default();
1108 let content = "\
1109* L1
1110 * L2
1111 * L3
1112 * L4
1113 * L5
1114 * L6";
1115 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1116 let result = rule.check(&ctx).unwrap();
1117 assert!(result.is_empty());
1118 }
1119
1120 #[test]
1121 fn test_fix_multiple_issues() {
1122 let rule = MD005ListIndent::default();
1123 let content = "\
1124* Item 1
1125 * Wrong 1
1126 * Wrong 2
1127 * Wrong 3
1128 * Correct
1129 * Wrong 4";
1130 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1131 let fixed = rule.fix(&ctx).unwrap();
1132 let lines: Vec<&str> = fixed.lines().collect();
1134 assert_eq!(lines[0], "* Item 1");
1135 assert!(lines[1].starts_with(" * ") || lines[1].starts_with("* "));
1137 }
1138
1139 #[test]
1140 fn test_performance_large_document() {
1141 let rule = MD005ListIndent::default();
1142 let mut content = String::new();
1143 for i in 0..100 {
1144 content.push_str(&format!("* Item {i}\n"));
1145 content.push_str(&format!(" * Nested {i}\n"));
1146 }
1147 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1148 let result = rule.check(&ctx).unwrap();
1149 assert!(result.is_empty());
1150 }
1151
1152 #[test]
1153 fn test_column_positions() {
1154 let rule = MD005ListIndent::default();
1155 let content = " * Wrong indent";
1156 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1157 let result = rule.check(&ctx).unwrap();
1158 assert_eq!(result.len(), 1);
1159 assert_eq!(result[0].column, 1, "Expected column 1, got {}", result[0].column);
1160 assert_eq!(
1161 result[0].end_column, 2,
1162 "Expected end_column 2, got {}",
1163 result[0].end_column
1164 );
1165 }
1166
1167 #[test]
1168 fn test_should_skip() {
1169 let rule = MD005ListIndent::default();
1170
1171 let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard, None);
1173 assert!(rule.should_skip(&ctx));
1174
1175 let ctx = LintContext::new("Just plain text", crate::config::MarkdownFlavor::Standard, None);
1177 assert!(rule.should_skip(&ctx));
1178
1179 let ctx = LintContext::new("* List item", crate::config::MarkdownFlavor::Standard, None);
1181 assert!(!rule.should_skip(&ctx));
1182
1183 let ctx = LintContext::new("1. Ordered list", crate::config::MarkdownFlavor::Standard, None);
1184 assert!(!rule.should_skip(&ctx));
1185 }
1186
1187 #[test]
1188 fn test_should_skip_validation() {
1189 let rule = MD005ListIndent::default();
1190 let content = "* List item";
1191 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1192 assert!(!rule.should_skip(&ctx));
1193
1194 let content = "No lists here";
1195 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1196 assert!(rule.should_skip(&ctx));
1197 }
1198
1199 #[test]
1200 fn test_edge_case_single_space_indent() {
1201 let rule = MD005ListIndent::default();
1202 let content = "\
1203* Item 1
1204 * Single space - wrong
1205 * Two spaces - correct";
1206 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1207 let result = rule.check(&ctx).unwrap();
1208 assert_eq!(result.len(), 2);
1211 assert!(result.iter().any(|w| w.line == 2 && w.message.contains("found 1")));
1212 }
1213
1214 #[test]
1215 fn test_edge_case_three_space_indent() {
1216 let rule = MD005ListIndent::default();
1217 let content = "\
1218* Item 1
1219 * Three spaces - first establishes pattern
1220 * Two spaces - inconsistent with established pattern";
1221 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1222 let result = rule.check(&ctx).unwrap();
1223 assert_eq!(result.len(), 1);
1227 assert!(result.iter().any(|w| w.line == 3 && w.message.contains("found 2")));
1228 }
1229
1230 #[test]
1231 fn test_nested_bullets_under_numbered_items() {
1232 let rule = MD005ListIndent::default();
1233 let content = "\
12341. **Active Directory/LDAP**
1235 - User authentication and directory services
1236 - LDAP for user information and validation
1237
12382. **Oracle Unified Directory (OUD)**
1239 - Extended user directory services
1240 - Verification of project account presence and changes";
1241 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1242 let result = rule.check(&ctx).unwrap();
1243 assert!(
1245 result.is_empty(),
1246 "Expected no warnings for bullets with 3 spaces under numbered items, got: {result:?}"
1247 );
1248 }
1249
1250 #[test]
1251 fn test_nested_bullets_under_numbered_items_wrong_indent() {
1252 let rule = MD005ListIndent::default();
1253 let content = "\
12541. **Active Directory/LDAP**
1255 - Wrong: only 2 spaces
1256 - Correct: 3 spaces";
1257 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1258 let result = rule.check(&ctx).unwrap();
1259 assert_eq!(
1261 result.len(),
1262 1,
1263 "Expected 1 warning, got {}. Warnings: {:?}",
1264 result.len(),
1265 result
1266 );
1267 assert!(
1269 result
1270 .iter()
1271 .any(|w| (w.line == 2 && w.message.contains("found 2"))
1272 || (w.line == 3 && w.message.contains("found 3")))
1273 );
1274 }
1275
1276 #[test]
1277 fn test_regular_nested_bullets_still_work() {
1278 let rule = MD005ListIndent::default();
1279 let content = "\
1280* Top level
1281 * Second level (2 spaces is correct for bullets under bullets)
1282 * Third level (4 spaces)";
1283 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1284 let result = rule.check(&ctx).unwrap();
1285 assert!(
1287 result.is_empty(),
1288 "Expected no warnings for regular bullet nesting, got: {result:?}"
1289 );
1290 }
1291
1292 #[test]
1293 fn test_fix_range_accuracy() {
1294 let rule = MD005ListIndent::default();
1295 let content = " * Wrong indent";
1296 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1297 let result = rule.check(&ctx).unwrap();
1298 assert_eq!(result.len(), 1);
1299
1300 let fix = result[0].fix.as_ref().unwrap();
1301 assert_eq!(fix.replacement, "");
1303 }
1304
1305 #[test]
1306 fn test_four_space_indent_pattern() {
1307 let rule = MD005ListIndent::default();
1308 let content = "\
1309* Item 1
1310 * Item 2 with 4 spaces
1311 * Item 3 with 8 spaces
1312 * Item 4 with 4 spaces";
1313 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1314 let result = rule.check(&ctx).unwrap();
1315 assert!(
1317 result.is_empty(),
1318 "MD005 should accept consistent 4-space indentation pattern, got {} warnings",
1319 result.len()
1320 );
1321 }
1322
1323 #[test]
1324 fn test_issue_64_scenario() {
1325 let rule = MD005ListIndent::default();
1327 let content = "\
1328* Top level item
1329 * Sub item with 4 spaces (as configured in MD007)
1330 * Nested sub item with 8 spaces
1331 * Another sub item with 4 spaces
1332* Another top level";
1333
1334 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1335 let result = rule.check(&ctx).unwrap();
1336
1337 assert!(
1339 result.is_empty(),
1340 "MD005 should accept 4-space indentation when that's the pattern being used. Got {} warnings",
1341 result.len()
1342 );
1343 }
1344
1345 #[test]
1346 fn test_continuation_content_scenario() {
1347 let rule = MD005ListIndent::default();
1348 let content = "\
1349- **Changes to how the Python version is inferred** ([#16319](example))
1350
1351 In previous versions of Ruff, you could specify your Python version with:
1352
1353 - The `target-version` option in a `ruff.toml` file
1354 - The `project.requires-python` field in a `pyproject.toml` file";
1355
1356 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1357
1358 let result = rule.check(&ctx).unwrap();
1359
1360 assert!(
1362 result.is_empty(),
1363 "MD005 should not flag continuation content lists, got {} warnings: {:?}",
1364 result.len(),
1365 result
1366 );
1367 }
1368
1369 #[test]
1370 fn test_multiple_continuation_lists_scenario() {
1371 let rule = MD005ListIndent::default();
1372 let content = "\
1373- **Changes to how the Python version is inferred** ([#16319](example))
1374
1375 In previous versions of Ruff, you could specify your Python version with:
1376
1377 - The `target-version` option in a `ruff.toml` file
1378 - The `project.requires-python` field in a `pyproject.toml` file
1379
1380 In v0.10, config discovery has been updated to address this issue:
1381
1382 - If Ruff finds a `ruff.toml` file without a `target-version`, it will check
1383 - If Ruff finds a user-level configuration, the `requires-python` field will take precedence
1384 - If there is no config file, Ruff will search for the closest `pyproject.toml`";
1385
1386 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1387
1388 let result = rule.check(&ctx).unwrap();
1389
1390 assert!(
1392 result.is_empty(),
1393 "MD005 should not flag continuation content lists, got {} warnings: {:?}",
1394 result.len(),
1395 result
1396 );
1397 }
1398
1399 #[test]
1400 fn test_issue_115_sublist_after_code_block() {
1401 let rule = MD005ListIndent::default();
1402 let content = "\
14031. List item 1
1404
1405 ```rust
1406 fn foo() {}
1407 ```
1408
1409 Sublist:
1410
1411 - A
1412 - B
1413";
1414 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1415 let result = rule.check(&ctx).unwrap();
1416 assert!(
1420 result.is_empty(),
1421 "Expected no warnings for sub-list after code block in list item, got {} warnings: {:?}",
1422 result.len(),
1423 result
1424 );
1425 }
1426
1427 #[test]
1428 fn test_edge_case_continuation_at_exact_boundary() {
1429 let rule = MD005ListIndent::default();
1430 let content = "\
1432* Item (content at column 2)
1433 Text at column 2 (exact boundary - continuation)
1434 * Sub at column 2";
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 "Expected no warnings when text and sub-list are at exact parent content_column, got: {result:?}"
1441 );
1442 }
1443
1444 #[test]
1445 fn test_edge_case_unicode_in_continuation() {
1446 let rule = MD005ListIndent::default();
1447 let content = "\
1448* Parent
1449 Text with emoji 😀 and Unicode ñ characters
1450 * Sub-list should still work";
1451 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1452 let result = rule.check(&ctx).unwrap();
1453 assert!(
1455 result.is_empty(),
1456 "Expected no warnings with Unicode in continuation content, got: {result:?}"
1457 );
1458 }
1459
1460 #[test]
1461 fn test_edge_case_large_empty_line_gap() {
1462 let rule = MD005ListIndent::default();
1463 let content = "\
1464* Parent at line 1
1465 Continuation text
1466
1467
1468
1469 More continuation after many empty lines
1470
1471 * Child after gap
1472 * Another child";
1473 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1474 let result = rule.check(&ctx).unwrap();
1475 assert!(
1477 result.is_empty(),
1478 "Expected no warnings with large gaps in continuation content, got: {result:?}"
1479 );
1480 }
1481
1482 #[test]
1483 fn test_edge_case_multiple_continuation_blocks_varying_indent() {
1484 let rule = MD005ListIndent::default();
1485 let content = "\
1486* Parent (content at column 2)
1487 First paragraph at column 2
1488 Indented quote at column 4
1489 Back to column 2
1490 * Sub-list at column 2";
1491 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1492 let result = rule.check(&ctx).unwrap();
1493 assert!(
1495 result.is_empty(),
1496 "Expected no warnings with varying continuation indent, got: {result:?}"
1497 );
1498 }
1499
1500 #[test]
1501 fn test_edge_case_deep_nesting_no_continuation() {
1502 let rule = MD005ListIndent::default();
1503 let content = "\
1504* Parent
1505 * Immediate child (no continuation text before)
1506 * Grandchild
1507 * Great-grandchild
1508 * Great-great-grandchild
1509 * Another child at level 2";
1510 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1511 let result = rule.check(&ctx).unwrap();
1512 assert!(
1514 result.is_empty(),
1515 "Expected no warnings for deep nesting without continuation, got: {result:?}"
1516 );
1517 }
1518
1519 #[test]
1520 fn test_edge_case_blockquote_continuation_content() {
1521 let rule = MD005ListIndent::default();
1522 let content = "\
1523> * Parent in blockquote
1524> Continuation in blockquote
1525> * Sub-list in blockquote
1526> * Another sub-list";
1527 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1528 let result = rule.check(&ctx).unwrap();
1529 assert!(
1531 result.is_empty(),
1532 "Expected no warnings for blockquote continuation, got: {result:?}"
1533 );
1534 }
1535
1536 #[test]
1537 fn test_edge_case_one_space_less_than_content_column() {
1538 let rule = MD005ListIndent::default();
1539 let content = "\
1540* Parent (content at column 2)
1541 Text at column 1 (one less than content_column - NOT continuation)
1542 * Child";
1543 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1544 let result = rule.check(&ctx).unwrap();
1545 assert!(
1551 result.is_empty() || !result.is_empty(),
1552 "Test should complete without panic"
1553 );
1554 }
1555
1556 #[test]
1557 fn test_edge_case_multiple_code_blocks_different_indentation() {
1558 let rule = MD005ListIndent::default();
1559 let content = "\
1560* Parent
1561 ```
1562 code at 2 spaces
1563 ```
1564 ```
1565 code at 4 spaces
1566 ```
1567 * Sub-list should not be confused";
1568 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1569 let result = rule.check(&ctx).unwrap();
1570 assert!(
1572 result.is_empty(),
1573 "Expected no warnings with multiple code blocks, got: {result:?}"
1574 );
1575 }
1576
1577 #[test]
1578 fn test_performance_very_large_document() {
1579 let rule = MD005ListIndent::default();
1580 let mut content = String::new();
1581
1582 for i in 0..1000 {
1584 content.push_str(&format!("* Item {i}\n"));
1585 content.push_str(&format!(" * Nested {i}\n"));
1586 if i % 10 == 0 {
1587 content.push_str(" Some continuation text\n");
1588 }
1589 }
1590
1591 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1592
1593 let start = std::time::Instant::now();
1595 let result = rule.check(&ctx).unwrap();
1596 let elapsed = start.elapsed();
1597
1598 assert!(result.is_empty());
1599 println!("Processed 1000 list items in {elapsed:?}");
1600 assert!(
1603 elapsed.as_secs() < 1,
1604 "Should complete in under 1 second, took {elapsed:?}"
1605 );
1606 }
1607
1608 #[test]
1609 fn test_ordered_list_variable_marker_width() {
1610 let rule = MD005ListIndent::default();
1615 let content = "\
16161. One
1617 - One
1618 - Two
16192. Two
1620 - One
16213. Three
1622 - One
16234. Four
1624 - One
16255. Five
1626 - One
16276. Six
1628 - One
16297. Seven
1630 - One
16318. Eight
1632 - One
16339. Nine
1634 - One
163510. Ten
1636 - One";
1637 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1638 let result = rule.check(&ctx).unwrap();
1639 assert!(
1640 result.is_empty(),
1641 "Expected no warnings for ordered list with variable marker widths, got: {result:?}"
1642 );
1643 }
1644
1645 #[test]
1646 fn test_ordered_list_inconsistent_siblings() {
1647 let rule = MD005ListIndent::default();
1649 let content = "\
16501. Item one
1651 - First sublist at 3 spaces
1652 - Second sublist at 2 spaces (inconsistent)
1653 - Third sublist at 3 spaces";
1654 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1655 let result = rule.check(&ctx).unwrap();
1656 assert_eq!(
1658 result.len(),
1659 1,
1660 "Expected 1 warning for inconsistent sibling indent, got: {result:?}"
1661 );
1662 assert!(result[0].message.contains("Expected indentation of 3"));
1663 }
1664
1665 #[test]
1666 fn test_ordered_list_single_sublist_no_warning() {
1667 let rule = MD005ListIndent::default();
1670 let content = "\
167110. Item ten
1672 - Only sublist at 3 spaces";
1673 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1674 let result = rule.check(&ctx).unwrap();
1675 assert!(
1677 result.is_empty(),
1678 "Expected no warnings for single sublist item, got: {result:?}"
1679 );
1680 }
1681
1682 #[test]
1683 fn test_sublists_grouped_by_parent_content_column() {
1684 let rule = MD005ListIndent::default();
1688 let content = "\
16899. Item nine
1690 - First sublist at 3 spaces
1691 - Second sublist at 3 spaces
1692 - Third sublist at 3 spaces
169310. Item ten
1694 - First sublist at 4 spaces
1695 - Second sublist at 4 spaces
1696 - Third sublist at 4 spaces";
1697 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1698 let result = rule.check(&ctx).unwrap();
1699 assert!(
1702 result.is_empty(),
1703 "Expected no warnings for sublists grouped by parent, got: {result:?}"
1704 );
1705 }
1706
1707 #[test]
1708 fn test_inconsistent_indent_within_parent_group() {
1709 let rule = MD005ListIndent::default();
1711 let content = "\
171210. Item ten
1713 - First sublist at 4 spaces
1714 - Second sublist at 3 spaces (inconsistent!)
1715 - Third sublist at 4 spaces";
1716 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1717 let result = rule.check(&ctx).unwrap();
1718 assert_eq!(
1720 result.len(),
1721 1,
1722 "Expected 1 warning for inconsistent indent within parent group, got: {result:?}"
1723 );
1724 assert!(result[0].line == 3);
1725 assert!(result[0].message.contains("Expected indentation of 4"));
1726 }
1727
1728 #[test]
1729 fn test_blockquote_nested_list_fix_preserves_blockquote_prefix() {
1730 use crate::rule::Rule;
1734
1735 let rule = MD005ListIndent::default();
1736 let content = "> * Federation sender blacklists are now persisted.";
1737 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1738 let result = rule.check(&ctx).unwrap();
1739
1740 assert_eq!(result.len(), 1, "Expected 1 warning for extra indent");
1741
1742 assert!(result[0].fix.is_some(), "Should have a fix");
1744 let fixed = rule.fix(&ctx).expect("Fix should succeed");
1745
1746 assert!(
1748 fixed.starts_with("> "),
1749 "Fixed content should start with blockquote prefix '> ', got: {fixed:?}"
1750 );
1751 assert!(
1752 !fixed.starts_with("* "),
1753 "Fixed content should NOT start with just '* ' (blockquote removed), got: {fixed:?}"
1754 );
1755 assert_eq!(
1756 fixed.trim(),
1757 "> * Federation sender blacklists are now persisted.",
1758 "Fixed content should be '> * Federation sender...' with single space after >"
1759 );
1760 }
1761
1762 #[test]
1763 fn test_nested_blockquote_list_fix_preserves_prefix() {
1764 use crate::rule::Rule;
1766
1767 let rule = MD005ListIndent::default();
1768 let content = ">> * Nested blockquote list item";
1769 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1770 let result = rule.check(&ctx).unwrap();
1771
1772 if !result.is_empty() {
1773 let fixed = rule.fix(&ctx).expect("Fix should succeed");
1774 assert!(
1776 fixed.contains(">>") || fixed.contains("> >"),
1777 "Fixed content should preserve nested blockquote prefix, got: {fixed:?}"
1778 );
1779 }
1780 }
1781}