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;
12pub(super) use md029_config::MD029Config;
13
14type ListItemGroup<'a> = (
16 usize,
17 Vec<(
18 usize,
19 &'a crate::lint_context::LineInfo,
20 &'a crate::lint_context::ListItemInfo,
21 )>,
22);
23
24#[derive(Debug, Clone, Default)]
25pub struct MD029OrderedListPrefix {
26 config: MD029Config,
27}
28
29impl MD029OrderedListPrefix {
30 pub fn new(style: ListStyle) -> Self {
31 Self {
32 config: MD029Config { style },
33 }
34 }
35
36 pub fn from_config_struct(config: MD029Config) -> Self {
37 Self { config }
38 }
39
40 #[inline]
41 fn parse_marker_number(marker: &str) -> Option<usize> {
42 let num_part = if let Some(stripped) = marker.strip_suffix('.') {
44 stripped
45 } else {
46 marker
47 };
48 num_part.parse::<usize>().ok()
49 }
50
51 #[inline]
55 fn get_expected_number(&self, index: usize, detected_style: Option<ListStyle>, start_value: u64) -> usize {
56 let style = match self.config.style {
59 ListStyle::OneOrOrdered | ListStyle::Consistent => detected_style.unwrap_or(ListStyle::OneOne),
60 _ => self.config.style,
61 };
62
63 match style {
64 ListStyle::One | ListStyle::OneOne => 1,
65 ListStyle::Ordered => (start_value as usize) + index,
66 ListStyle::Ordered0 => index,
67 ListStyle::OneOrOrdered | ListStyle::Consistent => {
68 1
70 }
71 }
72 }
73
74 fn detect_list_style(
77 items: &[(
78 usize,
79 &crate::lint_context::LineInfo,
80 &crate::lint_context::ListItemInfo,
81 )],
82 start_value: u64,
83 ) -> ListStyle {
84 if items.len() < 2 {
85 let first_num = Self::parse_marker_number(&items[0].2.marker);
89 if first_num == Some(start_value as usize) {
90 return ListStyle::Ordered;
91 }
92 return ListStyle::OneOne;
93 }
94
95 let first_num = Self::parse_marker_number(&items[0].2.marker);
96 let second_num = Self::parse_marker_number(&items[1].2.marker);
97
98 if matches!((first_num, second_num), (Some(0), Some(1))) {
100 return ListStyle::Ordered0;
101 }
102
103 if first_num != Some(1) || second_num != Some(1) {
106 return ListStyle::Ordered;
107 }
108
109 let all_ones = items
112 .iter()
113 .all(|(_, _, item)| Self::parse_marker_number(&item.marker) == Some(1));
114
115 if all_ones {
116 ListStyle::OneOne
117 } else {
118 ListStyle::Ordered
119 }
120 }
121
122 fn group_items_by_commonmark_list<'a>(
125 ctx: &'a crate::lint_context::LintContext,
126 line_to_list: &std::collections::HashMap<usize, usize>,
127 ) -> Vec<ListItemGroup<'a>> {
128 let mut items_with_list_id: Vec<(
130 usize,
131 usize,
132 &crate::lint_context::LineInfo,
133 &crate::lint_context::ListItemInfo,
134 )> = Vec::new();
135
136 for line_num in 1..=ctx.lines.len() {
137 if let Some(line_info) = ctx.line_info(line_num)
138 && let Some(list_item) = line_info.list_item.as_deref()
139 && list_item.is_ordered
140 {
141 if let Some(&list_id) = line_to_list.get(&line_num) {
143 items_with_list_id.push((list_id, line_num, line_info, list_item));
144 }
145 }
146 }
147
148 let mut groups: std::collections::HashMap<
150 usize,
151 Vec<(
152 usize,
153 &crate::lint_context::LineInfo,
154 &crate::lint_context::ListItemInfo,
155 )>,
156 > = std::collections::HashMap::new();
157
158 for (list_id, line_num, line_info, list_item) in items_with_list_id {
159 groups
160 .entry(list_id)
161 .or_default()
162 .push((line_num, line_info, list_item));
163 }
164
165 let mut result: Vec<_> = groups.into_iter().collect();
167 for (_, items) in &mut result {
168 items.sort_by_key(|(line_num, _, _)| *line_num);
169 }
170 result.sort_by_key(|(_, items)| items.first().map_or(0, |(ln, _, _)| *ln));
172
173 result
174 }
175
176 fn check_commonmark_list_group(
180 &self,
181 _ctx: &crate::lint_context::LintContext,
182 group: &[(
183 usize,
184 &crate::lint_context::LineInfo,
185 &crate::lint_context::ListItemInfo,
186 )],
187 warnings: &mut Vec<LintWarning>,
188 document_wide_style: Option<ListStyle>,
189 start_value: u64,
190 ) {
191 if group.is_empty() {
192 return;
193 }
194
195 type LevelGroups<'a> = HashMap<
197 usize,
198 Vec<(
199 usize,
200 &'a crate::lint_context::LineInfo,
201 &'a crate::lint_context::ListItemInfo,
202 )>,
203 >;
204 let mut level_groups: LevelGroups = HashMap::new();
205
206 for (line_num, line_info, list_item) in group {
207 level_groups
208 .entry(list_item.marker_column)
209 .or_default()
210 .push((*line_num, *line_info, *list_item));
211 }
212
213 let mut sorted_levels: Vec<_> = level_groups.into_iter().collect();
215 sorted_levels.sort_by_key(|(indent, _)| *indent);
216
217 for (_indent, mut items) in sorted_levels {
218 items.sort_by_key(|(line_num, _, _)| *line_num);
220
221 if items.is_empty() {
222 continue;
223 }
224
225 let detected_style = if let Some(doc_style) = document_wide_style {
227 Some(doc_style)
228 } else if self.config.style == ListStyle::OneOrOrdered {
229 Some(Self::detect_list_style(&items, start_value))
230 } else {
231 None
232 };
233
234 for (idx, (line_num, line_info, list_item)) in items.iter().enumerate() {
236 if let Some(actual_num) = Self::parse_marker_number(&list_item.marker) {
237 let expected_num = self.get_expected_number(idx, detected_style, start_value);
238
239 if actual_num != expected_num {
240 let marker_start = line_info.byte_offset + list_item.marker_column;
241 let number_len = if let Some(dot_pos) = list_item.marker.find('.') {
242 dot_pos
243 } else if let Some(paren_pos) = list_item.marker.find(')') {
244 paren_pos
245 } else {
246 list_item.marker.len()
247 };
248
249 let style_name = match detected_style.as_ref().unwrap_or(&ListStyle::Ordered) {
250 ListStyle::OneOne => "one",
251 ListStyle::Ordered => "ordered",
252 ListStyle::Ordered0 => "ordered0",
253 _ => "ordered",
254 };
255
256 let style_context = match self.config.style {
257 ListStyle::Consistent => format!("document style '{style_name}'"),
258 ListStyle::OneOrOrdered => format!("list style '{style_name}'"),
259 ListStyle::One | ListStyle::OneOne => "configured style 'one'".to_string(),
260 ListStyle::Ordered => "configured style 'ordered'".to_string(),
261 ListStyle::Ordered0 => "configured style 'ordered0'".to_string(),
262 };
263
264 let should_provide_fix =
270 start_value == 1 || matches!(self.config.style, ListStyle::One | ListStyle::OneOne);
271
272 warnings.push(LintWarning {
273 rule_name: Some(self.name().to_string()),
274 message: format!(
275 "Ordered list item number {actual_num} does not match {style_context} (expected {expected_num})"
276 ),
277 line: *line_num,
278 column: list_item.marker_column + 1,
279 end_line: *line_num,
280 end_column: list_item.marker_column + number_len + 1,
281 severity: Severity::Warning,
282 fix: if should_provide_fix {
283 Some(Fix::new(marker_start..marker_start + number_len, expected_num.to_string()))
284 } else {
285 None
286 },
287 });
288 }
289 }
290 }
291 }
292 }
293}
294
295impl Rule for MD029OrderedListPrefix {
296 fn name(&self) -> &'static str {
297 "MD029"
298 }
299
300 fn description(&self) -> &'static str {
301 "Ordered list marker value"
302 }
303
304 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
305 if ctx.content.is_empty() {
307 return Ok(Vec::new());
308 }
309
310 if (!ctx.content.contains('.') && !ctx.content.contains(')'))
312 || !ctx.content.lines().any(|line| ORDERED_LIST_MARKER_REGEX.is_match(line))
313 {
314 return Ok(Vec::new());
315 }
316
317 let mut warnings = Vec::new();
318
319 let list_groups = Self::group_items_by_commonmark_list(ctx, &ctx.line_to_list);
323
324 if list_groups.is_empty() {
325 return Ok(Vec::new());
326 }
327
328 let document_wide_style = if self.config.style == ListStyle::Consistent {
330 let mut all_document_items = Vec::new();
332 for (_, items) in &list_groups {
333 for (line_num, line_info, list_item) in items {
334 all_document_items.push((*line_num, *line_info, *list_item));
335 }
336 }
337 if !all_document_items.is_empty() {
339 Some(Self::detect_list_style(&all_document_items, 1))
340 } else {
341 None
342 }
343 } else {
344 None
345 };
346
347 for (list_id, items) in list_groups {
349 let start_value = ctx.list_start_values.get(&list_id).copied().unwrap_or(1);
350 self.check_commonmark_list_group(ctx, &items, &mut warnings, document_wide_style, start_value);
351 }
352
353 warnings.sort_by_key(|w| (w.line, w.column));
355
356 Ok(warnings)
357 }
358
359 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
360 let warnings = self.check(ctx)?;
364 if warnings.is_empty() {
365 return Ok(ctx.content.to_string());
366 }
367 let warnings =
368 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
369 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings).map_err(LintError::InvalidInput)
370 }
371
372 fn category(&self) -> RuleCategory {
374 RuleCategory::List
375 }
376
377 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
379 ctx.content.is_empty() || !ctx.likely_has_lists()
380 }
381
382 fn as_any(&self) -> &dyn std::any::Any {
383 self
384 }
385
386 fn default_config_section(&self) -> Option<(String, toml::Value)> {
387 let default_config = MD029Config::default();
388 let json_value = serde_json::to_value(&default_config).ok()?;
389 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
390 if let toml::Value::Table(table) = toml_value {
391 if !table.is_empty() {
392 Some((MD029Config::RULE_NAME.to_string(), toml::Value::Table(table)))
393 } else {
394 None
395 }
396 } else {
397 None
398 }
399 }
400
401 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
402 where
403 Self: Sized,
404 {
405 let rule_config = crate::rule_config_serde::load_rule_config::<MD029Config>(config);
406 Box::new(MD029OrderedListPrefix::from_config_struct(rule_config))
407 }
408}
409
410#[cfg(test)]
411mod tests {
412 use super::*;
413
414 #[test]
415 fn test_basic_functionality() {
416 let rule = MD029OrderedListPrefix::default();
418
419 let content = "1. First item\n2. Second item\n3. 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!(result.is_empty());
424
425 let content = "1. First item\n3. Third item\n5. Fifth item";
427 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
428 let result = rule.check(&ctx).unwrap();
429 assert_eq!(result.len(), 2); let rule = MD029OrderedListPrefix::new(ListStyle::OneOne);
433 let content = "1. First item\n2. Second item\n3. Third item";
434 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
435 let result = rule.check(&ctx).unwrap();
436 assert_eq!(result.len(), 2); let rule = MD029OrderedListPrefix::new(ListStyle::Ordered0);
440 let content = "0. First item\n1. Second item\n2. Third item";
441 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
442 let result = rule.check(&ctx).unwrap();
443 assert!(result.is_empty());
444 }
445
446 #[test]
447 fn test_redundant_computation_fix() {
448 let rule = MD029OrderedListPrefix::default();
453
454 let content = "1. First item\n3. Wrong number\n2. Another wrong number";
456 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
457
458 let result = rule.check(&ctx).unwrap();
460 assert_eq!(result.len(), 2); assert!(result[0].message.contains('3') && result[0].message.contains("expected 2"));
464 assert!(result[1].message.contains('2') && result[1].message.contains("expected 3"));
465 }
466
467 #[test]
468 fn test_performance_improvement() {
469 let rule = MD029OrderedListPrefix::default();
471
472 let mut content = String::from("1. Item 1\n"); for i in 2..=100 {
477 content.push_str(&format!("{}. Item {}\n", i * 5 - 5, i)); }
479
480 let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
481
482 let result = rule.check(&ctx).unwrap();
484 assert_eq!(result.len(), 99, "Should have warnings for items 2-100 (99 items)");
485
486 assert!(result[0].message.contains('5') && result[0].message.contains("expected 2"));
488 }
489
490 #[test]
491 fn test_one_or_ordered_with_all_ones() {
492 let rule = MD029OrderedListPrefix::new(ListStyle::OneOrOrdered);
494
495 let content = "1. First item\n1. Second item\n1. Third item";
496 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
497 let result = rule.check(&ctx).unwrap();
498 assert!(result.is_empty(), "All ones should be valid in OneOrOrdered mode");
499 }
500
501 #[test]
502 fn test_one_or_ordered_with_sequential() {
503 let rule = MD029OrderedListPrefix::new(ListStyle::OneOrOrdered);
505
506 let content = "1. First item\n2. Second item\n3. Third item";
507 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
508 let result = rule.check(&ctx).unwrap();
509 assert!(
510 result.is_empty(),
511 "Sequential numbering should be valid in OneOrOrdered mode"
512 );
513 }
514
515 #[test]
516 fn test_one_or_ordered_with_mixed_style() {
517 let rule = MD029OrderedListPrefix::new(ListStyle::OneOrOrdered);
519
520 let content = "1. First item\n2. Second item\n1. Third item";
521 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
522 let result = rule.check(&ctx).unwrap();
523 assert_eq!(result.len(), 1, "Mixed style should produce one warning");
524 assert!(result[0].message.contains('1') && result[0].message.contains("expected 3"));
525 }
526
527 #[test]
528 fn test_one_or_ordered_separate_lists() {
529 let rule = MD029OrderedListPrefix::new(ListStyle::OneOrOrdered);
531
532 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";
533 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
534 let result = rule.check(&ctx).unwrap();
535 assert!(
536 result.is_empty(),
537 "Separate lists can use different styles in OneOrOrdered mode"
538 );
539 }
540
541 #[test]
544 fn test_check_and_fix_produce_identical_replacements() {
545 let rule = MD029OrderedListPrefix::default();
546
547 let inputs = [
548 "1. First\n3. Skip\n5. Skip\n",
549 "1. First\n3. Third\n2. Second\n",
550 "1. A\n\n3. B\n",
551 "- Unordered\n\n1. A\n3. B\n",
552 "1. A\n 1. Nested wrong\n 3. Nested\n2. B\n",
553 ];
554
555 for input in &inputs {
556 let ctx = crate::lint_context::LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
557 let warnings = rule.check(&ctx).unwrap();
558 let fixed = rule.fix(&ctx).unwrap();
559
560 let ctx2 = crate::lint_context::LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
562 let fixed_twice = rule.fix(&ctx2).unwrap();
563 assert_eq!(
564 fixed, fixed_twice,
565 "fix() is not idempotent for input: {input:?}\nfirst: {fixed:?}\nsecond: {fixed_twice:?}"
566 );
567
568 let warnings_after = rule.check(&ctx2).unwrap();
570 assert!(
571 warnings_after.is_empty(),
572 "check() should produce no warnings after fix() for input: {input:?}\nfixed: {fixed:?}\nremaining: {warnings_after:?}"
573 );
574
575 for warning in &warnings {
578 if let Some(ref fix) = warning.fix {
579 assert!(
580 fix.range.end <= input.len(),
581 "Fix range exceeds input length for {input:?}"
582 );
583 }
584 }
585 }
586 }
587
588 #[test]
590 fn test_fix_idempotent() {
591 let rule = MD029OrderedListPrefix::default();
592
593 let inputs = [
594 "1. A\n3. B\n5. C\n",
595 "# Intro\n\n1. First\n3. Third\n",
596 "1. A\n1. B\n1. C\n",
597 ];
598
599 for input in &inputs {
600 let ctx1 = crate::lint_context::LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
601 let fixed_once = rule.fix(&ctx1).unwrap();
602 let ctx2 =
603 crate::lint_context::LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
604 let fixed_twice = rule.fix(&ctx2).unwrap();
605 assert_eq!(fixed_once, fixed_twice, "fix() is not idempotent for input: {input:?}");
606 }
607 }
608
609 #[test]
612 fn test_fix_preserves_non_default_start_value() {
613 let rule = MD029OrderedListPrefix::default();
614
615 let content = "11. First\n14. Fourth\n";
618 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
619 let warnings = rule.check(&ctx).unwrap();
620 assert!(!warnings.is_empty(), "Should produce warnings for misnumbered list");
622 assert!(
623 warnings.iter().all(|w| w.fix.is_none()),
624 "Should not provide auto-fix for lists starting at non-1 values"
625 );
626 let fixed = rule.fix(&ctx).unwrap();
628 assert_eq!(
629 fixed, content,
630 "Content should be unchanged when no fixes are available"
631 );
632 }
633}