1use 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, 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.clone(),
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(|(ln, _, _)| *ln).unwrap_or(0));
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.clone() {
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.clone(), 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 {
283 range: marker_start..marker_start + number_len,
284 replacement: expected_num.to_string(),
285 })
286 } else {
287 None
288 },
289 });
290 }
291 }
292 }
293 }
294 }
295}
296
297impl Rule for MD029OrderedListPrefix {
298 fn name(&self) -> &'static str {
299 "MD029"
300 }
301
302 fn description(&self) -> &'static str {
303 "Ordered list marker value"
304 }
305
306 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
307 if ctx.content.is_empty() {
309 return Ok(Vec::new());
310 }
311
312 if (!ctx.content.contains('.') && !ctx.content.contains(')'))
314 || !ctx.content.lines().any(|line| ORDERED_LIST_MARKER_REGEX.is_match(line))
315 {
316 return Ok(Vec::new());
317 }
318
319 let mut warnings = Vec::new();
320
321 let list_groups = Self::group_items_by_commonmark_list(ctx, &ctx.line_to_list);
325
326 if list_groups.is_empty() {
327 return Ok(Vec::new());
328 }
329
330 let document_wide_style = if self.config.style == ListStyle::Consistent {
332 let mut all_document_items = Vec::new();
334 for (_, items) in &list_groups {
335 for (line_num, line_info, list_item) in items {
336 all_document_items.push((*line_num, *line_info, *list_item));
337 }
338 }
339 if !all_document_items.is_empty() {
341 Some(Self::detect_list_style(&all_document_items, 1))
342 } else {
343 None
344 }
345 } else {
346 None
347 };
348
349 for (list_id, items) in list_groups {
351 let start_value = ctx.list_start_values.get(&list_id).copied().unwrap_or(1);
352 self.check_commonmark_list_group(ctx, &items, &mut warnings, document_wide_style.clone(), start_value);
353 }
354
355 warnings.sort_by_key(|w| (w.line, w.column));
357
358 Ok(warnings)
359 }
360
361 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
362 let warnings = self.check(ctx)?;
366 if warnings.is_empty() {
367 return Ok(ctx.content.to_string());
368 }
369 let warnings =
370 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
371 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings).map_err(LintError::InvalidInput)
372 }
373
374 fn category(&self) -> RuleCategory {
376 RuleCategory::List
377 }
378
379 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
381 ctx.content.is_empty() || !ctx.likely_has_lists()
382 }
383
384 fn as_any(&self) -> &dyn std::any::Any {
385 self
386 }
387
388 fn default_config_section(&self) -> Option<(String, toml::Value)> {
389 let default_config = MD029Config::default();
390 let json_value = serde_json::to_value(&default_config).ok()?;
391 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
392 if let toml::Value::Table(table) = toml_value {
393 if !table.is_empty() {
394 Some((MD029Config::RULE_NAME.to_string(), toml::Value::Table(table)))
395 } else {
396 None
397 }
398 } else {
399 None
400 }
401 }
402
403 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
404 where
405 Self: Sized,
406 {
407 let rule_config = crate::rule_config_serde::load_rule_config::<MD029Config>(config);
408 Box::new(MD029OrderedListPrefix::from_config_struct(rule_config))
409 }
410}
411
412#[cfg(test)]
413mod tests {
414 use super::*;
415
416 #[test]
417 fn test_basic_functionality() {
418 let rule = MD029OrderedListPrefix::default();
420
421 let content = "1. First item\n2. Second item\n3. Third item";
423 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
424 let result = rule.check(&ctx).unwrap();
425 assert!(result.is_empty());
426
427 let content = "1. First item\n3. Third item\n5. Fifth item";
429 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
430 let result = rule.check(&ctx).unwrap();
431 assert_eq!(result.len(), 2); let rule = MD029OrderedListPrefix::new(ListStyle::OneOne);
435 let content = "1. First item\n2. Second item\n3. Third item";
436 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
437 let result = rule.check(&ctx).unwrap();
438 assert_eq!(result.len(), 2); let rule = MD029OrderedListPrefix::new(ListStyle::Ordered0);
442 let content = "0. First item\n1. Second item\n2. Third item";
443 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
444 let result = rule.check(&ctx).unwrap();
445 assert!(result.is_empty());
446 }
447
448 #[test]
449 fn test_redundant_computation_fix() {
450 let rule = MD029OrderedListPrefix::default();
455
456 let content = "1. First item\n3. Wrong number\n2. Another wrong number";
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(), 2); assert!(result[0].message.contains("3") && result[0].message.contains("expected 2"));
466 assert!(result[1].message.contains("2") && result[1].message.contains("expected 3"));
467 }
468
469 #[test]
470 fn test_performance_improvement() {
471 let rule = MD029OrderedListPrefix::default();
473
474 let mut content = String::from("1. Item 1\n"); for i in 2..=100 {
479 content.push_str(&format!("{}. Item {}\n", i * 5 - 5, i)); }
481
482 let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
483
484 let result = rule.check(&ctx).unwrap();
486 assert_eq!(result.len(), 99, "Should have warnings for items 2-100 (99 items)");
487
488 assert!(result[0].message.contains("5") && result[0].message.contains("expected 2"));
490 }
491
492 #[test]
493 fn test_one_or_ordered_with_all_ones() {
494 let rule = MD029OrderedListPrefix::new(ListStyle::OneOrOrdered);
496
497 let content = "1. First item\n1. Second item\n1. Third item";
498 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
499 let result = rule.check(&ctx).unwrap();
500 assert!(result.is_empty(), "All ones should be valid in OneOrOrdered mode");
501 }
502
503 #[test]
504 fn test_one_or_ordered_with_sequential() {
505 let rule = MD029OrderedListPrefix::new(ListStyle::OneOrOrdered);
507
508 let content = "1. First item\n2. Second item\n3. Third item";
509 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
510 let result = rule.check(&ctx).unwrap();
511 assert!(
512 result.is_empty(),
513 "Sequential numbering should be valid in OneOrOrdered mode"
514 );
515 }
516
517 #[test]
518 fn test_one_or_ordered_with_mixed_style() {
519 let rule = MD029OrderedListPrefix::new(ListStyle::OneOrOrdered);
521
522 let content = "1. First item\n2. Second item\n1. Third item";
523 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
524 let result = rule.check(&ctx).unwrap();
525 assert_eq!(result.len(), 1, "Mixed style should produce one warning");
526 assert!(result[0].message.contains("1") && result[0].message.contains("expected 3"));
527 }
528
529 #[test]
530 fn test_one_or_ordered_separate_lists() {
531 let rule = MD029OrderedListPrefix::new(ListStyle::OneOrOrdered);
533
534 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";
535 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
536 let result = rule.check(&ctx).unwrap();
537 assert!(
538 result.is_empty(),
539 "Separate lists can use different styles in OneOrOrdered mode"
540 );
541 }
542
543 #[test]
546 fn test_check_and_fix_produce_identical_replacements() {
547 let rule = MD029OrderedListPrefix::default();
548
549 let inputs = [
550 "1. First\n3. Skip\n5. Skip\n",
551 "1. First\n3. Third\n2. Second\n",
552 "1. A\n\n3. B\n",
553 "- Unordered\n\n1. A\n3. B\n",
554 "1. A\n 1. Nested wrong\n 3. Nested\n2. B\n",
555 ];
556
557 for input in &inputs {
558 let ctx = crate::lint_context::LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
559 let warnings = rule.check(&ctx).unwrap();
560 let fixed = rule.fix(&ctx).unwrap();
561
562 let ctx2 = crate::lint_context::LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
564 let fixed_twice = rule.fix(&ctx2).unwrap();
565 assert_eq!(
566 fixed, fixed_twice,
567 "fix() is not idempotent for input: {input:?}\nfirst: {fixed:?}\nsecond: {fixed_twice:?}"
568 );
569
570 let warnings_after = rule.check(&ctx2).unwrap();
572 assert!(
573 warnings_after.is_empty(),
574 "check() should produce no warnings after fix() for input: {input:?}\nfixed: {fixed:?}\nremaining: {warnings_after:?}"
575 );
576
577 for warning in &warnings {
580 if let Some(ref fix) = warning.fix {
581 assert!(
582 fix.range.end <= input.len(),
583 "Fix range exceeds input length for {input:?}"
584 );
585 }
586 }
587 }
588 }
589
590 #[test]
592 fn test_fix_idempotent() {
593 let rule = MD029OrderedListPrefix::default();
594
595 let inputs = [
596 "1. A\n3. B\n5. C\n",
597 "# Intro\n\n1. First\n3. Third\n",
598 "1. A\n1. B\n1. C\n",
599 ];
600
601 for input in &inputs {
602 let ctx1 = crate::lint_context::LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
603 let fixed_once = rule.fix(&ctx1).unwrap();
604 let ctx2 =
605 crate::lint_context::LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
606 let fixed_twice = rule.fix(&ctx2).unwrap();
607 assert_eq!(fixed_once, fixed_twice, "fix() is not idempotent for input: {input:?}");
608 }
609 }
610
611 #[test]
614 fn test_fix_preserves_non_default_start_value() {
615 let rule = MD029OrderedListPrefix::default();
616
617 let content = "11. First\n14. Fourth\n";
620 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
621 let warnings = rule.check(&ctx).unwrap();
622 assert!(!warnings.is_empty(), "Should produce warnings for misnumbered list");
624 assert!(
625 warnings.iter().all(|w| w.fix.is_none()),
626 "Should not provide auto-fix for lists starting at non-1 values"
627 );
628 let fixed = rule.fix(&ctx).unwrap();
630 assert_eq!(
631 fixed, content,
632 "Content should be unchanged when no fixes are available"
633 );
634 }
635}