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