mdbook_lint_core/rules/standard/
md007.rs

1use crate::Document;
2use crate::error::Result;
3use crate::rule::{Rule, RuleCategory, RuleMetadata};
4use crate::violation::{Severity, Violation};
5use comrak::nodes::AstNode;
6
7/// MD007 - Unordered list indentation
8pub struct MD007 {
9    /// Number of spaces for indent (default: 2)
10    pub indent: usize,
11    /// Spaces for first level indent when start_indented is set (default: 2)
12    pub start_indent: usize,
13    /// Whether to indent the first level of the list (default: false)
14    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        // Count leading spaces
66        while let Some(ch) = chars.next() {
67            if ch == ' ' {
68                indent += 1;
69            } else if ch == '\t' {
70                indent += 4; // Treat tab as 4 spaces
71            } else if matches!(ch, '*' | '+' | '-') {
72                // Check if followed by whitespace (valid list marker)
73                if let Some(next_ch) = chars.next()
74                    && next_ch.is_whitespace()
75                {
76                    return Some((indent, ch, false)); // false = unordered
77                }
78                break;
79            } else if ch.is_ascii_digit() {
80                // Check for ordered list (digit followed by . or ))
81                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)); // true = ordered
88                        }
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        // Find the depth based on indentation level
105        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        // Remove items with greater or equal indentation
121        list_stack.retain(|&(stack_indent, _, _)| stack_indent < indent);
122
123        // Add current item
124        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(); // (indent, marker, is_ordered)
164
165        for (line_number, line) in lines.iter().enumerate() {
166            let line_number = line_number + 1;
167
168            // Skip empty lines
169            if line.trim().is_empty() {
170                continue;
171            }
172
173            // Check if this line is a list item
174            if let Some((indent, marker, is_ordered)) = self.parse_list_item(line) {
175                // Only check unordered lists and only if all ancestors are unordered
176                if !is_ordered && !self.has_ordered_ancestors(&list_stack) {
177                    // Calculate expected indentation
178                    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, // Convert to 1-based column
188                            Severity::Warning,
189                        ));
190                    }
191                }
192
193                // Update the list stack
194                self.update_list_stack(&mut list_stack, indent, marker, is_ordered);
195            } else {
196                // Non-list line resets the stack
197                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); // Mixed list types are ignored
299    }
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}