1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
5use crate::rule_config_serde::RuleConfig;
6use crate::utils::document_structure::{DocumentStructure, DocumentStructureExtensions};
7use crate::utils::regex_cache::ORDERED_LIST_MARKER_REGEX;
8use toml;
9
10mod md029_config;
11pub use md029_config::{ListStyle, MD029Config};
12
13#[derive(Debug, Clone, Default)]
14pub struct MD029OrderedListPrefix {
15 config: MD029Config,
16}
17
18impl MD029OrderedListPrefix {
19 pub fn new(style: ListStyle) -> Self {
20 Self {
21 config: MD029Config { style },
22 }
23 }
24
25 pub fn from_config_struct(config: MD029Config) -> Self {
26 Self { config }
27 }
28
29 #[inline]
30 fn parse_marker_number(marker: &str) -> Option<usize> {
31 let num_part = if let Some(stripped) = marker.strip_suffix('.') {
33 stripped
34 } else {
35 marker
36 };
37 num_part.parse::<usize>().ok()
38 }
39
40 #[inline]
41 fn get_expected_number(&self, index: usize, detected_style: Option<ListStyle>) -> usize {
42 let style = detected_style.unwrap_or(self.config.style.clone());
43 match style {
44 ListStyle::One | ListStyle::OneOne => 1,
45 ListStyle::Ordered => index + 1,
46 ListStyle::Ordered0 => index,
47 ListStyle::OneOrOrdered => {
48 1
51 }
52 }
53 }
54
55 fn detect_list_style(
57 items: &[(
58 usize,
59 &crate::lint_context::LineInfo,
60 &crate::lint_context::ListItemInfo,
61 )],
62 ) -> ListStyle {
63 if items.len() < 2 {
64 return ListStyle::OneOne;
66 }
67
68 let first_num = Self::parse_marker_number(&items[0].2.marker);
70 let second_num = Self::parse_marker_number(&items[1].2.marker);
71
72 match (first_num, second_num) {
73 (Some(1), Some(1)) => ListStyle::OneOne, (Some(0), Some(1)) => ListStyle::Ordered0, (Some(1), Some(2)) => ListStyle::Ordered, _ => {
77 let all_ones = items
79 .iter()
80 .all(|(_, _, item)| Self::parse_marker_number(&item.marker) == Some(1));
81 if all_ones {
82 ListStyle::OneOne
83 } else {
84 ListStyle::Ordered
85 }
86 }
87 }
88 }
89}
90
91impl Rule for MD029OrderedListPrefix {
92 fn name(&self) -> &'static str {
93 "MD029"
94 }
95
96 fn description(&self) -> &'static str {
97 "Ordered list marker value"
98 }
99
100 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
101 if ctx.content.is_empty() {
103 return Ok(Vec::new());
104 }
105
106 if !ctx.content.contains('.') || !ctx.content.lines().any(|line| ORDERED_LIST_MARKER_REGEX.is_match(line)) {
108 return Ok(Vec::new());
109 }
110
111 let mut warnings = Vec::new();
112
113 let blocks_with_ordered: Vec<_> = ctx
116 .list_blocks
117 .iter()
118 .filter(|block| {
119 block.item_lines.iter().any(|&line| {
121 ctx.line_info(line)
122 .and_then(|info| info.list_item.as_ref())
123 .map(|item| item.is_ordered)
124 .unwrap_or(false)
125 })
126 })
127 .collect();
128
129 if blocks_with_ordered.is_empty() {
130 return Ok(Vec::new());
131 }
132
133 let mut block_groups = Vec::new();
135 let mut current_group = vec![blocks_with_ordered[0]];
136
137 for i in 1..blocks_with_ordered.len() {
138 let prev_block = blocks_with_ordered[i - 1];
139 let current_block = blocks_with_ordered[i];
140
141 let has_only_unindented_lists =
143 self.has_only_unindented_lists_between(ctx, prev_block.end_line, current_block.start_line);
144
145 let has_heading_between =
148 self.has_heading_between_blocks(ctx, prev_block.end_line, current_block.start_line);
149
150 let between_content_is_code_only =
152 self.is_only_code_between_blocks(ctx, prev_block.end_line, current_block.start_line);
153
154 let should_group = (between_content_is_code_only || has_only_unindented_lists)
158 && self.blocks_are_logically_continuous(ctx, prev_block.end_line, current_block.start_line)
159 && !has_heading_between;
160
161 if should_group {
162 current_group.push(current_block);
164 } else {
165 block_groups.push(current_group);
167 current_group = vec![current_block];
168 }
169 }
170 block_groups.push(current_group);
171
172 for group in block_groups {
174 self.check_ordered_list_group(ctx, &group, &mut warnings);
175 }
176
177 Ok(warnings)
178 }
179
180 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
181 let warnings = self.check(ctx)?;
183
184 if warnings.is_empty() {
185 return Ok(ctx.content.to_string());
187 }
188
189 let mut fixes: Vec<&Fix> = Vec::new();
192 for warning in &warnings {
193 if warning.rule_name == Some("MD029-style") {
195 continue;
196 }
197 if let Some(ref fix) = warning.fix {
198 fixes.push(fix);
199 }
200 }
201 fixes.sort_by_key(|f| f.range.start);
202
203 let mut result = String::new();
204 let mut last_pos = 0;
205 let content_bytes = ctx.content.as_bytes();
206
207 for fix in fixes {
208 if last_pos < fix.range.start {
210 let chunk = &content_bytes[last_pos..fix.range.start];
211 result.push_str(
212 std::str::from_utf8(chunk).map_err(|_| LintError::InvalidInput("Invalid UTF-8".to_string()))?,
213 );
214 }
215 result.push_str(&fix.replacement);
217 last_pos = fix.range.end;
218 }
219
220 if last_pos < content_bytes.len() {
222 let chunk = &content_bytes[last_pos..];
223 result.push_str(
224 std::str::from_utf8(chunk).map_err(|_| LintError::InvalidInput("Invalid UTF-8".to_string()))?,
225 );
226 }
227
228 Ok(result)
229 }
230
231 fn check_with_structure(
233 &self,
234 ctx: &crate::lint_context::LintContext,
235 _structure: &crate::utils::document_structure::DocumentStructure,
236 ) -> LintResult {
237 self.check(ctx)
240 }
241
242 fn category(&self) -> RuleCategory {
244 RuleCategory::List
245 }
246
247 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
249 let content = ctx.content;
250 content.is_empty()
251 || !content.contains('1')
252 || (!content.contains("1.") && !content.contains("2.") && !content.contains("0."))
253 }
254
255 fn as_any(&self) -> &dyn std::any::Any {
256 self
257 }
258
259 fn as_maybe_document_structure(&self) -> Option<&dyn crate::rule::MaybeDocumentStructure> {
260 Some(self)
261 }
262
263 fn default_config_section(&self) -> Option<(String, toml::Value)> {
264 let default_config = MD029Config::default();
265 let json_value = serde_json::to_value(&default_config).ok()?;
266 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
267 if let toml::Value::Table(table) = toml_value {
268 if !table.is_empty() {
269 Some((MD029Config::RULE_NAME.to_string(), toml::Value::Table(table)))
270 } else {
271 None
272 }
273 } else {
274 None
275 }
276 }
277
278 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
279 where
280 Self: Sized,
281 {
282 let rule_config = crate::rule_config_serde::load_rule_config::<MD029Config>(config);
283 Box::new(MD029OrderedListPrefix::from_config_struct(rule_config))
284 }
285}
286
287impl DocumentStructureExtensions for MD029OrderedListPrefix {
288 fn has_relevant_elements(
289 &self,
290 ctx: &crate::lint_context::LintContext,
291 _doc_structure: &DocumentStructure,
292 ) -> bool {
293 ctx.list_blocks.iter().any(|block| block.is_ordered)
298 }
299}
300
301impl MD029OrderedListPrefix {
302 fn check_for_lazy_continuation(
304 &self,
305 ctx: &crate::lint_context::LintContext,
306 list_block: &crate::lint_context::ListBlock,
307 warnings: &mut Vec<LintWarning>,
308 ) {
309 for line_num in list_block.start_line..=list_block.end_line {
311 if let Some(line_info) = ctx.line_info(line_num) {
312 if list_block.item_lines.contains(&line_num) {
314 continue;
315 }
316
317 if line_info.is_blank {
319 continue;
320 }
321
322 if line_info.in_code_block {
324 continue;
325 }
326
327 let trimmed = line_info.content.trim();
329 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
330 continue;
331 }
332
333 if line_info.heading.is_some() {
335 continue;
336 }
337
338 if line_info.indent <= 2 && !line_info.content.trim().is_empty() {
340 let col = line_info.indent + 1;
342
343 warnings.push(LintWarning {
344 rule_name: Some("MD029-style"),
345 message: "List continuation should be indented (lazy continuation detected)".to_string(),
346 line: line_num,
347 column: col,
348 end_line: line_num,
349 end_column: col,
350 severity: Severity::Warning,
351 fix: Some(Fix {
352 range: line_info.byte_offset..line_info.byte_offset,
353 replacement: " ".to_string(), }),
355 });
356 }
357 }
358 }
359 }
360
361 fn has_only_unindented_lists_between(
364 &self,
365 ctx: &crate::lint_context::LintContext,
366 end_line: usize,
367 start_line: usize,
368 ) -> bool {
369 if end_line >= start_line {
370 return false;
371 }
372
373 for line_num in (end_line + 1)..start_line {
374 if let Some(line_info) = ctx.line_info(line_num) {
375 let trimmed = line_info.content.trim();
376
377 if trimmed.is_empty() {
379 continue;
380 }
381
382 if line_info.list_item.is_some() && line_info.indent == 0 {
384 continue;
385 }
386
387 return false;
389 }
390 }
391
392 true
393 }
394
395 fn blocks_are_logically_continuous(
397 &self,
398 ctx: &crate::lint_context::LintContext,
399 end_line: usize,
400 start_line: usize,
401 ) -> bool {
402 if end_line >= start_line {
403 return false;
404 }
405
406 for line_num in (end_line + 1)..start_line {
407 if let Some(line_info) = ctx.line_info(line_num) {
408 if line_info.is_blank {
410 continue;
411 }
412
413 if line_info.in_code_block {
415 continue;
416 }
417
418 if line_info.heading.is_some() {
420 return false;
421 }
422
423 let trimmed = line_info.content.trim();
425 if !trimmed.is_empty() && !trimmed.starts_with("```") && !trimmed.starts_with("~~~") {
426 return false;
427 }
428 }
429 }
430
431 true
432 }
433
434 fn is_only_code_between_blocks(
435 &self,
436 ctx: &crate::lint_context::LintContext,
437 end_line: usize,
438 start_line: usize,
439 ) -> bool {
440 if end_line >= start_line {
441 return false;
442 }
443
444 let min_continuation_indent =
446 if let Some(prev_block) = ctx.list_blocks.iter().find(|block| block.end_line == end_line) {
447 if let Some(&last_item_line) = prev_block.item_lines.last() {
449 if let Some(line_info) = ctx.line_info(last_item_line) {
450 if let Some(list_item) = &line_info.list_item {
451 if list_item.is_ordered {
452 list_item.marker.len() + 1 } else {
454 2 }
456 } else {
457 3 }
459 } else {
460 3 }
462 } else {
463 3 }
465 } else {
466 3 };
468
469 for line_num in (end_line + 1)..start_line {
470 if let Some(line_info) = ctx.line_info(line_num) {
471 let trimmed = line_info.content.trim();
472
473 if trimmed.is_empty() {
475 continue;
476 }
477
478 if line_info.in_code_block || trimmed.starts_with("```") || trimmed.starts_with("~~~") {
480 if line_info.in_code_block {
482 let context = crate::utils::code_block_utils::CodeBlockUtils::analyze_code_block_context(
484 &ctx.lines,
485 line_num - 1,
486 min_continuation_indent,
487 );
488
489 if matches!(context, crate::utils::code_block_utils::CodeBlockContext::Standalone) {
491 return false; }
493 }
494 continue; }
496
497 if line_info.heading.is_some() {
499 return false;
500 }
501
502 return false;
504 }
505 }
506
507 true
508 }
509
510 fn has_heading_between_blocks(
512 &self,
513 ctx: &crate::lint_context::LintContext,
514 end_line: usize,
515 start_line: usize,
516 ) -> bool {
517 if end_line >= start_line {
518 return false;
519 }
520
521 for line_num in (end_line + 1)..start_line {
522 if let Some(line_info) = ctx.line_info(line_num)
523 && line_info.heading.is_some()
524 {
525 return true;
526 }
527 }
528
529 false
530 }
531
532 fn find_parent_list_item(
535 &self,
536 ctx: &crate::lint_context::LintContext,
537 ordered_line: usize,
538 ordered_indent: usize,
539 ) -> usize {
540 for line_num in (1..ordered_line).rev() {
542 if let Some(line_info) = ctx.line_info(line_num) {
543 if let Some(list_item) = &line_info.list_item {
544 if list_item.marker_column < ordered_indent {
546 return line_num;
548 }
549 }
550 else if !line_info.is_blank && line_info.indent == 0 {
552 break;
553 }
554 }
555 }
556 0 }
558
559 fn check_ordered_list_group(
561 &self,
562 ctx: &crate::lint_context::LintContext,
563 group: &[&crate::lint_context::ListBlock],
564 warnings: &mut Vec<LintWarning>,
565 ) {
566 let mut all_items = Vec::new();
568
569 for list_block in group {
570 self.check_for_lazy_continuation(ctx, list_block, warnings);
572
573 for &item_line in &list_block.item_lines {
574 if let Some(line_info) = ctx.line_info(item_line)
575 && let Some(list_item) = &line_info.list_item
576 {
577 if !list_item.is_ordered {
579 continue;
580 }
581 all_items.push((item_line, line_info, list_item));
582 }
583 }
584 }
585
586 all_items.sort_by_key(|(line_num, _, _)| *line_num);
588
589 type LevelGroups<'a> = std::collections::HashMap<
592 (usize, usize),
593 Vec<(
594 usize,
595 &'a crate::lint_context::LineInfo,
596 &'a crate::lint_context::ListItemInfo,
597 )>,
598 >;
599 let mut level_groups: LevelGroups = std::collections::HashMap::new();
600
601 for (line_num, line_info, list_item) in all_items {
602 let parent_line = self.find_parent_list_item(ctx, line_num, list_item.marker_column);
604
605 level_groups
607 .entry((list_item.marker_column, parent_line))
608 .or_default()
609 .push((line_num, line_info, list_item));
610 }
611
612 for ((_indent, _parent), mut group) in level_groups {
614 group.sort_by_key(|(line_num, _, _)| *line_num);
616
617 let detected_style = if self.config.style == ListStyle::OneOrOrdered {
619 Some(Self::detect_list_style(&group))
620 } else {
621 None
622 };
623
624 for (idx, (line_num, line_info, list_item)) in group.iter().enumerate() {
626 if let Some(actual_num) = Self::parse_marker_number(&list_item.marker) {
628 let expected_num = self.get_expected_number(idx, detected_style.clone());
629
630 if actual_num != expected_num {
631 let marker_start = line_info.byte_offset + list_item.marker_column;
633 let number_len = if let Some(dot_pos) = list_item.marker.find('.') {
635 dot_pos } else if let Some(paren_pos) = list_item.marker.find(')') {
637 paren_pos } else {
639 list_item.marker.len() };
641
642 warnings.push(LintWarning {
643 rule_name: Some(self.name()),
644 message: format!(
645 "Ordered list item number {actual_num} does not match style (expected {expected_num})"
646 ),
647 line: *line_num,
648 column: list_item.marker_column + 1,
649 end_line: *line_num,
650 end_column: list_item.marker_column + number_len + 1,
651 severity: Severity::Warning,
652 fix: Some(Fix {
653 range: marker_start..marker_start + number_len,
654 replacement: expected_num.to_string(),
655 }),
656 });
657 }
658 }
659 }
660 }
661 }
662}
663
664#[cfg(test)]
665mod tests {
666 use super::*;
667
668 use crate::utils::document_structure::DocumentStructure;
669
670 #[test]
671 fn test_with_document_structure() {
672 let rule = MD029OrderedListPrefix::default();
674
675 let content = "1. First item\n2. Second item\n3. Third item";
677 let structure = DocumentStructure::new(content);
678 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
679 let result = rule.check_with_structure(&ctx, &structure).unwrap();
680 assert!(result.is_empty());
681
682 let content = "1. First item\n3. Third item\n5. Fifth item";
684 let structure = DocumentStructure::new(content);
685 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
686 let result = rule.check_with_structure(&ctx, &structure).unwrap();
687 assert_eq!(result.len(), 2); let rule = MD029OrderedListPrefix::new(ListStyle::OneOne);
691 let content = "1. First item\n2. Second item\n3. Third item";
692 let structure = DocumentStructure::new(content);
693 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
694 let result = rule.check_with_structure(&ctx, &structure).unwrap();
695 assert_eq!(result.len(), 2); let rule = MD029OrderedListPrefix::new(ListStyle::Ordered0);
699 let content = "0. First item\n1. Second item\n2. Third item";
700 let structure = DocumentStructure::new(content);
701 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
702 let result = rule.check_with_structure(&ctx, &structure).unwrap();
703 assert!(result.is_empty());
704 }
705
706 #[test]
707 fn test_redundant_computation_fix() {
708 let rule = MD029OrderedListPrefix::default();
713
714 let content = "1. First item\n3. Wrong number\n2. Another wrong number";
716 let structure = DocumentStructure::new(content);
717 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
718
719 let result = rule.check_with_structure(&ctx, &structure).unwrap();
721 assert_eq!(result.len(), 2); assert!(result[0].message.contains("3 does not match style (expected 2)"));
725 assert!(result[1].message.contains("2 does not match style (expected 3)"));
726 }
727
728 #[test]
729 fn test_performance_improvement() {
730 let rule = MD029OrderedListPrefix::default();
732
733 let mut content = String::new();
735 for i in 1..=100 {
736 content.push_str(&format!("{}. Item {}\n", i + 1, i)); }
738
739 let structure = DocumentStructure::new(&content);
740 let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Standard);
741
742 let result = rule.check_with_structure(&ctx, &structure).unwrap();
744 assert_eq!(result.len(), 100); assert!(result[0].message.contains("2 does not match style (expected 1)"));
748 assert!(result[99].message.contains("101 does not match style (expected 100)"));
749 }
750
751 #[test]
752 fn test_one_or_ordered_with_all_ones() {
753 let rule = MD029OrderedListPrefix::new(ListStyle::OneOrOrdered);
755
756 let content = "1. First item\n1. Second item\n1. Third item";
757 let structure = DocumentStructure::new(content);
758 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
759 let result = rule.check_with_structure(&ctx, &structure).unwrap();
760 assert!(result.is_empty(), "All ones should be valid in OneOrOrdered mode");
761 }
762
763 #[test]
764 fn test_one_or_ordered_with_sequential() {
765 let rule = MD029OrderedListPrefix::new(ListStyle::OneOrOrdered);
767
768 let content = "1. First item\n2. Second item\n3. Third item";
769 let structure = DocumentStructure::new(content);
770 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
771 let result = rule.check_with_structure(&ctx, &structure).unwrap();
772 assert!(
773 result.is_empty(),
774 "Sequential numbering should be valid in OneOrOrdered mode"
775 );
776 }
777
778 #[test]
779 fn test_one_or_ordered_with_mixed_style() {
780 let rule = MD029OrderedListPrefix::new(ListStyle::OneOrOrdered);
782
783 let content = "1. First item\n2. Second item\n1. Third item";
784 let structure = DocumentStructure::new(content);
785 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
786 let result = rule.check_with_structure(&ctx, &structure).unwrap();
787 assert_eq!(result.len(), 1, "Mixed style should produce one warning");
788 assert!(result[0].message.contains("1 does not match style (expected 3)"));
789 }
790
791 #[test]
792 fn test_one_or_ordered_separate_lists() {
793 let rule = MD029OrderedListPrefix::new(ListStyle::OneOrOrdered);
795
796 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";
797 let structure = DocumentStructure::new(content);
798 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
799 let result = rule.check_with_structure(&ctx, &structure).unwrap();
800 assert!(
801 result.is_empty(),
802 "Separate lists can use different styles in OneOrOrdered mode"
803 );
804 }
805}