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