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