mdbook_lint_core/rules/standard/
md007.rs1use crate::Document;
2use crate::error::Result;
3use crate::rule::{Rule, RuleCategory, RuleMetadata};
4use crate::violation::{Severity, Violation};
5use comrak::nodes::AstNode;
6
7pub struct MD007 {
9 pub indent: usize,
11 pub start_indent: usize,
13 pub start_indented: bool,
15}
16
17impl MD007 {
18 pub fn new() -> Self {
19 Self {
20 indent: 2,
21 start_indent: 2,
22 start_indented: false,
23 }
24 }
25
26 #[allow(dead_code)]
27 pub fn with_indent(mut self, indent: usize) -> Self {
28 self.indent = indent;
29 self
30 }
31
32 #[allow(dead_code)]
33 pub fn with_start_indent(mut self, start_indent: usize) -> Self {
34 self.start_indent = start_indent;
35 self
36 }
37
38 #[allow(dead_code)]
39 pub fn with_start_indented(mut self, start_indented: bool) -> Self {
40 self.start_indented = start_indented;
41 self
42 }
43
44 fn calculate_expected_indent(&self, depth: usize) -> usize {
45 if depth == 0 {
46 if self.start_indented {
47 self.start_indent
48 } else {
49 0
50 }
51 } else {
52 let base = if self.start_indented {
53 self.start_indent
54 } else {
55 0
56 };
57 base + depth * self.indent
58 }
59 }
60
61 fn parse_list_item(&self, line: &str) -> Option<(usize, char, bool)> {
62 let mut indent = 0;
63 let mut chars = line.chars();
64
65 while let Some(ch) = chars.next() {
67 if ch == ' ' {
68 indent += 1;
69 } else if ch == '\t' {
70 indent += 4; } else if matches!(ch, '*' | '+' | '-') {
72 if let Some(next_ch) = chars.next()
74 && next_ch.is_whitespace()
75 {
76 return Some((indent, ch, false)); }
78 break;
79 } else if ch.is_ascii_digit() {
80 let mut temp_chars = chars.as_str().chars();
82 while let Some(digit_ch) = temp_chars.next() {
83 if digit_ch == '.' || digit_ch == ')' {
84 if let Some(next_ch) = temp_chars.next()
85 && next_ch.is_whitespace()
86 {
87 return Some((indent, ch, true)); }
89 break;
90 } else if !digit_ch.is_ascii_digit() {
91 break;
92 }
93 }
94 break;
95 } else {
96 break;
97 }
98 }
99
100 None
101 }
102
103 fn calculate_depth(&self, list_stack: &[(usize, char, bool)], current_indent: usize) -> usize {
104 for (i, &(stack_indent, _, _)) in list_stack.iter().enumerate() {
106 if current_indent <= stack_indent {
107 return i;
108 }
109 }
110 list_stack.len()
111 }
112
113 fn update_list_stack(
114 &self,
115 list_stack: &mut Vec<(usize, char, bool)>,
116 indent: usize,
117 marker: char,
118 is_ordered: bool,
119 ) {
120 list_stack.retain(|&(stack_indent, _, _)| stack_indent < indent);
122
123 list_stack.push((indent, marker, is_ordered));
125 }
126
127 fn has_ordered_ancestors(&self, list_stack: &[(usize, char, bool)]) -> bool {
128 list_stack.iter().any(|&(_, _, is_ordered)| is_ordered)
129 }
130}
131
132impl Default for MD007 {
133 fn default() -> Self {
134 Self::new()
135 }
136}
137
138impl Rule for MD007 {
139 fn id(&self) -> &'static str {
140 "MD007"
141 }
142
143 fn name(&self) -> &'static str {
144 "ul-indent"
145 }
146
147 fn description(&self) -> &'static str {
148 "Unordered list indentation"
149 }
150
151 fn metadata(&self) -> RuleMetadata {
152 RuleMetadata::stable(RuleCategory::Formatting)
153 }
154
155 fn check_with_ast<'a>(
156 &self,
157 document: &Document,
158 _ast: Option<&'a AstNode<'a>>,
159 ) -> Result<Vec<Violation>> {
160 let mut violations = Vec::new();
161 let lines: Vec<&str> = document.content.lines().collect();
162
163 let mut list_stack: Vec<(usize, char, bool)> = Vec::new(); for (line_number, line) in lines.iter().enumerate() {
166 let line_number = line_number + 1;
167
168 if line.trim().is_empty() {
170 continue;
171 }
172
173 if let Some((indent, marker, is_ordered)) = self.parse_list_item(line) {
175 if !is_ordered && !self.has_ordered_ancestors(&list_stack) {
177 let current_depth = self.calculate_depth(&list_stack, indent);
179 let expected_indent = self.calculate_expected_indent(current_depth);
180
181 if indent != expected_indent {
182 violations.push(self.create_violation(
183 format!(
184 "Unordered list indentation: Expected {expected_indent} spaces, found {indent}"
185 ),
186 line_number,
187 indent + 1, Severity::Warning,
189 ));
190 }
191 }
192
193 self.update_list_stack(&mut list_stack, indent, marker, is_ordered);
195 } else {
196 list_stack.clear();
198 }
199 }
200
201 Ok(violations)
202 }
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208 use crate::Document;
209 use std::path::PathBuf;
210
211 #[test]
212 fn test_md007_correct_indentation() {
213 let content = r#"* Item 1
214 * Nested item (2 spaces)
215 * Deep nested item (4 spaces)
216* Item 2
217 * Another nested item
218"#;
219
220 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
221 let rule = MD007::new();
222 let violations = rule.check(&document).unwrap();
223 assert_eq!(violations.len(), 0);
224 }
225
226 #[test]
227 fn test_md007_incorrect_indentation() {
228 let content = r#"* Item 1
229 * Nested item (3 spaces - wrong!)
230 * Deep nested item (5 spaces - wrong!)
231* Item 2
232"#;
233
234 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
235 let rule = MD007::new();
236 let violations = rule.check(&document).unwrap();
237 assert_eq!(violations.len(), 2);
238 assert_eq!(violations[0].line, 2);
239 assert_eq!(violations[1].line, 3);
240 assert!(violations[0].message.contains("Expected 2 spaces, found 3"));
241 assert!(violations[1].message.contains("Expected 4 spaces, found 5"));
242 }
243
244 #[test]
245 fn test_md007_custom_indent() {
246 let content = r#"* Item 1
247 * Nested item (4 spaces)
248 * Deep nested item (8 spaces)
249"#;
250
251 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
252 let rule = MD007::new().with_indent(4);
253 let violations = rule.check(&document).unwrap();
254 assert_eq!(violations.len(), 0);
255 }
256
257 #[test]
258 fn test_md007_start_indented() {
259 let content = r#" * Item 1 (2 spaces start)
260 * Nested item (4 spaces total)
261 * Deep nested item (6 spaces total)
262"#;
263
264 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
265 let rule = MD007::new().with_start_indented(true);
266 let violations = rule.check(&document).unwrap();
267 assert_eq!(violations.len(), 0);
268 }
269
270 #[test]
271 fn test_md007_start_indented_custom() {
272 let content = r#" * Item 1 (4 spaces start)
273 * Nested item (8 spaces total)
274 * Deep nested item (12 spaces total)
275"#;
276
277 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
278 let rule = MD007::new()
279 .with_start_indented(true)
280 .with_start_indent(4)
281 .with_indent(4);
282 let violations = rule.check(&document).unwrap();
283 assert_eq!(violations.len(), 0);
284 }
285
286 #[test]
287 fn test_md007_mixed_list_types() {
288 let content = r#"1. Ordered item
289 * Unordered nested (should be ignored due to ordered parent)
290 * Deep nested (should be ignored)
291* Unordered item
292 * Unordered nested (should be checked)
293"#;
294
295 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
296 let rule = MD007::new();
297 let violations = rule.check(&document).unwrap();
298 assert_eq!(violations.len(), 0); }
300
301 #[test]
302 fn test_md007_only_unordered_lists() {
303 let content = r#"1. Ordered item
304 2. Another ordered item (wrong indentation but ignored)
305 3. Deep ordered item (also ignored)
306"#;
307
308 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
309 let rule = MD007::new();
310 let violations = rule.check(&document).unwrap();
311 assert_eq!(violations.len(), 0);
312 }
313
314 #[test]
315 fn test_md007_no_indentation_needed() {
316 let content = r#"* Item 1
317* Item 2
318* Item 3
319"#;
320
321 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
322 let rule = MD007::new();
323 let violations = rule.check(&document).unwrap();
324 assert_eq!(violations.len(), 0);
325 }
326
327 #[test]
328 fn test_md007_zero_indentation_with_start_indented() {
329 let content = r#"* Item 1 (should be indented)
330* Item 2 (should be indented)
331"#;
332
333 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
334 let rule = MD007::new().with_start_indented(true);
335 let violations = rule.check(&document).unwrap();
336 assert_eq!(violations.len(), 2);
337 assert!(violations[0].message.contains("Expected 2 spaces, found 0"));
338 assert!(violations[1].message.contains("Expected 2 spaces, found 0"));
339 }
340
341 #[test]
342 fn test_md007_complex_nesting() {
343 let content = r#"* Level 1
344 * Level 2 correct
345 * Level 3 correct
346 * Level 4 correct
347 * Level 2 wrong (3 spaces)
348 * Level 3 wrong (5 spaces)
349"#;
350
351 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
352 let rule = MD007::new();
353 let violations = rule.check(&document).unwrap();
354 assert_eq!(violations.len(), 2);
355 assert_eq!(violations[0].line, 5);
356 assert_eq!(violations[1].line, 6);
357 }
358}