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::{LineIndex, calculate_match_range};
7
8use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
9use crate::utils::document_structure::DocumentStructure;
10// No regex patterns needed for this rule
11use std::collections::HashMap;
12use toml;
13
14/// Rule MD005: Inconsistent indentation for list items at the same level
15#[derive(Clone)]
16pub struct MD005ListIndent;
17
18impl MD005ListIndent {
19    // Determine the expected indentation for a list item
20    // Each nested item should align with the text content of its parent
21    #[inline]
22    fn get_expected_indent(level: usize, parent_text_position: Option<usize>) -> usize {
23        if level == 1 {
24            0 // Top level items should be at the start of the line
25        } else if let Some(pos) = parent_text_position {
26            // Align with parent's text content
27            pos
28        } else {
29            // Fallback to standard nested indentation: 2 spaces per level
30            2 * (level - 1)
31        }
32    }
33
34    /// Get parent info for any list item to determine proper text alignment
35    /// Returns parent_text_position where the child should align
36    fn get_parent_text_position(
37        &self,
38        ctx: &crate::lint_context::LintContext,
39        current_line: usize,
40        current_indent: usize,
41    ) -> Option<usize> {
42        // Look backward from current line to find parent item
43        for line_idx in (1..current_line).rev() {
44            if let Some(line_info) = ctx.line_info(line_idx) {
45                if let Some(list_item) = &line_info.list_item {
46                    // Found a list item - check if it's at a lower indentation (parent level)
47                    if list_item.marker_column < current_indent {
48                        // This is a parent item - calculate where child should align
49                        if list_item.is_ordered {
50                            // For ordered lists, align with text start
51                            let text_start_pos = list_item.marker_column + list_item.marker.len() + 1; // +1 for space after marker
52                            return Some(text_start_pos);
53                        } else {
54                            // For unordered lists, align with text start
55                            let text_start_pos = list_item.marker_column + 2; // "* " or "- " or "+ "
56                            return Some(text_start_pos);
57                        }
58                    }
59                }
60                // If we encounter non-blank, non-list content at column 0, stop looking
61                else if !line_info.is_blank && line_info.indent == 0 {
62                    break;
63                }
64            }
65        }
66        None
67    }
68
69    /// Group related list blocks that should be treated as one logical list structure
70    fn group_related_list_blocks<'a>(
71        &self,
72        list_blocks: &'a [crate::lint_context::ListBlock],
73    ) -> Vec<Vec<&'a crate::lint_context::ListBlock>> {
74        if list_blocks.is_empty() {
75            return Vec::new();
76        }
77
78        let mut groups = Vec::new();
79        let mut current_group = vec![&list_blocks[0]];
80
81        for i in 1..list_blocks.len() {
82            let prev_block = &list_blocks[i - 1];
83            let current_block = &list_blocks[i];
84
85            // Check if blocks are consecutive (no significant gap between them)
86            let line_gap = current_block.start_line.saturating_sub(prev_block.end_line);
87
88            // Group blocks if they are close together (within 2 lines)
89            // This handles cases where mixed list types are split but should be treated together
90            if line_gap <= 2 {
91                current_group.push(current_block);
92            } else {
93                // Start a new group
94                groups.push(current_group);
95                current_group = vec![current_block];
96            }
97        }
98        groups.push(current_group);
99
100        groups
101    }
102
103    /// Check a group of related list blocks as one logical list structure
104    fn check_list_block_group(
105        &self,
106        ctx: &crate::lint_context::LintContext,
107        group: &[&crate::lint_context::ListBlock],
108        warnings: &mut Vec<LintWarning>,
109    ) -> Result<(), LintError> {
110        let line_index = LineIndex::new(ctx.content.to_string());
111
112        // Collect all list items from all blocks in the group
113        let mut all_list_items = Vec::new();
114
115        for list_block in group {
116            for &item_line in &list_block.item_lines {
117                if let Some(line_info) = ctx.line_info(item_line)
118                    && let Some(list_item) = &line_info.list_item
119                {
120                    // Calculate the effective indentation (considering blockquotes)
121                    let effective_indent = if let Some(blockquote) = &line_info.blockquote {
122                        // For blockquoted lists, use relative indentation within the blockquote
123                        list_item.marker_column.saturating_sub(blockquote.nesting_level * 2)
124                    } else {
125                        // For normal lists, use the marker column directly
126                        list_item.marker_column
127                    };
128
129                    all_list_items.push((item_line, effective_indent, line_info, list_item));
130                }
131            }
132        }
133
134        if all_list_items.is_empty() {
135            return Ok(());
136        }
137
138        // Sort by line number to process in order
139        all_list_items.sort_by_key(|(line_num, _, _, _)| *line_num);
140
141        // Determine levels based on indentation progression (like the original algorithm)
142        let mut indent_to_level: HashMap<usize, usize> = HashMap::new();
143
144        // Process items to establish level mapping based on nesting structure
145        for (_line_num, indent, _line_info, _list_item) in &all_list_items {
146            let _level = if indent_to_level.is_empty() {
147                // First item establishes level 1
148                indent_to_level.insert(*indent, 1);
149                1
150            } else if let Some(&existing_level) = indent_to_level.get(indent) {
151                // This indentation already has a level
152                existing_level
153            } else {
154                // Determine level based on relative indentation and parent-child relationships
155                let mut level = 1;
156                for (&existing_indent, &existing_level) in &indent_to_level {
157                    if existing_indent < *indent {
158                        level = level.max(existing_level + 1);
159                    }
160                }
161                indent_to_level.insert(*indent, level);
162                level
163            };
164        }
165
166        // Group items by level and check for consistency within each level
167        let mut level_groups: HashMap<usize, Vec<(usize, usize, &crate::lint_context::LineInfo)>> = HashMap::new();
168        for (line_num, indent, line_info, _list_item) in &all_list_items {
169            let level = indent_to_level[indent];
170            level_groups
171                .entry(level)
172                .or_default()
173                .push((*line_num, *indent, *line_info));
174        }
175
176        // Process each level group
177        for (level, mut group) in level_groups {
178            // Sort by line number to process in order
179            group.sort_by_key(|(line_num, _, _)| *line_num);
180
181            // Get parent text position for proper alignment
182            let parent_text_position = if level > 1 {
183                // Get parent info from the first item in the group
184                if let Some((line_num, indent, _)) = group.first() {
185                    self.get_parent_text_position(ctx, *line_num, *indent)
186                } else {
187                    None
188                }
189            } else {
190                None
191            };
192
193            let expected_indent = Self::get_expected_indent(level, parent_text_position);
194
195            // Check if items in this level have consistent indentation
196            let indents: std::collections::HashSet<usize> = group.iter().map(|(_, indent, _)| *indent).collect();
197
198            if indents.len() > 1 {
199                // Multiple different indentations at the same level - flag all as inconsistent
200                for (line_num, indent, line_info) in &group {
201                    let inconsistent_message = format!(
202                        "Expected indentation of {} {}, found {}",
203                        expected_indent,
204                        if expected_indent == 1 { "space" } else { "spaces" },
205                        indent
206                    );
207
208                    let (start_line, start_col, end_line, end_col) = if *indent > 0 {
209                        calculate_match_range(*line_num, &line_info.content, 0, *indent)
210                    } else {
211                        calculate_match_range(*line_num, &line_info.content, 0, 1)
212                    };
213
214                    let fix_range = if *indent > 0 {
215                        let start_byte = line_index.line_col_to_byte_range(*line_num, 1).start;
216                        let end_byte = line_index.line_col_to_byte_range(*line_num, *indent + 1).start;
217                        start_byte..end_byte
218                    } else {
219                        let byte_pos = line_index.line_col_to_byte_range(*line_num, 1).start;
220                        byte_pos..byte_pos
221                    };
222
223                    let replacement = if expected_indent > 0 {
224                        " ".repeat(expected_indent)
225                    } else {
226                        String::new()
227                    };
228
229                    warnings.push(LintWarning {
230                        rule_name: Some(self.name()),
231                        line: start_line,
232                        column: start_col,
233                        end_line,
234                        end_column: end_col,
235                        message: inconsistent_message,
236                        severity: Severity::Warning,
237                        fix: Some(Fix {
238                            range: fix_range,
239                            replacement,
240                        }),
241                    });
242                }
243            } else {
244                // Single indentation at this level - check if it matches expected
245                let actual_indent = indents.iter().next().unwrap();
246                if *actual_indent != expected_indent {
247                    for (line_num, indent, line_info) in &group {
248                        let inconsistent_message = format!(
249                            "Expected indentation of {} {}, found {}",
250                            expected_indent,
251                            if expected_indent == 1 { "space" } else { "spaces" },
252                            indent
253                        );
254
255                        let (start_line, start_col, end_line, end_col) = if *indent > 0 {
256                            calculate_match_range(*line_num, &line_info.content, 0, *indent)
257                        } else {
258                            calculate_match_range(*line_num, &line_info.content, 0, 1)
259                        };
260
261                        let fix_range = if *indent > 0 {
262                            let start_byte = line_index.line_col_to_byte_range(*line_num, 1).start;
263                            let end_byte = line_index.line_col_to_byte_range(*line_num, *indent + 1).start;
264                            start_byte..end_byte
265                        } else {
266                            let byte_pos = line_index.line_col_to_byte_range(*line_num, 1).start;
267                            byte_pos..byte_pos
268                        };
269
270                        let replacement = if expected_indent > 0 {
271                            " ".repeat(expected_indent)
272                        } else {
273                            String::new()
274                        };
275
276                        warnings.push(LintWarning {
277                            rule_name: Some(self.name()),
278                            line: start_line,
279                            column: start_col,
280                            end_line,
281                            end_column: end_col,
282                            message: inconsistent_message,
283                            severity: Severity::Warning,
284                            fix: Some(Fix {
285                                range: fix_range,
286                                replacement,
287                            }),
288                        });
289                    }
290                }
291            }
292        }
293
294        Ok(())
295    }
296
297    /// Migrated to use centralized list blocks for better performance and accuracy
298    fn check_optimized(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
299        let content = ctx.content;
300
301        // Early returns for common cases
302        if content.is_empty() {
303            return Ok(Vec::new());
304        }
305
306        // Quick check for any list blocks before processing
307        if ctx.list_blocks.is_empty() {
308            return Ok(Vec::new());
309        }
310
311        let mut warnings = Vec::new();
312
313        // Group consecutive list blocks that should be treated as one logical structure
314        // This is needed because mixed list types (ordered/unordered) get split into separate blocks
315        let block_groups = self.group_related_list_blocks(&ctx.list_blocks);
316
317        for group in block_groups {
318            self.check_list_block_group(ctx, &group, &mut warnings)?;
319        }
320
321        Ok(warnings)
322    }
323}
324
325impl Default for MD005ListIndent {
326    fn default() -> Self {
327        Self
328    }
329}
330
331impl Rule for MD005ListIndent {
332    fn name(&self) -> &'static str {
333        "MD005"
334    }
335
336    fn description(&self) -> &'static str {
337        "List indentation should be consistent"
338    }
339
340    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
341        // Use optimized version
342        self.check_optimized(ctx)
343    }
344
345    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
346        let warnings = self.check(ctx)?;
347        if warnings.is_empty() {
348            return Ok(ctx.content.to_string());
349        }
350
351        // Sort warnings by position (descending) to apply from end to start
352        let mut warnings_with_fixes: Vec<_> = warnings
353            .into_iter()
354            .filter_map(|w| w.fix.clone().map(|fix| (w, fix)))
355            .collect();
356        warnings_with_fixes.sort_by_key(|(_, fix)| std::cmp::Reverse(fix.range.start));
357
358        // Apply fixes to content
359        let mut content = ctx.content.to_string();
360        for (_, fix) in warnings_with_fixes {
361            if fix.range.start <= content.len() && fix.range.end <= content.len() {
362                content.replace_range(fix.range, &fix.replacement);
363            }
364        }
365
366        Ok(content)
367    }
368
369    fn category(&self) -> RuleCategory {
370        RuleCategory::List
371    }
372
373    /// Check if this rule should be skipped
374    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
375        // Skip if content is empty or has no list items
376        ctx.content.is_empty() || !ctx.lines.iter().any(|line| line.list_item.is_some())
377    }
378
379    /// Optimized check using document structure
380    fn check_with_structure(
381        &self,
382        ctx: &crate::lint_context::LintContext,
383        structure: &DocumentStructure,
384    ) -> LintResult {
385        // If no lists in structure, return early
386        if structure.list_lines.is_empty() {
387            return Ok(Vec::new());
388        }
389
390        // Use optimized check - it's already efficient enough
391        self.check_optimized(ctx)
392    }
393
394    fn as_any(&self) -> &dyn std::any::Any {
395        self
396    }
397
398    fn as_maybe_document_structure(&self) -> Option<&dyn crate::rule::MaybeDocumentStructure> {
399        Some(self)
400    }
401
402    fn default_config_section(&self) -> Option<(String, toml::Value)> {
403        None
404    }
405
406    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
407    where
408        Self: Sized,
409    {
410        Box::new(MD005ListIndent)
411    }
412}
413
414impl crate::utils::document_structure::DocumentStructureExtensions for MD005ListIndent {
415    fn has_relevant_elements(
416        &self,
417        _ctx: &crate::lint_context::LintContext,
418        doc_structure: &crate::utils::document_structure::DocumentStructure,
419    ) -> bool {
420        !doc_structure.list_lines.is_empty()
421    }
422}
423
424#[cfg(test)]
425mod tests {
426    use super::*;
427    use crate::lint_context::LintContext;
428    use crate::utils::document_structure::DocumentStructureExtensions;
429
430    #[test]
431    fn test_valid_unordered_list() {
432        let rule = MD005ListIndent;
433        let content = "\
434* Item 1
435* Item 2
436  * Nested 1
437  * Nested 2
438* Item 3";
439        let ctx = LintContext::new(content);
440        let result = rule.check(&ctx).unwrap();
441        assert!(result.is_empty());
442    }
443
444    #[test]
445    fn test_valid_ordered_list() {
446        let rule = MD005ListIndent;
447        let content = "\
4481. Item 1
4492. Item 2
450   1. Nested 1
451   2. Nested 2
4523. Item 3";
453        let ctx = LintContext::new(content);
454        let result = rule.check(&ctx).unwrap();
455        // With dynamic alignment, nested items should align with parent's text content
456        // Ordered items starting with "1. " have text at column 3, so nested items need 3 spaces
457        assert!(result.is_empty());
458    }
459
460    #[test]
461    fn test_invalid_unordered_indent() {
462        let rule = MD005ListIndent;
463        let content = "\
464* Item 1
465 * Item 2
466   * Nested 1";
467        let ctx = LintContext::new(content);
468        let result = rule.check(&ctx).unwrap();
469        // With dynamic alignment, line 3 correctly aligns with line 2's text position
470        // Only line 2 is incorrectly indented
471        assert_eq!(result.len(), 1);
472        let fixed = rule.fix(&ctx).unwrap();
473        assert_eq!(fixed, "* Item 1\n  * Item 2\n   * Nested 1");
474    }
475
476    #[test]
477    fn test_invalid_ordered_indent() {
478        let rule = MD005ListIndent;
479        let content = "\
4801. Item 1
481 2. Item 2
482    1. Nested 1";
483        let ctx = LintContext::new(content);
484        let result = rule.check(&ctx).unwrap();
485        assert_eq!(result.len(), 1);
486        let fixed = rule.fix(&ctx).unwrap();
487        // With dynamic alignment, ordered items align with parent's text content
488        // Line 1 text starts at col 3, so line 2 should have 3 spaces
489        // Line 3 already correctly aligns with line 2's text position
490        assert_eq!(fixed, "1. Item 1\n   2. Item 2\n    1. Nested 1");
491    }
492
493    #[test]
494    fn test_mixed_list_types() {
495        let rule = MD005ListIndent;
496        let content = "\
497* Item 1
498  1. Nested ordered
499  * Nested unordered
500* Item 2";
501        let ctx = LintContext::new(content);
502        let result = rule.check(&ctx).unwrap();
503        assert!(result.is_empty());
504    }
505
506    #[test]
507    fn test_multiple_levels() {
508        let rule = MD005ListIndent;
509        let content = "\
510* Level 1
511   * Level 2
512      * Level 3";
513        let ctx = LintContext::new(content);
514        let result = rule.check(&ctx).unwrap();
515        assert_eq!(result.len(), 2);
516        let fixed = rule.fix(&ctx).unwrap();
517        // With dynamic alignment:
518        // Level 2 aligns with Level 1's text (2 spaces)
519        // Level 3 aligns with Level 2's text (5 spaces: 2 + "* " + 1)
520        assert_eq!(
521            fixed,
522            "\
523* Level 1
524  * Level 2
525     * Level 3"
526        );
527    }
528
529    #[test]
530    fn test_empty_lines() {
531        let rule = MD005ListIndent;
532        let content = "\
533* Item 1
534
535  * Nested 1
536
537* Item 2";
538        let ctx = LintContext::new(content);
539        let result = rule.check(&ctx).unwrap();
540        assert!(result.is_empty());
541    }
542
543    #[test]
544    fn test_no_lists() {
545        let rule = MD005ListIndent;
546        let content = "\
547Just some text
548More text
549Even more text";
550        let ctx = LintContext::new(content);
551        let result = rule.check(&ctx).unwrap();
552        assert!(result.is_empty());
553    }
554
555    #[test]
556    fn test_complex_nesting() {
557        let rule = MD005ListIndent;
558        let content = "\
559* Level 1
560  * Level 2
561    * Level 3
562  * Back to 2
563    1. Ordered 3
564    2. Still 3
565* Back to 1";
566        let ctx = LintContext::new(content);
567        let result = rule.check(&ctx).unwrap();
568        assert!(result.is_empty());
569    }
570
571    #[test]
572    fn test_invalid_complex_nesting() {
573        let rule = MD005ListIndent;
574        let content = "\
575* Level 1
576   * Level 2
577     * Level 3
578   * Back to 2
579      1. Ordered 3
580     2. Still 3
581* Back to 1";
582        let ctx = LintContext::new(content);
583        let result = rule.check(&ctx).unwrap();
584        // With dynamic alignment, fewer items need correction
585        // Lines 2,4: should align with Level 1's text (2 spaces)
586        // Line 5: should align with "Back to 2"'s text (5 spaces)
587        assert_eq!(result.len(), 3);
588        let fixed = rule.fix(&ctx).unwrap();
589        assert_eq!(
590            fixed,
591            "* Level 1\n  * Level 2\n     * Level 3\n  * Back to 2\n     1. Ordered 3\n     2. Still 3\n* Back to 1"
592        );
593    }
594
595    #[test]
596    fn test_with_document_structure() {
597        let rule = MD005ListIndent;
598
599        // Test with consistent list indentation
600        let content = "* Item 1\n* Item 2\n  * Nested item\n  * Another nested item";
601        let structure = DocumentStructure::new(content);
602        let ctx = LintContext::new(content);
603        let result = rule.check_with_structure(&ctx, &structure).unwrap();
604        assert!(result.is_empty());
605
606        // Test with inconsistent list indentation
607        let content = "* Item 1\n* Item 2\n * Nested item\n  * Another nested item";
608        let structure = DocumentStructure::new(content);
609        let ctx = LintContext::new(content);
610        let result = rule.check_with_structure(&ctx, &structure).unwrap();
611        assert!(!result.is_empty()); // Should have at least one warning
612
613        // Test with different level indentation issues
614        let content = "* Item 1\n  * Nested item\n * Another nested item with wrong indent";
615        let structure = DocumentStructure::new(content);
616        let ctx = LintContext::new(content);
617        let result = rule.check_with_structure(&ctx, &structure).unwrap();
618        assert!(!result.is_empty()); // Should have at least one warning
619    }
620
621    // Additional comprehensive tests
622    #[test]
623    fn test_list_with_continuations() {
624        let rule = MD005ListIndent;
625        let content = "\
626* Item 1
627  This is a continuation
628  of the first item
629  * Nested item
630    with its own continuation
631* Item 2";
632        let ctx = LintContext::new(content);
633        let result = rule.check(&ctx).unwrap();
634        assert!(result.is_empty());
635    }
636
637    #[test]
638    fn test_list_in_blockquote() {
639        let rule = MD005ListIndent;
640        let content = "\
641> * Item 1
642>   * Nested 1
643>   * Nested 2
644> * Item 2";
645        let ctx = LintContext::new(content);
646        let result = rule.check(&ctx).unwrap();
647
648        // Blockquoted lists should have correct indentation within the blockquote context
649        assert!(
650            result.is_empty(),
651            "Expected no warnings for correctly indented blockquote list, got: {result:?}"
652        );
653    }
654
655    #[test]
656    fn test_list_with_code_blocks() {
657        let rule = MD005ListIndent;
658        let content = "\
659* Item 1
660  ```
661  code block
662  ```
663  * Nested item
664* Item 2";
665        let ctx = LintContext::new(content);
666        let result = rule.check(&ctx).unwrap();
667        assert!(result.is_empty());
668    }
669
670    #[test]
671    fn test_list_with_tabs() {
672        let rule = MD005ListIndent;
673        let content = "* Item 1\n\t* Tab indented\n  * Space indented";
674        let ctx = LintContext::new(content);
675        let result = rule.check(&ctx).unwrap();
676        // Should detect inconsistent indentation
677        assert!(!result.is_empty());
678    }
679
680    #[test]
681    fn test_inconsistent_at_same_level() {
682        let rule = MD005ListIndent;
683        let content = "\
684* Item 1
685  * Nested 1
686  * Nested 2
687   * Wrong indent for same level
688  * Nested 3";
689        let ctx = LintContext::new(content);
690        let result = rule.check(&ctx).unwrap();
691        assert!(!result.is_empty());
692        // Should flag the inconsistent item
693        assert!(result.iter().any(|w| w.line == 4));
694    }
695
696    #[test]
697    fn test_zero_indent_top_level() {
698        let rule = MD005ListIndent;
699        let content = "\
700 * Wrong indent
701* Correct
702  * Nested";
703        let ctx = LintContext::new(content);
704        let result = rule.check(&ctx).unwrap();
705
706        // The current implementation accepts lists that start indented
707        // It treats the first item as establishing the base indent level
708        // This is reasonable behavior - not all lists must start at column 0
709        assert_eq!(result.len(), 0);
710    }
711
712    #[test]
713    fn test_fix_preserves_content() {
714        let rule = MD005ListIndent;
715        let content = "\
716* Item with **bold** and *italic*
717 * Wrong indent with `code`
718   * Also wrong with [link](url)";
719        let ctx = LintContext::new(content);
720        let fixed = rule.fix(&ctx).unwrap();
721        assert!(fixed.contains("**bold**"));
722        assert!(fixed.contains("*italic*"));
723        assert!(fixed.contains("`code`"));
724        assert!(fixed.contains("[link](url)"));
725    }
726
727    #[test]
728    fn test_deeply_nested_lists() {
729        let rule = MD005ListIndent;
730        let content = "\
731* L1
732  * L2
733    * L3
734      * L4
735        * L5
736          * L6";
737        let ctx = LintContext::new(content);
738        let result = rule.check(&ctx).unwrap();
739        assert!(result.is_empty());
740    }
741
742    #[test]
743    fn test_fix_multiple_issues() {
744        let rule = MD005ListIndent;
745        let content = "\
746* Item 1
747 * Wrong 1
748   * Wrong 2
749    * Wrong 3
750  * Correct
751   * Wrong 4";
752        let ctx = LintContext::new(content);
753        let fixed = rule.fix(&ctx).unwrap();
754        // With dynamic alignment, items align with their parent's text content
755        let lines: Vec<&str> = fixed.lines().collect();
756        assert_eq!(lines[0], "* Item 1");
757        assert_eq!(lines[1], "  * Wrong 1");
758        assert_eq!(lines[2], "   * Wrong 2"); // Aligns with line 2's text
759        assert_eq!(lines[3], "     * Wrong 3"); // Aligns with line 3's text
760        assert_eq!(lines[4], "   * Correct"); // Back to level 2, aligns with line 1's text
761        assert_eq!(lines[5], "   * Wrong 4"); // Same level as "Correct"
762    }
763
764    #[test]
765    fn test_performance_large_document() {
766        let rule = MD005ListIndent;
767        let mut content = String::new();
768        for i in 0..100 {
769            content.push_str(&format!("* Item {i}\n"));
770            content.push_str(&format!("  * Nested {i}\n"));
771        }
772        let ctx = LintContext::new(&content);
773        let result = rule.check(&ctx).unwrap();
774        assert!(result.is_empty());
775    }
776
777    #[test]
778    fn test_column_positions() {
779        let rule = MD005ListIndent;
780        let content = " * Wrong indent";
781        let ctx = LintContext::new(content);
782        let result = rule.check(&ctx).unwrap();
783        assert_eq!(result.len(), 1);
784        assert_eq!(result[0].column, 1);
785        assert_eq!(result[0].end_column, 2);
786    }
787
788    #[test]
789    fn test_should_skip() {
790        let rule = MD005ListIndent;
791
792        // Empty content should skip
793        let ctx = LintContext::new("");
794        assert!(rule.should_skip(&ctx));
795
796        // Content without lists should skip
797        let ctx = LintContext::new("Just plain text");
798        assert!(rule.should_skip(&ctx));
799
800        // Content with lists should not skip
801        let ctx = LintContext::new("* List item");
802        assert!(!rule.should_skip(&ctx));
803
804        let ctx = LintContext::new("1. Ordered list");
805        assert!(!rule.should_skip(&ctx));
806    }
807
808    #[test]
809    fn test_has_relevant_elements() {
810        let rule = MD005ListIndent;
811        let content = "* List item";
812        let ctx = LintContext::new(content);
813        let doc_structure = DocumentStructure::new(content);
814        assert!(rule.has_relevant_elements(&ctx, &doc_structure));
815
816        let content = "No lists here";
817        let ctx = LintContext::new(content);
818        let doc_structure = DocumentStructure::new(content);
819        assert!(!rule.has_relevant_elements(&ctx, &doc_structure));
820    }
821
822    #[test]
823    fn test_edge_case_single_space_indent() {
824        let rule = MD005ListIndent;
825        let content = "\
826* Item 1
827 * Single space - wrong
828  * Two spaces - correct";
829        let ctx = LintContext::new(content);
830        let result = rule.check(&ctx).unwrap();
831        // Both the single space and two space items get warnings
832        // because they establish inconsistent indentation at the same level
833        assert_eq!(result.len(), 2);
834        assert!(result.iter().any(|w| w.line == 2 && w.message.contains("found 1")));
835    }
836
837    #[test]
838    fn test_edge_case_three_space_indent() {
839        let rule = MD005ListIndent;
840        let content = "\
841* Item 1
842   * Three spaces - wrong
843  * Two spaces - correct";
844        let ctx = LintContext::new(content);
845        let result = rule.check(&ctx).unwrap();
846        // Both items get warnings due to inconsistent indentation
847        assert_eq!(result.len(), 2);
848        assert!(result.iter().any(|w| w.line == 2 && w.message.contains("found 3")));
849    }
850
851    #[test]
852    fn test_nested_bullets_under_numbered_items() {
853        let rule = MD005ListIndent;
854        let content = "\
8551. **Active Directory/LDAP**
856   - User authentication and directory services
857   - LDAP for user information and validation
858
8592. **Oracle Unified Directory (OUD)**
860   - Extended user directory services
861   - Verification of project account presence and changes";
862        let ctx = LintContext::new(content);
863        let result = rule.check(&ctx).unwrap();
864        // Should have no warnings - 3 spaces is correct for bullets under numbered items
865        assert!(
866            result.is_empty(),
867            "Expected no warnings for bullets with 3 spaces under numbered items, got: {result:?}"
868        );
869    }
870
871    #[test]
872    fn test_nested_bullets_under_numbered_items_wrong_indent() {
873        let rule = MD005ListIndent;
874        let content = "\
8751. **Active Directory/LDAP**
876  - Wrong: only 2 spaces
877   - Correct: 3 spaces";
878        let ctx = LintContext::new(content);
879        let result = rule.check(&ctx).unwrap();
880        // Should flag the 2-space indentation as wrong
881        assert_eq!(result.len(), 2); // Both items flagged due to inconsistency
882        assert!(result.iter().any(|w| w.line == 2 && w.message.contains("found 2")));
883    }
884
885    #[test]
886    fn test_regular_nested_bullets_still_work() {
887        let rule = MD005ListIndent;
888        let content = "\
889* Top level
890  * Second level (2 spaces is correct for bullets under bullets)
891    * Third level (4 spaces)";
892        let ctx = LintContext::new(content);
893        let result = rule.check(&ctx).unwrap();
894        // Should have no warnings - regular bullet nesting still uses 2-space increments
895        assert!(
896            result.is_empty(),
897            "Expected no warnings for regular bullet nesting, got: {result:?}"
898        );
899    }
900
901    #[test]
902    fn test_fix_range_accuracy() {
903        let rule = MD005ListIndent;
904        let content = " * Wrong indent";
905        let ctx = LintContext::new(content);
906        let result = rule.check(&ctx).unwrap();
907        assert_eq!(result.len(), 1);
908
909        let fix = result[0].fix.as_ref().unwrap();
910        // Fix should replace the single space with nothing (0 indent for level 1)
911        assert_eq!(fix.replacement, "");
912    }
913}