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, Default)]
16pub struct MD005ListIndent {
17    /// Expected indentation for top-level lists (from MD007 config)
18    top_level_indent: usize,
19    /// Expected indentation increment for nested lists (from MD007 config)
20    md007_indent: usize,
21}
22
23impl MD005ListIndent {
24    /// Group related list blocks that should be treated as one logical list structure
25    fn group_related_list_blocks<'a>(
26        &self,
27        list_blocks: &'a [crate::lint_context::ListBlock],
28    ) -> Vec<Vec<&'a crate::lint_context::ListBlock>> {
29        if list_blocks.is_empty() {
30            return Vec::new();
31        }
32
33        let mut groups = Vec::new();
34        let mut current_group = vec![&list_blocks[0]];
35
36        for i in 1..list_blocks.len() {
37            let prev_block = &list_blocks[i - 1];
38            let current_block = &list_blocks[i];
39
40            // Check if blocks are consecutive (no significant gap between them)
41            let line_gap = current_block.start_line.saturating_sub(prev_block.end_line);
42
43            // Group blocks if they are close together (within 2 lines)
44            // This handles cases where mixed list types are split but should be treated together
45            if line_gap <= 2 {
46                current_group.push(current_block);
47            } else {
48                // Start a new group
49                groups.push(current_group);
50                current_group = vec![current_block];
51            }
52        }
53        groups.push(current_group);
54
55        groups
56    }
57
58    /// Check if a list item is continuation content of a parent list item
59    fn is_continuation_content(
60        &self,
61        ctx: &crate::lint_context::LintContext,
62        list_line: usize,
63        list_indent: usize,
64    ) -> bool {
65        // Look backward to find the true parent list item (not just immediate previous)
66        for line_num in (1..list_line).rev() {
67            if let Some(line_info) = ctx.line_info(line_num) {
68                if let Some(parent_list_item) = &line_info.list_item {
69                    let parent_marker_column = parent_list_item.marker_column;
70                    let parent_content_column = parent_list_item.content_column;
71
72                    // Skip list items at the same or greater indentation - we want the true parent
73                    if parent_marker_column >= list_indent {
74                        continue;
75                    }
76
77                    // Found a potential parent list item at a shallower indentation
78                    // Check if there are continuation lines between parent and current list
79                    let continuation_indent =
80                        self.find_continuation_indent_between(ctx, line_num + 1, list_line - 1, parent_content_column);
81
82                    if let Some(cont_indent) = continuation_indent {
83                        // If the current list's indent matches the continuation content indent,
84                        // OR if it's at the standard continuation list indentation (parent_content + 2),
85                        // it's continuation content
86                        let is_standard_continuation = list_indent == parent_content_column + 2;
87                        let matches_content_indent = list_indent == cont_indent;
88
89                        if matches_content_indent || is_standard_continuation {
90                            return true;
91                        }
92                    }
93
94                    // Special case: if this list item is at the same indentation as previous
95                    // continuation lists, it might be part of the same continuation block
96                    if list_indent > parent_marker_column {
97                        // Check if previous list items at this indentation are also continuation
98                        if self.has_continuation_list_at_indent(
99                            ctx,
100                            line_num,
101                            list_line,
102                            list_indent,
103                            parent_content_column,
104                        ) {
105                            return true;
106                        }
107
108                        // Also check if there are any continuation text blocks between the parent
109                        // and this list (even if there are other lists in between)
110                        if self.has_any_continuation_content_after_parent(
111                            ctx,
112                            line_num,
113                            list_line,
114                            parent_content_column,
115                        ) {
116                            return true;
117                        }
118                    }
119
120                    // If no continuation lines, this might still be a child list
121                    // but not continuation content, so continue looking for a parent
122                } else if !line_info.content.trim().is_empty() {
123                    // Found non-list content - only stop if it's at the left margin
124                    // (which would indicate we've moved out of any potential parent structure)
125                    let content = line_info.content.trim_start();
126                    let line_indent = line_info.content.len() - content.len();
127
128                    if line_indent == 0 {
129                        break;
130                    }
131                }
132            }
133        }
134        false
135    }
136
137    /// Check if there are continuation lists at the same indentation after a parent
138    fn has_continuation_list_at_indent(
139        &self,
140        ctx: &crate::lint_context::LintContext,
141        parent_line: usize,
142        current_line: usize,
143        list_indent: usize,
144        parent_content_column: usize,
145    ) -> bool {
146        // Look for list items between parent and current that are at the same indentation
147        // and are part of continuation content
148        for line_num in (parent_line + 1)..current_line {
149            if let Some(line_info) = ctx.line_info(line_num)
150                && let Some(list_item) = &line_info.list_item
151                && list_item.marker_column == list_indent
152            {
153                // Found a list at same indentation - check if it has continuation content before it
154                if self
155                    .find_continuation_indent_between(ctx, parent_line + 1, line_num - 1, parent_content_column)
156                    .is_some()
157                {
158                    return true;
159                }
160            }
161        }
162        false
163    }
164
165    /// Check if there are any continuation content blocks after a parent (anywhere between parent and current)
166    fn has_any_continuation_content_after_parent(
167        &self,
168        ctx: &crate::lint_context::LintContext,
169        parent_line: usize,
170        current_line: usize,
171        parent_content_column: usize,
172    ) -> bool {
173        // Look for any continuation content between parent and current line
174        for line_num in (parent_line + 1)..current_line {
175            if let Some(line_info) = ctx.line_info(line_num) {
176                let content = line_info.content.trim_start();
177
178                // Skip empty lines and list items
179                if content.is_empty() || line_info.list_item.is_some() {
180                    continue;
181                }
182
183                // Calculate indentation of this line
184                let line_indent = line_info.content.len() - content.len();
185
186                // If this line is indented more than the parent's content column,
187                // it's continuation content
188                if line_indent > parent_content_column {
189                    return true;
190                }
191            }
192        }
193        false
194    }
195
196    /// Find the indentation level used for continuation content between two line numbers
197    fn find_continuation_indent_between(
198        &self,
199        ctx: &crate::lint_context::LintContext,
200        start_line: usize,
201        end_line: usize,
202        parent_content_column: usize,
203    ) -> Option<usize> {
204        if start_line > end_line {
205            return None;
206        }
207
208        for line_num in start_line..=end_line {
209            if let Some(line_info) = ctx.line_info(line_num) {
210                let content = line_info.content.trim_start();
211
212                // Skip empty lines
213                if content.is_empty() {
214                    continue;
215                }
216
217                // Skip list items
218                if line_info.list_item.is_some() {
219                    continue;
220                }
221
222                // Calculate indentation of this line
223                let line_indent = line_info.content.len() - content.len();
224
225                // If this line is indented more than the parent's content column,
226                // it's continuation content - return its indentation level
227                if line_indent > parent_content_column {
228                    return Some(line_indent);
229                }
230            }
231        }
232        None
233    }
234
235    /// Check a group of related list blocks as one logical list structure
236    fn check_list_block_group(
237        &self,
238        ctx: &crate::lint_context::LintContext,
239        group: &[&crate::lint_context::ListBlock],
240        warnings: &mut Vec<LintWarning>,
241    ) -> Result<(), LintError> {
242        let line_index = LineIndex::new(ctx.content.to_string());
243
244        // Collect all list items from all blocks in the group
245        let mut all_list_items = Vec::new();
246
247        for list_block in group {
248            for &item_line in &list_block.item_lines {
249                if let Some(line_info) = ctx.line_info(item_line)
250                    && let Some(list_item) = &line_info.list_item
251                {
252                    // Calculate the effective indentation (considering blockquotes)
253                    let effective_indent = if let Some(blockquote) = &line_info.blockquote {
254                        // For blockquoted lists, use relative indentation within the blockquote
255                        list_item.marker_column.saturating_sub(blockquote.nesting_level * 2)
256                    } else {
257                        // For normal lists, use the marker column directly
258                        list_item.marker_column
259                    };
260
261                    // Skip list items that are continuation content
262                    if self.is_continuation_content(ctx, item_line, effective_indent) {
263                        continue;
264                    }
265
266                    all_list_items.push((item_line, effective_indent, line_info, list_item));
267                }
268            }
269        }
270
271        if all_list_items.is_empty() {
272            return Ok(());
273        }
274
275        // Sort by line number to process in order
276        all_list_items.sort_by_key(|(line_num, _, _, _)| *line_num);
277
278        // Build level mapping based on hierarchical structure
279        // Key insight: We need to identify which items are meant to be at the same level
280        // even if they have slightly different indentations (inconsistent formatting)
281        let mut level_map: HashMap<usize, usize> = HashMap::new();
282        let mut level_indents: HashMap<usize, Vec<usize>> = HashMap::new(); // Track all indents seen at each level
283
284        // Process items in order to build the level hierarchy
285        for i in 0..all_list_items.len() {
286            let (line_num, indent, _, _) = &all_list_items[i];
287
288            let level = if i == 0 {
289                // First item establishes level 1
290                level_indents.entry(1).or_default().push(*indent);
291                1
292            } else {
293                // Find the appropriate level for this item
294                let mut determined_level = 0;
295
296                // First, check if this indent matches any existing level exactly
297                for (lvl, indents) in &level_indents {
298                    if indents.contains(indent) {
299                        determined_level = *lvl;
300                        break;
301                    }
302                }
303
304                if determined_level == 0 {
305                    // No exact match - determine level based on hierarchy
306                    // Look for the most recent item with clearly less indentation (parent)
307                    for j in (0..i).rev() {
308                        let (prev_line, prev_indent, _, _) = &all_list_items[j];
309                        let prev_level = level_map[prev_line];
310
311                        // A clear parent has at least 2 spaces less indentation
312                        if *prev_indent + 2 <= *indent {
313                            // This is a child of prev_item
314                            determined_level = prev_level + 1;
315                            break;
316                        } else if (*prev_indent as i32 - *indent as i32).abs() <= 1 {
317                            // Within 1 space - likely meant to be same level but inconsistent
318                            determined_level = prev_level;
319                            break;
320                        } else if *prev_indent < *indent {
321                            // Less than 2 space difference but more than 1
322                            // This is ambiguous - could be same level or child
323                            // Look at the pattern: if prev_level already has items with similar indent,
324                            // this is probably meant to be at the same level
325                            if let Some(level_indents_list) = level_indents.get(&prev_level) {
326                                // Check if any indent at prev_level is close to this indent
327                                for &lvl_indent in level_indents_list {
328                                    if (lvl_indent as i32 - *indent as i32).abs() <= 1 {
329                                        // Close to an existing indent at prev_level
330                                        determined_level = prev_level;
331                                        break;
332                                    }
333                                }
334                            }
335                            if determined_level == 0 {
336                                // Still not determined - treat as child since it has more indent
337                                determined_level = prev_level + 1;
338                            }
339                            break;
340                        }
341                    }
342
343                    // If still not determined, default to level 1
344                    if determined_level == 0 {
345                        determined_level = 1;
346                    }
347
348                    // Record this indent for the level
349                    level_indents.entry(determined_level).or_default().push(*indent);
350                }
351
352                determined_level
353            };
354
355            level_map.insert(*line_num, level);
356        }
357
358        // Now group items by their level
359        let mut level_groups: HashMap<usize, Vec<(usize, usize, &crate::lint_context::LineInfo)>> = HashMap::new();
360        for (line_num, indent, line_info, _) in &all_list_items {
361            let level = level_map[line_num];
362            level_groups
363                .entry(level)
364                .or_default()
365                .push((*line_num, *indent, *line_info));
366        }
367
368        // For each level, check consistency
369        for (level, group) in level_groups {
370            // For level 1 (top-level), even single items should start at column 0
371            // For other levels, we need at least 2 items to check consistency
372            if level != 1 && group.len() < 2 {
373                continue;
374            }
375
376            // Sort by line number
377            let mut group = group;
378            group.sort_by_key(|(line_num, _, _)| *line_num);
379
380            // Check if all items at this level have the same indentation
381            let indents: std::collections::HashSet<usize> = group.iter().map(|(_, indent, _)| *indent).collect();
382
383            // For level 1, check if any item doesn't match expected top-level indentation
384            // For other levels, check for inconsistent indentation
385            let has_issue = if level == 1 {
386                // Top-level items should have the configured indentation
387                indents.iter().any(|&indent| indent != self.top_level_indent)
388            } else {
389                // Other levels need consistency
390                indents.len() > 1
391            };
392
393            if has_issue {
394                // Inconsistent indentation at this level!
395                // Determine what the correct indentation should be
396
397                // For level 1, it should be the configured top-level indent
398                // For other levels, we need to look at parent alignment or use the most common indent
399                let expected_indent = if level == 1 {
400                    self.top_level_indent
401                } else {
402                    // For non-top-level items, determine the expected indent
403                    // If MD007 is configured with fixed indentation, use that
404                    if self.md007_indent > 0 {
405                        // When MD007 indent is configured, use fixed indentation
406                        // Each level should be indented by md007_indent * (level - 1)
407                        (level - 1) * self.md007_indent
408                    } else {
409                        // No MD007 config, determine based on existing patterns
410                        let mut indent_counts: HashMap<usize, usize> = HashMap::new();
411                        for (_, indent, _) in &group {
412                            *indent_counts.entry(*indent).or_insert(0) += 1;
413                        }
414
415                        if indent_counts.len() == 1 {
416                            // All items have the same indent already
417                            *indent_counts.keys().next().unwrap()
418                        } else {
419                            // Multiple indents - pick the most common one
420                            // When counts are equal, prefer the smaller indentation
421                            // This handles cases where one item has correct indentation and another is wrong
422                            indent_counts
423                                .iter()
424                                .max_by(|(indent_a, count_a), (indent_b, count_b)| {
425                                    // First compare by count, then by preferring smaller indent
426                                    count_a.cmp(count_b).then(indent_b.cmp(indent_a))
427                                })
428                                .map(|(indent, _)| *indent)
429                                .unwrap()
430                        }
431                    }
432                };
433
434                // Flag all items that don't match the expected indentation
435                for (line_num, indent, line_info) in &group {
436                    if *indent != expected_indent {
437                        let message = format!(
438                            "Expected indentation of {} {}, found {}",
439                            expected_indent,
440                            if expected_indent == 1 { "space" } else { "spaces" },
441                            indent
442                        );
443
444                        let (start_line, start_col, end_line, end_col) = if *indent > 0 {
445                            calculate_match_range(*line_num, &line_info.content, 0, *indent)
446                        } else {
447                            calculate_match_range(*line_num, &line_info.content, 0, 1)
448                        };
449
450                        let fix_range = if *indent > 0 {
451                            let start_byte = line_index.line_col_to_byte_range(*line_num, 1).start;
452                            let end_byte = line_index.line_col_to_byte_range(*line_num, *indent + 1).start;
453                            start_byte..end_byte
454                        } else {
455                            let byte_pos = line_index.line_col_to_byte_range(*line_num, 1).start;
456                            byte_pos..byte_pos
457                        };
458
459                        let replacement = if expected_indent > 0 {
460                            " ".repeat(expected_indent)
461                        } else {
462                            String::new()
463                        };
464
465                        warnings.push(LintWarning {
466                            rule_name: Some(self.name()),
467                            line: start_line,
468                            column: start_col,
469                            end_line,
470                            end_column: end_col,
471                            message,
472                            severity: Severity::Warning,
473                            fix: Some(Fix {
474                                range: fix_range,
475                                replacement,
476                            }),
477                        });
478                    }
479                }
480            }
481        }
482
483        Ok(())
484    }
485
486    /// Migrated to use centralized list blocks for better performance and accuracy
487    fn check_optimized(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
488        let content = ctx.content;
489
490        // Early returns for common cases
491        if content.is_empty() {
492            return Ok(Vec::new());
493        }
494
495        // Quick check for any list blocks before processing
496        if ctx.list_blocks.is_empty() {
497            return Ok(Vec::new());
498        }
499
500        let mut warnings = Vec::new();
501
502        // Group consecutive list blocks that should be treated as one logical structure
503        // This is needed because mixed list types (ordered/unordered) get split into separate blocks
504        let block_groups = self.group_related_list_blocks(&ctx.list_blocks);
505
506        for group in block_groups {
507            self.check_list_block_group(ctx, &group, &mut warnings)?;
508        }
509
510        Ok(warnings)
511    }
512}
513
514impl Rule for MD005ListIndent {
515    fn name(&self) -> &'static str {
516        "MD005"
517    }
518
519    fn description(&self) -> &'static str {
520        "List indentation should be consistent"
521    }
522
523    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
524        // Use optimized version
525        self.check_optimized(ctx)
526    }
527
528    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
529        let warnings = self.check(ctx)?;
530        if warnings.is_empty() {
531            return Ok(ctx.content.to_string());
532        }
533
534        // Sort warnings by position (descending) to apply from end to start
535        let mut warnings_with_fixes: Vec<_> = warnings
536            .into_iter()
537            .filter_map(|w| w.fix.clone().map(|fix| (w, fix)))
538            .collect();
539        warnings_with_fixes.sort_by_key(|(_, fix)| std::cmp::Reverse(fix.range.start));
540
541        // Apply fixes to content
542        let mut content = ctx.content.to_string();
543        for (_, fix) in warnings_with_fixes {
544            if fix.range.start <= content.len() && fix.range.end <= content.len() {
545                content.replace_range(fix.range, &fix.replacement);
546            }
547        }
548
549        Ok(content)
550    }
551
552    fn category(&self) -> RuleCategory {
553        RuleCategory::List
554    }
555
556    /// Check if this rule should be skipped
557    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
558        // Skip if content is empty or has no list items
559        ctx.content.is_empty() || !ctx.lines.iter().any(|line| line.list_item.is_some())
560    }
561
562    /// Optimized check using document structure
563    fn check_with_structure(
564        &self,
565        ctx: &crate::lint_context::LintContext,
566        structure: &DocumentStructure,
567    ) -> LintResult {
568        // If no lists in structure, return early
569        if structure.list_lines.is_empty() {
570            return Ok(Vec::new());
571        }
572
573        // Use optimized check - it's already efficient enough
574        self.check_optimized(ctx)
575    }
576
577    fn as_any(&self) -> &dyn std::any::Any {
578        self
579    }
580
581    fn as_maybe_document_structure(&self) -> Option<&dyn crate::rule::MaybeDocumentStructure> {
582        Some(self)
583    }
584
585    fn default_config_section(&self) -> Option<(String, toml::Value)> {
586        None
587    }
588
589    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
590    where
591        Self: Sized,
592    {
593        // Check MD007 configuration to understand expected list indentation
594        let mut top_level_indent = 0;
595        let mut md007_indent = 2; // Default to 2 if not specified
596
597        // Try to get MD007 configuration
598        if let Some(md007_config) = config.rules.get("MD007") {
599            // Check for start_indented setting
600            if let Some(start_indented) = md007_config.values.get("start-indented")
601                && let Some(start_indented_bool) = start_indented.as_bool()
602                && start_indented_bool
603            {
604                // If start_indented is true, check for start_indent value
605                if let Some(start_indent) = md007_config.values.get("start-indent") {
606                    if let Some(indent_value) = start_indent.as_integer() {
607                        top_level_indent = indent_value as usize;
608                    }
609                } else {
610                    // Default start_indent when start_indented is true
611                    top_level_indent = 2;
612                }
613            }
614
615            // Also check 'indent' setting - this is the expected increment for nested lists
616            if let Some(indent) = md007_config.values.get("indent")
617                && let Some(indent_value) = indent.as_integer()
618            {
619                md007_indent = indent_value as usize;
620            }
621        }
622
623        Box::new(MD005ListIndent {
624            top_level_indent,
625            md007_indent,
626        })
627    }
628}
629
630impl crate::utils::document_structure::DocumentStructureExtensions for MD005ListIndent {
631    fn has_relevant_elements(
632        &self,
633        _ctx: &crate::lint_context::LintContext,
634        doc_structure: &crate::utils::document_structure::DocumentStructure,
635    ) -> bool {
636        !doc_structure.list_lines.is_empty()
637    }
638}
639
640#[cfg(test)]
641mod tests {
642    use super::*;
643    use crate::lint_context::LintContext;
644    use crate::utils::document_structure::DocumentStructureExtensions;
645
646    #[test]
647    fn test_valid_unordered_list() {
648        let rule = MD005ListIndent::default();
649        let content = "\
650* Item 1
651* Item 2
652  * Nested 1
653  * Nested 2
654* Item 3";
655        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
656        let result = rule.check(&ctx).unwrap();
657        assert!(result.is_empty());
658    }
659
660    #[test]
661    fn test_valid_ordered_list() {
662        let rule = MD005ListIndent::default();
663        let content = "\
6641. Item 1
6652. Item 2
666   1. Nested 1
667   2. Nested 2
6683. Item 3";
669        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
670        let result = rule.check(&ctx).unwrap();
671        // With dynamic alignment, nested items should align with parent's text content
672        // Ordered items starting with "1. " have text at column 3, so nested items need 3 spaces
673        assert!(result.is_empty());
674    }
675
676    #[test]
677    fn test_invalid_unordered_indent() {
678        let rule = MD005ListIndent::default();
679        let content = "\
680* Item 1
681 * Item 2
682   * Nested 1";
683        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
684        let result = rule.check(&ctx).unwrap();
685        // With dynamic alignment, line 3 correctly aligns with line 2's text position
686        // Only line 2 is incorrectly indented
687        assert_eq!(result.len(), 1);
688        let fixed = rule.fix(&ctx).unwrap();
689        assert_eq!(fixed, "* Item 1\n* Item 2\n   * Nested 1");
690    }
691
692    #[test]
693    fn test_invalid_ordered_indent() {
694        let rule = MD005ListIndent::default();
695        let content = "\
6961. Item 1
697 2. Item 2
698    1. Nested 1";
699        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
700        let result = rule.check(&ctx).unwrap();
701        assert_eq!(result.len(), 1);
702        let fixed = rule.fix(&ctx).unwrap();
703        // With dynamic alignment, ordered items align with parent's text content
704        // Line 1 text starts at col 3, so line 2 should have 3 spaces
705        // Line 3 already correctly aligns with line 2's text position
706        assert_eq!(fixed, "1. Item 1\n2. Item 2\n    1. Nested 1");
707    }
708
709    #[test]
710    fn test_mixed_list_types() {
711        let rule = MD005ListIndent::default();
712        let content = "\
713* Item 1
714  1. Nested ordered
715  * Nested unordered
716* Item 2";
717        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
718        let result = rule.check(&ctx).unwrap();
719        assert!(result.is_empty());
720    }
721
722    #[test]
723    fn test_multiple_levels() {
724        let rule = MD005ListIndent::default();
725        let content = "\
726* Level 1
727   * Level 2
728      * Level 3";
729        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
730        let result = rule.check(&ctx).unwrap();
731        // MD005 should now accept consistent 3-space increments
732        assert!(result.is_empty(), "MD005 should accept consistent indentation pattern");
733    }
734
735    #[test]
736    fn test_empty_lines() {
737        let rule = MD005ListIndent::default();
738        let content = "\
739* Item 1
740
741  * Nested 1
742
743* Item 2";
744        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
745        let result = rule.check(&ctx).unwrap();
746        assert!(result.is_empty());
747    }
748
749    #[test]
750    fn test_no_lists() {
751        let rule = MD005ListIndent::default();
752        let content = "\
753Just some text
754More text
755Even more text";
756        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
757        let result = rule.check(&ctx).unwrap();
758        assert!(result.is_empty());
759    }
760
761    #[test]
762    fn test_complex_nesting() {
763        let rule = MD005ListIndent::default();
764        let content = "\
765* Level 1
766  * Level 2
767    * Level 3
768  * Back to 2
769    1. Ordered 3
770    2. Still 3
771* Back to 1";
772        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
773        let result = rule.check(&ctx).unwrap();
774        assert!(result.is_empty());
775    }
776
777    #[test]
778    fn test_invalid_complex_nesting() {
779        let rule = MD005ListIndent::default();
780        let content = "\
781* Level 1
782   * Level 2
783     * Level 3
784   * Back to 2
785      1. Ordered 3
786     2. Still 3
787* Back to 1";
788        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
789        let result = rule.check(&ctx).unwrap();
790        // Lines 5-6 have inconsistent indentation (6 vs 5 spaces) for the same level
791        assert_eq!(result.len(), 1);
792        assert!(
793            result[0].message.contains("Expected indentation of 5 spaces, found 6")
794                || result[0].message.contains("Expected indentation of 6 spaces, found 5")
795        );
796    }
797
798    #[test]
799    fn test_with_document_structure() {
800        let rule = MD005ListIndent::default();
801
802        // Test with consistent list indentation
803        let content = "* Item 1\n* Item 2\n  * Nested item\n  * Another nested item";
804        let structure = DocumentStructure::new(content);
805        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
806        let result = rule.check_with_structure(&ctx, &structure).unwrap();
807        assert!(result.is_empty());
808
809        // Test with inconsistent list indentation
810        let content = "* Item 1\n* Item 2\n * Nested item\n  * Another nested item";
811        let structure = DocumentStructure::new(content);
812        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
813        let result = rule.check_with_structure(&ctx, &structure).unwrap();
814        assert!(!result.is_empty()); // Should have at least one warning
815
816        // Test with different level indentation issues
817        let content = "* Item 1\n  * Nested item\n * Another nested item with wrong indent";
818        let structure = DocumentStructure::new(content);
819        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
820        let result = rule.check_with_structure(&ctx, &structure).unwrap();
821        assert!(!result.is_empty()); // Should have at least one warning
822    }
823
824    // Additional comprehensive tests
825    #[test]
826    fn test_list_with_continuations() {
827        let rule = MD005ListIndent::default();
828        let content = "\
829* Item 1
830  This is a continuation
831  of the first item
832  * Nested item
833    with its own continuation
834* Item 2";
835        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
836        let result = rule.check(&ctx).unwrap();
837        assert!(result.is_empty());
838    }
839
840    #[test]
841    fn test_list_in_blockquote() {
842        let rule = MD005ListIndent::default();
843        let content = "\
844> * Item 1
845>   * Nested 1
846>   * Nested 2
847> * Item 2";
848        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
849        let result = rule.check(&ctx).unwrap();
850
851        // Blockquoted lists should have correct indentation within the blockquote context
852        assert!(
853            result.is_empty(),
854            "Expected no warnings for correctly indented blockquote list, got: {result:?}"
855        );
856    }
857
858    #[test]
859    fn test_list_with_code_blocks() {
860        let rule = MD005ListIndent::default();
861        let content = "\
862* Item 1
863  ```
864  code block
865  ```
866  * Nested item
867* Item 2";
868        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
869        let result = rule.check(&ctx).unwrap();
870        assert!(result.is_empty());
871    }
872
873    #[test]
874    fn test_list_with_tabs() {
875        let rule = MD005ListIndent::default();
876        let content = "* Item 1\n\t* Tab indented\n  * Space indented";
877        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
878        let result = rule.check(&ctx).unwrap();
879        // Should detect inconsistent indentation
880        assert!(!result.is_empty());
881    }
882
883    #[test]
884    fn test_inconsistent_at_same_level() {
885        let rule = MD005ListIndent::default();
886        let content = "\
887* Item 1
888  * Nested 1
889  * Nested 2
890   * Wrong indent for same level
891  * Nested 3";
892        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
893        let result = rule.check(&ctx).unwrap();
894        assert!(!result.is_empty());
895        // Should flag the inconsistent item
896        assert!(result.iter().any(|w| w.line == 4));
897    }
898
899    #[test]
900    fn test_zero_indent_top_level() {
901        let rule = MD005ListIndent::default();
902        // Use concat to preserve the leading space
903        let content = concat!(" * Wrong indent\n", "* Correct\n", "  * Nested");
904        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
905        let result = rule.check(&ctx).unwrap();
906
907        // Should flag the indented top-level item
908        assert!(!result.is_empty());
909        assert!(result.iter().any(|w| w.line == 1));
910    }
911
912    #[test]
913    fn test_fix_preserves_content() {
914        let rule = MD005ListIndent::default();
915        let content = "\
916* Item with **bold** and *italic*
917 * Wrong indent with `code`
918   * Also wrong with [link](url)";
919        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
920        let fixed = rule.fix(&ctx).unwrap();
921        assert!(fixed.contains("**bold**"));
922        assert!(fixed.contains("*italic*"));
923        assert!(fixed.contains("`code`"));
924        assert!(fixed.contains("[link](url)"));
925    }
926
927    #[test]
928    fn test_deeply_nested_lists() {
929        let rule = MD005ListIndent::default();
930        let content = "\
931* L1
932  * L2
933    * L3
934      * L4
935        * L5
936          * L6";
937        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
938        let result = rule.check(&ctx).unwrap();
939        assert!(result.is_empty());
940    }
941
942    #[test]
943    fn test_fix_multiple_issues() {
944        let rule = MD005ListIndent::default();
945        let content = "\
946* Item 1
947 * Wrong 1
948   * Wrong 2
949    * Wrong 3
950  * Correct
951   * Wrong 4";
952        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
953        let fixed = rule.fix(&ctx).unwrap();
954        // Should fix to consistent indentation
955        let lines: Vec<&str> = fixed.lines().collect();
956        assert_eq!(lines[0], "* Item 1");
957        // All level 2 items should have same indent
958        assert!(lines[1].starts_with("  * ") || lines[1].starts_with("* "));
959    }
960
961    #[test]
962    fn test_performance_large_document() {
963        let rule = MD005ListIndent::default();
964        let mut content = String::new();
965        for i in 0..100 {
966            content.push_str(&format!("* Item {i}\n"));
967            content.push_str(&format!("  * Nested {i}\n"));
968        }
969        let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard);
970        let result = rule.check(&ctx).unwrap();
971        assert!(result.is_empty());
972    }
973
974    #[test]
975    fn test_column_positions() {
976        let rule = MD005ListIndent::default();
977        let content = " * Wrong indent";
978        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
979        let result = rule.check(&ctx).unwrap();
980        assert_eq!(result.len(), 1);
981        assert_eq!(result[0].column, 1, "Expected column 1, got {}", result[0].column);
982        assert_eq!(
983            result[0].end_column, 2,
984            "Expected end_column 2, got {}",
985            result[0].end_column
986        );
987    }
988
989    #[test]
990    fn test_should_skip() {
991        let rule = MD005ListIndent::default();
992
993        // Empty content should skip
994        let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard);
995        assert!(rule.should_skip(&ctx));
996
997        // Content without lists should skip
998        let ctx = LintContext::new("Just plain text", crate::config::MarkdownFlavor::Standard);
999        assert!(rule.should_skip(&ctx));
1000
1001        // Content with lists should not skip
1002        let ctx = LintContext::new("* List item", crate::config::MarkdownFlavor::Standard);
1003        assert!(!rule.should_skip(&ctx));
1004
1005        let ctx = LintContext::new("1. Ordered list", crate::config::MarkdownFlavor::Standard);
1006        assert!(!rule.should_skip(&ctx));
1007    }
1008
1009    #[test]
1010    fn test_has_relevant_elements() {
1011        let rule = MD005ListIndent::default();
1012        let content = "* List item";
1013        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1014        let doc_structure = DocumentStructure::new(content);
1015        assert!(rule.has_relevant_elements(&ctx, &doc_structure));
1016
1017        let content = "No lists here";
1018        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1019        let doc_structure = DocumentStructure::new(content);
1020        assert!(!rule.has_relevant_elements(&ctx, &doc_structure));
1021    }
1022
1023    #[test]
1024    fn test_edge_case_single_space_indent() {
1025        let rule = MD005ListIndent::default();
1026        let content = "\
1027* Item 1
1028 * Single space - wrong
1029  * Two spaces - correct";
1030        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1031        let result = rule.check(&ctx).unwrap();
1032        // Both the single space and two space items get warnings
1033        // because they establish inconsistent indentation at the same level
1034        assert_eq!(result.len(), 2);
1035        assert!(result.iter().any(|w| w.line == 2 && w.message.contains("found 1")));
1036    }
1037
1038    #[test]
1039    fn test_edge_case_three_space_indent() {
1040        let rule = MD005ListIndent::default();
1041        let content = "\
1042* Item 1
1043   * Three spaces - wrong
1044  * Two spaces - correct";
1045        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1046        let result = rule.check(&ctx).unwrap();
1047        // Should flag the item with 3 spaces as inconsistent (2 spaces is correct)
1048        assert_eq!(result.len(), 1);
1049        assert!(result.iter().any(|w| w.line == 2 && w.message.contains("found 3")));
1050    }
1051
1052    #[test]
1053    fn test_nested_bullets_under_numbered_items() {
1054        let rule = MD005ListIndent::default();
1055        let content = "\
10561. **Active Directory/LDAP**
1057   - User authentication and directory services
1058   - LDAP for user information and validation
1059
10602. **Oracle Unified Directory (OUD)**
1061   - Extended user directory services
1062   - Verification of project account presence and changes";
1063        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1064        let result = rule.check(&ctx).unwrap();
1065        // Should have no warnings - 3 spaces is correct for bullets under numbered items
1066        assert!(
1067            result.is_empty(),
1068            "Expected no warnings for bullets with 3 spaces under numbered items, got: {result:?}"
1069        );
1070    }
1071
1072    #[test]
1073    fn test_nested_bullets_under_numbered_items_wrong_indent() {
1074        let rule = MD005ListIndent::default();
1075        let content = "\
10761. **Active Directory/LDAP**
1077  - Wrong: only 2 spaces
1078   - Correct: 3 spaces";
1079        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1080        let result = rule.check(&ctx).unwrap();
1081        // Should flag one of them as inconsistent
1082        assert_eq!(
1083            result.len(),
1084            1,
1085            "Expected 1 warning, got {}. Warnings: {:?}",
1086            result.len(),
1087            result
1088        );
1089        // Either line 2 or line 3 should be flagged for inconsistency
1090        assert!(
1091            result
1092                .iter()
1093                .any(|w| (w.line == 2 && w.message.contains("found 2"))
1094                    || (w.line == 3 && w.message.contains("found 3")))
1095        );
1096    }
1097
1098    #[test]
1099    fn test_regular_nested_bullets_still_work() {
1100        let rule = MD005ListIndent::default();
1101        let content = "\
1102* Top level
1103  * Second level (2 spaces is correct for bullets under bullets)
1104    * Third level (4 spaces)";
1105        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1106        let result = rule.check(&ctx).unwrap();
1107        // Should have no warnings - regular bullet nesting still uses 2-space increments
1108        assert!(
1109            result.is_empty(),
1110            "Expected no warnings for regular bullet nesting, got: {result:?}"
1111        );
1112    }
1113
1114    #[test]
1115    fn test_fix_range_accuracy() {
1116        let rule = MD005ListIndent::default();
1117        let content = " * Wrong indent";
1118        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1119        let result = rule.check(&ctx).unwrap();
1120        assert_eq!(result.len(), 1);
1121
1122        let fix = result[0].fix.as_ref().unwrap();
1123        // Fix should replace the single space with nothing (0 indent for level 1)
1124        assert_eq!(fix.replacement, "");
1125    }
1126
1127    #[test]
1128    fn test_four_space_indent_pattern() {
1129        let rule = MD005ListIndent::default();
1130        let content = "\
1131* Item 1
1132    * Item 2 with 4 spaces
1133        * Item 3 with 8 spaces
1134    * Item 4 with 4 spaces";
1135        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1136        let result = rule.check(&ctx).unwrap();
1137        // MD005 should accept consistent 4-space pattern
1138        assert!(
1139            result.is_empty(),
1140            "MD005 should accept consistent 4-space indentation pattern, got {} warnings",
1141            result.len()
1142        );
1143    }
1144
1145    #[test]
1146    fn test_issue_64_scenario() {
1147        // Test the exact scenario from issue #64
1148        let rule = MD005ListIndent::default();
1149        let content = "\
1150* Top level item
1151    * Sub item with 4 spaces (as configured in MD007)
1152        * Nested sub item with 8 spaces
1153    * Another sub item with 4 spaces
1154* Another top level";
1155
1156        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1157        let result = rule.check(&ctx).unwrap();
1158
1159        // MD005 should accept consistent 4-space pattern
1160        assert!(
1161            result.is_empty(),
1162            "MD005 should accept 4-space indentation when that's the pattern being used. Got {} warnings",
1163            result.len()
1164        );
1165    }
1166
1167    #[test]
1168    fn test_continuation_content_scenario() {
1169        let rule = MD005ListIndent::default();
1170        let content = "\
1171- **Changes to how the Python version is inferred** ([#16319](example))
1172
1173    In previous versions of Ruff, you could specify your Python version with:
1174
1175    - The `target-version` option in a `ruff.toml` file
1176    - The `project.requires-python` field in a `pyproject.toml` file";
1177
1178        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1179
1180        let result = rule.check(&ctx).unwrap();
1181
1182        // Should not flag continuation content lists as inconsistent
1183        assert!(
1184            result.is_empty(),
1185            "MD005 should not flag continuation content lists, got {} warnings: {:?}",
1186            result.len(),
1187            result
1188        );
1189    }
1190
1191    #[test]
1192    fn test_multiple_continuation_lists_scenario() {
1193        let rule = MD005ListIndent::default();
1194        let content = "\
1195- **Changes to how the Python version is inferred** ([#16319](example))
1196
1197    In previous versions of Ruff, you could specify your Python version with:
1198
1199    - The `target-version` option in a `ruff.toml` file
1200    - The `project.requires-python` field in a `pyproject.toml` file
1201
1202    In v0.10, config discovery has been updated to address this issue:
1203
1204    - If Ruff finds a `ruff.toml` file without a `target-version`, it will check
1205    - If Ruff finds a user-level configuration, the `requires-python` field will take precedence
1206    - If there is no config file, Ruff will search for the closest `pyproject.toml`";
1207
1208        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1209
1210        let result = rule.check(&ctx).unwrap();
1211
1212        // Should not flag continuation content lists as inconsistent
1213        assert!(
1214            result.is_empty(),
1215            "MD005 should not flag continuation content lists, got {} warnings: {:?}",
1216            result.len(),
1217            result
1218        );
1219    }
1220}