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            // Group blocks if they are close together
556            // This handles cases where mixed list types are split but should be treated together.
557            // Lists in different blockquotes are never one structure: each
558            // measures its indentation from its own quote's margin.
559            let same_quote = current_block.blockquote_prefix().matches('>').count()
560                == prev_block.blockquote_prefix().matches('>').count();
561            if same_quote && line_gap <= Self::LIST_GROUP_GAP_TOLERANCE {
562                current_group.push(current_block);
563            } else {
564                // Start a new group
565                groups.push(current_group);
566                current_group = vec![current_block];
567            }
568            prev_block = current_block;
569        }
570        groups.push(current_group);
571
572        groups
573    }
574
575    /// Check if a list item is continuation content of a parent list item
576    /// Uses pre-computed parent map for O(1) lookup instead of O(n) backward scanning
577    fn is_continuation_content(
578        &self,
579        ctx: &crate::lint_context::LintContext,
580        cache: &LineCacheInfo,
581        list_line: usize,
582        list_indent: usize,
583    ) -> bool {
584        // Use pre-computed parent map instead of O(n) backward scan
585        let parent_line = cache.parent_map.get(&list_line).copied();
586
587        if let Some(parent_line) = parent_line
588            && let Some(parent_list_item) = ctx.list_item_on_line(parent_line)
589        {
590            let line_info = parent_list_item.line_info();
591            let parent_marker_column = parent_list_item.marker_column();
592            let parent_content_column = parent_list_item.content_column();
593
594            // Get parent's blockquote info for blockquote-aware continuation detection
595            let parent_bq_level = line_info.blockquote.as_ref().map_or(0, |bq| bq.nesting_level);
596            let parent_bq_prefix_len = line_info.blockquote.as_ref().map_or(0, |bq| bq.prefix.len());
597
598            // Check if there are continuation lines between parent and current list.
599            // Tight (lazy) continuation is valid at any indent past the marker; loose
600            // continuation (after a blank line) requires the parent's content column.
601            let thresholds = (parent_marker_column + 1, parent_content_column);
602            let continuation_indent = cache.continuation_before(
603                parent_line,
604                list_line,
605                thresholds,
606                (parent_bq_level, parent_bq_prefix_len),
607            );
608
609            if let Some(continuation_indent) = continuation_indent {
610                let is_standard_continuation =
611                    list_indent == parent_content_column + Self::STANDARD_CONTINUATION_OFFSET;
612                let matches_content_indent = list_indent == continuation_indent;
613
614                if matches_content_indent || is_standard_continuation {
615                    return true;
616                }
617            }
618
619            // Special case: if this list item is at the same indentation as previous
620            // continuation lists, it might be part of the same continuation block
621            if list_indent > parent_marker_column {
622                // Check if previous list items at this indentation are also continuation
623                if self.has_continuation_list_at_indent(ctx, cache, parent_line, list_line, list_indent, thresholds) {
624                    return true;
625                }
626
627                // Get blockquote info for continuation check
628                let parent_bq = cache.blockquote_info(parent_line);
629                if cache
630                    .continuation_before(parent_line, list_line, thresholds, parent_bq)
631                    .is_some()
632                {
633                    return true;
634                }
635            }
636        }
637
638        false
639    }
640
641    /// Check if there are continuation lists at the same indentation after a parent:
642    /// an earlier list item at `list_indent` that the parent's continuation
643    /// content precedes.
644    ///
645    /// `thresholds` is a `(tight, loose)` pair; see [`LineCacheInfo::scan_continuation`].
646    fn has_continuation_list_at_indent(
647        &self,
648        ctx: &crate::lint_context::LintContext,
649        cache: &LineCacheInfo,
650        parent_line: usize,
651        current_line: usize,
652        list_indent: usize,
653        thresholds: (usize, usize),
654    ) -> bool {
655        let parent_bq = cache.blockquote_info(parent_line);
656        match cache.continuation_decided(parent_line, thresholds, parent_bq) {
657            Some((decided_at, Some(_))) => cache
658                .first_item_at_column(ctx, parent_line, decided_at + 1, list_indent)
659                .is_some_and(|line_num| line_num < current_line),
660            _ => false,
661        }
662    }
663
664    /// Check a group of related list blocks as one logical list structure
665    fn check_list_block_group(
666        &self,
667        ctx: &crate::lint_context::LintContext,
668        cache: &LineCacheInfo,
669        group: &[ParsedListBlock<'_>],
670        warnings: &mut Vec<(LintWarning, isize)>,
671    ) {
672        // First pass: collect all candidate items without filtering
673        // We need to process in line order so parents are seen before children
674        let mut candidate_items: Vec<(usize, usize, &crate::lint_context::LineInfo, ParsedListItem<'_>)> = Vec::new();
675
676        for list_block in group {
677            for list_item in list_block.items() {
678                if is_inside_literal_code(ctx, list_item.marker_byte_offset()) {
679                    continue;
680                }
681                let item_line = list_item.line_num();
682                let line_info = list_item.line_info();
683                // Calculate the effective indentation (considering blockquotes)
684                let effective_indent = if let Some(blockquote) = &line_info.blockquote {
685                    // For blockquoted lists, use relative indentation within the blockquote
686                    list_item.marker_column().saturating_sub(blockquote.nesting_level * 2)
687                } else {
688                    // For normal lists, use the marker column directly
689                    list_item.marker_column()
690                };
691
692                candidate_items.push((item_line, effective_indent, line_info, list_item));
693            }
694        }
695
696        // Sort by line number so parents are processed before children
697        candidate_items.sort_by_key(|(line_num, _, _, _)| *line_num);
698
699        // Second pass: filter out continuation content AND their children
700        // When a parent is skipped, all its descendants must also be skipped
701        let mut skipped_lines: std::collections::HashSet<usize> = std::collections::HashSet::new();
702        let mut all_list_items: Vec<(usize, usize, &crate::lint_context::LineInfo, ParsedListItem<'_>)> = Vec::new();
703
704        for (item_line, effective_indent, line_info, list_item) in candidate_items {
705            // Skip list items inside footnote definitions
706            if line_info.in_footnote_definition {
707                skipped_lines.insert(item_line);
708                continue;
709            }
710            // Skip list items that are continuation content
711            if self.is_continuation_content(ctx, cache, item_line, effective_indent) {
712                skipped_lines.insert(item_line);
713                continue;
714            }
715
716            // Also skip items whose parent was skipped (children of continuation content)
717            if let Some(&parent_line) = cache.parent_map.get(&item_line)
718                && skipped_lines.contains(&parent_line)
719            {
720                skipped_lines.insert(item_line);
721                continue;
722            }
723
724            all_list_items.push((item_line, effective_indent, line_info, list_item));
725        }
726
727        if all_list_items.is_empty() {
728            return;
729        }
730
731        // Sort by line number to process in order
732        all_list_items.sort_by_key(|(line_num, _, _, _)| *line_num);
733
734        // Build level mapping based on hierarchical structure
735        // Key insight: We need to identify which items are meant to be at the same level
736        // even if they have slightly different indentations (inconsistent formatting)
737        let mut level_map: HashMap<usize, usize> = HashMap::new();
738        let mut level_indents: HashMap<usize, Vec<usize>> = HashMap::new(); // Track all indents seen at each level
739
740        // Track the most recent item at each indent level for O(1) parent lookups
741        // Key: indent value, Value: (level, line_num)
742        let mut indent_to_level: HashMap<usize, (usize, usize)> = HashMap::new();
743
744        // Process items in order to build the level hierarchy - now O(n) instead of O(n²)
745        for (line_num, indent, _, _) in &all_list_items {
746            let level = if indent_to_level.is_empty() {
747                // First item establishes level 1
748                level_indents.entry(1).or_default().push(*indent);
749                1
750            } else {
751                // Find the appropriate level for this item
752                let mut determined_level = 0;
753
754                // First, check if this indent matches any existing level exactly
755                if let Some(&(existing_level, _)) = indent_to_level.get(indent) {
756                    determined_level = existing_level;
757                } else {
758                    // No exact match - determine level based on hierarchy
759                    // Find the most recent item with clearly less indentation (parent)
760                    // Instead of scanning backward O(n), look through tracked indents O(k) where k is number of unique indents
761                    let mut best_parent: Option<(usize, usize, usize)> = None; // (indent, level, line)
762
763                    for (&tracked_indent, &(tracked_level, tracked_line)) in &indent_to_level {
764                        if tracked_indent < *indent {
765                            // This is a potential parent (less indentation)
766                            // Keep the one with the largest indent (closest parent)
767                            if best_parent.is_none() || tracked_indent > best_parent.unwrap().0 {
768                                best_parent = Some((tracked_indent, tracked_level, tracked_line));
769                            }
770                        }
771                    }
772
773                    if let Some((parent_indent, parent_level, _parent_line)) = best_parent {
774                        // A clear parent has at least MIN_CHILD_INDENT_INCREASE spaces less indentation
775                        if parent_indent + Self::MIN_CHILD_INDENT_INCREASE <= *indent {
776                            // This is a child of the parent
777                            determined_level = parent_level + 1;
778                        } else if (*indent as i32 - parent_indent as i32).abs() <= Self::SAME_LEVEL_TOLERANCE {
779                            // Within SAME_LEVEL_TOLERANCE - likely meant to be same level but inconsistent
780                            determined_level = parent_level;
781                        } else {
782                            // Less than 2 space difference but more than 1
783                            // This is ambiguous - could be same level or child
784                            // Check if any existing level has a similar indent
785                            let mut found_similar = false;
786                            if let Some(indents_at_level) = level_indents.get(&parent_level) {
787                                for &level_indent in indents_at_level {
788                                    if (level_indent as i32 - *indent as i32).abs() <= Self::SAME_LEVEL_TOLERANCE {
789                                        determined_level = parent_level;
790                                        found_similar = true;
791                                        break;
792                                    }
793                                }
794                            }
795                            if !found_similar {
796                                // Treat as child since it has more indent
797                                determined_level = parent_level + 1;
798                            }
799                        }
800                    }
801
802                    // If still not determined, default to level 1
803                    if determined_level == 0 {
804                        determined_level = 1;
805                    }
806
807                    // Record this indent for the level
808                    level_indents.entry(determined_level).or_default().push(*indent);
809                }
810
811                determined_level
812            };
813
814            level_map.insert(*line_num, level);
815            // Track this indent and level for future O(1) lookups
816            indent_to_level.insert(*indent, (level, *line_num));
817        }
818
819        // Now group items by their level
820        let mut level_groups: HashMap<usize, Vec<(usize, usize, &crate::lint_context::LineInfo)>> = HashMap::new();
821        for (line_num, indent, line_info, _) in &all_list_items {
822            let level = level_map[line_num];
823            level_groups
824                .entry(level)
825                .or_default()
826                .push((*line_num, *indent, *line_info));
827        }
828
829        // For each level, check consistency
830        for (level, mut group) in level_groups {
831            group.sort_by_key(|(line_num, _, _)| *line_num);
832
833            if level == 1 {
834                // Top-level items should have the configured indentation
835                for (line_num, indent, line_info) in &group {
836                    if *indent != self.top_level_indent {
837                        warnings.push((
838                            self.create_indent_warning(ctx, *line_num, line_info, *indent, self.top_level_indent),
839                            self.top_level_indent as isize - *indent as isize,
840                        ));
841                    }
842                }
843            } else {
844                // For sublists (level > 1), group items by their semantic parent's content column.
845                // This handles ordered lists where marker widths vary (e.g., "1. " vs "10. ").
846                let parent_content_groups =
847                    self.group_by_parent_content_column(level, &group, &all_list_items, &level_map);
848
849                // Check consistency within each parent content column group
850                for items in parent_content_groups.values() {
851                    self.check_indent_consistency(ctx, items, warnings);
852                }
853            }
854        }
855    }
856
857    /// Migrated to use centralized list blocks for better performance and accuracy
858    fn check_optimized(&self, ctx: &crate::lint_context::LintContext) -> Vec<LintWarning> {
859        let content = ctx.content;
860
861        // Early returns for common cases
862        if content.is_empty() {
863            return Vec::new();
864        }
865
866        // Quick check for any list blocks before processing
867        let list_blocks = ctx.parsed_list_blocks();
868        if list_blocks.is_empty() {
869            return Vec::new();
870        }
871
872        let mut warnings = Vec::new();
873
874        // Build cache once for all groups instead of per-group
875        let cache = LineCacheInfo::new(ctx);
876
877        // Group consecutive list blocks that should be treated as one logical structure
878        // This is needed because mixed list types (ordered/unordered) get split into separate blocks
879        let block_groups = self.group_related_list_blocks(list_blocks);
880
881        for group in block_groups {
882            self.check_list_block_group(ctx, &cache, &group, &mut warnings);
883        }
884
885        // Re-indenting a marker moves its item's content column, so the lines the
886        // item owns move with it. Every marker MD005 flags gets its own warning, so
887        // nested markers are left to those.
888        let moves = warnings
889            .iter()
890            .enumerate()
891            .filter(|(_, (_, delta))| *delta != 0)
892            .map(|(index, (warning, delta))| (warning.line - 1, (index, *delta)))
893            .collect();
894        let mut warnings: Vec<LintWarning> = warnings.into_iter().map(|(warning, _)| warning).collect();
895        move_owned_lines(ctx, &moves, Nesting::Absolute, &mut warnings);
896        warnings
897    }
898}
899
900impl Rule for MD005ListIndent {
901    fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
902        (self.top_level_indent == 0).then_some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(5))
903    }
904
905    fn name(&self) -> &'static str {
906        "MD005"
907    }
908
909    fn description(&self) -> &'static str {
910        "List indentation should be consistent"
911    }
912
913    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
914        let mut warnings = self.check_optimized(ctx);
915        protect_literal_code(ctx, &mut warnings);
916        drop_structure_changing_fixes(ctx, &mut warnings, Allowed::Nothing);
917        Ok(warnings)
918    }
919
920    fn fixes_from_diagnostics(&self) -> bool {
921        true
922    }
923
924    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
925        let warnings = self.check(ctx)?;
926        let warnings =
927            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
928        if warnings.is_empty() {
929            return Ok(ctx.content.to_string());
930        }
931
932        crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings).map_err(LintError::InvalidInput)
933    }
934
935    fn category(&self) -> RuleCategory {
936        RuleCategory::List
937    }
938
939    /// Check if this rule should be skipped
940    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
941        // Skip if content is empty or has no list items
942        ctx.content.is_empty() || !ctx.has_list_items()
943    }
944
945    fn as_any(&self) -> &dyn std::any::Any {
946        self
947    }
948
949    fn default_config_section(&self) -> Option<(String, toml::Value)> {
950        None
951    }
952
953    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
954    where
955        Self: Sized,
956    {
957        // Check MD007 configuration to understand expected list indentation
958        let mut top_level_indent = 0;
959
960        // Try to get MD007 configuration for top-level indentation
961        if let Some(md007_config) = config.rules.get("MD007") {
962            // Check for start_indented setting
963            if let Some(start_indented) = md007_config.values.get("start-indented")
964                && let Some(start_indented_bool) = start_indented.as_bool()
965                && start_indented_bool
966            {
967                // If start_indented is true, check for start_indent value
968                if let Some(start_indent) = md007_config.values.get("start-indent") {
969                    if let Some(indent_value) = start_indent.as_integer() {
970                        top_level_indent = indent_value as usize;
971                    }
972                } else {
973                    // Default start_indent when start_indented is true
974                    top_level_indent = 2;
975                }
976            }
977        }
978
979        Box::new(MD005ListIndent { top_level_indent })
980    }
981}
982
983#[cfg(test)]
984mod tests {
985    use super::*;
986    use crate::lint_context::LintContext;
987
988    #[test]
989    fn test_valid_unordered_list() {
990        let rule = MD005ListIndent::default();
991        let content = "\
992* Item 1
993* Item 2
994  * Nested 1
995  * Nested 2
996* Item 3";
997        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
998        let result = rule.check(&ctx).unwrap();
999        assert!(result.is_empty());
1000    }
1001
1002    #[test]
1003    fn test_valid_ordered_list() {
1004        let rule = MD005ListIndent::default();
1005        let content = "\
10061. Item 1
10072. Item 2
1008   1. Nested 1
1009   2. Nested 2
10103. Item 3";
1011        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1012        let result = rule.check(&ctx).unwrap();
1013        // With dynamic alignment, nested items should align with parent's text content
1014        // Ordered items starting with "1. " have text at column 3, so nested items need 3 spaces
1015        assert!(result.is_empty());
1016    }
1017
1018    #[test]
1019    fn test_invalid_unordered_indent() {
1020        let rule = MD005ListIndent::default();
1021        let content = "\
1022* Item 1
1023 * Item 2
1024   * Nested 1";
1025        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1026        let result = rule.check(&ctx).unwrap();
1027        // With dynamic alignment, line 3 correctly aligns with line 2's text position
1028        // Only line 2 is incorrectly indented
1029        assert_eq!(result.len(), 1);
1030        let fixed = rule.fix(&ctx).unwrap();
1031        assert_eq!(fixed, "* Item 1\n* Item 2\n   * Nested 1");
1032    }
1033
1034    #[test]
1035    fn test_invalid_ordered_indent() {
1036        let rule = MD005ListIndent::default();
1037        let content = "\
10381. Item 1
1039 2. Item 2
1040    1. Nested 1";
1041        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1042        let result = rule.check(&ctx).unwrap();
1043        assert_eq!(result.len(), 1);
1044        let fixed = rule.fix(&ctx).unwrap();
1045        // With dynamic alignment, ordered items align with parent's text content
1046        // Line 1 text starts at col 3, so line 2 should have 3 spaces
1047        // Line 3 already correctly aligns with line 2's text position
1048        assert_eq!(fixed, "1. Item 1\n2. Item 2\n    1. Nested 1");
1049    }
1050
1051    #[test]
1052    fn test_mixed_list_types() {
1053        let rule = MD005ListIndent::default();
1054        let content = "\
1055* Item 1
1056  1. Nested ordered
1057  * Nested unordered
1058* Item 2";
1059        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1060        let result = rule.check(&ctx).unwrap();
1061        assert!(result.is_empty());
1062    }
1063
1064    #[test]
1065    fn test_multiple_levels() {
1066        let rule = MD005ListIndent::default();
1067        let content = "\
1068* Level 1
1069   * Level 2
1070      * Level 3";
1071        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1072        let result = rule.check(&ctx).unwrap();
1073        // MD005 should now accept consistent 3-space increments
1074        assert!(result.is_empty(), "MD005 should accept consistent indentation pattern");
1075    }
1076
1077    #[test]
1078    fn test_empty_lines() {
1079        let rule = MD005ListIndent::default();
1080        let content = "\
1081* Item 1
1082
1083  * Nested 1
1084
1085* Item 2";
1086        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1087        let result = rule.check(&ctx).unwrap();
1088        assert!(result.is_empty());
1089    }
1090
1091    #[test]
1092    fn test_no_lists() {
1093        let rule = MD005ListIndent::default();
1094        let content = "\
1095Just some text
1096More text
1097Even more text";
1098        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1099        let result = rule.check(&ctx).unwrap();
1100        assert!(result.is_empty());
1101    }
1102
1103    #[test]
1104    fn test_complex_nesting() {
1105        let rule = MD005ListIndent::default();
1106        let content = "\
1107* Level 1
1108  * Level 2
1109    * Level 3
1110  * Back to 2
1111    1. Ordered 3
1112    2. Still 3
1113* Back to 1";
1114        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1115        let result = rule.check(&ctx).unwrap();
1116        assert!(result.is_empty());
1117    }
1118
1119    #[test]
1120    fn test_invalid_complex_nesting() {
1121        let rule = MD005ListIndent::default();
1122        let content = "\
1123* Level 1
1124   * Level 2
1125     * Level 3
1126   * Back to 2
1127      1. Ordered 3
1128     2. Still 3
1129* Back to 1";
1130        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1131        let result = rule.check(&ctx).unwrap();
1132        // Lines 5-6 have inconsistent indentation (6 vs 5 spaces) for the same level
1133        assert_eq!(result.len(), 1);
1134        assert!(
1135            result[0].message.contains("Expected indentation of 5 spaces, found 6")
1136                || result[0].message.contains("Expected indentation of 6 spaces, found 5")
1137        );
1138    }
1139
1140    #[test]
1141    fn test_with_lint_context() {
1142        let rule = MD005ListIndent::default();
1143
1144        // Test with consistent list indentation
1145        let content = "* Item 1\n* Item 2\n  * Nested item\n  * Another nested item";
1146        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1147        let result = rule.check(&ctx).unwrap();
1148        assert!(result.is_empty());
1149
1150        // Test with inconsistent list indentation
1151        let content = "* Item 1\n* Item 2\n * Nested item\n  * Another nested item";
1152        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1153        let result = rule.check(&ctx).unwrap();
1154        assert!(!result.is_empty()); // Should have at least one warning
1155
1156        // Test with different level indentation issues
1157        let content = "* Item 1\n  * Nested item\n * Another nested item with wrong indent";
1158        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1159        let result = rule.check(&ctx).unwrap();
1160        assert!(!result.is_empty()); // Should have at least one warning
1161    }
1162
1163    // Additional comprehensive tests
1164    #[test]
1165    fn test_list_with_continuations() {
1166        let rule = MD005ListIndent::default();
1167        let content = "\
1168* Item 1
1169  This is a continuation
1170  of the first item
1171  * Nested item
1172    with its own continuation
1173* Item 2";
1174        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1175        let result = rule.check(&ctx).unwrap();
1176        assert!(result.is_empty());
1177    }
1178
1179    #[test]
1180    fn test_list_after_a_quoted_list_is_judged_on_its_own() {
1181        // The quoted list is a separate list, so its indentation says nothing
1182        // about the one below it, where `item 52` is nested under `item 50`.
1183        let rule = MD005ListIndent::default();
1184        let content = ">   * item 49\n + item 50\n   +  item 52\n";
1185        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1186        let lines: Vec<usize> = rule.check(&ctx).unwrap().iter().map(|w| w.line).collect();
1187        assert_eq!(lines, vec![1, 2]);
1188        let fixed = rule.fix(&ctx).unwrap();
1189        assert_eq!(fixed, "> * item 49\n+ item 50\n   +  item 52\n");
1190        let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1191        assert!(rule.check(&fixed_ctx).unwrap().is_empty());
1192    }
1193
1194    #[test]
1195    fn test_list_in_blockquote() {
1196        let rule = MD005ListIndent::default();
1197        let content = "\
1198> * Item 1
1199>   * Nested 1
1200>   * Nested 2
1201> * Item 2";
1202        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1203        let result = rule.check(&ctx).unwrap();
1204
1205        // Blockquoted lists should have correct indentation within the blockquote context
1206        assert!(
1207            result.is_empty(),
1208            "Expected no warnings for correctly indented blockquote list, got: {result:?}"
1209        );
1210    }
1211
1212    #[test]
1213    fn test_list_with_code_blocks() {
1214        let rule = MD005ListIndent::default();
1215        let content = "\
1216* Item 1
1217  ```
1218  code block
1219  ```
1220  * Nested item
1221* Item 2";
1222        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1223        let result = rule.check(&ctx).unwrap();
1224        assert!(result.is_empty());
1225    }
1226
1227    #[test]
1228    fn test_list_with_tabs() {
1229        let rule = MD005ListIndent::default();
1230        // Tab at line start = 4 spaces = indented code per CommonMark, NOT a nested list
1231        // MD010 catches hard tabs, MD005 checks nested list indent consistency
1232        // This test now uses actual nested lists with mixed indentation
1233        let content = "* Item 1\n   * Wrong indent (3 spaces)\n  * Correct indent (2 spaces)";
1234        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1235        let result = rule.check(&ctx).unwrap();
1236        // Should detect inconsistent indentation (3 spaces vs 2 spaces)
1237        assert!(!result.is_empty());
1238    }
1239
1240    #[test]
1241    fn test_inconsistent_at_same_level() {
1242        let rule = MD005ListIndent::default();
1243        let content = "\
1244* Item 1
1245  * Nested 1
1246  * Nested 2
1247   * Wrong indent for same level
1248  * Nested 3";
1249        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1250        let result = rule.check(&ctx).unwrap();
1251        assert!(!result.is_empty());
1252        // Should flag the inconsistent item
1253        assert!(result.iter().any(|w| w.line == 4));
1254    }
1255
1256    #[test]
1257    fn test_zero_indent_top_level() {
1258        let rule = MD005ListIndent::default();
1259        // Use concat to preserve the leading space
1260        let content = concat!(" * Wrong indent\n", "* Correct\n", "  * Nested");
1261        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1262        let result = rule.check(&ctx).unwrap();
1263
1264        // Should flag the indented top-level item
1265        assert!(!result.is_empty());
1266        assert!(result.iter().any(|w| w.line == 1));
1267    }
1268
1269    #[test]
1270    fn test_fix_preserves_content() {
1271        let rule = MD005ListIndent::default();
1272        let content = "\
1273* Item with **bold** and *italic*
1274 * Wrong indent with `code`
1275   * Also wrong with [link](url)";
1276        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1277        let fixed = rule.fix(&ctx).unwrap();
1278        assert!(fixed.contains("**bold**"));
1279        assert!(fixed.contains("*italic*"));
1280        assert!(fixed.contains("`code`"));
1281        assert!(fixed.contains("[link](url)"));
1282    }
1283
1284    #[test]
1285    fn test_deeply_nested_lists() {
1286        let rule = MD005ListIndent::default();
1287        let content = "\
1288* L1
1289  * L2
1290    * L3
1291      * L4
1292        * L5
1293          * L6";
1294        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1295        let result = rule.check(&ctx).unwrap();
1296        assert!(result.is_empty());
1297    }
1298
1299    #[test]
1300    fn test_fix_multiple_issues() {
1301        let rule = MD005ListIndent::default();
1302        let content = "\
1303* Item 1
1304 * Wrong 1
1305   * Wrong 2
1306    * Wrong 3
1307  * Correct";
1308        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1309        let fixed = rule.fix(&ctx).unwrap();
1310        assert_eq!(fixed, "* Item 1\n* Wrong 1\n   * Wrong 2\n   * Wrong 3\n* Correct");
1311
1312        // `Wrong 4` sits short of `Correct`'s content column, so it is a sibling
1313        // of `Correct`; moving it to the nested items' column would nest it, so
1314        // the list's fixes are withheld and its warnings stay.
1315        let content = format!("{content}\n   * Wrong 4");
1316        let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1317        assert!(!rule.check(&ctx).unwrap().is_empty());
1318        assert_eq!(rule.fix(&ctx).unwrap(), content);
1319    }
1320
1321    #[test]
1322    fn test_performance_large_document() {
1323        let rule = MD005ListIndent::default();
1324        let mut content = String::new();
1325        for i in 0..100 {
1326            content.push_str(&format!("* Item {i}\n"));
1327            content.push_str(&format!("  * Nested {i}\n"));
1328        }
1329        let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1330        let result = rule.check(&ctx).unwrap();
1331        assert!(result.is_empty());
1332    }
1333
1334    #[test]
1335    fn test_column_positions() {
1336        let rule = MD005ListIndent::default();
1337        let content = " * Wrong indent";
1338        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1339        let result = rule.check(&ctx).unwrap();
1340        assert_eq!(result.len(), 1);
1341        assert_eq!(result[0].column, 1, "Expected column 1, got {}", result[0].column);
1342        assert_eq!(
1343            result[0].end_column, 2,
1344            "Expected end_column 2, got {}",
1345            result[0].end_column
1346        );
1347    }
1348
1349    #[test]
1350    fn test_should_skip() {
1351        let rule = MD005ListIndent::default();
1352
1353        // Empty content should skip
1354        let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard, None);
1355        assert!(rule.should_skip(&ctx));
1356
1357        // Content without lists should skip
1358        let ctx = LintContext::new("Just plain text", crate::config::MarkdownFlavor::Standard, None);
1359        assert!(rule.should_skip(&ctx));
1360
1361        // Content with lists should not skip
1362        let ctx = LintContext::new("* List item", crate::config::MarkdownFlavor::Standard, None);
1363        assert!(!rule.should_skip(&ctx));
1364
1365        let ctx = LintContext::new("1. Ordered list", crate::config::MarkdownFlavor::Standard, None);
1366        assert!(!rule.should_skip(&ctx));
1367    }
1368
1369    #[test]
1370    fn test_should_skip_validation() {
1371        let rule = MD005ListIndent::default();
1372        let content = "* List item";
1373        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1374        assert!(!rule.should_skip(&ctx));
1375
1376        let content = "No lists here";
1377        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1378        assert!(rule.should_skip(&ctx));
1379    }
1380
1381    #[test]
1382    fn test_edge_case_single_space_indent() {
1383        let rule = MD005ListIndent::default();
1384        let content = "\
1385* Item 1
1386 * Single space - wrong
1387  * Two spaces - correct";
1388        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1389        let result = rule.check(&ctx).unwrap();
1390        // Both the single space and two space items get warnings
1391        // because they establish inconsistent indentation at the same level
1392        assert_eq!(result.len(), 2);
1393        assert!(result.iter().any(|w| w.line == 2 && w.message.contains("found 1")));
1394    }
1395
1396    #[test]
1397    fn test_edge_case_three_space_indent() {
1398        let rule = MD005ListIndent::default();
1399        let content = "\
1400* Item 1
1401   * Three spaces - first establishes pattern
1402  * Two spaces - inconsistent with established pattern";
1403        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1404        let result = rule.check(&ctx).unwrap();
1405        // First-established indent (3) is the expected value
1406        // Line 3 with 2 spaces is inconsistent with the pattern
1407        // (Verified with markdownlint-cli: line 3 gets MD005, line 2 gets MD007)
1408        assert_eq!(result.len(), 1);
1409        assert!(result.iter().any(|w| w.line == 3 && w.message.contains("found 2")));
1410    }
1411
1412    #[test]
1413    fn test_nested_bullets_under_numbered_items() {
1414        let rule = MD005ListIndent::default();
1415        let content = "\
14161. **Active Directory/LDAP**
1417   - User authentication and directory services
1418   - LDAP for user information and validation
1419
14202. **Oracle Unified Directory (OUD)**
1421   - Extended user directory services
1422   - Verification of project account presence and changes";
1423        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1424        let result = rule.check(&ctx).unwrap();
1425        // Should have no warnings - 3 spaces is correct for bullets under numbered items
1426        assert!(
1427            result.is_empty(),
1428            "Expected no warnings for bullets with 3 spaces under numbered items, got: {result:?}"
1429        );
1430    }
1431
1432    #[test]
1433    fn test_nested_bullets_under_numbered_items_wrong_indent() {
1434        let rule = MD005ListIndent::default();
1435        let content = "\
14361. **Active Directory/LDAP**
1437  - Wrong: only 2 spaces
1438   - Correct: 3 spaces";
1439        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1440        let result = rule.check(&ctx).unwrap();
1441        // Should flag one of them as inconsistent
1442        assert_eq!(
1443            result.len(),
1444            1,
1445            "Expected 1 warning, got {}. Warnings: {:?}",
1446            result.len(),
1447            result
1448        );
1449        // Either line 2 or line 3 should be flagged for inconsistency
1450        assert!(
1451            result
1452                .iter()
1453                .any(|w| (w.line == 2 && w.message.contains("found 2"))
1454                    || (w.line == 3 && w.message.contains("found 3")))
1455        );
1456    }
1457
1458    #[test]
1459    fn test_regular_nested_bullets_still_work() {
1460        let rule = MD005ListIndent::default();
1461        let content = "\
1462* Top level
1463  * Second level (2 spaces is correct for bullets under bullets)
1464    * Third level (4 spaces)";
1465        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1466        let result = rule.check(&ctx).unwrap();
1467        // Should have no warnings - regular bullet nesting still uses 2-space increments
1468        assert!(
1469            result.is_empty(),
1470            "Expected no warnings for regular bullet nesting, got: {result:?}"
1471        );
1472    }
1473
1474    #[test]
1475    fn test_fix_range_accuracy() {
1476        let rule = MD005ListIndent::default();
1477        let content = " * Wrong indent";
1478        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1479        let result = rule.check(&ctx).unwrap();
1480        assert_eq!(result.len(), 1);
1481
1482        let fix = result[0].fix.as_ref().unwrap();
1483        // Fix should replace the single space with nothing (0 indent for level 1)
1484        assert_eq!(fix.replacement, "");
1485    }
1486
1487    #[test]
1488    fn test_four_space_indent_pattern() {
1489        let rule = MD005ListIndent::default();
1490        let content = "\
1491* Item 1
1492    * Item 2 with 4 spaces
1493        * Item 3 with 8 spaces
1494    * Item 4 with 4 spaces";
1495        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1496        let result = rule.check(&ctx).unwrap();
1497        // MD005 should accept consistent 4-space pattern
1498        assert!(
1499            result.is_empty(),
1500            "MD005 should accept consistent 4-space indentation pattern, got {} warnings",
1501            result.len()
1502        );
1503    }
1504
1505    #[test]
1506    fn test_issue_64_scenario() {
1507        // Test the exact scenario from issue #64
1508        let rule = MD005ListIndent::default();
1509        let content = "\
1510* Top level item
1511    * Sub item with 4 spaces (as configured in MD007)
1512        * Nested sub item with 8 spaces
1513    * Another sub item with 4 spaces
1514* Another top level";
1515
1516        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1517        let result = rule.check(&ctx).unwrap();
1518
1519        // MD005 should accept consistent 4-space pattern
1520        assert!(
1521            result.is_empty(),
1522            "MD005 should accept 4-space indentation when that's the pattern being used. Got {} warnings",
1523            result.len()
1524        );
1525    }
1526
1527    #[test]
1528    fn test_continuation_content_scenario() {
1529        let rule = MD005ListIndent::default();
1530        let content = "\
1531- **Changes to how the Python version is inferred** ([#16319](example))
1532
1533    In previous versions of Ruff, you could specify your Python version with:
1534
1535    - The `target-version` option in a `ruff.toml` file
1536    - The `project.requires-python` field in a `pyproject.toml` file";
1537
1538        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1539
1540        let result = rule.check(&ctx).unwrap();
1541
1542        // Should not flag continuation content lists as inconsistent
1543        assert!(
1544            result.is_empty(),
1545            "MD005 should not flag continuation content lists, got {} warnings: {:?}",
1546            result.len(),
1547            result
1548        );
1549    }
1550
1551    #[test]
1552    fn test_multiple_continuation_lists_scenario() {
1553        let rule = MD005ListIndent::default();
1554        let content = "\
1555- **Changes to how the Python version is inferred** ([#16319](example))
1556
1557    In previous versions of Ruff, you could specify your Python version with:
1558
1559    - The `target-version` option in a `ruff.toml` file
1560    - The `project.requires-python` field in a `pyproject.toml` file
1561
1562    In v0.10, config discovery has been updated to address this issue:
1563
1564    - If Ruff finds a `ruff.toml` file without a `target-version`, it will check
1565    - If Ruff finds a user-level configuration, the `requires-python` field will take precedence
1566    - If there is no config file, Ruff will search for the closest `pyproject.toml`";
1567
1568        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1569
1570        let result = rule.check(&ctx).unwrap();
1571
1572        // Should not flag continuation content lists as inconsistent
1573        assert!(
1574            result.is_empty(),
1575            "MD005 should not flag continuation content lists, got {} warnings: {:?}",
1576            result.len(),
1577            result
1578        );
1579    }
1580
1581    #[test]
1582    fn test_issue_115_sublist_after_code_block() {
1583        let rule = MD005ListIndent::default();
1584        let content = "\
15851. List item 1
1586
1587   ```rust
1588   fn foo() {}
1589   ```
1590
1591   Sublist:
1592
1593   - A
1594   - B
1595";
1596        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1597        let result = rule.check(&ctx).unwrap();
1598        // Sub-list items A and B are continuation content (3-space indent is correct)
1599        // because they appear after continuation content (code block and text) that is
1600        // indented at the parent's content_column (3 spaces)
1601        assert!(
1602            result.is_empty(),
1603            "Expected no warnings for sub-list after code block in list item, got {} warnings: {:?}",
1604            result.len(),
1605            result
1606        );
1607    }
1608
1609    #[test]
1610    fn test_edge_case_continuation_at_exact_boundary() {
1611        let rule = MD005ListIndent::default();
1612        // Text at EXACTLY parent_content_column (not greater than)
1613        let content = "\
1614* Item (content at column 2)
1615  Text at column 2 (exact boundary - continuation)
1616  * Sub at column 2";
1617        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1618        let result = rule.check(&ctx).unwrap();
1619        // The sub-list should be recognized as continuation content
1620        assert!(
1621            result.is_empty(),
1622            "Expected no warnings when text and sub-list are at exact parent content_column, got: {result:?}"
1623        );
1624    }
1625
1626    #[test]
1627    fn test_edge_case_unicode_in_continuation() {
1628        let rule = MD005ListIndent::default();
1629        let content = "\
1630* Parent
1631  Text with emoji 😀 and Unicode ñ characters
1632  * Sub-list should still work";
1633        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1634        let result = rule.check(&ctx).unwrap();
1635        // Unicode shouldn't break continuation detection
1636        assert!(
1637            result.is_empty(),
1638            "Expected no warnings with Unicode in continuation content, got: {result:?}"
1639        );
1640    }
1641
1642    #[test]
1643    fn test_edge_case_large_empty_line_gap() {
1644        let rule = MD005ListIndent::default();
1645        let content = "\
1646* Parent at line 1
1647  Continuation text
1648
1649
1650
1651  More continuation after many empty lines
1652
1653  * Child after gap
1654  * Another child";
1655        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1656        let result = rule.check(&ctx).unwrap();
1657        // Empty lines shouldn't break continuation detection
1658        assert!(
1659            result.is_empty(),
1660            "Expected no warnings with large gaps in continuation content, got: {result:?}"
1661        );
1662    }
1663
1664    #[test]
1665    fn consecutive_blank_continuations_preserve_line_endings_and_quotes() {
1666        let rule = MD005ListIndent::default();
1667        for content in ["* a\n  b\n\n\n\n  c\n", "> * a\n>   b\n>\n>\n>\n>   c\n"] {
1668            for ending in ["\n", "\r\n"] {
1669                let source = content.replace('\n', ending);
1670                let ctx = LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
1671                assert!(rule.check(&ctx).unwrap().is_empty(), "{source:?}");
1672            }
1673        }
1674    }
1675
1676    #[test]
1677    fn test_edge_case_multiple_continuation_blocks_varying_indent() {
1678        let rule = MD005ListIndent::default();
1679        let content = "\
1680* Parent (content at column 2)
1681  First paragraph at column 2
1682    Indented quote at column 4
1683  Back to column 2
1684  * Sub-list at column 2";
1685        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1686        let result = rule.check(&ctx).unwrap();
1687        // Should handle varying indentation in continuation content
1688        assert!(
1689            result.is_empty(),
1690            "Expected no warnings with varying continuation indent, got: {result:?}"
1691        );
1692    }
1693
1694    #[test]
1695    fn test_edge_case_deep_nesting_no_continuation() {
1696        let rule = MD005ListIndent::default();
1697        let content = "\
1698* Parent
1699  * Immediate child (no continuation text before)
1700    * Grandchild
1701      * Great-grandchild
1702        * Great-great-grandchild
1703  * Another child at level 2";
1704        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1705        let result = rule.check(&ctx).unwrap();
1706        // Deep nesting without continuation content should work
1707        assert!(
1708            result.is_empty(),
1709            "Expected no warnings for deep nesting without continuation, got: {result:?}"
1710        );
1711    }
1712
1713    #[test]
1714    fn test_edge_case_blockquote_continuation_content() {
1715        let rule = MD005ListIndent::default();
1716        let content = "\
1717> * Parent in blockquote
1718>   Continuation in blockquote
1719>   * Sub-list in blockquote
1720>   * Another sub-list";
1721        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1722        let result = rule.check(&ctx).unwrap();
1723        // Blockquote continuation should work correctly
1724        assert!(
1725            result.is_empty(),
1726            "Expected no warnings for blockquote continuation, got: {result:?}"
1727        );
1728    }
1729
1730    #[test]
1731    fn test_edge_case_one_space_less_than_content_column() {
1732        let rule = MD005ListIndent::default();
1733        let content = "\
1734* Parent (content at column 2)
1735 Text at column 1 (one less than content_column - NOT continuation)
1736  * Child";
1737        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1738        let result = rule.check(&ctx).unwrap();
1739        // Text at column 1 should NOT be continuation (< parent_content_column)
1740        // This breaks the list context, so child should be treated as top-level
1741        // BUT since there's a parent at column 0, the child at column 2 is actually
1742        // a child of that parent, not continuation content
1743        // The test verifies the behavior is consistent
1744        assert!(
1745            result.is_empty() || !result.is_empty(),
1746            "Test should complete without panic"
1747        );
1748    }
1749
1750    #[test]
1751    fn test_edge_case_multiple_code_blocks_different_indentation() {
1752        let rule = MD005ListIndent::default();
1753        let content = "\
1754* Parent
1755  ```
1756  code at 2 spaces
1757  ```
1758    ```
1759    code at 4 spaces
1760    ```
1761  * Sub-list should not be confused";
1762        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1763        let result = rule.check(&ctx).unwrap();
1764        // Multiple code blocks shouldn't confuse continuation detection
1765        assert!(
1766            result.is_empty(),
1767            "Expected no warnings with multiple code blocks, got: {result:?}"
1768        );
1769    }
1770
1771    #[test]
1772    fn test_performance_very_large_document() {
1773        let rule = MD005ListIndent::default();
1774        let mut content = String::new();
1775
1776        // Create document with 1000 list items with continuation content
1777        for i in 0..1000 {
1778            content.push_str(&format!("* Item {i}\n"));
1779            content.push_str(&format!("  * Nested {i}\n"));
1780            if i % 10 == 0 {
1781                content.push_str("  Some continuation text\n");
1782            }
1783        }
1784
1785        let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1786
1787        // Should complete quickly with O(n) optimization
1788        let start = std::time::Instant::now();
1789        let result = rule.check(&ctx).unwrap();
1790        let elapsed = start.elapsed();
1791
1792        assert!(result.is_empty());
1793        println!("Processed 1000 list items in {elapsed:?}");
1794        // Before optimization (O(n²)): ~seconds
1795        // After optimization (O(n)): ~milliseconds
1796        assert!(
1797            elapsed.as_secs() < 1,
1798            "Should complete in under 1 second, took {elapsed:?}"
1799        );
1800    }
1801
1802    #[test]
1803    fn test_ordered_list_variable_marker_width() {
1804        // Ordered lists with items 1-9 (marker "N. " = 3 chars) and 10+
1805        // (marker "NN. " = 4 chars) should have sublists aligned with parent content.
1806        // Sublists under items 1-9 are at column 3, sublists under 10+ are at column 4.
1807        // This should NOT trigger MD005 warnings.
1808        let rule = MD005ListIndent::default();
1809        let content = "\
18101. One
1811   - One
1812   - Two
18132. Two
1814   - One
18153. Three
1816   - One
18174. Four
1818   - One
18195. Five
1820   - One
18216. Six
1822   - One
18237. Seven
1824   - One
18258. Eight
1826   - One
18279. Nine
1828   - One
182910. Ten
1830    - One";
1831        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1832        let result = rule.check(&ctx).unwrap();
1833        assert!(
1834            result.is_empty(),
1835            "Expected no warnings for ordered list with variable marker widths, got: {result:?}"
1836        );
1837    }
1838
1839    #[test]
1840    fn test_ordered_list_inconsistent_siblings() {
1841        // MD005 checks that siblings (items under the same parent) have consistent indentation
1842        let rule = MD005ListIndent::default();
1843        let content = "\
18441. Item one
1845   - First sublist at 3 spaces
1846  - Second sublist at 2 spaces (inconsistent)
1847   - Third sublist at 3 spaces";
1848        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1849        let result = rule.check(&ctx).unwrap();
1850        // The item at column 2 should be flagged (inconsistent with siblings at column 3)
1851        assert_eq!(
1852            result.len(),
1853            1,
1854            "Expected 1 warning for inconsistent sibling indent, got: {result:?}"
1855        );
1856        assert!(result[0].message.contains("Expected indentation of 3"));
1857    }
1858
1859    #[test]
1860    fn test_ordered_list_single_sublist_no_warning() {
1861        // A single sublist item under a parent should not trigger MD005
1862        // (nothing to compare for consistency)
1863        let rule = MD005ListIndent::default();
1864        let content = "\
186510. Item ten
1866   - Only sublist at 3 spaces";
1867        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1868        let result = rule.check(&ctx).unwrap();
1869        // No warning because there's only one sibling
1870        assert!(
1871            result.is_empty(),
1872            "Expected no warnings for single sublist item, got: {result:?}"
1873        );
1874    }
1875
1876    #[test]
1877    fn test_sublists_grouped_by_parent_content_column() {
1878        // Sublists should be grouped by parent content column.
1879        // Items 9 and 10 have different marker widths (3 vs 4 chars), so their sublists
1880        // are at different column positions. Each group should be checked independently.
1881        let rule = MD005ListIndent::default();
1882        let content = "\
18839. Item nine
1884   - First sublist at 3 spaces
1885   - Second sublist at 3 spaces
1886   - Third sublist at 3 spaces
188710. Item ten
1888    - First sublist at 4 spaces
1889    - Second sublist at 4 spaces
1890    - Third sublist at 4 spaces";
1891        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1892        let result = rule.check(&ctx).unwrap();
1893        // No warnings: sublists under item 9 are at col 3 (consistent within group),
1894        // sublists under item 10 are at col 4 (consistent within their group)
1895        assert!(
1896            result.is_empty(),
1897            "Expected no warnings for sublists grouped by parent, got: {result:?}"
1898        );
1899    }
1900
1901    #[test]
1902    fn test_inconsistent_indent_within_parent_group() {
1903        // Test that inconsistency WITHIN a parent group is still detected
1904        let rule = MD005ListIndent::default();
1905        let content = "\
190610. Item ten
1907    - First sublist at 4 spaces
1908   - Second sublist at 3 spaces (inconsistent!)
1909    - Third sublist at 4 spaces";
1910        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1911        let result = rule.check(&ctx).unwrap();
1912        // The item at 3 spaces should be flagged (inconsistent with siblings at 4 spaces)
1913        assert_eq!(
1914            result.len(),
1915            1,
1916            "Expected 1 warning for inconsistent indent within parent group, got: {result:?}"
1917        );
1918        assert!(result[0].line == 3);
1919        assert!(result[0].message.contains("Expected indentation of 4"));
1920    }
1921
1922    #[test]
1923    fn test_blockquote_nested_list_fix_preserves_blockquote_prefix() {
1924        // Test that MD005 fix preserves blockquote prefix instead of removing it
1925        // This was a bug where ">  * item" would be fixed to "* item" (blockquote removed)
1926        // instead of "> * item" (blockquote preserved)
1927        use crate::rule::Rule;
1928
1929        let rule = MD005ListIndent::default();
1930        let content = ">  * Federation sender blacklists are now persisted.";
1931        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1932        let result = rule.check(&ctx).unwrap();
1933
1934        assert_eq!(result.len(), 1, "Expected 1 warning for extra indent");
1935
1936        // The fix should preserve the blockquote prefix
1937        assert!(result[0].fix.is_some(), "Should have a fix");
1938        let fixed = rule.fix(&ctx).expect("Fix should succeed");
1939
1940        // Verify blockquote prefix is preserved
1941        assert!(
1942            fixed.starts_with("> "),
1943            "Fixed content should start with blockquote prefix '> ', got: {fixed:?}"
1944        );
1945        assert!(
1946            !fixed.starts_with("* "),
1947            "Fixed content should NOT start with just '* ' (blockquote removed), got: {fixed:?}"
1948        );
1949        assert_eq!(
1950            fixed.trim(),
1951            "> * Federation sender blacklists are now persisted.",
1952            "Fixed content should be '> * Federation sender...' with single space after >"
1953        );
1954    }
1955
1956    #[test]
1957    fn test_nested_blockquote_list_fix_preserves_prefix() {
1958        // Test nested blockquotes (>> syntax)
1959        use crate::rule::Rule;
1960
1961        let rule = MD005ListIndent::default();
1962        let content = ">>   * Nested blockquote list item";
1963        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1964        let result = rule.check(&ctx).unwrap();
1965
1966        assert_eq!(result.len(), 1, "Fixture must trigger an indentation warning");
1967        let fixed = rule.fix(&ctx).expect("Fix should succeed");
1968        assert_eq!(fixed, "> > * Nested blockquote list item");
1969        let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1970        assert!(rule.check(&fixed_ctx).unwrap().is_empty());
1971    }
1972}