1use serde::Deserialize;
2use std::rc::Rc;
3
4use tree_sitter::Node;
5
6use crate::{
7 linter::{range_from_tree_sitter, RuleViolation},
8 rules::{Context, Rule, RuleLinter, RuleType},
9};
10
11#[derive(Debug, PartialEq, Clone, Copy, Deserialize)]
13pub enum OlPrefixStyle {
14 #[serde(rename = "one")]
15 One,
16 #[serde(rename = "ordered")]
17 Ordered,
18 #[serde(rename = "one_or_ordered")]
19 OneOrOrdered,
20 #[serde(rename = "zero")]
21 Zero,
22}
23
24impl Default for OlPrefixStyle {
25 fn default() -> Self {
26 Self::OneOrOrdered
27 }
28}
29
30#[derive(Debug, PartialEq, Clone, Deserialize)]
31pub struct MD029OlPrefixTable {
32 #[serde(default)]
33 pub style: OlPrefixStyle,
34}
35
36impl Default for MD029OlPrefixTable {
37 fn default() -> Self {
38 Self {
39 style: OlPrefixStyle::OneOrOrdered,
40 }
41 }
42}
43
44pub(crate) struct MD029Linter {
45 context: Rc<Context>,
46 violations: Vec<RuleViolation>,
47 document_style: Option<OlPrefixStyle>,
49 is_zero_based: bool,
51}
52
53impl MD029Linter {
54 pub fn new(context: Rc<Context>) -> Self {
55 Self {
56 context,
57 violations: Vec::new(),
58 document_style: None,
59 is_zero_based: false,
60 }
61 }
62
63 fn extract_list_item_value(&self, list_item_node: &Node) -> Option<u32> {
65 let content = self.context.document_content.borrow();
66 let source_bytes = content.as_bytes();
67
68 let mut cursor = list_item_node.walk();
70 let result = list_item_node
71 .children(&mut cursor)
72 .find(|child| child.kind() == "list_marker_dot")
73 .and_then(|marker_node| marker_node.utf8_text(source_bytes).ok())
74 .and_then(|text| text.trim().trim_end_matches('.').parse::<u32>().ok());
75 result
76 }
77
78 fn is_ordered_list(&self, list_node: &Node) -> bool {
82 let mut cursor = list_node.walk();
83 if let Some(first_item) = list_node
84 .children(&mut cursor)
85 .find(|c| c.kind() == "list_item")
86 {
87 let mut item_cursor = first_item.walk();
88 return first_item
89 .children(&mut item_cursor)
90 .any(|child| child.kind() == "list_marker_dot");
91 }
92 false
93 }
94
95 fn get_style_example(&self, style: &OlPrefixStyle) -> &'static str {
97 match style {
98 OlPrefixStyle::One => "1/1/1",
99 OlPrefixStyle::Ordered => {
100 if self.is_zero_based {
101 "0/1/2"
102 } else {
103 "1/2/3"
104 }
105 }
106 OlPrefixStyle::OneOrOrdered => "1/1/1 or 1/2/3",
107 OlPrefixStyle::Zero => "0/0/0",
108 }
109 }
110
111 fn check_list(&mut self, node: &Node) {
112 let configured_style = self.context.config.linters.settings.ol_prefix.style;
113
114 let mut list_items_with_values = Vec::new();
116 let mut cursor = node.walk();
117
118 for list_item in node.children(&mut cursor) {
119 if list_item.kind() == "list_item" {
120 if let Some(value) = self.extract_list_item_value(&list_item) {
121 list_items_with_values.push((list_item, value));
122 }
123 }
124 }
125
126 if list_items_with_values.is_empty() {
127 return; }
129
130 let logical_lists = {
134 let content = self.context.document_content.borrow();
135 let lines: Vec<&str> = content.lines().collect();
136 self.split_into_logical_lists(&list_items_with_values, &lines)
137 };
138
139 for logical_list in logical_lists {
140 match configured_style {
141 OlPrefixStyle::OneOrOrdered => {
142 self.check_list_with_document_style(&logical_list);
143 }
144 OlPrefixStyle::One => {
145 self.check_list_with_fixed_style(&logical_list, OlPrefixStyle::One);
146 }
147 OlPrefixStyle::Zero => {
148 self.check_list_with_fixed_style(&logical_list, OlPrefixStyle::Zero);
149 }
150 OlPrefixStyle::Ordered => {
151 self.check_list_with_fixed_style(&logical_list, OlPrefixStyle::Ordered);
152 }
153 }
154 }
155 }
156
157 fn split_into_logical_lists<'a>(
159 &self,
160 list_items_with_values: &[(Node<'a>, u32)],
161 lines: &[&str],
162 ) -> Vec<Vec<(Node<'a>, u32)>> {
163 if list_items_with_values.len() <= 1 {
164 return vec![list_items_with_values.to_vec()];
165 }
166
167 let mut logical_lists = Vec::new();
168 let mut current_list = Vec::new();
169
170 for (i, (list_item, value)) in list_items_with_values.iter().enumerate() {
171 current_list.push((*list_item, *value));
172
173 let should_split = if i < list_items_with_values.len() - 1 {
175 let current_start_line = list_item.start_position().row;
176 let next_start_line = list_items_with_values[i + 1].0.start_position().row;
177
178 let lines_between = if (current_start_line + 1) < next_start_line {
179 &lines[(current_start_line + 1)..next_start_line]
180 } else {
181 &[]
182 };
183
184 let (has_content_separation, has_blank_lines) =
185 self.analyze_lines_between(lines_between.iter().copied());
186 let has_numbering_gap =
187 self.has_significant_numbering_gap(*value, list_items_with_values[i + 1].1);
188
189 has_content_separation || (has_blank_lines && has_numbering_gap)
191 } else {
192 false
193 };
194
195 if should_split {
196 logical_lists.push(std::mem::take(&mut current_list));
197 }
198 }
199
200 if !current_list.is_empty() {
202 logical_lists.push(current_list);
203 }
204
205 logical_lists
206 }
207
208 fn analyze_lines_between<'b, I>(&self, lines: I) -> (bool, bool)
211 where
212 I: Iterator<Item = &'b str>,
213 {
214 let mut has_blank_lines = false;
215 let mut has_content_separation = false;
216
217 for line in lines {
218 let trimmed_line = line.trim();
219
220 if trimmed_line.is_empty() {
221 has_blank_lines = true;
222 continue;
223 }
224
225 if trimmed_line.starts_with('#') || trimmed_line.starts_with("---") || trimmed_line.starts_with("***")
229 {
230 has_content_separation = true;
231 break; }
233
234 if !line.starts_with(' ') && !line.starts_with('\t') {
236 has_content_separation = true;
237 break; }
239 }
240
241 (has_content_separation, has_blank_lines)
242 }
243
244 fn has_significant_numbering_gap(&self, current: u32, next: u32) -> bool {
247 next != current + 1
249 }
250
251 fn is_valid_ordered_pattern(&self, list_items_with_values: &[(Node, u32)]) -> bool {
253 if list_items_with_values.is_empty() {
254 return true; }
256
257 let start_value = list_items_with_values[0].1;
258
259 if start_value > 1 {
261 return false;
262 }
263
264 let expected_sequence = (0..list_items_with_values.len()).map(|i| start_value + i as u32);
266 list_items_with_values
267 .iter()
268 .map(|(_, value)| *value)
269 .eq(expected_sequence)
270 }
271
272 fn check_list_with_document_style(&mut self, list_items_with_values: &[(Node, u32)]) {
273 let is_first_multi_item_list =
275 self.document_style.is_none() && list_items_with_values.len() >= 2;
276
277 if is_first_multi_item_list {
279 let first_value = list_items_with_values[0].1;
281 let second_value = list_items_with_values[1].1;
282
283 if second_value != 1 || first_value == 0 {
284 self.document_style = Some(OlPrefixStyle::Ordered);
286 self.is_zero_based = first_value == 0;
287 } else {
288 self.document_style = Some(OlPrefixStyle::One);
290 self.is_zero_based = false; }
292 }
293
294 let effective_style = self.document_style.unwrap_or(OlPrefixStyle::Ordered);
296
297 match effective_style {
299 OlPrefixStyle::One => {
300 for (list_item, actual_value) in list_items_with_values {
302 if actual_value != &1 {
303 let message = format!(
304 "{} [Expected: 1; Actual: {}; Style: {}]",
305 MD029.description,
306 actual_value,
307 self.get_style_example(&effective_style)
308 );
309
310 self.violations.push(RuleViolation::new(
311 &MD029,
312 message,
313 self.context.file_path.clone(),
314 range_from_tree_sitter(&list_item.range()),
315 ));
316 }
317 }
318 }
319 OlPrefixStyle::Ordered => {
320 if !list_items_with_values.is_empty() {
322 let list_start_value = list_items_with_values[0].1;
323
324 if list_items_with_values.len() == 1 && !is_first_multi_item_list {
327 let expected_value = 1;
329 let actual_value = list_items_with_values[0].1;
330
331 if actual_value != expected_value {
332 let message = format!(
333 "{} [Expected: {}; Actual: {}; Style: {}]",
334 MD029.description,
335 expected_value,
336 actual_value,
337 "1/1/1" );
339
340 self.violations.push(RuleViolation::new(
341 &MD029,
342 message,
343 self.context.file_path.clone(),
344 range_from_tree_sitter(&list_items_with_values[0].0.range()),
345 ));
346 }
347 return; }
349
350 let expected_start = if is_first_multi_item_list {
352 list_start_value
354 } else {
355 let is_valid_pattern =
360 self.is_valid_ordered_pattern(list_items_with_values);
361 let is_zero_based_pattern = list_start_value == 0 && is_valid_pattern;
362
363 if is_zero_based_pattern && self.is_zero_based {
366 1 } else if is_valid_pattern {
368 list_start_value } else {
370 1 }
372 };
373
374 let mut expected_value = expected_start;
376
377 for (list_item, actual_value) in list_items_with_values {
378 if actual_value != &expected_value {
379 let message = format!(
380 "{} [Expected: {}; Actual: {}; Style: {}]",
381 MD029.description,
382 expected_value,
383 actual_value,
384 self.get_style_example(&effective_style)
385 );
386
387 self.violations.push(RuleViolation::new(
388 &MD029,
389 message,
390 self.context.file_path.clone(),
391 range_from_tree_sitter(&list_item.range()),
392 ));
393 }
394 expected_value += 1;
395 }
396 }
397 }
398 _ => {} }
400 }
401
402 fn check_list_with_fixed_style(
403 &mut self,
404 list_items_with_values: &[(Node, u32)],
405 style: OlPrefixStyle,
406 ) {
407 if list_items_with_values.len() < 2 {
409 return; }
411
412 let (effective_style, mut expected_value) = match style {
413 OlPrefixStyle::One => (OlPrefixStyle::One, 1),
414 OlPrefixStyle::Zero => (OlPrefixStyle::Zero, 0),
415 OlPrefixStyle::Ordered => (OlPrefixStyle::Ordered, list_items_with_values[0].1),
416 OlPrefixStyle::OneOrOrdered => unreachable!(), };
418
419 for (list_item, actual_value) in list_items_with_values {
421 let should_report = match effective_style {
422 OlPrefixStyle::One => actual_value != &1,
423 OlPrefixStyle::Zero => actual_value != &0,
424 OlPrefixStyle::Ordered => actual_value != &expected_value,
425 OlPrefixStyle::OneOrOrdered => unreachable!(),
426 };
427
428 if should_report {
429 let message = format!(
430 "{} [Expected: {}; Actual: {}; Style: {}]",
431 MD029.description,
432 expected_value,
433 actual_value,
434 self.get_style_example(&effective_style)
435 );
436
437 self.violations.push(RuleViolation::new(
438 &MD029,
439 message,
440 self.context.file_path.clone(),
441 range_from_tree_sitter(&list_item.range()),
442 ));
443 }
444
445 if matches!(effective_style, OlPrefixStyle::Ordered) {
447 expected_value += 1;
448 }
449 }
450 }
451}
452
453impl RuleLinter for MD029Linter {
454 fn feed(&mut self, node: &Node) {
455 if node.kind() == "list" && self.is_ordered_list(node) {
456 self.check_list(node);
457 }
458 }
459
460 fn finalize(&mut self) -> Vec<RuleViolation> {
461 std::mem::take(&mut self.violations)
462 }
463}
464
465pub const MD029: Rule = Rule {
466 id: "MD029",
467 alias: "ol-prefix",
468 tags: &["ol"],
469 description: "Ordered list item prefix",
470 rule_type: RuleType::Document,
471 required_nodes: &["list"],
472 new_linter: |context| Box::new(MD029Linter::new(context)),
473};
474
475#[cfg(test)]
476mod test {
477 use std::path::PathBuf;
478
479 use crate::config::{
480 LintersSettingsTable, LintersTable, MD029OlPrefixTable, OlPrefixStyle, QuickmarkConfig,
481 RuleSeverity,
482 };
483 use crate::linter::MultiRuleLinter;
484 use crate::test_utils::test_helpers::test_config_with_rules;
485 use std::collections::HashMap;
486
487 fn test_config() -> crate::config::QuickmarkConfig {
488 test_config_with_rules(vec![("ol-prefix", RuleSeverity::Error)])
489 }
490
491 fn test_config_style(style: OlPrefixStyle) -> crate::config::QuickmarkConfig {
492 let severity: HashMap<String, RuleSeverity> =
493 vec![("ol-prefix".to_string(), RuleSeverity::Error)]
494 .into_iter()
495 .collect();
496
497 QuickmarkConfig::new(LintersTable {
498 severity,
499 settings: LintersSettingsTable {
500 ol_prefix: MD029OlPrefixTable { style },
501 ..Default::default()
502 },
503 })
504 }
505
506 #[test]
507 fn test_empty_document() {
508 let input = "";
509 let config = test_config();
510 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
511 let violations = linter.analyze();
512 assert_eq!(0, violations.len());
513 }
514
515 #[test]
516 fn test_single_item_separated_lists_start_at_one() {
517 let input = "# Test\n\n1. Single item\n\ntext\n\n2. This should be 1\n\ntext\n\n3. This should also be 1\n";
520 let config = test_config_style(OlPrefixStyle::OneOrOrdered);
521 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
522 let violations = linter.analyze();
523
524 assert_eq!(
525 2,
526 violations.len(),
527 "Single-item separated lists should start at 1"
528 );
529 assert!(violations[0].message().contains("Expected: 1; Actual: 2"));
530 assert!(violations[1].message().contains("Expected: 1; Actual: 3"));
531 }
532
533 #[test]
534 fn test_separated_lists_proper_numbering() {
535 let input = "# First List\n\n1. First\n2. Second\n3. Third\n\n# Second List\n\n4. Should be 1\n5. Should be 2\n";
537 let config = test_config_style(OlPrefixStyle::OneOrOrdered);
538 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
539 let violations = linter.analyze();
540
541 assert_eq!(
542 2,
543 violations.len(),
544 "Separated lists should start fresh at 1"
545 );
546 assert!(violations[0].message().contains("Expected: 1; Actual: 4"));
547 assert!(violations[1].message().contains("Expected: 2; Actual: 5"));
548 assert!(violations[0].message().contains("Style: 1/2/3"));
550 }
551
552 #[test]
553 fn test_one_or_ordered_document_consistency() {
554 let input = "# First (sets style)\n\n1. One\n1. One\n1. One\n\n# Second (must follow)\n\n1. Should pass\n2. Should violate\n3. Should violate\n";
556 let config = test_config_style(OlPrefixStyle::OneOrOrdered);
557 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
558 let violations = linter.analyze();
559
560 assert_eq!(
561 2,
562 violations.len(),
563 "Once 'one' style is established, all lists must follow it"
564 );
565 assert!(violations[0].message().contains("Expected: 1; Actual: 2"));
566 assert!(violations[1].message().contains("Expected: 1; Actual: 3"));
567 assert!(violations[0].message().contains("Style: 1/1/1"));
569 }
570
571 #[test]
572 fn test_ordered_first_then_ones_style_violation() {
573 let input = "# First (sets ordered style)\n\n1. First\n2. Second\n3. Third\n\n# Second (violates)\n\n1. Should violate\n1. Should violate\n";
575 let config = test_config_style(OlPrefixStyle::OneOrOrdered);
576 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
577 let violations = linter.analyze();
578
579 assert_eq!(
580 1,
581 violations.len(),
582 "Ones style should violate when ordered style was established"
583 );
584 assert!(violations[0].message().contains("Expected: 2; Actual: 1"));
585 assert!(violations[0].message().contains("Style: 1/2/3"));
586 }
587
588 #[test]
589 fn test_zero_based_continuous_list_valid() {
590 let input = "# Test\n\n0. Zero start\n1. One\n2. Two\n";
592 let config = test_config_style(OlPrefixStyle::OneOrOrdered);
593 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
594 let violations = linter.analyze();
595
596 assert_eq!(
597 0,
598 violations.len(),
599 "Zero-based continuous list should be valid"
600 );
601 }
602
603 #[test]
604 fn test_zero_based_document_separated_lists() {
605 let input = "# First (zero-based)\n\n0. Zero\n1. One\n2. Two\n\n# Second (should start at 1)\n\n0. Should violate\n1. Should violate\n";
607 let config = test_config_style(OlPrefixStyle::OneOrOrdered);
608 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
609 let violations = linter.analyze();
610
611 assert_eq!(
613 2,
614 violations.len(),
615 "Zero-based documents should have separated lists start at 1"
616 );
617 assert!(violations[0].message().contains("Expected: 1; Actual: 0"));
618 assert!(violations[1].message().contains("Expected: 2; Actual: 1"));
619 }
620
621 #[test]
622 fn test_mixed_single_and_multi_item_lists() {
623 let input = "# Mix test\n\n5. Single wrong start\n\ntext\n\n1. Multi start\n1. Multi second\n1. Multi third\n\ntext\n\n1. Single correct\n\ntext\n\n1. Multi after\n2. Should violate (ones style established)\n";
625 let config = test_config_style(OlPrefixStyle::OneOrOrdered);
626 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
627 let violations = linter.analyze();
628
629 assert!(
630 violations.len() >= 2,
631 "Should catch single list wrong start and style violations"
632 );
633 assert!(violations
635 .iter()
636 .any(|v| v.message().contains("Expected: 1; Actual: 5")));
637 assert!(violations
639 .iter()
640 .any(|v| v.message().contains("Expected: 1; Actual: 2")
641 && v.message().contains("Style: 1/1/1")));
642 }
643
644 #[test]
645 fn test_large_numbers_separated_lists() {
646 let input = "# First\n\n98. Large start\n99. Large next\n100. Large third\n\n# Second\n\n200. Should be 1\n201. Should be 2\n";
648 let config = test_config_style(OlPrefixStyle::OneOrOrdered);
649 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
650 let violations = linter.analyze();
651
652 assert_eq!(
653 2,
654 violations.len(),
655 "Large numbered separated lists should start at 1"
656 );
657 assert!(violations[0].message().contains("Expected: 1; Actual: 200"));
658 assert!(violations[1].message().contains("Expected: 2; Actual: 201"));
659 assert!(violations[0].message().contains("Style: 1/2/3")); }
661
662 #[test]
663 fn test_nested_lists_follow_document_style() {
664 let input = "# Test\n\n1. Parent one\n1. Parent one\n 1. Nested ordered\n 2. Nested ordered (violates 'one' style)\n1. Parent one\n\n# Separate\n\n1. Should not violate\n2. Should violate (violates 'one' style)\n";
666 let config = test_config_style(OlPrefixStyle::OneOrOrdered);
667 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
668 let violations = linter.analyze();
669
670 assert_eq!(
672 2,
673 violations.len(),
674 "Nested and separate lists must follow document-wide style"
675 );
676 assert!(violations
678 .iter()
679 .any(|v| v.message().contains("Expected: 1; Actual: 2")));
680 assert!(violations
681 .iter()
682 .all(|v| v.message().contains("Style: 1/1/1")));
683 }
684
685 #[test]
686 fn test_empty_lines_vs_content_separation() {
687 let input = "# Test\n\n1. First list\n2. Second item\n\n\n3. After blank lines - should continue\n\nActual content\n\n1. New list - should start at 1\n2. Second in new list\n";
689 let config = test_config_style(OlPrefixStyle::OneOrOrdered);
690 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
691 let violations = linter.analyze();
692
693 assert_eq!(
695 0,
696 violations.len(),
697 "Blank lines alone shouldn't separate lists, content should create new list"
698 );
699 }
700
701 #[test]
702 fn test_single_item_list() {
703 let input = "1. Single item\n";
704 let config = test_config();
705 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
706 let violations = linter.analyze();
707 assert_eq!(0, violations.len(), "Single item lists should not violate");
708 }
709
710 #[test]
711 fn test_no_ordered_lists() {
712 let input = "* Unordered item\n- Another item\n+ Plus item\n";
713 let config = test_config();
714 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
715 let violations = linter.analyze();
716 assert_eq!(0, violations.len(), "Should not check unordered lists");
717 }
718
719 #[test]
721 fn test_one_or_ordered_detects_one_style() {
722 let input = "1. Item one\n1. Item two\n1. Item three\n";
723 let config = test_config_style(OlPrefixStyle::OneOrOrdered);
724 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
725 let violations = linter.analyze();
726 assert_eq!(0, violations.len(), "Should detect and allow 'one' style");
727 }
728
729 #[test]
730 fn test_one_or_ordered_detects_ordered_style() {
731 let input = "1. Item one\n2. Item two\n3. Item three\n";
732 let config = test_config_style(OlPrefixStyle::OneOrOrdered);
733 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
734 let violations = linter.analyze();
735 assert_eq!(
736 0,
737 violations.len(),
738 "Should detect and allow 'ordered' style"
739 );
740 }
741
742 #[test]
743 fn test_one_or_ordered_detects_zero_based() {
744 let input = "0. Item zero\n1. Item one\n2. Item two\n";
745 let config = test_config_style(OlPrefixStyle::OneOrOrdered);
746 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
747 let violations = linter.analyze();
748 assert_eq!(
749 0,
750 violations.len(),
751 "Should detect and allow zero-based ordered style"
752 );
753 }
754
755 #[test]
756 fn test_one_or_ordered_violates_mixed_style() {
757 let input = "1. Item one\n1. Item two\n3. Item three\n";
758 let config = test_config_style(OlPrefixStyle::OneOrOrdered);
759 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
760 let violations = linter.analyze();
761 assert_eq!(1, violations.len(), "Should violate inconsistent numbering");
762 assert!(violations[0].message().contains("Expected: 1; Actual: 3"));
765 }
766
767 #[test]
769 fn test_one_style_passes() {
770 let input = "1. Item one\n1. Item two\n1. Item three\n";
771 let config = test_config_style(OlPrefixStyle::One);
772 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
773 let violations = linter.analyze();
774 assert_eq!(0, violations.len(), "All '1.' should pass");
775 }
776
777 #[test]
778 fn test_one_style_violates_ordered() {
779 let input = "1. Item one\n2. Item two\n3. Item three\n";
780 let config = test_config_style(OlPrefixStyle::One);
781 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
782 let violations = linter.analyze();
783 assert_eq!(2, violations.len(), "Should violate items 2 and 3");
784 assert!(violations[0].message().contains("Expected: 1; Actual: 2"));
785 assert!(violations[1].message().contains("Expected: 1; Actual: 3"));
786 }
787
788 #[test]
789 fn test_one_style_violates_zero_start() {
790 let input = "0. Item zero\n1. Item one\n2. Item two\n";
791 let config = test_config_style(OlPrefixStyle::One);
792 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
793 let violations = linter.analyze();
794 assert_eq!(
795 2,
796 violations.len(),
797 "Should violate items with 0 and 2, but not 1"
798 );
799 assert!(violations[0].message().contains("Expected: 1; Actual: 0"));
800 assert!(violations[1].message().contains("Expected: 1; Actual: 2"));
801 }
802
803 #[test]
805 fn test_ordered_style_passes_one_based() {
806 let input = "1. Item one\n2. Item two\n3. Item three\n";
807 let config = test_config_style(OlPrefixStyle::Ordered);
808 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
809 let violations = linter.analyze();
810 assert_eq!(0, violations.len(), "Incrementing 1/2/3 should pass");
811 }
812
813 #[test]
814 fn test_ordered_style_passes_zero_based() {
815 let input = "0. Item zero\n1. Item one\n2. Item two\n";
816 let config = test_config_style(OlPrefixStyle::Ordered);
817 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
818 let violations = linter.analyze();
819 assert_eq!(0, violations.len(), "Incrementing 0/1/2 should pass");
820 }
821
822 #[test]
823 fn test_ordered_style_violates_all_ones() {
824 let input = "1. Item one\n1. Item two\n1. Item three\n";
825 let config = test_config_style(OlPrefixStyle::Ordered);
826 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
827 let violations = linter.analyze();
828 assert_eq!(2, violations.len(), "Should violate items 2 and 3");
829 assert!(violations[0].message().contains("Expected: 2; Actual: 1"));
830 assert!(violations[1].message().contains("Expected: 3; Actual: 1"));
831 }
832
833 #[test]
834 fn test_ordered_style_violates_skip() {
835 let input = "1. Item one\n2. Item two\n4. Item four\n";
836 let config = test_config_style(OlPrefixStyle::Ordered);
837 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
838 let violations = linter.analyze();
839 assert_eq!(1, violations.len(), "Should violate skipped number");
840 assert!(violations[0].message().contains("Expected: 3; Actual: 4"));
841 }
842
843 #[test]
845 fn test_zero_style_passes() {
846 let input = "0. Item zero\n0. Item zero\n0. Item zero\n";
847 let config = test_config_style(OlPrefixStyle::Zero);
848 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
849 let violations = linter.analyze();
850 assert_eq!(0, violations.len(), "All '0.' should pass");
851 }
852
853 #[test]
854 fn test_zero_style_violates_ones() {
855 let input = "1. Item one\n1. Item two\n1. Item three\n";
856 let config = test_config_style(OlPrefixStyle::Zero);
857 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
858 let violations = linter.analyze();
859 assert_eq!(3, violations.len(), "Should violate all items");
860 assert!(violations[0].message().contains("Expected: 0; Actual: 1"));
861 }
862
863 #[test]
864 fn test_zero_style_violates_ordered() {
865 let input = "0. Item zero\n1. Item one\n2. Item two\n";
866 let config = test_config_style(OlPrefixStyle::Zero);
867 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
868 let violations = linter.analyze();
869 assert_eq!(2, violations.len(), "Should violate incrementing items");
870 assert!(violations[0].message().contains("Expected: 0; Actual: 1"));
871 assert!(violations[1].message().contains("Expected: 0; Actual: 2"));
872 }
873
874 #[test]
876 fn test_separate_lists_document_consistency() {
877 let input = "1. First list item\n2. Second list item\n\nSome text\n\n1. New list item\n3. Should violate - expected 2\n";
878 let config = test_config_style(OlPrefixStyle::OneOrOrdered);
879 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
880 let violations = linter.analyze();
881 assert_eq!(1, violations.len(), "Second list should increment properly");
883 assert!(violations[0].message().contains("Expected: 2; Actual: 3"));
884 }
885
886 #[test]
888 fn test_zero_padded_ordered() {
889 let input = "08. Item eight\n09. Item nine\n10. Item ten\n";
890 let config = test_config_style(OlPrefixStyle::Ordered);
891 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
892 let violations = linter.analyze();
893 assert_eq!(
894 0,
895 violations.len(),
896 "Zero-padded ordered numbers should work"
897 );
898 }
899
900 #[test]
902 fn test_large_numbers() {
903 let input = "100. Item hundred\n101. Item hundred-one\n102. Item hundred-two\n";
904 let config = test_config_style(OlPrefixStyle::Ordered);
905 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
906 let violations = linter.analyze();
907 assert_eq!(0, violations.len(), "Large numbers should work");
908 }
909
910 #[test]
912 fn test_nested_lists() {
913 let input = "1. Outer item\n 1. Inner item\n 2. Inner item\n2. Outer item\n";
914 let config = test_config_style(OlPrefixStyle::Ordered);
915 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
916 let violations = linter.analyze();
917 assert_eq!(0, violations.len(), "Nested lists should be independent");
918 }
919
920 #[test]
922 fn test_mixed_list_types() {
923 let input = "1. Ordered item\n* Unordered item\n2. Another ordered\n";
924 let config = test_config_style(OlPrefixStyle::Ordered);
925 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
926 let _violations = linter.analyze();
927 }
930
931 #[test]
933 fn test_document_wide_style_consistency() {
934 let input = "# First section\n\n1. First item\n2. Second item\n3. Third item\n\n# Second section\n\n100. Should violate - expected 1\n102. Should violate - expected 2\n103. Should violate - expected 3\n";
937 let config = test_config_style(OlPrefixStyle::OneOrOrdered);
938 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
939 let violations = linter.analyze();
940
941 assert_eq!(
943 3,
944 violations.len(),
945 "Should have 3 violations for wrong start in ordered style"
946 );
947 assert!(violations[0].message().contains("Expected: 1; Actual: 100"));
948 assert!(violations[1].message().contains("Expected: 2; Actual: 102"));
949 assert!(violations[2].message().contains("Expected: 3; Actual: 103"));
950 }
951
952 #[test]
953 fn test_document_wide_zero_based_style() {
954 let input = "# First section\n\n0. First item\n1. Second item\n2. Third item\n\n# Second section\n\n5. Should violate - expected 1\n5. Should violate - expected 2\n";
957 let config = test_config_style(OlPrefixStyle::OneOrOrdered);
958 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
959 let violations = linter.analyze();
960
961 assert_eq!(
962 2,
963 violations.len(),
964 "Should have 2 violations for wrong start in ordered style"
965 );
966 assert!(violations[0].message().contains("Expected: 1; Actual: 5"));
967 assert!(violations[1].message().contains("Expected: 2; Actual: 5"));
968 }
969
970 #[test]
971 fn test_document_wide_one_style() {
972 let input = "# First section\n\n1. First item\n1. Second item\n1. Third item\n\n# Second section\n\n1. Should pass\n2. Should violate - expected 1\n";
975 let config = test_config_style(OlPrefixStyle::OneOrOrdered);
976 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
977 let violations = linter.analyze();
978
979 assert_eq!(
980 1,
981 violations.len(),
982 "Should have 1 violation for not following 'one' style"
983 );
984 assert!(violations[0].message().contains("Expected: 1; Actual: 2"));
985 }
986
987 #[test]
988 fn test_fixed_style_modes_ignore_document_consistency() {
989 let input = "# First section\n\n1. First item\n2. Second item\n\n# Second section\n\n1. Different style OK\n1. In ordered mode\n";
991 let config = test_config_style(OlPrefixStyle::Ordered);
992 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
993 let violations = linter.analyze();
994
995 assert_eq!(
997 1,
998 violations.len(),
999 "Should have 1 violation in second list for not incrementing"
1000 );
1001 assert!(violations[0].message().contains("Expected: 2; Actual: 1"));
1002 }
1003
1004 #[test]
1006 fn test_markdownlint_parity_blank_separated_lists() {
1007 let input = "1. First list\n2. Second item\n\n100. Second list should violate\n101. Should also violate\n";
1010 let config = test_config_style(OlPrefixStyle::OneOrOrdered);
1011 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
1012 let violations = linter.analyze();
1013
1014 assert_eq!(
1016 2,
1017 violations.len(),
1018 "Should treat blank-separated lists as separate"
1019 );
1020 assert!(violations[0].message().contains("Expected: 1; Actual: 100"));
1021 assert!(violations[1].message().contains("Expected: 2; Actual: 101"));
1022 }
1023
1024 #[test]
1025 fn test_markdownlint_parity_zero_padded_separate() {
1026 let input = "1. First\n2. Second\n\n08. Zero-padded start\n09. Next\n10. Third\n";
1028 let config = test_config_style(OlPrefixStyle::OneOrOrdered);
1029 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
1030 let violations = linter.analyze();
1031
1032 assert_eq!(
1034 3,
1035 violations.len(),
1036 "Zero-padded separate list should violate"
1037 );
1038 assert!(violations[0].message().contains("Expected: 1; Actual: 8"));
1039 assert!(violations[1].message().contains("Expected: 2; Actual: 9"));
1040 assert!(violations[2].message().contains("Expected: 3; Actual: 10"));
1041 }
1042
1043 #[test]
1044 fn test_markdownlint_parity_single_item_style_detection() {
1045 let input = "1. First\n2. Second\n\n42. Single item should violate\n";
1047 let config = test_config_style(OlPrefixStyle::OneOrOrdered);
1048 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
1049 let violations = linter.analyze();
1050
1051 assert_eq!(
1053 1,
1054 violations.len(),
1055 "Single item should follow document style"
1056 );
1057 assert!(violations[0].message().contains("Expected: 1; Actual: 42"));
1059 }
1060
1061 #[test]
1062 fn test_markdownlint_parity_mixed_with_headings() {
1063 let input = "# Section 1\n\n1. First\n2. Second\n\n## Section 2\n\n5. Should violate\n6. Also violate\n\n### Section 3\n\n0. Zero start\n1. Should pass\n";
1065 let config = test_config_style(OlPrefixStyle::OneOrOrdered);
1066 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
1067 let violations = linter.analyze();
1068
1069 assert_eq!(
1072 2,
1073 violations.len(),
1074 "Lists in different sections should be separate"
1075 );
1076 assert!(violations[0].message().contains("Expected: 1; Actual: 5"));
1077 assert!(violations[1].message().contains("Expected: 2; Actual: 6"));
1078 }
1079
1080 #[test]
1081 fn test_markdownlint_parity_continuous_vs_separate() {
1082 let input = "1. Item one\n2. Item two\n3. Item three\n\n1. Should this be separate?\n2. Or continuous?\n";
1084 let config = test_config_style(OlPrefixStyle::OneOrOrdered);
1085 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
1086 let violations = linter.analyze();
1087
1088 assert_eq!(
1091 0,
1092 violations.len(),
1093 "Lists with proper ordered pattern should not violate"
1094 );
1095 }
1096
1097 #[test]
1098 fn test_markdownlint_parity_text_separation() {
1099 let input = "1. First list\n2. Second item\n\nSome paragraph text here.\n\n5. Different start\n6. Should violate\n";
1101 let config = test_config_style(OlPrefixStyle::OneOrOrdered);
1102 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
1103 let violations = linter.analyze();
1104
1105 assert_eq!(
1107 2,
1108 violations.len(),
1109 "Text-separated lists should be independent"
1110 );
1111 assert!(violations[0].message().contains("Expected: 1; Actual: 5"));
1112 assert!(violations[1].message().contains("Expected: 2; Actual: 6"));
1113 }
1114
1115 #[test]
1116 fn test_markdownlint_parity_one_style_detection() {
1117 let input = "1. All ones\n1. Pattern\n1. Here\n\n2. Should violate\n2. Different pattern\n";
1119 let config = test_config_style(OlPrefixStyle::OneOrOrdered);
1120 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
1121 let violations = linter.analyze();
1122
1123 assert_eq!(2, violations.len(), "Should enforce one style globally");
1125 assert!(violations[0].message().contains("Expected: 1; Actual: 2"));
1126 assert!(violations[1].message().contains("Expected: 1; Actual: 2"));
1127 }
1128}