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 {
284                                    range: marker_start..marker_start + number_len,
285                                    replacement: expected_num.to_string(),
286                                })
287                            } else {
288                                None
289                            },
290                        });
291                    }
292                }
293            }
294        }
295    }
296}
297
298impl Rule for MD029OrderedListPrefix {
299    fn name(&self) -> &'static str {
300        "MD029"
301    }
302
303    fn description(&self) -> &'static str {
304        "Ordered list marker value"
305    }
306
307    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
308        // Early returns for performance
309        if ctx.content.is_empty() {
310            return Ok(Vec::new());
311        }
312
313        // Quick check for any ordered list markers before processing
314        if (!ctx.content.contains('.') && !ctx.content.contains(')'))
315            || !ctx.content.lines().any(|line| ORDERED_LIST_MARKER_REGEX.is_match(line))
316        {
317            return Ok(Vec::new());
318        }
319
320        let mut warnings = Vec::new();
321
322        // Use pulldown-cmark's AST for authoritative list membership and start values.
323        // This respects CommonMark's list start values (e.g., a list starting at 11
324        // expects items 11, 12, 13... - no violation there).
325        let list_groups = Self::group_items_by_commonmark_list(ctx, &ctx.line_to_list);
326
327        if list_groups.is_empty() {
328            return Ok(Vec::new());
329        }
330
331        // For Consistent style, detect document-wide prevalent style
332        let document_wide_style = if self.config.style == ListStyle::Consistent {
333            // Collect ALL ordered items from ALL groups
334            let mut all_document_items = Vec::new();
335            for (_, items) in &list_groups {
336                for (line_num, line_info, list_item) in items {
337                    all_document_items.push((*line_num, *line_info, *list_item));
338                }
339            }
340            // Detect style across entire document (use 1 as default for pattern detection)
341            if !all_document_items.is_empty() {
342                Some(Self::detect_list_style(&all_document_items, 1))
343            } else {
344                None
345            }
346        } else {
347            None
348        };
349
350        // Process each CommonMark-defined list group with its start value
351        for (list_id, items) in list_groups {
352            let start_value = ctx.list_start_values.get(&list_id).copied().unwrap_or(1);
353            self.check_commonmark_list_group(ctx, &items, &mut warnings, document_wide_style, start_value);
354        }
355
356        // Sort warnings by line number for deterministic output
357        warnings.sort_by_key(|w| (w.line, w.column));
358
359        Ok(warnings)
360    }
361
362    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
363        // Note: do not call self.should_skip() here — MD029's should_skip only covers
364        // unordered list markers (*, -, +), not ordered list markers (digits + . or )).
365        // check() has its own fast-path early-return for documents without ordered markers.
366        let warnings = self.check(ctx)?;
367        if warnings.is_empty() {
368            return Ok(ctx.content.to_string());
369        }
370        let warnings =
371            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
372        crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings).map_err(LintError::InvalidInput)
373    }
374
375    /// Get the category of this rule for selective processing
376    fn category(&self) -> RuleCategory {
377        RuleCategory::List
378    }
379
380    /// Check if this rule should be skipped
381    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
382        ctx.content.is_empty() || !ctx.likely_has_lists()
383    }
384
385    fn as_any(&self) -> &dyn std::any::Any {
386        self
387    }
388
389    fn default_config_section(&self) -> Option<(String, toml::Value)> {
390        let default_config = MD029Config::default();
391        let json_value = serde_json::to_value(&default_config).ok()?;
392        let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
393        if let toml::Value::Table(table) = toml_value {
394            if !table.is_empty() {
395                Some((MD029Config::RULE_NAME.to_string(), toml::Value::Table(table)))
396            } else {
397                None
398            }
399        } else {
400            None
401        }
402    }
403
404    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
405    where
406        Self: Sized,
407    {
408        let rule_config = crate::rule_config_serde::load_rule_config::<MD029Config>(config);
409        Box::new(MD029OrderedListPrefix::from_config_struct(rule_config))
410    }
411}
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416
417    #[test]
418    fn test_basic_functionality() {
419        // Test with default style (ordered)
420        let rule = MD029OrderedListPrefix::default();
421
422        // Test with correctly ordered list
423        let content = "1. First item\n2. Second item\n3. Third item";
424        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
425        let result = rule.check(&ctx).unwrap();
426        assert!(result.is_empty());
427
428        // Test with incorrectly ordered list
429        let content = "1. First item\n3. Third item\n5. Fifth item";
430        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
431        let result = rule.check(&ctx).unwrap();
432        assert_eq!(result.len(), 2); // Should have warnings for items 3 and 5
433
434        // Test with one-one style
435        let rule = MD029OrderedListPrefix::new(ListStyle::OneOne);
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_eq!(result.len(), 2); // Should have warnings for items 2 and 3
440
441        // Test with ordered0 style
442        let rule = MD029OrderedListPrefix::new(ListStyle::Ordered0);
443        let content = "0. First item\n1. Second item\n2. Third item";
444        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
445        let result = rule.check(&ctx).unwrap();
446        assert!(result.is_empty());
447    }
448
449    #[test]
450    fn test_redundant_computation_fix() {
451        // This test confirms that the redundant computation bug is fixed
452        // Previously: get_list_number() was called twice (once for is_some(), once for unwrap())
453        // Now: get_list_number() is called once with if let pattern
454
455        let rule = MD029OrderedListPrefix::default();
456
457        // Test with mixed valid and edge case content
458        let content = "1. First item\n3. Wrong number\n2. Another wrong number";
459        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
460
461        // This should not panic and should produce warnings for incorrect numbering
462        let result = rule.check(&ctx).unwrap();
463        assert_eq!(result.len(), 2); // Should have warnings for items 3 and 2
464
465        // Verify the warnings have correct content
466        assert!(result[0].message.contains('3') && result[0].message.contains("expected 2"));
467        assert!(result[1].message.contains('2') && result[1].message.contains("expected 3"));
468    }
469
470    #[test]
471    fn test_performance_improvement() {
472        // This test verifies the rule handles large lists without performance issues
473        let rule = MD029OrderedListPrefix::default();
474
475        // Create a larger list with WRONG numbers: 1, 5, 10, 15, ...
476        // Starting at 1, CommonMark expects 1, 2, 3, 4, ...
477        // So items 2-100 are all wrong (expected 2, got 5; expected 3, got 10; etc.)
478        let mut content = String::from("1. Item 1\n"); // First item correct
479        for i in 2..=100 {
480            content.push_str(&format!("{}. Item {}\n", i * 5 - 5, i)); // Wrong numbers
481        }
482
483        let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
484
485        // This should complete without issues and produce warnings for items 2-100
486        let result = rule.check(&ctx).unwrap();
487        assert_eq!(result.len(), 99, "Should have warnings for items 2-100 (99 items)");
488
489        // First wrong item: "5. Item 2" (expected 2)
490        assert!(result[0].message.contains('5') && result[0].message.contains("expected 2"));
491    }
492
493    #[test]
494    fn test_one_or_ordered_with_all_ones() {
495        // Test OneOrOrdered style with all 1s (should pass)
496        let rule = MD029OrderedListPrefix::new(ListStyle::OneOrOrdered);
497
498        let content = "1. First item\n1. Second item\n1. Third item";
499        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
500        let result = rule.check(&ctx).unwrap();
501        assert!(result.is_empty(), "All ones should be valid in OneOrOrdered mode");
502    }
503
504    #[test]
505    fn test_one_or_ordered_with_sequential() {
506        // Test OneOrOrdered style with sequential numbering (should pass)
507        let rule = MD029OrderedListPrefix::new(ListStyle::OneOrOrdered);
508
509        let content = "1. First item\n2. Second item\n3. Third item";
510        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
511        let result = rule.check(&ctx).unwrap();
512        assert!(
513            result.is_empty(),
514            "Sequential numbering should be valid in OneOrOrdered mode"
515        );
516    }
517
518    #[test]
519    fn test_one_or_ordered_with_mixed_style() {
520        // Test OneOrOrdered style with mixed numbering (should fail)
521        let rule = MD029OrderedListPrefix::new(ListStyle::OneOrOrdered);
522
523        let content = "1. First item\n2. Second item\n1. Third item";
524        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
525        let result = rule.check(&ctx).unwrap();
526        assert_eq!(result.len(), 1, "Mixed style should produce one warning");
527        assert!(result[0].message.contains('1') && result[0].message.contains("expected 3"));
528    }
529
530    #[test]
531    fn test_one_or_ordered_separate_lists() {
532        // Test OneOrOrdered with separate lists using different styles (should pass)
533        let rule = MD029OrderedListPrefix::new(ListStyle::OneOrOrdered);
534
535        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";
536        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
537        let result = rule.check(&ctx).unwrap();
538        assert!(
539            result.is_empty(),
540            "Separate lists can use different styles in OneOrOrdered mode"
541        );
542    }
543
544    /// Core invariant: for every warning with a Fix, the replacement text must
545    /// match what fix() produces for the same byte range in the output.
546    #[test]
547    fn test_check_and_fix_produce_identical_replacements() {
548        let rule = MD029OrderedListPrefix::default();
549
550        let inputs = [
551            "1. First\n3. Skip\n5. Skip\n",
552            "1. First\n3. Third\n2. Second\n",
553            "1. A\n\n3. B\n",
554            "- Unordered\n\n1. A\n3. B\n",
555            "1. A\n   1. Nested wrong\n   3. Nested\n2. B\n",
556        ];
557
558        for input in &inputs {
559            let ctx = crate::lint_context::LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
560            let warnings = rule.check(&ctx).unwrap();
561            let fixed = rule.fix(&ctx).unwrap();
562
563            // fix() must be idempotent: applying it again produces the same output
564            let ctx2 = crate::lint_context::LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
565            let fixed_twice = rule.fix(&ctx2).unwrap();
566            assert_eq!(
567                fixed, fixed_twice,
568                "fix() is not idempotent for input: {input:?}\nfirst:  {fixed:?}\nsecond: {fixed_twice:?}"
569            );
570
571            // After fixing, check() should produce no warnings
572            let warnings_after = rule.check(&ctx2).unwrap();
573            assert!(
574                warnings_after.is_empty(),
575                "check() should produce no warnings after fix() for input: {input:?}\nfixed: {fixed:?}\nremaining: {warnings_after:?}"
576            );
577
578            // For every warning with a Fix, applying the fix alone should match
579            // the content at the same range in the final fixed output
580            for warning in &warnings {
581                if let Some(ref fix) = warning.fix {
582                    assert!(
583                        fix.range.end <= input.len(),
584                        "Fix range exceeds input length for {input:?}"
585                    );
586                }
587            }
588        }
589    }
590
591    /// fix(fix(x)) == fix(x)
592    #[test]
593    fn test_fix_idempotent() {
594        let rule = MD029OrderedListPrefix::default();
595
596        let inputs = [
597            "1. A\n3. B\n5. C\n",
598            "# Intro\n\n1. First\n3. Third\n",
599            "1. A\n1. B\n1. C\n",
600        ];
601
602        for input in &inputs {
603            let ctx1 = crate::lint_context::LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
604            let fixed_once = rule.fix(&ctx1).unwrap();
605            let ctx2 =
606                crate::lint_context::LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
607            let fixed_twice = rule.fix(&ctx2).unwrap();
608            assert_eq!(fixed_once, fixed_twice, "fix() is not idempotent for input: {input:?}");
609        }
610    }
611
612    /// Lists with explicit non-1 start values should not be auto-fixed
613    /// (to preserve user intent).
614    #[test]
615    fn test_fix_preserves_non_default_start_value() {
616        let rule = MD029OrderedListPrefix::default();
617
618        // List starts at 11 — CommonMark expects 11, 12, 13... Item "14" is wrong
619        // but user explicitly chose 11 so no auto-fix should be offered.
620        let content = "11. First\n14. Fourth\n";
621        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
622        let warnings = rule.check(&ctx).unwrap();
623        // Warning present but no fix
624        assert!(!warnings.is_empty(), "Should produce warnings for misnumbered list");
625        assert!(
626            warnings.iter().all(|w| w.fix.is_none()),
627            "Should not provide auto-fix for lists starting at non-1 values"
628        );
629        // fix() should leave content unchanged
630        let fixed = rule.fix(&ctx).unwrap();
631        assert_eq!(
632            fixed, content,
633            "Content should be unchanged when no fixes are available"
634        );
635    }
636}