mdbook_lint_core/rules/standard/
md005.rs

1//! MD005: List item indentation consistency
2//!
3//! This rule checks that list items have consistent indentation throughout the document.
4
5use crate::error::Result;
6use crate::rule::{AstRule, RuleCategory, RuleMetadata};
7use crate::{
8    Document,
9    violation::{Severity, Violation},
10};
11use comrak::nodes::{AstNode, NodeValue};
12
13/// Rule to check for consistent list item indentation
14pub struct MD005;
15
16impl AstRule for MD005 {
17    fn id(&self) -> &'static str {
18        "MD005"
19    }
20
21    fn name(&self) -> &'static str {
22        "list-indent"
23    }
24
25    fn description(&self) -> &'static str {
26        "List item indentation should be consistent"
27    }
28
29    fn metadata(&self) -> RuleMetadata {
30        RuleMetadata::stable(RuleCategory::Formatting).introduced_in("mdbook-lint v0.1.0")
31    }
32
33    fn check_ast<'a>(&self, document: &Document, ast: &'a AstNode<'a>) -> Result<Vec<Violation>> {
34        let mut violations = Vec::new();
35
36        // Find all list nodes
37        for node in ast.descendants() {
38            if let NodeValue::List(list_data) = &node.data.borrow().value {
39                // Check indentation consistency within this list
40                violations.extend(self.check_list_indentation(document, node, list_data)?);
41            }
42        }
43
44        Ok(violations)
45    }
46}
47
48impl MD005 {
49    /// Check indentation consistency within a single list
50    fn check_list_indentation<'a>(
51        &self,
52        document: &Document,
53        list_node: &'a AstNode<'a>,
54        _list_data: &comrak::nodes::NodeList,
55    ) -> Result<Vec<Violation>> {
56        let mut violations = Vec::new();
57        let mut expected_indent: Option<usize> = None;
58
59        // Iterate through list items
60        for child in list_node.children() {
61            if let NodeValue::Item(_) = &child.data.borrow().value
62                && let Some((line_num, _)) = document.node_position(child)
63                && let Some(line) = document.lines.get(line_num - 1)
64            {
65                let actual_indent = self.get_line_indentation(line);
66
67                // Set expected indentation from first item
68                if expected_indent.is_none() {
69                    expected_indent = Some(actual_indent);
70                } else if let Some(expected) = expected_indent
71                    && actual_indent != expected
72                {
73                    // Check if this item's indentation matches
74                    violations.push(self.create_violation(
75                        format!(
76                            "List item indentation inconsistent: expected {expected} spaces, found {actual_indent}"
77                        ),
78                        line_num,
79                        1,
80                        Severity::Warning,
81                    ));
82                }
83            }
84        }
85
86        Ok(violations)
87    }
88
89    /// Get the indentation level (number of leading spaces/tabs) of a line
90    fn get_line_indentation(&self, line: &str) -> usize {
91        let mut indent = 0;
92        for ch in line.chars() {
93            match ch {
94                ' ' => indent += 1,
95                '\t' => indent += 4, // Count tabs as 4 spaces
96                _ => break,
97            }
98        }
99        indent
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106    use crate::Document;
107    use crate::rule::Rule;
108    use std::path::PathBuf;
109
110    #[test]
111    fn test_md005_no_violations() {
112        let content = r#"# Consistent List Indentation
113
114These lists have consistent indentation:
115
116- Item 1
117- Item 2
118- Item 3
119
1201. First item
1212. Second item
1223. Third item
123
124Nested lists with consistent indentation:
125
126- Top level
127  - Nested item 1
128  - Nested item 2
129    - Deeply nested 1
130    - Deeply nested 2
131- Back to top level
132"#;
133        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
134        let rule = MD005;
135        let violations = rule.check(&document).unwrap();
136
137        assert_eq!(violations.len(), 0);
138    }
139
140    #[test]
141    fn test_md005_inconsistent_indentation() {
142        let content = r#"# Inconsistent List Indentation
143
144This list has inconsistent indentation at the same level:
145
146- Item 1
147- Item 2
148 - Item 3 (inconsistent - 1 space instead of 0)
149- Item 4
150"#;
151        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
152        let rule = MD005;
153        let violations = rule.check(&document).unwrap();
154
155        // Should detect inconsistent indentation in the main list
156        assert_eq!(violations.len(), 1);
157        assert!(violations[0].message.contains("expected 0 spaces, found 1"));
158    }
159
160    #[test]
161    fn test_md005_ordered_list_inconsistent() {
162        let content = r#"# Inconsistent Ordered List
163
1641. First item
165 2. Second item (wrong indentation)
1661. Third item
167  3. Fourth item (wrong again)
168"#;
169        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
170        let rule = MD005;
171        let violations = rule.check(&document).unwrap();
172
173        assert_eq!(violations.len(), 2);
174        assert!(violations[0].message.contains("expected 0 spaces, found 1"));
175        assert!(violations[1].message.contains("expected 0 spaces, found 2"));
176    }
177
178    #[test]
179    fn test_md005_mixed_spaces_tabs() {
180        let content = "# Mixed Spaces and Tabs\n\n- Item 1\n\t- Item 2 (tab indented)\n    - Item 3 (space indented)\n";
181        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
182        let rule = MD005;
183        let violations = rule.check(&document).unwrap();
184
185        // Should detect inconsistency between tab (4 spaces) and 4 actual spaces
186        // Note: tabs are converted to 4 spaces for comparison
187        assert_eq!(violations.len(), 0); // Both should be equivalent to 4 spaces
188    }
189
190    #[test]
191    fn test_md005_separate_lists() {
192        let content = r#"# Separate Lists
193
194First list:
195- Item A
196- Item B
197
198Second list with different indentation (should be OK):
199  - Item X
200  - Item Y
201
202Third list:
2031. Item 1
2041. Item 2
205"#;
206        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
207        let rule = MD005;
208        let violations = rule.check(&document).unwrap();
209
210        // Each list can have its own indentation style
211        assert_eq!(violations.len(), 0);
212    }
213
214    #[test]
215    fn test_md005_nested_lists_independent() {
216        let content = r#"# Nested Lists
217
218- Top level item 1
219- Top level item 2
220  - Nested item A
221   - Nested item B (inconsistent with nested level)
222  - Nested item C
223- Top level item 3
224"#;
225        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
226        let rule = MD005;
227        let violations = rule.check(&document).unwrap();
228
229        // Should detect inconsistency in the nested list
230        assert_eq!(violations.len(), 1);
231        assert!(violations[0].message.contains("expected 2 spaces, found 3"));
232    }
233
234    #[test]
235    fn test_md005_empty_list() {
236        let content = r#"# Empty or Single Item Lists
237
238- Single item
239
2401. Another single item
241
242Some text here.
243"#;
244        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
245        let rule = MD005;
246        let violations = rule.check(&document).unwrap();
247
248        // No violations for single-item lists
249        assert_eq!(violations.len(), 0);
250    }
251
252    #[test]
253    fn test_md005_complex_nesting() {
254        let content = r#"# Complex Nesting
255
256- Level 1 item 1
257  - Level 2 item 1
258    - Level 3 item 1
259    - Level 3 item 2
260  - Level 2 item 2
261   - Level 2 item 3 (wrong indentation)
262- Level 1 item 2
263"#;
264        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
265        let rule = MD005;
266        let violations = rule.check(&document).unwrap();
267
268        // Should detect the inconsistent level 2 item
269        assert_eq!(violations.len(), 1);
270        assert!(violations[0].message.contains("expected 2 spaces, found 3"));
271    }
272
273    #[test]
274    fn test_get_line_indentation() {
275        let rule = MD005;
276
277        assert_eq!(rule.get_line_indentation("- No indentation"), 0);
278        assert_eq!(rule.get_line_indentation("  - Two spaces"), 2);
279        assert_eq!(rule.get_line_indentation("    - Four spaces"), 4);
280        assert_eq!(rule.get_line_indentation("\t- One tab"), 4);
281        assert_eq!(rule.get_line_indentation("\t  - Tab plus two spaces"), 6);
282        assert_eq!(rule.get_line_indentation("      - Six spaces"), 6);
283    }
284}