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) -> &'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 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_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); }
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); }
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); }
186
187 #[test]
188 fn test_list_markers_in_code_block_not_flagged() {
189 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 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 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 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 assert_eq!(violations.len(), 2);
269 assert_eq!(violations[0].line, 1); assert_eq!(violations[1].line, 8); }
272}