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