Skip to main content

mdlint/lint/rules/
md007.rs

1use crate::lint::rule::Rule;
2use crate::markdown::MarkdownParser;
3use crate::types::Violation;
4use serde_json::Value;
5
6pub struct MD007;
7
8impl Rule for MD007 {
9    fn name(&self) -> &'static str {
10        "MD007"
11    }
12
13    fn description(&self) -> &'static str {
14        "Unordered list indentation"
15    }
16
17    fn tags(&self) -> &[&str] {
18        &["bullet", "ul", "indentation"]
19    }
20
21    #[allow(clippy::cast_possible_truncation)] // serde_json gives u64; values are small config counts
22    fn check(&self, parser: &MarkdownParser, config: Option<&Value>) -> Vec<Violation> {
23        let indent_size = config
24            .and_then(|c| c.get("indent"))
25            .and_then(serde_json::Value::as_u64)
26            .unwrap_or(2) as usize;
27
28        let mut violations = Vec::new();
29        let mut list_depth = 0;
30        let mut prev_indent = 0;
31        let code_block_lines = parser.get_code_block_line_numbers();
32
33        for (line_num, line) in parser.lines().iter().enumerate() {
34            let line_number = line_num + 1;
35
36            if code_block_lines.contains(&line_number) {
37                continue;
38            }
39
40            let trimmed = line.trim_start();
41
42            // Check if this is an unordered list item
43            let is_ul_item =
44                trimmed.starts_with("* ") || trimmed.starts_with("+ ") || trimmed.starts_with("- ");
45
46            if !is_ul_item {
47                if !line.trim().is_empty() && !trimmed.starts_with("  ") {
48                    // Reset depth when we leave the list
49                    list_depth = 0;
50                    prev_indent = 0;
51                }
52                continue;
53            }
54
55            // Calculate indentation
56            let indent = line.len() - trimmed.len();
57
58            // Determine expected indentation based on depth
59            if indent > prev_indent {
60                // Going deeper
61                list_depth += 1;
62            } else if indent < prev_indent {
63                // Going shallower
64                list_depth = indent / indent_size;
65            }
66
67            let expected_indent = list_depth * indent_size;
68
69            if indent != expected_indent {
70                violations.push(Violation {
71                    line: line_number,
72                    column: Some(1),
73                    rule: self.name().to_owned(),
74                    message: format!(
75                        "Unordered list indentation should be {expected_indent} spaces (found {indent})"
76                    ),
77                    fix: None,
78                });
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_correct_indentation() {
98        let content = "* Item 1\n  * Nested 1\n    * Double nested\n  * Nested 2\n* Item 2";
99        let parser = MarkdownParser::new(content);
100        let rule = MD007;
101        let violations = rule.check(&parser, None);
102
103        assert_eq!(violations.len(), 0);
104    }
105
106    #[test]
107    fn test_incorrect_indentation() {
108        let content = "* Item 1\n   * Nested wrong - 3 spaces instead of 2";
109        let parser = MarkdownParser::new(content);
110        let rule = MD007;
111        let violations = rule.check(&parser, None);
112
113        assert!(!violations.is_empty());
114    }
115
116    #[test]
117    fn test_custom_indent_size() {
118        let content = "* Item 1\n    * Nested with 4 spaces";
119        let parser = MarkdownParser::new(content);
120        let rule = MD007;
121        let config = serde_json::json!({ "indent": 4 });
122        let violations = rule.check(&parser, Some(&config));
123
124        assert_eq!(violations.len(), 0);
125    }
126
127    #[test]
128    fn test_multiple_levels() {
129        let content = "* Level 1\n  * Level 2\n    * Level 3\n      * Level 4";
130        let parser = MarkdownParser::new(content);
131        let rule = MD007;
132        let violations = rule.check(&parser, None);
133
134        assert_eq!(violations.len(), 0);
135    }
136}