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 = detected_style.unwrap_or(self.config.style.clone());
42 match style {
43 ListStyle::One | ListStyle::OneOne => 1,
44 ListStyle::Ordered => index + 1,
45 ListStyle::Ordered0 => index,
46 ListStyle::OneOrOrdered => {
47 1
50 }
51 }
52 }
53
54 fn detect_list_style(
56 items: &[(
57 usize,
58 &crate::lint_context::LineInfo,
59 &crate::lint_context::ListItemInfo,
60 )],
61 ) -> ListStyle {
62 if items.len() < 2 {
63 return ListStyle::OneOne;
65 }
66
67 let first_num = Self::parse_marker_number(&items[0].2.marker);
69 let second_num = Self::parse_marker_number(&items[1].2.marker);
70
71 match (first_num, second_num) {
72 (Some(1), Some(1)) => ListStyle::OneOne, (Some(0), Some(1)) => ListStyle::Ordered0, (Some(1), Some(2)) => ListStyle::Ordered, _ => {
76 let all_ones = items
78 .iter()
79 .all(|(_, _, item)| Self::parse_marker_number(&item.marker) == Some(1));
80 if all_ones {
81 ListStyle::OneOne
82 } else {
83 ListStyle::Ordered
84 }
85 }
86 }
87 }
88}
89
90impl Rule for MD029OrderedListPrefix {
91 fn name(&self) -> &'static str {
92 "MD029"
93 }
94
95 fn description(&self) -> &'static str {
96 "Ordered list marker value"
97 }
98
99 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
100 if ctx.content.is_empty() {
102 return Ok(Vec::new());
103 }
104
105 if !ctx.content.contains('.') || !ctx.content.lines().any(|line| ORDERED_LIST_MARKER_REGEX.is_match(line)) {
107 return Ok(Vec::new());
108 }
109
110 let mut warnings = Vec::new();
111
112 let blocks_with_ordered: Vec<_> = ctx
115 .list_blocks
116 .iter()
117 .filter(|block| {
118 block.item_lines.iter().any(|&line| {
120 ctx.line_info(line)
121 .and_then(|info| info.list_item.as_ref())
122 .map(|item| item.is_ordered)
123 .unwrap_or(false)
124 })
125 })
126 .collect();
127
128 if blocks_with_ordered.is_empty() {
129 return Ok(Vec::new());
130 }
131
132 let mut block_groups = Vec::new();
134 let mut current_group = vec![blocks_with_ordered[0]];
135
136 for i in 1..blocks_with_ordered.len() {
137 let prev_block = blocks_with_ordered[i - 1];
138 let current_block = blocks_with_ordered[i];
139
140 let has_only_unindented_lists =
142 self.has_only_unindented_lists_between(ctx, prev_block.end_line, current_block.start_line);
143
144 let has_heading_between =
147 self.has_heading_between_blocks(ctx, prev_block.end_line, current_block.start_line);
148
149 let between_content_is_code_only =
151 self.is_only_code_between_blocks(ctx, prev_block.end_line, current_block.start_line);
152
153 let should_group = (between_content_is_code_only || has_only_unindented_lists)
157 && self.blocks_are_logically_continuous(ctx, prev_block.end_line, current_block.start_line)
158 && !has_heading_between;
159
160 if should_group {
161 current_group.push(current_block);
163 } else {
164 block_groups.push(current_group);
166 current_group = vec![current_block];
167 }
168 }
169 block_groups.push(current_group);
170
171 for group in block_groups {
173 self.check_ordered_list_group(ctx, &group, &mut warnings);
174 }
175
176 Ok(warnings)
177 }
178
179 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
180 let warnings = self.check(ctx)?;
182
183 if warnings.is_empty() {
184 return Ok(ctx.content.to_string());
186 }
187
188 let mut fixes: Vec<&Fix> = Vec::new();
191 for warning in &warnings {
192 if warning.rule_name.as_deref() == Some("MD029-style") {
194 continue;
195 }
196 if let Some(ref fix) = warning.fix {
197 fixes.push(fix);
198 }
199 }
200 fixes.sort_by_key(|f| f.range.start);
201
202 let mut result = String::new();
203 let mut last_pos = 0;
204 let content_bytes = ctx.content.as_bytes();
205
206 for fix in fixes {
207 if last_pos < fix.range.start {
209 let chunk = &content_bytes[last_pos..fix.range.start];
210 result.push_str(
211 std::str::from_utf8(chunk).map_err(|_| LintError::InvalidInput("Invalid UTF-8".to_string()))?,
212 );
213 }
214 result.push_str(&fix.replacement);
216 last_pos = fix.range.end;
217 }
218
219 if last_pos < content_bytes.len() {
221 let chunk = &content_bytes[last_pos..];
222 result.push_str(
223 std::str::from_utf8(chunk).map_err(|_| LintError::InvalidInput("Invalid UTF-8".to_string()))?,
224 );
225 }
226
227 Ok(result)
228 }
229
230 fn category(&self) -> RuleCategory {
232 RuleCategory::List
233 }
234
235 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
237 ctx.content.is_empty() || !ctx.likely_has_lists()
238 }
239
240 fn as_any(&self) -> &dyn std::any::Any {
241 self
242 }
243
244 fn default_config_section(&self) -> Option<(String, toml::Value)> {
245 let default_config = MD029Config::default();
246 let json_value = serde_json::to_value(&default_config).ok()?;
247 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
248 if let toml::Value::Table(table) = toml_value {
249 if !table.is_empty() {
250 Some((MD029Config::RULE_NAME.to_string(), toml::Value::Table(table)))
251 } else {
252 None
253 }
254 } else {
255 None
256 }
257 }
258
259 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
260 where
261 Self: Sized,
262 {
263 let rule_config = crate::rule_config_serde::load_rule_config::<MD029Config>(config);
264 Box::new(MD029OrderedListPrefix::from_config_struct(rule_config))
265 }
266}
267
268impl MD029OrderedListPrefix {
269 fn check_for_lazy_continuation(
271 &self,
272 ctx: &crate::lint_context::LintContext,
273 list_block: &crate::lint_context::ListBlock,
274 warnings: &mut Vec<LintWarning>,
275 ) {
276 for line_num in list_block.start_line..=list_block.end_line {
278 if let Some(line_info) = ctx.line_info(line_num) {
279 if list_block.item_lines.contains(&line_num) {
281 continue;
282 }
283
284 if line_info.is_blank {
286 continue;
287 }
288
289 if line_info.in_code_block {
291 continue;
292 }
293
294 let trimmed = line_info.content.trim();
296 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
297 continue;
298 }
299
300 if line_info.heading.is_some() {
302 continue;
303 }
304
305 if line_info.indent <= 2 && !line_info.content.trim().is_empty() {
307 let col = line_info.indent + 1;
309
310 warnings.push(LintWarning {
311 rule_name: Some("MD029-style".to_string()),
312 message: "List continuation should be indented (lazy continuation detected)".to_string(),
313 line: line_num,
314 column: col,
315 end_line: line_num,
316 end_column: col,
317 severity: Severity::Warning,
318 fix: Some(Fix {
319 range: line_info.byte_offset..line_info.byte_offset,
320 replacement: " ".to_string(), }),
322 });
323 }
324 }
325 }
326 }
327
328 fn has_only_unindented_lists_between(
332 &self,
333 ctx: &crate::lint_context::LintContext,
334 end_line: usize,
335 start_line: usize,
336 ) -> bool {
337 if end_line >= start_line {
338 return false;
339 }
340
341 let min_continuation_indent =
343 if let Some(prev_block) = ctx.list_blocks.iter().find(|block| block.end_line == end_line) {
344 if let Some(&last_item_line) = prev_block.item_lines.last() {
345 if let Some(line_info) = ctx.line_info(last_item_line) {
346 if let Some(list_item) = &line_info.list_item {
347 if list_item.is_ordered {
348 list_item.marker.len() + 1 } else {
350 2 }
352 } else {
353 3 }
355 } else {
356 3 }
358 } else {
359 3 }
361 } else {
362 3 };
364
365 for line_num in (end_line + 1)..start_line {
366 if let Some(line_info) = ctx.line_info(line_num) {
367 let trimmed = line_info.content.trim();
368
369 if trimmed.is_empty() {
371 continue;
372 }
373
374 if line_info.list_item.is_some() {
376 if line_info.indent >= min_continuation_indent {
378 continue; }
380 return false;
382 }
383
384 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
386 if line_info.indent >= min_continuation_indent {
387 continue; }
389 return false;
391 }
392
393 if line_info.in_code_block {
395 if line_info.indent >= min_continuation_indent {
396 continue; }
398 return false;
400 }
401
402 if line_info.indent >= min_continuation_indent {
404 continue;
405 }
406
407 return false;
409 }
410 }
411
412 true
413 }
414
415 fn blocks_are_logically_continuous(
417 &self,
418 ctx: &crate::lint_context::LintContext,
419 end_line: usize,
420 start_line: usize,
421 ) -> bool {
422 if end_line >= start_line {
423 return false;
424 }
425
426 let min_continuation_indent =
428 if let Some(prev_block) = ctx.list_blocks.iter().find(|block| block.end_line == end_line) {
429 if let Some(&last_item_line) = prev_block.item_lines.last() {
430 if let Some(line_info) = ctx.line_info(last_item_line) {
431 if let Some(list_item) = &line_info.list_item {
432 if list_item.is_ordered {
433 list_item.marker.len() + 1 } else {
435 2 }
437 } else {
438 3 }
440 } else {
441 3 }
443 } else {
444 3 }
446 } else {
447 3 };
449
450 for line_num in (end_line + 1)..start_line {
451 if let Some(line_info) = ctx.line_info(line_num) {
452 if line_info.is_blank {
454 continue;
455 }
456
457 if line_info.heading.is_some() {
459 return false;
460 }
461
462 let trimmed = line_info.content.trim();
463
464 if line_info.list_item.is_some() {
466 if line_info.indent >= min_continuation_indent {
467 continue; }
469 return false;
471 }
472
473 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
475 if line_info.indent >= min_continuation_indent {
476 continue; }
478 return false;
480 }
481
482 if line_info.in_code_block {
484 if line_info.indent >= min_continuation_indent {
485 continue; }
487 return false;
489 }
490
491 if line_info.indent >= min_continuation_indent {
493 continue;
494 }
495
496 if !trimmed.is_empty() {
498 return false;
499 }
500 }
501 }
502
503 true
504 }
505
506 fn is_only_code_between_blocks(
507 &self,
508 ctx: &crate::lint_context::LintContext,
509 end_line: usize,
510 start_line: usize,
511 ) -> bool {
512 if end_line >= start_line {
513 return false;
514 }
515
516 let min_continuation_indent =
518 if let Some(prev_block) = ctx.list_blocks.iter().find(|block| block.end_line == end_line) {
519 if let Some(&last_item_line) = prev_block.item_lines.last() {
521 if let Some(line_info) = ctx.line_info(last_item_line) {
522 if let Some(list_item) = &line_info.list_item {
523 if list_item.is_ordered {
524 list_item.marker.len() + 1 } else {
526 2 }
528 } else {
529 3 }
531 } else {
532 3 }
534 } else {
535 3 }
537 } else {
538 3 };
540
541 for line_num in (end_line + 1)..start_line {
542 if let Some(line_info) = ctx.line_info(line_num) {
543 let trimmed = line_info.content.trim();
544
545 if trimmed.is_empty() {
547 continue;
548 }
549
550 if line_info.in_code_block || trimmed.starts_with("```") || trimmed.starts_with("~~~") {
552 if line_info.in_code_block {
554 let context = crate::utils::code_block_utils::CodeBlockUtils::analyze_code_block_context(
556 &ctx.lines,
557 line_num - 1,
558 min_continuation_indent,
559 );
560
561 if matches!(context, crate::utils::code_block_utils::CodeBlockContext::Standalone) {
563 return false; }
565 }
566 continue; }
568
569 if line_info.heading.is_some() {
571 return false;
572 }
573
574 return false;
576 }
577 }
578
579 true
580 }
581
582 fn has_heading_between_blocks(
584 &self,
585 ctx: &crate::lint_context::LintContext,
586 end_line: usize,
587 start_line: usize,
588 ) -> bool {
589 if end_line >= start_line {
590 return false;
591 }
592
593 for line_num in (end_line + 1)..start_line {
594 if let Some(line_info) = ctx.line_info(line_num)
595 && line_info.heading.is_some()
596 {
597 return true;
598 }
599 }
600
601 false
602 }
603
604 fn find_parent_list_item(
607 &self,
608 ctx: &crate::lint_context::LintContext,
609 ordered_line: usize,
610 ordered_indent: usize,
611 ) -> usize {
612 for line_num in (1..ordered_line).rev() {
614 if let Some(line_info) = ctx.line_info(line_num) {
615 if let Some(list_item) = &line_info.list_item {
616 if list_item.marker_column < ordered_indent {
618 return line_num;
620 }
621 }
622 else if !line_info.is_blank && line_info.indent == 0 {
624 break;
625 }
626 }
627 }
628 0 }
630
631 fn check_ordered_list_group(
633 &self,
634 ctx: &crate::lint_context::LintContext,
635 group: &[&crate::lint_context::ListBlock],
636 warnings: &mut Vec<LintWarning>,
637 ) {
638 let mut all_items = Vec::new();
640
641 for list_block in group {
642 self.check_for_lazy_continuation(ctx, list_block, warnings);
644
645 for &item_line in &list_block.item_lines {
646 if let Some(line_info) = ctx.line_info(item_line)
647 && let Some(list_item) = &line_info.list_item
648 {
649 if !list_item.is_ordered {
651 continue;
652 }
653 all_items.push((item_line, line_info, list_item));
654 }
655 }
656 }
657
658 all_items.sort_by_key(|(line_num, _, _)| *line_num);
660
661 type LevelGroups<'a> = std::collections::HashMap<
664 (usize, usize),
665 Vec<(
666 usize,
667 &'a crate::lint_context::LineInfo,
668 &'a crate::lint_context::ListItemInfo,
669 )>,
670 >;
671 let mut level_groups: LevelGroups = std::collections::HashMap::new();
672
673 for (line_num, line_info, list_item) in all_items {
674 let parent_line = self.find_parent_list_item(ctx, line_num, list_item.marker_column);
676
677 level_groups
679 .entry((list_item.marker_column, parent_line))
680 .or_default()
681 .push((line_num, line_info, list_item));
682 }
683
684 for ((_indent, _parent), mut group) in level_groups {
686 group.sort_by_key(|(line_num, _, _)| *line_num);
688
689 let detected_style = if self.config.style == ListStyle::OneOrOrdered {
691 Some(Self::detect_list_style(&group))
692 } else {
693 None
694 };
695
696 for (idx, (line_num, line_info, list_item)) in group.iter().enumerate() {
698 if let Some(actual_num) = Self::parse_marker_number(&list_item.marker) {
700 let expected_num = self.get_expected_number(idx, detected_style.clone());
701
702 if actual_num != expected_num {
703 let marker_start = line_info.byte_offset + list_item.marker_column;
705 let number_len = if let Some(dot_pos) = list_item.marker.find('.') {
707 dot_pos } else if let Some(paren_pos) = list_item.marker.find(')') {
709 paren_pos } else {
711 list_item.marker.len() };
713
714 warnings.push(LintWarning {
715 rule_name: Some(self.name().to_string()),
716 message: format!(
717 "Ordered list item number {actual_num} does not match style (expected {expected_num})"
718 ),
719 line: *line_num,
720 column: list_item.marker_column + 1,
721 end_line: *line_num,
722 end_column: list_item.marker_column + number_len + 1,
723 severity: Severity::Warning,
724 fix: Some(Fix {
725 range: marker_start..marker_start + number_len,
726 replacement: expected_num.to_string(),
727 }),
728 });
729 }
730 }
731 }
732 }
733 }
734}
735
736#[cfg(test)]
737mod tests {
738 use super::*;
739
740 #[test]
741 fn test_basic_functionality() {
742 let rule = MD029OrderedListPrefix::default();
744
745 let content = "1. First item\n2. Second item\n3. Third item";
747 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
748 let result = rule.check(&ctx).unwrap();
749 assert!(result.is_empty());
750
751 let content = "1. First item\n3. Third item\n5. Fifth item";
753 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
754 let result = rule.check(&ctx).unwrap();
755 assert_eq!(result.len(), 2); let rule = MD029OrderedListPrefix::new(ListStyle::OneOne);
759 let content = "1. First item\n2. Second item\n3. Third item";
760 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
761 let result = rule.check(&ctx).unwrap();
762 assert_eq!(result.len(), 2); let rule = MD029OrderedListPrefix::new(ListStyle::Ordered0);
766 let content = "0. First item\n1. Second item\n2. Third item";
767 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
768 let result = rule.check(&ctx).unwrap();
769 assert!(result.is_empty());
770 }
771
772 #[test]
773 fn test_redundant_computation_fix() {
774 let rule = MD029OrderedListPrefix::default();
779
780 let content = "1. First item\n3. Wrong number\n2. Another wrong number";
782 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
783
784 let result = rule.check(&ctx).unwrap();
786 assert_eq!(result.len(), 2); assert!(result[0].message.contains("3 does not match style (expected 2)"));
790 assert!(result[1].message.contains("2 does not match style (expected 3)"));
791 }
792
793 #[test]
794 fn test_performance_improvement() {
795 let rule = MD029OrderedListPrefix::default();
797
798 let mut content = String::new();
800 for i in 1..=100 {
801 content.push_str(&format!("{}. Item {}\n", i + 1, i)); }
803
804 let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Standard);
805
806 let result = rule.check(&ctx).unwrap();
808 assert_eq!(result.len(), 100); assert!(result[0].message.contains("2 does not match style (expected 1)"));
812 assert!(result[99].message.contains("101 does not match style (expected 100)"));
813 }
814
815 #[test]
816 fn test_one_or_ordered_with_all_ones() {
817 let rule = MD029OrderedListPrefix::new(ListStyle::OneOrOrdered);
819
820 let content = "1. First item\n1. Second item\n1. Third item";
821 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
822 let result = rule.check(&ctx).unwrap();
823 assert!(result.is_empty(), "All ones should be valid in OneOrOrdered mode");
824 }
825
826 #[test]
827 fn test_one_or_ordered_with_sequential() {
828 let rule = MD029OrderedListPrefix::new(ListStyle::OneOrOrdered);
830
831 let content = "1. First item\n2. Second item\n3. Third item";
832 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
833 let result = rule.check(&ctx).unwrap();
834 assert!(
835 result.is_empty(),
836 "Sequential numbering should be valid in OneOrOrdered mode"
837 );
838 }
839
840 #[test]
841 fn test_one_or_ordered_with_mixed_style() {
842 let rule = MD029OrderedListPrefix::new(ListStyle::OneOrOrdered);
844
845 let content = "1. First item\n2. Second item\n1. Third item";
846 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
847 let result = rule.check(&ctx).unwrap();
848 assert_eq!(result.len(), 1, "Mixed style should produce one warning");
849 assert!(result[0].message.contains("1 does not match style (expected 3)"));
850 }
851
852 #[test]
853 fn test_one_or_ordered_separate_lists() {
854 let rule = MD029OrderedListPrefix::new(ListStyle::OneOrOrdered);
856
857 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";
858 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
859 let result = rule.check(&ctx).unwrap();
860 assert!(
861 result.is_empty(),
862 "Separate lists can use different styles in OneOrOrdered mode"
863 );
864 }
865}