Skip to main content

mdlint/lint/rules/
md004.rs

1use crate::lint::rule::Rule;
2use crate::markdown::MarkdownParser;
3use crate::types::{Fix, Violation};
4use serde_json::Value;
5
6pub struct MD004;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9enum ListMarker {
10    Asterisk, // *
11    Plus,     // +
12    Dash,     // -
13}
14
15impl Rule for MD004 {
16    fn name(&self) -> &'static str {
17        "MD004"
18    }
19
20    fn description(&self) -> &'static str {
21        "Unordered list style should be consistent"
22    }
23
24    fn tags(&self) -> &[&str] {
25        &["bullet", "ul"]
26    }
27
28    fn check(&self, parser: &MarkdownParser, config: Option<&Value>) -> Vec<Violation> {
29        let style = config
30            .and_then(|c| c.get("style"))
31            .and_then(|v| v.as_str())
32            .unwrap_or("dash");
33
34        let mut violations = Vec::new();
35        let mut first_marker: Option<ListMarker> = None;
36        let code_block_lines = parser.get_code_block_line_numbers();
37
38        for (line_num, line) in parser.lines().iter().enumerate() {
39            let line_number = line_num + 1;
40
41            // Skip code blocks
42            if code_block_lines.contains(&line_number) {
43                continue;
44            }
45
46            let trimmed = line.trim_start();
47
48            // Detect unordered list marker
49            let marker = if trimmed.starts_with("* ") {
50                Some(ListMarker::Asterisk)
51            } else if trimmed.starts_with("+ ") {
52                Some(ListMarker::Plus)
53            } else if trimmed.starts_with("- ") {
54                Some(ListMarker::Dash)
55            } else {
56                None
57            };
58
59            if let Some(current_marker) = marker {
60                if style == "consistent" {
61                    if let Some(first) = first_marker {
62                        if current_marker != first {
63                            let indent_len = line.len() - trimmed.len();
64                            let replacement = format!("{}- {}", &line[..indent_len], &trimmed[2..]);
65                            violations.push(Violation {
66                                line: line_number,
67                                column: Some(indent_len + 1),
68                                rule: self.name().to_owned(),
69                                message: format!(
70                                    "List marker style should be consistent (expected {first:?}, found {current_marker:?})"
71                                ),
72                                fix: Some(Fix {
73                                    line_start: line_number,
74                                    line_end: line_number,
75                                    column_start: None,
76                                    column_end: None,
77                                    replacement,
78                                    description: "Replace list marker with dash".to_owned(),
79                                }),
80                            });
81                        }
82                    } else {
83                        first_marker = Some(current_marker);
84                    }
85                } else {
86                    let required_marker = match style {
87                        "asterisk" => ListMarker::Asterisk,
88                        "plus" => ListMarker::Plus,
89                        "dash" => ListMarker::Dash,
90                        _ => continue,
91                    };
92
93                    if current_marker != required_marker {
94                        let indent_len = line.len() - trimmed.len();
95                        let replacement = format!(
96                            "{}{} {}",
97                            &line[..indent_len],
98                            match required_marker {
99                                ListMarker::Asterisk => "*",
100                                ListMarker::Plus => "+",
101                                ListMarker::Dash => "-",
102                            },
103                            &trimmed[2..]
104                        );
105                        violations.push(Violation {
106                            line: line_number,
107                            column: Some(indent_len + 1),
108                            rule: self.name().to_owned(),
109                            message: format!("List marker style should be {required_marker:?}"),
110                            fix: Some(Fix {
111                                line_start: line_number,
112                                line_end: line_number,
113                                column_start: None,
114                                column_end: None,
115                                replacement,
116                                description: "Replace list marker with required style".to_owned(),
117                            }),
118                        });
119                    }
120                }
121            }
122        }
123
124        violations
125    }
126
127    fn fixable(&self) -> bool {
128        true
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135    use crate::fix::Fixer;
136
137    fn apply_fixes(content: &str, violations: &[Violation]) -> String {
138        let fixes: Vec<_> = violations.iter().filter_map(|v| v.fix.clone()).collect();
139        Fixer::new()
140            .apply_fixes_to_content(content, &fixes)
141            .unwrap()
142    }
143
144    #[test]
145    fn test_consistent_asterisk() {
146        let content = "* Item 1\n* Item 2\n* Item 3";
147        let parser = MarkdownParser::new(content);
148        let rule = MD004;
149        let config = serde_json::json!({ "style": "consistent" });
150        let violations = rule.check(&parser, Some(&config));
151
152        assert_eq!(violations.len(), 0);
153    }
154
155    #[test]
156    fn test_inconsistent_markers() {
157        let content = "* Item 1\n+ Item 2\n- Item 3";
158        let parser = MarkdownParser::new(content);
159        let rule = MD004;
160        let violations = rule.check(&parser, None);
161
162        assert_eq!(violations.len(), 2); // Second and third items differ from first
163    }
164
165    #[test]
166    fn test_enforced_dash_style() {
167        let content = "* Item 1\n- Item 2";
168        let parser = MarkdownParser::new(content);
169        let rule = MD004;
170        let config = serde_json::json!({ "style": "dash" });
171        let violations = rule.check(&parser, Some(&config));
172
173        assert_eq!(violations.len(), 1); // First item uses asterisk
174    }
175
176    #[test]
177    fn test_nested_lists() {
178        let content = "* Item 1\n  * Nested 1\n  * Nested 2\n* Item 2";
179        let parser = MarkdownParser::new(content);
180        let rule = MD004;
181        let config = serde_json::json!({ "style": "consistent" });
182        let violations = rule.check(&parser, Some(&config));
183
184        assert_eq!(violations.len(), 0); // All use asterisk
185    }
186
187    #[test]
188    fn test_list_markers_in_code_block_not_flagged() {
189        // List markers inside fenced code blocks must not be checked.
190        let content = "```\n* asterisk\n+ plus\n- dash\n```\n\n- real item\n";
191        let parser = MarkdownParser::new(content);
192        let rule = MD004;
193        let config = serde_json::json!({ "style": "dash" });
194        let violations = rule.check(&parser, Some(&config));
195
196        // Only the real list item on the last line matters; the code block is ignored.
197        assert_eq!(violations.len(), 0);
198    }
199
200    #[test]
201    fn test_markdown_syntax_in_code_block() {
202        let content = "# My Document
203
204Here's a code block with markdown syntax:
205
206```
207- This looks like a list item
208* This also looks like a list item
209+ And this one too
210```
211
212- Real list item
213";
214        let parser = MarkdownParser::new(content);
215        let rule = MD004;
216        let violations = rule.check(&parser, None);
217
218        // Should not flag list markers inside code blocks
219        assert_eq!(violations.len(), 0);
220    }
221
222    #[test]
223    fn test_indented_code_block() {
224        let content = "Regular text
225
226    - This is an indented code block
227    * Not a real list
228    + Just code
229
230- Real list item
231";
232        let parser = MarkdownParser::new(content);
233        let rule = MD004;
234        let violations = rule.check(&parser, None);
235
236        // Should not flag list markers in indented code blocks
237        assert_eq!(violations.len(), 0);
238    }
239
240    #[test]
241    fn test_fix_normalises_marker_to_dash() {
242        let content = "* Item 1\n* Item 2\n";
243        let parser = MarkdownParser::new(content);
244        let rule = MD004;
245        let config = serde_json::json!({ "style": "dash" });
246        let violations = rule.check(&parser, Some(&config));
247        assert_eq!(violations.len(), 2);
248        let fixed = apply_fixes(content, &violations);
249        assert_eq!(fixed, "- Item 1\n- Item 2\n");
250    }
251
252    #[test]
253    fn test_dash_in_code_block_with_real_list() {
254        let content = "* List item 1
255
256```python
257# Comment with -- dashes
258value = 10 - 5  # subtraction
259```
260
261+ List item 2
262";
263        let parser = MarkdownParser::new(content);
264        let rule = MD004;
265        let violations = rule.check(&parser, None);
266
267        // Both non-dash markers violate the default "dash" style
268        assert_eq!(violations.len(), 2);
269        assert_eq!(violations[0].line, 1); // Line with "* List item 1"
270        assert_eq!(violations[1].line, 8); // Line with "+ List item 2"
271    }
272}