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 let content = ctx.content;
238 content.is_empty()
239 || !content.contains('1')
240 || (!content.contains("1.") && !content.contains("2.") && !content.contains("0."))
241 }
242
243 fn as_any(&self) -> &dyn std::any::Any {
244 self
245 }
246
247 fn default_config_section(&self) -> Option<(String, toml::Value)> {
248 let default_config = MD029Config::default();
249 let json_value = serde_json::to_value(&default_config).ok()?;
250 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
251 if let toml::Value::Table(table) = toml_value {
252 if !table.is_empty() {
253 Some((MD029Config::RULE_NAME.to_string(), toml::Value::Table(table)))
254 } else {
255 None
256 }
257 } else {
258 None
259 }
260 }
261
262 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
263 where
264 Self: Sized,
265 {
266 let rule_config = crate::rule_config_serde::load_rule_config::<MD029Config>(config);
267 Box::new(MD029OrderedListPrefix::from_config_struct(rule_config))
268 }
269}
270
271impl MD029OrderedListPrefix {
272 fn check_for_lazy_continuation(
274 &self,
275 ctx: &crate::lint_context::LintContext,
276 list_block: &crate::lint_context::ListBlock,
277 warnings: &mut Vec<LintWarning>,
278 ) {
279 for line_num in list_block.start_line..=list_block.end_line {
281 if let Some(line_info) = ctx.line_info(line_num) {
282 if list_block.item_lines.contains(&line_num) {
284 continue;
285 }
286
287 if line_info.is_blank {
289 continue;
290 }
291
292 if line_info.in_code_block {
294 continue;
295 }
296
297 let trimmed = line_info.content.trim();
299 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
300 continue;
301 }
302
303 if line_info.heading.is_some() {
305 continue;
306 }
307
308 if line_info.indent <= 2 && !line_info.content.trim().is_empty() {
310 let col = line_info.indent + 1;
312
313 warnings.push(LintWarning {
314 rule_name: Some("MD029-style"),
315 message: "List continuation should be indented (lazy continuation detected)".to_string(),
316 line: line_num,
317 column: col,
318 end_line: line_num,
319 end_column: col,
320 severity: Severity::Warning,
321 fix: Some(Fix {
322 range: line_info.byte_offset..line_info.byte_offset,
323 replacement: " ".to_string(), }),
325 });
326 }
327 }
328 }
329 }
330
331 fn has_only_unindented_lists_between(
334 &self,
335 ctx: &crate::lint_context::LintContext,
336 end_line: usize,
337 start_line: usize,
338 ) -> bool {
339 if end_line >= start_line {
340 return false;
341 }
342
343 for line_num in (end_line + 1)..start_line {
344 if let Some(line_info) = ctx.line_info(line_num) {
345 let trimmed = line_info.content.trim();
346
347 if trimmed.is_empty() {
349 continue;
350 }
351
352 if line_info.list_item.is_some() && line_info.indent == 0 {
354 continue;
355 }
356
357 return false;
359 }
360 }
361
362 true
363 }
364
365 fn blocks_are_logically_continuous(
367 &self,
368 ctx: &crate::lint_context::LintContext,
369 end_line: usize,
370 start_line: usize,
371 ) -> bool {
372 if end_line >= start_line {
373 return false;
374 }
375
376 for line_num in (end_line + 1)..start_line {
377 if let Some(line_info) = ctx.line_info(line_num) {
378 if line_info.is_blank {
380 continue;
381 }
382
383 if line_info.in_code_block {
385 continue;
386 }
387
388 if line_info.heading.is_some() {
390 return false;
391 }
392
393 let trimmed = line_info.content.trim();
395 if !trimmed.is_empty() && !trimmed.starts_with("```") && !trimmed.starts_with("~~~") {
396 return false;
397 }
398 }
399 }
400
401 true
402 }
403
404 fn is_only_code_between_blocks(
405 &self,
406 ctx: &crate::lint_context::LintContext,
407 end_line: usize,
408 start_line: usize,
409 ) -> bool {
410 if end_line >= start_line {
411 return false;
412 }
413
414 let min_continuation_indent =
416 if let Some(prev_block) = ctx.list_blocks.iter().find(|block| block.end_line == end_line) {
417 if let Some(&last_item_line) = prev_block.item_lines.last() {
419 if let Some(line_info) = ctx.line_info(last_item_line) {
420 if let Some(list_item) = &line_info.list_item {
421 if list_item.is_ordered {
422 list_item.marker.len() + 1 } else {
424 2 }
426 } else {
427 3 }
429 } else {
430 3 }
432 } else {
433 3 }
435 } else {
436 3 };
438
439 for line_num in (end_line + 1)..start_line {
440 if let Some(line_info) = ctx.line_info(line_num) {
441 let trimmed = line_info.content.trim();
442
443 if trimmed.is_empty() {
445 continue;
446 }
447
448 if line_info.in_code_block || trimmed.starts_with("```") || trimmed.starts_with("~~~") {
450 if line_info.in_code_block {
452 let context = crate::utils::code_block_utils::CodeBlockUtils::analyze_code_block_context(
454 &ctx.lines,
455 line_num - 1,
456 min_continuation_indent,
457 );
458
459 if matches!(context, crate::utils::code_block_utils::CodeBlockContext::Standalone) {
461 return false; }
463 }
464 continue; }
466
467 if line_info.heading.is_some() {
469 return false;
470 }
471
472 return false;
474 }
475 }
476
477 true
478 }
479
480 fn has_heading_between_blocks(
482 &self,
483 ctx: &crate::lint_context::LintContext,
484 end_line: usize,
485 start_line: usize,
486 ) -> bool {
487 if end_line >= start_line {
488 return false;
489 }
490
491 for line_num in (end_line + 1)..start_line {
492 if let Some(line_info) = ctx.line_info(line_num)
493 && line_info.heading.is_some()
494 {
495 return true;
496 }
497 }
498
499 false
500 }
501
502 fn find_parent_list_item(
505 &self,
506 ctx: &crate::lint_context::LintContext,
507 ordered_line: usize,
508 ordered_indent: usize,
509 ) -> usize {
510 for line_num in (1..ordered_line).rev() {
512 if let Some(line_info) = ctx.line_info(line_num) {
513 if let Some(list_item) = &line_info.list_item {
514 if list_item.marker_column < ordered_indent {
516 return line_num;
518 }
519 }
520 else if !line_info.is_blank && line_info.indent == 0 {
522 break;
523 }
524 }
525 }
526 0 }
528
529 fn check_ordered_list_group(
531 &self,
532 ctx: &crate::lint_context::LintContext,
533 group: &[&crate::lint_context::ListBlock],
534 warnings: &mut Vec<LintWarning>,
535 ) {
536 let mut all_items = Vec::new();
538
539 for list_block in group {
540 self.check_for_lazy_continuation(ctx, list_block, warnings);
542
543 for &item_line in &list_block.item_lines {
544 if let Some(line_info) = ctx.line_info(item_line)
545 && let Some(list_item) = &line_info.list_item
546 {
547 if !list_item.is_ordered {
549 continue;
550 }
551 all_items.push((item_line, line_info, list_item));
552 }
553 }
554 }
555
556 all_items.sort_by_key(|(line_num, _, _)| *line_num);
558
559 type LevelGroups<'a> = std::collections::HashMap<
562 (usize, usize),
563 Vec<(
564 usize,
565 &'a crate::lint_context::LineInfo,
566 &'a crate::lint_context::ListItemInfo,
567 )>,
568 >;
569 let mut level_groups: LevelGroups = std::collections::HashMap::new();
570
571 for (line_num, line_info, list_item) in all_items {
572 let parent_line = self.find_parent_list_item(ctx, line_num, list_item.marker_column);
574
575 level_groups
577 .entry((list_item.marker_column, parent_line))
578 .or_default()
579 .push((line_num, line_info, list_item));
580 }
581
582 for ((_indent, _parent), mut group) in level_groups {
584 group.sort_by_key(|(line_num, _, _)| *line_num);
586
587 let detected_style = if self.config.style == ListStyle::OneOrOrdered {
589 Some(Self::detect_list_style(&group))
590 } else {
591 None
592 };
593
594 for (idx, (line_num, line_info, list_item)) in group.iter().enumerate() {
596 if let Some(actual_num) = Self::parse_marker_number(&list_item.marker) {
598 let expected_num = self.get_expected_number(idx, detected_style.clone());
599
600 if actual_num != expected_num {
601 let marker_start = line_info.byte_offset + list_item.marker_column;
603 let number_len = if let Some(dot_pos) = list_item.marker.find('.') {
605 dot_pos } else if let Some(paren_pos) = list_item.marker.find(')') {
607 paren_pos } else {
609 list_item.marker.len() };
611
612 warnings.push(LintWarning {
613 rule_name: Some(self.name()),
614 message: format!(
615 "Ordered list item number {actual_num} does not match style (expected {expected_num})"
616 ),
617 line: *line_num,
618 column: list_item.marker_column + 1,
619 end_line: *line_num,
620 end_column: list_item.marker_column + number_len + 1,
621 severity: Severity::Warning,
622 fix: Some(Fix {
623 range: marker_start..marker_start + number_len,
624 replacement: expected_num.to_string(),
625 }),
626 });
627 }
628 }
629 }
630 }
631 }
632}
633
634#[cfg(test)]
635mod tests {
636 use super::*;
637
638 #[test]
639 fn test_basic_functionality() {
640 let rule = MD029OrderedListPrefix::default();
642
643 let content = "1. First item\n2. Second item\n3. Third item";
645 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
646 let result = rule.check(&ctx).unwrap();
647 assert!(result.is_empty());
648
649 let content = "1. First item\n3. Third item\n5. Fifth item";
651 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
652 let result = rule.check(&ctx).unwrap();
653 assert_eq!(result.len(), 2); let rule = MD029OrderedListPrefix::new(ListStyle::OneOne);
657 let content = "1. First item\n2. Second item\n3. Third item";
658 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
659 let result = rule.check(&ctx).unwrap();
660 assert_eq!(result.len(), 2); let rule = MD029OrderedListPrefix::new(ListStyle::Ordered0);
664 let content = "0. First item\n1. Second item\n2. Third item";
665 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
666 let result = rule.check(&ctx).unwrap();
667 assert!(result.is_empty());
668 }
669
670 #[test]
671 fn test_redundant_computation_fix() {
672 let rule = MD029OrderedListPrefix::default();
677
678 let content = "1. First item\n3. Wrong number\n2. Another wrong number";
680 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
681
682 let result = rule.check(&ctx).unwrap();
684 assert_eq!(result.len(), 2); assert!(result[0].message.contains("3 does not match style (expected 2)"));
688 assert!(result[1].message.contains("2 does not match style (expected 3)"));
689 }
690
691 #[test]
692 fn test_performance_improvement() {
693 let rule = MD029OrderedListPrefix::default();
695
696 let mut content = String::new();
698 for i in 1..=100 {
699 content.push_str(&format!("{}. Item {}\n", i + 1, i)); }
701
702 let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Standard);
703
704 let result = rule.check(&ctx).unwrap();
706 assert_eq!(result.len(), 100); assert!(result[0].message.contains("2 does not match style (expected 1)"));
710 assert!(result[99].message.contains("101 does not match style (expected 100)"));
711 }
712
713 #[test]
714 fn test_one_or_ordered_with_all_ones() {
715 let rule = MD029OrderedListPrefix::new(ListStyle::OneOrOrdered);
717
718 let content = "1. First item\n1. Second item\n1. Third item";
719 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
720 let result = rule.check(&ctx).unwrap();
721 assert!(result.is_empty(), "All ones should be valid in OneOrOrdered mode");
722 }
723
724 #[test]
725 fn test_one_or_ordered_with_sequential() {
726 let rule = MD029OrderedListPrefix::new(ListStyle::OneOrOrdered);
728
729 let content = "1. First item\n2. Second item\n3. Third item";
730 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
731 let result = rule.check(&ctx).unwrap();
732 assert!(
733 result.is_empty(),
734 "Sequential numbering should be valid in OneOrOrdered mode"
735 );
736 }
737
738 #[test]
739 fn test_one_or_ordered_with_mixed_style() {
740 let rule = MD029OrderedListPrefix::new(ListStyle::OneOrOrdered);
742
743 let content = "1. First item\n2. Second item\n1. Third item";
744 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
745 let result = rule.check(&ctx).unwrap();
746 assert_eq!(result.len(), 1, "Mixed style should produce one warning");
747 assert!(result[0].message.contains("1 does not match style (expected 3)"));
748 }
749
750 #[test]
751 fn test_one_or_ordered_separate_lists() {
752 let rule = MD029OrderedListPrefix::new(ListStyle::OneOrOrdered);
754
755 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";
756 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
757 let result = rule.check(&ctx).unwrap();
758 assert!(
759 result.is_empty(),
760 "Separate lists can use different styles in OneOrOrdered mode"
761 );
762 }
763}