rumdl_lib/rules/
md005_list_indent.rs

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