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