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