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) -> &str {
17        "MD004"
18    }
19
20    fn description(&self) -> &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_string(),
69                                message: format!(
70                                    "List marker style should be consistent (expected {:?}, found {:?})",
71                                    first, current_marker
72                                ),
73                                fix: Some(Fix {
74                                    line_start: line_number,
75                                    line_end: line_number,
76                                    column_start: None,
77                                    column_end: None,
78                                    replacement,
79                                    description: "Replace list marker with dash".to_string(),
80                                }),
81                            });
82                        }
83                    } else {
84                        first_marker = Some(current_marker);
85                    }
86                } else {
87                    let required_marker = match style {
88                        "asterisk" => ListMarker::Asterisk,
89                        "plus" => ListMarker::Plus,
90                        "dash" => ListMarker::Dash,
91                        _ => continue,
92                    };
93
94                    if current_marker != required_marker {
95                        let indent_len = line.len() - trimmed.len();
96                        let replacement = format!(
97                            "{}{} {}",
98                            &line[..indent_len],
99                            match required_marker {
100                                ListMarker::Asterisk => "*",
101                                ListMarker::Plus => "+",
102                                ListMarker::Dash => "-",
103                            },
104                            &trimmed[2..]
105                        );
106                        violations.push(Violation {
107                            line: line_number,
108                            column: Some(indent_len + 1),
109                            rule: self.name().to_string(),
110                            message: format!("List marker style should be {:?}", required_marker),
111                            fix: Some(Fix {
112                                line_start: line_number,
113                                line_end: line_number,
114                                column_start: None,
115                                column_end: None,
116                                replacement,
117                                description: "Replace list marker with required style".to_string(),
118                            }),
119                        });
120                    }
121                }
122            }
123        }
124
125        violations
126    }
127
128    fn fixable(&self) -> bool {
129        true
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136    use crate::fix::Fixer;
137
138    fn apply_fixes(content: &str, violations: &[Violation]) -> String {
139        let fixes: Vec<_> = violations.iter().filter_map(|v| v.fix.clone()).collect();
140        Fixer::new()
141            .apply_fixes_to_content(content, &fixes)
142            .unwrap()
143    }
144
145    #[test]
146    fn test_consistent_asterisk() {
147        let content = "* Item 1\n* Item 2\n* Item 3";
148        let parser = MarkdownParser::new(content);
149        let rule = MD004;
150        let config = serde_json::json!({ "style": "consistent" });
151        let violations = rule.check(&parser, Some(&config));
152
153        assert_eq!(violations.len(), 0);
154    }
155
156    #[test]
157    fn test_inconsistent_markers() {
158        let content = "* Item 1\n+ Item 2\n- Item 3";
159        let parser = MarkdownParser::new(content);
160        let rule = MD004;
161        let violations = rule.check(&parser, None);
162
163        assert_eq!(violations.len(), 2); // Second and third items differ from first
164    }
165
166    #[test]
167    fn test_enforced_dash_style() {
168        let content = "* Item 1\n- Item 2";
169        let parser = MarkdownParser::new(content);
170        let rule = MD004;
171        let config = serde_json::json!({ "style": "dash" });
172        let violations = rule.check(&parser, Some(&config));
173
174        assert_eq!(violations.len(), 1); // First item uses asterisk
175    }
176
177    #[test]
178    fn test_nested_lists() {
179        let content = "* Item 1\n  * Nested 1\n  * Nested 2\n* Item 2";
180        let parser = MarkdownParser::new(content);
181        let rule = MD004;
182        let config = serde_json::json!({ "style": "consistent" });
183        let violations = rule.check(&parser, Some(&config));
184
185        assert_eq!(violations.len(), 0); // All use asterisk
186    }
187
188    #[test]
189    fn test_list_markers_in_code_block_not_flagged() {
190        // List markers inside fenced code blocks must not be checked.
191        let content = "```\n* asterisk\n+ plus\n- dash\n```\n\n- real item\n";
192        let parser = MarkdownParser::new(content);
193        let rule = MD004;
194        let config = serde_json::json!({ "style": "dash" });
195        let violations = rule.check(&parser, Some(&config));
196
197        // Only the real list item on the last line matters; the code block is ignored.
198        assert_eq!(violations.len(), 0);
199    }
200
201    #[test]
202    fn test_markdown_syntax_in_code_block() {
203        let content = r#"# My Document
204
205Here's a code block with markdown syntax:
206
207```
208- This looks like a list item
209* This also looks like a list item
210+ And this one too
211```
212
213- Real list item
214"#;
215        let parser = MarkdownParser::new(content);
216        let rule = MD004;
217        let violations = rule.check(&parser, None);
218
219        // Should not flag list markers inside code blocks
220        assert_eq!(violations.len(), 0);
221    }
222
223    #[test]
224    fn test_indented_code_block() {
225        let content = r#"Regular text
226
227    - This is an indented code block
228    * Not a real list
229    + Just code
230
231- Real list item
232"#;
233        let parser = MarkdownParser::new(content);
234        let rule = MD004;
235        let violations = rule.check(&parser, None);
236
237        // Should not flag list markers in indented code blocks
238        assert_eq!(violations.len(), 0);
239    }
240
241    #[test]
242    fn test_fix_normalises_marker_to_dash() {
243        let content = "* Item 1\n* Item 2\n";
244        let parser = MarkdownParser::new(content);
245        let rule = MD004;
246        let config = serde_json::json!({ "style": "dash" });
247        let violations = rule.check(&parser, Some(&config));
248        assert_eq!(violations.len(), 2);
249        let fixed = apply_fixes(content, &violations);
250        assert_eq!(fixed, "- Item 1\n- Item 2\n");
251    }
252
253    #[test]
254    fn test_dash_in_code_block_with_real_list() {
255        let content = r#"* List item 1
256
257```python
258# Comment with -- dashes
259value = 10 - 5  # subtraction
260```
261
262+ List item 2
263"#;
264        let parser = MarkdownParser::new(content);
265        let rule = MD004;
266        let violations = rule.check(&parser, None);
267
268        // Both non-dash markers violate the default "dash" style
269        assert_eq!(violations.len(), 2);
270        assert_eq!(violations[0].line, 1); // Line with "* List item 1"
271        assert_eq!(violations[1].line, 8); // Line with "+ List item 2"
272    }
273}