mdbook_lint_core/rules/standard/
md004.rs

1//! MD004: Unordered list style consistency
2//!
3//! This rule checks that unordered list styles are consistent throughout the document.
4
5use 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/// List marker styles for unordered lists
14#[derive(Debug, Clone, Copy, PartialEq)]
15pub enum ListStyle {
16    Asterisk, // *
17    Plus,     // +
18    Dash,     // -
19}
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/// Configuration for list style checking
41#[derive(Debug, Clone, Copy, PartialEq)]
42pub enum ListStyleConfig {
43    Consistent, // Use the first style found
44    #[allow(dead_code)]
45    Asterisk, // Enforce asterisk style
46    #[allow(dead_code)]
47    Plus, // Enforce plus style
48    #[allow(dead_code)]
49    Dash, // Enforce dash style
50}
51
52/// Rule to check unordered list style consistency
53pub struct MD004 {
54    /// The list style configuration
55    style: ListStyleConfig,
56}
57
58impl MD004 {
59    /// Create a new MD004 rule with consistent style (default)
60    pub fn new() -> Self {
61        Self {
62            style: ListStyleConfig::Consistent,
63        }
64    }
65
66    /// Create a new MD004 rule with a specific style
67    #[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 we have a configured style, use it immediately
101        if let Some(configured_style) = self.get_configured_style() {
102            expected_style = Some(configured_style);
103        }
104
105        // Find all unordered list items
106        for node in ast.descendants() {
107            if let NodeValue::List(list_info) = &node.data.borrow().value {
108                // Only check unordered lists
109                if list_info.list_type == comrak::nodes::ListType::Bullet {
110                    // Check each list item in this list
111                    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                                // We have an expected style, check if it matches
119                                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                                // First list found, set the expected style
133                                expected_style = Some(detected_style);
134                            }
135                        }
136                    }
137                }
138            }
139        }
140
141        Ok(violations)
142    }
143}
144
145impl MD004 {
146    /// Get the configured style if one is set
147    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    /// Detect the list marker style from the source line
157    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]; // Convert to 0-based index
167
168        // Find the first list marker character
169        for ch in line.chars() {
170            if let Some(style) = ListStyle::from_char(ch) {
171                return Some(style);
172            }
173            // Stop if we hit non-whitespace that isn't a list marker
174            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        // Should detect all items in second and third lists as violations
275        assert_eq!(violations[0].line, 8); // First + item
276        assert_eq!(violations[1].line, 9); // Second + item
277        assert_eq!(violations[2].line, 12); // First - item
278        assert_eq!(violations[3].line, 13); // Second - item
279    }
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        // Should detect violations for the nested items
352        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        // Should only check unordered lists, ignore ordered lists
375        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}