mdbook_lint_core/rules/standard/
md004.rs1use crate::error::Result;
6use crate::rule::{AstRule, RuleCategory, RuleMetadata};
7use crate::{
8 Document,
9 violation::{Severity, Violation},
10};
11use comrak::nodes::{AstNode, NodeValue};
12
13#[derive(Debug, Clone, Copy, PartialEq)]
15pub enum ListStyle {
16 Asterisk, Plus, Dash, }
20
21impl ListStyle {
22 fn from_char(c: char) -> Option<Self> {
23 match c {
24 '*' => Some(ListStyle::Asterisk),
25 '+' => Some(ListStyle::Plus),
26 '-' => Some(ListStyle::Dash),
27 _ => None,
28 }
29 }
30
31 fn to_char(self) -> char {
32 match self {
33 ListStyle::Asterisk => '*',
34 ListStyle::Plus => '+',
35 ListStyle::Dash => '-',
36 }
37 }
38}
39
40#[derive(Debug, Clone, Copy, PartialEq)]
42pub enum ListStyleConfig {
43 Consistent, #[allow(dead_code)]
45 Asterisk, #[allow(dead_code)]
47 Plus, #[allow(dead_code)]
49 Dash, }
51
52pub struct MD004 {
54 style: ListStyleConfig,
56}
57
58impl MD004 {
59 pub fn new() -> Self {
61 Self {
62 style: ListStyleConfig::Consistent,
63 }
64 }
65
66 #[allow(dead_code)]
68 pub fn with_style(style: ListStyleConfig) -> Self {
69 Self { style }
70 }
71}
72
73impl Default for MD004 {
74 fn default() -> Self {
75 Self::new()
76 }
77}
78
79impl AstRule for MD004 {
80 fn id(&self) -> &'static str {
81 "MD004"
82 }
83
84 fn name(&self) -> &'static str {
85 "ul-style"
86 }
87
88 fn description(&self) -> &'static str {
89 "Unordered list style"
90 }
91
92 fn metadata(&self) -> RuleMetadata {
93 RuleMetadata::stable(RuleCategory::Formatting).introduced_in("mdbook-lint v0.1.0")
94 }
95
96 fn check_ast<'a>(&self, document: &Document, ast: &'a AstNode<'a>) -> Result<Vec<Violation>> {
97 let mut violations = Vec::new();
98 let mut expected_style: Option<ListStyle> = None;
99
100 if let Some(configured_style) = self.get_configured_style() {
102 expected_style = Some(configured_style);
103 }
104
105 for node in ast.descendants() {
107 if let NodeValue::List(list_info) = &node.data.borrow().value {
108 if list_info.list_type == comrak::nodes::ListType::Bullet {
110 for child in node.children() {
112 if let NodeValue::Item(_) = &child.data.borrow().value
113 && let Some((line, column)) = document.node_position(child)
114 && let Some(detected_style) =
115 self.detect_list_marker_style(document, line)
116 {
117 if let Some(expected) = expected_style {
118 if detected_style != expected {
120 violations.push(self.create_violation(
121 format!(
122 "Inconsistent list style: expected '{}' but found '{}'",
123 expected.to_char(),
124 detected_style.to_char()
125 ),
126 line,
127 column,
128 Severity::Warning,
129 ));
130 }
131 } else {
132 expected_style = Some(detected_style);
134 }
135 }
136 }
137 }
138 }
139 }
140
141 Ok(violations)
142 }
143}
144
145impl MD004 {
146 fn get_configured_style(&self) -> Option<ListStyle> {
148 match self.style {
149 ListStyleConfig::Consistent => None,
150 ListStyleConfig::Asterisk => Some(ListStyle::Asterisk),
151 ListStyleConfig::Plus => Some(ListStyle::Plus),
152 ListStyleConfig::Dash => Some(ListStyle::Dash),
153 }
154 }
155
156 fn detect_list_marker_style(
158 &self,
159 document: &Document,
160 line_number: usize,
161 ) -> Option<ListStyle> {
162 if line_number == 0 || line_number > document.lines.len() {
163 return None;
164 }
165
166 let line = &document.lines[line_number - 1]; for ch in line.chars() {
170 if let Some(style) = ListStyle::from_char(ch) {
171 return Some(style);
172 }
173 if !ch.is_whitespace() {
175 break;
176 }
177 }
178
179 None
180 }
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186 use crate::Document;
187 use crate::rule::Rule;
188 use std::path::PathBuf;
189
190 #[test]
191 fn test_md004_consistent_asterisk_style() {
192 let content = r#"# List Test
193
194* Item 1
195* Item 2
196* Item 3
197
198Some text.
199
200* Another list
201* More items
202"#;
203 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
204 let rule = MD004::new();
205 let violations = rule.check(&document).unwrap();
206
207 assert_eq!(violations.len(), 0);
208 }
209
210 #[test]
211 fn test_md004_inconsistent_styles_violation() {
212 let content = r#"# List Test
213
214* Item 1
215+ Item 2
216- Item 3
217"#;
218 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
219 let rule = MD004::new();
220 let violations = rule.check(&document).unwrap();
221
222 assert_eq!(violations.len(), 2);
223 assert!(violations[0].message.contains("Inconsistent list style"));
224 assert!(violations[0].message.contains("expected '*' but found '+'"));
225 assert!(violations[1].message.contains("expected '*' but found '-'"));
226 assert_eq!(violations[0].line, 4);
227 assert_eq!(violations[1].line, 5);
228 }
229
230 #[test]
231 fn test_md004_multiple_lists_consistent() {
232 let content = r#"# Multiple Lists
233
234First list:
235- Item 1
236- Item 2
237
238Second list:
239- Item 3
240- Item 4
241
242Third list:
243- Item 5
244- Item 6
245"#;
246 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
247 let rule = MD004::new();
248 let violations = rule.check(&document).unwrap();
249
250 assert_eq!(violations.len(), 0);
251 }
252
253 #[test]
254 fn test_md004_multiple_lists_inconsistent() {
255 let content = r#"# Multiple Lists
256
257First list:
258* Item 1
259* Item 2
260
261Second list:
262+ Item 3
263+ Item 4
264
265Third list:
266- Item 5
267- Item 6
268"#;
269 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
270 let rule = MD004::new();
271 let violations = rule.check(&document).unwrap();
272
273 assert_eq!(violations.len(), 4);
274 assert_eq!(violations[0].line, 8); assert_eq!(violations[1].line, 9); assert_eq!(violations[2].line, 12); assert_eq!(violations[3].line, 13); }
280
281 #[test]
282 fn test_md004_configured_asterisk_style() {
283 let content = r#"# List Test
284
285+ Item 1
286+ Item 2
287* Item 3
288"#;
289 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
290 let rule = MD004::with_style(ListStyleConfig::Asterisk);
291 let violations = rule.check(&document).unwrap();
292
293 assert_eq!(violations.len(), 2);
294 assert!(violations[0].message.contains("expected '*' but found '+'"));
295 assert!(violations[1].message.contains("expected '*' but found '+'"));
296 assert_eq!(violations[0].line, 3);
297 assert_eq!(violations[1].line, 4);
298 }
299
300 #[test]
301 fn test_md004_configured_plus_style() {
302 let content = r#"# List Test
303
304* Item 1
305+ Item 2
306- Item 3
307"#;
308 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
309 let rule = MD004::with_style(ListStyleConfig::Plus);
310 let violations = rule.check(&document).unwrap();
311
312 assert_eq!(violations.len(), 2);
313 assert!(violations[0].message.contains("expected '+' but found '*'"));
314 assert!(violations[1].message.contains("expected '+' but found '-'"));
315 assert_eq!(violations[0].line, 3);
316 assert_eq!(violations[1].line, 5);
317 }
318
319 #[test]
320 fn test_md004_configured_dash_style() {
321 let content = r#"# List Test
322
323* Item 1
324+ Item 2
325- Item 3
326"#;
327 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
328 let rule = MD004::with_style(ListStyleConfig::Dash);
329 let violations = rule.check(&document).unwrap();
330
331 assert_eq!(violations.len(), 2);
332 assert!(violations[0].message.contains("expected '-' but found '*'"));
333 assert!(violations[1].message.contains("expected '-' but found '+'"));
334 assert_eq!(violations[0].line, 3);
335 assert_eq!(violations[1].line, 4);
336 }
337
338 #[test]
339 fn test_md004_nested_lists() {
340 let content = r#"# Nested Lists
341
342* Top level item
343 + Nested item (different style should be violation)
344 + Another nested item
345* Another top level item
346"#;
347 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
348 let rule = MD004::new();
349 let violations = rule.check(&document).unwrap();
350
351 assert_eq!(violations.len(), 2);
353 assert_eq!(violations[0].line, 4);
354 assert_eq!(violations[1].line, 5);
355 }
356
357 #[test]
358 fn test_md004_ordered_lists_ignored() {
359 let content = r#"# Mixed Lists
360
3611. Ordered item 1
3622. Ordered item 2
363
364* Unordered item 1
365* Unordered item 2
366
3673. More ordered items
3684. Should be ignored
369"#;
370 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
371 let rule = MD004::new();
372 let violations = rule.check(&document).unwrap();
373
374 assert_eq!(violations.len(), 0);
376 }
377
378 #[test]
379 fn test_md004_indented_lists() {
380 let content = r#"# Indented Lists
381
382Some paragraph with indented list:
383
384 * Indented item 1
385 * Indented item 2
386 + Different style (should be violation)
387
388Regular list:
389* Regular item
390"#;
391 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
392 let rule = MD004::new();
393 let violations = rule.check(&document).unwrap();
394
395 assert_eq!(violations.len(), 1);
396 assert_eq!(violations[0].line, 7);
397 assert!(violations[0].message.contains("expected '*' but found '+'"));
398 }
399
400 #[test]
401 fn test_md004_empty_document() {
402 let content = "";
403 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
404 let rule = MD004::new();
405 let violations = rule.check(&document).unwrap();
406
407 assert_eq!(violations.len(), 0);
408 }
409
410 #[test]
411 fn test_md004_no_lists() {
412 let content = r#"# Document Without Lists
413
414This document has no lists, so there should be no violations.
415
416Just paragraphs and headings.
417
418## Another Section
419
420More text without any lists.
421"#;
422 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
423 let rule = MD004::new();
424 let violations = rule.check(&document).unwrap();
425
426 assert_eq!(violations.len(), 0);
427 }
428}