use comrak::nodes::{AstNode, ListType, NodeValue};
use mdbook_lint_core::error::Result;
use mdbook_lint_core::rule::{AstRule, RuleCategory, RuleMetadata};
use mdbook_lint_core::{
Document,
violation::{Fix, Position, Severity, Violation},
};
#[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 }
}
pub fn from_config(config: &toml::Value) -> Self {
let mut rule = Self::new();
if let Some(style_str) = config.get("style").and_then(|v| v.as_str()) {
rule.style = match style_str.to_lowercase().as_str() {
"sequential" => OrderedListStyle::Sequential,
"all_ones" | "all-ones" => OrderedListStyle::AllOnes,
"consistent" => OrderedListStyle::Consistent,
_ => OrderedListStyle::Consistent, };
}
rule
}
}
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 can_fix(&self) -> bool {
true
}
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
&& 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
&& let Some((line_num, _)) = document.node_position(child)
&& let Some(line) = document.lines.get(line_num - 1)
&& 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 {
let line_content = &document.lines[*line_num - 1];
let trimmed = line_content.trim_start();
let indent = &line_content[..line_content.len() - trimmed.len()];
if let Some(dot_pos) = trimmed.find('.') {
let after_dot = &trimmed[dot_pos..];
let fixed_line = format!("{}{}{}\n", indent, expected_prefix, after_dot);
let fix = Fix {
description: format!(
"Change list item prefix from '{}' to '{}'",
actual_prefix, expected_prefix
),
replacement: Some(fixed_line),
start: Position {
line: *line_num,
column: 1,
},
end: Position {
line: *line_num,
column: line_content.len() + 1,
},
};
violations.push(self.create_violation_with_fix(
format!(
"Ordered list item prefix inconsistent: expected '{expected_prefix}', found '{actual_prefix}'"
),
*line_num,
1,
Severity::Warning,
fix,
));
} else {
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 mdbook_lint_core::Document;
use mdbook_lint_core::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
);
}
#[test]
fn test_md029_fix_sequential_style() {
let content = r#"# Wrong Numbering
1. First item
1. Second item (should be 2)
1. Third item (should be 3)
"#;
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].fix.is_some());
let fix1 = violations[0].fix.as_ref().unwrap();
assert_eq!(fix1.description, "Change list item prefix from '1' to '2'");
assert_eq!(
fix1.replacement,
Some("2. Second item (should be 2)\n".to_string())
);
assert!(violations[1].fix.is_some());
let fix2 = violations[1].fix.as_ref().unwrap();
assert_eq!(fix2.description, "Change list item prefix from '1' to '3'");
assert_eq!(
fix2.replacement,
Some("3. Third item (should be 3)\n".to_string())
);
}
#[test]
fn test_md029_fix_all_ones_style() {
let content = r#"# Sequential when should be all ones
1. First item
2. Second item (should be 1)
3. Third item (should be 1)
"#;
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(), 2);
assert!(violations[0].fix.is_some());
let fix1 = violations[0].fix.as_ref().unwrap();
assert_eq!(fix1.description, "Change list item prefix from '2' to '1'");
assert_eq!(
fix1.replacement,
Some("1. Second item (should be 1)\n".to_string())
);
assert!(violations[1].fix.is_some());
let fix2 = violations[1].fix.as_ref().unwrap();
assert_eq!(fix2.description, "Change list item prefix from '3' to '1'");
assert_eq!(
fix2.replacement,
Some("1. Third item (should be 1)\n".to_string())
);
}
#[test]
fn test_md029_fix_with_indentation() {
let content = r#"# Indented list
1. First item
1. Second item
1. Third item
"#;
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].fix.is_some());
let fix1 = violations[0].fix.as_ref().unwrap();
assert_eq!(fix1.replacement, Some(" 2. Second item\n".to_string()));
assert!(violations[1].fix.is_some());
let fix2 = violations[1].fix.as_ref().unwrap();
assert_eq!(fix2.replacement, Some(" 3. Third item\n".to_string()));
}
#[test]
fn test_md029_can_fix() {
let rule = MD029::new();
assert!(mdbook_lint_core::AstRule::can_fix(&rule));
}
}