Skip to main content

mdlint/lint/rules/
md005.rs

1use crate::lint::rule::Rule;
2use crate::markdown::MarkdownParser;
3use crate::types::Violation;
4use serde_json::Value;
5use std::collections::HashMap;
6
7pub struct MD005;
8
9impl Rule for MD005 {
10    fn name(&self) -> &'static str {
11        "MD005"
12    }
13
14    fn description(&self) -> &'static str {
15        "Inconsistent indentation for list items at the same level"
16    }
17
18    fn tags(&self) -> &[&str] {
19        &["bullet", "ul", "indentation"]
20    }
21
22    fn check(&self, parser: &MarkdownParser, _config: Option<&Value>) -> Vec<Violation> {
23        let mut violations = Vec::new();
24        let mut level_indents: HashMap<usize, usize> = HashMap::new();
25        let mut prev_indent = 0;
26        let mut current_level = 0;
27
28        for (line_num, line) in parser.lines().iter().enumerate() {
29            let line_number = line_num + 1;
30
31            // Check if this is a list item
32            let is_list_item = line.trim_start().starts_with("* ")
33                || line.trim_start().starts_with("+ ")
34                || line.trim_start().starts_with("- ")
35                || line
36                    .trim_start()
37                    .chars()
38                    .next()
39                    .is_some_and(|c| c.is_ascii_digit());
40
41            if !is_list_item {
42                continue;
43            }
44
45            // Calculate indentation
46            let indent = line.len() - line.trim_start().len();
47
48            // Determine list level based on indentation
49            // Only consider it a new level if indented by at least 2 spaces more
50            if indent >= prev_indent + 2 {
51                current_level += 1;
52            } else if indent < prev_indent {
53                // Find the level for this indentation
54                current_level = level_indents
55                    .iter()
56                    .filter(|(_, i)| **i == indent)
57                    .map(|(l, _)| *l)
58                    .next()
59                    .unwrap_or(0);
60            }
61            // If indent is between prev_indent and prev_indent + 2, stay at same level
62
63            // Check if this level has a recorded indentation
64            if let Some(&expected_indent) = level_indents.get(&current_level) {
65                if indent != expected_indent {
66                    violations.push(Violation {
67                        line: line_number,
68                        column: Some(1),
69                        rule: self.name().to_owned(),
70                        message: format!(
71                            "List item indentation mismatch: expected {expected_indent} spaces, found {indent}"
72                        ),
73                        fix: None,
74                    });
75                }
76            } else {
77                // Record this level's indentation
78                level_indents.insert(current_level, indent);
79            }
80
81            prev_indent = indent;
82        }
83
84        violations
85    }
86
87    fn fixable(&self) -> bool {
88        false
89    }
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95
96    #[test]
97    fn test_consistent_indentation() {
98        let content = "* Item 1\n* Item 2\n  * Nested 1\n  * Nested 2\n* Item 3";
99        let parser = MarkdownParser::new(content);
100        let rule = MD005;
101        let violations = rule.check(&parser, None);
102
103        assert_eq!(violations.len(), 0);
104    }
105
106    #[test]
107    fn test_inconsistent_indentation() {
108        let content = "* Item 1\n * Item 2 - wrong indent\n* Item 3";
109        let parser = MarkdownParser::new(content);
110        let rule = MD005;
111        let violations = rule.check(&parser, None);
112
113        assert!(!violations.is_empty());
114    }
115
116    #[test]
117    fn test_ordered_list() {
118        let content = "1. Item 1\n2. Item 2\n3. Item 3";
119        let parser = MarkdownParser::new(content);
120        let rule = MD005;
121        let violations = rule.check(&parser, None);
122
123        assert_eq!(violations.len(), 0);
124    }
125}