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