1use crate::lint_context::ParsedListItem;
5use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
6use crate::utils::range_utils::byte_to_char_count;
7use crate::utils::regex_cache::ORDERED_LIST_MARKER_REGEX;
8use std::collections::HashMap;
9use toml;
10
11mod md029_config;
12pub use md029_config::ListStyle;
13pub(super) use md029_config::MD029Config;
14
15#[derive(Debug, Clone, Default)]
16pub struct MD029OrderedListPrefix {
17 config: MD029Config,
18}
19
20impl MD029OrderedListPrefix {
21 pub fn new(style: ListStyle) -> Self {
22 Self {
23 config: MD029Config { style },
24 }
25 }
26
27 pub fn from_config_struct(config: MD029Config) -> Self {
28 Self { config }
29 }
30
31 #[inline]
34 fn parse_marker_number(marker: &str) -> Option<usize> {
35 marker.strip_suffix(['.', ')']).unwrap_or(marker).parse::<usize>().ok()
36 }
37
38 #[inline]
42 fn get_expected_number(&self, index: usize, detected_style: Option<ListStyle>, start_value: u64) -> usize {
43 let style = match self.config.style {
46 ListStyle::OneOrOrdered | ListStyle::Consistent => detected_style.unwrap_or(ListStyle::OneOne),
47 _ => self.config.style,
48 };
49
50 match style {
51 ListStyle::One | ListStyle::OneOne => 1,
52 ListStyle::Ordered => (start_value as usize) + index,
53 ListStyle::Ordered0 => index,
54 ListStyle::OneOrOrdered | ListStyle::Consistent => {
55 1
57 }
58 }
59 }
60
61 fn detect_list_style(items: &[ParsedListItem<'_>], start_value: u64) -> ListStyle {
64 if items.len() < 2 {
65 let first_num = Self::parse_marker_number(items[0].marker());
69 if first_num == Some(start_value as usize) {
70 return ListStyle::Ordered;
71 }
72 return ListStyle::OneOne;
73 }
74
75 let first_num = Self::parse_marker_number(items[0].marker());
76 let second_num = Self::parse_marker_number(items[1].marker());
77
78 if matches!((first_num, second_num), (Some(0), Some(1))) {
80 return ListStyle::Ordered0;
81 }
82
83 if first_num != Some(1) || second_num != Some(1) {
86 return ListStyle::Ordered;
87 }
88
89 let all_ones = items
92 .iter()
93 .all(|item| Self::parse_marker_number(item.marker()) == Some(1));
94
95 if all_ones {
96 ListStyle::OneOne
97 } else {
98 ListStyle::Ordered
99 }
100 }
101
102 fn check_commonmark_list_group(
106 &self,
107 ctx: &crate::lint_context::LintContext,
108 group: &[ParsedListItem<'_>],
109 warnings: &mut Vec<LintWarning>,
110 document_wide_style: Option<ListStyle>,
111 start_value: u64,
112 ) {
113 if group.is_empty() {
114 return;
115 }
116
117 type LevelGroups<'a> = HashMap<usize, Vec<ParsedListItem<'a>>>;
119 let mut level_groups: LevelGroups = HashMap::new();
120
121 for &list_item in group {
122 level_groups
123 .entry(list_item.marker_column())
124 .or_default()
125 .push(list_item);
126 }
127
128 let mut sorted_levels: Vec<_> = level_groups.into_iter().collect();
130 sorted_levels.sort_by_key(|(indent, _)| *indent);
131
132 for (_indent, mut items) in sorted_levels {
133 items.sort_by_key(|item| item.line_num());
135
136 if items.is_empty() {
137 continue;
138 }
139
140 let detected_style = if let Some(doc_style) = document_wide_style {
142 Some(doc_style)
143 } else if self.config.style == ListStyle::OneOrOrdered {
144 Some(Self::detect_list_style(&items, start_value))
145 } else {
146 None
147 };
148
149 for (idx, list_item) in items.iter().copied().enumerate() {
151 if let Some(actual_num) = Self::parse_marker_number(list_item.marker()) {
152 let expected_num = self.get_expected_number(idx, detected_style, start_value);
153
154 if actual_num != expected_num {
155 let line_num = list_item.line_num();
156 let line_info = list_item.line_info();
157 let marker_start = list_item.marker_byte_offset();
158 let number_len = if let Some(dot_pos) = list_item.marker().find('.') {
159 dot_pos
160 } else if let Some(paren_pos) = list_item.marker().find(')') {
161 paren_pos
162 } else {
163 list_item.marker().len()
164 };
165
166 let style_name = match detected_style.as_ref().unwrap_or(&ListStyle::Ordered) {
167 ListStyle::OneOne => "one",
168 ListStyle::Ordered => "ordered",
169 ListStyle::Ordered0 => "ordered0",
170 _ => "ordered",
171 };
172
173 let style_context = match self.config.style {
174 ListStyle::Consistent => format!("document style '{style_name}'"),
175 ListStyle::OneOrOrdered => format!("list style '{style_name}'"),
176 ListStyle::One | ListStyle::OneOne => "configured style 'one'".to_string(),
177 ListStyle::Ordered => "configured style 'ordered'".to_string(),
178 ListStyle::Ordered0 => "configured style 'ordered0'".to_string(),
179 };
180
181 let should_provide_fix =
187 start_value == 1 || matches!(self.config.style, ListStyle::One | ListStyle::OneOne);
188
189 let line_text = line_info.content(ctx.content);
192
193 warnings.push(LintWarning {
194 rule_name: Some(self.name().to_string()),
195 message: format!(
196 "Ordered list item number {actual_num} does not match {style_context} (expected {expected_num})"
197 ),
198 line: line_num,
199 column: byte_to_char_count(line_text, list_item.marker_column()),
200 end_line: line_num,
201 end_column: byte_to_char_count(line_text, list_item.marker_column() + number_len),
202 severity: Severity::Warning,
203 fix: if should_provide_fix {
204 Some(Fix::new(marker_start..marker_start + number_len, expected_num.to_string()))
205 } else {
206 None
207 },
208 });
209 }
210 }
211 }
212 }
213 }
214}
215
216impl Rule for MD029OrderedListPrefix {
217 fn name(&self) -> &'static str {
218 "MD029"
219 }
220
221 fn description(&self) -> &'static str {
222 "Ordered list marker value"
223 }
224
225 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
226 if ctx.content.is_empty() {
228 return Ok(Vec::new());
229 }
230
231 if (!ctx.content.contains('.') && !ctx.content.contains(')'))
233 || !ctx.content.lines().any(|line| ORDERED_LIST_MARKER_REGEX.is_match(line))
234 {
235 return Ok(Vec::new());
236 }
237
238 let mut warnings = Vec::new();
239
240 let list_groups = ctx.commonmark_ordered_lists();
244
245 if list_groups.is_empty() {
246 return Ok(Vec::new());
247 }
248
249 let document_wide_style = if self.config.style == ListStyle::Consistent {
251 let mut all_document_items = Vec::new();
253 for list in list_groups {
254 all_document_items.extend(list.items());
255 }
256 if !all_document_items.is_empty() {
258 Some(Self::detect_list_style(&all_document_items, 1))
259 } else {
260 None
261 }
262 } else {
263 None
264 };
265
266 for list in list_groups {
268 let items: Vec<_> = list.items().collect();
269 self.check_commonmark_list_group(ctx, &items, &mut warnings, document_wide_style, list.start_value());
270 }
271
272 warnings.sort_by_key(|w| (w.line, w.column));
274
275 Ok(warnings)
276 }
277
278 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
279 let warnings = self.check(ctx)?;
283 if warnings.is_empty() {
284 return Ok(ctx.content.to_string());
285 }
286 let warnings =
287 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
288 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings).map_err(LintError::InvalidInput)
289 }
290
291 fn category(&self) -> RuleCategory {
293 RuleCategory::List
294 }
295
296 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
300 ctx.content.is_empty() || ctx.commonmark_ordered_lists().is_empty()
301 }
302
303 fn as_any(&self) -> &dyn std::any::Any {
304 self
305 }
306
307 crate::impl_rule_config_methods!(MD029Config);
308}
309
310#[cfg(test)]
311mod tests {
312 use super::*;
313
314 #[test]
315 fn test_basic_functionality() {
316 let rule = MD029OrderedListPrefix::default();
318
319 let content = "1. First item\n2. Second item\n3. Third item";
321 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
322 let result = rule.check(&ctx).unwrap();
323 assert!(result.is_empty());
324
325 let content = "1. First item\n3. Third item\n5. Fifth item";
327 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
328 let result = rule.check(&ctx).unwrap();
329 assert_eq!(result.len(), 2); let rule = MD029OrderedListPrefix::new(ListStyle::OneOne);
333 let content = "1. First item\n2. Second item\n3. Third item";
334 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
335 let result = rule.check(&ctx).unwrap();
336 assert_eq!(result.len(), 2); let rule = MD029OrderedListPrefix::new(ListStyle::Ordered0);
340 let content = "0. First item\n1. Second item\n2. Third item";
341 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
342 let result = rule.check(&ctx).unwrap();
343 assert!(result.is_empty());
344 }
345
346 #[test]
347 fn test_redundant_computation_fix() {
348 let rule = MD029OrderedListPrefix::default();
353
354 let content = "1. First item\n3. Wrong number\n2. Another wrong number";
356 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
357
358 let result = rule.check(&ctx).unwrap();
360 assert_eq!(result.len(), 2); assert!(result[0].message.contains('3') && result[0].message.contains("expected 2"));
364 assert!(result[1].message.contains('2') && result[1].message.contains("expected 3"));
365 }
366
367 #[test]
368 fn test_performance_improvement() {
369 let rule = MD029OrderedListPrefix::default();
371
372 let mut content = String::from("1. Item 1\n"); for i in 2..=100 {
377 content.push_str(&format!("{}. Item {}\n", i * 5 - 5, i)); }
379
380 let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
381
382 let result = rule.check(&ctx).unwrap();
384 assert_eq!(result.len(), 99, "Should have warnings for items 2-100 (99 items)");
385
386 assert!(result[0].message.contains('5') && result[0].message.contains("expected 2"));
388 }
389
390 #[test]
391 fn test_one_or_ordered_with_all_ones() {
392 let rule = MD029OrderedListPrefix::new(ListStyle::OneOrOrdered);
394
395 let content = "1. First item\n1. Second item\n1. Third item";
396 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
397 let result = rule.check(&ctx).unwrap();
398 assert!(result.is_empty(), "All ones should be valid in OneOrOrdered mode");
399 }
400
401 #[test]
402 fn test_one_or_ordered_with_sequential() {
403 let rule = MD029OrderedListPrefix::new(ListStyle::OneOrOrdered);
405
406 let content = "1. First item\n2. Second item\n3. Third item";
407 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
408 let result = rule.check(&ctx).unwrap();
409 assert!(
410 result.is_empty(),
411 "Sequential numbering should be valid in OneOrOrdered mode"
412 );
413 }
414
415 #[test]
416 fn test_one_or_ordered_with_mixed_style() {
417 let rule = MD029OrderedListPrefix::new(ListStyle::OneOrOrdered);
419
420 let content = "1. First item\n2. Second item\n1. Third item";
421 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
422 let result = rule.check(&ctx).unwrap();
423 assert_eq!(result.len(), 1, "Mixed style should produce one warning");
424 assert!(result[0].message.contains('1') && result[0].message.contains("expected 3"));
425 }
426
427 #[test]
428 fn test_one_or_ordered_separate_lists() {
429 let rule = MD029OrderedListPrefix::new(ListStyle::OneOrOrdered);
431
432 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";
433 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
434 let result = rule.check(&ctx).unwrap();
435 assert!(
436 result.is_empty(),
437 "Separate lists can use different styles in OneOrOrdered mode"
438 );
439 }
440
441 #[test]
444 fn test_check_and_fix_produce_identical_replacements() {
445 let rule = MD029OrderedListPrefix::default();
446
447 let inputs = [
448 "1. First\n3. Skip\n5. Skip\n",
449 "1. First\n3. Third\n2. Second\n",
450 "1. A\n\n3. B\n",
451 "- Unordered\n\n1. A\n3. B\n",
452 "1. A\n 1. Nested wrong\n 3. Nested\n2. B\n",
453 ];
454
455 for input in &inputs {
456 let ctx = crate::lint_context::LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
457 let warnings = rule.check(&ctx).unwrap();
458 let fixed = rule.fix(&ctx).unwrap();
459
460 let ctx2 = crate::lint_context::LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
462 let fixed_twice = rule.fix(&ctx2).unwrap();
463 assert_eq!(
464 fixed, fixed_twice,
465 "fix() is not idempotent for input: {input:?}\nfirst: {fixed:?}\nsecond: {fixed_twice:?}"
466 );
467
468 let warnings_after = rule.check(&ctx2).unwrap();
470 assert!(
471 warnings_after.is_empty(),
472 "check() should produce no warnings after fix() for input: {input:?}\nfixed: {fixed:?}\nremaining: {warnings_after:?}"
473 );
474
475 for warning in &warnings {
478 if let Some(ref fix) = warning.fix {
479 assert!(
480 fix.range.end <= input.len(),
481 "Fix range exceeds input length for {input:?}"
482 );
483 }
484 }
485 }
486 }
487
488 #[test]
490 fn test_fix_idempotent() {
491 let rule = MD029OrderedListPrefix::default();
492
493 let inputs = [
494 "1. A\n3. B\n5. C\n",
495 "# Intro\n\n1. First\n3. Third\n",
496 "1. A\n1. B\n1. C\n",
497 ];
498
499 for input in &inputs {
500 let ctx1 = crate::lint_context::LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
501 let fixed_once = rule.fix(&ctx1).unwrap();
502 let ctx2 =
503 crate::lint_context::LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
504 let fixed_twice = rule.fix(&ctx2).unwrap();
505 assert_eq!(fixed_once, fixed_twice, "fix() is not idempotent for input: {input:?}");
506 }
507 }
508
509 #[test]
512 fn test_pandoc_skips_example_list_markers() {
513 use crate::config::MarkdownFlavor;
514 use crate::lint_context::LintContext;
515 let rule = MD029OrderedListPrefix::default();
516 let content = "(@) First.\n(@good) Second.\n(@) Third.\n";
517 let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
518 let result = rule.check(&ctx).unwrap();
519 assert!(
520 result.is_empty(),
521 "MD029 should not flag (@)/(@label) example markers under Pandoc: {result:?}"
522 );
523 }
524
525 #[test]
528 fn test_pandoc_example_markers_do_not_break_real_ordered_list() {
529 use crate::config::MarkdownFlavor;
530 use crate::lint_context::LintContext;
531 let rule = MD029OrderedListPrefix::default();
532 let content = "1. Real first.\n\n(@) Example.\n\n2. Real second.\n";
533 let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
534 let result = rule.check(&ctx).unwrap();
535 assert!(
536 result.is_empty(),
537 "MD029 should validate the digit-prefixed sequence and skip the example marker: {result:?}"
538 );
539 }
540
541 #[test]
544 fn test_fix_preserves_non_default_start_value() {
545 let rule = MD029OrderedListPrefix::default();
546
547 let content = "11. First\n14. Fourth\n";
550 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
551 let warnings = rule.check(&ctx).unwrap();
552 assert!(!warnings.is_empty(), "Should produce warnings for misnumbered list");
554 assert!(
555 warnings.iter().all(|w| w.fix.is_none()),
556 "Should not provide auto-fix for lists starting at non-1 values"
557 );
558 let fixed = rule.fix(&ctx).unwrap();
560 assert_eq!(
561 fixed, content,
562 "Content should be unchanged when no fixes are available"
563 );
564 }
565
566 #[test]
567 fn test_md029_front_matter() {
568 let rule = MD029OrderedListPrefix::default();
569 let content = "---\n1. key: value\n3. key2: value2\n---\n1. Item 1\n2. Item 2\n";
570 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
571 let result = rule.check(&ctx).unwrap();
572 assert!(
573 result.is_empty(),
574 "Should not flag list-like items in front-matter: {result:?}"
575 );
576 }
577}