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) -> &str {
10        "MD007"
11    }
12
13    fn description(&self) -> &str {
14        "Unordered list indentation"
15    }
16
17    fn tags(&self) -> &[&str] {
18        &["bullet", "ul", "indentation"]
19    }
20
21    fn check(&self, parser: &MarkdownParser, config: Option<&Value>) -> Vec<Violation> {
22        let indent_size = config
23            .and_then(|c| c.get("indent"))
24            .and_then(|v| v.as_u64())
25            .unwrap_or(2) as usize;
26
27        let mut violations = Vec::new();
28        let mut list_depth = 0;
29        let mut prev_indent = 0;
30        let code_block_lines = parser.get_code_block_line_numbers();
31
32        for (line_num, line) in parser.lines().iter().enumerate() {
33            let line_number = line_num + 1;
34
35            if code_block_lines.contains(&line_number) {
36                continue;
37            }
38
39            let trimmed = line.trim_start();
40
41            // Check if this is an unordered list item
42            let is_ul_item =
43                trimmed.starts_with("* ") || trimmed.starts_with("+ ") || trimmed.starts_with("- ");
44
45            if !is_ul_item {
46                if !line.trim().is_empty() && !trimmed.starts_with("  ") {
47                    // Reset depth when we leave the list
48                    list_depth = 0;
49                    prev_indent = 0;
50                }
51                continue;
52            }
53
54            // Calculate indentation
55            let indent = line.len() - trimmed.len();
56
57            // Determine expected indentation based on depth
58            if indent > prev_indent {
59                // Going deeper
60                list_depth += 1;
61            } else if indent < prev_indent {
62                // Going shallower
63                list_depth = indent / indent_size;
64            }
65
66            let expected_indent = list_depth * indent_size;
67
68            if indent != expected_indent {
69                violations.push(Violation {
70                    line: line_number,
71                    column: Some(1),
72                    rule: self.name().to_string(),
73                    message: format!(
74                        "Unordered list indentation should be {} spaces (found {})",
75                        expected_indent, 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}