mdbook_lint_core/rules/standard/
md005.rs1use crate::error::Result;
6use crate::rule::{AstRule, RuleCategory, RuleMetadata};
7use crate::{
8 Document,
9 violation::{Severity, Violation},
10};
11use comrak::nodes::{AstNode, NodeValue};
12
13pub 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 for node in ast.descendants() {
38 if let NodeValue::List(list_data) = &node.data.borrow().value {
39 violations.extend(self.check_list_indentation(document, node, list_data)?);
41 }
42 }
43
44 Ok(violations)
45 }
46}
47
48impl MD005 {
49 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 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 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 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 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, _ => 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 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 assert_eq!(violations.len(), 0); }
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 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 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 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 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}