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