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