mdlint/lint/rules/
md030.rs1use crate::lint::rule::Rule;
2use crate::markdown::MarkdownParser;
3use crate::types::{Fix, Violation};
4use pulldown_cmark::{Event, Tag};
5use serde_json::Value;
6use std::collections::HashSet;
7
8pub struct MD030;
9
10impl Rule for MD030 {
11 fn name(&self) -> &str {
12 "MD030"
13 }
14
15 fn description(&self) -> &str {
16 "Spaces after list markers"
17 }
18
19 fn tags(&self) -> &[&str] {
20 &["ol", "ul", "whitespace"]
21 }
22
23 fn check(&self, parser: &MarkdownParser, config: Option<&Value>) -> Vec<Violation> {
24 let ul_single = config
25 .and_then(|c| c.get("ul_single"))
26 .and_then(|v| v.as_u64())
27 .unwrap_or(1) as usize;
28
29 let _ul_multi = config
30 .and_then(|c| c.get("ul_multi"))
31 .and_then(|v| v.as_u64())
32 .unwrap_or(1) as usize;
33
34 let ol_single = config
35 .and_then(|c| c.get("ol_single"))
36 .and_then(|v| v.as_u64())
37 .unwrap_or(1) as usize;
38
39 let _ol_multi = config
40 .and_then(|c| c.get("ol_multi"))
41 .and_then(|v| v.as_u64())
42 .unwrap_or(1) as usize;
43
44 let mut violations = Vec::new();
45
46 let code_lines = parser.get_code_block_line_numbers();
48
49 let mut emphasis_start_lines = HashSet::new();
51
52 let mut line_offsets = vec![0];
54 let mut current_offset = 0;
55 for line in parser.lines() {
56 current_offset += line.len() + 1; line_offsets.push(current_offset);
58 }
59
60 for (event, range) in parser.parse_with_offsets() {
61 if let Event::Start(Tag::Emphasis | Tag::Strong) = event {
62 let line_num = parser.offset_to_line(range.start);
63 if let Some(line) = parser.lines().get(line_num - 1) {
65 let trimmed_start = line.len() - line.trim_start().len();
66 if let Some(&line_start_offset) = line_offsets.get(line_num - 1)
68 && range.start == line_start_offset + trimmed_start
69 {
70 emphasis_start_lines.insert(line_num);
71 }
72 }
73 }
74 }
75
76 for (line_num, line) in parser.lines().iter().enumerate() {
78 let line_number = line_num + 1;
79
80 if code_lines.contains(&line_number) {
82 continue;
83 }
84
85 if emphasis_start_lines.contains(&line_number) {
87 continue;
88 }
89
90 let trimmed = line.trim_start();
91
92 if is_horizontal_rule(trimmed) {
94 continue;
95 }
96
97 if is_table_separator(trimmed) {
99 continue;
100 }
101
102 if trimmed.starts_with('*') || trimmed.starts_with('+') || trimmed.starts_with('-') {
104 let marker_char = trimmed.chars().next().unwrap();
105 let after_marker = &trimmed[1..];
106 let space_count = after_marker.chars().take_while(|&c| c == ' ').count();
107
108 if !after_marker.trim().is_empty() {
110 let expected = ul_single;
112
113 if space_count != expected {
114 let leading_spaces = &line[..line.len() - trimmed.len()];
116 let content = after_marker[space_count..].trim_start();
117 let spaces = " ".repeat(expected);
118 let replacement =
119 format!("{}{}{}{}", leading_spaces, marker_char, spaces, content);
120
121 violations.push(Violation {
122 line: line_number,
123 column: Some(line.len() - trimmed.len() + 2),
124 rule: self.name().to_string(),
125 message: format!(
126 "Expected {} space(s) after list marker, found {}",
127 expected, space_count
128 ),
129 fix: Some(Fix {
130 line_start: line_number,
131 line_end: line_number,
132 column_start: None,
133 column_end: None,
134 replacement,
135 description: format!("Adjust spacing to {} space(s)", expected),
136 }),
137 });
138 }
139 }
140 }
141
142 if let Some(dot_pos) = trimmed.find('.') {
144 let prefix = &trimmed[..dot_pos];
145 if prefix.chars().all(|c| c.is_ascii_digit()) && !prefix.is_empty() {
146 let after_dot = &trimmed[dot_pos + 1..];
147
148 if !after_dot.trim().is_empty() {
150 let space_count = after_dot.chars().take_while(|&c| c == ' ').count();
151
152 let expected = ol_single;
154
155 if space_count != expected {
156 let leading_spaces = &line[..line.len() - trimmed.len()];
158 let marker = &trimmed[..=dot_pos];
159 let content = after_dot[space_count..].trim_start();
160 let spaces = " ".repeat(expected);
161 let replacement =
162 format!("{}{}{}{}", leading_spaces, marker, spaces, content);
163
164 violations.push(Violation {
165 line: line_number,
166 column: Some(line.len() - trimmed.len() + dot_pos + 2),
167 rule: self.name().to_string(),
168 message: format!(
169 "Expected {} space(s) after list marker, found {}",
170 expected, space_count
171 ),
172 fix: Some(Fix {
173 line_start: line_number,
174 line_end: line_number,
175 column_start: None,
176 column_end: None,
177 replacement,
178 description: format!("Adjust spacing to {} space(s)", expected),
179 }),
180 });
181 }
182 }
183 }
184 }
185 }
186
187 violations
188 }
189
190 fn fixable(&self) -> bool {
191 true
192 }
193}
194
195fn is_horizontal_rule(line: &str) -> bool {
197 let trimmed = line.trim();
198 if trimmed.len() < 3 {
199 return false;
200 }
201
202 let chars: Vec<char> = trimmed.chars().filter(|&c| c != ' ').collect();
203 if chars.len() < 3 {
204 return false;
205 }
206
207 let first_char = chars[0];
208 if first_char != '-' && first_char != '*' && first_char != '_' {
209 return false;
210 }
211
212 chars.iter().all(|&c| c == first_char)
213}
214
215fn is_table_separator(line: &str) -> bool {
217 let trimmed = line.trim();
218 if trimmed.is_empty() {
219 return false;
220 }
221
222 let has_pipe = trimmed.contains('|');
224 let dash_count = trimmed.chars().filter(|&c| c == '-').count();
225
226 if !has_pipe || dash_count < 3 {
227 return false;
228 }
229
230 trimmed.chars().all(|c| c == '-' || c == '|' || c == ' ')
232}
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237
238 #[test]
239 fn test_correct_spacing() {
240 let content = "* Item 1\n+ Item 2\n- Item 3\n1. Ordered";
241 let parser = MarkdownParser::new(content);
242 let rule = MD030;
243 let violations = rule.check(&parser, None);
244
245 assert_eq!(violations.len(), 0);
246 }
247
248 #[test]
249 fn test_no_space() {
250 let content = "*Item without space";
251 let parser = MarkdownParser::new(content);
252 let rule = MD030;
253 let violations = rule.check(&parser, None);
254
255 assert_eq!(violations.len(), 1);
256 assert!(violations[0].message.contains("found 0"));
257 }
258
259 #[test]
260 fn test_multiple_spaces() {
261 let content = "* Item with 2 spaces";
262 let parser = MarkdownParser::new(content);
263 let rule = MD030;
264 let violations = rule.check(&parser, None);
265
266 assert_eq!(violations.len(), 1);
267 assert!(violations[0].message.contains("found 2"));
268 }
269
270 #[test]
271 fn test_custom_spacing() {
272 let content = "* Item with 2 spaces";
273 let parser = MarkdownParser::new(content);
274 let rule = MD030;
275 let config = serde_json::json!({ "ul_single": 2 });
276 let violations = rule.check(&parser, Some(&config));
277
278 assert_eq!(violations.len(), 0); }
280
281 #[test]
282 fn test_bold_not_list_marker() {
283 let content = "**Slice-specific schemas** → some text\n\
285 **Bold text** at start\n\
286 *Italic text* here\n\
287 __Also bold__ text";
288 let parser = MarkdownParser::new(content);
289 let rule = MD030;
290 let violations = rule.check(&parser, None);
291
292 assert_eq!(
293 violations.len(),
294 0,
295 "Bold/emphasis should not trigger MD030"
296 );
297 }
298
299 #[test]
300 fn test_actual_list_with_bold() {
301 let content = "* **Bold** item\n\
303 + *Italic* item\n\
304 - Normal item";
305 let parser = MarkdownParser::new(content);
306 let rule = MD030;
307 let violations = rule.check(&parser, None);
308
309 assert_eq!(violations.len(), 0);
310 }
311
312 #[test]
313 fn test_horizontal_rules_not_list_markers() {
314 let content = "# Heading\n\
316 \n\
317 ---\n\
318 \n\
319 More content\n\
320 \n\
321 ***\n\
322 \n\
323 ___\n\
324 \n\
325 * * *\n\
326 \n\
327 - - -";
328 let parser = MarkdownParser::new(content);
329 let rule = MD030;
330 let violations = rule.check(&parser, None);
331
332 assert_eq!(
333 violations.len(),
334 0,
335 "Horizontal rules should not be treated as list markers"
336 );
337 }
338
339 #[test]
340 fn test_code_blocks_not_checked() {
341 let content = "# Heading\n\
343 \n\
344 ```\n\
345 --config <CONFIG>\n\
346 --fix\n\
347 -h, --help\n\
348 ```\n\
349 \n\
350 Normal text with `-h` inline code.";
351 let parser = MarkdownParser::new(content);
352 let rule = MD030;
353 let violations = rule.check(&parser, None);
354
355 assert_eq!(
356 violations.len(),
357 0,
358 "Code blocks and inline code should not be checked for list markers"
359 );
360 }
361
362 #[test]
363 fn test_real_list_after_code_block() {
364 let content = "```\n\
366 --config\n\
367 ```\n\
368 \n\
369 *Item without space";
370 let parser = MarkdownParser::new(content);
371 let rule = MD030;
372 let violations = rule.check(&parser, None);
373
374 assert_eq!(
375 violations.len(),
376 1,
377 "Real list markers outside code blocks should be checked"
378 );
379 assert_eq!(violations[0].line, 5);
380 }
381
382 #[test]
383 fn test_table_separator_not_list() {
384 let content = "Rule | Description\n\
386 ------|------------\n\
387 MD001 | First rule\n\
388 MD002 | Second rule";
389 let parser = MarkdownParser::new(content);
390 let rule = MD030;
391 let violations = rule.check(&parser, None);
392
393 assert_eq!(
394 violations.len(),
395 0,
396 "Table separator lines should not be treated as list markers"
397 );
398 }
399}