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 toml;
8
9mod md029_config;
10pub use md029_config::{ListStyle, MD029Config};
11
12#[derive(Debug, Clone, Default)]
13pub struct MD029OrderedListPrefix {
14 config: MD029Config,
15}
16
17impl MD029OrderedListPrefix {
18 pub fn new(style: ListStyle) -> Self {
19 Self {
20 config: MD029Config { style },
21 }
22 }
23
24 pub fn from_config_struct(config: MD029Config) -> Self {
25 Self { config }
26 }
27
28 #[inline]
29 fn parse_marker_number(marker: &str) -> Option<usize> {
30 let num_part = if let Some(stripped) = marker.strip_suffix('.') {
32 stripped
33 } else {
34 marker
35 };
36 num_part.parse::<usize>().ok()
37 }
38
39 #[inline]
40 fn get_expected_number(&self, index: usize, detected_style: Option<ListStyle>) -> usize {
41 let style = match self.config.style {
44 ListStyle::OneOrOrdered | ListStyle::Consistent => detected_style.unwrap_or(ListStyle::OneOne),
45 _ => self.config.style.clone(),
46 };
47
48 match style {
49 ListStyle::One | ListStyle::OneOne => 1,
50 ListStyle::Ordered => index + 1,
51 ListStyle::Ordered0 => index,
52 ListStyle::OneOrOrdered | ListStyle::Consistent => {
53 1
55 }
56 }
57 }
58
59 fn detect_list_style(
61 items: &[(
62 usize,
63 &crate::lint_context::LineInfo,
64 &crate::lint_context::ListItemInfo,
65 )],
66 ) -> ListStyle {
67 if items.len() < 2 {
68 return ListStyle::OneOne;
70 }
71
72 let first_num = Self::parse_marker_number(&items[0].2.marker);
73 let second_num = Self::parse_marker_number(&items[1].2.marker);
74
75 if matches!((first_num, second_num), (Some(0), Some(1))) {
77 return ListStyle::Ordered0;
78 }
79
80 if first_num != Some(1) || second_num != Some(1) {
83 return ListStyle::Ordered;
84 }
85
86 let all_ones = items
89 .iter()
90 .all(|(_, _, item)| Self::parse_marker_number(&item.marker) == Some(1));
91
92 if all_ones {
93 ListStyle::OneOne
94 } else {
95 ListStyle::Ordered
96 }
97 }
98}
99
100impl Rule for MD029OrderedListPrefix {
101 fn name(&self) -> &'static str {
102 "MD029"
103 }
104
105 fn description(&self) -> &'static str {
106 "Ordered list marker value"
107 }
108
109 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
110 if ctx.content.is_empty() {
112 return Ok(Vec::new());
113 }
114
115 if !ctx.content.contains('.') || !ctx.content.lines().any(|line| ORDERED_LIST_MARKER_REGEX.is_match(line)) {
117 return Ok(Vec::new());
118 }
119
120 let mut warnings = Vec::new();
121
122 let blocks_with_ordered: Vec<_> = ctx
125 .list_blocks
126 .iter()
127 .filter(|block| {
128 block.item_lines.iter().any(|&line| {
130 ctx.line_info(line)
131 .and_then(|info| info.list_item.as_ref())
132 .map(|item| item.is_ordered)
133 .unwrap_or(false)
134 })
135 })
136 .collect();
137
138 if blocks_with_ordered.is_empty() {
139 return Ok(Vec::new());
140 }
141
142 let mut block_groups = Vec::new();
144 let mut current_group = vec![blocks_with_ordered[0]];
145
146 for i in 1..blocks_with_ordered.len() {
147 let prev_block = blocks_with_ordered[i - 1];
148 let current_block = blocks_with_ordered[i];
149
150 let has_only_unindented_lists =
152 self.has_only_unindented_lists_between(ctx, prev_block.end_line, current_block.start_line);
153
154 let has_heading_between =
157 self.has_heading_between_blocks(ctx, prev_block.end_line, current_block.start_line);
158
159 let between_content_is_code_only =
161 self.is_only_code_between_blocks(ctx, prev_block.end_line, current_block.start_line);
162
163 let should_group = (between_content_is_code_only || has_only_unindented_lists)
167 && self.blocks_are_logically_continuous(ctx, prev_block.end_line, current_block.start_line)
168 && !has_heading_between;
169
170 if should_group {
171 current_group.push(current_block);
173 } else {
174 block_groups.push(current_group);
176 current_group = vec![current_block];
177 }
178 }
179 block_groups.push(current_group);
180
181 let document_wide_style = if self.config.style == ListStyle::Consistent {
183 let mut all_document_items = Vec::new();
185 for group in &block_groups {
186 for list_block in group {
187 for &item_line in &list_block.item_lines {
188 if let Some(line_info) = ctx.line_info(item_line)
189 && let Some(list_item) = &line_info.list_item
190 && list_item.is_ordered
191 {
192 all_document_items.push((item_line, line_info, list_item));
193 }
194 }
195 }
196 }
197 if !all_document_items.is_empty() {
199 Some(Self::detect_list_style(&all_document_items))
200 } else {
201 None
202 }
203 } else {
204 None
205 };
206
207 for group in block_groups {
209 self.check_ordered_list_group(ctx, &group, &mut warnings, document_wide_style.clone());
210 }
211
212 Ok(warnings)
213 }
214
215 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
216 let warnings = self.check(ctx)?;
218
219 if warnings.is_empty() {
220 return Ok(ctx.content.to_string());
222 }
223
224 let mut fixes: Vec<&Fix> = Vec::new();
226 for warning in &warnings {
227 if let Some(ref fix) = warning.fix {
228 fixes.push(fix);
229 }
230 }
231 fixes.sort_by_key(|f| f.range.start);
232
233 let mut result = String::new();
234 let mut last_pos = 0;
235 let content_bytes = ctx.content.as_bytes();
236
237 for fix in fixes {
238 if last_pos < fix.range.start {
240 let chunk = &content_bytes[last_pos..fix.range.start];
241 result.push_str(
242 std::str::from_utf8(chunk).map_err(|_| LintError::InvalidInput("Invalid UTF-8".to_string()))?,
243 );
244 }
245 result.push_str(&fix.replacement);
247 last_pos = fix.range.end;
248 }
249
250 if last_pos < content_bytes.len() {
252 let chunk = &content_bytes[last_pos..];
253 result.push_str(
254 std::str::from_utf8(chunk).map_err(|_| LintError::InvalidInput("Invalid UTF-8".to_string()))?,
255 );
256 }
257
258 Ok(result)
259 }
260
261 fn category(&self) -> RuleCategory {
263 RuleCategory::List
264 }
265
266 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
268 ctx.content.is_empty() || !ctx.likely_has_lists()
269 }
270
271 fn as_any(&self) -> &dyn std::any::Any {
272 self
273 }
274
275 fn default_config_section(&self) -> Option<(String, toml::Value)> {
276 let default_config = MD029Config::default();
277 let json_value = serde_json::to_value(&default_config).ok()?;
278 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
279 if let toml::Value::Table(table) = toml_value {
280 if !table.is_empty() {
281 Some((MD029Config::RULE_NAME.to_string(), toml::Value::Table(table)))
282 } else {
283 None
284 }
285 } else {
286 None
287 }
288 }
289
290 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
291 where
292 Self: Sized,
293 {
294 let rule_config = crate::rule_config_serde::load_rule_config::<MD029Config>(config);
295 Box::new(MD029OrderedListPrefix::from_config_struct(rule_config))
296 }
297}
298
299impl MD029OrderedListPrefix {
300 fn check_for_lazy_continuation(
302 &self,
303 ctx: &crate::lint_context::LintContext,
304 list_block: &crate::lint_context::ListBlock,
305 warnings: &mut Vec<LintWarning>,
306 ) {
307 for line_num in list_block.start_line..=list_block.end_line {
309 if let Some(line_info) = ctx.line_info(line_num) {
310 if list_block.item_lines.contains(&line_num) {
312 continue;
313 }
314
315 if line_info.is_blank {
317 continue;
318 }
319
320 if line_info.in_code_block {
322 continue;
323 }
324
325 let trimmed = line_info.content(ctx.content).trim();
327 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
328 continue;
329 }
330
331 if line_info.heading.is_some() {
333 continue;
334 }
335
336 if line_info.indent <= 2 && !line_info.content(ctx.content).trim().is_empty() {
338 let col = line_info.indent + 1;
340
341 warnings.push(LintWarning {
342 rule_name: Some(self.name().to_string()),
343 message: "List continuation should be indented (lazy continuation detected)".to_string(),
344 line: line_num,
345 column: col,
346 end_line: line_num,
347 end_column: col,
348 severity: Severity::Warning,
349 fix: Some(Fix {
350 range: line_info.byte_offset..line_info.byte_offset,
351 replacement: " ".to_string(), }),
353 });
354 }
355 }
356 }
357 }
358
359 fn has_only_unindented_lists_between(
363 &self,
364 ctx: &crate::lint_context::LintContext,
365 end_line: usize,
366 start_line: usize,
367 ) -> bool {
368 if end_line >= start_line {
369 return false;
370 }
371
372 let min_continuation_indent =
374 if let Some(prev_block) = ctx.list_blocks.iter().find(|block| block.end_line == end_line) {
375 if let Some(&last_item_line) = prev_block.item_lines.last() {
376 if let Some(line_info) = ctx.line_info(last_item_line) {
377 if let Some(list_item) = &line_info.list_item {
378 if list_item.is_ordered {
379 list_item.marker.len() + 1 } else {
381 2 }
383 } else {
384 3 }
386 } else {
387 3 }
389 } else {
390 3 }
392 } else {
393 3 };
395
396 for line_num in (end_line + 1)..start_line {
397 if let Some(line_info) = ctx.line_info(line_num) {
398 let trimmed = line_info.content(ctx.content).trim();
399
400 if trimmed.is_empty() {
402 continue;
403 }
404
405 if line_info.list_item.is_some() {
407 if line_info.indent >= min_continuation_indent {
409 continue; }
411 return false;
413 }
414
415 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
417 if line_info.indent >= min_continuation_indent {
418 continue; }
420 return false;
422 }
423
424 if line_info.in_code_block {
426 if line_info.indent >= min_continuation_indent {
427 continue; }
429 return false;
431 }
432
433 if line_info.indent >= min_continuation_indent {
435 continue;
436 }
437
438 return false;
440 }
441 }
442
443 true
444 }
445
446 fn calculate_min_continuation_indent(&self, ctx: &crate::lint_context::LintContext, end_line: usize) -> usize {
448 if let Some(prev_block) = ctx.list_blocks.iter().find(|block| block.end_line == end_line) {
449 if let Some(&last_item_line) = prev_block.item_lines.last() {
450 if let Some(line_info) = ctx.line_info(last_item_line) {
451 if let Some(list_item) = &line_info.list_item {
452 if list_item.is_ordered {
453 list_item.marker.len() + 1 } else {
455 2 }
457 } else {
458 3 }
460 } else {
461 3 }
463 } else {
464 3 }
466 } else {
467 3 }
469 }
470
471 fn blocks_are_logically_continuous(
473 &self,
474 ctx: &crate::lint_context::LintContext,
475 end_line: usize,
476 start_line: usize,
477 ) -> bool {
478 if end_line >= start_line {
479 return false;
480 }
481
482 let min_continuation_indent = self.calculate_min_continuation_indent(ctx, end_line);
484
485 for line_num in (end_line + 1)..start_line {
486 if let Some(line_info) = ctx.line_info(line_num) {
487 if line_info.is_blank {
489 continue;
490 }
491
492 if line_info.heading.is_some() {
494 return false;
495 }
496
497 let trimmed = line_info.content(ctx.content).trim();
498
499 if line_info.list_item.is_some() {
501 if line_info.indent >= min_continuation_indent {
502 continue; }
504 return false;
506 }
507
508 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
510 if line_info.indent >= min_continuation_indent {
511 continue; }
513 return false;
515 }
516
517 if line_info.in_code_block {
519 if line_info.indent >= min_continuation_indent {
520 continue; }
522 return false;
524 }
525
526 if line_info.indent >= min_continuation_indent {
528 continue;
529 }
530
531 if !trimmed.is_empty() {
533 return false;
534 }
535 }
536 }
537
538 true
539 }
540
541 fn is_only_code_between_blocks(
542 &self,
543 ctx: &crate::lint_context::LintContext,
544 end_line: usize,
545 start_line: usize,
546 ) -> bool {
547 if end_line >= start_line {
548 return false;
549 }
550
551 let min_continuation_indent = self.calculate_min_continuation_indent(ctx, end_line);
553
554 for line_num in (end_line + 1)..start_line {
555 if let Some(line_info) = ctx.line_info(line_num) {
556 let trimmed = line_info.content(ctx.content).trim();
557
558 if trimmed.is_empty() {
560 continue;
561 }
562
563 if line_info.in_code_block || trimmed.starts_with("```") || trimmed.starts_with("~~~") {
565 if line_info.in_code_block {
567 let context = crate::utils::code_block_utils::CodeBlockUtils::analyze_code_block_context(
569 &ctx.lines,
570 line_num - 1,
571 min_continuation_indent,
572 );
573
574 if matches!(context, crate::utils::code_block_utils::CodeBlockContext::Standalone) {
576 return false; }
578 }
579 continue; }
581
582 if line_info.heading.is_some() {
584 return false;
585 }
586
587 return false;
589 }
590 }
591
592 true
593 }
594
595 fn has_heading_between_blocks(
597 &self,
598 ctx: &crate::lint_context::LintContext,
599 end_line: usize,
600 start_line: usize,
601 ) -> bool {
602 if end_line >= start_line {
603 return false;
604 }
605
606 for line_num in (end_line + 1)..start_line {
607 if let Some(line_info) = ctx.line_info(line_num)
608 && line_info.heading.is_some()
609 {
610 return true;
611 }
612 }
613
614 false
615 }
616
617 fn find_parent_list_item(
620 &self,
621 ctx: &crate::lint_context::LintContext,
622 ordered_line: usize,
623 ordered_indent: usize,
624 ) -> usize {
625 for line_num in (1..ordered_line).rev() {
627 if let Some(line_info) = ctx.line_info(line_num) {
628 if let Some(list_item) = &line_info.list_item {
629 if list_item.marker_column < ordered_indent {
631 return line_num;
633 }
634 }
635 else if !line_info.is_blank && line_info.indent == 0 {
637 break;
638 }
639 }
640 }
641 0 }
643
644 fn check_ordered_list_group(
646 &self,
647 ctx: &crate::lint_context::LintContext,
648 group: &[&crate::lint_context::ListBlock],
649 warnings: &mut Vec<LintWarning>,
650 document_wide_style: Option<ListStyle>,
651 ) {
652 let mut all_items = Vec::new();
654
655 for list_block in group {
656 self.check_for_lazy_continuation(ctx, list_block, warnings);
658
659 for &item_line in &list_block.item_lines {
660 if let Some(line_info) = ctx.line_info(item_line)
661 && let Some(list_item) = &line_info.list_item
662 {
663 if !list_item.is_ordered {
665 continue;
666 }
667 all_items.push((item_line, line_info, list_item));
668 }
669 }
670 }
671
672 all_items.sort_by_key(|(line_num, _, _)| *line_num);
674
675 type LevelGroups<'a> = std::collections::HashMap<
678 (usize, usize),
679 Vec<(
680 usize,
681 &'a crate::lint_context::LineInfo,
682 &'a crate::lint_context::ListItemInfo,
683 )>,
684 >;
685 let mut level_groups: LevelGroups = std::collections::HashMap::new();
686
687 for (line_num, line_info, list_item) in all_items {
688 let parent_line = self.find_parent_list_item(ctx, line_num, list_item.marker_column);
690
691 level_groups
693 .entry((list_item.marker_column, parent_line))
694 .or_default()
695 .push((line_num, line_info, list_item));
696 }
697
698 for ((_indent, _parent), mut group) in level_groups {
700 group.sort_by_key(|(line_num, _, _)| *line_num);
702
703 let detected_style = if let Some(doc_style) = document_wide_style.clone() {
706 Some(doc_style)
708 } else if self.config.style == ListStyle::OneOrOrdered {
709 Some(Self::detect_list_style(&group))
711 } else {
712 None
714 };
715
716 for (idx, (line_num, line_info, list_item)) in group.iter().enumerate() {
718 if let Some(actual_num) = Self::parse_marker_number(&list_item.marker) {
720 let expected_num = self.get_expected_number(idx, detected_style.clone());
721
722 if actual_num != expected_num {
723 let marker_start = line_info.byte_offset + list_item.marker_column;
725 let number_len = if let Some(dot_pos) = list_item.marker.find('.') {
727 dot_pos } else if let Some(paren_pos) = list_item.marker.find(')') {
729 paren_pos } else {
731 list_item.marker.len() };
733
734 let style_name = match detected_style.as_ref().unwrap_or(&ListStyle::Ordered) {
736 ListStyle::OneOne => "one",
737 ListStyle::Ordered => "ordered",
738 ListStyle::Ordered0 => "ordered0",
739 _ => "ordered", };
741
742 let style_context = match self.config.style {
743 ListStyle::Consistent => format!("document style '{style_name}'"),
744 ListStyle::OneOrOrdered => format!("list style '{style_name}'"),
745 ListStyle::One | ListStyle::OneOne => "configured style 'one'".to_string(),
746 ListStyle::Ordered => "configured style 'ordered'".to_string(),
747 ListStyle::Ordered0 => "configured style 'ordered0'".to_string(),
748 };
749
750 warnings.push(LintWarning {
751 rule_name: Some(self.name().to_string()),
752 message: format!(
753 "Ordered list item number {actual_num} does not match {style_context} (expected {expected_num})"
754 ),
755 line: *line_num,
756 column: list_item.marker_column + 1,
757 end_line: *line_num,
758 end_column: list_item.marker_column + number_len + 1,
759 severity: Severity::Warning,
760 fix: Some(Fix {
761 range: marker_start..marker_start + number_len,
762 replacement: expected_num.to_string(),
763 }),
764 });
765 }
766 }
767 }
768 }
769 }
770}
771
772#[cfg(test)]
773mod tests {
774 use super::*;
775
776 #[test]
777 fn test_basic_functionality() {
778 let rule = MD029OrderedListPrefix::default();
780
781 let content = "1. First item\n2. Second item\n3. Third item";
783 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
784 let result = rule.check(&ctx).unwrap();
785 assert!(result.is_empty());
786
787 let content = "1. First item\n3. Third item\n5. Fifth item";
789 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
790 let result = rule.check(&ctx).unwrap();
791 assert_eq!(result.len(), 2); let rule = MD029OrderedListPrefix::new(ListStyle::OneOne);
795 let content = "1. First item\n2. Second item\n3. Third item";
796 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
797 let result = rule.check(&ctx).unwrap();
798 assert_eq!(result.len(), 2); let rule = MD029OrderedListPrefix::new(ListStyle::Ordered0);
802 let content = "0. First item\n1. Second item\n2. Third item";
803 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
804 let result = rule.check(&ctx).unwrap();
805 assert!(result.is_empty());
806 }
807
808 #[test]
809 fn test_redundant_computation_fix() {
810 let rule = MD029OrderedListPrefix::default();
815
816 let content = "1. First item\n3. Wrong number\n2. Another wrong number";
818 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
819
820 let result = rule.check(&ctx).unwrap();
822 assert_eq!(result.len(), 2); assert!(result[0].message.contains("3") && result[0].message.contains("expected 2"));
826 assert!(result[1].message.contains("2") && result[1].message.contains("expected 3"));
827 }
828
829 #[test]
830 fn test_performance_improvement() {
831 let rule = MD029OrderedListPrefix::default();
833
834 let mut content = String::new();
836 for i in 1..=100 {
837 content.push_str(&format!("{}. Item {}\n", i + 1, i)); }
839
840 let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Standard);
841
842 let result = rule.check(&ctx).unwrap();
844 assert_eq!(result.len(), 100); assert!(result[0].message.contains("2") && result[0].message.contains("expected 1"));
848 assert!(result[99].message.contains("101") && result[99].message.contains("expected 100"));
849 }
850
851 #[test]
852 fn test_one_or_ordered_with_all_ones() {
853 let rule = MD029OrderedListPrefix::new(ListStyle::OneOrOrdered);
855
856 let content = "1. First item\n1. Second item\n1. Third item";
857 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
858 let result = rule.check(&ctx).unwrap();
859 assert!(result.is_empty(), "All ones should be valid in OneOrOrdered mode");
860 }
861
862 #[test]
863 fn test_one_or_ordered_with_sequential() {
864 let rule = MD029OrderedListPrefix::new(ListStyle::OneOrOrdered);
866
867 let content = "1. First item\n2. Second item\n3. Third item";
868 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
869 let result = rule.check(&ctx).unwrap();
870 assert!(
871 result.is_empty(),
872 "Sequential numbering should be valid in OneOrOrdered mode"
873 );
874 }
875
876 #[test]
877 fn test_one_or_ordered_with_mixed_style() {
878 let rule = MD029OrderedListPrefix::new(ListStyle::OneOrOrdered);
880
881 let content = "1. First item\n2. Second item\n1. Third item";
882 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
883 let result = rule.check(&ctx).unwrap();
884 assert_eq!(result.len(), 1, "Mixed style should produce one warning");
885 assert!(result[0].message.contains("1") && result[0].message.contains("expected 3"));
886 }
887
888 #[test]
889 fn test_one_or_ordered_separate_lists() {
890 let rule = MD029OrderedListPrefix::new(ListStyle::OneOrOrdered);
892
893 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";
894 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
895 let result = rule.check(&ctx).unwrap();
896 assert!(
897 result.is_empty(),
898 "Separate lists can use different styles in OneOrOrdered mode"
899 );
900 }
901}