mdbook_lint_core/rules/standard/
md029.rs1use 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#[derive(Debug, Clone, PartialEq)]
16pub enum OrderedListStyle {
17 Sequential,
19 AllOnes,
21 Consistent,
23}
24
25pub struct MD029 {
27 style: OrderedListStyle,
28}
29
30impl MD029 {
31 pub fn new() -> Self {
33 Self {
34 style: OrderedListStyle::Consistent,
35 }
36 }
37
38 #[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 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 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 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 return Ok(violations);
110 }
111
112 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 let detected = self.detect_list_style(&list_items);
122 *detected_style = Some(detected.clone());
123 detected
124 }
125 }
126 };
127
128 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 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 fn extract_list_prefix(&self, line: &str) -> Option<String> {
156 let trimmed = line.trim_start();
157
158 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 fn detect_list_style(&self, items: &[(usize, String)]) -> OrderedListStyle {
171 if items.len() < 2 {
172 return OrderedListStyle::Sequential; }
174
175 if items.iter().all(|(_, prefix)| prefix == "1") {
177 return OrderedListStyle::AllOnes;
178 }
179
180 for (i, (_, prefix)) in items.iter().enumerate() {
182 if prefix != &(i + 1).to_string() {
183 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(); let violations = rule.check(&document).unwrap();
292
293 assert_eq!(violations.len(), 2);
295 assert_eq!(violations[0].line, 10); assert_eq!(violations[1].line, 11); }
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 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 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 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 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 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 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}