use comrak::nodes::{AstNode, 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},
};
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 can_fix(&self) -> bool {
true
}
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
&& let Some((line_num, _)) = document.node_position(child)
&& 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
&& actual_indent != expected
{
let spaces_to_adjust = actual_indent.abs_diff(expected);
let fixed_line = if actual_indent > expected {
format!("{}\n", &line[spaces_to_adjust..])
} else {
format!("{}{}\n", " ".repeat(spaces_to_adjust), line)
};
let fix = Fix {
description: format!(
"Adjust indentation from {} to {} spaces",
actual_indent, expected
),
replacement: Some(fixed_line),
start: Position {
line: line_num,
column: 1,
},
end: Position {
line: line_num,
column: line.len() + 1,
},
};
violations.push(self.create_violation_with_fix(
format!(
"List item indentation inconsistent: expected {expected} spaces, found {actual_indent}"
),
line_num,
1,
Severity::Warning,
fix,
));
}
}
}
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 mdbook_lint_core::Document;
use mdbook_lint_core::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);
}
#[test]
fn test_md005_fix_add_indentation() {
let content = r#"# Inconsistent List
- Item 1
- Item 2
- Item 3 (wrong indentation)"#;
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].fix.is_some());
let fix = violations[0].fix.as_ref().unwrap();
assert_eq!(fix.description, "Adjust indentation from 0 to 2 spaces");
assert_eq!(
fix.replacement,
Some(" - Item 3 (wrong indentation)\n".to_string())
);
}
#[test]
fn test_md005_fix_remove_indentation() {
let content = r#"# Inconsistent List
- Item 1
- Item 2
- Item 3 (inconsistent at same 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(), 1);
assert!(violations[0].fix.is_some());
let fix = violations[0].fix.as_ref().unwrap();
assert_eq!(fix.description, "Adjust indentation from 1 to 0 spaces");
assert_eq!(
fix.replacement,
Some("- Item 3 (inconsistent at same level)\n".to_string())
);
}
#[test]
fn test_md005_fix_ordered_list() {
let content = r#"# Inconsistent Ordered List
1. First item
2. Second item (wrong indentation)
1. Third item"#;
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].fix.is_some());
let fix = violations[0].fix.as_ref().unwrap();
assert_eq!(fix.description, "Adjust indentation from 1 to 0 spaces");
assert_eq!(
fix.replacement,
Some("2. Second item (wrong indentation)\n".to_string())
);
}
#[test]
fn test_md005_fix_nested_lists() {
let content = r#"# Nested Lists
- Top level item 1
- Top level item 2
- Nested item A
- Nested item B (inconsistent)"#;
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].fix.is_some());
let fix = violations[0].fix.as_ref().unwrap();
assert_eq!(fix.description, "Adjust indentation from 3 to 2 spaces");
assert_eq!(
fix.replacement,
Some(" - Nested item B (inconsistent)\n".to_string())
);
}
#[test]
fn test_md005_fix_multiple_violations() {
let content = r#"# Multiple Issues
- Item 1
- Item 2 (1 space)
- Item 3 (2 spaces)
- Item 4 (3 spaces)"#;
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(), 3);
for violation in &violations {
assert!(violation.fix.is_some());
}
assert_eq!(
violations[0].fix.as_ref().unwrap().description,
"Adjust indentation from 1 to 0 spaces"
);
assert_eq!(
violations[0].fix.as_ref().unwrap().replacement,
Some("- Item 2 (1 space)\n".to_string())
);
assert_eq!(
violations[1].fix.as_ref().unwrap().description,
"Adjust indentation from 2 to 0 spaces"
);
assert_eq!(
violations[1].fix.as_ref().unwrap().replacement,
Some("- Item 3 (2 spaces)\n".to_string())
);
assert_eq!(
violations[2].fix.as_ref().unwrap().description,
"Adjust indentation from 3 to 0 spaces"
);
assert_eq!(
violations[2].fix.as_ref().unwrap().replacement,
Some("- Item 4 (3 spaces)\n".to_string())
);
}
#[test]
fn test_md005_fix_preserves_content() {
let content = r#"# List with Formatting
- Item with **bold** text
- Item with *italic* text (wrong indent)
- Item with `code` text"#;
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].fix.is_some());
let fix = violations[0].fix.as_ref().unwrap();
assert_eq!(
fix.replacement,
Some("- Item with *italic* text (wrong indent)\n".to_string())
);
}
#[test]
fn test_md005_fix_tabs_converted() {
let rule = MD005;
assert!(mdbook_lint_core::AstRule::can_fix(&rule));
let content = "# List\n\n- Item 1\n - Item 2 (1 space - inconsistent)";
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 1);
assert!(violations[0].fix.is_some());
let fix = violations[0].fix.as_ref().unwrap();
assert_eq!(fix.description, "Adjust indentation from 1 to 0 spaces");
assert_eq!(
fix.replacement,
Some("- Item 2 (1 space - inconsistent)\n".to_string())
);
}
#[test]
fn test_md005_can_fix() {
let rule = MD005;
assert!(mdbook_lint_core::AstRule::can_fix(&rule));
}
}