use crate::error::Result;
use crate::rule::{AstRule, RuleCategory, RuleMetadata};
use crate::{
Document,
violation::{Severity, Violation},
};
use comrak::nodes::{AstNode, NodeValue};
pub struct MD005;
impl AstRule for MD005 {
fn id(&self) -> &'static str {
"MD005"
}
fn name(&self) -> &'static str {
"list-indent"
}
fn description(&self) -> &'static str {
"List item indentation should be consistent"
}
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();
for node in ast.descendants() {
if let NodeValue::List(list_data) = &node.data.borrow().value {
violations.extend(self.check_list_indentation(document, node, list_data)?);
}
}
Ok(violations)
}
}
impl MD005 {
fn check_list_indentation<'a>(
&self,
document: &Document,
list_node: &'a AstNode<'a>,
_list_data: &comrak::nodes::NodeList,
) -> Result<Vec<Violation>> {
let mut violations = Vec::new();
let mut expected_indent: Option<usize> = None;
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) {
let actual_indent = self.get_line_indentation(line);
if expected_indent.is_none() {
expected_indent = Some(actual_indent);
} else if let Some(expected) = expected_indent {
if actual_indent != expected {
violations.push(self.create_violation(
format!(
"List item indentation inconsistent: expected {expected} spaces, found {actual_indent}"
),
line_num,
1,
Severity::Warning,
));
}
}
}
}
}
}
Ok(violations)
}
fn get_line_indentation(&self, line: &str) -> usize {
let mut indent = 0;
for ch in line.chars() {
match ch {
' ' => indent += 1,
'\t' => indent += 4, _ => break,
}
}
indent
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Document;
use crate::rule::Rule;
use std::path::PathBuf;
#[test]
fn test_md005_no_violations() {
let content = r#"# Consistent List Indentation
These lists have consistent indentation:
- Item 1
- Item 2
- Item 3
1. First item
2. Second item
3. Third item
Nested lists with consistent indentation:
- Top level
- Nested item 1
- Nested item 2
- Deeply nested 1
- Deeply nested 2
- Back to top level
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD005;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 0);
}
#[test]
fn test_md005_inconsistent_indentation() {
let content = r#"# Inconsistent List Indentation
This list has inconsistent indentation at the same level:
- Item 1
- Item 2
- Item 3 (inconsistent - 1 space instead of 0)
- Item 4
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD005;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 1);
assert!(violations[0].message.contains("expected 0 spaces, found 1"));
}
#[test]
fn test_md005_ordered_list_inconsistent() {
let content = r#"# Inconsistent Ordered List
1. First item
2. Second item (wrong indentation)
1. Third item
3. Fourth item (wrong again)
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD005;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 2);
assert!(violations[0].message.contains("expected 0 spaces, found 1"));
assert!(violations[1].message.contains("expected 0 spaces, found 2"));
}
#[test]
fn test_md005_mixed_spaces_tabs() {
let content = "# Mixed Spaces and Tabs\n\n- Item 1\n\t- Item 2 (tab indented)\n - Item 3 (space indented)\n";
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD005;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 0); }
#[test]
fn test_md005_separate_lists() {
let content = r#"# Separate Lists
First list:
- Item A
- Item B
Second list with different indentation (should be OK):
- Item X
- Item Y
Third list:
1. Item 1
1. Item 2
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD005;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 0);
}
#[test]
fn test_md005_nested_lists_independent() {
let content = r#"# Nested Lists
- Top level item 1
- Top level item 2
- Nested item A
- Nested item B (inconsistent with nested level)
- Nested item C
- Top level item 3
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD005;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 1);
assert!(violations[0].message.contains("expected 2 spaces, found 3"));
}
#[test]
fn test_md005_empty_list() {
let content = r#"# Empty or Single Item Lists
- Single item
1. Another single item
Some text here.
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD005;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 0);
}
#[test]
fn test_md005_complex_nesting() {
let content = r#"# Complex Nesting
- Level 1 item 1
- Level 2 item 1
- Level 3 item 1
- Level 3 item 2
- Level 2 item 2
- Level 2 item 3 (wrong indentation)
- Level 1 item 2
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD005;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 1);
assert!(violations[0].message.contains("expected 2 spaces, found 3"));
}
#[test]
fn test_get_line_indentation() {
let rule = MD005;
assert_eq!(rule.get_line_indentation("- No indentation"), 0);
assert_eq!(rule.get_line_indentation(" - Two spaces"), 2);
assert_eq!(rule.get_line_indentation(" - Four spaces"), 4);
assert_eq!(rule.get_line_indentation("\t- One tab"), 4);
assert_eq!(rule.get_line_indentation("\t - Tab plus two spaces"), 6);
assert_eq!(rule.get_line_indentation(" - Six spaces"), 6);
}
}