Skip to main content

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::lint_context::ParsedListItem;
5use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
6use crate::utils::range_utils::byte_to_char_count;
7use crate::utils::regex_cache::ORDERED_LIST_MARKER_REGEX;
8use std::collections::HashMap;
9use toml;
10
11mod md029_config;
12pub use md029_config::ListStyle;
13pub(super) use md029_config::MD029Config;
14
15#[derive(Debug, Clone, Default)]
16pub struct MD029OrderedListPrefix {
17    config: MD029Config,
18}
19
20impl MD029OrderedListPrefix {
21    pub fn new(style: ListStyle) -> Self {
22        Self {
23            config: MD029Config { style },
24        }
25    }
26
27    pub fn from_config_struct(config: MD029Config) -> Self {
28        Self { config }
29    }
30
31    /// The number of an ordered list marker, whichever delimiter closes it:
32    /// `1.` and `1)` both number the item 1.
33    #[inline]
34    fn parse_marker_number(marker: &str) -> Option<usize> {
35        marker.strip_suffix(['.', ')']).unwrap_or(marker).parse::<usize>().ok()
36    }
37
38    /// Calculate the expected number for a list item.
39    /// The `start_value` is the CommonMark-provided start value for the list.
40    /// For style `Ordered`, items should be `start_value, start_value+1, start_value+2, ...`
41    #[inline]
42    fn get_expected_number(&self, index: usize, detected_style: Option<ListStyle>, start_value: u64) -> usize {
43        // Use detected_style when the configuration is auto-detect mode (OneOrOrdered or Consistent)
44        // For explicit style configurations, always use the configured style
45        let style = match self.config.style {
46            ListStyle::OneOrOrdered | ListStyle::Consistent => detected_style.unwrap_or(ListStyle::OneOne),
47            _ => self.config.style,
48        };
49
50        match style {
51            ListStyle::One | ListStyle::OneOne => 1,
52            ListStyle::Ordered => (start_value as usize) + index,
53            ListStyle::Ordered0 => index,
54            ListStyle::OneOrOrdered | ListStyle::Consistent => {
55                // This shouldn't be reached since we handle these above
56                1
57            }
58        }
59    }
60
61    /// Detect the style being used in a list by checking all items for prevalence.
62    /// The `start_value` parameter is the CommonMark-provided list start value.
63    fn detect_list_style(items: &[ParsedListItem<'_>], start_value: u64) -> ListStyle {
64        if items.len() < 2 {
65            // With only one item, check if it matches the start value
66            // If so, treat as Ordered (respects CommonMark start value)
67            // Otherwise, check if it's 1 (OneOne style)
68            let first_num = Self::parse_marker_number(items[0].marker());
69            if first_num == Some(start_value as usize) {
70                return ListStyle::Ordered;
71            }
72            return ListStyle::OneOne;
73        }
74
75        let first_num = Self::parse_marker_number(items[0].marker());
76        let second_num = Self::parse_marker_number(items[1].marker());
77
78        // Fast path: Check for Ordered0 special case (starts with 0, 1)
79        if matches!((first_num, second_num), (Some(0), Some(1))) {
80            return ListStyle::Ordered0;
81        }
82
83        // Fast path: If first 2 items aren't both "1", it must be Ordered (O(1))
84        // This handles ~95% of lists instantly: "1. 2. 3...", "2. 3. 4...", etc.
85        if first_num != Some(1) || second_num != Some(1) {
86            return ListStyle::Ordered;
87        }
88
89        // Slow path: Both first items are "1", check if ALL are "1" (O(n))
90        // This is necessary for lists like "1. 1. 1..." vs "1. 1. 2. 3..."
91        let all_ones = items
92            .iter()
93            .all(|item| Self::parse_marker_number(item.marker()) == Some(1));
94
95        if all_ones {
96            ListStyle::OneOne
97        } else {
98            ListStyle::Ordered
99        }
100    }
101
102    /// Check a CommonMark-grouped list for correct ordering.
103    /// Uses the CommonMark start value to validate items (e.g., a list starting at 11
104    /// expects items 11, 12, 13... - no violation there).
105    fn check_commonmark_list_group(
106        &self,
107        ctx: &crate::lint_context::LintContext,
108        group: &[ParsedListItem<'_>],
109        warnings: &mut Vec<LintWarning>,
110        document_wide_style: Option<ListStyle>,
111        start_value: u64,
112    ) {
113        if group.is_empty() {
114            return;
115        }
116
117        // Group items by indentation level (marker_column) to handle nested lists
118        type LevelGroups<'a> = HashMap<usize, Vec<ParsedListItem<'a>>>;
119        let mut level_groups: LevelGroups = HashMap::new();
120
121        for &list_item in group {
122            level_groups
123                .entry(list_item.marker_column())
124                .or_default()
125                .push(list_item);
126        }
127
128        // Process each indentation level in sorted order for deterministic output
129        let mut sorted_levels: Vec<_> = level_groups.into_iter().collect();
130        sorted_levels.sort_by_key(|(indent, _)| *indent);
131
132        for (_indent, mut items) in sorted_levels {
133            // Sort by line number
134            items.sort_by_key(|item| item.line_num());
135
136            if items.is_empty() {
137                continue;
138            }
139
140            // Determine style for this group
141            let detected_style = if let Some(doc_style) = document_wide_style {
142                Some(doc_style)
143            } else if self.config.style == ListStyle::OneOrOrdered {
144                Some(Self::detect_list_style(&items, start_value))
145            } else {
146                None
147            };
148
149            // Check each item using the CommonMark start value
150            for (idx, list_item) in items.iter().copied().enumerate() {
151                if let Some(actual_num) = Self::parse_marker_number(list_item.marker()) {
152                    let expected_num = self.get_expected_number(idx, detected_style, start_value);
153
154                    if actual_num != expected_num {
155                        let line_num = list_item.line_num();
156                        let line_info = list_item.line_info();
157                        let marker_start = list_item.marker_byte_offset();
158                        let number_len = if let Some(dot_pos) = list_item.marker().find('.') {
159                            dot_pos
160                        } else if let Some(paren_pos) = list_item.marker().find(')') {
161                            paren_pos
162                        } else {
163                            list_item.marker().len()
164                        };
165
166                        let style_name = match detected_style.as_ref().unwrap_or(&ListStyle::Ordered) {
167                            ListStyle::OneOne => "one",
168                            ListStyle::Ordered => "ordered",
169                            ListStyle::Ordered0 => "ordered0",
170                            _ => "ordered",
171                        };
172
173                        let style_context = match self.config.style {
174                            ListStyle::Consistent => format!("document style '{style_name}'"),
175                            ListStyle::OneOrOrdered => format!("list style '{style_name}'"),
176                            ListStyle::One | ListStyle::OneOne => "configured style 'one'".to_string(),
177                            ListStyle::Ordered => "configured style 'ordered'".to_string(),
178                            ListStyle::Ordered0 => "configured style 'ordered0'".to_string(),
179                        };
180
181                        // Only provide auto-fix when:
182                        // 1. The list starts at 1 (default numbering), OR
183                        // 2. We're using explicit 'one' style (numbers are meaningless)
184                        // When start_value > 1, the user explicitly chose that number,
185                        // so auto-fixing would destroy their intent.
186                        let should_provide_fix =
187                            start_value == 1 || matches!(self.config.style, ListStyle::One | ListStyle::OneOne);
188
189                        // marker_column is a byte offset within the line; convert to a
190                        // character column for the diagnostic.
191                        let line_text = line_info.content(ctx.content);
192
193                        warnings.push(LintWarning {
194                            rule_name: Some(self.name().to_string()),
195                            message: format!(
196                                "Ordered list item number {actual_num} does not match {style_context} (expected {expected_num})"
197                            ),
198                            line: line_num,
199                            column: byte_to_char_count(line_text, list_item.marker_column()),
200                            end_line: line_num,
201                            end_column: byte_to_char_count(line_text, list_item.marker_column() + number_len),
202                            severity: Severity::Warning,
203                            fix: if should_provide_fix {
204                                Some(Fix::new(marker_start..marker_start + number_len, expected_num.to_string()))
205                            } else {
206                                None
207                            },
208                        });
209                    }
210                }
211            }
212        }
213    }
214}
215
216impl Rule for MD029OrderedListPrefix {
217    fn name(&self) -> &'static str {
218        "MD029"
219    }
220
221    fn description(&self) -> &'static str {
222        "Ordered list marker value"
223    }
224
225    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
226        // Early returns for performance
227        if ctx.content.is_empty() {
228            return Ok(Vec::new());
229        }
230
231        // Quick check for any ordered list markers before processing
232        if (!ctx.content.contains('.') && !ctx.content.contains(')'))
233            || !ctx.content.lines().any(|line| ORDERED_LIST_MARKER_REGEX.is_match(line))
234        {
235            return Ok(Vec::new());
236        }
237
238        let mut warnings = Vec::new();
239
240        // Use pulldown-cmark's AST for authoritative list membership and start values.
241        // This respects CommonMark's list start values (e.g., a list starting at 11
242        // expects items 11, 12, 13... - no violation there).
243        let list_groups = ctx.commonmark_ordered_lists();
244
245        if list_groups.is_empty() {
246            return Ok(Vec::new());
247        }
248
249        // For Consistent style, detect document-wide prevalent style
250        let document_wide_style = if self.config.style == ListStyle::Consistent {
251            // Collect ALL ordered items from ALL groups
252            let mut all_document_items = Vec::new();
253            for list in list_groups {
254                all_document_items.extend(list.items());
255            }
256            // Detect style across entire document (use 1 as default for pattern detection)
257            if !all_document_items.is_empty() {
258                Some(Self::detect_list_style(&all_document_items, 1))
259            } else {
260                None
261            }
262        } else {
263            None
264        };
265
266        // Process each CommonMark-defined list group with its start value
267        for list in list_groups {
268            let items: Vec<_> = list.items().collect();
269            self.check_commonmark_list_group(ctx, &items, &mut warnings, document_wide_style, list.start_value());
270        }
271
272        // Sort warnings by line number for deterministic output
273        warnings.sort_by_key(|w| (w.line, w.column));
274
275        Ok(warnings)
276    }
277
278    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
279        // Note: do not call self.should_skip() here — MD029's should_skip only covers
280        // unordered list markers (*, -, +), not ordered list markers (digits + . or )).
281        // check() has its own fast-path early-return for documents without ordered markers.
282        let warnings = self.check(ctx)?;
283        if warnings.is_empty() {
284            return Ok(ctx.content.to_string());
285        }
286        let warnings =
287            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
288        crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings).map_err(LintError::InvalidInput)
289    }
290
291    /// Get the category of this rule for selective processing
292    fn category(&self) -> RuleCategory {
293        RuleCategory::List
294    }
295
296    /// Skip a document with no ordered list. `likely_has_lists` counts bullet
297    /// characters only, so it says nothing about ordered lists; the parsed list
298    /// set does, and `check` reads the same cached value.
299    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
300        ctx.content.is_empty() || ctx.commonmark_ordered_lists().is_empty()
301    }
302
303    fn as_any(&self) -> &dyn std::any::Any {
304        self
305    }
306
307    crate::impl_rule_config_methods!(MD029Config);
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313
314    #[test]
315    fn test_basic_functionality() {
316        // Test with default style (ordered)
317        let rule = MD029OrderedListPrefix::default();
318
319        // Test with correctly ordered list
320        let content = "1. First item\n2. Second item\n3. Third item";
321        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
322        let result = rule.check(&ctx).unwrap();
323        assert!(result.is_empty());
324
325        // Test with incorrectly ordered list
326        let content = "1. First item\n3. Third item\n5. Fifth item";
327        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
328        let result = rule.check(&ctx).unwrap();
329        assert_eq!(result.len(), 2); // Should have warnings for items 3 and 5
330
331        // Test with one-one style
332        let rule = MD029OrderedListPrefix::new(ListStyle::OneOne);
333        let content = "1. First item\n2. Second item\n3. Third item";
334        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
335        let result = rule.check(&ctx).unwrap();
336        assert_eq!(result.len(), 2); // Should have warnings for items 2 and 3
337
338        // Test with ordered0 style
339        let rule = MD029OrderedListPrefix::new(ListStyle::Ordered0);
340        let content = "0. First item\n1. Second item\n2. Third item";
341        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
342        let result = rule.check(&ctx).unwrap();
343        assert!(result.is_empty());
344    }
345
346    #[test]
347    fn test_redundant_computation_fix() {
348        // This test confirms that the redundant computation bug is fixed
349        // Previously: get_list_number() was called twice (once for is_some(), once for unwrap())
350        // Now: get_list_number() is called once with if let pattern
351
352        let rule = MD029OrderedListPrefix::default();
353
354        // Test with mixed valid and edge case content
355        let content = "1. First item\n3. Wrong number\n2. Another wrong number";
356        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
357
358        // This should not panic and should produce warnings for incorrect numbering
359        let result = rule.check(&ctx).unwrap();
360        assert_eq!(result.len(), 2); // Should have warnings for items 3 and 2
361
362        // Verify the warnings have correct content
363        assert!(result[0].message.contains('3') && result[0].message.contains("expected 2"));
364        assert!(result[1].message.contains('2') && result[1].message.contains("expected 3"));
365    }
366
367    #[test]
368    fn test_performance_improvement() {
369        // This test verifies the rule handles large lists without performance issues
370        let rule = MD029OrderedListPrefix::default();
371
372        // Create a larger list with WRONG numbers: 1, 5, 10, 15, ...
373        // Starting at 1, CommonMark expects 1, 2, 3, 4, ...
374        // So items 2-100 are all wrong (expected 2, got 5; expected 3, got 10; etc.)
375        let mut content = String::from("1. Item 1\n"); // First item correct
376        for i in 2..=100 {
377            content.push_str(&format!("{}. Item {}\n", i * 5 - 5, i)); // Wrong numbers
378        }
379
380        let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
381
382        // This should complete without issues and produce warnings for items 2-100
383        let result = rule.check(&ctx).unwrap();
384        assert_eq!(result.len(), 99, "Should have warnings for items 2-100 (99 items)");
385
386        // First wrong item: "5. Item 2" (expected 2)
387        assert!(result[0].message.contains('5') && result[0].message.contains("expected 2"));
388    }
389
390    #[test]
391    fn test_one_or_ordered_with_all_ones() {
392        // Test OneOrOrdered style with all 1s (should pass)
393        let rule = MD029OrderedListPrefix::new(ListStyle::OneOrOrdered);
394
395        let content = "1. First item\n1. Second item\n1. Third item";
396        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
397        let result = rule.check(&ctx).unwrap();
398        assert!(result.is_empty(), "All ones should be valid in OneOrOrdered mode");
399    }
400
401    #[test]
402    fn test_one_or_ordered_with_sequential() {
403        // Test OneOrOrdered style with sequential numbering (should pass)
404        let rule = MD029OrderedListPrefix::new(ListStyle::OneOrOrdered);
405
406        let content = "1. First item\n2. Second item\n3. Third item";
407        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
408        let result = rule.check(&ctx).unwrap();
409        assert!(
410            result.is_empty(),
411            "Sequential numbering should be valid in OneOrOrdered mode"
412        );
413    }
414
415    #[test]
416    fn test_one_or_ordered_with_mixed_style() {
417        // Test OneOrOrdered style with mixed numbering (should fail)
418        let rule = MD029OrderedListPrefix::new(ListStyle::OneOrOrdered);
419
420        let content = "1. First item\n2. Second item\n1. Third item";
421        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
422        let result = rule.check(&ctx).unwrap();
423        assert_eq!(result.len(), 1, "Mixed style should produce one warning");
424        assert!(result[0].message.contains('1') && result[0].message.contains("expected 3"));
425    }
426
427    #[test]
428    fn test_one_or_ordered_separate_lists() {
429        // Test OneOrOrdered with separate lists using different styles (should pass)
430        let rule = MD029OrderedListPrefix::new(ListStyle::OneOrOrdered);
431
432        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";
433        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
434        let result = rule.check(&ctx).unwrap();
435        assert!(
436            result.is_empty(),
437            "Separate lists can use different styles in OneOrOrdered mode"
438        );
439    }
440
441    /// Core invariant: for every warning with a Fix, the replacement text must
442    /// match what fix() produces for the same byte range in the output.
443    #[test]
444    fn test_check_and_fix_produce_identical_replacements() {
445        let rule = MD029OrderedListPrefix::default();
446
447        let inputs = [
448            "1. First\n3. Skip\n5. Skip\n",
449            "1. First\n3. Third\n2. Second\n",
450            "1. A\n\n3. B\n",
451            "- Unordered\n\n1. A\n3. B\n",
452            "1. A\n   1. Nested wrong\n   3. Nested\n2. B\n",
453        ];
454
455        for input in &inputs {
456            let ctx = crate::lint_context::LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
457            let warnings = rule.check(&ctx).unwrap();
458            let fixed = rule.fix(&ctx).unwrap();
459
460            // fix() must be idempotent: applying it again produces the same output
461            let ctx2 = crate::lint_context::LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
462            let fixed_twice = rule.fix(&ctx2).unwrap();
463            assert_eq!(
464                fixed, fixed_twice,
465                "fix() is not idempotent for input: {input:?}\nfirst:  {fixed:?}\nsecond: {fixed_twice:?}"
466            );
467
468            // After fixing, check() should produce no warnings
469            let warnings_after = rule.check(&ctx2).unwrap();
470            assert!(
471                warnings_after.is_empty(),
472                "check() should produce no warnings after fix() for input: {input:?}\nfixed: {fixed:?}\nremaining: {warnings_after:?}"
473            );
474
475            // For every warning with a Fix, applying the fix alone should match
476            // the content at the same range in the final fixed output
477            for warning in &warnings {
478                if let Some(ref fix) = warning.fix {
479                    assert!(
480                        fix.range.end <= input.len(),
481                        "Fix range exceeds input length for {input:?}"
482                    );
483                }
484            }
485        }
486    }
487
488    /// fix(fix(x)) == fix(x)
489    #[test]
490    fn test_fix_idempotent() {
491        let rule = MD029OrderedListPrefix::default();
492
493        let inputs = [
494            "1. A\n3. B\n5. C\n",
495            "# Intro\n\n1. First\n3. Third\n",
496            "1. A\n1. B\n1. C\n",
497        ];
498
499        for input in &inputs {
500            let ctx1 = crate::lint_context::LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
501            let fixed_once = rule.fix(&ctx1).unwrap();
502            let ctx2 =
503                crate::lint_context::LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
504            let fixed_twice = rule.fix(&ctx2).unwrap();
505            assert_eq!(fixed_once, fixed_twice, "fix() is not idempotent for input: {input:?}");
506        }
507    }
508
509    /// Example list markers `(@)` and `(@label)` must not be reported under
510    /// the Pandoc flavor — they are not ordered list items.
511    #[test]
512    fn test_pandoc_skips_example_list_markers() {
513        use crate::config::MarkdownFlavor;
514        use crate::lint_context::LintContext;
515        let rule = MD029OrderedListPrefix::default();
516        let content = "(@) First.\n(@good) Second.\n(@) Third.\n";
517        let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
518        let result = rule.check(&ctx).unwrap();
519        assert!(
520            result.is_empty(),
521            "MD029 should not flag (@)/(@label) example markers under Pandoc: {result:?}"
522        );
523    }
524
525    /// A real ordered list interleaved with example markers should validate
526    /// only the digit-prefixed items, ignoring the example markers.
527    #[test]
528    fn test_pandoc_example_markers_do_not_break_real_ordered_list() {
529        use crate::config::MarkdownFlavor;
530        use crate::lint_context::LintContext;
531        let rule = MD029OrderedListPrefix::default();
532        let content = "1. Real first.\n\n(@) Example.\n\n2. Real second.\n";
533        let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
534        let result = rule.check(&ctx).unwrap();
535        assert!(
536            result.is_empty(),
537            "MD029 should validate the digit-prefixed sequence and skip the example marker: {result:?}"
538        );
539    }
540
541    /// Lists with explicit non-1 start values should not be auto-fixed
542    /// (to preserve user intent).
543    #[test]
544    fn test_fix_preserves_non_default_start_value() {
545        let rule = MD029OrderedListPrefix::default();
546
547        // List starts at 11 — CommonMark expects 11, 12, 13... Item "14" is wrong
548        // but user explicitly chose 11 so no auto-fix should be offered.
549        let content = "11. First\n14. Fourth\n";
550        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
551        let warnings = rule.check(&ctx).unwrap();
552        // Warning present but no fix
553        assert!(!warnings.is_empty(), "Should produce warnings for misnumbered list");
554        assert!(
555            warnings.iter().all(|w| w.fix.is_none()),
556            "Should not provide auto-fix for lists starting at non-1 values"
557        );
558        // fix() should leave content unchanged
559        let fixed = rule.fix(&ctx).unwrap();
560        assert_eq!(
561            fixed, content,
562            "Content should be unchanged when no fixes are available"
563        );
564    }
565
566    #[test]
567    fn test_md029_front_matter() {
568        let rule = MD029OrderedListPrefix::default();
569        let content = "---\n1. key: value\n3. key2: value2\n---\n1. Item 1\n2. Item 2\n";
570        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
571        let result = rule.check(&ctx).unwrap();
572        assert!(
573            result.is_empty(),
574            "Should not flag list-like items in front-matter: {result:?}"
575        );
576    }
577}