mdbook_lint_core/rules/standard/
md006.rs1use crate::Document;
2use crate::error::Result;
3use crate::rule::{Rule, RuleCategory, RuleMetadata};
4use crate::violation::{Severity, Violation};
5
6pub 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 if line.trim().is_empty() {
40 continue;
41 }
42
43 if in_code_block[line_number - 1] {
45 continue;
46 }
47
48 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 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 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 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 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 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 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); 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 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}