Skip to main content

rumdl_lib/rules/
md005_list_indent.rs

1//!
2//! Rule MD005: Inconsistent indentation for list items at the same level
3//!
4//! See [docs/md005.md](../../docs/md005.md) for full documentation, configuration, and examples.
5
6use crate::utils::blockquote::effective_indent_in_blockquote;
7use crate::utils::list_fix_guard::{
8    Allowed, drop_structure_changing_fixes, is_inside_literal_code, protect_literal_code,
9};
10use crate::utils::list_indent_shift::{Nesting, move_owned_lines};
11use crate::utils::range_utils::calculate_match_range;
12
13use crate::lint_context::{ParsedListBlock, ParsedListBlocks, ParsedListItem};
14use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
15// No regex patterns needed for this rule
16use std::cell::RefCell;
17use std::collections::HashMap;
18use toml;
19
20/// Type alias for parent content column groups, keyed by (parent_col, is_ordered).
21/// Used by `group_by_parent_content_column` to separate ordered and unordered items.
22type ParentContentGroups<'a> = HashMap<(usize, bool), Vec<(usize, usize, &'a crate::lint_context::LineInfo)>>;
23
24/// Rule MD005: Inconsistent indentation for list items at the same level
25#[derive(Clone, Default)]
26pub struct MD005ListIndent {
27    /// Expected indentation for top-level lists (from MD007 config)
28    top_level_indent: usize,
29}
30
31/// Cache for fast line information lookups to avoid O(n²) scanning
32struct LineCacheInfo<'a> {
33    /// Indentation level for each line (0 for empty lines)
34    indentation: Vec<usize>,
35    /// Blockquote nesting level for each line (0 for non-blockquote lines)
36    blockquote_levels: Vec<usize>,
37    /// Line content references for blockquote-aware indent calculation
38    line_contents: &'a [&'a str],
39    /// Bit flags: bit 0 = has_content, bit 1 = is_list_item, bit 2 = is_continuation_content
40    flags: Vec<u8>,
41    /// Parent list item line number for each list item (1-indexed, 0 = no parent)
42    /// Pre-computed in O(n) to avoid O(n²) backward scanning
43    parent_map: HashMap<usize, usize>,
44    /// For each list item (1-indexed), the first later line holding a list item
45    /// at or left of its marker column, which ends the item; absent when nothing
46    /// does before the end of the document. Every question asked about a parent's
47    /// continuation content concerns lines before this one.
48    item_end: HashMap<usize, usize>,
49    /// Memoized [`Self::continuation_decided`] results, keyed by the parent line
50    /// and the thresholds and blockquote context the scan used.
51    decided: RefCell<HashMap<ScanKey, Option<Decision>>>,
52    /// Memoized [`Self::first_item_at_column`] results, keyed by
53    /// `(parent_line, from_line, marker_column)`.
54    first_item_at: RefCell<HashMap<(usize, usize, usize), Option<usize>>>,
55}
56
57/// `(parent_line, tight_threshold, loose_threshold, parent_bq_level, parent_bq_prefix_len)`
58type ScanKey = (usize, usize, usize, usize, usize);
59
60/// The line (1-indexed) that settles whether a parent item has continuation
61/// content, and the answer: the continuation line's indent, or `None` when that
62/// line ends the item instead.
63type Decision = (usize, Option<usize>);
64
65const FLAG_HAS_CONTENT: u8 = 1;
66const FLAG_IS_LIST_ITEM: u8 = 2;
67
68impl<'a> LineCacheInfo<'a> {
69    /// Build cache from context in one O(n) pass
70    fn new(ctx: &'a crate::lint_context::LintContext<'_>) -> Self {
71        let total_lines = ctx.lines.len();
72        let mut indentation = Vec::with_capacity(total_lines);
73        let mut blockquote_levels = Vec::with_capacity(total_lines);
74        let line_contents = ctx.raw_lines();
75        let mut flags = Vec::with_capacity(total_lines);
76        let mut parent_map = HashMap::new();
77        let mut item_end = HashMap::new();
78
79        // Track most recent list item at each indentation level for O(1) parent lookups
80        // Key: marker_column, Value: line_num (1-indexed)
81        //
82        // Algorithm correctness invariant:
83        // For each list item L at line N with marker_column M:
84        //   parent_map[N] = the line number of the most recent list item P where:
85        //     1. P.line < N (appears before L)
86        //     2. P.marker_column < M (less indented than L)
87        //     3. P.marker_column is maximal among all candidates (closest parent)
88        //
89        // This matches the original O(n) backward scan logic but pre-computes in O(n).
90        let mut indent_stack: Vec<(usize, usize)> = Vec::new();
91
92        for (idx, line_info) in ctx.lines.iter().enumerate() {
93            let line_content = line_info.content(ctx.content);
94            let content = line_content.trim_start();
95            let line_indent = line_info.byte_len - content.len();
96
97            indentation.push(line_indent);
98
99            // Store blockquote level for blockquote-aware indent calculation
100            let bq_level = line_info.blockquote.as_ref().map_or(0, |bq| bq.nesting_level);
101            blockquote_levels.push(bq_level);
102
103            let mut flag = 0u8;
104            if !content.is_empty() {
105                flag |= FLAG_HAS_CONTENT;
106            }
107            if let Some(list_item) = ctx
108                .list_item_on_line(idx + 1)
109                .filter(|item| !is_inside_literal_code(ctx, item.marker_byte_offset()))
110            {
111                flag |= FLAG_IS_LIST_ITEM;
112
113                let line_num = idx + 1; // Convert to 1-indexed
114                let marker_column = list_item.marker_column();
115
116                // Maintain a monotonic stack of indentation levels (O(1) amortized)
117                while let Some(&(indent, popped_line)) = indent_stack.last() {
118                    if indent < marker_column {
119                        break;
120                    }
121                    item_end.insert(popped_line, line_num);
122                    indent_stack.pop();
123                }
124
125                if let Some((_, parent_line)) = indent_stack.last() {
126                    parent_map.insert(line_num, *parent_line);
127                }
128
129                indent_stack.push((marker_column, line_num));
130            }
131            flags.push(flag);
132        }
133
134        Self {
135            indentation,
136            blockquote_levels,
137            line_contents,
138            flags,
139            parent_map,
140            item_end,
141            decided: RefCell::default(),
142            first_item_at: RefCell::default(),
143        }
144    }
145
146    /// Check if line has content
147    fn has_content(&self, idx: usize) -> bool {
148        self.flags.get(idx).is_some_and(|&f| f & FLAG_HAS_CONTENT != 0)
149    }
150
151    /// Check if line is a list item
152    fn is_list_item(&self, idx: usize) -> bool {
153        self.flags.get(idx).is_some_and(|&f| f & FLAG_IS_LIST_ITEM != 0)
154    }
155
156    /// Get blockquote info for a line (level and prefix length)
157    fn blockquote_info(&self, line: usize) -> (usize, usize) {
158        if line == 0 || line > self.line_contents.len() {
159            return (0, 0);
160        }
161        let idx = line - 1;
162        let bq_level = self.blockquote_levels.get(idx).copied().unwrap_or(0);
163        if bq_level == 0 {
164            return (0, 0);
165        }
166        // Calculate prefix length from line content
167        let content = &self.line_contents[idx];
168        let mut prefix_len = 0;
169        let mut found = 0;
170        for c in content.chars() {
171            prefix_len += c.len_utf8();
172            if c == '>' {
173                found += 1;
174                if found == bq_level {
175                    // Include optional space after last >
176                    if content.get(prefix_len..prefix_len + 1) == Some(" ") {
177                        prefix_len += 1;
178                    }
179                    break;
180                }
181            }
182        }
183        (bq_level, prefix_len)
184    }
185
186    /// The first line in `start_line..=end_line` (1-indexed) that settles
187    /// whether a parent item has continuation content there, with the answer.
188    ///
189    /// For blockquote-aware detection, also pass the parent's blockquote level and
190    /// blockquote prefix length. These are used to calculate effective indentation
191    /// for lines inside blockquotes.
192    ///
193    /// Per CommonMark, tight (lazy) continuation — content with no blank line
194    /// preceding it inside the item — is valid at any indent strictly past the
195    /// marker column. Loose continuation — content following a blank line — must
196    /// be indented to the parent's content column to remain inside the item.
197    /// Callers therefore pass two thresholds: `tight_threshold` for content
198    /// before the first blank line in the range, and `loose_threshold` for
199    /// content after a blank line. Once a blank line is followed by content
200    /// below `loose_threshold` the list item has ended, so no later content in
201    /// the range can be continuation.
202    fn scan_continuation(
203        &self,
204        start_line: usize,
205        end_line: usize,
206        tight_threshold: usize,
207        loose_threshold: usize,
208        parent_bq_level: usize,
209        parent_bq_prefix_len: usize,
210    ) -> Option<Decision> {
211        if start_line == 0 || start_line > end_line || end_line > self.indentation.len() {
212            return None;
213        }
214
215        // For blockquote lists, subtract the blockquote prefix so we compare
216        // against the effective indentation within the blockquote context.
217        let adjust = |t: usize| {
218            if parent_bq_level > 0 {
219                t.saturating_sub(parent_bq_prefix_len)
220            } else {
221                t
222            }
223        };
224        let tight = adjust(tight_threshold);
225        let loose = adjust(loose_threshold);
226
227        let mut seen_blank = false;
228        for idx in start_line - 1..end_line {
229            if !self.has_content(idx) {
230                seen_blank = true;
231                continue;
232            }
233            if self.is_list_item(idx) {
234                continue;
235            }
236
237            // Calculate effective indent (blockquote-aware)
238            let line_bq_level = self.blockquote_levels.get(idx).copied().unwrap_or(0);
239            let raw_indent = self.indentation[idx];
240            let effective_indent = if line_bq_level == parent_bq_level && parent_bq_level > 0 {
241                effective_indent_in_blockquote(self.line_contents[idx], parent_bq_level, raw_indent)
242            } else {
243                raw_indent
244            };
245
246            let threshold = if seen_blank { loose } else { tight };
247            if effective_indent >= threshold {
248                return Some((idx + 1, Some(effective_indent)));
249            }
250            // After a blank line, content below the loose threshold ends the
251            // list item; nothing further in the range can be continuation.
252            if seen_blank {
253                return Some((idx + 1, None));
254            }
255        }
256        None
257    }
258
259    /// [`Self::scan_continuation`] over everything `parent_line` can own,
260    /// computed once per parent: each of its children asks about a prefix of the
261    /// same range, and the first decisive line answers all of them.
262    fn continuation_decided(
263        &self,
264        parent_line: usize,
265        (tight_threshold, loose_threshold): (usize, usize),
266        (parent_bq_level, parent_bq_prefix_len): (usize, usize),
267    ) -> Option<Decision> {
268        let key = (
269            parent_line,
270            tight_threshold,
271            loose_threshold,
272            parent_bq_level,
273            parent_bq_prefix_len,
274        );
275        if let Some(&decision) = self.decided.borrow().get(&key) {
276            return decision;
277        }
278        let last_owned = self
279            .item_end
280            .get(&parent_line)
281            .map_or(self.indentation.len(), |&end| end - 1);
282        let decision = self.scan_continuation(
283            parent_line + 1,
284            last_owned,
285            tight_threshold,
286            loose_threshold,
287            parent_bq_level,
288            parent_bq_prefix_len,
289        );
290        self.decided.borrow_mut().insert(key, decision);
291        decision
292    }
293
294    /// The indent of the first continuation line between `parent_line` and
295    /// `before_line` (both exclusive, 1-indexed), if the parent has one there.
296    ///
297    /// `before_line` must be a line the parent owns, such as one of its children.
298    fn continuation_before(
299        &self,
300        parent_line: usize,
301        before_line: usize,
302        thresholds: (usize, usize),
303        parent_bq: (usize, usize),
304    ) -> Option<usize> {
305        match self.continuation_decided(parent_line, thresholds, parent_bq) {
306            Some((decided_at, indent)) if decided_at < before_line => indent,
307            _ => None,
308        }
309    }
310
311    /// The first list item at `marker_column` from `from_line` on (1-indexed)
312    /// within what `parent_line` owns.
313    fn first_item_at_column(
314        &self,
315        ctx: &crate::lint_context::LintContext,
316        parent_line: usize,
317        from_line: usize,
318        marker_column: usize,
319    ) -> Option<usize> {
320        let key = (parent_line, from_line, marker_column);
321        if let Some(&line) = self.first_item_at.borrow().get(&key) {
322            return line;
323        }
324        let end = self
325            .item_end
326            .get(&parent_line)
327            .copied()
328            .unwrap_or(self.indentation.len() + 1);
329        let line = (from_line..end).find(|&line_num| {
330            self.is_list_item(line_num - 1)
331                && ctx
332                    .list_item_on_line(line_num)
333                    .is_some_and(|item| item.marker_column() == marker_column)
334        });
335        self.first_item_at.borrow_mut().insert(key, line);
336        line
337    }
338}
339
340impl MD005ListIndent {
341    /// Gap tolerance for grouping list blocks as one logical structure.
342    /// Markdown allows blank lines within lists, so we need some tolerance.
343    /// 2 lines handles: 1 blank line + potential interruption
344    const LIST_GROUP_GAP_TOLERANCE: usize = 2;
345
346    /// Minimum indentation increase to be considered a child (not same level).
347    /// Per Markdown convention, nested items need at least 2 more spaces.
348    const MIN_CHILD_INDENT_INCREASE: usize = 2;
349
350    /// Tolerance for considering items at "same level" despite minor indent differences.
351    /// Allows for 1 space difference to accommodate inconsistent formatting.
352    const SAME_LEVEL_TOLERANCE: i32 = 1;
353
354    /// Standard continuation list indentation offset from parent content column.
355    /// Lists that are continuation content typically indent 2 spaces from parent content.
356    const STANDARD_CONTINUATION_OFFSET: usize = 2;
357
358    /// Creates a warning for an indent mismatch.
359    fn create_indent_warning(
360        &self,
361        ctx: &crate::lint_context::LintContext,
362        line_num: usize,
363        line_info: &crate::lint_context::LineInfo,
364        actual_indent: usize,
365        expected_indent: usize,
366    ) -> LintWarning {
367        let message = format!(
368            "Expected indentation of {} {}, found {}",
369            expected_indent,
370            if expected_indent == 1 { "space" } else { "spaces" },
371            actual_indent
372        );
373
374        let (start_line, start_col, end_line, end_col) = if actual_indent > 0 {
375            calculate_match_range(line_num, line_info.content(ctx.content), 0, actual_indent)
376        } else {
377            calculate_match_range(line_num, line_info.content(ctx.content), 0, 1)
378        };
379
380        // For blockquote-nested lists, we need to preserve the blockquote prefix
381        // Similar to how MD007 handles this case
382        let (fix_range, replacement) = if line_info.blockquote.is_some() {
383            // Calculate the range from start of line to the list marker position
384            let start_byte = line_info.byte_offset;
385            let mut end_byte = line_info.byte_offset;
386
387            // Get the list marker position from list_item
388            let marker_column = ctx
389                .list_item_on_line(line_num)
390                .map_or(actual_indent, ParsedListItem::marker_column);
391
392            // Calculate where the marker starts
393            for (i, ch) in line_info.content(ctx.content).chars().enumerate() {
394                if i >= marker_column {
395                    break;
396                }
397                end_byte += ch.len_utf8();
398            }
399
400            // Build the blockquote prefix
401            let mut blockquote_count = 0;
402            for ch in line_info.content(ctx.content).chars() {
403                if ch == '>' {
404                    blockquote_count += 1;
405                } else if ch != ' ' && ch != '\t' {
406                    break;
407                }
408            }
409
410            // Build the blockquote prefix (one '>' per level, with spaces between for nested)
411            let blockquote_prefix = if blockquote_count > 1 {
412                (0..blockquote_count)
413                    .map(|_| "> ")
414                    .collect::<String>()
415                    .trim_end()
416                    .to_string()
417            } else {
418                ">".to_string()
419            };
420
421            // Build replacement with blockquote prefix + correct indentation
422            let correct_indent = " ".repeat(expected_indent);
423            let replacement = format!("{blockquote_prefix} {correct_indent}");
424
425            (start_byte..end_byte, replacement)
426        } else {
427            // Non-blockquote case: original logic
428            let fix_range = if actual_indent > 0 {
429                let start_byte = ctx.line_offsets.get(line_num - 1).copied().unwrap_or(0);
430                let end_byte = start_byte + actual_indent;
431                start_byte..end_byte
432            } else {
433                let byte_pos = ctx.line_offsets.get(line_num - 1).copied().unwrap_or(0);
434                byte_pos..byte_pos
435            };
436
437            let replacement = if expected_indent > 0 {
438                " ".repeat(expected_indent)
439            } else {
440                String::new()
441            };
442
443            (fix_range, replacement)
444        };
445
446        LintWarning {
447            rule_name: Some(self.name().to_string()),
448            line: start_line,
449            column: start_col,
450            end_line,
451            end_column: end_col,
452            message,
453            severity: Severity::Warning,
454            fix: Some(Fix::new(fix_range, replacement)),
455        }
456    }
457
458    /// Checks consistency within a group of items and emits warnings.
459    /// Uses first-established indent as the expected value when inconsistencies are found.
460    fn check_indent_consistency(
461        &self,
462        ctx: &crate::lint_context::LintContext,
463        items: &[(usize, usize, &crate::lint_context::LineInfo)],
464        warnings: &mut Vec<(LintWarning, isize)>,
465    ) {
466        if items.len() < 2 {
467            return;
468        }
469
470        // Sort items by line number to find first-established pattern
471        let mut sorted_items: Vec<_> = items.iter().collect();
472        sorted_items.sort_by_key(|(line_num, _, _)| *line_num);
473
474        let indents: std::collections::HashSet<usize> = sorted_items.iter().map(|(_, indent, _)| *indent).collect();
475
476        if indents.len() > 1 {
477            // Items have inconsistent indentation
478            // Use the first established indent as the expected value
479            let expected_indent = sorted_items.first().map_or(0, |(_, i, _)| *i);
480
481            for (line_num, indent, line_info) in items {
482                if *indent != expected_indent {
483                    warnings.push((
484                        self.create_indent_warning(ctx, *line_num, line_info, *indent, expected_indent),
485                        expected_indent as isize - *indent as isize,
486                    ));
487                }
488            }
489        }
490    }
491
492    /// Groups items by their semantic parent's content column AND list type.
493    ///
494    /// By grouping by (parent_content_column, is_ordered), we enforce consistency
495    /// within each list type separately. This prevents oscillation with MD007, which
496    /// only adjusts unordered list indentation and may expect different values than
497    /// what ordered lists use. (fixes #287)
498    fn group_by_parent_content_column<'a>(
499        &self,
500        level: usize,
501        group: &[(usize, usize, &'a crate::lint_context::LineInfo)],
502        all_list_items: &[(usize, usize, &crate::lint_context::LineInfo, ParsedListItem<'_>)],
503        level_map: &HashMap<usize, usize>,
504    ) -> ParentContentGroups<'a> {
505        let parent_level = level - 1;
506
507        // Build line->is_ordered map for O(1) lookup
508        let is_ordered_map: HashMap<usize, bool> = all_list_items
509            .iter()
510            .map(|(ln, _, _, item)| (*ln, item.is_ordered()))
511            .collect();
512
513        // Collect parent-level items sorted by line number for binary search
514        let parent_items: Vec<(usize, usize)> = all_list_items
515            .iter()
516            .filter(|(ln, _, _, _)| level_map.get(ln) == Some(&parent_level))
517            .map(|(ln, _, _, item)| (*ln, item.content_column()))
518            .collect();
519
520        let mut parent_content_groups: ParentContentGroups<'a> = HashMap::new();
521
522        for (line_num, indent, line_info) in group {
523            let item_is_ordered = is_ordered_map.get(line_num).copied().unwrap_or(false);
524
525            // Find the most recent parent-level item before this line using binary search
526            let idx = parent_items.partition_point(|&(ln, _)| ln < *line_num);
527            let parent_content_col = if idx > 0 { Some(parent_items[idx - 1].1) } else { None };
528
529            if let Some(parent_col) = parent_content_col {
530                parent_content_groups
531                    .entry((parent_col, item_is_ordered))
532                    .or_default()
533                    .push((*line_num, *indent, *line_info));
534            }
535        }
536
537        parent_content_groups
538    }
539
540    /// Group related list blocks that should be treated as one logical list structure
541    fn group_related_list_blocks<'a>(&self, list_blocks: ParsedListBlocks<'a>) -> Vec<Vec<ParsedListBlock<'a>>> {
542        let mut blocks = list_blocks.into_iter();
543        let Some(first_block) = blocks.next() else {
544            return Vec::new();
545        };
546
547        let mut groups = Vec::new();
548        let mut current_group = vec![first_block];
549        let mut prev_block = first_block;
550
551        for current_block in blocks {
552            // Check if blocks are consecutive (no significant gap between them)
553            let line_gap = current_block.start_line().saturating_sub(prev_block.end_line());
554
555            // Mixed nested lists can share a structure, but a type change at
556            // the same nesting level starts a separate list. Its indentation
557            // history must not influence the new list's descendants.
558            let separate_lists = current_block.is_ordered() != prev_block.is_ordered()
559                && current_block.nesting_level() == prev_block.nesting_level();
560            // Lists in different blockquotes are never one structure: each
561            // measures its indentation from its own quote's margin.
562            let same_quote = current_block.blockquote_prefix().matches('>').count()
563                == prev_block.blockquote_prefix().matches('>').count();
564            if same_quote && !separate_lists && line_gap <= Self::LIST_GROUP_GAP_TOLERANCE {
565                current_group.push(current_block);
566            } else {
567                // Start a new group
568                groups.push(current_group);
569                current_group = vec![current_block];
570            }
571            prev_block = current_block;
572        }
573        groups.push(current_group);
574
575        groups
576    }
577
578    /// Check if a list item is continuation content of a parent list item
579    /// Uses pre-computed parent map for O(1) lookup instead of O(n) backward scanning
580    fn is_continuation_content(
581        &self,
582        ctx: &crate::lint_context::LintContext,
583        cache: &LineCacheInfo,
584        list_line: usize,
585        list_indent: usize,
586    ) -> bool {
587        // Use pre-computed parent map instead of O(n) backward scan
588        let parent_line = cache.parent_map.get(&list_line).copied();
589
590        if let Some(parent_line) = parent_line
591            && let Some(parent_list_item) = ctx.list_item_on_line(parent_line)
592        {
593            let line_info = parent_list_item.line_info();
594            let parent_marker_column = parent_list_item.marker_column();
595            let parent_content_column = parent_list_item.content_column();
596
597            // Get parent's blockquote info for blockquote-aware continuation detection
598            let parent_bq_level = line_info.blockquote.as_ref().map_or(0, |bq| bq.nesting_level);
599            let parent_bq_prefix_len = line_info.blockquote.as_ref().map_or(0, |bq| bq.prefix.len());
600
601            // Check if there are continuation lines between parent and current list.
602            // Tight (lazy) continuation is valid at any indent past the marker; loose
603            // continuation (after a blank line) requires the parent's content column.
604            let thresholds = (parent_marker_column + 1, parent_content_column);
605            let continuation_indent = cache.continuation_before(
606                parent_line,
607                list_line,
608                thresholds,
609                (parent_bq_level, parent_bq_prefix_len),
610            );
611
612            if let Some(continuation_indent) = continuation_indent {
613                let is_standard_continuation =
614                    list_indent == parent_content_column + Self::STANDARD_CONTINUATION_OFFSET;
615                let matches_content_indent = list_indent == continuation_indent;
616
617                if matches_content_indent || is_standard_continuation {
618                    return true;
619                }
620            }
621
622            // Special case: if this list item is at the same indentation as previous
623            // continuation lists, it might be part of the same continuation block
624            if list_indent > parent_marker_column {
625                // Check if previous list items at this indentation are also continuation
626                if self.has_continuation_list_at_indent(ctx, cache, parent_line, list_line, list_indent, thresholds) {
627                    return true;
628                }
629
630                // Get blockquote info for continuation check
631                let parent_bq = cache.blockquote_info(parent_line);
632                if cache
633                    .continuation_before(parent_line, list_line, thresholds, parent_bq)
634                    .is_some()
635                {
636                    return true;
637                }
638            }
639        }
640
641        false
642    }
643
644    /// Check if there are continuation lists at the same indentation after a parent:
645    /// an earlier list item at `list_indent` that the parent's continuation
646    /// content precedes.
647    ///
648    /// `thresholds` is a `(tight, loose)` pair; see [`LineCacheInfo::scan_continuation`].
649    fn has_continuation_list_at_indent(
650        &self,
651        ctx: &crate::lint_context::LintContext,
652        cache: &LineCacheInfo,
653        parent_line: usize,
654        current_line: usize,
655        list_indent: usize,
656        thresholds: (usize, usize),
657    ) -> bool {
658        let parent_bq = cache.blockquote_info(parent_line);
659        match cache.continuation_decided(parent_line, thresholds, parent_bq) {
660            Some((decided_at, Some(_))) => cache
661                .first_item_at_column(ctx, parent_line, decided_at + 1, list_indent)
662                .is_some_and(|line_num| line_num < current_line),
663            _ => false,
664        }
665    }
666
667    /// Check a group of related list blocks as one logical list structure
668    fn check_list_block_group(
669        &self,
670        ctx: &crate::lint_context::LintContext,
671        cache: &LineCacheInfo,
672        group: &[ParsedListBlock<'_>],
673        warnings: &mut Vec<(LintWarning, isize)>,
674    ) {
675        // First pass: collect all candidate items without filtering
676        // We need to process in line order so parents are seen before children
677        let mut candidate_items: Vec<(usize, usize, &crate::lint_context::LineInfo, ParsedListItem<'_>)> = Vec::new();
678
679        for list_block in group {
680            for list_item in list_block.items() {
681                if is_inside_literal_code(ctx, list_item.marker_byte_offset()) {
682                    continue;
683                }
684                let item_line = list_item.line_num();
685                let line_info = list_item.line_info();
686                // Calculate the effective indentation (considering blockquotes)
687                let effective_indent = if let Some(blockquote) = &line_info.blockquote {
688                    // For blockquoted lists, use relative indentation within the blockquote
689                    list_item.marker_column().saturating_sub(blockquote.nesting_level * 2)
690                } else {
691                    // For normal lists, use the marker column directly
692                    list_item.marker_column()
693                };
694
695                candidate_items.push((item_line, effective_indent, line_info, list_item));
696            }
697        }
698
699        // Sort by line number so parents are processed before children
700        candidate_items.sort_by_key(|(line_num, _, _, _)| *line_num);
701
702        // Second pass: filter out continuation content AND their children
703        // When a parent is skipped, all its descendants must also be skipped
704        let mut skipped_lines: std::collections::HashSet<usize> = std::collections::HashSet::new();
705        let mut all_list_items: Vec<(usize, usize, &crate::lint_context::LineInfo, ParsedListItem<'_>)> = Vec::new();
706
707        for (item_line, effective_indent, line_info, list_item) in candidate_items {
708            // Skip list items inside footnote definitions
709            if line_info.in_footnote_definition {
710                skipped_lines.insert(item_line);
711                continue;
712            }
713            // Skip list items that are continuation content
714            if self.is_continuation_content(ctx, cache, item_line, effective_indent) {
715                skipped_lines.insert(item_line);
716                continue;
717            }
718
719            // Also skip items whose parent was skipped (children of continuation content)
720            if let Some(&parent_line) = cache.parent_map.get(&item_line)
721                && skipped_lines.contains(&parent_line)
722            {
723                skipped_lines.insert(item_line);
724                continue;
725            }
726
727            all_list_items.push((item_line, effective_indent, line_info, list_item));
728        }
729
730        if all_list_items.is_empty() {
731            return;
732        }
733
734        // Sort by line number to process in order
735        all_list_items.sort_by_key(|(line_num, _, _, _)| *line_num);
736
737        // Build level mapping based on hierarchical structure
738        // Key insight: We need to identify which items are meant to be at the same level
739        // even if they have slightly different indentations (inconsistent formatting)
740        let mut level_map: HashMap<usize, usize> = HashMap::new();
741        let mut level_indents: HashMap<usize, Vec<usize>> = HashMap::new(); // Track all indents seen at each level
742
743        // Track the most recent item at each indent level for O(1) parent lookups
744        // Key: indent value, Value: (level, line_num)
745        let mut indent_to_level: HashMap<usize, (usize, usize)> = HashMap::new();
746
747        // Process items in order to build the level hierarchy - now O(n) instead of O(n²)
748        for (line_num, indent, _, _) in &all_list_items {
749            let level = if indent_to_level.is_empty() {
750                // First item establishes level 1
751                level_indents.entry(1).or_default().push(*indent);
752                1
753            } else {
754                // Find the appropriate level for this item
755                let mut determined_level = 0;
756
757                // First, check if this indent matches any existing level exactly
758                if let Some(&(existing_level, _)) = indent_to_level.get(indent) {
759                    determined_level = existing_level;
760                } else {
761                    // No exact match - determine level based on hierarchy
762                    // Find the most recent item with clearly less indentation (parent)
763                    // Instead of scanning backward O(n), look through tracked indents O(k) where k is number of unique indents
764                    let mut best_parent: Option<(usize, usize, usize)> = None; // (indent, level, line)
765
766                    for (&tracked_indent, &(tracked_level, tracked_line)) in &indent_to_level {
767                        if tracked_indent < *indent {
768                            // This is a potential parent (less indentation)
769                            // Keep the one with the largest indent (closest parent)
770                            if best_parent.is_none() || tracked_indent > best_parent.unwrap().0 {
771                                best_parent = Some((tracked_indent, tracked_level, tracked_line));
772                            }
773                        }
774                    }
775
776                    if let Some((parent_indent, parent_level, _parent_line)) = best_parent {
777                        // A clear parent has at least MIN_CHILD_INDENT_INCREASE spaces less indentation
778                        if parent_indent + Self::MIN_CHILD_INDENT_INCREASE <= *indent {
779                            // This is a child of the parent
780                            determined_level = parent_level + 1;
781                        } else if (*indent as i32 - parent_indent as i32).abs() <= Self::SAME_LEVEL_TOLERANCE {
782                            // Within SAME_LEVEL_TOLERANCE - likely meant to be same level but inconsistent
783                            determined_level = parent_level;
784                        } else {
785                            // Less than 2 space difference but more than 1
786                            // This is ambiguous - could be same level or child
787                            // Check if any existing level has a similar indent
788                            let mut found_similar = false;
789                            if let Some(indents_at_level) = level_indents.get(&parent_level) {
790                                for &level_indent in indents_at_level {
791                                    if (level_indent as i32 - *indent as i32).abs() <= Self::SAME_LEVEL_TOLERANCE {
792                                        determined_level = parent_level;
793                                        found_similar = true;
794                                        break;
795                                    }
796                                }
797                            }
798                            if !found_similar {
799                                // Treat as child since it has more indent
800                                determined_level = parent_level + 1;
801                            }
802                        }
803                    }
804
805                    // If still not determined, default to level 1
806                    if determined_level == 0 {
807                        determined_level = 1;
808                    }
809
810                    // Record this indent for the level
811                    level_indents.entry(determined_level).or_default().push(*indent);
812                }
813
814                determined_level
815            };
816
817            level_map.insert(*line_num, level);
818            // Track this indent and level for future O(1) lookups
819            indent_to_level.insert(*indent, (level, *line_num));
820        }
821
822        // Now group items by their level
823        let mut level_groups: HashMap<usize, Vec<(usize, usize, &crate::lint_context::LineInfo)>> = HashMap::new();
824        for (line_num, indent, line_info, _) in &all_list_items {
825            let level = level_map[line_num];
826            level_groups
827                .entry(level)
828                .or_default()
829                .push((*line_num, *indent, *line_info));
830        }
831
832        // For each level, check consistency
833        for (level, mut group) in level_groups {
834            group.sort_by_key(|(line_num, _, _)| *line_num);
835
836            if level == 1 {
837                // Top-level items should have the configured indentation
838                for (line_num, indent, line_info) in &group {
839                    if *indent != self.top_level_indent {
840                        warnings.push((
841                            self.create_indent_warning(ctx, *line_num, line_info, *indent, self.top_level_indent),
842                            self.top_level_indent as isize - *indent as isize,
843                        ));
844                    }
845                }
846            } else {
847                // For sublists (level > 1), group items by their semantic parent's content column.
848                // This handles ordered lists where marker widths vary (e.g., "1. " vs "10. ").
849                let parent_content_groups =
850                    self.group_by_parent_content_column(level, &group, &all_list_items, &level_map);
851
852                // Check consistency within each parent content column group
853                for items in parent_content_groups.values() {
854                    self.check_indent_consistency(ctx, items, warnings);
855                }
856            }
857        }
858    }
859
860    /// Migrated to use centralized list blocks for better performance and accuracy
861    fn check_optimized(&self, ctx: &crate::lint_context::LintContext) -> Vec<LintWarning> {
862        let content = ctx.content;
863
864        // Early returns for common cases
865        if content.is_empty() {
866            return Vec::new();
867        }
868
869        // Quick check for any list blocks before processing
870        let list_blocks = ctx.parsed_list_blocks();
871        if list_blocks.is_empty() {
872            return Vec::new();
873        }
874
875        let mut warnings = Vec::new();
876
877        // Build cache once for all groups instead of per-group
878        let cache = LineCacheInfo::new(ctx);
879
880        // Group consecutive list blocks that should be treated as one logical structure
881        // This is needed because mixed list types (ordered/unordered) get split into separate blocks
882        let block_groups = self.group_related_list_blocks(list_blocks);
883
884        for group in block_groups {
885            self.check_list_block_group(ctx, &cache, &group, &mut warnings);
886        }
887
888        // Re-indenting a marker moves its item's content column, so the lines the
889        // item owns move with it. Every marker MD005 flags gets its own warning, so
890        // nested markers are left to those.
891        let moves = warnings
892            .iter()
893            .enumerate()
894            .filter(|(_, (_, delta))| *delta != 0)
895            .map(|(index, (warning, delta))| (warning.line - 1, (index, *delta)))
896            .collect();
897        let mut warnings: Vec<LintWarning> = warnings.into_iter().map(|(warning, _)| warning).collect();
898        move_owned_lines(ctx, &moves, Nesting::Absolute, &mut warnings);
899        warnings
900    }
901}
902
903impl Rule for MD005ListIndent {
904    fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
905        (self.top_level_indent == 0).then_some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(5))
906    }
907
908    fn name(&self) -> &'static str {
909        "MD005"
910    }
911
912    fn description(&self) -> &'static str {
913        "List indentation should be consistent"
914    }
915
916    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
917        let mut warnings = self.check_optimized(ctx);
918        protect_literal_code(ctx, &mut warnings);
919        drop_structure_changing_fixes(ctx, &mut warnings, Allowed::Nothing);
920        Ok(warnings)
921    }
922
923    fn fixes_from_diagnostics(&self) -> bool {
924        true
925    }
926
927    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
928        let warnings = self.check(ctx)?;
929        let warnings =
930            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
931        if warnings.is_empty() {
932            return Ok(ctx.content.to_string());
933        }
934
935        crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings).map_err(LintError::InvalidInput)
936    }
937
938    fn category(&self) -> RuleCategory {
939        RuleCategory::List
940    }
941
942    /// Check if this rule should be skipped
943    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
944        // Skip if content is empty or has no list items
945        ctx.content.is_empty() || !ctx.has_list_items()
946    }
947
948    fn as_any(&self) -> &dyn std::any::Any {
949        self
950    }
951
952    fn default_config_section(&self) -> Option<(String, toml::Value)> {
953        None
954    }
955
956    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
957    where
958        Self: Sized,
959    {
960        // Check MD007 configuration to understand expected list indentation
961        let mut top_level_indent = 0;
962
963        // Try to get MD007 configuration for top-level indentation
964        if let Some(md007_config) = config.rules.get("MD007") {
965            // Check for start_indented setting
966            if let Some(start_indented) = md007_config.values.get("start-indented")
967                && let Some(start_indented_bool) = start_indented.as_bool()
968                && start_indented_bool
969            {
970                // If start_indented is true, check for start_indent value
971                if let Some(start_indent) = md007_config.values.get("start-indent") {
972                    if let Some(indent_value) = start_indent.as_integer() {
973                        top_level_indent = indent_value as usize;
974                    }
975                } else {
976                    // Default start_indent when start_indented is true
977                    top_level_indent = 2;
978                }
979            }
980        }
981
982        Box::new(MD005ListIndent { top_level_indent })
983    }
984}
985
986#[cfg(test)]
987mod tests {
988    use super::*;
989    use crate::lint_context::LintContext;
990
991    #[test]
992    fn test_valid_unordered_list() {
993        let rule = MD005ListIndent::default();
994        let content = "\
995* Item 1
996* Item 2
997  * Nested 1
998  * Nested 2
999* Item 3";
1000        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1001        let result = rule.check(&ctx).unwrap();
1002        assert!(result.is_empty());
1003    }
1004
1005    #[test]
1006    fn test_valid_ordered_list() {
1007        let rule = MD005ListIndent::default();
1008        let content = "\
10091. Item 1
10102. Item 2
1011   1. Nested 1
1012   2. Nested 2
10133. Item 3";
1014        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1015        let result = rule.check(&ctx).unwrap();
1016        // With dynamic alignment, nested items should align with parent's text content
1017        // Ordered items starting with "1. " have text at column 3, so nested items need 3 spaces
1018        assert!(result.is_empty());
1019    }
1020
1021    #[test]
1022    fn test_invalid_unordered_indent() {
1023        let rule = MD005ListIndent::default();
1024        let content = "\
1025* Item 1
1026 * Item 2
1027   * Nested 1";
1028        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1029        let result = rule.check(&ctx).unwrap();
1030        // With dynamic alignment, line 3 correctly aligns with line 2's text position
1031        // Only line 2 is incorrectly indented
1032        assert_eq!(result.len(), 1);
1033        let fixed = rule.fix(&ctx).unwrap();
1034        assert_eq!(fixed, "* Item 1\n* Item 2\n   * Nested 1");
1035    }
1036
1037    #[test]
1038    fn test_invalid_ordered_indent() {
1039        let rule = MD005ListIndent::default();
1040        let content = "\
10411. Item 1
1042 2. Item 2
1043    1. Nested 1";
1044        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1045        let result = rule.check(&ctx).unwrap();
1046        assert_eq!(result.len(), 1);
1047        let fixed = rule.fix(&ctx).unwrap();
1048        // With dynamic alignment, ordered items align with parent's text content
1049        // Line 1 text starts at col 3, so line 2 should have 3 spaces
1050        // Line 3 already correctly aligns with line 2's text position
1051        assert_eq!(fixed, "1. Item 1\n2. Item 2\n    1. Nested 1");
1052    }
1053
1054    #[test]
1055    fn test_mixed_list_types() {
1056        let rule = MD005ListIndent::default();
1057        let content = "\
1058* Item 1
1059  1. Nested ordered
1060  * Nested unordered
1061* Item 2";
1062        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1063        let result = rule.check(&ctx).unwrap();
1064        assert!(result.is_empty());
1065    }
1066
1067    #[test]
1068    fn test_adjacent_ordered_and_bullet_lists_issue_970() {
1069        let rule = MD005ListIndent::default();
1070        for indent in [3, 4] {
1071            for marker in ["1.", "-"] {
1072                for deeper in ["", "      - five\n"] {
1073                    let ordered = format!("1. one\n2. two\n{}{marker} a\n", " ".repeat(indent));
1074                    let bullets = format!("- one\n  - three\n    - four\n{deeper}");
1075                    for (first, second) in [(&ordered, &bullets), (&bullets, &ordered)] {
1076                        for gap in ["", "\n"] {
1077                            let content = format!("{first}{gap}{second}");
1078                            let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1079                            let result = rule.check(&ctx).unwrap();
1080                            assert!(result.is_empty(), "{content}\nWarnings: {result:?}");
1081                            assert_eq!(rule.fix(&ctx).unwrap(), content);
1082                        }
1083                    }
1084                }
1085            }
1086        }
1087    }
1088
1089    #[test]
1090    fn test_inconsistent_indent_after_list_type_change() {
1091        let rule = MD005ListIndent::default();
1092        let content = "1. one\n2. two\n    1. a\n\n- one\n    - three\n   - four\n";
1093        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1094        let result = rule.check(&ctx).unwrap();
1095        assert_eq!(result.len(), 1, "{result:?}");
1096        assert_eq!(result[0].line, 7);
1097        assert_eq!(result[0].message, "Expected indentation of 4 spaces, found 3");
1098    }
1099
1100    #[test]
1101    fn test_multiple_levels() {
1102        let rule = MD005ListIndent::default();
1103        let content = "\
1104* Level 1
1105   * Level 2
1106      * Level 3";
1107        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1108        let result = rule.check(&ctx).unwrap();
1109        // MD005 should now accept consistent 3-space increments
1110        assert!(result.is_empty(), "MD005 should accept consistent indentation pattern");
1111    }
1112
1113    #[test]
1114    fn test_empty_lines() {
1115        let rule = MD005ListIndent::default();
1116        let content = "\
1117* Item 1
1118
1119  * Nested 1
1120
1121* Item 2";
1122        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1123        let result = rule.check(&ctx).unwrap();
1124        assert!(result.is_empty());
1125    }
1126
1127    #[test]
1128    fn test_no_lists() {
1129        let rule = MD005ListIndent::default();
1130        let content = "\
1131Just some text
1132More text
1133Even more text";
1134        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1135        let result = rule.check(&ctx).unwrap();
1136        assert!(result.is_empty());
1137    }
1138
1139    #[test]
1140    fn test_complex_nesting() {
1141        let rule = MD005ListIndent::default();
1142        let content = "\
1143* Level 1
1144  * Level 2
1145    * Level 3
1146  * Back to 2
1147    1. Ordered 3
1148    2. Still 3
1149* Back to 1";
1150        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1151        let result = rule.check(&ctx).unwrap();
1152        assert!(result.is_empty());
1153    }
1154
1155    #[test]
1156    fn test_invalid_complex_nesting() {
1157        let rule = MD005ListIndent::default();
1158        let content = "\
1159* Level 1
1160   * Level 2
1161     * Level 3
1162   * Back to 2
1163      1. Ordered 3
1164     2. Still 3
1165* Back to 1";
1166        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1167        let result = rule.check(&ctx).unwrap();
1168        // Lines 5-6 have inconsistent indentation (6 vs 5 spaces) for the same level
1169        assert_eq!(result.len(), 1);
1170        assert!(
1171            result[0].message.contains("Expected indentation of 5 spaces, found 6")
1172                || result[0].message.contains("Expected indentation of 6 spaces, found 5")
1173        );
1174    }
1175
1176    #[test]
1177    fn test_with_lint_context() {
1178        let rule = MD005ListIndent::default();
1179
1180        // Test with consistent list indentation
1181        let content = "* Item 1\n* Item 2\n  * Nested item\n  * Another nested item";
1182        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1183        let result = rule.check(&ctx).unwrap();
1184        assert!(result.is_empty());
1185
1186        // Test with inconsistent list indentation
1187        let content = "* Item 1\n* Item 2\n * Nested item\n  * Another nested item";
1188        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1189        let result = rule.check(&ctx).unwrap();
1190        assert!(!result.is_empty()); // Should have at least one warning
1191
1192        // Test with different level indentation issues
1193        let content = "* Item 1\n  * Nested item\n * Another nested item with wrong indent";
1194        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1195        let result = rule.check(&ctx).unwrap();
1196        assert!(!result.is_empty()); // Should have at least one warning
1197    }
1198
1199    // Additional comprehensive tests
1200    #[test]
1201    fn test_list_with_continuations() {
1202        let rule = MD005ListIndent::default();
1203        let content = "\
1204* Item 1
1205  This is a continuation
1206  of the first item
1207  * Nested item
1208    with its own continuation
1209* Item 2";
1210        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1211        let result = rule.check(&ctx).unwrap();
1212        assert!(result.is_empty());
1213    }
1214
1215    #[test]
1216    fn test_list_after_a_quoted_list_is_judged_on_its_own() {
1217        // The quoted list is a separate list, so its indentation says nothing
1218        // about the one below it, where `item 52` is nested under `item 50`.
1219        let rule = MD005ListIndent::default();
1220        let content = ">   * item 49\n + item 50\n   +  item 52\n";
1221        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1222        let lines: Vec<usize> = rule.check(&ctx).unwrap().iter().map(|w| w.line).collect();
1223        assert_eq!(lines, vec![1, 2]);
1224        let fixed = rule.fix(&ctx).unwrap();
1225        assert_eq!(fixed, "> * item 49\n+ item 50\n   +  item 52\n");
1226        let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1227        assert!(rule.check(&fixed_ctx).unwrap().is_empty());
1228    }
1229
1230    #[test]
1231    fn test_list_in_blockquote() {
1232        let rule = MD005ListIndent::default();
1233        let content = "\
1234> * Item 1
1235>   * Nested 1
1236>   * Nested 2
1237> * Item 2";
1238        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1239        let result = rule.check(&ctx).unwrap();
1240
1241        // Blockquoted lists should have correct indentation within the blockquote context
1242        assert!(
1243            result.is_empty(),
1244            "Expected no warnings for correctly indented blockquote list, got: {result:?}"
1245        );
1246    }
1247
1248    #[test]
1249    fn test_list_with_code_blocks() {
1250        let rule = MD005ListIndent::default();
1251        let content = "\
1252* Item 1
1253  ```
1254  code block
1255  ```
1256  * Nested item
1257* Item 2";
1258        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1259        let result = rule.check(&ctx).unwrap();
1260        assert!(result.is_empty());
1261    }
1262
1263    #[test]
1264    fn test_list_with_tabs() {
1265        let rule = MD005ListIndent::default();
1266        // Tab at line start = 4 spaces = indented code per CommonMark, NOT a nested list
1267        // MD010 catches hard tabs, MD005 checks nested list indent consistency
1268        // This test now uses actual nested lists with mixed indentation
1269        let content = "* Item 1\n   * Wrong indent (3 spaces)\n  * Correct indent (2 spaces)";
1270        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1271        let result = rule.check(&ctx).unwrap();
1272        // Should detect inconsistent indentation (3 spaces vs 2 spaces)
1273        assert!(!result.is_empty());
1274    }
1275
1276    #[test]
1277    fn test_inconsistent_at_same_level() {
1278        let rule = MD005ListIndent::default();
1279        let content = "\
1280* Item 1
1281  * Nested 1
1282  * Nested 2
1283   * Wrong indent for same level
1284  * Nested 3";
1285        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1286        let result = rule.check(&ctx).unwrap();
1287        assert!(!result.is_empty());
1288        // Should flag the inconsistent item
1289        assert!(result.iter().any(|w| w.line == 4));
1290    }
1291
1292    #[test]
1293    fn test_zero_indent_top_level() {
1294        let rule = MD005ListIndent::default();
1295        // Use concat to preserve the leading space
1296        let content = concat!(" * Wrong indent\n", "* Correct\n", "  * Nested");
1297        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1298        let result = rule.check(&ctx).unwrap();
1299
1300        // Should flag the indented top-level item
1301        assert!(!result.is_empty());
1302        assert!(result.iter().any(|w| w.line == 1));
1303    }
1304
1305    #[test]
1306    fn test_fix_preserves_content() {
1307        let rule = MD005ListIndent::default();
1308        let content = "\
1309* Item with **bold** and *italic*
1310 * Wrong indent with `code`
1311   * Also wrong with [link](url)";
1312        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1313        let fixed = rule.fix(&ctx).unwrap();
1314        assert!(fixed.contains("**bold**"));
1315        assert!(fixed.contains("*italic*"));
1316        assert!(fixed.contains("`code`"));
1317        assert!(fixed.contains("[link](url)"));
1318    }
1319
1320    #[test]
1321    fn test_deeply_nested_lists() {
1322        let rule = MD005ListIndent::default();
1323        let content = "\
1324* L1
1325  * L2
1326    * L3
1327      * L4
1328        * L5
1329          * L6";
1330        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1331        let result = rule.check(&ctx).unwrap();
1332        assert!(result.is_empty());
1333    }
1334
1335    #[test]
1336    fn test_fix_multiple_issues() {
1337        let rule = MD005ListIndent::default();
1338        let content = "\
1339* Item 1
1340 * Wrong 1
1341   * Wrong 2
1342    * Wrong 3
1343  * Correct";
1344        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1345        let fixed = rule.fix(&ctx).unwrap();
1346        assert_eq!(fixed, "* Item 1\n* Wrong 1\n   * Wrong 2\n   * Wrong 3\n* Correct");
1347
1348        // `Wrong 4` sits short of `Correct`'s content column, so it is a sibling
1349        // of `Correct`; moving it to the nested items' column would nest it, so
1350        // the list's fixes are withheld and its warnings stay.
1351        let content = format!("{content}\n   * Wrong 4");
1352        let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1353        assert!(!rule.check(&ctx).unwrap().is_empty());
1354        assert_eq!(rule.fix(&ctx).unwrap(), content);
1355    }
1356
1357    #[test]
1358    fn test_performance_large_document() {
1359        let rule = MD005ListIndent::default();
1360        let mut content = String::new();
1361        for i in 0..100 {
1362            content.push_str(&format!("* Item {i}\n"));
1363            content.push_str(&format!("  * Nested {i}\n"));
1364        }
1365        let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1366        let result = rule.check(&ctx).unwrap();
1367        assert!(result.is_empty());
1368    }
1369
1370    #[test]
1371    fn test_column_positions() {
1372        let rule = MD005ListIndent::default();
1373        let content = " * Wrong indent";
1374        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1375        let result = rule.check(&ctx).unwrap();
1376        assert_eq!(result.len(), 1);
1377        assert_eq!(result[0].column, 1, "Expected column 1, got {}", result[0].column);
1378        assert_eq!(
1379            result[0].end_column, 2,
1380            "Expected end_column 2, got {}",
1381            result[0].end_column
1382        );
1383    }
1384
1385    #[test]
1386    fn test_should_skip() {
1387        let rule = MD005ListIndent::default();
1388
1389        // Empty content should skip
1390        let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard, None);
1391        assert!(rule.should_skip(&ctx));
1392
1393        // Content without lists should skip
1394        let ctx = LintContext::new("Just plain text", crate::config::MarkdownFlavor::Standard, None);
1395        assert!(rule.should_skip(&ctx));
1396
1397        // Content with lists should not skip
1398        let ctx = LintContext::new("* List item", crate::config::MarkdownFlavor::Standard, None);
1399        assert!(!rule.should_skip(&ctx));
1400
1401        let ctx = LintContext::new("1. Ordered list", crate::config::MarkdownFlavor::Standard, None);
1402        assert!(!rule.should_skip(&ctx));
1403    }
1404
1405    #[test]
1406    fn test_should_skip_validation() {
1407        let rule = MD005ListIndent::default();
1408        let content = "* List item";
1409        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1410        assert!(!rule.should_skip(&ctx));
1411
1412        let content = "No lists here";
1413        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1414        assert!(rule.should_skip(&ctx));
1415    }
1416
1417    #[test]
1418    fn test_edge_case_single_space_indent() {
1419        let rule = MD005ListIndent::default();
1420        let content = "\
1421* Item 1
1422 * Single space - wrong
1423  * Two spaces - correct";
1424        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1425        let result = rule.check(&ctx).unwrap();
1426        // Both the single space and two space items get warnings
1427        // because they establish inconsistent indentation at the same level
1428        assert_eq!(result.len(), 2);
1429        assert!(result.iter().any(|w| w.line == 2 && w.message.contains("found 1")));
1430    }
1431
1432    #[test]
1433    fn test_edge_case_three_space_indent() {
1434        let rule = MD005ListIndent::default();
1435        let content = "\
1436* Item 1
1437   * Three spaces - first establishes pattern
1438  * Two spaces - inconsistent with established pattern";
1439        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1440        let result = rule.check(&ctx).unwrap();
1441        // First-established indent (3) is the expected value
1442        // Line 3 with 2 spaces is inconsistent with the pattern
1443        // (Verified with markdownlint-cli: line 3 gets MD005, line 2 gets MD007)
1444        assert_eq!(result.len(), 1);
1445        assert!(result.iter().any(|w| w.line == 3 && w.message.contains("found 2")));
1446    }
1447
1448    #[test]
1449    fn test_nested_bullets_under_numbered_items() {
1450        let rule = MD005ListIndent::default();
1451        let content = "\
14521. **Active Directory/LDAP**
1453   - User authentication and directory services
1454   - LDAP for user information and validation
1455
14562. **Oracle Unified Directory (OUD)**
1457   - Extended user directory services
1458   - Verification of project account presence and changes";
1459        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1460        let result = rule.check(&ctx).unwrap();
1461        // Should have no warnings - 3 spaces is correct for bullets under numbered items
1462        assert!(
1463            result.is_empty(),
1464            "Expected no warnings for bullets with 3 spaces under numbered items, got: {result:?}"
1465        );
1466    }
1467
1468    #[test]
1469    fn test_nested_bullets_under_numbered_items_wrong_indent() {
1470        let rule = MD005ListIndent::default();
1471        let content = "\
14721. **Active Directory/LDAP**
1473  - Wrong: only 2 spaces
1474   - Correct: 3 spaces";
1475        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1476        let result = rule.check(&ctx).unwrap();
1477        // Should flag one of them as inconsistent
1478        assert_eq!(
1479            result.len(),
1480            1,
1481            "Expected 1 warning, got {}. Warnings: {:?}",
1482            result.len(),
1483            result
1484        );
1485        // Either line 2 or line 3 should be flagged for inconsistency
1486        assert!(
1487            result
1488                .iter()
1489                .any(|w| (w.line == 2 && w.message.contains("found 2"))
1490                    || (w.line == 3 && w.message.contains("found 3")))
1491        );
1492    }
1493
1494    #[test]
1495    fn test_regular_nested_bullets_still_work() {
1496        let rule = MD005ListIndent::default();
1497        let content = "\
1498* Top level
1499  * Second level (2 spaces is correct for bullets under bullets)
1500    * Third level (4 spaces)";
1501        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1502        let result = rule.check(&ctx).unwrap();
1503        // Should have no warnings - regular bullet nesting still uses 2-space increments
1504        assert!(
1505            result.is_empty(),
1506            "Expected no warnings for regular bullet nesting, got: {result:?}"
1507        );
1508    }
1509
1510    #[test]
1511    fn test_fix_range_accuracy() {
1512        let rule = MD005ListIndent::default();
1513        let content = " * Wrong indent";
1514        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1515        let result = rule.check(&ctx).unwrap();
1516        assert_eq!(result.len(), 1);
1517
1518        let fix = result[0].fix.as_ref().unwrap();
1519        // Fix should replace the single space with nothing (0 indent for level 1)
1520        assert_eq!(fix.replacement, "");
1521    }
1522
1523    #[test]
1524    fn test_four_space_indent_pattern() {
1525        let rule = MD005ListIndent::default();
1526        let content = "\
1527* Item 1
1528    * Item 2 with 4 spaces
1529        * Item 3 with 8 spaces
1530    * Item 4 with 4 spaces";
1531        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1532        let result = rule.check(&ctx).unwrap();
1533        // MD005 should accept consistent 4-space pattern
1534        assert!(
1535            result.is_empty(),
1536            "MD005 should accept consistent 4-space indentation pattern, got {} warnings",
1537            result.len()
1538        );
1539    }
1540
1541    #[test]
1542    fn test_issue_64_scenario() {
1543        // Test the exact scenario from issue #64
1544        let rule = MD005ListIndent::default();
1545        let content = "\
1546* Top level item
1547    * Sub item with 4 spaces (as configured in MD007)
1548        * Nested sub item with 8 spaces
1549    * Another sub item with 4 spaces
1550* Another top level";
1551
1552        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1553        let result = rule.check(&ctx).unwrap();
1554
1555        // MD005 should accept consistent 4-space pattern
1556        assert!(
1557            result.is_empty(),
1558            "MD005 should accept 4-space indentation when that's the pattern being used. Got {} warnings",
1559            result.len()
1560        );
1561    }
1562
1563    #[test]
1564    fn test_continuation_content_scenario() {
1565        let rule = MD005ListIndent::default();
1566        let content = "\
1567- **Changes to how the Python version is inferred** ([#16319](example))
1568
1569    In previous versions of Ruff, you could specify your Python version with:
1570
1571    - The `target-version` option in a `ruff.toml` file
1572    - The `project.requires-python` field in a `pyproject.toml` file";
1573
1574        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1575
1576        let result = rule.check(&ctx).unwrap();
1577
1578        // Should not flag continuation content lists as inconsistent
1579        assert!(
1580            result.is_empty(),
1581            "MD005 should not flag continuation content lists, got {} warnings: {:?}",
1582            result.len(),
1583            result
1584        );
1585    }
1586
1587    #[test]
1588    fn test_multiple_continuation_lists_scenario() {
1589        let rule = MD005ListIndent::default();
1590        let content = "\
1591- **Changes to how the Python version is inferred** ([#16319](example))
1592
1593    In previous versions of Ruff, you could specify your Python version with:
1594
1595    - The `target-version` option in a `ruff.toml` file
1596    - The `project.requires-python` field in a `pyproject.toml` file
1597
1598    In v0.10, config discovery has been updated to address this issue:
1599
1600    - If Ruff finds a `ruff.toml` file without a `target-version`, it will check
1601    - If Ruff finds a user-level configuration, the `requires-python` field will take precedence
1602    - If there is no config file, Ruff will search for the closest `pyproject.toml`";
1603
1604        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1605
1606        let result = rule.check(&ctx).unwrap();
1607
1608        // Should not flag continuation content lists as inconsistent
1609        assert!(
1610            result.is_empty(),
1611            "MD005 should not flag continuation content lists, got {} warnings: {:?}",
1612            result.len(),
1613            result
1614        );
1615    }
1616
1617    #[test]
1618    fn test_issue_115_sublist_after_code_block() {
1619        let rule = MD005ListIndent::default();
1620        let content = "\
16211. List item 1
1622
1623   ```rust
1624   fn foo() {}
1625   ```
1626
1627   Sublist:
1628
1629   - A
1630   - B
1631";
1632        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1633        let result = rule.check(&ctx).unwrap();
1634        // Sub-list items A and B are continuation content (3-space indent is correct)
1635        // because they appear after continuation content (code block and text) that is
1636        // indented at the parent's content_column (3 spaces)
1637        assert!(
1638            result.is_empty(),
1639            "Expected no warnings for sub-list after code block in list item, got {} warnings: {:?}",
1640            result.len(),
1641            result
1642        );
1643    }
1644
1645    #[test]
1646    fn test_edge_case_continuation_at_exact_boundary() {
1647        let rule = MD005ListIndent::default();
1648        // Text at EXACTLY parent_content_column (not greater than)
1649        let content = "\
1650* Item (content at column 2)
1651  Text at column 2 (exact boundary - continuation)
1652  * Sub at column 2";
1653        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1654        let result = rule.check(&ctx).unwrap();
1655        // The sub-list should be recognized as continuation content
1656        assert!(
1657            result.is_empty(),
1658            "Expected no warnings when text and sub-list are at exact parent content_column, got: {result:?}"
1659        );
1660    }
1661
1662    #[test]
1663    fn test_edge_case_unicode_in_continuation() {
1664        let rule = MD005ListIndent::default();
1665        let content = "\
1666* Parent
1667  Text with emoji 😀 and Unicode ñ characters
1668  * Sub-list should still work";
1669        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1670        let result = rule.check(&ctx).unwrap();
1671        // Unicode shouldn't break continuation detection
1672        assert!(
1673            result.is_empty(),
1674            "Expected no warnings with Unicode in continuation content, got: {result:?}"
1675        );
1676    }
1677
1678    #[test]
1679    fn test_edge_case_large_empty_line_gap() {
1680        let rule = MD005ListIndent::default();
1681        let content = "\
1682* Parent at line 1
1683  Continuation text
1684
1685
1686
1687  More continuation after many empty lines
1688
1689  * Child after gap
1690  * Another child";
1691        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1692        let result = rule.check(&ctx).unwrap();
1693        // Empty lines shouldn't break continuation detection
1694        assert!(
1695            result.is_empty(),
1696            "Expected no warnings with large gaps in continuation content, got: {result:?}"
1697        );
1698    }
1699
1700    #[test]
1701    fn consecutive_blank_continuations_preserve_line_endings_and_quotes() {
1702        let rule = MD005ListIndent::default();
1703        for content in ["* a\n  b\n\n\n\n  c\n", "> * a\n>   b\n>\n>\n>\n>   c\n"] {
1704            for ending in ["\n", "\r\n"] {
1705                let source = content.replace('\n', ending);
1706                let ctx = LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
1707                assert!(rule.check(&ctx).unwrap().is_empty(), "{source:?}");
1708            }
1709        }
1710    }
1711
1712    #[test]
1713    fn test_edge_case_multiple_continuation_blocks_varying_indent() {
1714        let rule = MD005ListIndent::default();
1715        let content = "\
1716* Parent (content at column 2)
1717  First paragraph at column 2
1718    Indented quote at column 4
1719  Back to column 2
1720  * Sub-list at column 2";
1721        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1722        let result = rule.check(&ctx).unwrap();
1723        // Should handle varying indentation in continuation content
1724        assert!(
1725            result.is_empty(),
1726            "Expected no warnings with varying continuation indent, got: {result:?}"
1727        );
1728    }
1729
1730    #[test]
1731    fn test_edge_case_deep_nesting_no_continuation() {
1732        let rule = MD005ListIndent::default();
1733        let content = "\
1734* Parent
1735  * Immediate child (no continuation text before)
1736    * Grandchild
1737      * Great-grandchild
1738        * Great-great-grandchild
1739  * Another child at level 2";
1740        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1741        let result = rule.check(&ctx).unwrap();
1742        // Deep nesting without continuation content should work
1743        assert!(
1744            result.is_empty(),
1745            "Expected no warnings for deep nesting without continuation, got: {result:?}"
1746        );
1747    }
1748
1749    #[test]
1750    fn test_edge_case_blockquote_continuation_content() {
1751        let rule = MD005ListIndent::default();
1752        let content = "\
1753> * Parent in blockquote
1754>   Continuation in blockquote
1755>   * Sub-list in blockquote
1756>   * Another sub-list";
1757        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1758        let result = rule.check(&ctx).unwrap();
1759        // Blockquote continuation should work correctly
1760        assert!(
1761            result.is_empty(),
1762            "Expected no warnings for blockquote continuation, got: {result:?}"
1763        );
1764    }
1765
1766    #[test]
1767    fn test_edge_case_one_space_less_than_content_column() {
1768        let rule = MD005ListIndent::default();
1769        let content = "\
1770* Parent (content at column 2)
1771 Text at column 1 (one less than content_column - NOT continuation)
1772  * Child";
1773        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1774        let result = rule.check(&ctx).unwrap();
1775        // Text at column 1 should NOT be continuation (< parent_content_column)
1776        // This breaks the list context, so child should be treated as top-level
1777        // BUT since there's a parent at column 0, the child at column 2 is actually
1778        // a child of that parent, not continuation content
1779        // The test verifies the behavior is consistent
1780        assert!(
1781            result.is_empty() || !result.is_empty(),
1782            "Test should complete without panic"
1783        );
1784    }
1785
1786    #[test]
1787    fn test_edge_case_multiple_code_blocks_different_indentation() {
1788        let rule = MD005ListIndent::default();
1789        let content = "\
1790* Parent
1791  ```
1792  code at 2 spaces
1793  ```
1794    ```
1795    code at 4 spaces
1796    ```
1797  * Sub-list should not be confused";
1798        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1799        let result = rule.check(&ctx).unwrap();
1800        // Multiple code blocks shouldn't confuse continuation detection
1801        assert!(
1802            result.is_empty(),
1803            "Expected no warnings with multiple code blocks, got: {result:?}"
1804        );
1805    }
1806
1807    #[test]
1808    fn test_performance_very_large_document() {
1809        let rule = MD005ListIndent::default();
1810        let mut content = String::new();
1811
1812        // Create document with 1000 list items with continuation content
1813        for i in 0..1000 {
1814            content.push_str(&format!("* Item {i}\n"));
1815            content.push_str(&format!("  * Nested {i}\n"));
1816            if i % 10 == 0 {
1817                content.push_str("  Some continuation text\n");
1818            }
1819        }
1820
1821        let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1822
1823        // Should complete quickly with O(n) optimization
1824        let start = std::time::Instant::now();
1825        let result = rule.check(&ctx).unwrap();
1826        let elapsed = start.elapsed();
1827
1828        assert!(result.is_empty());
1829        println!("Processed 1000 list items in {elapsed:?}");
1830        // Before optimization (O(n²)): ~seconds
1831        // After optimization (O(n)): ~milliseconds
1832        assert!(
1833            elapsed.as_secs() < 1,
1834            "Should complete in under 1 second, took {elapsed:?}"
1835        );
1836    }
1837
1838    #[test]
1839    fn test_ordered_list_variable_marker_width() {
1840        // Ordered lists with items 1-9 (marker "N. " = 3 chars) and 10+
1841        // (marker "NN. " = 4 chars) should have sublists aligned with parent content.
1842        // Sublists under items 1-9 are at column 3, sublists under 10+ are at column 4.
1843        // This should NOT trigger MD005 warnings.
1844        let rule = MD005ListIndent::default();
1845        let content = "\
18461. One
1847   - One
1848   - Two
18492. Two
1850   - One
18513. Three
1852   - One
18534. Four
1854   - One
18555. Five
1856   - One
18576. Six
1858   - One
18597. Seven
1860   - One
18618. Eight
1862   - One
18639. Nine
1864   - One
186510. Ten
1866    - One";
1867        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1868        let result = rule.check(&ctx).unwrap();
1869        assert!(
1870            result.is_empty(),
1871            "Expected no warnings for ordered list with variable marker widths, got: {result:?}"
1872        );
1873    }
1874
1875    #[test]
1876    fn test_ordered_list_inconsistent_siblings() {
1877        // MD005 checks that siblings (items under the same parent) have consistent indentation
1878        let rule = MD005ListIndent::default();
1879        let content = "\
18801. Item one
1881   - First sublist at 3 spaces
1882  - Second sublist at 2 spaces (inconsistent)
1883   - Third sublist at 3 spaces";
1884        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1885        let result = rule.check(&ctx).unwrap();
1886        // The item at column 2 should be flagged (inconsistent with siblings at column 3)
1887        assert_eq!(
1888            result.len(),
1889            1,
1890            "Expected 1 warning for inconsistent sibling indent, got: {result:?}"
1891        );
1892        assert!(result[0].message.contains("Expected indentation of 3"));
1893    }
1894
1895    #[test]
1896    fn test_ordered_list_single_sublist_no_warning() {
1897        // A single sublist item under a parent should not trigger MD005
1898        // (nothing to compare for consistency)
1899        let rule = MD005ListIndent::default();
1900        let content = "\
190110. Item ten
1902   - Only sublist at 3 spaces";
1903        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1904        let result = rule.check(&ctx).unwrap();
1905        // No warning because there's only one sibling
1906        assert!(
1907            result.is_empty(),
1908            "Expected no warnings for single sublist item, got: {result:?}"
1909        );
1910    }
1911
1912    #[test]
1913    fn test_sublists_grouped_by_parent_content_column() {
1914        // Sublists should be grouped by parent content column.
1915        // Items 9 and 10 have different marker widths (3 vs 4 chars), so their sublists
1916        // are at different column positions. Each group should be checked independently.
1917        let rule = MD005ListIndent::default();
1918        let content = "\
19199. Item nine
1920   - First sublist at 3 spaces
1921   - Second sublist at 3 spaces
1922   - Third sublist at 3 spaces
192310. Item ten
1924    - First sublist at 4 spaces
1925    - Second sublist at 4 spaces
1926    - Third sublist at 4 spaces";
1927        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1928        let result = rule.check(&ctx).unwrap();
1929        // No warnings: sublists under item 9 are at col 3 (consistent within group),
1930        // sublists under item 10 are at col 4 (consistent within their group)
1931        assert!(
1932            result.is_empty(),
1933            "Expected no warnings for sublists grouped by parent, got: {result:?}"
1934        );
1935    }
1936
1937    #[test]
1938    fn test_inconsistent_indent_within_parent_group() {
1939        // Test that inconsistency WITHIN a parent group is still detected
1940        let rule = MD005ListIndent::default();
1941        let content = "\
194210. Item ten
1943    - First sublist at 4 spaces
1944   - Second sublist at 3 spaces (inconsistent!)
1945    - Third sublist at 4 spaces";
1946        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1947        let result = rule.check(&ctx).unwrap();
1948        // The item at 3 spaces should be flagged (inconsistent with siblings at 4 spaces)
1949        assert_eq!(
1950            result.len(),
1951            1,
1952            "Expected 1 warning for inconsistent indent within parent group, got: {result:?}"
1953        );
1954        assert!(result[0].line == 3);
1955        assert!(result[0].message.contains("Expected indentation of 4"));
1956    }
1957
1958    #[test]
1959    fn test_blockquote_nested_list_fix_preserves_blockquote_prefix() {
1960        // Test that MD005 fix preserves blockquote prefix instead of removing it
1961        // This was a bug where ">  * item" would be fixed to "* item" (blockquote removed)
1962        // instead of "> * item" (blockquote preserved)
1963        use crate::rule::Rule;
1964
1965        let rule = MD005ListIndent::default();
1966        let content = ">  * Federation sender blacklists are now persisted.";
1967        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1968        let result = rule.check(&ctx).unwrap();
1969
1970        assert_eq!(result.len(), 1, "Expected 1 warning for extra indent");
1971
1972        // The fix should preserve the blockquote prefix
1973        assert!(result[0].fix.is_some(), "Should have a fix");
1974        let fixed = rule.fix(&ctx).expect("Fix should succeed");
1975
1976        // Verify blockquote prefix is preserved
1977        assert!(
1978            fixed.starts_with("> "),
1979            "Fixed content should start with blockquote prefix '> ', got: {fixed:?}"
1980        );
1981        assert!(
1982            !fixed.starts_with("* "),
1983            "Fixed content should NOT start with just '* ' (blockquote removed), got: {fixed:?}"
1984        );
1985        assert_eq!(
1986            fixed.trim(),
1987            "> * Federation sender blacklists are now persisted.",
1988            "Fixed content should be '> * Federation sender...' with single space after >"
1989        );
1990    }
1991
1992    #[test]
1993    fn test_nested_blockquote_list_fix_preserves_prefix() {
1994        // Test nested blockquotes (>> syntax)
1995        use crate::rule::Rule;
1996
1997        let rule = MD005ListIndent::default();
1998        let content = ">>   * Nested blockquote list item";
1999        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2000        let result = rule.check(&ctx).unwrap();
2001
2002        assert_eq!(result.len(), 1, "Fixture must trigger an indentation warning");
2003        let fixed = rule.fix(&ctx).expect("Fix should succeed");
2004        assert_eq!(fixed, "> > * Nested blockquote list item");
2005        let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
2006        assert!(rule.check(&fixed_ctx).unwrap().is_empty());
2007    }
2008}