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