use crate::error::Result;
use crate::rule::{AstRule, RuleCategory, RuleMetadata};
use crate::{
Document,
violation::{Severity, Violation},
};
use comrak::nodes::{AstNode, ListType, NodeValue};
#[derive(Debug, Clone, PartialEq)]
pub enum OrderedListStyle {
Sequential,
AllOnes,
Consistent,
}
pub struct MD029 {
style: OrderedListStyle,
}
impl MD029 {
pub fn new() -> Self {
Self {
style: OrderedListStyle::Consistent,
}
}
#[allow(dead_code)]
pub fn with_style(style: OrderedListStyle) -> Self {
Self { style }
}
}
impl Default for MD029 {
fn default() -> Self {
Self::new()
}
}
impl AstRule for MD029 {
fn id(&self) -> &'static str {
"MD029"
}
fn name(&self) -> &'static str {
"ol-prefix"
}
fn description(&self) -> &'static str {
"Ordered list item prefix consistency"
}
fn metadata(&self) -> RuleMetadata {
RuleMetadata::stable(RuleCategory::Formatting).introduced_in("mdbook-lint v0.1.0")
}
fn check_ast<'a>(&self, document: &Document, ast: &'a AstNode<'a>) -> Result<Vec<Violation>> {
let mut violations = Vec::new();
let mut detected_style: Option<OrderedListStyle> = None;
for node in ast.descendants() {
if let NodeValue::List(list_data) = &node.data.borrow().value {
if let ListType::Ordered = list_data.list_type {
violations.extend(self.check_ordered_list(
document,
node,
&mut detected_style,
)?);
}
}
}
Ok(violations)
}
}
impl MD029 {
fn check_ordered_list<'a>(
&self,
document: &Document,
list_node: &'a AstNode<'a>,
detected_style: &mut Option<OrderedListStyle>,
) -> Result<Vec<Violation>> {
let mut violations = Vec::new();
let mut list_items = Vec::new();
for child in list_node.children() {
if let NodeValue::Item(_) = &child.data.borrow().value {
if let Some((line_num, _)) = document.node_position(child) {
if let Some(line) = document.lines.get(line_num - 1) {
if let Some(prefix) = self.extract_list_prefix(line) {
list_items.push((line_num, prefix));
}
}
}
}
}
if list_items.len() < 2 {
return Ok(violations);
}
let expected_style = match &self.style {
OrderedListStyle::Sequential => OrderedListStyle::Sequential,
OrderedListStyle::AllOnes => OrderedListStyle::AllOnes,
OrderedListStyle::Consistent => {
if let Some(style) = detected_style {
style.clone()
} else {
let detected = self.detect_list_style(&list_items);
*detected_style = Some(detected.clone());
detected
}
}
};
for (i, (line_num, actual_prefix)) in list_items.iter().enumerate() {
let expected_prefix = match expected_style {
OrderedListStyle::Sequential => (i + 1).to_string(),
OrderedListStyle::AllOnes => "1".to_string(),
OrderedListStyle::Consistent => {
continue;
}
};
if actual_prefix != &expected_prefix {
violations.push(self.create_violation(
format!(
"Ordered list item prefix inconsistent: expected '{expected_prefix}', found '{actual_prefix}'"
),
*line_num,
1,
Severity::Warning,
));
}
}
Ok(violations)
}
fn extract_list_prefix(&self, line: &str) -> Option<String> {
let trimmed = line.trim_start();
if let Some(dot_pos) = trimmed.find('.') {
let prefix = &trimmed[..dot_pos];
if prefix.chars().all(|c| c.is_ascii_digit()) && !prefix.is_empty() {
return Some(prefix.to_string());
}
}
None
}
fn detect_list_style(&self, items: &[(usize, String)]) -> OrderedListStyle {
if items.len() < 2 {
return OrderedListStyle::Sequential; }
if items.iter().all(|(_, prefix)| prefix == "1") {
return OrderedListStyle::AllOnes;
}
for (i, (_, prefix)) in items.iter().enumerate() {
if prefix != &(i + 1).to_string() {
return if items[0].1 == "1" {
OrderedListStyle::AllOnes
} else {
OrderedListStyle::Sequential
};
}
}
OrderedListStyle::Sequential
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Document;
use crate::rule::Rule;
use std::path::PathBuf;
#[test]
fn test_md029_no_violations_sequential() {
let content = r#"# Sequential Lists
1. First item
2. Second item
3. Third item
4. Fourth item
Another list:
1. Item one
2. Item two
3. Item three
Text here.
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD029::new();
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 0);
}
#[test]
fn test_md029_no_violations_all_ones() {
let content = r#"# All Ones Lists
1. First item
1. Second item
1. Third item
1. Fourth item
Another list:
1. Item one
1. Item two
1. Item three
Text here.
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD029::new();
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 0);
}
#[test]
fn test_md029_inconsistent_numbering() {
let content = r#"# Inconsistent Numbering
1. First item
1. Second item should be 2
3. Third item is correct
1. Fourth item should be 4
Text here.
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD029::with_style(OrderedListStyle::Sequential);
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 2);
assert!(violations[0].message.contains("expected '2', found '1'"));
assert!(violations[1].message.contains("expected '4', found '1'"));
assert_eq!(violations[0].line, 4);
assert_eq!(violations[1].line, 6);
}
#[test]
fn test_md029_mixed_styles_in_document() {
let content = r#"# Mixed Styles
First list (sequential):
1. First item
2. Second item
3. Third item
Second list (all ones):
1. First item
1. Second item
1. Third item
Text here.
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD029::new(); let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 2);
assert_eq!(violations[0].line, 10); assert_eq!(violations[1].line, 11); }
#[test]
fn test_md029_forced_sequential_style() {
let content = r#"# Forced Sequential Style
1. First item
1. Should be 2
1. Should be 3
1. Should be 4
Text here.
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD029::with_style(OrderedListStyle::Sequential);
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 3);
assert!(violations[0].message.contains("expected '2', found '1'"));
assert!(violations[1].message.contains("expected '3', found '1'"));
assert!(violations[2].message.contains("expected '4', found '1'"));
}
#[test]
fn test_md029_forced_all_ones_style() {
let content = r#"# Forced All Ones Style
1. First item
2. Should be 1
3. Should be 1
4. Should be 1
Text here.
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD029::with_style(OrderedListStyle::AllOnes);
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 3);
assert!(violations[0].message.contains("expected '1', found '2'"));
assert!(violations[1].message.contains("expected '1', found '3'"));
assert!(violations[2].message.contains("expected '1', found '4'"));
}
#[test]
fn test_md029_nested_lists() {
let content = r#"# Nested Lists
1. Top level item
1. Nested item one
2. Nested item two
2. Second top level
1. Another nested item
1. This should be 2
Text here.
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD029::with_style(OrderedListStyle::Sequential);
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 1);
assert!(violations[0].message.contains("expected '2', found '1'"));
assert_eq!(violations[0].line, 8);
}
#[test]
fn test_md029_single_item_lists() {
let content = r#"# Single Item Lists
1. Only item in this list
Another single item:
1. Just this one
Text here.
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD029::new();
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 0);
}
#[test]
fn test_md029_moderately_indented_lists() {
let content = r#"# Moderately Indented Lists
1. Moderately indented list item
2. Second moderately indented item
1. This should be 3
Text here.
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD029::with_style(OrderedListStyle::Sequential);
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 1);
assert!(violations[0].message.contains("expected '3', found '1'"));
assert_eq!(violations[0].line, 5);
}
#[test]
fn test_md029_extract_prefix() {
let rule = MD029::new();
assert_eq!(
rule.extract_list_prefix("1. Item text"),
Some("1".to_string())
);
assert_eq!(
rule.extract_list_prefix("42. Item text"),
Some("42".to_string())
);
assert_eq!(
rule.extract_list_prefix(" 1. Indented item"),
Some("1".to_string())
);
assert_eq!(
rule.extract_list_prefix(" 42. More indented"),
Some("42".to_string())
);
assert_eq!(rule.extract_list_prefix("- Unordered item"), None);
assert_eq!(rule.extract_list_prefix("Not a list"), None);
assert_eq!(rule.extract_list_prefix("1) Wrong delimiter"), None);
assert_eq!(rule.extract_list_prefix("a. Letter prefix"), None);
}
#[test]
fn test_md029_detect_style() {
let rule = MD029::new();
let sequential_items = vec![
(1, "1".to_string()),
(2, "2".to_string()),
(3, "3".to_string()),
];
assert_eq!(
rule.detect_list_style(&sequential_items),
OrderedListStyle::Sequential
);
let all_ones_items = vec![
(1, "1".to_string()),
(2, "1".to_string()),
(3, "1".to_string()),
];
assert_eq!(
rule.detect_list_style(&all_ones_items),
OrderedListStyle::AllOnes
);
let mixed_items = vec![
(1, "1".to_string()),
(2, "3".to_string()),
(3, "1".to_string()),
];
assert_eq!(
rule.detect_list_style(&mixed_items),
OrderedListStyle::AllOnes
);
}
}