mdbook_lint_core/rules/standard/
md029.rs

1//! MD029: Ordered list item prefix consistency
2//!
3//! This rule checks for consistent numbering style in ordered lists.
4//! Lists can use either sequential numbering (1, 2, 3) or all ones (1, 1, 1).
5
6use crate::error::Result;
7use crate::rule::{AstRule, RuleCategory, RuleMetadata};
8use crate::{
9    Document,
10    violation::{Severity, Violation},
11};
12use comrak::nodes::{AstNode, ListType, NodeValue};
13
14/// Configuration for ordered list prefix style
15#[derive(Debug, Clone, PartialEq)]
16pub enum OrderedListStyle {
17    /// Sequential numbering: 1, 2, 3, 4...
18    Sequential,
19    /// All ones: 1, 1, 1, 1...
20    AllOnes,
21    /// Use whatever style is found first in the document
22    Consistent,
23}
24
25/// Rule to check for ordered list item prefix consistency
26pub struct MD029 {
27    style: OrderedListStyle,
28}
29
30impl MD029 {
31    /// Create a new MD029 rule with default settings (consistent style)
32    pub fn new() -> Self {
33        Self {
34            style: OrderedListStyle::Consistent,
35        }
36    }
37
38    /// Create a new MD029 rule with a specific style
39    #[allow(dead_code)]
40    pub fn with_style(style: OrderedListStyle) -> Self {
41        Self { style }
42    }
43}
44
45impl Default for MD029 {
46    fn default() -> Self {
47        Self::new()
48    }
49}
50
51impl AstRule for MD029 {
52    fn id(&self) -> &'static str {
53        "MD029"
54    }
55
56    fn name(&self) -> &'static str {
57        "ol-prefix"
58    }
59
60    fn description(&self) -> &'static str {
61        "Ordered list item prefix consistency"
62    }
63
64    fn metadata(&self) -> RuleMetadata {
65        RuleMetadata::stable(RuleCategory::Formatting).introduced_in("mdbook-lint v0.1.0")
66    }
67
68    fn check_ast<'a>(&self, document: &Document, ast: &'a AstNode<'a>) -> Result<Vec<Violation>> {
69        let mut violations = Vec::new();
70        let mut detected_style: Option<OrderedListStyle> = None;
71
72        // Find all ordered list nodes
73        for node in ast.descendants() {
74            if let NodeValue::List(list_data) = &node.data.borrow().value
75                && let ListType::Ordered = list_data.list_type
76            {
77                violations.extend(self.check_ordered_list(document, node, &mut detected_style)?);
78            }
79        }
80
81        Ok(violations)
82    }
83}
84
85impl MD029 {
86    /// Check an individual ordered list for prefix consistency
87    fn check_ordered_list<'a>(
88        &self,
89        document: &Document,
90        list_node: &'a AstNode<'a>,
91        detected_style: &mut Option<OrderedListStyle>,
92    ) -> Result<Vec<Violation>> {
93        let mut violations = Vec::new();
94        let mut list_items = Vec::new();
95
96        // Collect all list items with their line numbers and prefixes
97        for child in list_node.children() {
98            if let NodeValue::Item(_) = &child.data.borrow().value
99                && let Some((line_num, _)) = document.node_position(child)
100                && let Some(line) = document.lines.get(line_num - 1)
101                && let Some(prefix) = self.extract_list_prefix(line)
102            {
103                list_items.push((line_num, prefix));
104            }
105        }
106
107        if list_items.len() < 2 {
108            // Single item lists don't need consistency checking
109            return Ok(violations);
110        }
111
112        // Determine the expected style for this list
113        let expected_style = match &self.style {
114            OrderedListStyle::Sequential => OrderedListStyle::Sequential,
115            OrderedListStyle::AllOnes => OrderedListStyle::AllOnes,
116            OrderedListStyle::Consistent => {
117                if let Some(style) = detected_style {
118                    style.clone()
119                } else {
120                    // Detect style from this list
121                    let detected = self.detect_list_style(&list_items);
122                    *detected_style = Some(detected.clone());
123                    detected
124                }
125            }
126        };
127
128        // Check each item against the expected style
129        for (i, (line_num, actual_prefix)) in list_items.iter().enumerate() {
130            let expected_prefix = match expected_style {
131                OrderedListStyle::Sequential => (i + 1).to_string(),
132                OrderedListStyle::AllOnes => "1".to_string(),
133                OrderedListStyle::Consistent => {
134                    // This case is handled by detecting the style first
135                    continue;
136                }
137            };
138
139            if actual_prefix != &expected_prefix {
140                violations.push(self.create_violation(
141                    format!(
142                        "Ordered list item prefix inconsistent: expected '{expected_prefix}', found '{actual_prefix}'"
143                    ),
144                    *line_num,
145                    1,
146                    Severity::Warning,
147                ));
148            }
149        }
150
151        Ok(violations)
152    }
153
154    /// Extract the numeric prefix from a list item line
155    fn extract_list_prefix(&self, line: &str) -> Option<String> {
156        let trimmed = line.trim_start();
157
158        // Look for pattern like "1. " or "42. "
159        if let Some(dot_pos) = trimmed.find('.') {
160            let prefix = &trimmed[..dot_pos];
161            if prefix.chars().all(|c| c.is_ascii_digit()) && !prefix.is_empty() {
162                return Some(prefix.to_string());
163            }
164        }
165
166        None
167    }
168
169    /// Detect the style used in a list based on its items
170    fn detect_list_style(&self, items: &[(usize, String)]) -> OrderedListStyle {
171        if items.len() < 2 {
172            return OrderedListStyle::Sequential; // Default for single items
173        }
174
175        // Check if all items use "1"
176        if items.iter().all(|(_, prefix)| prefix == "1") {
177            return OrderedListStyle::AllOnes;
178        }
179
180        // Check if items are sequential starting from 1
181        for (i, (_, prefix)) in items.iter().enumerate() {
182            if prefix != &(i + 1).to_string() {
183                // Not sequential, return the style of the first item
184                return if items[0].1 == "1" {
185                    OrderedListStyle::AllOnes
186                } else {
187                    OrderedListStyle::Sequential
188                };
189            }
190        }
191
192        OrderedListStyle::Sequential
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199    use crate::Document;
200    use crate::rule::Rule;
201    use std::path::PathBuf;
202
203    #[test]
204    fn test_md029_no_violations_sequential() {
205        let content = r#"# Sequential Lists
206
2071. First item
2082. Second item
2093. Third item
2104. Fourth item
211
212Another list:
213
2141. Item one
2152. Item two
2163. Item three
217
218Text here.
219"#;
220        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
221        let rule = MD029::new();
222        let violations = rule.check(&document).unwrap();
223
224        assert_eq!(violations.len(), 0);
225    }
226
227    #[test]
228    fn test_md029_no_violations_all_ones() {
229        let content = r#"# All Ones Lists
230
2311. First item
2321. Second item
2331. Third item
2341. Fourth item
235
236Another list:
237
2381. Item one
2391. Item two
2401. Item three
241
242Text here.
243"#;
244        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
245        let rule = MD029::new();
246        let violations = rule.check(&document).unwrap();
247
248        assert_eq!(violations.len(), 0);
249    }
250
251    #[test]
252    fn test_md029_inconsistent_numbering() {
253        let content = r#"# Inconsistent Numbering
254
2551. First item
2561. Second item should be 2
2573. Third item is correct
2581. Fourth item should be 4
259
260Text here.
261"#;
262        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
263        let rule = MD029::with_style(OrderedListStyle::Sequential);
264        let violations = rule.check(&document).unwrap();
265
266        assert_eq!(violations.len(), 2);
267        assert!(violations[0].message.contains("expected '2', found '1'"));
268        assert!(violations[1].message.contains("expected '4', found '1'"));
269        assert_eq!(violations[0].line, 4);
270        assert_eq!(violations[1].line, 6);
271    }
272
273    #[test]
274    fn test_md029_mixed_styles_in_document() {
275        let content = r#"# Mixed Styles
276
277First list (sequential):
2781. First item
2792. Second item
2803. Third item
281
282Second list (all ones):
2831. First item
2841. Second item
2851. Third item
286
287Text here.
288"#;
289        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
290        let rule = MD029::new(); // Consistent mode
291        let violations = rule.check(&document).unwrap();
292
293        // With consistent mode, it should detect inconsistency
294        assert_eq!(violations.len(), 2);
295        assert_eq!(violations[0].line, 10); // Second list, second item
296        assert_eq!(violations[1].line, 11); // Second list, third item
297    }
298
299    #[test]
300    fn test_md029_forced_sequential_style() {
301        let content = r#"# Forced Sequential Style
302
3031. First item
3041. Should be 2
3051. Should be 3
3061. Should be 4
307
308Text here.
309"#;
310        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
311        let rule = MD029::with_style(OrderedListStyle::Sequential);
312        let violations = rule.check(&document).unwrap();
313
314        assert_eq!(violations.len(), 3);
315        assert!(violations[0].message.contains("expected '2', found '1'"));
316        assert!(violations[1].message.contains("expected '3', found '1'"));
317        assert!(violations[2].message.contains("expected '4', found '1'"));
318    }
319
320    #[test]
321    fn test_md029_forced_all_ones_style() {
322        let content = r#"# Forced All Ones Style
323
3241. First item
3252. Should be 1
3263. Should be 1
3274. Should be 1
328
329Text here.
330"#;
331        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
332        let rule = MD029::with_style(OrderedListStyle::AllOnes);
333        let violations = rule.check(&document).unwrap();
334
335        assert_eq!(violations.len(), 3);
336        assert!(violations[0].message.contains("expected '1', found '2'"));
337        assert!(violations[1].message.contains("expected '1', found '3'"));
338        assert!(violations[2].message.contains("expected '1', found '4'"));
339    }
340
341    #[test]
342    fn test_md029_nested_lists() {
343        let content = r#"# Nested Lists
344
3451. Top level item
346   1. Nested item one
347   2. Nested item two
3482. Second top level
349   1. Another nested item
350   1. This should be 2
351
352Text here.
353"#;
354        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
355        let rule = MD029::with_style(OrderedListStyle::Sequential);
356        let violations = rule.check(&document).unwrap();
357
358        assert_eq!(violations.len(), 1);
359        assert!(violations[0].message.contains("expected '2', found '1'"));
360        assert_eq!(violations[0].line, 8);
361    }
362
363    #[test]
364    fn test_md029_single_item_lists() {
365        let content = r#"# Single Item Lists
366
3671. Only item in this list
368
369Another single item:
3701. Just this one
371
372Text here.
373"#;
374        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
375        let rule = MD029::new();
376        let violations = rule.check(&document).unwrap();
377
378        // Single item lists should not generate violations
379        assert_eq!(violations.len(), 0);
380    }
381
382    #[test]
383    fn test_md029_moderately_indented_lists() {
384        let content = r#"# Moderately Indented Lists
385
386  1. Moderately indented list item
387  2. Second moderately indented item
388  1. This should be 3
389
390Text here.
391"#;
392        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
393        let rule = MD029::with_style(OrderedListStyle::Sequential);
394        let violations = rule.check(&document).unwrap();
395
396        // Test with moderately indented list (2 spaces - should still be parsed as list)
397        assert_eq!(violations.len(), 1);
398        assert!(violations[0].message.contains("expected '3', found '1'"));
399        assert_eq!(violations[0].line, 5);
400    }
401
402    #[test]
403    fn test_md029_extract_prefix() {
404        let rule = MD029::new();
405
406        assert_eq!(
407            rule.extract_list_prefix("1. Item text"),
408            Some("1".to_string())
409        );
410        assert_eq!(
411            rule.extract_list_prefix("42. Item text"),
412            Some("42".to_string())
413        );
414        assert_eq!(
415            rule.extract_list_prefix("  1. Indented item"),
416            Some("1".to_string())
417        );
418        assert_eq!(
419            rule.extract_list_prefix("    42. More indented"),
420            Some("42".to_string())
421        );
422
423        // Invalid formats
424        assert_eq!(rule.extract_list_prefix("- Unordered item"), None);
425        assert_eq!(rule.extract_list_prefix("Not a list"), None);
426        assert_eq!(rule.extract_list_prefix("1) Wrong delimiter"), None);
427        assert_eq!(rule.extract_list_prefix("a. Letter prefix"), None);
428    }
429
430    #[test]
431    fn test_md029_detect_style() {
432        let rule = MD029::new();
433
434        // Sequential style
435        let sequential_items = vec![
436            (1, "1".to_string()),
437            (2, "2".to_string()),
438            (3, "3".to_string()),
439        ];
440        assert_eq!(
441            rule.detect_list_style(&sequential_items),
442            OrderedListStyle::Sequential
443        );
444
445        // All ones style
446        let all_ones_items = vec![
447            (1, "1".to_string()),
448            (2, "1".to_string()),
449            (3, "1".to_string()),
450        ];
451        assert_eq!(
452            rule.detect_list_style(&all_ones_items),
453            OrderedListStyle::AllOnes
454        );
455
456        // Mixed style (defaults to all ones if starts with 1)
457        let mixed_items = vec![
458            (1, "1".to_string()),
459            (2, "3".to_string()),
460            (3, "1".to_string()),
461        ];
462        assert_eq!(
463            rule.detect_list_style(&mixed_items),
464            OrderedListStyle::AllOnes
465        );
466    }
467}