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 {
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: Vec<String>,
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 LineCacheInfo {
69    /// Build cache from context in one O(n) pass
70    fn new(ctx: &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 mut line_contents = Vec::with_capacity(total_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            // Store line content for blockquote-aware indent calculation
104            line_contents.push(line_content.to_string());
105
106            let mut flag = 0u8;
107            if !content.is_empty() {
108                flag |= FLAG_HAS_CONTENT;
109            }
110            if let Some(list_item) = ctx
111                .list_item_on_line(idx + 1)
112                .filter(|item| !is_inside_literal_code(ctx, item.marker_byte_offset()))
113            {
114                flag |= FLAG_IS_LIST_ITEM;
115
116                let line_num = idx + 1; // Convert to 1-indexed
117                let marker_column = list_item.marker_column();
118
119                // Maintain a monotonic stack of indentation levels (O(1) amortized)
120                while let Some(&(indent, popped_line)) = indent_stack.last() {
121                    if indent < marker_column {
122                        break;
123                    }
124                    item_end.insert(popped_line, line_num);
125                    indent_stack.pop();
126                }
127
128                if let Some((_, parent_line)) = indent_stack.last() {
129                    parent_map.insert(line_num, *parent_line);
130                }
131
132                indent_stack.push((marker_column, line_num));
133            }
134            flags.push(flag);
135        }
136
137        Self {
138            indentation,
139            blockquote_levels,
140            line_contents,
141            flags,
142            parent_map,
143            item_end,
144            decided: RefCell::default(),
145            first_item_at: RefCell::default(),
146        }
147    }
148
149    /// Check if line has content
150    fn has_content(&self, idx: usize) -> bool {
151        self.flags.get(idx).is_some_and(|&f| f & FLAG_HAS_CONTENT != 0)
152    }
153
154    /// Check if line is a list item
155    fn is_list_item(&self, idx: usize) -> bool {
156        self.flags.get(idx).is_some_and(|&f| f & FLAG_IS_LIST_ITEM != 0)
157    }
158
159    /// Get blockquote info for a line (level and prefix length)
160    fn blockquote_info(&self, line: usize) -> (usize, usize) {
161        if line == 0 || line > self.line_contents.len() {
162            return (0, 0);
163        }
164        let idx = line - 1;
165        let bq_level = self.blockquote_levels.get(idx).copied().unwrap_or(0);
166        if bq_level == 0 {
167            return (0, 0);
168        }
169        // Calculate prefix length from line content
170        let content = &self.line_contents[idx];
171        let mut prefix_len = 0;
172        let mut found = 0;
173        for c in content.chars() {
174            prefix_len += c.len_utf8();
175            if c == '>' {
176                found += 1;
177                if found == bq_level {
178                    // Include optional space after last >
179                    if content.get(prefix_len..prefix_len + 1) == Some(" ") {
180                        prefix_len += 1;
181                    }
182                    break;
183                }
184            }
185        }
186        (bq_level, prefix_len)
187    }
188
189    /// The first line in `start_line..=end_line` (1-indexed) that settles
190    /// whether a parent item has continuation content there, with the answer.
191    ///
192    /// For blockquote-aware detection, also pass the parent's blockquote level and
193    /// blockquote prefix length. These are used to calculate effective indentation
194    /// for lines inside blockquotes.
195    ///
196    /// Per CommonMark, tight (lazy) continuation — content with no blank line
197    /// preceding it inside the item — is valid at any indent strictly past the
198    /// marker column. Loose continuation — content following a blank line — must
199    /// be indented to the parent's content column to remain inside the item.
200    /// Callers therefore pass two thresholds: `tight_threshold` for content
201    /// before the first blank line in the range, and `loose_threshold` for
202    /// content after a blank line. Once a blank line is followed by content
203    /// below `loose_threshold` the list item has ended, so no later content in
204    /// the range can be continuation.
205    fn scan_continuation(
206        &self,
207        start_line: usize,
208        end_line: usize,
209        tight_threshold: usize,
210        loose_threshold: usize,
211        parent_bq_level: usize,
212        parent_bq_prefix_len: usize,
213    ) -> Option<Decision> {
214        if start_line == 0 || start_line > end_line || end_line > self.indentation.len() {
215            return None;
216        }
217
218        // For blockquote lists, subtract the blockquote prefix so we compare
219        // against the effective indentation within the blockquote context.
220        let adjust = |t: usize| {
221            if parent_bq_level > 0 {
222                t.saturating_sub(parent_bq_prefix_len)
223            } else {
224                t
225            }
226        };
227        let tight = adjust(tight_threshold);
228        let loose = adjust(loose_threshold);
229
230        let mut seen_blank = false;
231        for idx in start_line - 1..end_line {
232            if !self.has_content(idx) {
233                seen_blank = true;
234                continue;
235            }
236            if self.is_list_item(idx) {
237                continue;
238            }
239
240            // Calculate effective indent (blockquote-aware)
241            let line_bq_level = self.blockquote_levels.get(idx).copied().unwrap_or(0);
242            let raw_indent = self.indentation[idx];
243            let effective_indent = if line_bq_level == parent_bq_level && parent_bq_level > 0 {
244                effective_indent_in_blockquote(&self.line_contents[idx], parent_bq_level, raw_indent)
245            } else {
246                raw_indent
247            };
248
249            let threshold = if seen_blank { loose } else { tight };
250            if effective_indent >= threshold {
251                return Some((idx + 1, Some(effective_indent)));
252            }
253            // After a blank line, content below the loose threshold ends the
254            // list item; nothing further in the range can be continuation.
255            if seen_blank {
256                return Some((idx + 1, None));
257            }
258        }
259        None
260    }
261
262    /// [`Self::scan_continuation`] over everything `parent_line` can own,
263    /// computed once per parent: each of its children asks about a prefix of the
264    /// same range, and the first decisive line answers all of them.
265    fn continuation_decided(
266        &self,
267        parent_line: usize,
268        (tight_threshold, loose_threshold): (usize, usize),
269        (parent_bq_level, parent_bq_prefix_len): (usize, usize),
270    ) -> Option<Decision> {
271        let key = (
272            parent_line,
273            tight_threshold,
274            loose_threshold,
275            parent_bq_level,
276            parent_bq_prefix_len,
277        );
278        if let Some(&decision) = self.decided.borrow().get(&key) {
279            return decision;
280        }
281        let last_owned = self
282            .item_end
283            .get(&parent_line)
284            .map_or(self.indentation.len(), |&end| end - 1);
285        let decision = self.scan_continuation(
286            parent_line + 1,
287            last_owned,
288            tight_threshold,
289            loose_threshold,
290            parent_bq_level,
291            parent_bq_prefix_len,
292        );
293        self.decided.borrow_mut().insert(key, decision);
294        decision
295    }
296
297    /// The indent of the first continuation line between `parent_line` and
298    /// `before_line` (both exclusive, 1-indexed), if the parent has one there.
299    ///
300    /// `before_line` must be a line the parent owns, such as one of its children.
301    fn continuation_before(
302        &self,
303        parent_line: usize,
304        before_line: usize,
305        thresholds: (usize, usize),
306        parent_bq: (usize, usize),
307    ) -> Option<usize> {
308        match self.continuation_decided(parent_line, thresholds, parent_bq) {
309            Some((decided_at, indent)) if decided_at < before_line => indent,
310            _ => None,
311        }
312    }
313
314    /// The first list item at `marker_column` from `from_line` on (1-indexed)
315    /// within what `parent_line` owns.
316    fn first_item_at_column(
317        &self,
318        ctx: &crate::lint_context::LintContext,
319        parent_line: usize,
320        from_line: usize,
321        marker_column: usize,
322    ) -> Option<usize> {
323        let key = (parent_line, from_line, marker_column);
324        if let Some(&line) = self.first_item_at.borrow().get(&key) {
325            return line;
326        }
327        let end = self
328            .item_end
329            .get(&parent_line)
330            .copied()
331            .unwrap_or(self.indentation.len() + 1);
332        let line = (from_line..end).find(|&line_num| {
333            self.is_list_item(line_num - 1)
334                && ctx
335                    .list_item_on_line(line_num)
336                    .is_some_and(|item| item.marker_column() == marker_column)
337        });
338        self.first_item_at.borrow_mut().insert(key, line);
339        line
340    }
341}
342
343impl MD005ListIndent {
344    /// Gap tolerance for grouping list blocks as one logical structure.
345    /// Markdown allows blank lines within lists, so we need some tolerance.
346    /// 2 lines handles: 1 blank line + potential interruption
347    const LIST_GROUP_GAP_TOLERANCE: usize = 2;
348
349    /// Minimum indentation increase to be considered a child (not same level).
350    /// Per Markdown convention, nested items need at least 2 more spaces.
351    const MIN_CHILD_INDENT_INCREASE: usize = 2;
352
353    /// Tolerance for considering items at "same level" despite minor indent differences.
354    /// Allows for 1 space difference to accommodate inconsistent formatting.
355    const SAME_LEVEL_TOLERANCE: i32 = 1;
356
357    /// Standard continuation list indentation offset from parent content column.
358    /// Lists that are continuation content typically indent 2 spaces from parent content.
359    const STANDARD_CONTINUATION_OFFSET: usize = 2;
360
361    /// Creates a warning for an indent mismatch.
362    fn create_indent_warning(
363        &self,
364        ctx: &crate::lint_context::LintContext,
365        line_num: usize,
366        line_info: &crate::lint_context::LineInfo,
367        actual_indent: usize,
368        expected_indent: usize,
369    ) -> LintWarning {
370        let message = format!(
371            "Expected indentation of {} {}, found {}",
372            expected_indent,
373            if expected_indent == 1 { "space" } else { "spaces" },
374            actual_indent
375        );
376
377        let (start_line, start_col, end_line, end_col) = if actual_indent > 0 {
378            calculate_match_range(line_num, line_info.content(ctx.content), 0, actual_indent)
379        } else {
380            calculate_match_range(line_num, line_info.content(ctx.content), 0, 1)
381        };
382
383        // For blockquote-nested lists, we need to preserve the blockquote prefix
384        // Similar to how MD007 handles this case
385        let (fix_range, replacement) = if line_info.blockquote.is_some() {
386            // Calculate the range from start of line to the list marker position
387            let start_byte = line_info.byte_offset;
388            let mut end_byte = line_info.byte_offset;
389
390            // Get the list marker position from list_item
391            let marker_column = ctx
392                .list_item_on_line(line_num)
393                .map_or(actual_indent, ParsedListItem::marker_column);
394
395            // Calculate where the marker starts
396            for (i, ch) in line_info.content(ctx.content).chars().enumerate() {
397                if i >= marker_column {
398                    break;
399                }
400                end_byte += ch.len_utf8();
401            }
402
403            // Build the blockquote prefix
404            let mut blockquote_count = 0;
405            for ch in line_info.content(ctx.content).chars() {
406                if ch == '>' {
407                    blockquote_count += 1;
408                } else if ch != ' ' && ch != '\t' {
409                    break;
410                }
411            }
412
413            // Build the blockquote prefix (one '>' per level, with spaces between for nested)
414            let blockquote_prefix = if blockquote_count > 1 {
415                (0..blockquote_count)
416                    .map(|_| "> ")
417                    .collect::<String>()
418                    .trim_end()
419                    .to_string()
420            } else {
421                ">".to_string()
422            };
423
424            // Build replacement with blockquote prefix + correct indentation
425            let correct_indent = " ".repeat(expected_indent);
426            let replacement = format!("{blockquote_prefix} {correct_indent}");
427
428            (start_byte..end_byte, replacement)
429        } else {
430            // Non-blockquote case: original logic
431            let fix_range = if actual_indent > 0 {
432                let start_byte = ctx.line_offsets.get(line_num - 1).copied().unwrap_or(0);
433                let end_byte = start_byte + actual_indent;
434                start_byte..end_byte
435            } else {
436                let byte_pos = ctx.line_offsets.get(line_num - 1).copied().unwrap_or(0);
437                byte_pos..byte_pos
438            };
439
440            let replacement = if expected_indent > 0 {
441                " ".repeat(expected_indent)
442            } else {
443                String::new()
444            };
445
446            (fix_range, replacement)
447        };
448
449        LintWarning {
450            rule_name: Some(self.name().to_string()),
451            line: start_line,
452            column: start_col,
453            end_line,
454            end_column: end_col,
455            message,
456            severity: Severity::Warning,
457            fix: Some(Fix::new(fix_range, replacement)),
458        }
459    }
460
461    /// Checks consistency within a group of items and emits warnings.
462    /// Uses first-established indent as the expected value when inconsistencies are found.
463    fn check_indent_consistency(
464        &self,
465        ctx: &crate::lint_context::LintContext,
466        items: &[(usize, usize, &crate::lint_context::LineInfo)],
467        warnings: &mut Vec<(LintWarning, isize)>,
468    ) {
469        if items.len() < 2 {
470            return;
471        }
472
473        // Sort items by line number to find first-established pattern
474        let mut sorted_items: Vec<_> = items.iter().collect();
475        sorted_items.sort_by_key(|(line_num, _, _)| *line_num);
476
477        let indents: std::collections::HashSet<usize> = sorted_items.iter().map(|(_, indent, _)| *indent).collect();
478
479        if indents.len() > 1 {
480            // Items have inconsistent indentation
481            // Use the first established indent as the expected value
482            let expected_indent = sorted_items.first().map_or(0, |(_, i, _)| *i);
483
484            for (line_num, indent, line_info) in items {
485                if *indent != expected_indent {
486                    warnings.push((
487                        self.create_indent_warning(ctx, *line_num, line_info, *indent, expected_indent),
488                        expected_indent as isize - *indent as isize,
489                    ));
490                }
491            }
492        }
493    }
494
495    /// Groups items by their semantic parent's content column AND list type.
496    ///
497    /// By grouping by (parent_content_column, is_ordered), we enforce consistency
498    /// within each list type separately. This prevents oscillation with MD007, which
499    /// only adjusts unordered list indentation and may expect different values than
500    /// what ordered lists use. (fixes #287)
501    fn group_by_parent_content_column<'a>(
502        &self,
503        level: usize,
504        group: &[(usize, usize, &'a crate::lint_context::LineInfo)],
505        all_list_items: &[(usize, usize, &crate::lint_context::LineInfo, ParsedListItem<'_>)],
506        level_map: &HashMap<usize, usize>,
507    ) -> ParentContentGroups<'a> {
508        let parent_level = level - 1;
509
510        // Build line->is_ordered map for O(1) lookup
511        let is_ordered_map: HashMap<usize, bool> = all_list_items
512            .iter()
513            .map(|(ln, _, _, item)| (*ln, item.is_ordered()))
514            .collect();
515
516        // Collect parent-level items sorted by line number for binary search
517        let parent_items: Vec<(usize, usize)> = all_list_items
518            .iter()
519            .filter(|(ln, _, _, _)| level_map.get(ln) == Some(&parent_level))
520            .map(|(ln, _, _, item)| (*ln, item.content_column()))
521            .collect();
522
523        let mut parent_content_groups: ParentContentGroups<'a> = HashMap::new();
524
525        for (line_num, indent, line_info) in group {
526            let item_is_ordered = is_ordered_map.get(line_num).copied().unwrap_or(false);
527
528            // Find the most recent parent-level item before this line using binary search
529            let idx = parent_items.partition_point(|&(ln, _)| ln < *line_num);
530            let parent_content_col = if idx > 0 { Some(parent_items[idx - 1].1) } else { None };
531
532            if let Some(parent_col) = parent_content_col {
533                parent_content_groups
534                    .entry((parent_col, item_is_ordered))
535                    .or_default()
536                    .push((*line_num, *indent, *line_info));
537            }
538        }
539
540        parent_content_groups
541    }
542
543    /// Group related list blocks that should be treated as one logical list structure
544    fn group_related_list_blocks<'a>(&self, list_blocks: ParsedListBlocks<'a>) -> Vec<Vec<ParsedListBlock<'a>>> {
545        let mut blocks = list_blocks.into_iter();
546        let Some(first_block) = blocks.next() else {
547            return Vec::new();
548        };
549
550        let mut groups = Vec::new();
551        let mut current_group = vec![first_block];
552        let mut prev_block = first_block;
553
554        for current_block in blocks {
555            // Check if blocks are consecutive (no significant gap between them)
556            let line_gap = current_block.start_line().saturating_sub(prev_block.end_line());
557
558            // Group blocks if they are close together
559            // This handles cases where mixed list types are split but should be treated together.
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 && 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 name(&self) -> &'static str {
905        "MD005"
906    }
907
908    fn description(&self) -> &'static str {
909        "List indentation should be consistent"
910    }
911
912    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
913        let mut warnings = self.check_optimized(ctx);
914        protect_literal_code(ctx, &mut warnings);
915        drop_structure_changing_fixes(ctx, &mut warnings, Allowed::Nothing);
916        Ok(warnings)
917    }
918
919    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
920        let warnings = self.check(ctx)?;
921        let warnings =
922            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
923        if warnings.is_empty() {
924            return Ok(ctx.content.to_string());
925        }
926
927        crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings).map_err(LintError::InvalidInput)
928    }
929
930    fn category(&self) -> RuleCategory {
931        RuleCategory::List
932    }
933
934    /// Check if this rule should be skipped
935    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
936        // Skip if content is empty or has no list items
937        ctx.content.is_empty() || !ctx.has_list_items()
938    }
939
940    fn as_any(&self) -> &dyn std::any::Any {
941        self
942    }
943
944    fn default_config_section(&self) -> Option<(String, toml::Value)> {
945        None
946    }
947
948    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
949    where
950        Self: Sized,
951    {
952        // Check MD007 configuration to understand expected list indentation
953        let mut top_level_indent = 0;
954
955        // Try to get MD007 configuration for top-level indentation
956        if let Some(md007_config) = config.rules.get("MD007") {
957            // Check for start_indented setting
958            if let Some(start_indented) = md007_config.values.get("start-indented")
959                && let Some(start_indented_bool) = start_indented.as_bool()
960                && start_indented_bool
961            {
962                // If start_indented is true, check for start_indent value
963                if let Some(start_indent) = md007_config.values.get("start-indent") {
964                    if let Some(indent_value) = start_indent.as_integer() {
965                        top_level_indent = indent_value as usize;
966                    }
967                } else {
968                    // Default start_indent when start_indented is true
969                    top_level_indent = 2;
970                }
971            }
972        }
973
974        Box::new(MD005ListIndent { top_level_indent })
975    }
976}
977
978#[cfg(test)]
979mod tests {
980    use super::*;
981    use crate::lint_context::LintContext;
982
983    #[test]
984    fn test_valid_unordered_list() {
985        let rule = MD005ListIndent::default();
986        let content = "\
987* Item 1
988* Item 2
989  * Nested 1
990  * Nested 2
991* Item 3";
992        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
993        let result = rule.check(&ctx).unwrap();
994        assert!(result.is_empty());
995    }
996
997    #[test]
998    fn test_valid_ordered_list() {
999        let rule = MD005ListIndent::default();
1000        let content = "\
10011. Item 1
10022. Item 2
1003   1. Nested 1
1004   2. Nested 2
10053. Item 3";
1006        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1007        let result = rule.check(&ctx).unwrap();
1008        // With dynamic alignment, nested items should align with parent's text content
1009        // Ordered items starting with "1. " have text at column 3, so nested items need 3 spaces
1010        assert!(result.is_empty());
1011    }
1012
1013    #[test]
1014    fn test_invalid_unordered_indent() {
1015        let rule = MD005ListIndent::default();
1016        let content = "\
1017* Item 1
1018 * Item 2
1019   * Nested 1";
1020        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1021        let result = rule.check(&ctx).unwrap();
1022        // With dynamic alignment, line 3 correctly aligns with line 2's text position
1023        // Only line 2 is incorrectly indented
1024        assert_eq!(result.len(), 1);
1025        let fixed = rule.fix(&ctx).unwrap();
1026        assert_eq!(fixed, "* Item 1\n* Item 2\n   * Nested 1");
1027    }
1028
1029    #[test]
1030    fn test_invalid_ordered_indent() {
1031        let rule = MD005ListIndent::default();
1032        let content = "\
10331. Item 1
1034 2. Item 2
1035    1. Nested 1";
1036        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1037        let result = rule.check(&ctx).unwrap();
1038        assert_eq!(result.len(), 1);
1039        let fixed = rule.fix(&ctx).unwrap();
1040        // With dynamic alignment, ordered items align with parent's text content
1041        // Line 1 text starts at col 3, so line 2 should have 3 spaces
1042        // Line 3 already correctly aligns with line 2's text position
1043        assert_eq!(fixed, "1. Item 1\n2. Item 2\n    1. Nested 1");
1044    }
1045
1046    #[test]
1047    fn test_mixed_list_types() {
1048        let rule = MD005ListIndent::default();
1049        let content = "\
1050* Item 1
1051  1. Nested ordered
1052  * Nested unordered
1053* Item 2";
1054        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1055        let result = rule.check(&ctx).unwrap();
1056        assert!(result.is_empty());
1057    }
1058
1059    #[test]
1060    fn test_multiple_levels() {
1061        let rule = MD005ListIndent::default();
1062        let content = "\
1063* Level 1
1064   * Level 2
1065      * Level 3";
1066        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1067        let result = rule.check(&ctx).unwrap();
1068        // MD005 should now accept consistent 3-space increments
1069        assert!(result.is_empty(), "MD005 should accept consistent indentation pattern");
1070    }
1071
1072    #[test]
1073    fn test_empty_lines() {
1074        let rule = MD005ListIndent::default();
1075        let content = "\
1076* Item 1
1077
1078  * Nested 1
1079
1080* Item 2";
1081        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1082        let result = rule.check(&ctx).unwrap();
1083        assert!(result.is_empty());
1084    }
1085
1086    #[test]
1087    fn test_no_lists() {
1088        let rule = MD005ListIndent::default();
1089        let content = "\
1090Just some text
1091More text
1092Even more text";
1093        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1094        let result = rule.check(&ctx).unwrap();
1095        assert!(result.is_empty());
1096    }
1097
1098    #[test]
1099    fn test_complex_nesting() {
1100        let rule = MD005ListIndent::default();
1101        let content = "\
1102* Level 1
1103  * Level 2
1104    * Level 3
1105  * Back to 2
1106    1. Ordered 3
1107    2. Still 3
1108* Back to 1";
1109        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1110        let result = rule.check(&ctx).unwrap();
1111        assert!(result.is_empty());
1112    }
1113
1114    #[test]
1115    fn test_invalid_complex_nesting() {
1116        let rule = MD005ListIndent::default();
1117        let content = "\
1118* Level 1
1119   * Level 2
1120     * Level 3
1121   * Back to 2
1122      1. Ordered 3
1123     2. Still 3
1124* Back to 1";
1125        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1126        let result = rule.check(&ctx).unwrap();
1127        // Lines 5-6 have inconsistent indentation (6 vs 5 spaces) for the same level
1128        assert_eq!(result.len(), 1);
1129        assert!(
1130            result[0].message.contains("Expected indentation of 5 spaces, found 6")
1131                || result[0].message.contains("Expected indentation of 6 spaces, found 5")
1132        );
1133    }
1134
1135    #[test]
1136    fn test_with_lint_context() {
1137        let rule = MD005ListIndent::default();
1138
1139        // Test with consistent list indentation
1140        let content = "* Item 1\n* Item 2\n  * Nested item\n  * Another nested item";
1141        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1142        let result = rule.check(&ctx).unwrap();
1143        assert!(result.is_empty());
1144
1145        // Test with inconsistent list indentation
1146        let content = "* Item 1\n* Item 2\n * Nested item\n  * Another nested item";
1147        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1148        let result = rule.check(&ctx).unwrap();
1149        assert!(!result.is_empty()); // Should have at least one warning
1150
1151        // Test with different level indentation issues
1152        let content = "* Item 1\n  * Nested item\n * Another nested item with wrong indent";
1153        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1154        let result = rule.check(&ctx).unwrap();
1155        assert!(!result.is_empty()); // Should have at least one warning
1156    }
1157
1158    // Additional comprehensive tests
1159    #[test]
1160    fn test_list_with_continuations() {
1161        let rule = MD005ListIndent::default();
1162        let content = "\
1163* Item 1
1164  This is a continuation
1165  of the first item
1166  * Nested item
1167    with its own continuation
1168* Item 2";
1169        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1170        let result = rule.check(&ctx).unwrap();
1171        assert!(result.is_empty());
1172    }
1173
1174    #[test]
1175    fn test_list_after_a_quoted_list_is_judged_on_its_own() {
1176        // The quoted list is a separate list, so its indentation says nothing
1177        // about the one below it, where `item 52` is nested under `item 50`.
1178        let rule = MD005ListIndent::default();
1179        let content = ">   * item 49\n + item 50\n   +  item 52\n";
1180        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1181        let lines: Vec<usize> = rule.check(&ctx).unwrap().iter().map(|w| w.line).collect();
1182        assert_eq!(lines, vec![1, 2]);
1183        let fixed = rule.fix(&ctx).unwrap();
1184        assert_eq!(fixed, "> * item 49\n+ item 50\n   +  item 52\n");
1185        let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1186        assert!(rule.check(&fixed_ctx).unwrap().is_empty());
1187    }
1188
1189    #[test]
1190    fn test_list_in_blockquote() {
1191        let rule = MD005ListIndent::default();
1192        let content = "\
1193> * Item 1
1194>   * Nested 1
1195>   * Nested 2
1196> * Item 2";
1197        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1198        let result = rule.check(&ctx).unwrap();
1199
1200        // Blockquoted lists should have correct indentation within the blockquote context
1201        assert!(
1202            result.is_empty(),
1203            "Expected no warnings for correctly indented blockquote list, got: {result:?}"
1204        );
1205    }
1206
1207    #[test]
1208    fn test_list_with_code_blocks() {
1209        let rule = MD005ListIndent::default();
1210        let content = "\
1211* Item 1
1212  ```
1213  code block
1214  ```
1215  * Nested item
1216* Item 2";
1217        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1218        let result = rule.check(&ctx).unwrap();
1219        assert!(result.is_empty());
1220    }
1221
1222    #[test]
1223    fn test_list_with_tabs() {
1224        let rule = MD005ListIndent::default();
1225        // Tab at line start = 4 spaces = indented code per CommonMark, NOT a nested list
1226        // MD010 catches hard tabs, MD005 checks nested list indent consistency
1227        // This test now uses actual nested lists with mixed indentation
1228        let content = "* Item 1\n   * Wrong indent (3 spaces)\n  * Correct indent (2 spaces)";
1229        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1230        let result = rule.check(&ctx).unwrap();
1231        // Should detect inconsistent indentation (3 spaces vs 2 spaces)
1232        assert!(!result.is_empty());
1233    }
1234
1235    #[test]
1236    fn test_inconsistent_at_same_level() {
1237        let rule = MD005ListIndent::default();
1238        let content = "\
1239* Item 1
1240  * Nested 1
1241  * Nested 2
1242   * Wrong indent for same level
1243  * Nested 3";
1244        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1245        let result = rule.check(&ctx).unwrap();
1246        assert!(!result.is_empty());
1247        // Should flag the inconsistent item
1248        assert!(result.iter().any(|w| w.line == 4));
1249    }
1250
1251    #[test]
1252    fn test_zero_indent_top_level() {
1253        let rule = MD005ListIndent::default();
1254        // Use concat to preserve the leading space
1255        let content = concat!(" * Wrong indent\n", "* Correct\n", "  * Nested");
1256        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1257        let result = rule.check(&ctx).unwrap();
1258
1259        // Should flag the indented top-level item
1260        assert!(!result.is_empty());
1261        assert!(result.iter().any(|w| w.line == 1));
1262    }
1263
1264    #[test]
1265    fn test_fix_preserves_content() {
1266        let rule = MD005ListIndent::default();
1267        let content = "\
1268* Item with **bold** and *italic*
1269 * Wrong indent with `code`
1270   * Also wrong with [link](url)";
1271        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1272        let fixed = rule.fix(&ctx).unwrap();
1273        assert!(fixed.contains("**bold**"));
1274        assert!(fixed.contains("*italic*"));
1275        assert!(fixed.contains("`code`"));
1276        assert!(fixed.contains("[link](url)"));
1277    }
1278
1279    #[test]
1280    fn test_deeply_nested_lists() {
1281        let rule = MD005ListIndent::default();
1282        let content = "\
1283* L1
1284  * L2
1285    * L3
1286      * L4
1287        * L5
1288          * L6";
1289        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1290        let result = rule.check(&ctx).unwrap();
1291        assert!(result.is_empty());
1292    }
1293
1294    #[test]
1295    fn test_fix_multiple_issues() {
1296        let rule = MD005ListIndent::default();
1297        let content = "\
1298* Item 1
1299 * Wrong 1
1300   * Wrong 2
1301    * Wrong 3
1302  * Correct";
1303        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1304        let fixed = rule.fix(&ctx).unwrap();
1305        assert_eq!(fixed, "* Item 1\n* Wrong 1\n   * Wrong 2\n   * Wrong 3\n* Correct");
1306
1307        // `Wrong 4` sits short of `Correct`'s content column, so it is a sibling
1308        // of `Correct`; moving it to the nested items' column would nest it, so
1309        // the list's fixes are withheld and its warnings stay.
1310        let content = format!("{content}\n   * Wrong 4");
1311        let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1312        assert!(!rule.check(&ctx).unwrap().is_empty());
1313        assert_eq!(rule.fix(&ctx).unwrap(), content);
1314    }
1315
1316    #[test]
1317    fn test_performance_large_document() {
1318        let rule = MD005ListIndent::default();
1319        let mut content = String::new();
1320        for i in 0..100 {
1321            content.push_str(&format!("* Item {i}\n"));
1322            content.push_str(&format!("  * Nested {i}\n"));
1323        }
1324        let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1325        let result = rule.check(&ctx).unwrap();
1326        assert!(result.is_empty());
1327    }
1328
1329    #[test]
1330    fn test_column_positions() {
1331        let rule = MD005ListIndent::default();
1332        let content = " * Wrong indent";
1333        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1334        let result = rule.check(&ctx).unwrap();
1335        assert_eq!(result.len(), 1);
1336        assert_eq!(result[0].column, 1, "Expected column 1, got {}", result[0].column);
1337        assert_eq!(
1338            result[0].end_column, 2,
1339            "Expected end_column 2, got {}",
1340            result[0].end_column
1341        );
1342    }
1343
1344    #[test]
1345    fn test_should_skip() {
1346        let rule = MD005ListIndent::default();
1347
1348        // Empty content should skip
1349        let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard, None);
1350        assert!(rule.should_skip(&ctx));
1351
1352        // Content without lists should skip
1353        let ctx = LintContext::new("Just plain text", crate::config::MarkdownFlavor::Standard, None);
1354        assert!(rule.should_skip(&ctx));
1355
1356        // Content with lists should not skip
1357        let ctx = LintContext::new("* List item", crate::config::MarkdownFlavor::Standard, None);
1358        assert!(!rule.should_skip(&ctx));
1359
1360        let ctx = LintContext::new("1. Ordered list", crate::config::MarkdownFlavor::Standard, None);
1361        assert!(!rule.should_skip(&ctx));
1362    }
1363
1364    #[test]
1365    fn test_should_skip_validation() {
1366        let rule = MD005ListIndent::default();
1367        let content = "* List item";
1368        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1369        assert!(!rule.should_skip(&ctx));
1370
1371        let content = "No lists here";
1372        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1373        assert!(rule.should_skip(&ctx));
1374    }
1375
1376    #[test]
1377    fn test_edge_case_single_space_indent() {
1378        let rule = MD005ListIndent::default();
1379        let content = "\
1380* Item 1
1381 * Single space - wrong
1382  * Two spaces - correct";
1383        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1384        let result = rule.check(&ctx).unwrap();
1385        // Both the single space and two space items get warnings
1386        // because they establish inconsistent indentation at the same level
1387        assert_eq!(result.len(), 2);
1388        assert!(result.iter().any(|w| w.line == 2 && w.message.contains("found 1")));
1389    }
1390
1391    #[test]
1392    fn test_edge_case_three_space_indent() {
1393        let rule = MD005ListIndent::default();
1394        let content = "\
1395* Item 1
1396   * Three spaces - first establishes pattern
1397  * Two spaces - inconsistent with established pattern";
1398        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1399        let result = rule.check(&ctx).unwrap();
1400        // First-established indent (3) is the expected value
1401        // Line 3 with 2 spaces is inconsistent with the pattern
1402        // (Verified with markdownlint-cli: line 3 gets MD005, line 2 gets MD007)
1403        assert_eq!(result.len(), 1);
1404        assert!(result.iter().any(|w| w.line == 3 && w.message.contains("found 2")));
1405    }
1406
1407    #[test]
1408    fn test_nested_bullets_under_numbered_items() {
1409        let rule = MD005ListIndent::default();
1410        let content = "\
14111. **Active Directory/LDAP**
1412   - User authentication and directory services
1413   - LDAP for user information and validation
1414
14152. **Oracle Unified Directory (OUD)**
1416   - Extended user directory services
1417   - Verification of project account presence and changes";
1418        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1419        let result = rule.check(&ctx).unwrap();
1420        // Should have no warnings - 3 spaces is correct for bullets under numbered items
1421        assert!(
1422            result.is_empty(),
1423            "Expected no warnings for bullets with 3 spaces under numbered items, got: {result:?}"
1424        );
1425    }
1426
1427    #[test]
1428    fn test_nested_bullets_under_numbered_items_wrong_indent() {
1429        let rule = MD005ListIndent::default();
1430        let content = "\
14311. **Active Directory/LDAP**
1432  - Wrong: only 2 spaces
1433   - Correct: 3 spaces";
1434        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1435        let result = rule.check(&ctx).unwrap();
1436        // Should flag one of them as inconsistent
1437        assert_eq!(
1438            result.len(),
1439            1,
1440            "Expected 1 warning, got {}. Warnings: {:?}",
1441            result.len(),
1442            result
1443        );
1444        // Either line 2 or line 3 should be flagged for inconsistency
1445        assert!(
1446            result
1447                .iter()
1448                .any(|w| (w.line == 2 && w.message.contains("found 2"))
1449                    || (w.line == 3 && w.message.contains("found 3")))
1450        );
1451    }
1452
1453    #[test]
1454    fn test_regular_nested_bullets_still_work() {
1455        let rule = MD005ListIndent::default();
1456        let content = "\
1457* Top level
1458  * Second level (2 spaces is correct for bullets under bullets)
1459    * Third level (4 spaces)";
1460        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1461        let result = rule.check(&ctx).unwrap();
1462        // Should have no warnings - regular bullet nesting still uses 2-space increments
1463        assert!(
1464            result.is_empty(),
1465            "Expected no warnings for regular bullet nesting, got: {result:?}"
1466        );
1467    }
1468
1469    #[test]
1470    fn test_fix_range_accuracy() {
1471        let rule = MD005ListIndent::default();
1472        let content = " * Wrong indent";
1473        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1474        let result = rule.check(&ctx).unwrap();
1475        assert_eq!(result.len(), 1);
1476
1477        let fix = result[0].fix.as_ref().unwrap();
1478        // Fix should replace the single space with nothing (0 indent for level 1)
1479        assert_eq!(fix.replacement, "");
1480    }
1481
1482    #[test]
1483    fn test_four_space_indent_pattern() {
1484        let rule = MD005ListIndent::default();
1485        let content = "\
1486* Item 1
1487    * Item 2 with 4 spaces
1488        * Item 3 with 8 spaces
1489    * Item 4 with 4 spaces";
1490        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1491        let result = rule.check(&ctx).unwrap();
1492        // MD005 should accept consistent 4-space pattern
1493        assert!(
1494            result.is_empty(),
1495            "MD005 should accept consistent 4-space indentation pattern, got {} warnings",
1496            result.len()
1497        );
1498    }
1499
1500    #[test]
1501    fn test_issue_64_scenario() {
1502        // Test the exact scenario from issue #64
1503        let rule = MD005ListIndent::default();
1504        let content = "\
1505* Top level item
1506    * Sub item with 4 spaces (as configured in MD007)
1507        * Nested sub item with 8 spaces
1508    * Another sub item with 4 spaces
1509* Another top level";
1510
1511        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1512        let result = rule.check(&ctx).unwrap();
1513
1514        // MD005 should accept consistent 4-space pattern
1515        assert!(
1516            result.is_empty(),
1517            "MD005 should accept 4-space indentation when that's the pattern being used. Got {} warnings",
1518            result.len()
1519        );
1520    }
1521
1522    #[test]
1523    fn test_continuation_content_scenario() {
1524        let rule = MD005ListIndent::default();
1525        let content = "\
1526- **Changes to how the Python version is inferred** ([#16319](example))
1527
1528    In previous versions of Ruff, you could specify your Python version with:
1529
1530    - The `target-version` option in a `ruff.toml` file
1531    - The `project.requires-python` field in a `pyproject.toml` file";
1532
1533        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1534
1535        let result = rule.check(&ctx).unwrap();
1536
1537        // Should not flag continuation content lists as inconsistent
1538        assert!(
1539            result.is_empty(),
1540            "MD005 should not flag continuation content lists, got {} warnings: {:?}",
1541            result.len(),
1542            result
1543        );
1544    }
1545
1546    #[test]
1547    fn test_multiple_continuation_lists_scenario() {
1548        let rule = MD005ListIndent::default();
1549        let content = "\
1550- **Changes to how the Python version is inferred** ([#16319](example))
1551
1552    In previous versions of Ruff, you could specify your Python version with:
1553
1554    - The `target-version` option in a `ruff.toml` file
1555    - The `project.requires-python` field in a `pyproject.toml` file
1556
1557    In v0.10, config discovery has been updated to address this issue:
1558
1559    - If Ruff finds a `ruff.toml` file without a `target-version`, it will check
1560    - If Ruff finds a user-level configuration, the `requires-python` field will take precedence
1561    - If there is no config file, Ruff will search for the closest `pyproject.toml`";
1562
1563        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1564
1565        let result = rule.check(&ctx).unwrap();
1566
1567        // Should not flag continuation content lists as inconsistent
1568        assert!(
1569            result.is_empty(),
1570            "MD005 should not flag continuation content lists, got {} warnings: {:?}",
1571            result.len(),
1572            result
1573        );
1574    }
1575
1576    #[test]
1577    fn test_issue_115_sublist_after_code_block() {
1578        let rule = MD005ListIndent::default();
1579        let content = "\
15801. List item 1
1581
1582   ```rust
1583   fn foo() {}
1584   ```
1585
1586   Sublist:
1587
1588   - A
1589   - B
1590";
1591        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1592        let result = rule.check(&ctx).unwrap();
1593        // Sub-list items A and B are continuation content (3-space indent is correct)
1594        // because they appear after continuation content (code block and text) that is
1595        // indented at the parent's content_column (3 spaces)
1596        assert!(
1597            result.is_empty(),
1598            "Expected no warnings for sub-list after code block in list item, got {} warnings: {:?}",
1599            result.len(),
1600            result
1601        );
1602    }
1603
1604    #[test]
1605    fn test_edge_case_continuation_at_exact_boundary() {
1606        let rule = MD005ListIndent::default();
1607        // Text at EXACTLY parent_content_column (not greater than)
1608        let content = "\
1609* Item (content at column 2)
1610  Text at column 2 (exact boundary - continuation)
1611  * Sub at column 2";
1612        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1613        let result = rule.check(&ctx).unwrap();
1614        // The sub-list should be recognized as continuation content
1615        assert!(
1616            result.is_empty(),
1617            "Expected no warnings when text and sub-list are at exact parent content_column, got: {result:?}"
1618        );
1619    }
1620
1621    #[test]
1622    fn test_edge_case_unicode_in_continuation() {
1623        let rule = MD005ListIndent::default();
1624        let content = "\
1625* Parent
1626  Text with emoji 😀 and Unicode ñ characters
1627  * Sub-list should still work";
1628        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1629        let result = rule.check(&ctx).unwrap();
1630        // Unicode shouldn't break continuation detection
1631        assert!(
1632            result.is_empty(),
1633            "Expected no warnings with Unicode in continuation content, got: {result:?}"
1634        );
1635    }
1636
1637    #[test]
1638    fn test_edge_case_large_empty_line_gap() {
1639        let rule = MD005ListIndent::default();
1640        let content = "\
1641* Parent at line 1
1642  Continuation text
1643
1644
1645
1646  More continuation after many empty lines
1647
1648  * Child after gap
1649  * Another child";
1650        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1651        let result = rule.check(&ctx).unwrap();
1652        // Empty lines shouldn't break continuation detection
1653        assert!(
1654            result.is_empty(),
1655            "Expected no warnings with large gaps in continuation content, got: {result:?}"
1656        );
1657    }
1658
1659    #[test]
1660    fn test_edge_case_multiple_continuation_blocks_varying_indent() {
1661        let rule = MD005ListIndent::default();
1662        let content = "\
1663* Parent (content at column 2)
1664  First paragraph at column 2
1665    Indented quote at column 4
1666  Back to column 2
1667  * Sub-list at column 2";
1668        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1669        let result = rule.check(&ctx).unwrap();
1670        // Should handle varying indentation in continuation content
1671        assert!(
1672            result.is_empty(),
1673            "Expected no warnings with varying continuation indent, got: {result:?}"
1674        );
1675    }
1676
1677    #[test]
1678    fn test_edge_case_deep_nesting_no_continuation() {
1679        let rule = MD005ListIndent::default();
1680        let content = "\
1681* Parent
1682  * Immediate child (no continuation text before)
1683    * Grandchild
1684      * Great-grandchild
1685        * Great-great-grandchild
1686  * Another child at level 2";
1687        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1688        let result = rule.check(&ctx).unwrap();
1689        // Deep nesting without continuation content should work
1690        assert!(
1691            result.is_empty(),
1692            "Expected no warnings for deep nesting without continuation, got: {result:?}"
1693        );
1694    }
1695
1696    #[test]
1697    fn test_edge_case_blockquote_continuation_content() {
1698        let rule = MD005ListIndent::default();
1699        let content = "\
1700> * Parent in blockquote
1701>   Continuation in blockquote
1702>   * Sub-list in blockquote
1703>   * Another sub-list";
1704        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1705        let result = rule.check(&ctx).unwrap();
1706        // Blockquote continuation should work correctly
1707        assert!(
1708            result.is_empty(),
1709            "Expected no warnings for blockquote continuation, got: {result:?}"
1710        );
1711    }
1712
1713    #[test]
1714    fn test_edge_case_one_space_less_than_content_column() {
1715        let rule = MD005ListIndent::default();
1716        let content = "\
1717* Parent (content at column 2)
1718 Text at column 1 (one less than content_column - NOT continuation)
1719  * Child";
1720        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1721        let result = rule.check(&ctx).unwrap();
1722        // Text at column 1 should NOT be continuation (< parent_content_column)
1723        // This breaks the list context, so child should be treated as top-level
1724        // BUT since there's a parent at column 0, the child at column 2 is actually
1725        // a child of that parent, not continuation content
1726        // The test verifies the behavior is consistent
1727        assert!(
1728            result.is_empty() || !result.is_empty(),
1729            "Test should complete without panic"
1730        );
1731    }
1732
1733    #[test]
1734    fn test_edge_case_multiple_code_blocks_different_indentation() {
1735        let rule = MD005ListIndent::default();
1736        let content = "\
1737* Parent
1738  ```
1739  code at 2 spaces
1740  ```
1741    ```
1742    code at 4 spaces
1743    ```
1744  * Sub-list should not be confused";
1745        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1746        let result = rule.check(&ctx).unwrap();
1747        // Multiple code blocks shouldn't confuse continuation detection
1748        assert!(
1749            result.is_empty(),
1750            "Expected no warnings with multiple code blocks, got: {result:?}"
1751        );
1752    }
1753
1754    #[test]
1755    fn test_performance_very_large_document() {
1756        let rule = MD005ListIndent::default();
1757        let mut content = String::new();
1758
1759        // Create document with 1000 list items with continuation content
1760        for i in 0..1000 {
1761            content.push_str(&format!("* Item {i}\n"));
1762            content.push_str(&format!("  * Nested {i}\n"));
1763            if i % 10 == 0 {
1764                content.push_str("  Some continuation text\n");
1765            }
1766        }
1767
1768        let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1769
1770        // Should complete quickly with O(n) optimization
1771        let start = std::time::Instant::now();
1772        let result = rule.check(&ctx).unwrap();
1773        let elapsed = start.elapsed();
1774
1775        assert!(result.is_empty());
1776        println!("Processed 1000 list items in {elapsed:?}");
1777        // Before optimization (O(n²)): ~seconds
1778        // After optimization (O(n)): ~milliseconds
1779        assert!(
1780            elapsed.as_secs() < 1,
1781            "Should complete in under 1 second, took {elapsed:?}"
1782        );
1783    }
1784
1785    #[test]
1786    fn test_ordered_list_variable_marker_width() {
1787        // Ordered lists with items 1-9 (marker "N. " = 3 chars) and 10+
1788        // (marker "NN. " = 4 chars) should have sublists aligned with parent content.
1789        // Sublists under items 1-9 are at column 3, sublists under 10+ are at column 4.
1790        // This should NOT trigger MD005 warnings.
1791        let rule = MD005ListIndent::default();
1792        let content = "\
17931. One
1794   - One
1795   - Two
17962. Two
1797   - One
17983. Three
1799   - One
18004. Four
1801   - One
18025. Five
1803   - One
18046. Six
1805   - One
18067. Seven
1807   - One
18088. Eight
1809   - One
18109. Nine
1811   - One
181210. Ten
1813    - One";
1814        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1815        let result = rule.check(&ctx).unwrap();
1816        assert!(
1817            result.is_empty(),
1818            "Expected no warnings for ordered list with variable marker widths, got: {result:?}"
1819        );
1820    }
1821
1822    #[test]
1823    fn test_ordered_list_inconsistent_siblings() {
1824        // MD005 checks that siblings (items under the same parent) have consistent indentation
1825        let rule = MD005ListIndent::default();
1826        let content = "\
18271. Item one
1828   - First sublist at 3 spaces
1829  - Second sublist at 2 spaces (inconsistent)
1830   - Third sublist at 3 spaces";
1831        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1832        let result = rule.check(&ctx).unwrap();
1833        // The item at column 2 should be flagged (inconsistent with siblings at column 3)
1834        assert_eq!(
1835            result.len(),
1836            1,
1837            "Expected 1 warning for inconsistent sibling indent, got: {result:?}"
1838        );
1839        assert!(result[0].message.contains("Expected indentation of 3"));
1840    }
1841
1842    #[test]
1843    fn test_ordered_list_single_sublist_no_warning() {
1844        // A single sublist item under a parent should not trigger MD005
1845        // (nothing to compare for consistency)
1846        let rule = MD005ListIndent::default();
1847        let content = "\
184810. Item ten
1849   - Only sublist at 3 spaces";
1850        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1851        let result = rule.check(&ctx).unwrap();
1852        // No warning because there's only one sibling
1853        assert!(
1854            result.is_empty(),
1855            "Expected no warnings for single sublist item, got: {result:?}"
1856        );
1857    }
1858
1859    #[test]
1860    fn test_sublists_grouped_by_parent_content_column() {
1861        // Sublists should be grouped by parent content column.
1862        // Items 9 and 10 have different marker widths (3 vs 4 chars), so their sublists
1863        // are at different column positions. Each group should be checked independently.
1864        let rule = MD005ListIndent::default();
1865        let content = "\
18669. Item nine
1867   - First sublist at 3 spaces
1868   - Second sublist at 3 spaces
1869   - Third sublist at 3 spaces
187010. Item ten
1871    - First sublist at 4 spaces
1872    - Second sublist at 4 spaces
1873    - Third sublist at 4 spaces";
1874        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1875        let result = rule.check(&ctx).unwrap();
1876        // No warnings: sublists under item 9 are at col 3 (consistent within group),
1877        // sublists under item 10 are at col 4 (consistent within their group)
1878        assert!(
1879            result.is_empty(),
1880            "Expected no warnings for sublists grouped by parent, got: {result:?}"
1881        );
1882    }
1883
1884    #[test]
1885    fn test_inconsistent_indent_within_parent_group() {
1886        // Test that inconsistency WITHIN a parent group is still detected
1887        let rule = MD005ListIndent::default();
1888        let content = "\
188910. Item ten
1890    - First sublist at 4 spaces
1891   - Second sublist at 3 spaces (inconsistent!)
1892    - Third sublist at 4 spaces";
1893        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1894        let result = rule.check(&ctx).unwrap();
1895        // The item at 3 spaces should be flagged (inconsistent with siblings at 4 spaces)
1896        assert_eq!(
1897            result.len(),
1898            1,
1899            "Expected 1 warning for inconsistent indent within parent group, got: {result:?}"
1900        );
1901        assert!(result[0].line == 3);
1902        assert!(result[0].message.contains("Expected indentation of 4"));
1903    }
1904
1905    #[test]
1906    fn test_blockquote_nested_list_fix_preserves_blockquote_prefix() {
1907        // Test that MD005 fix preserves blockquote prefix instead of removing it
1908        // This was a bug where ">  * item" would be fixed to "* item" (blockquote removed)
1909        // instead of "> * item" (blockquote preserved)
1910        use crate::rule::Rule;
1911
1912        let rule = MD005ListIndent::default();
1913        let content = ">  * Federation sender blacklists are now persisted.";
1914        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1915        let result = rule.check(&ctx).unwrap();
1916
1917        assert_eq!(result.len(), 1, "Expected 1 warning for extra indent");
1918
1919        // The fix should preserve the blockquote prefix
1920        assert!(result[0].fix.is_some(), "Should have a fix");
1921        let fixed = rule.fix(&ctx).expect("Fix should succeed");
1922
1923        // Verify blockquote prefix is preserved
1924        assert!(
1925            fixed.starts_with("> "),
1926            "Fixed content should start with blockquote prefix '> ', got: {fixed:?}"
1927        );
1928        assert!(
1929            !fixed.starts_with("* "),
1930            "Fixed content should NOT start with just '* ' (blockquote removed), got: {fixed:?}"
1931        );
1932        assert_eq!(
1933            fixed.trim(),
1934            "> * Federation sender blacklists are now persisted.",
1935            "Fixed content should be '> * Federation sender...' with single space after >"
1936        );
1937    }
1938
1939    #[test]
1940    fn test_nested_blockquote_list_fix_preserves_prefix() {
1941        // Test nested blockquotes (>> syntax)
1942        use crate::rule::Rule;
1943
1944        let rule = MD005ListIndent::default();
1945        let content = ">>   * Nested blockquote list item";
1946        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1947        let result = rule.check(&ctx).unwrap();
1948
1949        assert_eq!(result.len(), 1, "Fixture must trigger an indentation warning");
1950        let fixed = rule.fix(&ctx).expect("Fix should succeed");
1951        assert_eq!(fixed, "> > * Nested blockquote list item");
1952        let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1953        assert!(rule.check(&fixed_ctx).unwrap().is_empty());
1954    }
1955}