mdlint/lint/rules/
md004.rs1use 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, Plus, Dash, }
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 if code_block_lines.contains(&line_number) {
43 continue;
44 }
45
46 let trimmed = line.trim_start();
47
48 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); }
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); }
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); }
187
188 #[test]
189 fn test_list_markers_in_code_block_not_flagged() {
190 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 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 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 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 assert_eq!(violations.len(), 2);
270 assert_eq!(violations[0].line, 1); assert_eq!(violations[1].line, 8); }
273}