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        // Collect all list items from all blocks in the group
453        let mut all_list_items = Vec::new();
454
455        for list_block in group {
456            for &item_line in &list_block.item_lines {
457                if let Some(line_info) = ctx.line_info(item_line)
458                    && let Some(list_item) = &line_info.list_item
459                {
460                    // Calculate the effective indentation (considering blockquotes)
461                    let effective_indent = if let Some(blockquote) = &line_info.blockquote {
462                        // For blockquoted lists, use relative indentation within the blockquote
463                        list_item.marker_column.saturating_sub(blockquote.nesting_level * 2)
464                    } else {
465                        // For normal lists, use the marker column directly
466                        list_item.marker_column
467                    };
468
469                    // Skip list items that are continuation content
470                    if self.is_continuation_content(ctx, &cache, item_line, effective_indent) {
471                        continue;
472                    }
473
474                    all_list_items.push((item_line, effective_indent, line_info, list_item));
475                }
476            }
477        }
478
479        if all_list_items.is_empty() {
480            return Ok(());
481        }
482
483        // Sort by line number to process in order
484        all_list_items.sort_by_key(|(line_num, _, _, _)| *line_num);
485
486        // Build level mapping based on hierarchical structure
487        // Key insight: We need to identify which items are meant to be at the same level
488        // even if they have slightly different indentations (inconsistent formatting)
489        let mut level_map: HashMap<usize, usize> = HashMap::new();
490        let mut level_indents: HashMap<usize, Vec<usize>> = HashMap::new(); // Track all indents seen at each level
491
492        // Track the most recent item at each indent level for O(1) parent lookups
493        // Key: indent value, Value: (level, line_num)
494        let mut indent_to_level: HashMap<usize, (usize, usize)> = HashMap::new();
495
496        // Process items in order to build the level hierarchy - now O(n) instead of O(n²)
497        for (line_num, indent, _, _) in &all_list_items {
498            let level = if indent_to_level.is_empty() {
499                // First item establishes level 1
500                level_indents.entry(1).or_default().push(*indent);
501                1
502            } else {
503                // Find the appropriate level for this item
504                let mut determined_level = 0;
505
506                // First, check if this indent matches any existing level exactly
507                if let Some(&(existing_level, _)) = indent_to_level.get(indent) {
508                    determined_level = existing_level;
509                } else {
510                    // No exact match - determine level based on hierarchy
511                    // Find the most recent item with clearly less indentation (parent)
512                    // Instead of scanning backward O(n), look through tracked indents O(k) where k is number of unique indents
513                    let mut best_parent: Option<(usize, usize, usize)> = None; // (indent, level, line)
514
515                    for (&tracked_indent, &(tracked_level, tracked_line)) in &indent_to_level {
516                        if tracked_indent < *indent {
517                            // This is a potential parent (less indentation)
518                            // Keep the one with the largest indent (closest parent)
519                            if best_parent.is_none() || tracked_indent > best_parent.unwrap().0 {
520                                best_parent = Some((tracked_indent, tracked_level, tracked_line));
521                            }
522                        }
523                    }
524
525                    if let Some((parent_indent, parent_level, _parent_line)) = best_parent {
526                        // A clear parent has at least MIN_CHILD_INDENT_INCREASE spaces less indentation
527                        if parent_indent + Self::MIN_CHILD_INDENT_INCREASE <= *indent {
528                            // This is a child of the parent
529                            determined_level = parent_level + 1;
530                        } else if (*indent as i32 - parent_indent as i32).abs() <= Self::SAME_LEVEL_TOLERANCE {
531                            // Within SAME_LEVEL_TOLERANCE - likely meant to be same level but inconsistent
532                            determined_level = parent_level;
533                        } else {
534                            // Less than 2 space difference but more than 1
535                            // This is ambiguous - could be same level or child
536                            // Check if any existing level has a similar indent
537                            let mut found_similar = false;
538                            if let Some(indents_at_level) = level_indents.get(&parent_level) {
539                                for &level_indent in indents_at_level {
540                                    if (level_indent as i32 - *indent as i32).abs() <= Self::SAME_LEVEL_TOLERANCE {
541                                        determined_level = parent_level;
542                                        found_similar = true;
543                                        break;
544                                    }
545                                }
546                            }
547                            if !found_similar {
548                                // Treat as child since it has more indent
549                                determined_level = parent_level + 1;
550                            }
551                        }
552                    }
553
554                    // If still not determined, default to level 1
555                    if determined_level == 0 {
556                        determined_level = 1;
557                    }
558
559                    // Record this indent for the level
560                    level_indents.entry(determined_level).or_default().push(*indent);
561                }
562
563                determined_level
564            };
565
566            level_map.insert(*line_num, level);
567            // Track this indent and level for future O(1) lookups
568            indent_to_level.insert(*indent, (level, *line_num));
569        }
570
571        // Now group items by their level
572        let mut level_groups: HashMap<usize, Vec<(usize, usize, &crate::lint_context::LineInfo)>> = HashMap::new();
573        for (line_num, indent, line_info, _) in &all_list_items {
574            let level = level_map[line_num];
575            level_groups
576                .entry(level)
577                .or_default()
578                .push((*line_num, *indent, *line_info));
579        }
580
581        // For each level, check consistency
582        for (level, mut group) in level_groups {
583            group.sort_by_key(|(line_num, _, _)| *line_num);
584
585            if level == 1 {
586                // Top-level items should have the configured indentation
587                for (line_num, indent, line_info) in &group {
588                    if *indent != self.top_level_indent {
589                        warnings.push(self.create_indent_warning(
590                            ctx,
591                            *line_num,
592                            line_info,
593                            *indent,
594                            self.top_level_indent,
595                        ));
596                    }
597                }
598            } else {
599                // For sublists (level > 1), group items by their semantic parent's content column.
600                // This handles ordered lists where marker widths vary (e.g., "1. " vs "10. ").
601                let parent_content_groups =
602                    self.group_by_parent_content_column(level, &group, &all_list_items, &level_map);
603
604                // Check consistency within each parent content column group
605                for items in parent_content_groups.values() {
606                    self.check_indent_consistency(ctx, items, warnings);
607                }
608            }
609        }
610
611        Ok(())
612    }
613
614    /// Migrated to use centralized list blocks for better performance and accuracy
615    fn check_optimized(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
616        let content = ctx.content;
617
618        // Early returns for common cases
619        if content.is_empty() {
620            return Ok(Vec::new());
621        }
622
623        // Quick check for any list blocks before processing
624        if ctx.list_blocks.is_empty() {
625            return Ok(Vec::new());
626        }
627
628        let mut warnings = Vec::new();
629
630        // Group consecutive list blocks that should be treated as one logical structure
631        // This is needed because mixed list types (ordered/unordered) get split into separate blocks
632        let block_groups = self.group_related_list_blocks(&ctx.list_blocks);
633
634        for group in block_groups {
635            self.check_list_block_group(ctx, &group, &mut warnings)?;
636        }
637
638        Ok(warnings)
639    }
640}
641
642impl Rule for MD005ListIndent {
643    fn name(&self) -> &'static str {
644        "MD005"
645    }
646
647    fn description(&self) -> &'static str {
648        "List indentation should be consistent"
649    }
650
651    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
652        // Use optimized version
653        self.check_optimized(ctx)
654    }
655
656    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
657        let warnings = self.check(ctx)?;
658        if warnings.is_empty() {
659            return Ok(ctx.content.to_string());
660        }
661
662        // Sort warnings by position (descending) to apply from end to start
663        let mut warnings_with_fixes: Vec<_> = warnings
664            .into_iter()
665            .filter_map(|w| w.fix.clone().map(|fix| (w, fix)))
666            .collect();
667        warnings_with_fixes.sort_by_key(|(_, fix)| std::cmp::Reverse(fix.range.start));
668
669        // Apply fixes to content
670        let mut content = ctx.content.to_string();
671        for (_, fix) in warnings_with_fixes {
672            if fix.range.start <= content.len() && fix.range.end <= content.len() {
673                content.replace_range(fix.range, &fix.replacement);
674            }
675        }
676
677        Ok(content)
678    }
679
680    fn category(&self) -> RuleCategory {
681        RuleCategory::List
682    }
683
684    /// Check if this rule should be skipped
685    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
686        // Skip if content is empty or has no list items
687        ctx.content.is_empty() || !ctx.lines.iter().any(|line| line.list_item.is_some())
688    }
689
690    fn as_any(&self) -> &dyn std::any::Any {
691        self
692    }
693
694    fn default_config_section(&self) -> Option<(String, toml::Value)> {
695        None
696    }
697
698    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
699    where
700        Self: Sized,
701    {
702        // Check MD007 configuration to understand expected list indentation
703        let mut top_level_indent = 0;
704
705        // Try to get MD007 configuration for top-level indentation
706        if let Some(md007_config) = config.rules.get("MD007") {
707            // Check for start_indented setting
708            if let Some(start_indented) = md007_config.values.get("start-indented")
709                && let Some(start_indented_bool) = start_indented.as_bool()
710                && start_indented_bool
711            {
712                // If start_indented is true, check for start_indent value
713                if let Some(start_indent) = md007_config.values.get("start-indent") {
714                    if let Some(indent_value) = start_indent.as_integer() {
715                        top_level_indent = indent_value as usize;
716                    }
717                } else {
718                    // Default start_indent when start_indented is true
719                    top_level_indent = 2;
720                }
721            }
722        }
723
724        Box::new(MD005ListIndent { top_level_indent })
725    }
726}
727
728#[cfg(test)]
729mod tests {
730    use super::*;
731    use crate::lint_context::LintContext;
732
733    #[test]
734    fn test_valid_unordered_list() {
735        let rule = MD005ListIndent::default();
736        let content = "\
737* Item 1
738* Item 2
739  * Nested 1
740  * Nested 2
741* Item 3";
742        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
743        let result = rule.check(&ctx).unwrap();
744        assert!(result.is_empty());
745    }
746
747    #[test]
748    fn test_valid_ordered_list() {
749        let rule = MD005ListIndent::default();
750        let content = "\
7511. Item 1
7522. Item 2
753   1. Nested 1
754   2. Nested 2
7553. Item 3";
756        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
757        let result = rule.check(&ctx).unwrap();
758        // With dynamic alignment, nested items should align with parent's text content
759        // Ordered items starting with "1. " have text at column 3, so nested items need 3 spaces
760        assert!(result.is_empty());
761    }
762
763    #[test]
764    fn test_invalid_unordered_indent() {
765        let rule = MD005ListIndent::default();
766        let content = "\
767* Item 1
768 * Item 2
769   * Nested 1";
770        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
771        let result = rule.check(&ctx).unwrap();
772        // With dynamic alignment, line 3 correctly aligns with line 2's text position
773        // Only line 2 is incorrectly indented
774        assert_eq!(result.len(), 1);
775        let fixed = rule.fix(&ctx).unwrap();
776        assert_eq!(fixed, "* Item 1\n* Item 2\n   * Nested 1");
777    }
778
779    #[test]
780    fn test_invalid_ordered_indent() {
781        let rule = MD005ListIndent::default();
782        let content = "\
7831. Item 1
784 2. Item 2
785    1. Nested 1";
786        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
787        let result = rule.check(&ctx).unwrap();
788        assert_eq!(result.len(), 1);
789        let fixed = rule.fix(&ctx).unwrap();
790        // With dynamic alignment, ordered items align with parent's text content
791        // Line 1 text starts at col 3, so line 2 should have 3 spaces
792        // Line 3 already correctly aligns with line 2's text position
793        assert_eq!(fixed, "1. Item 1\n2. Item 2\n    1. Nested 1");
794    }
795
796    #[test]
797    fn test_mixed_list_types() {
798        let rule = MD005ListIndent::default();
799        let content = "\
800* Item 1
801  1. Nested ordered
802  * Nested unordered
803* Item 2";
804        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
805        let result = rule.check(&ctx).unwrap();
806        assert!(result.is_empty());
807    }
808
809    #[test]
810    fn test_multiple_levels() {
811        let rule = MD005ListIndent::default();
812        let content = "\
813* Level 1
814   * Level 2
815      * Level 3";
816        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
817        let result = rule.check(&ctx).unwrap();
818        // MD005 should now accept consistent 3-space increments
819        assert!(result.is_empty(), "MD005 should accept consistent indentation pattern");
820    }
821
822    #[test]
823    fn test_empty_lines() {
824        let rule = MD005ListIndent::default();
825        let content = "\
826* Item 1
827
828  * Nested 1
829
830* Item 2";
831        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
832        let result = rule.check(&ctx).unwrap();
833        assert!(result.is_empty());
834    }
835
836    #[test]
837    fn test_no_lists() {
838        let rule = MD005ListIndent::default();
839        let content = "\
840Just some text
841More text
842Even more text";
843        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
844        let result = rule.check(&ctx).unwrap();
845        assert!(result.is_empty());
846    }
847
848    #[test]
849    fn test_complex_nesting() {
850        let rule = MD005ListIndent::default();
851        let content = "\
852* Level 1
853  * Level 2
854    * Level 3
855  * Back to 2
856    1. Ordered 3
857    2. Still 3
858* Back to 1";
859        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
860        let result = rule.check(&ctx).unwrap();
861        assert!(result.is_empty());
862    }
863
864    #[test]
865    fn test_invalid_complex_nesting() {
866        let rule = MD005ListIndent::default();
867        let content = "\
868* Level 1
869   * Level 2
870     * Level 3
871   * Back to 2
872      1. Ordered 3
873     2. Still 3
874* Back to 1";
875        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
876        let result = rule.check(&ctx).unwrap();
877        // Lines 5-6 have inconsistent indentation (6 vs 5 spaces) for the same level
878        assert_eq!(result.len(), 1);
879        assert!(
880            result[0].message.contains("Expected indentation of 5 spaces, found 6")
881                || result[0].message.contains("Expected indentation of 6 spaces, found 5")
882        );
883    }
884
885    #[test]
886    fn test_with_lint_context() {
887        let rule = MD005ListIndent::default();
888
889        // Test with consistent list indentation
890        let content = "* Item 1\n* Item 2\n  * Nested item\n  * Another nested item";
891        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
892        let result = rule.check(&ctx).unwrap();
893        assert!(result.is_empty());
894
895        // Test with inconsistent list indentation
896        let content = "* Item 1\n* Item 2\n * Nested item\n  * Another nested item";
897        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
898        let result = rule.check(&ctx).unwrap();
899        assert!(!result.is_empty()); // Should have at least one warning
900
901        // Test with different level indentation issues
902        let content = "* Item 1\n  * Nested item\n * Another nested item with wrong indent";
903        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
904        let result = rule.check(&ctx).unwrap();
905        assert!(!result.is_empty()); // Should have at least one warning
906    }
907
908    // Additional comprehensive tests
909    #[test]
910    fn test_list_with_continuations() {
911        let rule = MD005ListIndent::default();
912        let content = "\
913* Item 1
914  This is a continuation
915  of the first item
916  * Nested item
917    with its own continuation
918* Item 2";
919        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
920        let result = rule.check(&ctx).unwrap();
921        assert!(result.is_empty());
922    }
923
924    #[test]
925    fn test_list_in_blockquote() {
926        let rule = MD005ListIndent::default();
927        let content = "\
928> * Item 1
929>   * Nested 1
930>   * Nested 2
931> * Item 2";
932        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
933        let result = rule.check(&ctx).unwrap();
934
935        // Blockquoted lists should have correct indentation within the blockquote context
936        assert!(
937            result.is_empty(),
938            "Expected no warnings for correctly indented blockquote list, got: {result:?}"
939        );
940    }
941
942    #[test]
943    fn test_list_with_code_blocks() {
944        let rule = MD005ListIndent::default();
945        let content = "\
946* Item 1
947  ```
948  code block
949  ```
950  * Nested item
951* Item 2";
952        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
953        let result = rule.check(&ctx).unwrap();
954        assert!(result.is_empty());
955    }
956
957    #[test]
958    fn test_list_with_tabs() {
959        let rule = MD005ListIndent::default();
960        let content = "* Item 1\n\t* Tab indented\n  * Space indented";
961        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
962        let result = rule.check(&ctx).unwrap();
963        // Should detect inconsistent indentation
964        assert!(!result.is_empty());
965    }
966
967    #[test]
968    fn test_inconsistent_at_same_level() {
969        let rule = MD005ListIndent::default();
970        let content = "\
971* Item 1
972  * Nested 1
973  * Nested 2
974   * Wrong indent for same level
975  * Nested 3";
976        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
977        let result = rule.check(&ctx).unwrap();
978        assert!(!result.is_empty());
979        // Should flag the inconsistent item
980        assert!(result.iter().any(|w| w.line == 4));
981    }
982
983    #[test]
984    fn test_zero_indent_top_level() {
985        let rule = MD005ListIndent::default();
986        // Use concat to preserve the leading space
987        let content = concat!(" * Wrong indent\n", "* Correct\n", "  * Nested");
988        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
989        let result = rule.check(&ctx).unwrap();
990
991        // Should flag the indented top-level item
992        assert!(!result.is_empty());
993        assert!(result.iter().any(|w| w.line == 1));
994    }
995
996    #[test]
997    fn test_fix_preserves_content() {
998        let rule = MD005ListIndent::default();
999        let content = "\
1000* Item with **bold** and *italic*
1001 * Wrong indent with `code`
1002   * Also wrong with [link](url)";
1003        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1004        let fixed = rule.fix(&ctx).unwrap();
1005        assert!(fixed.contains("**bold**"));
1006        assert!(fixed.contains("*italic*"));
1007        assert!(fixed.contains("`code`"));
1008        assert!(fixed.contains("[link](url)"));
1009    }
1010
1011    #[test]
1012    fn test_deeply_nested_lists() {
1013        let rule = MD005ListIndent::default();
1014        let content = "\
1015* L1
1016  * L2
1017    * L3
1018      * L4
1019        * L5
1020          * L6";
1021        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1022        let result = rule.check(&ctx).unwrap();
1023        assert!(result.is_empty());
1024    }
1025
1026    #[test]
1027    fn test_fix_multiple_issues() {
1028        let rule = MD005ListIndent::default();
1029        let content = "\
1030* Item 1
1031 * Wrong 1
1032   * Wrong 2
1033    * Wrong 3
1034  * Correct
1035   * Wrong 4";
1036        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1037        let fixed = rule.fix(&ctx).unwrap();
1038        // Should fix to consistent indentation
1039        let lines: Vec<&str> = fixed.lines().collect();
1040        assert_eq!(lines[0], "* Item 1");
1041        // All level 2 items should have same indent
1042        assert!(lines[1].starts_with("  * ") || lines[1].starts_with("* "));
1043    }
1044
1045    #[test]
1046    fn test_performance_large_document() {
1047        let rule = MD005ListIndent::default();
1048        let mut content = String::new();
1049        for i in 0..100 {
1050            content.push_str(&format!("* Item {i}\n"));
1051            content.push_str(&format!("  * Nested {i}\n"));
1052        }
1053        let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard);
1054        let result = rule.check(&ctx).unwrap();
1055        assert!(result.is_empty());
1056    }
1057
1058    #[test]
1059    fn test_column_positions() {
1060        let rule = MD005ListIndent::default();
1061        let content = " * Wrong indent";
1062        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1063        let result = rule.check(&ctx).unwrap();
1064        assert_eq!(result.len(), 1);
1065        assert_eq!(result[0].column, 1, "Expected column 1, got {}", result[0].column);
1066        assert_eq!(
1067            result[0].end_column, 2,
1068            "Expected end_column 2, got {}",
1069            result[0].end_column
1070        );
1071    }
1072
1073    #[test]
1074    fn test_should_skip() {
1075        let rule = MD005ListIndent::default();
1076
1077        // Empty content should skip
1078        let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard);
1079        assert!(rule.should_skip(&ctx));
1080
1081        // Content without lists should skip
1082        let ctx = LintContext::new("Just plain text", crate::config::MarkdownFlavor::Standard);
1083        assert!(rule.should_skip(&ctx));
1084
1085        // Content with lists should not skip
1086        let ctx = LintContext::new("* List item", crate::config::MarkdownFlavor::Standard);
1087        assert!(!rule.should_skip(&ctx));
1088
1089        let ctx = LintContext::new("1. Ordered list", crate::config::MarkdownFlavor::Standard);
1090        assert!(!rule.should_skip(&ctx));
1091    }
1092
1093    #[test]
1094    fn test_should_skip_validation() {
1095        let rule = MD005ListIndent::default();
1096        let content = "* List item";
1097        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1098        assert!(!rule.should_skip(&ctx));
1099
1100        let content = "No lists here";
1101        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1102        assert!(rule.should_skip(&ctx));
1103    }
1104
1105    #[test]
1106    fn test_edge_case_single_space_indent() {
1107        let rule = MD005ListIndent::default();
1108        let content = "\
1109* Item 1
1110 * Single space - wrong
1111  * Two spaces - correct";
1112        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1113        let result = rule.check(&ctx).unwrap();
1114        // Both the single space and two space items get warnings
1115        // because they establish inconsistent indentation at the same level
1116        assert_eq!(result.len(), 2);
1117        assert!(result.iter().any(|w| w.line == 2 && w.message.contains("found 1")));
1118    }
1119
1120    #[test]
1121    fn test_edge_case_three_space_indent() {
1122        let rule = MD005ListIndent::default();
1123        let content = "\
1124* Item 1
1125   * Three spaces - first establishes pattern
1126  * Two spaces - inconsistent with established pattern";
1127        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1128        let result = rule.check(&ctx).unwrap();
1129        // First-established indent (3) is the expected value
1130        // Line 3 with 2 spaces is inconsistent with the pattern
1131        // (Verified with markdownlint-cli: line 3 gets MD005, line 2 gets MD007)
1132        assert_eq!(result.len(), 1);
1133        assert!(result.iter().any(|w| w.line == 3 && w.message.contains("found 2")));
1134    }
1135
1136    #[test]
1137    fn test_nested_bullets_under_numbered_items() {
1138        let rule = MD005ListIndent::default();
1139        let content = "\
11401. **Active Directory/LDAP**
1141   - User authentication and directory services
1142   - LDAP for user information and validation
1143
11442. **Oracle Unified Directory (OUD)**
1145   - Extended user directory services
1146   - Verification of project account presence and changes";
1147        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1148        let result = rule.check(&ctx).unwrap();
1149        // Should have no warnings - 3 spaces is correct for bullets under numbered items
1150        assert!(
1151            result.is_empty(),
1152            "Expected no warnings for bullets with 3 spaces under numbered items, got: {result:?}"
1153        );
1154    }
1155
1156    #[test]
1157    fn test_nested_bullets_under_numbered_items_wrong_indent() {
1158        let rule = MD005ListIndent::default();
1159        let content = "\
11601. **Active Directory/LDAP**
1161  - Wrong: only 2 spaces
1162   - Correct: 3 spaces";
1163        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1164        let result = rule.check(&ctx).unwrap();
1165        // Should flag one of them as inconsistent
1166        assert_eq!(
1167            result.len(),
1168            1,
1169            "Expected 1 warning, got {}. Warnings: {:?}",
1170            result.len(),
1171            result
1172        );
1173        // Either line 2 or line 3 should be flagged for inconsistency
1174        assert!(
1175            result
1176                .iter()
1177                .any(|w| (w.line == 2 && w.message.contains("found 2"))
1178                    || (w.line == 3 && w.message.contains("found 3")))
1179        );
1180    }
1181
1182    #[test]
1183    fn test_regular_nested_bullets_still_work() {
1184        let rule = MD005ListIndent::default();
1185        let content = "\
1186* Top level
1187  * Second level (2 spaces is correct for bullets under bullets)
1188    * Third level (4 spaces)";
1189        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1190        let result = rule.check(&ctx).unwrap();
1191        // Should have no warnings - regular bullet nesting still uses 2-space increments
1192        assert!(
1193            result.is_empty(),
1194            "Expected no warnings for regular bullet nesting, got: {result:?}"
1195        );
1196    }
1197
1198    #[test]
1199    fn test_fix_range_accuracy() {
1200        let rule = MD005ListIndent::default();
1201        let content = " * Wrong indent";
1202        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1203        let result = rule.check(&ctx).unwrap();
1204        assert_eq!(result.len(), 1);
1205
1206        let fix = result[0].fix.as_ref().unwrap();
1207        // Fix should replace the single space with nothing (0 indent for level 1)
1208        assert_eq!(fix.replacement, "");
1209    }
1210
1211    #[test]
1212    fn test_four_space_indent_pattern() {
1213        let rule = MD005ListIndent::default();
1214        let content = "\
1215* Item 1
1216    * Item 2 with 4 spaces
1217        * Item 3 with 8 spaces
1218    * Item 4 with 4 spaces";
1219        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1220        let result = rule.check(&ctx).unwrap();
1221        // MD005 should accept consistent 4-space pattern
1222        assert!(
1223            result.is_empty(),
1224            "MD005 should accept consistent 4-space indentation pattern, got {} warnings",
1225            result.len()
1226        );
1227    }
1228
1229    #[test]
1230    fn test_issue_64_scenario() {
1231        // Test the exact scenario from issue #64
1232        let rule = MD005ListIndent::default();
1233        let content = "\
1234* Top level item
1235    * Sub item with 4 spaces (as configured in MD007)
1236        * Nested sub item with 8 spaces
1237    * Another sub item with 4 spaces
1238* Another top level";
1239
1240        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1241        let result = rule.check(&ctx).unwrap();
1242
1243        // MD005 should accept consistent 4-space pattern
1244        assert!(
1245            result.is_empty(),
1246            "MD005 should accept 4-space indentation when that's the pattern being used. Got {} warnings",
1247            result.len()
1248        );
1249    }
1250
1251    #[test]
1252    fn test_continuation_content_scenario() {
1253        let rule = MD005ListIndent::default();
1254        let content = "\
1255- **Changes to how the Python version is inferred** ([#16319](example))
1256
1257    In previous versions of Ruff, you could specify your Python version with:
1258
1259    - The `target-version` option in a `ruff.toml` file
1260    - The `project.requires-python` field in a `pyproject.toml` file";
1261
1262        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1263
1264        let result = rule.check(&ctx).unwrap();
1265
1266        // Should not flag continuation content lists as inconsistent
1267        assert!(
1268            result.is_empty(),
1269            "MD005 should not flag continuation content lists, got {} warnings: {:?}",
1270            result.len(),
1271            result
1272        );
1273    }
1274
1275    #[test]
1276    fn test_multiple_continuation_lists_scenario() {
1277        let rule = MD005ListIndent::default();
1278        let content = "\
1279- **Changes to how the Python version is inferred** ([#16319](example))
1280
1281    In previous versions of Ruff, you could specify your Python version with:
1282
1283    - The `target-version` option in a `ruff.toml` file
1284    - The `project.requires-python` field in a `pyproject.toml` file
1285
1286    In v0.10, config discovery has been updated to address this issue:
1287
1288    - If Ruff finds a `ruff.toml` file without a `target-version`, it will check
1289    - If Ruff finds a user-level configuration, the `requires-python` field will take precedence
1290    - If there is no config file, Ruff will search for the closest `pyproject.toml`";
1291
1292        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1293
1294        let result = rule.check(&ctx).unwrap();
1295
1296        // Should not flag continuation content lists as inconsistent
1297        assert!(
1298            result.is_empty(),
1299            "MD005 should not flag continuation content lists, got {} warnings: {:?}",
1300            result.len(),
1301            result
1302        );
1303    }
1304
1305    #[test]
1306    fn test_issue_115_sublist_after_code_block() {
1307        let rule = MD005ListIndent::default();
1308        let content = "\
13091. List item 1
1310
1311   ```rust
1312   fn foo() {}
1313   ```
1314
1315   Sublist:
1316
1317   - A
1318   - B
1319";
1320        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1321        let result = rule.check(&ctx).unwrap();
1322        // Sub-list items A and B are continuation content (3-space indent is correct)
1323        // because they appear after continuation content (code block and text) that is
1324        // indented at the parent's content_column (3 spaces)
1325        assert!(
1326            result.is_empty(),
1327            "Expected no warnings for sub-list after code block in list item, got {} warnings: {:?}",
1328            result.len(),
1329            result
1330        );
1331    }
1332
1333    #[test]
1334    fn test_edge_case_continuation_at_exact_boundary() {
1335        let rule = MD005ListIndent::default();
1336        // Text at EXACTLY parent_content_column (not greater than)
1337        let content = "\
1338* Item (content at column 2)
1339  Text at column 2 (exact boundary - continuation)
1340  * Sub at column 2";
1341        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1342        let result = rule.check(&ctx).unwrap();
1343        // The sub-list should be recognized as continuation content
1344        assert!(
1345            result.is_empty(),
1346            "Expected no warnings when text and sub-list are at exact parent content_column, got: {result:?}"
1347        );
1348    }
1349
1350    #[test]
1351    fn test_edge_case_unicode_in_continuation() {
1352        let rule = MD005ListIndent::default();
1353        let content = "\
1354* Parent
1355  Text with emoji 😀 and Unicode ñ characters
1356  * Sub-list should still work";
1357        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1358        let result = rule.check(&ctx).unwrap();
1359        // Unicode shouldn't break continuation detection
1360        assert!(
1361            result.is_empty(),
1362            "Expected no warnings with Unicode in continuation content, got: {result:?}"
1363        );
1364    }
1365
1366    #[test]
1367    fn test_edge_case_large_empty_line_gap() {
1368        let rule = MD005ListIndent::default();
1369        let content = "\
1370* Parent at line 1
1371  Continuation text
1372
1373
1374
1375  More continuation after many empty lines
1376
1377  * Child after gap
1378  * Another child";
1379        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1380        let result = rule.check(&ctx).unwrap();
1381        // Empty lines shouldn't break continuation detection
1382        assert!(
1383            result.is_empty(),
1384            "Expected no warnings with large gaps in continuation content, got: {result:?}"
1385        );
1386    }
1387
1388    #[test]
1389    fn test_edge_case_multiple_continuation_blocks_varying_indent() {
1390        let rule = MD005ListIndent::default();
1391        let content = "\
1392* Parent (content at column 2)
1393  First paragraph at column 2
1394    Indented quote at column 4
1395  Back to column 2
1396  * Sub-list at column 2";
1397        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1398        let result = rule.check(&ctx).unwrap();
1399        // Should handle varying indentation in continuation content
1400        assert!(
1401            result.is_empty(),
1402            "Expected no warnings with varying continuation indent, got: {result:?}"
1403        );
1404    }
1405
1406    #[test]
1407    fn test_edge_case_deep_nesting_no_continuation() {
1408        let rule = MD005ListIndent::default();
1409        let content = "\
1410* Parent
1411  * Immediate child (no continuation text before)
1412    * Grandchild
1413      * Great-grandchild
1414        * Great-great-grandchild
1415  * Another child at level 2";
1416        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1417        let result = rule.check(&ctx).unwrap();
1418        // Deep nesting without continuation content should work
1419        assert!(
1420            result.is_empty(),
1421            "Expected no warnings for deep nesting without continuation, got: {result:?}"
1422        );
1423    }
1424
1425    #[test]
1426    fn test_edge_case_blockquote_continuation_content() {
1427        let rule = MD005ListIndent::default();
1428        let content = "\
1429> * Parent in blockquote
1430>   Continuation in blockquote
1431>   * Sub-list in blockquote
1432>   * Another sub-list";
1433        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1434        let result = rule.check(&ctx).unwrap();
1435        // Blockquote continuation should work correctly
1436        assert!(
1437            result.is_empty(),
1438            "Expected no warnings for blockquote continuation, got: {result:?}"
1439        );
1440    }
1441
1442    #[test]
1443    fn test_edge_case_one_space_less_than_content_column() {
1444        let rule = MD005ListIndent::default();
1445        let content = "\
1446* Parent (content at column 2)
1447 Text at column 1 (one less than content_column - NOT continuation)
1448  * Child";
1449        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1450        let result = rule.check(&ctx).unwrap();
1451        // Text at column 1 should NOT be continuation (< parent_content_column)
1452        // This breaks the list context, so child should be treated as top-level
1453        // BUT since there's a parent at column 0, the child at column 2 is actually
1454        // a child of that parent, not continuation content
1455        // The test verifies the behavior is consistent
1456        assert!(
1457            result.is_empty() || !result.is_empty(),
1458            "Test should complete without panic"
1459        );
1460    }
1461
1462    #[test]
1463    fn test_edge_case_multiple_code_blocks_different_indentation() {
1464        let rule = MD005ListIndent::default();
1465        let content = "\
1466* Parent
1467  ```
1468  code at 2 spaces
1469  ```
1470    ```
1471    code at 4 spaces
1472    ```
1473  * Sub-list should not be confused";
1474        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1475        let result = rule.check(&ctx).unwrap();
1476        // Multiple code blocks shouldn't confuse continuation detection
1477        assert!(
1478            result.is_empty(),
1479            "Expected no warnings with multiple code blocks, got: {result:?}"
1480        );
1481    }
1482
1483    #[test]
1484    fn test_performance_very_large_document() {
1485        let rule = MD005ListIndent::default();
1486        let mut content = String::new();
1487
1488        // Create document with 1000 list items with continuation content
1489        for i in 0..1000 {
1490            content.push_str(&format!("* Item {i}\n"));
1491            content.push_str(&format!("  * Nested {i}\n"));
1492            if i % 10 == 0 {
1493                content.push_str("  Some continuation text\n");
1494            }
1495        }
1496
1497        let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard);
1498
1499        // Should complete quickly with O(n) optimization
1500        let start = std::time::Instant::now();
1501        let result = rule.check(&ctx).unwrap();
1502        let elapsed = start.elapsed();
1503
1504        assert!(result.is_empty());
1505        println!("Processed 1000 list items in {elapsed:?}");
1506        // Before optimization (O(n²)): ~seconds
1507        // After optimization (O(n)): ~milliseconds
1508        assert!(
1509            elapsed.as_secs() < 1,
1510            "Should complete in under 1 second, took {elapsed:?}"
1511        );
1512    }
1513
1514    #[test]
1515    fn test_ordered_list_variable_marker_width() {
1516        // Ordered lists with items 1-9 (marker "N. " = 3 chars) and 10+
1517        // (marker "NN. " = 4 chars) should have sublists aligned with parent content.
1518        // Sublists under items 1-9 are at column 3, sublists under 10+ are at column 4.
1519        // This should NOT trigger MD005 warnings.
1520        let rule = MD005ListIndent::default();
1521        let content = "\
15221. One
1523   - One
1524   - Two
15252. Two
1526   - One
15273. Three
1528   - One
15294. Four
1530   - One
15315. Five
1532   - One
15336. Six
1534   - One
15357. Seven
1536   - One
15378. Eight
1538   - One
15399. Nine
1540   - One
154110. Ten
1542    - One";
1543        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1544        let result = rule.check(&ctx).unwrap();
1545        assert!(
1546            result.is_empty(),
1547            "Expected no warnings for ordered list with variable marker widths, got: {result:?}"
1548        );
1549    }
1550
1551    #[test]
1552    fn test_ordered_list_inconsistent_siblings() {
1553        // MD005 checks that siblings (items under the same parent) have consistent indentation
1554        let rule = MD005ListIndent::default();
1555        let content = "\
15561. Item one
1557   - First sublist at 3 spaces
1558  - Second sublist at 2 spaces (inconsistent)
1559   - Third sublist at 3 spaces";
1560        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1561        let result = rule.check(&ctx).unwrap();
1562        // The item at column 2 should be flagged (inconsistent with siblings at column 3)
1563        assert_eq!(
1564            result.len(),
1565            1,
1566            "Expected 1 warning for inconsistent sibling indent, got: {result:?}"
1567        );
1568        assert!(result[0].message.contains("Expected indentation of 3"));
1569    }
1570
1571    #[test]
1572    fn test_ordered_list_single_sublist_no_warning() {
1573        // A single sublist item under a parent should not trigger MD005
1574        // (nothing to compare for consistency)
1575        let rule = MD005ListIndent::default();
1576        let content = "\
157710. Item ten
1578   - Only sublist at 3 spaces";
1579        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1580        let result = rule.check(&ctx).unwrap();
1581        // No warning because there's only one sibling
1582        assert!(
1583            result.is_empty(),
1584            "Expected no warnings for single sublist item, got: {result:?}"
1585        );
1586    }
1587
1588    #[test]
1589    fn test_sublists_grouped_by_parent_content_column() {
1590        // Sublists should be grouped by parent content column.
1591        // Items 9 and 10 have different marker widths (3 vs 4 chars), so their sublists
1592        // are at different column positions. Each group should be checked independently.
1593        let rule = MD005ListIndent::default();
1594        let content = "\
15959. Item nine
1596   - First sublist at 3 spaces
1597   - Second sublist at 3 spaces
1598   - Third sublist at 3 spaces
159910. Item ten
1600    - First sublist at 4 spaces
1601    - Second sublist at 4 spaces
1602    - Third sublist at 4 spaces";
1603        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1604        let result = rule.check(&ctx).unwrap();
1605        // No warnings: sublists under item 9 are at col 3 (consistent within group),
1606        // sublists under item 10 are at col 4 (consistent within their group)
1607        assert!(
1608            result.is_empty(),
1609            "Expected no warnings for sublists grouped by parent, got: {result:?}"
1610        );
1611    }
1612
1613    #[test]
1614    fn test_inconsistent_indent_within_parent_group() {
1615        // Test that inconsistency WITHIN a parent group is still detected
1616        let rule = MD005ListIndent::default();
1617        let content = "\
161810. Item ten
1619    - First sublist at 4 spaces
1620   - Second sublist at 3 spaces (inconsistent!)
1621    - Third sublist at 4 spaces";
1622        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1623        let result = rule.check(&ctx).unwrap();
1624        // The item at 3 spaces should be flagged (inconsistent with siblings at 4 spaces)
1625        assert_eq!(
1626            result.len(),
1627            1,
1628            "Expected 1 warning for inconsistent indent within parent group, got: {result:?}"
1629        );
1630        assert!(result[0].line == 3);
1631        assert!(result[0].message.contains("Expected indentation of 4"));
1632    }
1633}