1use crate::lint_context::ParsedListItem;
5use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
6use crate::utils::list_fix_guard::{
7 Allowed, drop_structure_changing_fixes, is_inside_literal_code, protect_literal_code,
8};
9use crate::utils::list_indent_shift::{Nesting, move_owned_lines};
10use crate::utils::range_utils::byte_to_char_count;
11use crate::utils::regex_cache::ORDERED_LIST_MARKER_REGEX;
12use std::collections::HashMap;
13use toml;
14
15mod md029_config;
16pub use md029_config::ListStyle;
17pub(super) use md029_config::MD029Config;
18
19#[derive(Debug, Clone, Default)]
20pub struct MD029OrderedListPrefix {
21 config: MD029Config,
22}
23
24impl MD029OrderedListPrefix {
25 pub fn new(style: ListStyle) -> Self {
26 Self {
27 config: MD029Config { style },
28 }
29 }
30
31 pub fn from_config_struct(config: MD029Config) -> Self {
32 Self { config }
33 }
34
35 #[inline]
38 fn parse_marker_number(marker: &str) -> Option<usize> {
39 marker.strip_suffix(['.', ')']).unwrap_or(marker).parse::<usize>().ok()
40 }
41
42 #[inline]
46 fn get_expected_number(&self, index: usize, detected_style: Option<ListStyle>, start_value: u64) -> usize {
47 let style = match self.config.style {
50 ListStyle::OneOrOrdered | ListStyle::Consistent => detected_style.unwrap_or(ListStyle::OneOne),
51 _ => self.config.style,
52 };
53
54 match style {
55 ListStyle::One | ListStyle::OneOne => 1,
56 ListStyle::Ordered => (start_value as usize) + index,
57 ListStyle::Ordered0 => index,
58 ListStyle::OneOrOrdered | ListStyle::Consistent => {
59 1
61 }
62 }
63 }
64
65 fn detect_list_style(items: &[ParsedListItem<'_>], start_value: u64) -> ListStyle {
68 if items.len() < 2 {
69 let first_num = Self::parse_marker_number(items[0].marker());
73 if first_num == Some(start_value as usize) {
74 return ListStyle::Ordered;
75 }
76 return ListStyle::OneOne;
77 }
78
79 let first_num = Self::parse_marker_number(items[0].marker());
80 let second_num = Self::parse_marker_number(items[1].marker());
81
82 if matches!((first_num, second_num), (Some(0), Some(1))) {
84 return ListStyle::Ordered0;
85 }
86
87 if first_num != Some(1) || second_num != Some(1) {
90 return ListStyle::Ordered;
91 }
92
93 let all_ones = items
96 .iter()
97 .all(|item| Self::parse_marker_number(item.marker()) == Some(1));
98
99 if all_ones {
100 ListStyle::OneOne
101 } else {
102 ListStyle::Ordered
103 }
104 }
105
106 fn check_commonmark_list_group(
110 &self,
111 ctx: &crate::lint_context::LintContext,
112 group: &[ParsedListItem<'_>],
113 warnings: &mut Vec<LintWarning>,
114 document_wide_style: Option<ListStyle>,
115 start_value: u64,
116 ) {
117 if group.is_empty() {
118 return;
119 }
120
121 type LevelGroups<'a> = HashMap<usize, Vec<ParsedListItem<'a>>>;
123 let mut level_groups: LevelGroups = HashMap::new();
124
125 for &list_item in group {
126 level_groups
127 .entry(list_item.marker_column())
128 .or_default()
129 .push(list_item);
130 }
131
132 let mut sorted_levels: Vec<_> = level_groups.into_iter().collect();
134 sorted_levels.sort_by_key(|(indent, _)| *indent);
135
136 for (_indent, mut items) in sorted_levels {
137 items.sort_by_key(|item| item.line_num());
139
140 if items.is_empty() {
141 continue;
142 }
143
144 let detected_style = if let Some(doc_style) = document_wide_style {
146 Some(doc_style)
147 } else if self.config.style == ListStyle::OneOrOrdered {
148 Some(Self::detect_list_style(&items, start_value))
149 } else {
150 None
151 };
152
153 for (idx, list_item) in items.iter().copied().enumerate() {
155 if let Some(actual_num) = Self::parse_marker_number(list_item.marker()) {
156 let expected_num = self.get_expected_number(idx, detected_style, start_value);
157
158 if actual_num != expected_num {
159 let line_num = list_item.line_num();
160 let line_info = list_item.line_info();
161 let marker_start = list_item.marker_byte_offset();
162 let number_len = if let Some(dot_pos) = list_item.marker().find('.') {
163 dot_pos
164 } else if let Some(paren_pos) = list_item.marker().find(')') {
165 paren_pos
166 } else {
167 list_item.marker().len()
168 };
169
170 let style_name = match detected_style.as_ref().unwrap_or(&ListStyle::Ordered) {
171 ListStyle::OneOne => "one",
172 ListStyle::Ordered => "ordered",
173 ListStyle::Ordered0 => "ordered0",
174 _ => "ordered",
175 };
176
177 let style_context = match self.config.style {
178 ListStyle::Consistent => format!("document style '{style_name}'"),
179 ListStyle::OneOrOrdered => format!("list style '{style_name}'"),
180 ListStyle::One | ListStyle::OneOne => "configured style 'one'".to_string(),
181 ListStyle::Ordered => "configured style 'ordered'".to_string(),
182 ListStyle::Ordered0 => "configured style 'ordered0'".to_string(),
183 };
184
185 let should_provide_fix =
191 start_value == 1 || matches!(self.config.style, ListStyle::One | ListStyle::OneOne);
192
193 let line_text = line_info.content(ctx.content);
196
197 warnings.push(LintWarning {
198 rule_name: Some(self.name().to_string()),
199 message: format!(
200 "Ordered list item number {actual_num} does not match {style_context} (expected {expected_num})"
201 ),
202 line: line_num,
203 column: byte_to_char_count(line_text, list_item.marker_column()),
204 end_line: line_num,
205 end_column: byte_to_char_count(line_text, list_item.marker_column() + number_len),
206 severity: Severity::Warning,
207 fix: if should_provide_fix {
208 Some(Fix::new(marker_start..marker_start + number_len, expected_num.to_string()))
209 } else {
210 None
211 },
212 });
213 }
214 }
215 }
216 }
217 }
218}
219
220impl Rule for MD029OrderedListPrefix {
221 fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
222 (self.config == Default::default()).then_some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(29))
223 }
224
225 fn name(&self) -> &'static str {
226 "MD029"
227 }
228
229 fn description(&self) -> &'static str {
230 "Ordered list marker value"
231 }
232
233 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
234 if ctx.content.is_empty() {
236 return Ok(Vec::new());
237 }
238
239 if (!ctx.content.contains('.') && !ctx.content.contains(')'))
241 || !ctx.content.lines().any(|line| ORDERED_LIST_MARKER_REGEX.is_match(line))
242 {
243 return Ok(Vec::new());
244 }
245
246 let mut warnings = Vec::new();
247
248 let list_groups = ctx.commonmark_ordered_lists();
252
253 if list_groups.is_empty() {
254 return Ok(Vec::new());
255 }
256
257 let document_wide_style = if self.config.style == ListStyle::Consistent {
259 let mut all_document_items = Vec::new();
261 for list in list_groups {
262 all_document_items.extend(
263 list.items()
264 .filter(|item| !is_inside_literal_code(ctx, item.marker_byte_offset())),
265 );
266 }
267 if !all_document_items.is_empty() {
269 Some(Self::detect_list_style(&all_document_items, 1))
270 } else {
271 None
272 }
273 } else {
274 None
275 };
276
277 for list in list_groups {
279 let mut items: Vec<_> = list.items().collect();
280 let starts_in_literal = items
281 .first()
282 .is_some_and(|item| is_inside_literal_code(ctx, item.marker_byte_offset()));
283 items.retain(|item| !is_inside_literal_code(ctx, item.marker_byte_offset()));
284 let start_value = if starts_in_literal {
288 items
289 .first()
290 .and_then(|item| Self::parse_marker_number(item.marker()))
291 .map_or(list.start_value(), |number| number as u64)
292 } else {
293 list.start_value()
294 };
295 self.check_commonmark_list_group(ctx, &items, &mut warnings, document_wide_style, start_value);
296 }
297
298 warnings.sort_by_key(|w| (w.line, w.column));
300
301 let moves = warnings
305 .iter()
306 .enumerate()
307 .filter_map(|(index, warning)| {
308 let fix = warning.fix.as_ref()?;
309 let delta = fix.replacement.len() as isize - fix.range.len() as isize;
310 (delta != 0).then_some((warning.line - 1, (index, delta)))
311 })
312 .collect();
313 move_owned_lines(ctx, &moves, Nesting::Relative, &mut warnings);
314 protect_literal_code(ctx, &mut warnings);
315 drop_structure_changing_fixes(ctx, &mut warnings, Allowed::ListStart);
316
317 Ok(warnings)
318 }
319
320 fn fixes_from_diagnostics(&self) -> bool {
321 true
322 }
323
324 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
325 let warnings = self.check(ctx)?;
329 if warnings.is_empty() {
330 return Ok(ctx.content.to_string());
331 }
332 let warnings =
333 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
334 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings).map_err(LintError::InvalidInput)
335 }
336
337 fn category(&self) -> RuleCategory {
339 RuleCategory::List
340 }
341
342 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
346 ctx.content.is_empty() || ctx.commonmark_ordered_lists().is_empty()
347 }
348
349 fn as_any(&self) -> &dyn std::any::Any {
350 self
351 }
352
353 crate::impl_rule_config_methods!(MD029Config);
354}
355
356#[cfg(test)]
357mod tests {
358 use super::*;
359
360 #[test]
361 fn test_fix_keeps_a_parent_paragraph_out_of_a_renumbered_child() {
362 let rule = MD029OrderedListPrefix::new(ListStyle::One);
365 let content = "1. one\n\n 1. a\n 10. child\n\n Parent paragraph.\n";
366 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
367 assert_eq!(
368 rule.fix(&ctx).unwrap(),
369 "1. one\n\n 1. a\n 1. child\n\n Parent paragraph.\n"
370 );
371 }
372
373 #[test]
374 fn test_basic_functionality() {
375 let rule = MD029OrderedListPrefix::default();
377
378 let content = "1. First item\n2. Second item\n3. Third item";
380 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
381 let result = rule.check(&ctx).unwrap();
382 assert!(result.is_empty());
383
384 let content = "1. First item\n3. Third item\n5. Fifth item";
386 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
387 let result = rule.check(&ctx).unwrap();
388 assert_eq!(result.len(), 2); let rule = MD029OrderedListPrefix::new(ListStyle::OneOne);
392 let content = "1. First item\n2. Second item\n3. Third item";
393 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
394 let result = rule.check(&ctx).unwrap();
395 assert_eq!(result.len(), 2); let rule = MD029OrderedListPrefix::new(ListStyle::Ordered0);
399 let content = "0. First item\n1. Second item\n2. Third item";
400 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
401 let result = rule.check(&ctx).unwrap();
402 assert!(result.is_empty());
403 }
404
405 #[test]
406 fn test_redundant_computation_fix() {
407 let rule = MD029OrderedListPrefix::default();
412
413 let content = "1. First item\n3. Wrong number\n2. Another wrong number";
415 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
416
417 let result = rule.check(&ctx).unwrap();
419 assert_eq!(result.len(), 2); assert!(result[0].message.contains('3') && result[0].message.contains("expected 2"));
423 assert!(result[1].message.contains('2') && result[1].message.contains("expected 3"));
424 }
425
426 #[test]
427 fn test_performance_improvement() {
428 let rule = MD029OrderedListPrefix::default();
430
431 let mut content = String::from("1. Item 1\n"); for i in 2..=100 {
436 content.push_str(&format!("{}. Item {}\n", i * 5 - 5, i)); }
438
439 let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
440
441 let result = rule.check(&ctx).unwrap();
443 assert_eq!(result.len(), 99, "Should have warnings for items 2-100 (99 items)");
444
445 assert!(result[0].message.contains('5') && result[0].message.contains("expected 2"));
447 }
448
449 #[test]
450 fn test_one_or_ordered_with_all_ones() {
451 let rule = MD029OrderedListPrefix::new(ListStyle::OneOrOrdered);
453
454 let content = "1. First item\n1. Second item\n1. Third item";
455 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
456 let result = rule.check(&ctx).unwrap();
457 assert!(result.is_empty(), "All ones should be valid in OneOrOrdered mode");
458 }
459
460 #[test]
461 fn test_one_or_ordered_with_sequential() {
462 let rule = MD029OrderedListPrefix::new(ListStyle::OneOrOrdered);
464
465 let content = "1. First item\n2. Second item\n3. Third item";
466 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
467 let result = rule.check(&ctx).unwrap();
468 assert!(
469 result.is_empty(),
470 "Sequential numbering should be valid in OneOrOrdered mode"
471 );
472 }
473
474 #[test]
475 fn test_one_or_ordered_with_mixed_style() {
476 let rule = MD029OrderedListPrefix::new(ListStyle::OneOrOrdered);
478
479 let content = "1. First item\n2. Second item\n1. Third item";
480 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
481 let result = rule.check(&ctx).unwrap();
482 assert_eq!(result.len(), 1, "Mixed style should produce one warning");
483 assert!(result[0].message.contains('1') && result[0].message.contains("expected 3"));
484 }
485
486 #[test]
487 fn test_one_or_ordered_separate_lists() {
488 let rule = MD029OrderedListPrefix::new(ListStyle::OneOrOrdered);
490
491 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";
492 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
493 let result = rule.check(&ctx).unwrap();
494 assert!(
495 result.is_empty(),
496 "Separate lists can use different styles in OneOrOrdered mode"
497 );
498 }
499
500 #[test]
503 fn test_check_and_fix_produce_identical_replacements() {
504 let rule = MD029OrderedListPrefix::default();
505
506 let inputs = [
507 "1. First\n3. Skip\n5. Skip\n",
508 "1. First\n3. Third\n2. Second\n",
509 "1. A\n\n3. B\n",
510 "- Unordered\n\n1. A\n3. B\n",
511 "1. A\n 1. Nested wrong\n 3. Nested\n2. B\n",
512 ];
513
514 for input in &inputs {
515 let ctx = crate::lint_context::LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
516 let warnings = rule.check(&ctx).unwrap();
517 let fixed = rule.fix(&ctx).unwrap();
518
519 let ctx2 = crate::lint_context::LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
521 let fixed_twice = rule.fix(&ctx2).unwrap();
522 assert_eq!(
523 fixed, fixed_twice,
524 "fix() is not idempotent for input: {input:?}\nfirst: {fixed:?}\nsecond: {fixed_twice:?}"
525 );
526
527 let warnings_after = rule.check(&ctx2).unwrap();
529 assert!(
530 warnings_after.is_empty(),
531 "check() should produce no warnings after fix() for input: {input:?}\nfixed: {fixed:?}\nremaining: {warnings_after:?}"
532 );
533
534 for warning in &warnings {
537 if let Some(ref fix) = warning.fix {
538 assert!(
539 fix.range.end <= input.len(),
540 "Fix range exceeds input length for {input:?}"
541 );
542 }
543 }
544 }
545 }
546
547 #[test]
549 fn test_fix_idempotent() {
550 let rule = MD029OrderedListPrefix::default();
551
552 let inputs = [
553 "1. A\n3. B\n5. C\n",
554 "# Intro\n\n1. First\n3. Third\n",
555 "1. A\n1. B\n1. C\n",
556 ];
557
558 for input in &inputs {
559 let ctx1 = crate::lint_context::LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
560 let fixed_once = rule.fix(&ctx1).unwrap();
561 let ctx2 =
562 crate::lint_context::LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
563 let fixed_twice = rule.fix(&ctx2).unwrap();
564 assert_eq!(fixed_once, fixed_twice, "fix() is not idempotent for input: {input:?}");
565 }
566 }
567
568 #[test]
571 fn test_pandoc_skips_example_list_markers() {
572 use crate::config::MarkdownFlavor;
573 use crate::lint_context::LintContext;
574 let rule = MD029OrderedListPrefix::default();
575 let content = "(@) First.\n(@good) Second.\n(@) Third.\n";
576 let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
577 let result = rule.check(&ctx).unwrap();
578 assert!(
579 result.is_empty(),
580 "MD029 should not flag (@)/(@label) example markers under Pandoc: {result:?}"
581 );
582 }
583
584 #[test]
587 fn test_pandoc_example_markers_do_not_break_real_ordered_list() {
588 use crate::config::MarkdownFlavor;
589 use crate::lint_context::LintContext;
590 let rule = MD029OrderedListPrefix::default();
591 let content = "1. Real first.\n\n(@) Example.\n\n2. Real second.\n";
592 let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
593 let result = rule.check(&ctx).unwrap();
594 assert!(
595 result.is_empty(),
596 "MD029 should validate the digit-prefixed sequence and skip the example marker: {result:?}"
597 );
598 }
599
600 #[test]
603 fn test_fix_preserves_non_default_start_value() {
604 let rule = MD029OrderedListPrefix::default();
605
606 let content = "11. First\n14. Fourth\n";
609 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
610 let warnings = rule.check(&ctx).unwrap();
611 assert!(!warnings.is_empty(), "Should produce warnings for misnumbered list");
613 assert!(
614 warnings.iter().all(|w| w.fix.is_none()),
615 "Should not provide auto-fix for lists starting at non-1 values"
616 );
617 let fixed = rule.fix(&ctx).unwrap();
619 assert_eq!(
620 fixed, content,
621 "Content should be unchanged when no fixes are available"
622 );
623 }
624
625 #[test]
626 fn test_md029_front_matter() {
627 let rule = MD029OrderedListPrefix::default();
628 let content = "---\n1. key: value\n3. key2: value2\n---\n1. Item 1\n2. Item 2\n";
629 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
630 let result = rule.check(&ctx).unwrap();
631 assert!(
632 result.is_empty(),
633 "Should not flag list-like items in front-matter: {result:?}"
634 );
635 }
636}