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