Skip to main content

mdlint/lint/rules/
md030.rs

1use 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)] // serde_json gives u64; values are small config counts
24    #[allow(clippy::too_many_lines)] // rule logic requires checking multiple interacting config flags
25    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        // Get code block lines to skip (not inline code, which can appear in list items)
49        let code_lines = parser.get_code_block_line_numbers();
50
51        // Use AST to identify lines that start with emphasis (to exclude them)
52        let mut emphasis_start_lines = HashSet::new();
53
54        // Calculate line start offsets
55        let mut line_offsets = vec![0];
56        let mut current_offset = 0;
57        for line in parser.lines() {
58            current_offset += line.len() + 1; // +1 for newline
59            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                // Check if this emphasis starts at the beginning of the line (after whitespace)
66                if let Some(line) = parser.lines().get(line_num - 1) {
67                    let trimmed_start = line.len() - line.trim_start().len();
68                    // If the emphasis starts right at the trimmed position, exclude this line
69                    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        // Now check spacing using string matching, but skip emphasis lines and code blocks
79        for (line_num, line) in parser.lines().iter().enumerate() {
80            let line_number = line_num + 1;
81
82            // Skip if line is in a code block or inline code
83            if code_lines.contains(&line_number) {
84                continue;
85            }
86
87            // Skip if line starts with emphasis (bold or italic)
88            if emphasis_start_lines.contains(&line_number) {
89                continue;
90            }
91
92            let trimmed = line.trim_start();
93
94            // Skip horizontal rules (3+ of same char: -, *, _)
95            if is_horizontal_rule(trimmed) {
96                continue;
97            }
98
99            // Skip table separator lines (lines with only -, |, and spaces)
100            if is_table_separator(trimmed) {
101                continue;
102            }
103
104            // Check unordered list markers
105            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                // Only check if there's content after the marker (not just a marker alone)
114                if !after_marker.trim().is_empty() {
115                    // For now, assume single-line (could be enhanced to detect multi-line)
116                    let expected = ul_single;
117
118                    if space_count != expected {
119                        // Fix the spacing after list marker
120                        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            // Check ordered list markers
146            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                    // Only check if there's content after the marker
152                    if !after_dot.trim().is_empty() {
153                        let space_count = after_dot.chars().take_while(|&c| c == ' ').count();
154
155                        // For now, assume single-line
156                        let expected = ol_single;
157
158                        if space_count != expected {
159                            // Fix the spacing after list marker
160                            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
196/// Check if a line is a horizontal rule (3+ of same char: -, *, _)
197fn 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
216/// Check if a line is a table separator (contains only -, |, and spaces)
217fn is_table_separator(line: &str) -> bool {
218    let trimmed = line.trim();
219    if trimmed.is_empty() {
220        return false;
221    }
222
223    // Must contain at least one pipe and three dashes
224    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    // All characters must be -, |, or space
232    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); // 2 spaces now expected
280    }
281
282    #[test]
283    fn test_bold_not_list_marker() {
284        // Bold/emphasis at start of line should not be treated as list marker
285        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        // Actual list items can contain bold text
303        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        // Horizontal rules should not trigger MD030 violations
316        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        // Code blocks should not trigger MD030 violations
343        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        // Real list markers outside code blocks should still be checked
366        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        // Table separator lines should not trigger MD030 violations
386        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}