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