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