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    /// Group related list blocks that should be treated as one logical list structure
20    fn group_related_list_blocks<'a>(
21        &self,
22        list_blocks: &'a [crate::lint_context::ListBlock],
23    ) -> Vec<Vec<&'a crate::lint_context::ListBlock>> {
24        if list_blocks.is_empty() {
25            return Vec::new();
26        }
27
28        let mut groups = Vec::new();
29        let mut current_group = vec![&list_blocks[0]];
30
31        for i in 1..list_blocks.len() {
32            let prev_block = &list_blocks[i - 1];
33            let current_block = &list_blocks[i];
34
35            // Check if blocks are consecutive (no significant gap between them)
36            let line_gap = current_block.start_line.saturating_sub(prev_block.end_line);
37
38            // Group blocks if they are close together (within 2 lines)
39            // This handles cases where mixed list types are split but should be treated together
40            if line_gap <= 2 {
41                current_group.push(current_block);
42            } else {
43                // Start a new group
44                groups.push(current_group);
45                current_group = vec![current_block];
46            }
47        }
48        groups.push(current_group);
49
50        groups
51    }
52
53    /// Check a group of related list blocks as one logical list structure
54    fn check_list_block_group(
55        &self,
56        ctx: &crate::lint_context::LintContext,
57        group: &[&crate::lint_context::ListBlock],
58        warnings: &mut Vec<LintWarning>,
59    ) -> Result<(), LintError> {
60        let line_index = LineIndex::new(ctx.content.to_string());
61
62        // Collect all list items from all blocks in the group
63        let mut all_list_items = Vec::new();
64
65        for list_block in group {
66            for &item_line in &list_block.item_lines {
67                if let Some(line_info) = ctx.line_info(item_line)
68                    && let Some(list_item) = &line_info.list_item
69                {
70                    // Calculate the effective indentation (considering blockquotes)
71                    let effective_indent = if let Some(blockquote) = &line_info.blockquote {
72                        // For blockquoted lists, use relative indentation within the blockquote
73                        list_item.marker_column.saturating_sub(blockquote.nesting_level * 2)
74                    } else {
75                        // For normal lists, use the marker column directly
76                        list_item.marker_column
77                    };
78
79                    all_list_items.push((item_line, effective_indent, line_info, list_item));
80                }
81            }
82        }
83
84        if all_list_items.is_empty() {
85            return Ok(());
86        }
87
88        // Sort by line number to process in order
89        all_list_items.sort_by_key(|(line_num, _, _, _)| *line_num);
90
91        // Build level mapping based on hierarchical structure
92        // Key insight: We need to identify which items are meant to be at the same level
93        // even if they have slightly different indentations (inconsistent formatting)
94        let mut level_map: HashMap<usize, usize> = HashMap::new();
95        let mut level_indents: HashMap<usize, Vec<usize>> = HashMap::new(); // Track all indents seen at each level
96
97        // Process items in order to build the level hierarchy
98        for i in 0..all_list_items.len() {
99            let (line_num, indent, _, _) = &all_list_items[i];
100
101            let level = if i == 0 {
102                // First item establishes level 1
103                level_indents.entry(1).or_default().push(*indent);
104                1
105            } else {
106                // Find the appropriate level for this item
107                let mut determined_level = 0;
108
109                // First, check if this indent matches any existing level exactly
110                for (lvl, indents) in &level_indents {
111                    if indents.contains(indent) {
112                        determined_level = *lvl;
113                        break;
114                    }
115                }
116
117                if determined_level == 0 {
118                    // No exact match - determine level based on hierarchy
119                    // Look for the most recent item with clearly less indentation (parent)
120                    for j in (0..i).rev() {
121                        let (prev_line, prev_indent, _, _) = &all_list_items[j];
122                        let prev_level = level_map[prev_line];
123
124                        // A clear parent has at least 2 spaces less indentation
125                        if *prev_indent + 2 <= *indent {
126                            // This is a child of prev_item
127                            determined_level = prev_level + 1;
128                            break;
129                        } else if (*prev_indent as i32 - *indent as i32).abs() <= 1 {
130                            // Within 1 space - likely meant to be same level but inconsistent
131                            determined_level = prev_level;
132                            break;
133                        } else if *prev_indent < *indent {
134                            // Less than 2 space difference but more than 1
135                            // This is ambiguous - could be same level or child
136                            // Look at the pattern: if prev_level already has items with similar indent,
137                            // this is probably meant to be at the same level
138                            if let Some(level_indents_list) = level_indents.get(&prev_level) {
139                                // Check if any indent at prev_level is close to this indent
140                                for &lvl_indent in level_indents_list {
141                                    if (lvl_indent as i32 - *indent as i32).abs() <= 1 {
142                                        // Close to an existing indent at prev_level
143                                        determined_level = prev_level;
144                                        break;
145                                    }
146                                }
147                            }
148                            if determined_level == 0 {
149                                // Still not determined - treat as child since it has more indent
150                                determined_level = prev_level + 1;
151                            }
152                            break;
153                        }
154                    }
155
156                    // If still not determined, default to level 1
157                    if determined_level == 0 {
158                        determined_level = 1;
159                    }
160
161                    // Record this indent for the level
162                    level_indents.entry(determined_level).or_default().push(*indent);
163                }
164
165                determined_level
166            };
167
168            level_map.insert(*line_num, level);
169        }
170
171        // Now group items by their level
172        let mut level_groups: HashMap<usize, Vec<(usize, usize, &crate::lint_context::LineInfo)>> = HashMap::new();
173        for (line_num, indent, line_info, _) in &all_list_items {
174            let level = level_map[line_num];
175            level_groups
176                .entry(level)
177                .or_default()
178                .push((*line_num, *indent, *line_info));
179        }
180
181        // For each level, check consistency
182        for (level, group) in level_groups {
183            // For level 1 (top-level), even single items should start at column 0
184            // For other levels, we need at least 2 items to check consistency
185            if level != 1 && group.len() < 2 {
186                continue;
187            }
188
189            // Sort by line number
190            let mut group = group;
191            group.sort_by_key(|(line_num, _, _)| *line_num);
192
193            // Check if all items at this level have the same indentation
194            let indents: std::collections::HashSet<usize> = group.iter().map(|(_, indent, _)| *indent).collect();
195
196            // For level 1, check if any item has non-zero indentation
197            // For other levels, check for inconsistent indentation
198            let has_issue = if level == 1 {
199                // Top-level items should start at column 0
200                indents.iter().any(|&indent| indent != 0)
201            } else {
202                // Other levels need consistency
203                indents.len() > 1
204            };
205
206            if has_issue {
207                // Inconsistent indentation at this level!
208                // Determine what the correct indentation should be
209
210                // For level 1, it should be 0
211                // For other levels, we need to look at parent alignment or use the most common indent
212                let expected_indent = if level == 1 {
213                    0
214                } else {
215                    // For non-top-level items, determine the expected indent
216                    // Prefer common patterns (2, 3, 4 spaces) and the most frequent indent
217                    let mut indent_counts: HashMap<usize, usize> = HashMap::new();
218                    for (_, indent, _) in &group {
219                        *indent_counts.entry(*indent).or_insert(0) += 1;
220                    }
221
222                    if indent_counts.len() == 1 {
223                        // All items have the same indent already
224                        *indent_counts.keys().next().unwrap()
225                    } else {
226                        // Multiple indents - pick the best one
227                        // For level 2, prefer common increments (4, 2, 3) in that order
228                        let mut chosen_indent = None;
229                        if level == 2 {
230                            // Check for common patterns in preference order
231                            for &common_indent in &[4, 2, 3] {
232                                if indent_counts.contains_key(&common_indent) {
233                                    chosen_indent = Some(common_indent);
234                                    break;
235                                }
236                            }
237                        }
238
239                        // Use the chosen common pattern or fall back to most common indent
240                        chosen_indent.unwrap_or_else(|| {
241                            // When counts are equal, prefer the smaller indentation
242                            // This handles cases where one item has correct indentation and another is wrong
243                            indent_counts
244                                .iter()
245                                .max_by(|(indent_a, count_a), (indent_b, count_b)| {
246                                    // First compare by count, then by preferring smaller indent
247                                    count_a.cmp(count_b).then(indent_b.cmp(indent_a))
248                                })
249                                .map(|(indent, _)| *indent)
250                                .unwrap()
251                        })
252                    }
253                };
254
255                // Flag all items that don't match the expected indentation
256                for (line_num, indent, line_info) in &group {
257                    if *indent != expected_indent {
258                        let message = format!(
259                            "Expected indentation of {} {}, found {}",
260                            expected_indent,
261                            if expected_indent == 1 { "space" } else { "spaces" },
262                            indent
263                        );
264
265                        let (start_line, start_col, end_line, end_col) = if *indent > 0 {
266                            calculate_match_range(*line_num, &line_info.content, 0, *indent)
267                        } else {
268                            calculate_match_range(*line_num, &line_info.content, 0, 1)
269                        };
270
271                        let fix_range = if *indent > 0 {
272                            let start_byte = line_index.line_col_to_byte_range(*line_num, 1).start;
273                            let end_byte = line_index.line_col_to_byte_range(*line_num, *indent + 1).start;
274                            start_byte..end_byte
275                        } else {
276                            let byte_pos = line_index.line_col_to_byte_range(*line_num, 1).start;
277                            byte_pos..byte_pos
278                        };
279
280                        let replacement = if expected_indent > 0 {
281                            " ".repeat(expected_indent)
282                        } else {
283                            String::new()
284                        };
285
286                        warnings.push(LintWarning {
287                            rule_name: Some(self.name()),
288                            line: start_line,
289                            column: start_col,
290                            end_line,
291                            end_column: end_col,
292                            message,
293                            severity: Severity::Warning,
294                            fix: Some(Fix {
295                                range: fix_range,
296                                replacement,
297                            }),
298                        });
299                    }
300                }
301            }
302        }
303
304        Ok(())
305    }
306
307    /// Migrated to use centralized list blocks for better performance and accuracy
308    fn check_optimized(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
309        let content = ctx.content;
310
311        // Early returns for common cases
312        if content.is_empty() {
313            return Ok(Vec::new());
314        }
315
316        // Quick check for any list blocks before processing
317        if ctx.list_blocks.is_empty() {
318            return Ok(Vec::new());
319        }
320
321        let mut warnings = Vec::new();
322
323        // Group consecutive list blocks that should be treated as one logical structure
324        // This is needed because mixed list types (ordered/unordered) get split into separate blocks
325        let block_groups = self.group_related_list_blocks(&ctx.list_blocks);
326
327        for group in block_groups {
328            self.check_list_block_group(ctx, &group, &mut warnings)?;
329        }
330
331        Ok(warnings)
332    }
333}
334
335impl Default for MD005ListIndent {
336    fn default() -> Self {
337        Self
338    }
339}
340
341impl Rule for MD005ListIndent {
342    fn name(&self) -> &'static str {
343        "MD005"
344    }
345
346    fn description(&self) -> &'static str {
347        "List indentation should be consistent"
348    }
349
350    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
351        // Use optimized version
352        self.check_optimized(ctx)
353    }
354
355    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
356        let warnings = self.check(ctx)?;
357        if warnings.is_empty() {
358            return Ok(ctx.content.to_string());
359        }
360
361        // Sort warnings by position (descending) to apply from end to start
362        let mut warnings_with_fixes: Vec<_> = warnings
363            .into_iter()
364            .filter_map(|w| w.fix.clone().map(|fix| (w, fix)))
365            .collect();
366        warnings_with_fixes.sort_by_key(|(_, fix)| std::cmp::Reverse(fix.range.start));
367
368        // Apply fixes to content
369        let mut content = ctx.content.to_string();
370        for (_, fix) in warnings_with_fixes {
371            if fix.range.start <= content.len() && fix.range.end <= content.len() {
372                content.replace_range(fix.range, &fix.replacement);
373            }
374        }
375
376        Ok(content)
377    }
378
379    fn category(&self) -> RuleCategory {
380        RuleCategory::List
381    }
382
383    /// Check if this rule should be skipped
384    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
385        // Skip if content is empty or has no list items
386        ctx.content.is_empty() || !ctx.lines.iter().any(|line| line.list_item.is_some())
387    }
388
389    /// Optimized check using document structure
390    fn check_with_structure(
391        &self,
392        ctx: &crate::lint_context::LintContext,
393        structure: &DocumentStructure,
394    ) -> LintResult {
395        // If no lists in structure, return early
396        if structure.list_lines.is_empty() {
397            return Ok(Vec::new());
398        }
399
400        // Use optimized check - it's already efficient enough
401        self.check_optimized(ctx)
402    }
403
404    fn as_any(&self) -> &dyn std::any::Any {
405        self
406    }
407
408    fn as_maybe_document_structure(&self) -> Option<&dyn crate::rule::MaybeDocumentStructure> {
409        Some(self)
410    }
411
412    fn default_config_section(&self) -> Option<(String, toml::Value)> {
413        None
414    }
415
416    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
417    where
418        Self: Sized,
419    {
420        Box::new(MD005ListIndent)
421    }
422}
423
424impl crate::utils::document_structure::DocumentStructureExtensions for MD005ListIndent {
425    fn has_relevant_elements(
426        &self,
427        _ctx: &crate::lint_context::LintContext,
428        doc_structure: &crate::utils::document_structure::DocumentStructure,
429    ) -> bool {
430        !doc_structure.list_lines.is_empty()
431    }
432}
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437    use crate::lint_context::LintContext;
438    use crate::utils::document_structure::DocumentStructureExtensions;
439
440    #[test]
441    fn test_valid_unordered_list() {
442        let rule = MD005ListIndent;
443        let content = "\
444* Item 1
445* Item 2
446  * Nested 1
447  * Nested 2
448* Item 3";
449        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
450        let result = rule.check(&ctx).unwrap();
451        assert!(result.is_empty());
452    }
453
454    #[test]
455    fn test_valid_ordered_list() {
456        let rule = MD005ListIndent;
457        let content = "\
4581. Item 1
4592. Item 2
460   1. Nested 1
461   2. Nested 2
4623. Item 3";
463        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
464        let result = rule.check(&ctx).unwrap();
465        // With dynamic alignment, nested items should align with parent's text content
466        // Ordered items starting with "1. " have text at column 3, so nested items need 3 spaces
467        assert!(result.is_empty());
468    }
469
470    #[test]
471    fn test_invalid_unordered_indent() {
472        let rule = MD005ListIndent;
473        let content = "\
474* Item 1
475 * Item 2
476   * Nested 1";
477        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
478        let result = rule.check(&ctx).unwrap();
479        // With dynamic alignment, line 3 correctly aligns with line 2's text position
480        // Only line 2 is incorrectly indented
481        assert_eq!(result.len(), 1);
482        let fixed = rule.fix(&ctx).unwrap();
483        assert_eq!(fixed, "* Item 1\n* Item 2\n   * Nested 1");
484    }
485
486    #[test]
487    fn test_invalid_ordered_indent() {
488        let rule = MD005ListIndent;
489        let content = "\
4901. Item 1
491 2. Item 2
492    1. Nested 1";
493        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
494        let result = rule.check(&ctx).unwrap();
495        assert_eq!(result.len(), 1);
496        let fixed = rule.fix(&ctx).unwrap();
497        // With dynamic alignment, ordered items align with parent's text content
498        // Line 1 text starts at col 3, so line 2 should have 3 spaces
499        // Line 3 already correctly aligns with line 2's text position
500        assert_eq!(fixed, "1. Item 1\n2. Item 2\n    1. Nested 1");
501    }
502
503    #[test]
504    fn test_mixed_list_types() {
505        let rule = MD005ListIndent;
506        let content = "\
507* Item 1
508  1. Nested ordered
509  * Nested unordered
510* Item 2";
511        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
512        let result = rule.check(&ctx).unwrap();
513        assert!(result.is_empty());
514    }
515
516    #[test]
517    fn test_multiple_levels() {
518        let rule = MD005ListIndent;
519        let content = "\
520* Level 1
521   * Level 2
522      * Level 3";
523        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
524        let result = rule.check(&ctx).unwrap();
525        // MD005 should now accept consistent 3-space increments
526        assert!(result.is_empty(), "MD005 should accept consistent indentation pattern");
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, crate::config::MarkdownFlavor::Standard);
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, crate::config::MarkdownFlavor::Standard);
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, crate::config::MarkdownFlavor::Standard);
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, crate::config::MarkdownFlavor::Standard);
583        let result = rule.check(&ctx).unwrap();
584        // Lines 5-6 have inconsistent indentation (6 vs 5 spaces) for the same level
585        assert_eq!(result.len(), 1);
586        assert!(
587            result[0].message.contains("Expected indentation of 5 spaces, found 6")
588                || result[0].message.contains("Expected indentation of 6 spaces, found 5")
589        );
590    }
591
592    #[test]
593    fn test_with_document_structure() {
594        let rule = MD005ListIndent;
595
596        // Test with consistent list indentation
597        let content = "* Item 1\n* Item 2\n  * Nested item\n  * Another nested item";
598        let structure = DocumentStructure::new(content);
599        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
600        let result = rule.check_with_structure(&ctx, &structure).unwrap();
601        assert!(result.is_empty());
602
603        // Test with inconsistent list indentation
604        let content = "* Item 1\n* Item 2\n * Nested item\n  * Another nested item";
605        let structure = DocumentStructure::new(content);
606        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
607        let result = rule.check_with_structure(&ctx, &structure).unwrap();
608        assert!(!result.is_empty()); // Should have at least one warning
609
610        // Test with different level indentation issues
611        let content = "* Item 1\n  * Nested item\n * Another nested item with wrong indent";
612        let structure = DocumentStructure::new(content);
613        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
614        let result = rule.check_with_structure(&ctx, &structure).unwrap();
615        assert!(!result.is_empty()); // Should have at least one warning
616    }
617
618    // Additional comprehensive tests
619    #[test]
620    fn test_list_with_continuations() {
621        let rule = MD005ListIndent;
622        let content = "\
623* Item 1
624  This is a continuation
625  of the first item
626  * Nested item
627    with its own continuation
628* Item 2";
629        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
630        let result = rule.check(&ctx).unwrap();
631        assert!(result.is_empty());
632    }
633
634    #[test]
635    fn test_list_in_blockquote() {
636        let rule = MD005ListIndent;
637        let content = "\
638> * Item 1
639>   * Nested 1
640>   * Nested 2
641> * Item 2";
642        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
643        let result = rule.check(&ctx).unwrap();
644
645        // Blockquoted lists should have correct indentation within the blockquote context
646        assert!(
647            result.is_empty(),
648            "Expected no warnings for correctly indented blockquote list, got: {result:?}"
649        );
650    }
651
652    #[test]
653    fn test_list_with_code_blocks() {
654        let rule = MD005ListIndent;
655        let content = "\
656* Item 1
657  ```
658  code block
659  ```
660  * Nested item
661* Item 2";
662        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
663        let result = rule.check(&ctx).unwrap();
664        assert!(result.is_empty());
665    }
666
667    #[test]
668    fn test_list_with_tabs() {
669        let rule = MD005ListIndent;
670        let content = "* Item 1\n\t* Tab indented\n  * Space indented";
671        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
672        let result = rule.check(&ctx).unwrap();
673        // Should detect inconsistent indentation
674        assert!(!result.is_empty());
675    }
676
677    #[test]
678    fn test_inconsistent_at_same_level() {
679        let rule = MD005ListIndent;
680        let content = "\
681* Item 1
682  * Nested 1
683  * Nested 2
684   * Wrong indent for same level
685  * Nested 3";
686        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
687        let result = rule.check(&ctx).unwrap();
688        assert!(!result.is_empty());
689        // Should flag the inconsistent item
690        assert!(result.iter().any(|w| w.line == 4));
691    }
692
693    #[test]
694    fn test_zero_indent_top_level() {
695        let rule = MD005ListIndent;
696        // Use concat to preserve the leading space
697        let content = concat!(" * Wrong indent\n", "* Correct\n", "  * Nested");
698        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
699        let result = rule.check(&ctx).unwrap();
700
701        // Should flag the indented top-level item
702        assert!(!result.is_empty());
703        assert!(result.iter().any(|w| w.line == 1));
704    }
705
706    #[test]
707    fn test_fix_preserves_content() {
708        let rule = MD005ListIndent;
709        let content = "\
710* Item with **bold** and *italic*
711 * Wrong indent with `code`
712   * Also wrong with [link](url)";
713        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
714        let fixed = rule.fix(&ctx).unwrap();
715        assert!(fixed.contains("**bold**"));
716        assert!(fixed.contains("*italic*"));
717        assert!(fixed.contains("`code`"));
718        assert!(fixed.contains("[link](url)"));
719    }
720
721    #[test]
722    fn test_deeply_nested_lists() {
723        let rule = MD005ListIndent;
724        let content = "\
725* L1
726  * L2
727    * L3
728      * L4
729        * L5
730          * L6";
731        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
732        let result = rule.check(&ctx).unwrap();
733        assert!(result.is_empty());
734    }
735
736    #[test]
737    fn test_fix_multiple_issues() {
738        let rule = MD005ListIndent;
739        let content = "\
740* Item 1
741 * Wrong 1
742   * Wrong 2
743    * Wrong 3
744  * Correct
745   * Wrong 4";
746        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
747        let fixed = rule.fix(&ctx).unwrap();
748        // Should fix to consistent indentation
749        let lines: Vec<&str> = fixed.lines().collect();
750        assert_eq!(lines[0], "* Item 1");
751        // All level 2 items should have same indent
752        assert!(lines[1].starts_with("  * ") || lines[1].starts_with("* "));
753    }
754
755    #[test]
756    fn test_performance_large_document() {
757        let rule = MD005ListIndent;
758        let mut content = String::new();
759        for i in 0..100 {
760            content.push_str(&format!("* Item {i}\n"));
761            content.push_str(&format!("  * Nested {i}\n"));
762        }
763        let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard);
764        let result = rule.check(&ctx).unwrap();
765        assert!(result.is_empty());
766    }
767
768    #[test]
769    fn test_column_positions() {
770        let rule = MD005ListIndent;
771        let content = " * Wrong indent";
772        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
773        let result = rule.check(&ctx).unwrap();
774        assert_eq!(result.len(), 1);
775        assert_eq!(result[0].column, 1, "Expected column 1, got {}", result[0].column);
776        assert_eq!(
777            result[0].end_column, 2,
778            "Expected end_column 2, got {}",
779            result[0].end_column
780        );
781    }
782
783    #[test]
784    fn test_should_skip() {
785        let rule = MD005ListIndent;
786
787        // Empty content should skip
788        let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard);
789        assert!(rule.should_skip(&ctx));
790
791        // Content without lists should skip
792        let ctx = LintContext::new("Just plain text", crate::config::MarkdownFlavor::Standard);
793        assert!(rule.should_skip(&ctx));
794
795        // Content with lists should not skip
796        let ctx = LintContext::new("* List item", crate::config::MarkdownFlavor::Standard);
797        assert!(!rule.should_skip(&ctx));
798
799        let ctx = LintContext::new("1. Ordered list", crate::config::MarkdownFlavor::Standard);
800        assert!(!rule.should_skip(&ctx));
801    }
802
803    #[test]
804    fn test_has_relevant_elements() {
805        let rule = MD005ListIndent;
806        let content = "* List item";
807        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
808        let doc_structure = DocumentStructure::new(content);
809        assert!(rule.has_relevant_elements(&ctx, &doc_structure));
810
811        let content = "No lists here";
812        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
813        let doc_structure = DocumentStructure::new(content);
814        assert!(!rule.has_relevant_elements(&ctx, &doc_structure));
815    }
816
817    #[test]
818    fn test_edge_case_single_space_indent() {
819        let rule = MD005ListIndent;
820        let content = "\
821* Item 1
822 * Single space - wrong
823  * Two spaces - correct";
824        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
825        let result = rule.check(&ctx).unwrap();
826        // Both the single space and two space items get warnings
827        // because they establish inconsistent indentation at the same level
828        assert_eq!(result.len(), 2);
829        assert!(result.iter().any(|w| w.line == 2 && w.message.contains("found 1")));
830    }
831
832    #[test]
833    fn test_edge_case_three_space_indent() {
834        let rule = MD005ListIndent;
835        let content = "\
836* Item 1
837   * Three spaces - wrong
838  * Two spaces - correct";
839        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
840        let result = rule.check(&ctx).unwrap();
841        // Should flag the item with 3 spaces as inconsistent (2 spaces is correct)
842        assert_eq!(result.len(), 1);
843        assert!(result.iter().any(|w| w.line == 2 && w.message.contains("found 3")));
844    }
845
846    #[test]
847    fn test_nested_bullets_under_numbered_items() {
848        let rule = MD005ListIndent;
849        let content = "\
8501. **Active Directory/LDAP**
851   - User authentication and directory services
852   - LDAP for user information and validation
853
8542. **Oracle Unified Directory (OUD)**
855   - Extended user directory services
856   - Verification of project account presence and changes";
857        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
858        let result = rule.check(&ctx).unwrap();
859        // Should have no warnings - 3 spaces is correct for bullets under numbered items
860        assert!(
861            result.is_empty(),
862            "Expected no warnings for bullets with 3 spaces under numbered items, got: {result:?}"
863        );
864    }
865
866    #[test]
867    fn test_nested_bullets_under_numbered_items_wrong_indent() {
868        let rule = MD005ListIndent;
869        let content = "\
8701. **Active Directory/LDAP**
871  - Wrong: only 2 spaces
872   - Correct: 3 spaces";
873        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
874        let result = rule.check(&ctx).unwrap();
875        // Should flag one of them as inconsistent
876        assert_eq!(
877            result.len(),
878            1,
879            "Expected 1 warning, got {}. Warnings: {:?}",
880            result.len(),
881            result
882        );
883        // Either line 2 or line 3 should be flagged for inconsistency
884        assert!(
885            result
886                .iter()
887                .any(|w| (w.line == 2 && w.message.contains("found 2"))
888                    || (w.line == 3 && w.message.contains("found 3")))
889        );
890    }
891
892    #[test]
893    fn test_regular_nested_bullets_still_work() {
894        let rule = MD005ListIndent;
895        let content = "\
896* Top level
897  * Second level (2 spaces is correct for bullets under bullets)
898    * Third level (4 spaces)";
899        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
900        let result = rule.check(&ctx).unwrap();
901        // Should have no warnings - regular bullet nesting still uses 2-space increments
902        assert!(
903            result.is_empty(),
904            "Expected no warnings for regular bullet nesting, got: {result:?}"
905        );
906    }
907
908    #[test]
909    fn test_fix_range_accuracy() {
910        let rule = MD005ListIndent;
911        let content = " * Wrong indent";
912        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
913        let result = rule.check(&ctx).unwrap();
914        assert_eq!(result.len(), 1);
915
916        let fix = result[0].fix.as_ref().unwrap();
917        // Fix should replace the single space with nothing (0 indent for level 1)
918        assert_eq!(fix.replacement, "");
919    }
920
921    #[test]
922    fn test_four_space_indent_pattern() {
923        let rule = MD005ListIndent;
924        let content = "\
925* Item 1
926    * Item 2 with 4 spaces
927        * Item 3 with 8 spaces
928    * Item 4 with 4 spaces";
929        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
930        let result = rule.check(&ctx).unwrap();
931        // MD005 should accept consistent 4-space pattern
932        assert!(
933            result.is_empty(),
934            "MD005 should accept consistent 4-space indentation pattern, got {} warnings",
935            result.len()
936        );
937    }
938
939    #[test]
940    fn test_issue_64_scenario() {
941        // Test the exact scenario from issue #64
942        let rule = MD005ListIndent;
943        let content = "\
944* Top level item
945    * Sub item with 4 spaces (as configured in MD007)
946        * Nested sub item with 8 spaces
947    * Another sub item with 4 spaces
948* Another top level";
949
950        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
951        let result = rule.check(&ctx).unwrap();
952
953        // MD005 should accept consistent 4-space pattern
954        assert!(
955            result.is_empty(),
956            "MD005 should accept 4-space indentation when that's the pattern being used. Got {} warnings",
957            result.len()
958        );
959    }
960}