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