1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
5use crate::utils::regex_cache::ORDERED_LIST_MARKER_REGEX;
6use std::collections::HashMap;
7use toml;
8
9mod md029_config;
10pub use md029_config::ListStyle;
11pub(super) use md029_config::MD029Config;
12
13type 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 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 #[inline]
54 fn get_expected_number(&self, index: usize, detected_style: Option<ListStyle>, start_value: u64) -> usize {
55 let style = match self.config.style {
58 ListStyle::OneOrOrdered | ListStyle::Consistent => detected_style.unwrap_or(ListStyle::OneOne),
59 _ => self.config.style,
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 1
69 }
70 }
71 }
72
73 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 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 if matches!((first_num, second_num), (Some(0), Some(1))) {
99 return ListStyle::Ordered0;
100 }
101
102 if first_num != Some(1) || second_num != Some(1) {
105 return ListStyle::Ordered;
106 }
107
108 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 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 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 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 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 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 result.sort_by_key(|(_, items)| items.first().map_or(0, |(ln, _, _)| *ln));
171
172 result
173 }
174
175 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 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 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 items.sort_by_key(|(line_num, _, _)| *line_num);
219
220 if items.is_empty() {
221 continue;
222 }
223
224 let detected_style = if let Some(doc_style) = document_wide_style {
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 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, 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 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::new(marker_start..marker_start + number_len, expected_num.to_string()))
283 } else {
284 None
285 },
286 });
287 }
288 }
289 }
290 }
291 }
292}
293
294impl Rule for MD029OrderedListPrefix {
295 fn name(&self) -> &'static str {
296 "MD029"
297 }
298
299 fn description(&self) -> &'static str {
300 "Ordered list marker value"
301 }
302
303 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
304 if ctx.content.is_empty() {
306 return Ok(Vec::new());
307 }
308
309 if (!ctx.content.contains('.') && !ctx.content.contains(')'))
311 || !ctx.content.lines().any(|line| ORDERED_LIST_MARKER_REGEX.is_match(line))
312 {
313 return Ok(Vec::new());
314 }
315
316 let mut warnings = Vec::new();
317
318 let list_groups = Self::group_items_by_commonmark_list(ctx, &ctx.line_to_list);
322
323 if list_groups.is_empty() {
324 return Ok(Vec::new());
325 }
326
327 let document_wide_style = if self.config.style == ListStyle::Consistent {
329 let mut all_document_items = Vec::new();
331 for (_, items) in &list_groups {
332 for (line_num, line_info, list_item) in items {
333 all_document_items.push((*line_num, *line_info, *list_item));
334 }
335 }
336 if !all_document_items.is_empty() {
338 Some(Self::detect_list_style(&all_document_items, 1))
339 } else {
340 None
341 }
342 } else {
343 None
344 };
345
346 for (list_id, items) in list_groups {
348 let start_value = ctx.list_start_values.get(&list_id).copied().unwrap_or(1);
349 self.check_commonmark_list_group(ctx, &items, &mut warnings, document_wide_style, start_value);
350 }
351
352 warnings.sort_by_key(|w| (w.line, w.column));
354
355 Ok(warnings)
356 }
357
358 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
359 let warnings = self.check(ctx)?;
363 if warnings.is_empty() {
364 return Ok(ctx.content.to_string());
365 }
366 let warnings =
367 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
368 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings).map_err(LintError::InvalidInput)
369 }
370
371 fn category(&self) -> RuleCategory {
373 RuleCategory::List
374 }
375
376 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
378 ctx.content.is_empty() || !ctx.likely_has_lists()
379 }
380
381 fn as_any(&self) -> &dyn std::any::Any {
382 self
383 }
384
385 crate::impl_rule_config_methods!(MD029Config);
386}
387
388#[cfg(test)]
389mod tests {
390 use super::*;
391
392 #[test]
393 fn test_basic_functionality() {
394 let rule = MD029OrderedListPrefix::default();
396
397 let content = "1. First item\n2. Second item\n3. Third item";
399 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
400 let result = rule.check(&ctx).unwrap();
401 assert!(result.is_empty());
402
403 let content = "1. First item\n3. Third item\n5. Fifth item";
405 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
406 let result = rule.check(&ctx).unwrap();
407 assert_eq!(result.len(), 2); let rule = MD029OrderedListPrefix::new(ListStyle::OneOne);
411 let content = "1. First item\n2. Second item\n3. Third item";
412 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
413 let result = rule.check(&ctx).unwrap();
414 assert_eq!(result.len(), 2); let rule = MD029OrderedListPrefix::new(ListStyle::Ordered0);
418 let content = "0. First item\n1. Second item\n2. Third item";
419 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
420 let result = rule.check(&ctx).unwrap();
421 assert!(result.is_empty());
422 }
423
424 #[test]
425 fn test_redundant_computation_fix() {
426 let rule = MD029OrderedListPrefix::default();
431
432 let content = "1. First item\n3. Wrong number\n2. Another wrong number";
434 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
435
436 let result = rule.check(&ctx).unwrap();
438 assert_eq!(result.len(), 2); assert!(result[0].message.contains('3') && result[0].message.contains("expected 2"));
442 assert!(result[1].message.contains('2') && result[1].message.contains("expected 3"));
443 }
444
445 #[test]
446 fn test_performance_improvement() {
447 let rule = MD029OrderedListPrefix::default();
449
450 let mut content = String::from("1. Item 1\n"); for i in 2..=100 {
455 content.push_str(&format!("{}. Item {}\n", i * 5 - 5, i)); }
457
458 let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
459
460 let result = rule.check(&ctx).unwrap();
462 assert_eq!(result.len(), 99, "Should have warnings for items 2-100 (99 items)");
463
464 assert!(result[0].message.contains('5') && result[0].message.contains("expected 2"));
466 }
467
468 #[test]
469 fn test_one_or_ordered_with_all_ones() {
470 let rule = MD029OrderedListPrefix::new(ListStyle::OneOrOrdered);
472
473 let content = "1. First item\n1. Second item\n1. Third item";
474 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
475 let result = rule.check(&ctx).unwrap();
476 assert!(result.is_empty(), "All ones should be valid in OneOrOrdered mode");
477 }
478
479 #[test]
480 fn test_one_or_ordered_with_sequential() {
481 let rule = MD029OrderedListPrefix::new(ListStyle::OneOrOrdered);
483
484 let content = "1. First item\n2. Second item\n3. Third item";
485 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
486 let result = rule.check(&ctx).unwrap();
487 assert!(
488 result.is_empty(),
489 "Sequential numbering should be valid in OneOrOrdered mode"
490 );
491 }
492
493 #[test]
494 fn test_one_or_ordered_with_mixed_style() {
495 let rule = MD029OrderedListPrefix::new(ListStyle::OneOrOrdered);
497
498 let content = "1. First item\n2. 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_eq!(result.len(), 1, "Mixed style should produce one warning");
502 assert!(result[0].message.contains('1') && result[0].message.contains("expected 3"));
503 }
504
505 #[test]
506 fn test_one_or_ordered_separate_lists() {
507 let rule = MD029OrderedListPrefix::new(ListStyle::OneOrOrdered);
509
510 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";
511 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
512 let result = rule.check(&ctx).unwrap();
513 assert!(
514 result.is_empty(),
515 "Separate lists can use different styles in OneOrOrdered mode"
516 );
517 }
518
519 #[test]
522 fn test_check_and_fix_produce_identical_replacements() {
523 let rule = MD029OrderedListPrefix::default();
524
525 let inputs = [
526 "1. First\n3. Skip\n5. Skip\n",
527 "1. First\n3. Third\n2. Second\n",
528 "1. A\n\n3. B\n",
529 "- Unordered\n\n1. A\n3. B\n",
530 "1. A\n 1. Nested wrong\n 3. Nested\n2. B\n",
531 ];
532
533 for input in &inputs {
534 let ctx = crate::lint_context::LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
535 let warnings = rule.check(&ctx).unwrap();
536 let fixed = rule.fix(&ctx).unwrap();
537
538 let ctx2 = crate::lint_context::LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
540 let fixed_twice = rule.fix(&ctx2).unwrap();
541 assert_eq!(
542 fixed, fixed_twice,
543 "fix() is not idempotent for input: {input:?}\nfirst: {fixed:?}\nsecond: {fixed_twice:?}"
544 );
545
546 let warnings_after = rule.check(&ctx2).unwrap();
548 assert!(
549 warnings_after.is_empty(),
550 "check() should produce no warnings after fix() for input: {input:?}\nfixed: {fixed:?}\nremaining: {warnings_after:?}"
551 );
552
553 for warning in &warnings {
556 if let Some(ref fix) = warning.fix {
557 assert!(
558 fix.range.end <= input.len(),
559 "Fix range exceeds input length for {input:?}"
560 );
561 }
562 }
563 }
564 }
565
566 #[test]
568 fn test_fix_idempotent() {
569 let rule = MD029OrderedListPrefix::default();
570
571 let inputs = [
572 "1. A\n3. B\n5. C\n",
573 "# Intro\n\n1. First\n3. Third\n",
574 "1. A\n1. B\n1. C\n",
575 ];
576
577 for input in &inputs {
578 let ctx1 = crate::lint_context::LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
579 let fixed_once = rule.fix(&ctx1).unwrap();
580 let ctx2 =
581 crate::lint_context::LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
582 let fixed_twice = rule.fix(&ctx2).unwrap();
583 assert_eq!(fixed_once, fixed_twice, "fix() is not idempotent for input: {input:?}");
584 }
585 }
586
587 #[test]
590 fn test_pandoc_skips_example_list_markers() {
591 use crate::config::MarkdownFlavor;
592 use crate::lint_context::LintContext;
593 let rule = MD029OrderedListPrefix::default();
594 let content = "(@) First.\n(@good) Second.\n(@) Third.\n";
595 let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
596 let result = rule.check(&ctx).unwrap();
597 assert!(
598 result.is_empty(),
599 "MD029 should not flag (@)/(@label) example markers under Pandoc: {result:?}"
600 );
601 }
602
603 #[test]
606 fn test_pandoc_example_markers_do_not_break_real_ordered_list() {
607 use crate::config::MarkdownFlavor;
608 use crate::lint_context::LintContext;
609 let rule = MD029OrderedListPrefix::default();
610 let content = "1. Real first.\n\n(@) Example.\n\n2. Real second.\n";
611 let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
612 let result = rule.check(&ctx).unwrap();
613 assert!(
614 result.is_empty(),
615 "MD029 should validate the digit-prefixed sequence and skip the example marker: {result:?}"
616 );
617 }
618
619 #[test]
622 fn test_fix_preserves_non_default_start_value() {
623 let rule = MD029OrderedListPrefix::default();
624
625 let content = "11. First\n14. Fourth\n";
628 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
629 let warnings = rule.check(&ctx).unwrap();
630 assert!(!warnings.is_empty(), "Should produce warnings for misnumbered list");
632 assert!(
633 warnings.iter().all(|w| w.fix.is_none()),
634 "Should not provide auto-fix for lists starting at non-1 values"
635 );
636 let fixed = rule.fix(&ctx).unwrap();
638 assert_eq!(
639 fixed, content,
640 "Content should be unchanged when no fixes are available"
641 );
642 }
643}