quickmark_core/rules/
md029.rs

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// MD029-specific configuration types
12#[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-wide state for one_or_ordered mode
48    document_style: Option<OlPrefixStyle>,
49    // Whether the document uses zero-based ordering (0,1,2...) vs one-based (1,2,3...)
50    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    /// Extract the numeric value from an ordered list item prefix
64    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        // Find the list marker within this list item
69        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    /// Check if a list node is an ordered list by examining its first marker.
79    /// This is an optimization based on the assumption that a list is either
80    /// entirely ordered or unordered.
81    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    /// Get style examples for error messages
96    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        // Extract list items and their values with position information
115        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; // No items, nothing to check
128        }
129
130        // Split the continuous list into logical separate lists like markdownlint.
131        // Performance: Collect lines once to avoid re-iterating the whole document content
132        // for each list item pair.
133        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    /// Split tree-sitter's continuous list into logical separate lists like markdownlint
158    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            // Check if this should end the current logical list
174            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                // Split if there's content separation OR (blank lines AND significant numbering gap)
190                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        // Add the final list if it has items
201        if !current_list.is_empty() {
202            logical_lists.push(current_list);
203        }
204
205        logical_lists
206    }
207
208    /// Check for content or blank lines between list items in a single pass.
209    /// Returns a tuple: (has_content_separation, has_blank_lines).
210    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            // Check for separating content
226            if trimmed_line.starts_with('#') // Heading
227                || trimmed_line.starts_with("---") // Horizontal rule
228                || trimmed_line.starts_with("***")
229            {
230                has_content_separation = true;
231                break; // Found separation, no need to check further
232            }
233
234            // Any other non-indented content also separates lists
235            if !line.starts_with(' ') && !line.starts_with('\t') {
236                has_content_separation = true;
237                break; // Found separation
238            }
239        }
240
241        (has_content_separation, has_blank_lines)
242    }
243
244    /// Check if there's a significant numbering gap between two list items
245    /// A gap of more than 1 is considered significant (e.g., 2 -> 100)
246    fn has_significant_numbering_gap(&self, current: u32, next: u32) -> bool {
247        // If next number is not the immediate successor, it's a significant gap
248        next != current + 1
249    }
250
251    /// Check if a list follows a valid ordered pattern (either 1,2,3... or 0,1,2...)
252    fn is_valid_ordered_pattern(&self, list_items_with_values: &[(Node, u32)]) -> bool {
253        if list_items_with_values.is_empty() {
254            return true; // Empty list is vacuously valid
255        }
256
257        let start_value = list_items_with_values[0].1;
258
259        // Valid ordered patterns must start with 0 or 1 (not arbitrary numbers like 5)
260        if start_value > 1 {
261            return false;
262        }
263
264        // Check if all values follow the expected sequence from start_value
265        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        // Track if this is the first multi-item list (style-establishing list)
274        let is_first_multi_item_list =
275            self.document_style.is_none() && list_items_with_values.len() >= 2;
276
277        // For OneOrOrdered mode, establish document-wide style from the first logical list with 2+ items
278        if is_first_multi_item_list {
279            // Determine document style from the first multi-item list
280            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                // Ordered style - also detect if it's zero-based
285                self.document_style = Some(OlPrefixStyle::Ordered);
286                self.is_zero_based = first_value == 0;
287            } else {
288                // One style (1/1/...)
289                self.document_style = Some(OlPrefixStyle::One);
290                self.is_zero_based = false; // One style is never zero-based
291            }
292        }
293
294        // For single-item lists or before style is established, assume ordered style and enforce proper starts
295        let effective_style = self.document_style.unwrap_or(OlPrefixStyle::Ordered);
296
297        // For document-wide style, each logical list should follow the style
298        match effective_style {
299            OlPrefixStyle::One => {
300                // One style: all items should be "1."
301                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                // Ordered style: in one_or_ordered mode, handle first list vs separated lists differently
321                if !list_items_with_values.is_empty() {
322                    let list_start_value = list_items_with_values[0].1;
323
324                    // Special case: single-item lists should follow "one" style (start at 1)
325                    // regardless of document's ordered style
326                    if list_items_with_values.len() == 1 && !is_first_multi_item_list {
327                        // Single item should use "1" regardless of ordered document style
328                        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" // Single items use one style
338                            );
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; // Early return for single items
348                    }
349
350                    // For ordered style, allow both 1-based and 0-based patterns
351                    let expected_start = if is_first_multi_item_list {
352                        // This is the first multi-item list establishing style - allow natural start (0 or 1)
353                        list_start_value
354                    } else {
355                        // For subsequent lists in ordered style, allow valid ordered patterns:
356                        // - 1-based: 1,2,3...
357                        // - 0-based: 0,1,2...
358                        // Check if this list follows a valid ordered pattern
359                        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                        // Special case: if document was established as zero-based,
364                        // separated lists cannot use zero-based patterns (must start at 1)
365                        if is_zero_based_pattern && self.is_zero_based {
366                            1 // Force separated lists to start at 1 in zero-based documents
367                        } else if is_valid_pattern {
368                            list_start_value // Allow the natural start if it's a valid ordered pattern
369                        } else {
370                            1 // Default to 1-based if not a valid pattern
371                        }
372                    };
373
374                    // Check if the first item in this logical list starts with the correct value
375                    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            _ => {} // Other styles not relevant here
399        }
400    }
401
402    fn check_list_with_fixed_style(
403        &mut self,
404        list_items_with_values: &[(Node, u32)],
405        style: OlPrefixStyle,
406    ) {
407        // For fixed styles, each list is independent (original behavior)
408        if list_items_with_values.len() < 2 {
409            return; // Single item lists are always valid
410        }
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!(), // Handled separately
417        };
418
419        // Check each list item against the expected pattern
420        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            // For ordered style, increment expected value (within this list only)
446            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        // Edge case discovered during parity testing:
518        // Single-item lists separated by content should each start at 1
519        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        // Edge case: Separated lists should start fresh, not continue from previous
536        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        // Should detect as ordered style from first list
549        assert!(violations[0].message().contains("Style: 1/2/3"));
550    }
551
552    #[test]
553    fn test_one_or_ordered_document_consistency() {
554        // Edge case: Document with all-ones list first should make ALL lists use ones style
555        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        // Should show 'one' style was detected
568        assert!(violations[0].message().contains("Style: 1/1/1"));
569    }
570
571    #[test]
572    fn test_ordered_first_then_ones_style_violation() {
573        // Edge case: Document with ordered list first should make ones lists violate
574        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        // Edge case: 0,1,2 should be valid zero-based continuous list
591        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        // Edge case: Zero-based document should still have separated lists start at 1
606        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        // The second list should start at 1,2 not 0,1 even though document is zero-based
612        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        // Edge case: Mix of single and multi-item lists in one_or_ordered mode
624        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        // First violation: single list should start at 1, not 5
634        assert!(violations
635            .iter()
636            .any(|v| v.message().contains("Expected: 1; Actual: 5")));
637        // Later violation: after 'ones' style established, ordered list should violate
638        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        // Edge case: Large numbers in separated lists should still start at 1
647        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")); // Generic ordered pattern
660    }
661
662    #[test]
663    fn test_nested_lists_follow_document_style() {
664        // Edge case: Nested lists must follow the document-wide style in one_or_ordered mode
665        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        // Once 'one' style is established by parent, nested and separate lists must follow it
671        assert_eq!(
672            2,
673            violations.len(),
674            "Nested and separate lists must follow document-wide style"
675        );
676        // Both violations should be expecting 1 but getting 2 (violating 'one' style)
677        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        // Edge case: Ensure proper distinction between blank line separation and content separation
688        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        // Blank lines alone shouldn't separate, but content should
694        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 one_or_ordered style (default)
720    #[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        // In "one_or_ordered" mode with pattern 1/1/3, this should be detected as "one" style
763        // So the violation should be that item 3 has "3" instead of "1"
764        assert!(violations[0].message().contains("Expected: 1; Actual: 3"));
765    }
766
767    // Test "one" style
768    #[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 "ordered" style
804    #[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 "zero" style
844    #[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 separate lists with document-wide consistency
875    #[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        // First list establishes ordered style, second list should increment properly within itself
882        assert_eq!(1, violations.len(), "Second list should increment properly");
883        assert!(violations[0].message().contains("Expected: 2; Actual: 3"));
884    }
885
886    // Test zero-padded numbers (should work)
887    #[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 edge case: large numbers
901    #[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 nested lists - each nesting level is independent
911    #[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 mixed ordered and unordered
921    #[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        // This depends on how tree-sitter parses this - if it creates separate lists, it should pass
928        // We'll adjust based on actual tree-sitter behavior
929    }
930
931    // Test document-wide style consistency (markdownlint behavior)
932    #[test]
933    fn test_document_wide_style_consistency() {
934        // First list establishes "ordered" style (1/2/3)
935        // Subsequent lists in ordered style should start with 1 and increment
936        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        // Should detect violations where second list doesn't start with 1 in ordered style
942        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        // First list establishes "zero-based ordered" style (0/1/2)
955        // Subsequent lists should follow ordered style, can start with 0 or 1
956        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        // First list establishes "one" style (1/1/1)
973        // Subsequent lists should also use all 1s
974        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        // When using fixed styles (not one_or_ordered), each list should be independent
990        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        // In "ordered" mode, the second list should violate because it's not incrementing
996        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    // Tests for 100% markdownlint parity
1005    #[test]
1006    fn test_markdownlint_parity_blank_separated_lists() {
1007        // Markdownlint treats lists separated by blank lines as separate lists
1008        // Each should start with 1 in ordered style
1009        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        // Should have 2 violations: 100 (expected 1) and 101 (expected 2)
1015        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        // Zero-padded numbers in separate list should violate
1027        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        // Should have 3 violations for the zero-padded list not starting with 1
1033        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        // Single items in separate lists should be checked against document style
1046        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        // Should have 1 violation - single item doesn't match established ordered style
1052        assert_eq!(
1053            1,
1054            violations.len(),
1055            "Single item should follow document style"
1056        );
1057        // Note: markdownlint shows "Style: 1/1/1" for single items, suggesting different logic
1058        assert!(violations[0].message().contains("Expected: 1; Actual: 42"));
1059    }
1060
1061    #[test]
1062    fn test_markdownlint_parity_mixed_with_headings() {
1063        // Lists separated by headings are definitely separate
1064        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        // Should have 2 violations for section 2 not starting with 1
1070        // Section 3 with 0/1 should be OK as it establishes ordered pattern
1071        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        // This tests the core difference: what markdownlint considers one list vs separate lists
1083        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        // Markdownlint would treat the second part as a separate list
1089        // So no violations expected (both lists follow ordered pattern correctly)
1090        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        // Lists separated by paragraph text should be separate
1100        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        // Should have 2 violations for not starting with 1
1106        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        // Test that 1/1/1 pattern is detected as "one" style and enforced
1118        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        // Should detect "one" style from first list, second list should violate for using 2s
1124        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}