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