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_or(0, |bq| bq.nesting_level);
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_or(actual_indent, |li| li.marker_column);
318
319 for (i, ch) in line_info.content(ctx.content).chars().enumerate() {
321 if i >= marker_column {
322 break;
323 }
324 end_byte += ch.len_utf8();
325 }
326
327 let mut blockquote_count = 0;
329 for ch in line_info.content(ctx.content).chars() {
330 if ch == '>' {
331 blockquote_count += 1;
332 } else if ch != ' ' && ch != '\t' {
333 break;
334 }
335 }
336
337 let blockquote_prefix = if blockquote_count > 1 {
339 (0..blockquote_count)
340 .map(|_| "> ")
341 .collect::<String>()
342 .trim_end()
343 .to_string()
344 } else {
345 ">".to_string()
346 };
347
348 let correct_indent = " ".repeat(expected_indent);
350 let replacement = format!("{blockquote_prefix} {correct_indent}");
351
352 (start_byte..end_byte, replacement)
353 } else {
354 let fix_range = if actual_indent > 0 {
356 let start_byte = ctx.line_offsets.get(line_num - 1).copied().unwrap_or(0);
357 let end_byte = start_byte + actual_indent;
358 start_byte..end_byte
359 } else {
360 let byte_pos = ctx.line_offsets.get(line_num - 1).copied().unwrap_or(0);
361 byte_pos..byte_pos
362 };
363
364 let replacement = if expected_indent > 0 {
365 " ".repeat(expected_indent)
366 } else {
367 String::new()
368 };
369
370 (fix_range, replacement)
371 };
372
373 LintWarning {
374 rule_name: Some(self.name().to_string()),
375 line: start_line,
376 column: start_col,
377 end_line,
378 end_column: end_col,
379 message,
380 severity: Severity::Warning,
381 fix: Some(Fix {
382 range: fix_range,
383 replacement,
384 }),
385 }
386 }
387
388 fn check_indent_consistency(
391 &self,
392 ctx: &crate::lint_context::LintContext,
393 items: &[(usize, usize, &crate::lint_context::LineInfo)],
394 warnings: &mut Vec<LintWarning>,
395 ) {
396 if items.len() < 2 {
397 return;
398 }
399
400 let mut sorted_items: Vec<_> = items.iter().collect();
402 sorted_items.sort_by_key(|(line_num, _, _)| *line_num);
403
404 let indents: std::collections::HashSet<usize> = sorted_items.iter().map(|(_, indent, _)| *indent).collect();
405
406 if indents.len() > 1 {
407 let expected_indent = sorted_items.first().map_or(0, |(_, i, _)| *i);
410
411 for (line_num, indent, line_info) in items {
412 if *indent != expected_indent {
413 warnings.push(self.create_indent_warning(ctx, *line_num, line_info, *indent, expected_indent));
414 }
415 }
416 }
417 }
418
419 fn group_by_parent_content_column<'a>(
426 &self,
427 level: usize,
428 group: &[(usize, usize, &'a crate::lint_context::LineInfo)],
429 all_list_items: &[(
430 usize,
431 usize,
432 &crate::lint_context::LineInfo,
433 &crate::lint_context::ListItemInfo,
434 )],
435 level_map: &HashMap<usize, usize>,
436 ) -> ParentContentGroups<'a> {
437 let parent_level = level - 1;
438
439 let is_ordered_map: HashMap<usize, bool> = all_list_items
441 .iter()
442 .map(|(ln, _, _, item)| (*ln, item.is_ordered))
443 .collect();
444
445 let parent_items: Vec<(usize, usize)> = all_list_items
447 .iter()
448 .filter(|(ln, _, _, _)| level_map.get(ln) == Some(&parent_level))
449 .map(|(ln, _, _, item)| (*ln, item.content_column))
450 .collect();
451
452 let mut parent_content_groups: ParentContentGroups<'a> = HashMap::new();
453
454 for (line_num, indent, line_info) in group {
455 let item_is_ordered = is_ordered_map.get(line_num).copied().unwrap_or(false);
456
457 let idx = parent_items.partition_point(|&(ln, _)| ln < *line_num);
459 let parent_content_col = if idx > 0 { Some(parent_items[idx - 1].1) } else { None };
460
461 if let Some(parent_col) = parent_content_col {
462 parent_content_groups
463 .entry((parent_col, item_is_ordered))
464 .or_default()
465 .push((*line_num, *indent, *line_info));
466 }
467 }
468
469 parent_content_groups
470 }
471
472 fn group_related_list_blocks<'a>(
474 &self,
475 list_blocks: &'a [crate::lint_context::ListBlock],
476 ) -> Vec<Vec<&'a crate::lint_context::ListBlock>> {
477 if list_blocks.is_empty() {
478 return Vec::new();
479 }
480
481 let mut groups = Vec::new();
482 let mut current_group = vec![&list_blocks[0]];
483
484 for i in 1..list_blocks.len() {
485 let prev_block = &list_blocks[i - 1];
486 let current_block = &list_blocks[i];
487
488 let line_gap = current_block.start_line.saturating_sub(prev_block.end_line);
490
491 if line_gap <= Self::LIST_GROUP_GAP_TOLERANCE {
494 current_group.push(current_block);
495 } else {
496 groups.push(current_group);
498 current_group = vec![current_block];
499 }
500 }
501 groups.push(current_group);
502
503 groups
504 }
505
506 fn is_continuation_content(
509 &self,
510 ctx: &crate::lint_context::LintContext,
511 cache: &LineCacheInfo,
512 list_line: usize,
513 list_indent: usize,
514 ) -> bool {
515 let parent_line = cache.parent_map.get(&list_line).copied();
517
518 if let Some(parent_line) = parent_line
519 && let Some(line_info) = ctx.line_info(parent_line)
520 && let Some(parent_list_item) = &line_info.list_item
521 {
522 let parent_marker_column = parent_list_item.marker_column;
523 let parent_content_column = parent_list_item.content_column;
524
525 let parent_bq_level = line_info.blockquote.as_ref().map_or(0, |bq| bq.nesting_level);
527 let parent_bq_prefix_len = line_info.blockquote.as_ref().map_or(0, |bq| bq.prefix.len());
528
529 let continuation_indent = cache.find_continuation_indent(
531 parent_line + 1,
532 list_line - 1,
533 parent_content_column,
534 parent_bq_level,
535 parent_bq_prefix_len,
536 );
537
538 if let Some(continuation_indent) = continuation_indent {
539 let is_standard_continuation =
540 list_indent == parent_content_column + Self::STANDARD_CONTINUATION_OFFSET;
541 let matches_content_indent = list_indent == continuation_indent;
542
543 if matches_content_indent || is_standard_continuation {
544 return true;
545 }
546 }
547
548 if list_indent > parent_marker_column {
551 if self.has_continuation_list_at_indent(
553 ctx,
554 cache,
555 parent_line,
556 list_line,
557 list_indent,
558 parent_content_column,
559 ) {
560 return true;
561 }
562
563 let (parent_bq_level, parent_bq_prefix_len) = cache.blockquote_info(parent_line);
565 if cache.has_continuation_content(
566 parent_line,
567 list_line,
568 parent_content_column,
569 parent_bq_level,
570 parent_bq_prefix_len,
571 ) {
572 return true;
573 }
574 }
575 }
576
577 false
578 }
579
580 fn has_continuation_list_at_indent(
582 &self,
583 ctx: &crate::lint_context::LintContext,
584 cache: &LineCacheInfo,
585 parent_line: usize,
586 current_line: usize,
587 list_indent: usize,
588 parent_content_column: usize,
589 ) -> bool {
590 let (parent_bq_level, parent_bq_prefix_len) = cache.blockquote_info(parent_line);
592
593 for line_num in (parent_line + 1)..current_line {
596 if let Some(line_info) = ctx.line_info(line_num)
597 && let Some(list_item) = &line_info.list_item
598 && list_item.marker_column == list_indent
599 {
600 if cache
602 .find_continuation_indent(
603 parent_line + 1,
604 line_num - 1,
605 parent_content_column,
606 parent_bq_level,
607 parent_bq_prefix_len,
608 )
609 .is_some()
610 {
611 return true;
612 }
613 }
614 }
615 false
616 }
617
618 fn check_list_block_group(
620 &self,
621 ctx: &crate::lint_context::LintContext,
622 cache: &LineCacheInfo,
623 group: &[&crate::lint_context::ListBlock],
624 warnings: &mut Vec<LintWarning>,
625 ) {
626 let mut candidate_items: Vec<(
629 usize,
630 usize,
631 &crate::lint_context::LineInfo,
632 &crate::lint_context::ListItemInfo,
633 )> = Vec::new();
634
635 for list_block in group {
636 for &item_line in &list_block.item_lines {
637 if let Some(line_info) = ctx.line_info(item_line)
638 && let Some(list_item) = line_info.list_item.as_deref()
639 {
640 let effective_indent = if let Some(blockquote) = &line_info.blockquote {
642 list_item.marker_column.saturating_sub(blockquote.nesting_level * 2)
644 } else {
645 list_item.marker_column
647 };
648
649 candidate_items.push((item_line, effective_indent, line_info, list_item));
650 }
651 }
652 }
653
654 candidate_items.sort_by_key(|(line_num, _, _, _)| *line_num);
656
657 let mut skipped_lines: std::collections::HashSet<usize> = std::collections::HashSet::new();
660 let mut all_list_items: Vec<(
661 usize,
662 usize,
663 &crate::lint_context::LineInfo,
664 &crate::lint_context::ListItemInfo,
665 )> = Vec::new();
666
667 for (item_line, effective_indent, line_info, list_item) in candidate_items {
668 if line_info.in_footnote_definition {
670 skipped_lines.insert(item_line);
671 continue;
672 }
673 if self.is_continuation_content(ctx, cache, item_line, effective_indent) {
675 skipped_lines.insert(item_line);
676 continue;
677 }
678
679 if let Some(&parent_line) = cache.parent_map.get(&item_line)
681 && skipped_lines.contains(&parent_line)
682 {
683 skipped_lines.insert(item_line);
684 continue;
685 }
686
687 all_list_items.push((item_line, effective_indent, line_info, list_item));
688 }
689
690 if all_list_items.is_empty() {
691 return;
692 }
693
694 all_list_items.sort_by_key(|(line_num, _, _, _)| *line_num);
696
697 let mut level_map: HashMap<usize, usize> = HashMap::new();
701 let mut level_indents: HashMap<usize, Vec<usize>> = HashMap::new(); let mut indent_to_level: HashMap<usize, (usize, usize)> = HashMap::new();
706
707 for (line_num, indent, _, _) in &all_list_items {
709 let level = if indent_to_level.is_empty() {
710 level_indents.entry(1).or_default().push(*indent);
712 1
713 } else {
714 let mut determined_level = 0;
716
717 if let Some(&(existing_level, _)) = indent_to_level.get(indent) {
719 determined_level = existing_level;
720 } else {
721 let mut best_parent: Option<(usize, usize, usize)> = None; for (&tracked_indent, &(tracked_level, tracked_line)) in &indent_to_level {
727 if tracked_indent < *indent {
728 if best_parent.is_none() || tracked_indent > best_parent.unwrap().0 {
731 best_parent = Some((tracked_indent, tracked_level, tracked_line));
732 }
733 }
734 }
735
736 if let Some((parent_indent, parent_level, _parent_line)) = best_parent {
737 if parent_indent + Self::MIN_CHILD_INDENT_INCREASE <= *indent {
739 determined_level = parent_level + 1;
741 } else if (*indent as i32 - parent_indent as i32).abs() <= Self::SAME_LEVEL_TOLERANCE {
742 determined_level = parent_level;
744 } else {
745 let mut found_similar = false;
749 if let Some(indents_at_level) = level_indents.get(&parent_level) {
750 for &level_indent in indents_at_level {
751 if (level_indent as i32 - *indent as i32).abs() <= Self::SAME_LEVEL_TOLERANCE {
752 determined_level = parent_level;
753 found_similar = true;
754 break;
755 }
756 }
757 }
758 if !found_similar {
759 determined_level = parent_level + 1;
761 }
762 }
763 }
764
765 if determined_level == 0 {
767 determined_level = 1;
768 }
769
770 level_indents.entry(determined_level).or_default().push(*indent);
772 }
773
774 determined_level
775 };
776
777 level_map.insert(*line_num, level);
778 indent_to_level.insert(*indent, (level, *line_num));
780 }
781
782 let mut level_groups: HashMap<usize, Vec<(usize, usize, &crate::lint_context::LineInfo)>> = HashMap::new();
784 for (line_num, indent, line_info, _) in &all_list_items {
785 let level = level_map[line_num];
786 level_groups
787 .entry(level)
788 .or_default()
789 .push((*line_num, *indent, *line_info));
790 }
791
792 for (level, mut group) in level_groups {
794 group.sort_by_key(|(line_num, _, _)| *line_num);
795
796 if level == 1 {
797 for (line_num, indent, line_info) in &group {
799 if *indent != self.top_level_indent {
800 warnings.push(self.create_indent_warning(
801 ctx,
802 *line_num,
803 line_info,
804 *indent,
805 self.top_level_indent,
806 ));
807 }
808 }
809 } else {
810 let parent_content_groups =
813 self.group_by_parent_content_column(level, &group, &all_list_items, &level_map);
814
815 for items in parent_content_groups.values() {
817 self.check_indent_consistency(ctx, items, warnings);
818 }
819 }
820 }
821 }
822
823 fn check_optimized(&self, ctx: &crate::lint_context::LintContext) -> Vec<LintWarning> {
825 let content = ctx.content;
826
827 if content.is_empty() {
829 return Vec::new();
830 }
831
832 if ctx.list_blocks.is_empty() {
834 return Vec::new();
835 }
836
837 let mut warnings = Vec::new();
838
839 let cache = LineCacheInfo::new(ctx);
841
842 let block_groups = self.group_related_list_blocks(&ctx.list_blocks);
845
846 for group in block_groups {
847 self.check_list_block_group(ctx, &cache, &group, &mut warnings);
848 }
849
850 warnings
851 }
852}
853
854impl Rule for MD005ListIndent {
855 fn name(&self) -> &'static str {
856 "MD005"
857 }
858
859 fn description(&self) -> &'static str {
860 "List indentation should be consistent"
861 }
862
863 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
864 Ok(self.check_optimized(ctx))
866 }
867
868 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
869 let warnings = self.check(ctx)?;
870 let warnings =
871 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
872 if warnings.is_empty() {
873 return Ok(ctx.content.to_string());
874 }
875
876 let mut warnings_with_fixes: Vec<_> = warnings
878 .into_iter()
879 .filter_map(|w| w.fix.clone().map(|fix| (w, fix)))
880 .collect();
881 warnings_with_fixes.sort_by_key(|(_, fix)| std::cmp::Reverse(fix.range.start));
882
883 let mut content = ctx.content.to_string();
885 for (_, fix) in warnings_with_fixes {
886 if fix.range.start <= content.len() && fix.range.end <= content.len() {
887 content.replace_range(fix.range, &fix.replacement);
888 }
889 }
890
891 Ok(content)
892 }
893
894 fn category(&self) -> RuleCategory {
895 RuleCategory::List
896 }
897
898 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
900 ctx.content.is_empty() || !ctx.lines.iter().any(|line| line.list_item.is_some())
902 }
903
904 fn as_any(&self) -> &dyn std::any::Any {
905 self
906 }
907
908 fn default_config_section(&self) -> Option<(String, toml::Value)> {
909 None
910 }
911
912 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
913 where
914 Self: Sized,
915 {
916 let mut top_level_indent = 0;
918
919 if let Some(md007_config) = config.rules.get("MD007") {
921 if let Some(start_indented) = md007_config.values.get("start-indented")
923 && let Some(start_indented_bool) = start_indented.as_bool()
924 && start_indented_bool
925 {
926 if let Some(start_indent) = md007_config.values.get("start-indent") {
928 if let Some(indent_value) = start_indent.as_integer() {
929 top_level_indent = indent_value as usize;
930 }
931 } else {
932 top_level_indent = 2;
934 }
935 }
936 }
937
938 Box::new(MD005ListIndent { top_level_indent })
939 }
940}
941
942#[cfg(test)]
943mod tests {
944 use super::*;
945 use crate::lint_context::LintContext;
946
947 #[test]
948 fn test_valid_unordered_list() {
949 let rule = MD005ListIndent::default();
950 let content = "\
951* Item 1
952* Item 2
953 * Nested 1
954 * Nested 2
955* Item 3";
956 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
957 let result = rule.check(&ctx).unwrap();
958 assert!(result.is_empty());
959 }
960
961 #[test]
962 fn test_valid_ordered_list() {
963 let rule = MD005ListIndent::default();
964 let content = "\
9651. Item 1
9662. Item 2
967 1. Nested 1
968 2. Nested 2
9693. Item 3";
970 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
971 let result = rule.check(&ctx).unwrap();
972 assert!(result.is_empty());
975 }
976
977 #[test]
978 fn test_invalid_unordered_indent() {
979 let rule = MD005ListIndent::default();
980 let content = "\
981* Item 1
982 * Item 2
983 * Nested 1";
984 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
985 let result = rule.check(&ctx).unwrap();
986 assert_eq!(result.len(), 1);
989 let fixed = rule.fix(&ctx).unwrap();
990 assert_eq!(fixed, "* Item 1\n* Item 2\n * Nested 1");
991 }
992
993 #[test]
994 fn test_invalid_ordered_indent() {
995 let rule = MD005ListIndent::default();
996 let content = "\
9971. Item 1
998 2. Item 2
999 1. Nested 1";
1000 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1001 let result = rule.check(&ctx).unwrap();
1002 assert_eq!(result.len(), 1);
1003 let fixed = rule.fix(&ctx).unwrap();
1004 assert_eq!(fixed, "1. Item 1\n2. Item 2\n 1. Nested 1");
1008 }
1009
1010 #[test]
1011 fn test_mixed_list_types() {
1012 let rule = MD005ListIndent::default();
1013 let content = "\
1014* Item 1
1015 1. Nested ordered
1016 * Nested unordered
1017* Item 2";
1018 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1019 let result = rule.check(&ctx).unwrap();
1020 assert!(result.is_empty());
1021 }
1022
1023 #[test]
1024 fn test_multiple_levels() {
1025 let rule = MD005ListIndent::default();
1026 let content = "\
1027* Level 1
1028 * Level 2
1029 * Level 3";
1030 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1031 let result = rule.check(&ctx).unwrap();
1032 assert!(result.is_empty(), "MD005 should accept consistent indentation pattern");
1034 }
1035
1036 #[test]
1037 fn test_empty_lines() {
1038 let rule = MD005ListIndent::default();
1039 let content = "\
1040* Item 1
1041
1042 * Nested 1
1043
1044* Item 2";
1045 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1046 let result = rule.check(&ctx).unwrap();
1047 assert!(result.is_empty());
1048 }
1049
1050 #[test]
1051 fn test_no_lists() {
1052 let rule = MD005ListIndent::default();
1053 let content = "\
1054Just some text
1055More text
1056Even more text";
1057 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1058 let result = rule.check(&ctx).unwrap();
1059 assert!(result.is_empty());
1060 }
1061
1062 #[test]
1063 fn test_complex_nesting() {
1064 let rule = MD005ListIndent::default();
1065 let content = "\
1066* Level 1
1067 * Level 2
1068 * Level 3
1069 * Back to 2
1070 1. Ordered 3
1071 2. Still 3
1072* Back to 1";
1073 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1074 let result = rule.check(&ctx).unwrap();
1075 assert!(result.is_empty());
1076 }
1077
1078 #[test]
1079 fn test_invalid_complex_nesting() {
1080 let rule = MD005ListIndent::default();
1081 let content = "\
1082* Level 1
1083 * Level 2
1084 * Level 3
1085 * Back to 2
1086 1. Ordered 3
1087 2. Still 3
1088* Back to 1";
1089 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1090 let result = rule.check(&ctx).unwrap();
1091 assert_eq!(result.len(), 1);
1093 assert!(
1094 result[0].message.contains("Expected indentation of 5 spaces, found 6")
1095 || result[0].message.contains("Expected indentation of 6 spaces, found 5")
1096 );
1097 }
1098
1099 #[test]
1100 fn test_with_lint_context() {
1101 let rule = MD005ListIndent::default();
1102
1103 let content = "* Item 1\n* Item 2\n * Nested item\n * Another nested item";
1105 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1106 let result = rule.check(&ctx).unwrap();
1107 assert!(result.is_empty());
1108
1109 let content = "* Item 1\n* Item 2\n * Nested item\n * Another nested item";
1111 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1112 let result = rule.check(&ctx).unwrap();
1113 assert!(!result.is_empty()); let content = "* Item 1\n * Nested item\n * Another nested item with wrong indent";
1117 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1118 let result = rule.check(&ctx).unwrap();
1119 assert!(!result.is_empty()); }
1121
1122 #[test]
1124 fn test_list_with_continuations() {
1125 let rule = MD005ListIndent::default();
1126 let content = "\
1127* Item 1
1128 This is a continuation
1129 of the first item
1130 * Nested item
1131 with its own continuation
1132* Item 2";
1133 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1134 let result = rule.check(&ctx).unwrap();
1135 assert!(result.is_empty());
1136 }
1137
1138 #[test]
1139 fn test_list_in_blockquote() {
1140 let rule = MD005ListIndent::default();
1141 let content = "\
1142> * Item 1
1143> * Nested 1
1144> * Nested 2
1145> * Item 2";
1146 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1147 let result = rule.check(&ctx).unwrap();
1148
1149 assert!(
1151 result.is_empty(),
1152 "Expected no warnings for correctly indented blockquote list, got: {result:?}"
1153 );
1154 }
1155
1156 #[test]
1157 fn test_list_with_code_blocks() {
1158 let rule = MD005ListIndent::default();
1159 let content = "\
1160* Item 1
1161 ```
1162 code block
1163 ```
1164 * Nested item
1165* Item 2";
1166 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1167 let result = rule.check(&ctx).unwrap();
1168 assert!(result.is_empty());
1169 }
1170
1171 #[test]
1172 fn test_list_with_tabs() {
1173 let rule = MD005ListIndent::default();
1174 let content = "* Item 1\n * Wrong indent (3 spaces)\n * Correct indent (2 spaces)";
1178 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1179 let result = rule.check(&ctx).unwrap();
1180 assert!(!result.is_empty());
1182 }
1183
1184 #[test]
1185 fn test_inconsistent_at_same_level() {
1186 let rule = MD005ListIndent::default();
1187 let content = "\
1188* Item 1
1189 * Nested 1
1190 * Nested 2
1191 * Wrong indent for same level
1192 * Nested 3";
1193 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1194 let result = rule.check(&ctx).unwrap();
1195 assert!(!result.is_empty());
1196 assert!(result.iter().any(|w| w.line == 4));
1198 }
1199
1200 #[test]
1201 fn test_zero_indent_top_level() {
1202 let rule = MD005ListIndent::default();
1203 let content = concat!(" * Wrong indent\n", "* Correct\n", " * Nested");
1205 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1206 let result = rule.check(&ctx).unwrap();
1207
1208 assert!(!result.is_empty());
1210 assert!(result.iter().any(|w| w.line == 1));
1211 }
1212
1213 #[test]
1214 fn test_fix_preserves_content() {
1215 let rule = MD005ListIndent::default();
1216 let content = "\
1217* Item with **bold** and *italic*
1218 * Wrong indent with `code`
1219 * Also wrong with [link](url)";
1220 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1221 let fixed = rule.fix(&ctx).unwrap();
1222 assert!(fixed.contains("**bold**"));
1223 assert!(fixed.contains("*italic*"));
1224 assert!(fixed.contains("`code`"));
1225 assert!(fixed.contains("[link](url)"));
1226 }
1227
1228 #[test]
1229 fn test_deeply_nested_lists() {
1230 let rule = MD005ListIndent::default();
1231 let content = "\
1232* L1
1233 * L2
1234 * L3
1235 * L4
1236 * L5
1237 * L6";
1238 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1239 let result = rule.check(&ctx).unwrap();
1240 assert!(result.is_empty());
1241 }
1242
1243 #[test]
1244 fn test_fix_multiple_issues() {
1245 let rule = MD005ListIndent::default();
1246 let content = "\
1247* Item 1
1248 * Wrong 1
1249 * Wrong 2
1250 * Wrong 3
1251 * Correct
1252 * Wrong 4";
1253 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1254 let fixed = rule.fix(&ctx).unwrap();
1255 let lines: Vec<&str> = fixed.lines().collect();
1257 assert_eq!(lines[0], "* Item 1");
1258 assert!(lines[1].starts_with(" * ") || lines[1].starts_with("* "));
1260 }
1261
1262 #[test]
1263 fn test_performance_large_document() {
1264 let rule = MD005ListIndent::default();
1265 let mut content = String::new();
1266 for i in 0..100 {
1267 content.push_str(&format!("* Item {i}\n"));
1268 content.push_str(&format!(" * Nested {i}\n"));
1269 }
1270 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1271 let result = rule.check(&ctx).unwrap();
1272 assert!(result.is_empty());
1273 }
1274
1275 #[test]
1276 fn test_column_positions() {
1277 let rule = MD005ListIndent::default();
1278 let content = " * Wrong indent";
1279 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1280 let result = rule.check(&ctx).unwrap();
1281 assert_eq!(result.len(), 1);
1282 assert_eq!(result[0].column, 1, "Expected column 1, got {}", result[0].column);
1283 assert_eq!(
1284 result[0].end_column, 2,
1285 "Expected end_column 2, got {}",
1286 result[0].end_column
1287 );
1288 }
1289
1290 #[test]
1291 fn test_should_skip() {
1292 let rule = MD005ListIndent::default();
1293
1294 let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard, None);
1296 assert!(rule.should_skip(&ctx));
1297
1298 let ctx = LintContext::new("Just plain text", crate::config::MarkdownFlavor::Standard, None);
1300 assert!(rule.should_skip(&ctx));
1301
1302 let ctx = LintContext::new("* List item", crate::config::MarkdownFlavor::Standard, None);
1304 assert!(!rule.should_skip(&ctx));
1305
1306 let ctx = LintContext::new("1. Ordered list", crate::config::MarkdownFlavor::Standard, None);
1307 assert!(!rule.should_skip(&ctx));
1308 }
1309
1310 #[test]
1311 fn test_should_skip_validation() {
1312 let rule = MD005ListIndent::default();
1313 let content = "* List item";
1314 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1315 assert!(!rule.should_skip(&ctx));
1316
1317 let content = "No lists here";
1318 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1319 assert!(rule.should_skip(&ctx));
1320 }
1321
1322 #[test]
1323 fn test_edge_case_single_space_indent() {
1324 let rule = MD005ListIndent::default();
1325 let content = "\
1326* Item 1
1327 * Single space - wrong
1328 * Two spaces - correct";
1329 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1330 let result = rule.check(&ctx).unwrap();
1331 assert_eq!(result.len(), 2);
1334 assert!(result.iter().any(|w| w.line == 2 && w.message.contains("found 1")));
1335 }
1336
1337 #[test]
1338 fn test_edge_case_three_space_indent() {
1339 let rule = MD005ListIndent::default();
1340 let content = "\
1341* Item 1
1342 * Three spaces - first establishes pattern
1343 * Two spaces - inconsistent with established pattern";
1344 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1345 let result = rule.check(&ctx).unwrap();
1346 assert_eq!(result.len(), 1);
1350 assert!(result.iter().any(|w| w.line == 3 && w.message.contains("found 2")));
1351 }
1352
1353 #[test]
1354 fn test_nested_bullets_under_numbered_items() {
1355 let rule = MD005ListIndent::default();
1356 let content = "\
13571. **Active Directory/LDAP**
1358 - User authentication and directory services
1359 - LDAP for user information and validation
1360
13612. **Oracle Unified Directory (OUD)**
1362 - Extended user directory services
1363 - Verification of project account presence and changes";
1364 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1365 let result = rule.check(&ctx).unwrap();
1366 assert!(
1368 result.is_empty(),
1369 "Expected no warnings for bullets with 3 spaces under numbered items, got: {result:?}"
1370 );
1371 }
1372
1373 #[test]
1374 fn test_nested_bullets_under_numbered_items_wrong_indent() {
1375 let rule = MD005ListIndent::default();
1376 let content = "\
13771. **Active Directory/LDAP**
1378 - Wrong: only 2 spaces
1379 - Correct: 3 spaces";
1380 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1381 let result = rule.check(&ctx).unwrap();
1382 assert_eq!(
1384 result.len(),
1385 1,
1386 "Expected 1 warning, got {}. Warnings: {:?}",
1387 result.len(),
1388 result
1389 );
1390 assert!(
1392 result
1393 .iter()
1394 .any(|w| (w.line == 2 && w.message.contains("found 2"))
1395 || (w.line == 3 && w.message.contains("found 3")))
1396 );
1397 }
1398
1399 #[test]
1400 fn test_regular_nested_bullets_still_work() {
1401 let rule = MD005ListIndent::default();
1402 let content = "\
1403* Top level
1404 * Second level (2 spaces is correct for bullets under bullets)
1405 * Third level (4 spaces)";
1406 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1407 let result = rule.check(&ctx).unwrap();
1408 assert!(
1410 result.is_empty(),
1411 "Expected no warnings for regular bullet nesting, got: {result:?}"
1412 );
1413 }
1414
1415 #[test]
1416 fn test_fix_range_accuracy() {
1417 let rule = MD005ListIndent::default();
1418 let content = " * Wrong indent";
1419 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1420 let result = rule.check(&ctx).unwrap();
1421 assert_eq!(result.len(), 1);
1422
1423 let fix = result[0].fix.as_ref().unwrap();
1424 assert_eq!(fix.replacement, "");
1426 }
1427
1428 #[test]
1429 fn test_four_space_indent_pattern() {
1430 let rule = MD005ListIndent::default();
1431 let content = "\
1432* Item 1
1433 * Item 2 with 4 spaces
1434 * Item 3 with 8 spaces
1435 * Item 4 with 4 spaces";
1436 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1437 let result = rule.check(&ctx).unwrap();
1438 assert!(
1440 result.is_empty(),
1441 "MD005 should accept consistent 4-space indentation pattern, got {} warnings",
1442 result.len()
1443 );
1444 }
1445
1446 #[test]
1447 fn test_issue_64_scenario() {
1448 let rule = MD005ListIndent::default();
1450 let content = "\
1451* Top level item
1452 * Sub item with 4 spaces (as configured in MD007)
1453 * Nested sub item with 8 spaces
1454 * Another sub item with 4 spaces
1455* Another top level";
1456
1457 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1458 let result = rule.check(&ctx).unwrap();
1459
1460 assert!(
1462 result.is_empty(),
1463 "MD005 should accept 4-space indentation when that's the pattern being used. Got {} warnings",
1464 result.len()
1465 );
1466 }
1467
1468 #[test]
1469 fn test_continuation_content_scenario() {
1470 let rule = MD005ListIndent::default();
1471 let content = "\
1472- **Changes to how the Python version is inferred** ([#16319](example))
1473
1474 In previous versions of Ruff, you could specify your Python version with:
1475
1476 - The `target-version` option in a `ruff.toml` file
1477 - The `project.requires-python` field in a `pyproject.toml` file";
1478
1479 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1480
1481 let result = rule.check(&ctx).unwrap();
1482
1483 assert!(
1485 result.is_empty(),
1486 "MD005 should not flag continuation content lists, got {} warnings: {:?}",
1487 result.len(),
1488 result
1489 );
1490 }
1491
1492 #[test]
1493 fn test_multiple_continuation_lists_scenario() {
1494 let rule = MD005ListIndent::default();
1495 let content = "\
1496- **Changes to how the Python version is inferred** ([#16319](example))
1497
1498 In previous versions of Ruff, you could specify your Python version with:
1499
1500 - The `target-version` option in a `ruff.toml` file
1501 - The `project.requires-python` field in a `pyproject.toml` file
1502
1503 In v0.10, config discovery has been updated to address this issue:
1504
1505 - If Ruff finds a `ruff.toml` file without a `target-version`, it will check
1506 - If Ruff finds a user-level configuration, the `requires-python` field will take precedence
1507 - If there is no config file, Ruff will search for the closest `pyproject.toml`";
1508
1509 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1510
1511 let result = rule.check(&ctx).unwrap();
1512
1513 assert!(
1515 result.is_empty(),
1516 "MD005 should not flag continuation content lists, got {} warnings: {:?}",
1517 result.len(),
1518 result
1519 );
1520 }
1521
1522 #[test]
1523 fn test_issue_115_sublist_after_code_block() {
1524 let rule = MD005ListIndent::default();
1525 let content = "\
15261. List item 1
1527
1528 ```rust
1529 fn foo() {}
1530 ```
1531
1532 Sublist:
1533
1534 - A
1535 - B
1536";
1537 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1538 let result = rule.check(&ctx).unwrap();
1539 assert!(
1543 result.is_empty(),
1544 "Expected no warnings for sub-list after code block in list item, got {} warnings: {:?}",
1545 result.len(),
1546 result
1547 );
1548 }
1549
1550 #[test]
1551 fn test_edge_case_continuation_at_exact_boundary() {
1552 let rule = MD005ListIndent::default();
1553 let content = "\
1555* Item (content at column 2)
1556 Text at column 2 (exact boundary - continuation)
1557 * Sub at column 2";
1558 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1559 let result = rule.check(&ctx).unwrap();
1560 assert!(
1562 result.is_empty(),
1563 "Expected no warnings when text and sub-list are at exact parent content_column, got: {result:?}"
1564 );
1565 }
1566
1567 #[test]
1568 fn test_edge_case_unicode_in_continuation() {
1569 let rule = MD005ListIndent::default();
1570 let content = "\
1571* Parent
1572 Text with emoji 😀 and Unicode ñ characters
1573 * Sub-list should still work";
1574 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1575 let result = rule.check(&ctx).unwrap();
1576 assert!(
1578 result.is_empty(),
1579 "Expected no warnings with Unicode in continuation content, got: {result:?}"
1580 );
1581 }
1582
1583 #[test]
1584 fn test_edge_case_large_empty_line_gap() {
1585 let rule = MD005ListIndent::default();
1586 let content = "\
1587* Parent at line 1
1588 Continuation text
1589
1590
1591
1592 More continuation after many empty lines
1593
1594 * Child after gap
1595 * Another child";
1596 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1597 let result = rule.check(&ctx).unwrap();
1598 assert!(
1600 result.is_empty(),
1601 "Expected no warnings with large gaps in continuation content, got: {result:?}"
1602 );
1603 }
1604
1605 #[test]
1606 fn test_edge_case_multiple_continuation_blocks_varying_indent() {
1607 let rule = MD005ListIndent::default();
1608 let content = "\
1609* Parent (content at column 2)
1610 First paragraph at column 2
1611 Indented quote at column 4
1612 Back to column 2
1613 * Sub-list at column 2";
1614 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1615 let result = rule.check(&ctx).unwrap();
1616 assert!(
1618 result.is_empty(),
1619 "Expected no warnings with varying continuation indent, got: {result:?}"
1620 );
1621 }
1622
1623 #[test]
1624 fn test_edge_case_deep_nesting_no_continuation() {
1625 let rule = MD005ListIndent::default();
1626 let content = "\
1627* Parent
1628 * Immediate child (no continuation text before)
1629 * Grandchild
1630 * Great-grandchild
1631 * Great-great-grandchild
1632 * Another child at level 2";
1633 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1634 let result = rule.check(&ctx).unwrap();
1635 assert!(
1637 result.is_empty(),
1638 "Expected no warnings for deep nesting without continuation, got: {result:?}"
1639 );
1640 }
1641
1642 #[test]
1643 fn test_edge_case_blockquote_continuation_content() {
1644 let rule = MD005ListIndent::default();
1645 let content = "\
1646> * Parent in blockquote
1647> Continuation in blockquote
1648> * Sub-list in blockquote
1649> * Another sub-list";
1650 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1651 let result = rule.check(&ctx).unwrap();
1652 assert!(
1654 result.is_empty(),
1655 "Expected no warnings for blockquote continuation, got: {result:?}"
1656 );
1657 }
1658
1659 #[test]
1660 fn test_edge_case_one_space_less_than_content_column() {
1661 let rule = MD005ListIndent::default();
1662 let content = "\
1663* Parent (content at column 2)
1664 Text at column 1 (one less than content_column - NOT continuation)
1665 * Child";
1666 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1667 let result = rule.check(&ctx).unwrap();
1668 assert!(
1674 result.is_empty() || !result.is_empty(),
1675 "Test should complete without panic"
1676 );
1677 }
1678
1679 #[test]
1680 fn test_edge_case_multiple_code_blocks_different_indentation() {
1681 let rule = MD005ListIndent::default();
1682 let content = "\
1683* Parent
1684 ```
1685 code at 2 spaces
1686 ```
1687 ```
1688 code at 4 spaces
1689 ```
1690 * Sub-list should not be confused";
1691 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1692 let result = rule.check(&ctx).unwrap();
1693 assert!(
1695 result.is_empty(),
1696 "Expected no warnings with multiple code blocks, got: {result:?}"
1697 );
1698 }
1699
1700 #[test]
1701 fn test_performance_very_large_document() {
1702 let rule = MD005ListIndent::default();
1703 let mut content = String::new();
1704
1705 for i in 0..1000 {
1707 content.push_str(&format!("* Item {i}\n"));
1708 content.push_str(&format!(" * Nested {i}\n"));
1709 if i % 10 == 0 {
1710 content.push_str(" Some continuation text\n");
1711 }
1712 }
1713
1714 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1715
1716 let start = std::time::Instant::now();
1718 let result = rule.check(&ctx).unwrap();
1719 let elapsed = start.elapsed();
1720
1721 assert!(result.is_empty());
1722 println!("Processed 1000 list items in {elapsed:?}");
1723 assert!(
1726 elapsed.as_secs() < 1,
1727 "Should complete in under 1 second, took {elapsed:?}"
1728 );
1729 }
1730
1731 #[test]
1732 fn test_ordered_list_variable_marker_width() {
1733 let rule = MD005ListIndent::default();
1738 let content = "\
17391. One
1740 - One
1741 - Two
17422. Two
1743 - One
17443. Three
1745 - One
17464. Four
1747 - One
17485. Five
1749 - One
17506. Six
1751 - One
17527. Seven
1753 - One
17548. Eight
1755 - One
17569. Nine
1757 - One
175810. Ten
1759 - One";
1760 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1761 let result = rule.check(&ctx).unwrap();
1762 assert!(
1763 result.is_empty(),
1764 "Expected no warnings for ordered list with variable marker widths, got: {result:?}"
1765 );
1766 }
1767
1768 #[test]
1769 fn test_ordered_list_inconsistent_siblings() {
1770 let rule = MD005ListIndent::default();
1772 let content = "\
17731. Item one
1774 - First sublist at 3 spaces
1775 - Second sublist at 2 spaces (inconsistent)
1776 - Third sublist at 3 spaces";
1777 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1778 let result = rule.check(&ctx).unwrap();
1779 assert_eq!(
1781 result.len(),
1782 1,
1783 "Expected 1 warning for inconsistent sibling indent, got: {result:?}"
1784 );
1785 assert!(result[0].message.contains("Expected indentation of 3"));
1786 }
1787
1788 #[test]
1789 fn test_ordered_list_single_sublist_no_warning() {
1790 let rule = MD005ListIndent::default();
1793 let content = "\
179410. Item ten
1795 - Only sublist at 3 spaces";
1796 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1797 let result = rule.check(&ctx).unwrap();
1798 assert!(
1800 result.is_empty(),
1801 "Expected no warnings for single sublist item, got: {result:?}"
1802 );
1803 }
1804
1805 #[test]
1806 fn test_sublists_grouped_by_parent_content_column() {
1807 let rule = MD005ListIndent::default();
1811 let content = "\
18129. Item nine
1813 - First sublist at 3 spaces
1814 - Second sublist at 3 spaces
1815 - Third sublist at 3 spaces
181610. Item ten
1817 - First sublist at 4 spaces
1818 - Second sublist at 4 spaces
1819 - Third sublist at 4 spaces";
1820 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1821 let result = rule.check(&ctx).unwrap();
1822 assert!(
1825 result.is_empty(),
1826 "Expected no warnings for sublists grouped by parent, got: {result:?}"
1827 );
1828 }
1829
1830 #[test]
1831 fn test_inconsistent_indent_within_parent_group() {
1832 let rule = MD005ListIndent::default();
1834 let content = "\
183510. Item ten
1836 - First sublist at 4 spaces
1837 - Second sublist at 3 spaces (inconsistent!)
1838 - Third sublist at 4 spaces";
1839 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1840 let result = rule.check(&ctx).unwrap();
1841 assert_eq!(
1843 result.len(),
1844 1,
1845 "Expected 1 warning for inconsistent indent within parent group, got: {result:?}"
1846 );
1847 assert!(result[0].line == 3);
1848 assert!(result[0].message.contains("Expected indentation of 4"));
1849 }
1850
1851 #[test]
1852 fn test_blockquote_nested_list_fix_preserves_blockquote_prefix() {
1853 use crate::rule::Rule;
1857
1858 let rule = MD005ListIndent::default();
1859 let content = "> * Federation sender blacklists are now persisted.";
1860 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1861 let result = rule.check(&ctx).unwrap();
1862
1863 assert_eq!(result.len(), 1, "Expected 1 warning for extra indent");
1864
1865 assert!(result[0].fix.is_some(), "Should have a fix");
1867 let fixed = rule.fix(&ctx).expect("Fix should succeed");
1868
1869 assert!(
1871 fixed.starts_with("> "),
1872 "Fixed content should start with blockquote prefix '> ', got: {fixed:?}"
1873 );
1874 assert!(
1875 !fixed.starts_with("* "),
1876 "Fixed content should NOT start with just '* ' (blockquote removed), got: {fixed:?}"
1877 );
1878 assert_eq!(
1879 fixed.trim(),
1880 "> * Federation sender blacklists are now persisted.",
1881 "Fixed content should be '> * Federation sender...' with single space after >"
1882 );
1883 }
1884
1885 #[test]
1886 fn test_nested_blockquote_list_fix_preserves_prefix() {
1887 use crate::rule::Rule;
1889
1890 let rule = MD005ListIndent::default();
1891 let content = ">> * Nested blockquote list item";
1892 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1893 let result = rule.check(&ctx).unwrap();
1894
1895 if !result.is_empty() {
1896 let fixed = rule.fix(&ctx).expect("Fix should succeed");
1897 assert!(
1899 fixed.contains(">>") || fixed.contains("> >"),
1900 "Fixed content should preserve nested blockquote prefix, got: {fixed:?}"
1901 );
1902 }
1903 }
1904}