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 == 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"),
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(
331 &self,
332 ctx: &crate::lint_context::LintContext,
333 end_line: usize,
334 start_line: usize,
335 ) -> bool {
336 if end_line >= start_line {
337 return false;
338 }
339
340 for line_num in (end_line + 1)..start_line {
341 if let Some(line_info) = ctx.line_info(line_num) {
342 let trimmed = line_info.content.trim();
343
344 if trimmed.is_empty() {
346 continue;
347 }
348
349 if line_info.list_item.is_some() && line_info.indent == 0 {
351 continue;
352 }
353
354 return false;
356 }
357 }
358
359 true
360 }
361
362 fn blocks_are_logically_continuous(
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 if line_info.is_blank {
377 continue;
378 }
379
380 if line_info.in_code_block {
382 continue;
383 }
384
385 if line_info.heading.is_some() {
387 return false;
388 }
389
390 let trimmed = line_info.content.trim();
392 if !trimmed.is_empty() && !trimmed.starts_with("```") && !trimmed.starts_with("~~~") {
393 return false;
394 }
395 }
396 }
397
398 true
399 }
400
401 fn is_only_code_between_blocks(
402 &self,
403 ctx: &crate::lint_context::LintContext,
404 end_line: usize,
405 start_line: usize,
406 ) -> bool {
407 if end_line >= start_line {
408 return false;
409 }
410
411 let min_continuation_indent =
413 if let Some(prev_block) = ctx.list_blocks.iter().find(|block| block.end_line == end_line) {
414 if let Some(&last_item_line) = prev_block.item_lines.last() {
416 if let Some(line_info) = ctx.line_info(last_item_line) {
417 if let Some(list_item) = &line_info.list_item {
418 if list_item.is_ordered {
419 list_item.marker.len() + 1 } else {
421 2 }
423 } else {
424 3 }
426 } else {
427 3 }
429 } else {
430 3 }
432 } else {
433 3 };
435
436 for line_num in (end_line + 1)..start_line {
437 if let Some(line_info) = ctx.line_info(line_num) {
438 let trimmed = line_info.content.trim();
439
440 if trimmed.is_empty() {
442 continue;
443 }
444
445 if line_info.in_code_block || trimmed.starts_with("```") || trimmed.starts_with("~~~") {
447 if line_info.in_code_block {
449 let context = crate::utils::code_block_utils::CodeBlockUtils::analyze_code_block_context(
451 &ctx.lines,
452 line_num - 1,
453 min_continuation_indent,
454 );
455
456 if matches!(context, crate::utils::code_block_utils::CodeBlockContext::Standalone) {
458 return false; }
460 }
461 continue; }
463
464 if line_info.heading.is_some() {
466 return false;
467 }
468
469 return false;
471 }
472 }
473
474 true
475 }
476
477 fn has_heading_between_blocks(
479 &self,
480 ctx: &crate::lint_context::LintContext,
481 end_line: usize,
482 start_line: usize,
483 ) -> bool {
484 if end_line >= start_line {
485 return false;
486 }
487
488 for line_num in (end_line + 1)..start_line {
489 if let Some(line_info) = ctx.line_info(line_num)
490 && line_info.heading.is_some()
491 {
492 return true;
493 }
494 }
495
496 false
497 }
498
499 fn find_parent_list_item(
502 &self,
503 ctx: &crate::lint_context::LintContext,
504 ordered_line: usize,
505 ordered_indent: usize,
506 ) -> usize {
507 for line_num in (1..ordered_line).rev() {
509 if let Some(line_info) = ctx.line_info(line_num) {
510 if let Some(list_item) = &line_info.list_item {
511 if list_item.marker_column < ordered_indent {
513 return line_num;
515 }
516 }
517 else if !line_info.is_blank && line_info.indent == 0 {
519 break;
520 }
521 }
522 }
523 0 }
525
526 fn check_ordered_list_group(
528 &self,
529 ctx: &crate::lint_context::LintContext,
530 group: &[&crate::lint_context::ListBlock],
531 warnings: &mut Vec<LintWarning>,
532 ) {
533 let mut all_items = Vec::new();
535
536 for list_block in group {
537 self.check_for_lazy_continuation(ctx, list_block, warnings);
539
540 for &item_line in &list_block.item_lines {
541 if let Some(line_info) = ctx.line_info(item_line)
542 && let Some(list_item) = &line_info.list_item
543 {
544 if !list_item.is_ordered {
546 continue;
547 }
548 all_items.push((item_line, line_info, list_item));
549 }
550 }
551 }
552
553 all_items.sort_by_key(|(line_num, _, _)| *line_num);
555
556 type LevelGroups<'a> = std::collections::HashMap<
559 (usize, usize),
560 Vec<(
561 usize,
562 &'a crate::lint_context::LineInfo,
563 &'a crate::lint_context::ListItemInfo,
564 )>,
565 >;
566 let mut level_groups: LevelGroups = std::collections::HashMap::new();
567
568 for (line_num, line_info, list_item) in all_items {
569 let parent_line = self.find_parent_list_item(ctx, line_num, list_item.marker_column);
571
572 level_groups
574 .entry((list_item.marker_column, parent_line))
575 .or_default()
576 .push((line_num, line_info, list_item));
577 }
578
579 for ((_indent, _parent), mut group) in level_groups {
581 group.sort_by_key(|(line_num, _, _)| *line_num);
583
584 let detected_style = if self.config.style == ListStyle::OneOrOrdered {
586 Some(Self::detect_list_style(&group))
587 } else {
588 None
589 };
590
591 for (idx, (line_num, line_info, list_item)) in group.iter().enumerate() {
593 if let Some(actual_num) = Self::parse_marker_number(&list_item.marker) {
595 let expected_num = self.get_expected_number(idx, detected_style.clone());
596
597 if actual_num != expected_num {
598 let marker_start = line_info.byte_offset + list_item.marker_column;
600 let number_len = if let Some(dot_pos) = list_item.marker.find('.') {
602 dot_pos } else if let Some(paren_pos) = list_item.marker.find(')') {
604 paren_pos } else {
606 list_item.marker.len() };
608
609 warnings.push(LintWarning {
610 rule_name: Some(self.name()),
611 message: format!(
612 "Ordered list item number {actual_num} does not match style (expected {expected_num})"
613 ),
614 line: *line_num,
615 column: list_item.marker_column + 1,
616 end_line: *line_num,
617 end_column: list_item.marker_column + number_len + 1,
618 severity: Severity::Warning,
619 fix: Some(Fix {
620 range: marker_start..marker_start + number_len,
621 replacement: expected_num.to_string(),
622 }),
623 });
624 }
625 }
626 }
627 }
628 }
629}
630
631#[cfg(test)]
632mod tests {
633 use super::*;
634
635 #[test]
636 fn test_basic_functionality() {
637 let rule = MD029OrderedListPrefix::default();
639
640 let content = "1. First item\n2. Second item\n3. Third item";
642 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
643 let result = rule.check(&ctx).unwrap();
644 assert!(result.is_empty());
645
646 let content = "1. First item\n3. Third item\n5. Fifth item";
648 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
649 let result = rule.check(&ctx).unwrap();
650 assert_eq!(result.len(), 2); let rule = MD029OrderedListPrefix::new(ListStyle::OneOne);
654 let content = "1. First item\n2. Second item\n3. Third item";
655 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
656 let result = rule.check(&ctx).unwrap();
657 assert_eq!(result.len(), 2); let rule = MD029OrderedListPrefix::new(ListStyle::Ordered0);
661 let content = "0. First item\n1. Second item\n2. Third item";
662 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
663 let result = rule.check(&ctx).unwrap();
664 assert!(result.is_empty());
665 }
666
667 #[test]
668 fn test_redundant_computation_fix() {
669 let rule = MD029OrderedListPrefix::default();
674
675 let content = "1. First item\n3. Wrong number\n2. Another wrong number";
677 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
678
679 let result = rule.check(&ctx).unwrap();
681 assert_eq!(result.len(), 2); assert!(result[0].message.contains("3 does not match style (expected 2)"));
685 assert!(result[1].message.contains("2 does not match style (expected 3)"));
686 }
687
688 #[test]
689 fn test_performance_improvement() {
690 let rule = MD029OrderedListPrefix::default();
692
693 let mut content = String::new();
695 for i in 1..=100 {
696 content.push_str(&format!("{}. Item {}\n", i + 1, i)); }
698
699 let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Standard);
700
701 let result = rule.check(&ctx).unwrap();
703 assert_eq!(result.len(), 100); assert!(result[0].message.contains("2 does not match style (expected 1)"));
707 assert!(result[99].message.contains("101 does not match style (expected 100)"));
708 }
709
710 #[test]
711 fn test_one_or_ordered_with_all_ones() {
712 let rule = MD029OrderedListPrefix::new(ListStyle::OneOrOrdered);
714
715 let content = "1. First item\n1. Second item\n1. Third item";
716 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
717 let result = rule.check(&ctx).unwrap();
718 assert!(result.is_empty(), "All ones should be valid in OneOrOrdered mode");
719 }
720
721 #[test]
722 fn test_one_or_ordered_with_sequential() {
723 let rule = MD029OrderedListPrefix::new(ListStyle::OneOrOrdered);
725
726 let content = "1. First item\n2. Second item\n3. Third item";
727 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
728 let result = rule.check(&ctx).unwrap();
729 assert!(
730 result.is_empty(),
731 "Sequential numbering should be valid in OneOrOrdered mode"
732 );
733 }
734
735 #[test]
736 fn test_one_or_ordered_with_mixed_style() {
737 let rule = MD029OrderedListPrefix::new(ListStyle::OneOrOrdered);
739
740 let content = "1. First item\n2. Second item\n1. Third item";
741 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
742 let result = rule.check(&ctx).unwrap();
743 assert_eq!(result.len(), 1, "Mixed style should produce one warning");
744 assert!(result[0].message.contains("1 does not match style (expected 3)"));
745 }
746
747 #[test]
748 fn test_one_or_ordered_separate_lists() {
749 let rule = MD029OrderedListPrefix::new(ListStyle::OneOrOrdered);
751
752 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";
753 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
754 let result = rule.check(&ctx).unwrap();
755 assert!(
756 result.is_empty(),
757 "Separate lists can use different styles in OneOrOrdered mode"
758 );
759 }
760}