1use crate::lint_context::ParsedListItem;
5use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
6use crate::utils::list_fix_guard::{Allowed, drop_structure_changing_fixes};
7use crate::utils::list_indent_shift::{Nesting, move_owned_lines};
8use crate::utils::range_utils::byte_to_char_count;
9use crate::utils::regex_cache::ORDERED_LIST_MARKER_REGEX;
10use std::collections::HashMap;
11use toml;
12
13mod md029_config;
14pub use md029_config::ListStyle;
15pub(super) use md029_config::MD029Config;
16
17#[derive(Debug, Clone, Default)]
18pub struct MD029OrderedListPrefix {
19 config: MD029Config,
20}
21
22impl MD029OrderedListPrefix {
23 pub fn new(style: ListStyle) -> Self {
24 Self {
25 config: MD029Config { style },
26 }
27 }
28
29 pub fn from_config_struct(config: MD029Config) -> Self {
30 Self { config }
31 }
32
33 #[inline]
36 fn parse_marker_number(marker: &str) -> Option<usize> {
37 marker.strip_suffix(['.', ')']).unwrap_or(marker).parse::<usize>().ok()
38 }
39
40 #[inline]
44 fn get_expected_number(&self, index: usize, detected_style: Option<ListStyle>, start_value: u64) -> usize {
45 let style = match self.config.style {
48 ListStyle::OneOrOrdered | ListStyle::Consistent => detected_style.unwrap_or(ListStyle::OneOne),
49 _ => self.config.style,
50 };
51
52 match style {
53 ListStyle::One | ListStyle::OneOne => 1,
54 ListStyle::Ordered => (start_value as usize) + index,
55 ListStyle::Ordered0 => index,
56 ListStyle::OneOrOrdered | ListStyle::Consistent => {
57 1
59 }
60 }
61 }
62
63 fn detect_list_style(items: &[ParsedListItem<'_>], start_value: u64) -> ListStyle {
66 if items.len() < 2 {
67 let first_num = Self::parse_marker_number(items[0].marker());
71 if first_num == Some(start_value as usize) {
72 return ListStyle::Ordered;
73 }
74 return ListStyle::OneOne;
75 }
76
77 let first_num = Self::parse_marker_number(items[0].marker());
78 let second_num = Self::parse_marker_number(items[1].marker());
79
80 if matches!((first_num, second_num), (Some(0), Some(1))) {
82 return ListStyle::Ordered0;
83 }
84
85 if first_num != Some(1) || second_num != Some(1) {
88 return ListStyle::Ordered;
89 }
90
91 let all_ones = items
94 .iter()
95 .all(|item| Self::parse_marker_number(item.marker()) == Some(1));
96
97 if all_ones {
98 ListStyle::OneOne
99 } else {
100 ListStyle::Ordered
101 }
102 }
103
104 fn check_commonmark_list_group(
108 &self,
109 ctx: &crate::lint_context::LintContext,
110 group: &[ParsedListItem<'_>],
111 warnings: &mut Vec<LintWarning>,
112 document_wide_style: Option<ListStyle>,
113 start_value: u64,
114 ) {
115 if group.is_empty() {
116 return;
117 }
118
119 type LevelGroups<'a> = HashMap<usize, Vec<ParsedListItem<'a>>>;
121 let mut level_groups: LevelGroups = HashMap::new();
122
123 for &list_item in group {
124 level_groups
125 .entry(list_item.marker_column())
126 .or_default()
127 .push(list_item);
128 }
129
130 let mut sorted_levels: Vec<_> = level_groups.into_iter().collect();
132 sorted_levels.sort_by_key(|(indent, _)| *indent);
133
134 for (_indent, mut items) in sorted_levels {
135 items.sort_by_key(|item| item.line_num());
137
138 if items.is_empty() {
139 continue;
140 }
141
142 let detected_style = if let Some(doc_style) = document_wide_style {
144 Some(doc_style)
145 } else if self.config.style == ListStyle::OneOrOrdered {
146 Some(Self::detect_list_style(&items, start_value))
147 } else {
148 None
149 };
150
151 for (idx, list_item) in items.iter().copied().enumerate() {
153 if let Some(actual_num) = Self::parse_marker_number(list_item.marker()) {
154 let expected_num = self.get_expected_number(idx, detected_style, start_value);
155
156 if actual_num != expected_num {
157 let line_num = list_item.line_num();
158 let line_info = list_item.line_info();
159 let marker_start = list_item.marker_byte_offset();
160 let number_len = if let Some(dot_pos) = list_item.marker().find('.') {
161 dot_pos
162 } else if let Some(paren_pos) = list_item.marker().find(')') {
163 paren_pos
164 } else {
165 list_item.marker().len()
166 };
167
168 let style_name = match detected_style.as_ref().unwrap_or(&ListStyle::Ordered) {
169 ListStyle::OneOne => "one",
170 ListStyle::Ordered => "ordered",
171 ListStyle::Ordered0 => "ordered0",
172 _ => "ordered",
173 };
174
175 let style_context = match self.config.style {
176 ListStyle::Consistent => format!("document style '{style_name}'"),
177 ListStyle::OneOrOrdered => format!("list style '{style_name}'"),
178 ListStyle::One | ListStyle::OneOne => "configured style 'one'".to_string(),
179 ListStyle::Ordered => "configured style 'ordered'".to_string(),
180 ListStyle::Ordered0 => "configured style 'ordered0'".to_string(),
181 };
182
183 let should_provide_fix =
189 start_value == 1 || matches!(self.config.style, ListStyle::One | ListStyle::OneOne);
190
191 let line_text = line_info.content(ctx.content);
194
195 warnings.push(LintWarning {
196 rule_name: Some(self.name().to_string()),
197 message: format!(
198 "Ordered list item number {actual_num} does not match {style_context} (expected {expected_num})"
199 ),
200 line: line_num,
201 column: byte_to_char_count(line_text, list_item.marker_column()),
202 end_line: line_num,
203 end_column: byte_to_char_count(line_text, list_item.marker_column() + number_len),
204 severity: Severity::Warning,
205 fix: if should_provide_fix {
206 Some(Fix::new(marker_start..marker_start + number_len, expected_num.to_string()))
207 } else {
208 None
209 },
210 });
211 }
212 }
213 }
214 }
215 }
216}
217
218impl Rule for MD029OrderedListPrefix {
219 fn name(&self) -> &'static str {
220 "MD029"
221 }
222
223 fn description(&self) -> &'static str {
224 "Ordered list marker value"
225 }
226
227 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
228 if ctx.content.is_empty() {
230 return Ok(Vec::new());
231 }
232
233 if (!ctx.content.contains('.') && !ctx.content.contains(')'))
235 || !ctx.content.lines().any(|line| ORDERED_LIST_MARKER_REGEX.is_match(line))
236 {
237 return Ok(Vec::new());
238 }
239
240 let mut warnings = Vec::new();
241
242 let list_groups = ctx.commonmark_ordered_lists();
246
247 if list_groups.is_empty() {
248 return Ok(Vec::new());
249 }
250
251 let document_wide_style = if self.config.style == ListStyle::Consistent {
253 let mut all_document_items = Vec::new();
255 for list in list_groups {
256 all_document_items.extend(list.items());
257 }
258 if !all_document_items.is_empty() {
260 Some(Self::detect_list_style(&all_document_items, 1))
261 } else {
262 None
263 }
264 } else {
265 None
266 };
267
268 for list in list_groups {
270 let items: Vec<_> = list.items().collect();
271 self.check_commonmark_list_group(ctx, &items, &mut warnings, document_wide_style, list.start_value());
272 }
273
274 warnings.sort_by_key(|w| (w.line, w.column));
276
277 let moves = warnings
281 .iter()
282 .enumerate()
283 .filter_map(|(index, warning)| {
284 let fix = warning.fix.as_ref()?;
285 let delta = fix.replacement.len() as isize - fix.range.len() as isize;
286 (delta != 0).then_some((warning.line - 1, (index, delta)))
287 })
288 .collect();
289 move_owned_lines(ctx, &moves, Nesting::Relative, &mut warnings);
290 drop_structure_changing_fixes(ctx, &mut warnings, Allowed::ListStart);
291
292 Ok(warnings)
293 }
294
295 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
296 let warnings = self.check(ctx)?;
300 if warnings.is_empty() {
301 return Ok(ctx.content.to_string());
302 }
303 let warnings =
304 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
305 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings).map_err(LintError::InvalidInput)
306 }
307
308 fn category(&self) -> RuleCategory {
310 RuleCategory::List
311 }
312
313 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
317 ctx.content.is_empty() || ctx.commonmark_ordered_lists().is_empty()
318 }
319
320 fn as_any(&self) -> &dyn std::any::Any {
321 self
322 }
323
324 crate::impl_rule_config_methods!(MD029Config);
325}
326
327#[cfg(test)]
328mod tests {
329 use super::*;
330
331 #[test]
332 fn test_fix_keeps_a_parent_paragraph_out_of_a_renumbered_child() {
333 let rule = MD029OrderedListPrefix::new(ListStyle::One);
336 let content = "1. one\n\n 1. a\n 10. child\n\n Parent paragraph.\n";
337 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
338 assert_eq!(
339 rule.fix(&ctx).unwrap(),
340 "1. one\n\n 1. a\n 1. child\n\n Parent paragraph.\n"
341 );
342 }
343
344 #[test]
345 fn test_basic_functionality() {
346 let rule = MD029OrderedListPrefix::default();
348
349 let content = "1. First item\n2. Second item\n3. Third item";
351 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
352 let result = rule.check(&ctx).unwrap();
353 assert!(result.is_empty());
354
355 let content = "1. First item\n3. Third item\n5. Fifth item";
357 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
358 let result = rule.check(&ctx).unwrap();
359 assert_eq!(result.len(), 2); let rule = MD029OrderedListPrefix::new(ListStyle::OneOne);
363 let content = "1. First item\n2. Second item\n3. Third item";
364 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
365 let result = rule.check(&ctx).unwrap();
366 assert_eq!(result.len(), 2); let rule = MD029OrderedListPrefix::new(ListStyle::Ordered0);
370 let content = "0. First item\n1. Second item\n2. Third item";
371 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
372 let result = rule.check(&ctx).unwrap();
373 assert!(result.is_empty());
374 }
375
376 #[test]
377 fn test_redundant_computation_fix() {
378 let rule = MD029OrderedListPrefix::default();
383
384 let content = "1. First item\n3. Wrong number\n2. Another wrong number";
386 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
387
388 let result = rule.check(&ctx).unwrap();
390 assert_eq!(result.len(), 2); assert!(result[0].message.contains('3') && result[0].message.contains("expected 2"));
394 assert!(result[1].message.contains('2') && result[1].message.contains("expected 3"));
395 }
396
397 #[test]
398 fn test_performance_improvement() {
399 let rule = MD029OrderedListPrefix::default();
401
402 let mut content = String::from("1. Item 1\n"); for i in 2..=100 {
407 content.push_str(&format!("{}. Item {}\n", i * 5 - 5, i)); }
409
410 let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
411
412 let result = rule.check(&ctx).unwrap();
414 assert_eq!(result.len(), 99, "Should have warnings for items 2-100 (99 items)");
415
416 assert!(result[0].message.contains('5') && result[0].message.contains("expected 2"));
418 }
419
420 #[test]
421 fn test_one_or_ordered_with_all_ones() {
422 let rule = MD029OrderedListPrefix::new(ListStyle::OneOrOrdered);
424
425 let content = "1. First item\n1. Second item\n1. Third item";
426 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
427 let result = rule.check(&ctx).unwrap();
428 assert!(result.is_empty(), "All ones should be valid in OneOrOrdered mode");
429 }
430
431 #[test]
432 fn test_one_or_ordered_with_sequential() {
433 let rule = MD029OrderedListPrefix::new(ListStyle::OneOrOrdered);
435
436 let content = "1. First item\n2. Second item\n3. Third item";
437 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
438 let result = rule.check(&ctx).unwrap();
439 assert!(
440 result.is_empty(),
441 "Sequential numbering should be valid in OneOrOrdered mode"
442 );
443 }
444
445 #[test]
446 fn test_one_or_ordered_with_mixed_style() {
447 let rule = MD029OrderedListPrefix::new(ListStyle::OneOrOrdered);
449
450 let content = "1. First item\n2. Second item\n1. Third item";
451 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
452 let result = rule.check(&ctx).unwrap();
453 assert_eq!(result.len(), 1, "Mixed style should produce one warning");
454 assert!(result[0].message.contains('1') && result[0].message.contains("expected 3"));
455 }
456
457 #[test]
458 fn test_one_or_ordered_separate_lists() {
459 let rule = MD029OrderedListPrefix::new(ListStyle::OneOrOrdered);
461
462 let content = "# First list\n\n1. Item A\n1. Item B\n\n# Second list\n\n1. Item X\n2. Item Y\n3. Item Z";
463 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
464 let result = rule.check(&ctx).unwrap();
465 assert!(
466 result.is_empty(),
467 "Separate lists can use different styles in OneOrOrdered mode"
468 );
469 }
470
471 #[test]
474 fn test_check_and_fix_produce_identical_replacements() {
475 let rule = MD029OrderedListPrefix::default();
476
477 let inputs = [
478 "1. First\n3. Skip\n5. Skip\n",
479 "1. First\n3. Third\n2. Second\n",
480 "1. A\n\n3. B\n",
481 "- Unordered\n\n1. A\n3. B\n",
482 "1. A\n 1. Nested wrong\n 3. Nested\n2. B\n",
483 ];
484
485 for input in &inputs {
486 let ctx = crate::lint_context::LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
487 let warnings = rule.check(&ctx).unwrap();
488 let fixed = rule.fix(&ctx).unwrap();
489
490 let ctx2 = crate::lint_context::LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
492 let fixed_twice = rule.fix(&ctx2).unwrap();
493 assert_eq!(
494 fixed, fixed_twice,
495 "fix() is not idempotent for input: {input:?}\nfirst: {fixed:?}\nsecond: {fixed_twice:?}"
496 );
497
498 let warnings_after = rule.check(&ctx2).unwrap();
500 assert!(
501 warnings_after.is_empty(),
502 "check() should produce no warnings after fix() for input: {input:?}\nfixed: {fixed:?}\nremaining: {warnings_after:?}"
503 );
504
505 for warning in &warnings {
508 if let Some(ref fix) = warning.fix {
509 assert!(
510 fix.range.end <= input.len(),
511 "Fix range exceeds input length for {input:?}"
512 );
513 }
514 }
515 }
516 }
517
518 #[test]
520 fn test_fix_idempotent() {
521 let rule = MD029OrderedListPrefix::default();
522
523 let inputs = [
524 "1. A\n3. B\n5. C\n",
525 "# Intro\n\n1. First\n3. Third\n",
526 "1. A\n1. B\n1. C\n",
527 ];
528
529 for input in &inputs {
530 let ctx1 = crate::lint_context::LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
531 let fixed_once = rule.fix(&ctx1).unwrap();
532 let ctx2 =
533 crate::lint_context::LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
534 let fixed_twice = rule.fix(&ctx2).unwrap();
535 assert_eq!(fixed_once, fixed_twice, "fix() is not idempotent for input: {input:?}");
536 }
537 }
538
539 #[test]
542 fn test_pandoc_skips_example_list_markers() {
543 use crate::config::MarkdownFlavor;
544 use crate::lint_context::LintContext;
545 let rule = MD029OrderedListPrefix::default();
546 let content = "(@) First.\n(@good) Second.\n(@) Third.\n";
547 let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
548 let result = rule.check(&ctx).unwrap();
549 assert!(
550 result.is_empty(),
551 "MD029 should not flag (@)/(@label) example markers under Pandoc: {result:?}"
552 );
553 }
554
555 #[test]
558 fn test_pandoc_example_markers_do_not_break_real_ordered_list() {
559 use crate::config::MarkdownFlavor;
560 use crate::lint_context::LintContext;
561 let rule = MD029OrderedListPrefix::default();
562 let content = "1. Real first.\n\n(@) Example.\n\n2. Real second.\n";
563 let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
564 let result = rule.check(&ctx).unwrap();
565 assert!(
566 result.is_empty(),
567 "MD029 should validate the digit-prefixed sequence and skip the example marker: {result:?}"
568 );
569 }
570
571 #[test]
574 fn test_fix_preserves_non_default_start_value() {
575 let rule = MD029OrderedListPrefix::default();
576
577 let content = "11. First\n14. Fourth\n";
580 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
581 let warnings = rule.check(&ctx).unwrap();
582 assert!(!warnings.is_empty(), "Should produce warnings for misnumbered list");
584 assert!(
585 warnings.iter().all(|w| w.fix.is_none()),
586 "Should not provide auto-fix for lists starting at non-1 values"
587 );
588 let fixed = rule.fix(&ctx).unwrap();
590 assert_eq!(
591 fixed, content,
592 "Content should be unchanged when no fixes are available"
593 );
594 }
595
596 #[test]
597 fn test_md029_front_matter() {
598 let rule = MD029OrderedListPrefix::default();
599 let content = "---\n1. key: value\n3. key2: value2\n---\n1. Item 1\n2. Item 2\n";
600 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
601 let result = rule.check(&ctx).unwrap();
602 assert!(
603 result.is_empty(),
604 "Should not flag list-like items in front-matter: {result:?}"
605 );
606 }
607}