rumdl_lib/rules/
md029_ordered_list_prefix.rs

1/// Rule MD029: Ordered list item prefix
2///
3/// See [docs/md029.md](../../docs/md029.md) for full documentation, configuration, and examples.
4use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
5use crate::rule_config_serde::RuleConfig;
6use crate::utils::document_structure::{DocumentStructure, DocumentStructureExtensions};
7use crate::utils::regex_cache::ORDERED_LIST_MARKER_REGEX;
8use toml;
9
10mod md029_config;
11pub use md029_config::{ListStyle, MD029Config};
12
13#[derive(Debug, Clone, Default)]
14pub struct MD029OrderedListPrefix {
15    config: MD029Config,
16}
17
18impl MD029OrderedListPrefix {
19    pub fn new(style: ListStyle) -> Self {
20        Self {
21            config: MD029Config { style },
22        }
23    }
24
25    pub fn from_config_struct(config: MD029Config) -> Self {
26        Self { config }
27    }
28
29    #[inline]
30    fn parse_marker_number(marker: &str) -> Option<usize> {
31        // Handle marker format like "1." or "1"
32        let num_part = if let Some(stripped) = marker.strip_suffix('.') {
33            stripped
34        } else {
35            marker
36        };
37        num_part.parse::<usize>().ok()
38    }
39
40    #[inline]
41    fn get_expected_number(&self, index: usize, detected_style: Option<ListStyle>) -> usize {
42        let style = detected_style.unwrap_or(self.config.style.clone());
43        match style {
44            ListStyle::One | ListStyle::OneOne => 1,
45            ListStyle::Ordered => index + 1,
46            ListStyle::Ordered0 => index,
47            ListStyle::OneOrOrdered => {
48                // This shouldn't be called directly for OneOrOrdered,
49                // as we should have detected the actual style
50                1
51            }
52        }
53    }
54
55    /// Detect the style being used in a list based on the first few items
56    fn detect_list_style(
57        items: &[(
58            usize,
59            &crate::lint_context::LineInfo,
60            &crate::lint_context::ListItemInfo,
61        )],
62    ) -> ListStyle {
63        if items.len() < 2 {
64            // With only one item, we can't determine the style, default to OneOne
65            return ListStyle::OneOne;
66        }
67
68        // Check the first two items to determine the pattern
69        let first_num = Self::parse_marker_number(&items[0].2.marker);
70        let second_num = Self::parse_marker_number(&items[1].2.marker);
71
72        match (first_num, second_num) {
73            (Some(1), Some(1)) => ListStyle::OneOne,   // 1. 1. pattern
74            (Some(0), Some(1)) => ListStyle::Ordered0, // 0. 1. pattern
75            (Some(1), Some(2)) => ListStyle::Ordered,  // 1. 2. pattern
76            _ => {
77                // Check if all items are 1
78                let all_ones = items
79                    .iter()
80                    .all(|(_, _, item)| Self::parse_marker_number(&item.marker) == Some(1));
81                if all_ones {
82                    ListStyle::OneOne
83                } else {
84                    ListStyle::Ordered
85                }
86            }
87        }
88    }
89}
90
91impl Rule for MD029OrderedListPrefix {
92    fn name(&self) -> &'static str {
93        "MD029"
94    }
95
96    fn description(&self) -> &'static str {
97        "Ordered list marker value"
98    }
99
100    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
101        // Early returns for performance
102        if ctx.content.is_empty() {
103            return Ok(Vec::new());
104        }
105
106        // Quick check for any ordered list markers before processing
107        if !ctx.content.contains('.') || !ctx.content.lines().any(|line| ORDERED_LIST_MARKER_REGEX.is_match(line)) {
108            return Ok(Vec::new());
109        }
110
111        let mut warnings = Vec::new();
112
113        // Collect all list blocks that contain ordered items (not just purely ordered blocks)
114        // This handles mixed lists where ordered items are nested within unordered lists
115        let blocks_with_ordered: Vec<_> = ctx
116            .list_blocks
117            .iter()
118            .filter(|block| {
119                // Check if this block contains any ordered items
120                block.item_lines.iter().any(|&line| {
121                    ctx.line_info(line)
122                        .and_then(|info| info.list_item.as_ref())
123                        .map(|item| item.is_ordered)
124                        .unwrap_or(false)
125                })
126            })
127            .collect();
128
129        if blocks_with_ordered.is_empty() {
130            return Ok(Vec::new());
131        }
132
133        // Group consecutive list blocks that should be treated as continuous
134        let mut block_groups = Vec::new();
135        let mut current_group = vec![blocks_with_ordered[0]];
136
137        for i in 1..blocks_with_ordered.len() {
138            let prev_block = blocks_with_ordered[i - 1];
139            let current_block = blocks_with_ordered[i];
140
141            // This catches the pattern: 1. item / - sub / 1. item (should be 2.)
142            let has_only_unindented_lists =
143                self.has_only_unindented_lists_between(ctx, prev_block.end_line, current_block.start_line);
144
145            // Be more conservative: only group if there are no structural separators
146            // Check specifically for headings between the blocks
147            let has_heading_between =
148                self.has_heading_between_blocks(ctx, prev_block.end_line, current_block.start_line);
149
150            // Check if there are only code blocks/fences between these list blocks
151            let between_content_is_code_only =
152                self.is_only_code_between_blocks(ctx, prev_block.end_line, current_block.start_line);
153
154            // Group blocks if:
155            // 1. They have only code between them, OR
156            // 2. They have only unindented list items between them (the new case!)
157            let should_group = (between_content_is_code_only || has_only_unindented_lists)
158                && self.blocks_are_logically_continuous(ctx, prev_block.end_line, current_block.start_line)
159                && !has_heading_between;
160
161            if should_group {
162                // Treat as continuation of the same logical list
163                current_group.push(current_block);
164            } else {
165                // Start a new list group
166                block_groups.push(current_group);
167                current_group = vec![current_block];
168            }
169        }
170        block_groups.push(current_group);
171
172        // Process each group of blocks as a continuous list
173        for group in block_groups {
174            self.check_ordered_list_group(ctx, &group, &mut warnings);
175        }
176
177        Ok(warnings)
178    }
179
180    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
181        // Use the same logic as check() - just apply the fixes from warnings
182        let warnings = self.check(ctx)?;
183
184        if warnings.is_empty() {
185            // No changes needed
186            return Ok(ctx.content.to_string());
187        }
188
189        // Collect fixes and sort by position
190        // Only apply MD029 fixes (numbering), not MD029-style fixes (indentation)
191        let mut fixes: Vec<&Fix> = Vec::new();
192        for warning in &warnings {
193            // Skip MD029-style warnings (lazy continuation indentation)
194            if warning.rule_name == Some("MD029-style") {
195                continue;
196            }
197            if let Some(ref fix) = warning.fix {
198                fixes.push(fix);
199            }
200        }
201        fixes.sort_by_key(|f| f.range.start);
202
203        let mut result = String::new();
204        let mut last_pos = 0;
205        let content_bytes = ctx.content.as_bytes();
206
207        for fix in fixes {
208            // Add content before the fix
209            if last_pos < fix.range.start {
210                let chunk = &content_bytes[last_pos..fix.range.start];
211                result.push_str(
212                    std::str::from_utf8(chunk).map_err(|_| LintError::InvalidInput("Invalid UTF-8".to_string()))?,
213                );
214            }
215            // Add the replacement
216            result.push_str(&fix.replacement);
217            last_pos = fix.range.end;
218        }
219
220        // Add remaining content
221        if last_pos < content_bytes.len() {
222            let chunk = &content_bytes[last_pos..];
223            result.push_str(
224                std::str::from_utf8(chunk).map_err(|_| LintError::InvalidInput("Invalid UTF-8".to_string()))?,
225            );
226        }
227
228        Ok(result)
229    }
230
231    /// Optimized check using document structure
232    fn check_with_structure(
233        &self,
234        ctx: &crate::lint_context::LintContext,
235        _structure: &crate::utils::document_structure::DocumentStructure,
236    ) -> LintResult {
237        // For MD029, we need to use the regular check method to get lazy continuation detection
238        // The document structure optimization doesn't provide enough context for proper lazy continuation checking
239        self.check(ctx)
240    }
241
242    /// Get the category of this rule for selective processing
243    fn category(&self) -> RuleCategory {
244        RuleCategory::List
245    }
246
247    /// Check if this rule should be skipped
248    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
249        let content = ctx.content;
250        content.is_empty()
251            || !content.contains('1')
252            || (!content.contains("1.") && !content.contains("2.") && !content.contains("0."))
253    }
254
255    fn as_any(&self) -> &dyn std::any::Any {
256        self
257    }
258
259    fn as_maybe_document_structure(&self) -> Option<&dyn crate::rule::MaybeDocumentStructure> {
260        Some(self)
261    }
262
263    fn default_config_section(&self) -> Option<(String, toml::Value)> {
264        let default_config = MD029Config::default();
265        let json_value = serde_json::to_value(&default_config).ok()?;
266        let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
267        if let toml::Value::Table(table) = toml_value {
268            if !table.is_empty() {
269                Some((MD029Config::RULE_NAME.to_string(), toml::Value::Table(table)))
270            } else {
271                None
272            }
273        } else {
274            None
275        }
276    }
277
278    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
279    where
280        Self: Sized,
281    {
282        let rule_config = crate::rule_config_serde::load_rule_config::<MD029Config>(config);
283        Box::new(MD029OrderedListPrefix::from_config_struct(rule_config))
284    }
285}
286
287impl DocumentStructureExtensions for MD029OrderedListPrefix {
288    fn has_relevant_elements(
289        &self,
290        ctx: &crate::lint_context::LintContext,
291        _doc_structure: &DocumentStructure,
292    ) -> bool {
293        // This rule is relevant if there are any ordered list items
294        // We need to check even lists with all "1." items for:
295        // 1. Incorrect numbering according to configured style
296        // 2. Lazy continuation issues
297        ctx.list_blocks.iter().any(|block| block.is_ordered)
298    }
299}
300
301impl MD029OrderedListPrefix {
302    /// Check for lazy continuation lines in a list block
303    fn check_for_lazy_continuation(
304        &self,
305        ctx: &crate::lint_context::LintContext,
306        list_block: &crate::lint_context::ListBlock,
307        warnings: &mut Vec<LintWarning>,
308    ) {
309        // Check all lines in the block for lazy continuation
310        for line_num in list_block.start_line..=list_block.end_line {
311            if let Some(line_info) = ctx.line_info(line_num) {
312                // Skip list item lines themselves
313                if list_block.item_lines.contains(&line_num) {
314                    continue;
315                }
316
317                // Skip blank lines
318                if line_info.is_blank {
319                    continue;
320                }
321
322                // Skip lines that are in code blocks
323                if line_info.in_code_block {
324                    continue;
325                }
326
327                // Skip code fence lines
328                let trimmed = line_info.content.trim();
329                if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
330                    continue;
331                }
332
333                // Skip headings - they should never be treated as lazy continuation
334                if line_info.heading.is_some() {
335                    continue;
336                }
337
338                // Check if this is a lazy continuation (0-2 spaces)
339                if line_info.indent <= 2 && !line_info.content.trim().is_empty() {
340                    // This is a lazy continuation - add a style warning
341                    let col = line_info.indent + 1;
342
343                    warnings.push(LintWarning {
344                        rule_name: Some("MD029-style"),
345                        message: "List continuation should be indented (lazy continuation detected)".to_string(),
346                        line: line_num,
347                        column: col,
348                        end_line: line_num,
349                        end_column: col,
350                        severity: Severity::Warning,
351                        fix: Some(Fix {
352                            range: line_info.byte_offset..line_info.byte_offset,
353                            replacement: "   ".to_string(), // Add 3 spaces
354                        }),
355                    });
356                }
357            }
358        }
359    }
360
361    /// Check if blocks are separated only by unindented list items
362    /// This helps detect the pattern: 1. item / - sub / 1. item (should be 2.)
363    fn has_only_unindented_lists_between(
364        &self,
365        ctx: &crate::lint_context::LintContext,
366        end_line: usize,
367        start_line: usize,
368    ) -> bool {
369        if end_line >= start_line {
370            return false;
371        }
372
373        for line_num in (end_line + 1)..start_line {
374            if let Some(line_info) = ctx.line_info(line_num) {
375                let trimmed = line_info.content.trim();
376
377                // Skip empty lines
378                if trimmed.is_empty() {
379                    continue;
380                }
381
382                // If it's an unindented list item (column 0), that's what we're looking for
383                if line_info.list_item.is_some() && line_info.indent == 0 {
384                    continue;
385                }
386
387                // Any other non-empty content means it's not just unindented lists
388                return false;
389            }
390        }
391
392        true
393    }
394
395    /// Check if two list blocks are logically continuous (no major structural separators)
396    fn blocks_are_logically_continuous(
397        &self,
398        ctx: &crate::lint_context::LintContext,
399        end_line: usize,
400        start_line: usize,
401    ) -> bool {
402        if end_line >= start_line {
403            return false;
404        }
405
406        for line_num in (end_line + 1)..start_line {
407            if let Some(line_info) = ctx.line_info(line_num) {
408                // Skip empty lines
409                if line_info.is_blank {
410                    continue;
411                }
412
413                // Skip lines in code blocks
414                if line_info.in_code_block {
415                    continue;
416                }
417
418                // If there's any heading, the lists are not continuous
419                if line_info.heading.is_some() {
420                    return false;
421                }
422
423                // If there's any other non-empty content, be conservative and separate
424                let trimmed = line_info.content.trim();
425                if !trimmed.is_empty() && !trimmed.starts_with("```") && !trimmed.starts_with("~~~") {
426                    return false;
427                }
428            }
429        }
430
431        true
432    }
433
434    fn is_only_code_between_blocks(
435        &self,
436        ctx: &crate::lint_context::LintContext,
437        end_line: usize,
438        start_line: usize,
439    ) -> bool {
440        if end_line >= start_line {
441            return false;
442        }
443
444        // Calculate minimum continuation indent from the previous block's last item
445        let min_continuation_indent =
446            if let Some(prev_block) = ctx.list_blocks.iter().find(|block| block.end_line == end_line) {
447                // Get the last list item from the previous block
448                if let Some(&last_item_line) = prev_block.item_lines.last() {
449                    if let Some(line_info) = ctx.line_info(last_item_line) {
450                        if let Some(list_item) = &line_info.list_item {
451                            if list_item.is_ordered {
452                                list_item.marker.len() + 1 // Add 1 for space after ordered markers
453                            } else {
454                                2 // Unordered lists need at least 2 spaces
455                            }
456                        } else {
457                            3 // Fallback
458                        }
459                    } else {
460                        3 // Fallback
461                    }
462                } else {
463                    3 // Fallback
464                }
465            } else {
466                3 // Fallback
467            };
468
469        for line_num in (end_line + 1)..start_line {
470            if let Some(line_info) = ctx.line_info(line_num) {
471                let trimmed = line_info.content.trim();
472
473                // Skip empty lines
474                if trimmed.is_empty() {
475                    continue;
476                }
477
478                // Enhanced code block analysis
479                if line_info.in_code_block || trimmed.starts_with("```") || trimmed.starts_with("~~~") {
480                    // Check if this is a standalone code block that should separate lists
481                    if line_info.in_code_block {
482                        // Use the new classification system to determine if this code block separates lists
483                        let context = crate::utils::code_block_utils::CodeBlockUtils::analyze_code_block_context(
484                            &ctx.lines,
485                            line_num - 1,
486                            min_continuation_indent,
487                        );
488
489                        // If it's a standalone code block, lists should be separated
490                        if matches!(context, crate::utils::code_block_utils::CodeBlockContext::Standalone) {
491                            return false; // Lists are separated, not continuous
492                        }
493                    }
494                    continue; // Other code block lines (indented/adjacent) don't break continuity
495                }
496
497                // If there's a heading, lists are definitely separated
498                if line_info.heading.is_some() {
499                    return false;
500                }
501
502                // Any other non-empty content means lists are truly separated
503                return false;
504            }
505        }
506
507        true
508    }
509
510    /// Check if there are any headings between two list blocks
511    fn has_heading_between_blocks(
512        &self,
513        ctx: &crate::lint_context::LintContext,
514        end_line: usize,
515        start_line: usize,
516    ) -> bool {
517        if end_line >= start_line {
518            return false;
519        }
520
521        for line_num in (end_line + 1)..start_line {
522            if let Some(line_info) = ctx.line_info(line_num)
523                && line_info.heading.is_some()
524            {
525                return true;
526            }
527        }
528
529        false
530    }
531
532    /// Find the closest parent list item for an ordered item (can be ordered or unordered)
533    /// Returns the line number of the parent, or 0 if no parent found
534    fn find_parent_list_item(
535        &self,
536        ctx: &crate::lint_context::LintContext,
537        ordered_line: usize,
538        ordered_indent: usize,
539    ) -> usize {
540        // Look backward from the ordered item to find its closest parent
541        for line_num in (1..ordered_line).rev() {
542            if let Some(line_info) = ctx.line_info(line_num) {
543                if let Some(list_item) = &line_info.list_item {
544                    // Found a list item - check if it could be the parent
545                    if list_item.marker_column < ordered_indent {
546                        // This list item is at a lower indentation, so it's the parent
547                        return line_num;
548                    }
549                }
550                // If we encounter non-blank, non-list content at column 0, stop looking
551                else if !line_info.is_blank && line_info.indent == 0 {
552                    break;
553                }
554            }
555        }
556        0 // No parent found
557    }
558
559    /// Check a group of ordered list blocks that should be treated as continuous
560    fn check_ordered_list_group(
561        &self,
562        ctx: &crate::lint_context::LintContext,
563        group: &[&crate::lint_context::ListBlock],
564        warnings: &mut Vec<LintWarning>,
565    ) {
566        // Collect all items from all blocks in the group
567        let mut all_items = Vec::new();
568
569        for list_block in group {
570            // First, check for lazy continuation in this block
571            self.check_for_lazy_continuation(ctx, list_block, warnings);
572
573            for &item_line in &list_block.item_lines {
574                if let Some(line_info) = ctx.line_info(item_line)
575                    && let Some(list_item) = &line_info.list_item
576                {
577                    // Skip unordered lists (safety check)
578                    if !list_item.is_ordered {
579                        continue;
580                    }
581                    all_items.push((item_line, line_info, list_item));
582                }
583            }
584        }
585
586        // Sort by line number to ensure correct order
587        all_items.sort_by_key(|(line_num, _, _)| *line_num);
588
589        // Group items by indentation level AND parent context
590        // Use (indent_level, parent_line) as the key to separate sequences under different parents
591        type LevelGroups<'a> = std::collections::HashMap<
592            (usize, usize),
593            Vec<(
594                usize,
595                &'a crate::lint_context::LineInfo,
596                &'a crate::lint_context::ListItemInfo,
597            )>,
598        >;
599        let mut level_groups: LevelGroups = std::collections::HashMap::new();
600
601        for (line_num, line_info, list_item) in all_items {
602            // Find the closest parent list item (ordered or unordered) for this ordered item
603            let parent_line = self.find_parent_list_item(ctx, line_num, list_item.marker_column);
604
605            // Group by both marker column (indentation level) and parent context
606            level_groups
607                .entry((list_item.marker_column, parent_line))
608                .or_default()
609                .push((line_num, line_info, list_item));
610        }
611
612        // Process each indentation level and parent context separately
613        for ((_indent, _parent), mut group) in level_groups {
614            // Sort by line number to ensure correct order
615            group.sort_by_key(|(line_num, _, _)| *line_num);
616
617            // Detect the style for this list group if using OneOrOrdered
618            let detected_style = if self.config.style == ListStyle::OneOrOrdered {
619                Some(Self::detect_list_style(&group))
620            } else {
621                None
622            };
623
624            // Check each item in the group for correct sequence
625            for (idx, (line_num, line_info, list_item)) in group.iter().enumerate() {
626                // Parse the actual number from the marker (e.g., "1." -> 1)
627                if let Some(actual_num) = Self::parse_marker_number(&list_item.marker) {
628                    let expected_num = self.get_expected_number(idx, detected_style.clone());
629
630                    if actual_num != expected_num {
631                        // Calculate byte position for the fix
632                        let marker_start = line_info.byte_offset + list_item.marker_column;
633                        // Use the actual marker length (e.g., "05" is 2 chars, not 1)
634                        let number_len = if let Some(dot_pos) = list_item.marker.find('.') {
635                            dot_pos // Length up to the dot
636                        } else if let Some(paren_pos) = list_item.marker.find(')') {
637                            paren_pos // Length up to the paren
638                        } else {
639                            list_item.marker.len() // Fallback to full marker length
640                        };
641
642                        warnings.push(LintWarning {
643                            rule_name: Some(self.name()),
644                            message: format!(
645                                "Ordered list item number {actual_num} does not match style (expected {expected_num})"
646                            ),
647                            line: *line_num,
648                            column: list_item.marker_column + 1,
649                            end_line: *line_num,
650                            end_column: list_item.marker_column + number_len + 1,
651                            severity: Severity::Warning,
652                            fix: Some(Fix {
653                                range: marker_start..marker_start + number_len,
654                                replacement: expected_num.to_string(),
655                            }),
656                        });
657                    }
658                }
659            }
660        }
661    }
662}
663
664#[cfg(test)]
665mod tests {
666    use super::*;
667
668    use crate::utils::document_structure::DocumentStructure;
669
670    #[test]
671    fn test_with_document_structure() {
672        // Test with default style (ordered)
673        let rule = MD029OrderedListPrefix::default();
674
675        // Test with correctly ordered list
676        let content = "1. First item\n2. Second item\n3. Third item";
677        let structure = DocumentStructure::new(content);
678        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
679        let result = rule.check_with_structure(&ctx, &structure).unwrap();
680        assert!(result.is_empty());
681
682        // Test with incorrectly ordered list
683        let content = "1. First item\n3. Third item\n5. Fifth item";
684        let structure = DocumentStructure::new(content);
685        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
686        let result = rule.check_with_structure(&ctx, &structure).unwrap();
687        assert_eq!(result.len(), 2); // Should have warnings for items 3 and 5
688
689        // Test with one-one style
690        let rule = MD029OrderedListPrefix::new(ListStyle::OneOne);
691        let content = "1. First item\n2. Second item\n3. Third item";
692        let structure = DocumentStructure::new(content);
693        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
694        let result = rule.check_with_structure(&ctx, &structure).unwrap();
695        assert_eq!(result.len(), 2); // Should have warnings for items 2 and 3
696
697        // Test with ordered0 style
698        let rule = MD029OrderedListPrefix::new(ListStyle::Ordered0);
699        let content = "0. First item\n1. Second item\n2. Third item";
700        let structure = DocumentStructure::new(content);
701        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
702        let result = rule.check_with_structure(&ctx, &structure).unwrap();
703        assert!(result.is_empty());
704    }
705
706    #[test]
707    fn test_redundant_computation_fix() {
708        // This test confirms that the redundant computation bug is fixed
709        // Previously: get_list_number() was called twice (once for is_some(), once for unwrap())
710        // Now: get_list_number() is called once with if let pattern
711
712        let rule = MD029OrderedListPrefix::default();
713
714        // Test with mixed valid and edge case content
715        let content = "1. First item\n3. Wrong number\n2. Another wrong number";
716        let structure = DocumentStructure::new(content);
717        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
718
719        // This should not panic and should produce warnings for incorrect numbering
720        let result = rule.check_with_structure(&ctx, &structure).unwrap();
721        assert_eq!(result.len(), 2); // Should have warnings for items 3 and 2
722
723        // Verify the warnings have correct content
724        assert!(result[0].message.contains("3 does not match style (expected 2)"));
725        assert!(result[1].message.contains("2 does not match style (expected 3)"));
726    }
727
728    #[test]
729    fn test_performance_improvement() {
730        // This test verifies that the fix improves performance by avoiding redundant calls
731        let rule = MD029OrderedListPrefix::default();
732
733        // Create a larger list to test performance
734        let mut content = String::new();
735        for i in 1..=100 {
736            content.push_str(&format!("{}. Item {}\n", i + 1, i)); // All wrong numbers
737        }
738
739        let structure = DocumentStructure::new(&content);
740        let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Standard);
741
742        // This should complete without issues and produce warnings for all items
743        let result = rule.check_with_structure(&ctx, &structure).unwrap();
744        assert_eq!(result.len(), 100); // Should have warnings for all 100 items
745
746        // Verify first and last warnings
747        assert!(result[0].message.contains("2 does not match style (expected 1)"));
748        assert!(result[99].message.contains("101 does not match style (expected 100)"));
749    }
750
751    #[test]
752    fn test_one_or_ordered_with_all_ones() {
753        // Test OneOrOrdered style with all 1s (should pass)
754        let rule = MD029OrderedListPrefix::new(ListStyle::OneOrOrdered);
755
756        let content = "1. First item\n1. Second item\n1. Third item";
757        let structure = DocumentStructure::new(content);
758        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
759        let result = rule.check_with_structure(&ctx, &structure).unwrap();
760        assert!(result.is_empty(), "All ones should be valid in OneOrOrdered mode");
761    }
762
763    #[test]
764    fn test_one_or_ordered_with_sequential() {
765        // Test OneOrOrdered style with sequential numbering (should pass)
766        let rule = MD029OrderedListPrefix::new(ListStyle::OneOrOrdered);
767
768        let content = "1. First item\n2. Second item\n3. Third item";
769        let structure = DocumentStructure::new(content);
770        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
771        let result = rule.check_with_structure(&ctx, &structure).unwrap();
772        assert!(
773            result.is_empty(),
774            "Sequential numbering should be valid in OneOrOrdered mode"
775        );
776    }
777
778    #[test]
779    fn test_one_or_ordered_with_mixed_style() {
780        // Test OneOrOrdered style with mixed numbering (should fail)
781        let rule = MD029OrderedListPrefix::new(ListStyle::OneOrOrdered);
782
783        let content = "1. First item\n2. Second item\n1. Third item";
784        let structure = DocumentStructure::new(content);
785        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
786        let result = rule.check_with_structure(&ctx, &structure).unwrap();
787        assert_eq!(result.len(), 1, "Mixed style should produce one warning");
788        assert!(result[0].message.contains("1 does not match style (expected 3)"));
789    }
790
791    #[test]
792    fn test_one_or_ordered_separate_lists() {
793        // Test OneOrOrdered with separate lists using different styles (should pass)
794        let rule = MD029OrderedListPrefix::new(ListStyle::OneOrOrdered);
795
796        let content = "# First list\n\n1. Item A\n1. Item B\n\n# Second list\n\n1. Item X\n2. Item Y\n3. Item Z";
797        let structure = DocumentStructure::new(content);
798        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
799        let result = rule.check_with_structure(&ctx, &structure).unwrap();
800        assert!(
801            result.is_empty(),
802            "Separate lists can use different styles in OneOrOrdered mode"
803        );
804    }
805}