mdbook_lint_core/rules/standard/
md006.rs

1use crate::Document;
2use crate::error::Result;
3use crate::rule::{Rule, RuleCategory, RuleMetadata};
4use crate::violation::{Severity, Violation};
5
6/// MD006 - Consider starting bulleted lists at the beginning of the line
7pub struct MD006;
8
9impl Rule for MD006 {
10    fn id(&self) -> &'static str {
11        "MD006"
12    }
13
14    fn name(&self) -> &'static str {
15        "ul-start-left"
16    }
17
18    fn description(&self) -> &'static str {
19        "Consider starting bulleted lists at the beginning of the line"
20    }
21
22    fn metadata(&self) -> RuleMetadata {
23        RuleMetadata::stable(RuleCategory::Formatting)
24    }
25
26    fn check_with_ast<'a>(
27        &self,
28        document: &Document,
29        _ast: Option<&'a comrak::nodes::AstNode<'a>>,
30    ) -> Result<Vec<Violation>> {
31        let mut violations = Vec::new();
32        let lines: Vec<&str> = document.content.lines().collect();
33        let in_code_block = self.get_code_block_ranges(&lines);
34
35        for (line_number, line) in lines.iter().enumerate() {
36            let line_number = line_number + 1;
37
38            // Skip empty lines
39            if line.trim().is_empty() {
40                continue;
41            }
42
43            // Skip lines inside code blocks
44            if in_code_block[line_number - 1] {
45                continue;
46            }
47
48            // Check for unordered list markers (*, +, -) that are indented
49            if let Some(first_char_pos) = line.find(|c: char| !c.is_whitespace())
50                && first_char_pos > 0
51            {
52                let remaining = &line[first_char_pos..];
53
54                // Check if this is a list item (starts with *, +, or - followed by space)
55                if let Some(first_char) = remaining.chars().next()
56                    && matches!(first_char, '*' | '+' | '-')
57                    && remaining.len() > 1
58                {
59                    let second_char = remaining.chars().nth(1).unwrap();
60                    if second_char.is_whitespace() {
61                        // This is an indented unordered list item
62                        violations.push(
63                            self.create_violation(
64                                "Consider starting bulleted lists at the beginning of the line"
65                                    .to_string(),
66                                line_number,
67                                1,
68                                Severity::Warning,
69                            ),
70                        );
71                    }
72                }
73            }
74        }
75
76        Ok(violations)
77    }
78}
79
80impl MD006 {
81    /// Get code block ranges to exclude from checking
82    fn get_code_block_ranges(&self, lines: &[&str]) -> Vec<bool> {
83        let mut in_code_block = vec![false; lines.len()];
84        let mut in_fenced_block = false;
85        let mut in_indented_block = false;
86
87        for (i, line) in lines.iter().enumerate() {
88            let trimmed = line.trim();
89
90            // Check for fenced code blocks
91            if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
92                in_fenced_block = !in_fenced_block;
93                in_code_block[i] = true;
94                continue;
95            }
96
97            if in_fenced_block {
98                in_code_block[i] = true;
99                continue;
100            }
101
102            // Check for indented code blocks (4+ spaces at start of line)
103            if !line.trim().is_empty() && line.starts_with("    ") {
104                in_indented_block = true;
105                in_code_block[i] = true;
106            } else if !line.trim().is_empty() {
107                in_indented_block = false;
108            } else if in_indented_block {
109                // Empty lines continue indented code blocks
110                in_code_block[i] = true;
111            }
112        }
113
114        in_code_block
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121    use crate::Document;
122    use std::path::PathBuf;
123
124    #[test]
125    fn test_md006_no_violations() {
126        let content = r#"# Heading
127
128* Item 1
129* Item 2
130* Item 3
131
132Some text
133
134+ Item A
135+ Item B
136
137More text
138
139- Item X
140- Item Y
141"#;
142
143        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
144        let rule = MD006;
145        let violations = rule.check(&document).unwrap();
146        assert_eq!(violations.len(), 0);
147    }
148
149    #[test]
150    fn test_md006_indented_list() {
151        let content = r#"# Heading
152
153Some text
154 * Indented item 1
155 * Indented item 2
156
157More text
158"#;
159
160        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
161        let rule = MD006;
162        let violations = rule.check(&document).unwrap();
163        assert_eq!(violations.len(), 2);
164        assert_eq!(violations[0].line, 4);
165        assert_eq!(violations[1].line, 5);
166        assert!(
167            violations[0]
168                .message
169                .contains("Consider starting bulleted lists")
170        );
171    }
172
173    #[test]
174    fn test_md006_mixed_indentation() {
175        let content = r#"* Good item
176 * Bad item
177* Good item
178  + Another bad item
179- Good item
180"#;
181
182        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
183        let rule = MD006;
184        let violations = rule.check(&document).unwrap();
185        assert_eq!(violations.len(), 2);
186        assert_eq!(violations[0].line, 2);
187        assert_eq!(violations[1].line, 4);
188    }
189
190    #[test]
191    fn test_md006_nested_lists_valid() {
192        let content = r#"* Item 1
193  * Nested item (this triggers the rule - it's indented)
194  * Another nested item
195* Item 2
196"#;
197
198        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
199        let rule = MD006;
200        let violations = rule.check(&document).unwrap();
201        assert_eq!(violations.len(), 2); // The nested items are indented
202        assert_eq!(violations[0].line, 2);
203        assert_eq!(violations[1].line, 3);
204    }
205
206    #[test]
207    fn test_md006_code_blocks_ignored() {
208        let content = r#"# Heading
209
210```
211 * This is in a code block
212 * Should not trigger the rule
213```
214
215 * But this should trigger it
216"#;
217
218        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
219        let rule = MD006;
220        let violations = rule.check(&document).unwrap();
221        assert_eq!(violations.len(), 1);
222        assert_eq!(violations[0].line, 8);
223    }
224
225    #[test]
226    fn test_md006_blockquotes_ignored() {
227        let content = r#"# Heading
228
229> * This is in a blockquote
230> * Should not trigger the rule
231
232 * But this should trigger it
233"#;
234
235        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
236        let rule = MD006;
237        let violations = rule.check(&document).unwrap();
238        assert_eq!(violations.len(), 1);
239        assert_eq!(violations[0].line, 6);
240    }
241
242    #[test]
243    fn test_md006_different_markers() {
244        let content = r#" * Asterisk indented
245 + Plus indented
246 - Dash indented
247"#;
248
249        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
250        let rule = MD006;
251        let violations = rule.check(&document).unwrap();
252        assert_eq!(violations.len(), 3);
253        assert_eq!(violations[0].line, 1);
254        assert_eq!(violations[1].line, 2);
255        assert_eq!(violations[2].line, 3);
256    }
257
258    #[test]
259    fn test_md006_not_list_markers() {
260        let content = r#" * Not followed by space
261 *Not followed by space
262 - Not followed by space
263 -Not followed by space
264"#;
265
266        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
267        let rule = MD006;
268        let violations = rule.check(&document).unwrap();
269        // First and third lines have space after marker, so they trigger the rule
270        assert_eq!(violations.len(), 2);
271        assert_eq!(violations[0].line, 1);
272        assert_eq!(violations[1].line, 3);
273    }
274
275    #[test]
276    fn test_md006_tab_indentation() {
277        let content = "\t* Tab indented item\n\t+ Another tab indented";
278
279        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
280        let rule = MD006;
281        let violations = rule.check(&document).unwrap();
282        assert_eq!(violations.len(), 2);
283        assert_eq!(violations[0].line, 1);
284        assert_eq!(violations[1].line, 2);
285    }
286}