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