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