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