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) -> &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        // Get code block lines to skip (not inline code, which can appear in list items)
47        let code_lines = parser.get_code_block_line_numbers();
48
49        // Use AST to identify lines that start with emphasis (to exclude them)
50        let mut emphasis_start_lines = HashSet::new();
51
52        // Calculate line start offsets
53        let mut line_offsets = vec![0];
54        let mut current_offset = 0;
55        for line in parser.lines() {
56            current_offset += line.len() + 1; // +1 for newline
57            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                // Check if this emphasis starts at the beginning of the line (after whitespace)
64                if let Some(line) = parser.lines().get(line_num - 1) {
65                    let trimmed_start = line.len() - line.trim_start().len();
66                    // If the emphasis starts right at the trimmed position, exclude this line
67                    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        // Now check spacing using string matching, but skip emphasis lines and code blocks
77        for (line_num, line) in parser.lines().iter().enumerate() {
78            let line_number = line_num + 1;
79
80            // Skip if line is in a code block or inline code
81            if code_lines.contains(&line_number) {
82                continue;
83            }
84
85            // Skip if line starts with emphasis (bold or italic)
86            if emphasis_start_lines.contains(&line_number) {
87                continue;
88            }
89
90            let trimmed = line.trim_start();
91
92            // Skip horizontal rules (3+ of same char: -, *, _)
93            if is_horizontal_rule(trimmed) {
94                continue;
95            }
96
97            // Skip table separator lines (lines with only -, |, and spaces)
98            if is_table_separator(trimmed) {
99                continue;
100            }
101
102            // Check unordered list markers
103            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                // Only check if there's content after the marker (not just a marker alone)
109                if !after_marker.trim().is_empty() {
110                    // For now, assume single-line (could be enhanced to detect multi-line)
111                    let expected = ul_single;
112
113                    if space_count != expected {
114                        // Fix the spacing after list marker
115                        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            // Check ordered list markers
143            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                    // Only check if there's content after the marker
149                    if !after_dot.trim().is_empty() {
150                        let space_count = after_dot.chars().take_while(|&c| c == ' ').count();
151
152                        // For now, assume single-line
153                        let expected = ol_single;
154
155                        if space_count != expected {
156                            // Fix the spacing after list marker
157                            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
195/// Check if a line is a horizontal rule (3+ of same char: -, *, _)
196fn 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
215/// Check if a line is a table separator (contains only -, |, and spaces)
216fn is_table_separator(line: &str) -> bool {
217    let trimmed = line.trim();
218    if trimmed.is_empty() {
219        return false;
220    }
221
222    // Must contain at least one pipe and three dashes
223    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    // All characters must be -, |, or space
231    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); // 2 spaces now expected
279    }
280
281    #[test]
282    fn test_bold_not_list_marker() {
283        // Bold/emphasis at start of line should not be treated as list marker
284        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        // Actual list items can contain bold text
302        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        // Horizontal rules should not trigger MD030 violations
315        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        // Code blocks should not trigger MD030 violations
342        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        // Real list markers outside code blocks should still be checked
365        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        // Table separator lines should not trigger MD030 violations
385        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}