Skip to main content

mdlint/lint/rules/
md032.rs

1use crate::lint::rule::Rule;
2use crate::markdown::MarkdownParser;
3use crate::types::Violation;
4use serde_json::Value;
5
6pub struct MD032;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9enum ListMarker {
10    Asterisk,
11    Plus,
12    Dash,
13    Ordered,
14}
15
16impl Rule for MD032 {
17    fn name(&self) -> &str {
18        "MD032"
19    }
20
21    fn description(&self) -> &str {
22        "Lists should be surrounded by blank lines"
23    }
24
25    fn tags(&self) -> &[&str] {
26        &["bullet", "ul", "ol", "blank_lines"]
27    }
28
29    fn check(&self, parser: &MarkdownParser, _config: Option<&Value>) -> Vec<Violation> {
30        let mut violations = Vec::new();
31        let lines = parser.lines();
32        let mut in_list = false;
33        let mut current_marker: Option<ListMarker> = None;
34        let mut last_list_line: usize = 0;
35        let code_block_lines = parser.get_code_block_line_numbers();
36
37        for (line_num, line) in lines.iter().enumerate() {
38            if code_block_lines.contains(&(line_num + 1)) {
39                continue;
40            }
41            let trimmed = line.trim_start();
42            let list_marker = get_list_marker(trimmed);
43            let is_indented = !line.is_empty() && line.chars().next().unwrap().is_whitespace();
44
45            if let Some(marker) = list_marker {
46                if !in_list {
47                    // Starting a new list
48                    in_list = true;
49                    current_marker = Some(marker);
50                    last_list_line = line_num;
51
52                    // Check if previous line is blank (unless it's the first line)
53                    if line_num > 0 {
54                        let prev_line = &lines[line_num - 1];
55                        if !prev_line.trim().is_empty() {
56                            // Detect broken ordered list continuation: a line that
57                            // looks like an ordered list item (e.g. "6.") following
58                            // non-list text won't be parsed as a list item because
59                            // only "1." can interrupt a paragraph (CommonMark §5.2).
60                            if marker == ListMarker::Ordered && !starts_with_one(trimmed) {
61                                // Report on the interrupting line (previous line),
62                                // not the list-like line — that's where the break is.
63                                violations.push(Violation {
64                                    line: line_num, // previous line (0-indexed → 1-indexed)
65                                    column: Some(1),
66                                    rule: self.name().to_string(),
67                                    message: "Line breaks ordered list continuation; subsequent \
68                                         numbered items are parsed as text, not list items"
69                                        .to_string(),
70                                    fix: None,
71                                });
72                            } else {
73                                violations.push(Violation {
74                                    line: line_num + 1,
75                                    column: Some(1),
76                                    rule: self.name().to_string(),
77                                    message: "List should be surrounded by blank lines".to_string(),
78                                    fix: None,
79                                });
80                            }
81                        }
82                    }
83                } else if Some(marker) != current_marker {
84                    // Different list marker - this is a new list!
85                    // The previous list needs a blank line after it (report at previous list line)
86                    violations.push(Violation {
87                        line: last_list_line + 1,
88                        column: Some(1),
89                        rule: self.name().to_string(),
90                        message: "List should be surrounded by blank lines".to_string(),
91                        fix: None,
92                    });
93                    // Also this new list needs a blank line before it (report at new list line)
94                    violations.push(Violation {
95                        line: line_num + 1,
96                        column: Some(1),
97                        rule: self.name().to_string(),
98                        message: "List should be surrounded by blank lines".to_string(),
99                        fix: None,
100                    });
101                    current_marker = Some(marker);
102                    last_list_line = line_num;
103                } else {
104                    // Same marker, continue in list
105                    last_list_line = line_num;
106                }
107            } else if in_list && is_indented && !line.trim().is_empty() {
108                // Indented non-list line - this is a continuation of the list item
109                // Do nothing, stay in list
110            } else if in_list && !line.trim().is_empty() {
111                // Ending a list (non-blank, non-indented, non-list line)
112                in_list = false;
113                current_marker = None;
114
115                // Check if next line should have been blank
116                violations.push(Violation {
117                    line: line_num + 1, // The line after the list
118                    column: Some(1),
119                    rule: self.name().to_string(),
120                    message: "List should be surrounded by blank lines".to_string(),
121                    fix: None,
122                });
123            } else if in_list && line.trim().is_empty() {
124                // Blank line during list - might be end
125                // Look ahead to see if list continues with same marker
126                let mut continues = false;
127                for future_line in lines.iter().skip(line_num + 1) {
128                    if let Some(future_marker) = get_list_marker(future_line.trim_start()) {
129                        if Some(future_marker) == current_marker {
130                            continues = true;
131                        }
132                        break;
133                    } else if !future_line.trim().is_empty() {
134                        break;
135                    }
136                }
137                if !continues {
138                    in_list = false;
139                    current_marker = None;
140                }
141            }
142        }
143
144        violations
145    }
146
147    fn fixable(&self) -> bool {
148        false
149    }
150}
151
152/// Returns true if the line starts with `1.` or `1)` (the only ordered marker
153/// that can interrupt a paragraph in CommonMark).
154fn starts_with_one(trimmed: &str) -> bool {
155    let check = trimmed.strip_prefix('\\').unwrap_or(trimmed);
156    check.starts_with("1. ") || check.starts_with("1) ")
157}
158
159fn get_list_marker(trimmed: &str) -> Option<ListMarker> {
160    // Check for unordered list markers
161    if trimmed.starts_with("* ") {
162        return Some(ListMarker::Asterisk);
163    }
164    if trimmed.starts_with("+ ") {
165        return Some(ListMarker::Plus);
166    }
167    if trimmed.starts_with("- ") {
168        return Some(ListMarker::Dash);
169    }
170
171    // Check for ordered list markers (also detect escaped markers like \6.)
172    let check = if let Some(stripped) = trimmed.strip_prefix('\\') {
173        stripped
174    } else {
175        trimmed
176    };
177    if let Some(dot_pos) = check.find(". ") {
178        let prefix = &check[..dot_pos];
179        if !prefix.is_empty() && prefix.chars().all(|c| c.is_ascii_digit()) {
180            return Some(ListMarker::Ordered);
181        }
182    }
183
184    None
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    #[test]
192    fn test_properly_surrounded() {
193        let content = "Text before\n\n* Item 1\n* Item 2\n\nText after";
194        let parser = MarkdownParser::new(content);
195        let rule = MD032;
196        let violations = rule.check(&parser, None);
197
198        assert_eq!(violations.len(), 0);
199    }
200
201    #[test]
202    fn test_missing_blank_before() {
203        let content = "Text before\n* Item 1\n* Item 2\n\nText after";
204        let parser = MarkdownParser::new(content);
205        let rule = MD032;
206        let violations = rule.check(&parser, None);
207
208        assert_eq!(violations.len(), 1);
209        assert_eq!(violations[0].line, 2); // List starts on line 2
210    }
211
212    #[test]
213    fn test_missing_blank_after() {
214        let content = "Text before\n\n* Item 1\n* Item 2\nText after";
215        let parser = MarkdownParser::new(content);
216        let rule = MD032;
217        let violations = rule.check(&parser, None);
218
219        assert_eq!(violations.len(), 1);
220        assert_eq!(violations[0].line, 5); // Text after list
221    }
222
223    #[test]
224    fn test_first_line() {
225        let content = "* Item 1\n* Item 2\n\nText after";
226        let parser = MarkdownParser::new(content);
227        let rule = MD032;
228        let violations = rule.check(&parser, None);
229
230        assert_eq!(violations.len(), 0); // First line is OK
231    }
232
233    #[test]
234    fn test_wrapped_list_item() {
235        // List items that wrap to multiple lines should not be treated as list ending
236        let content = "Text before\n\n* This is a long list item\n  that wraps to the next line\n* Item 2\n\nText after";
237        let parser = MarkdownParser::new(content);
238        let rule = MD032;
239        let violations = rule.check(&parser, None);
240
241        // Should have 0 violations - the wrapped line is a continuation, not a new paragraph
242        assert_eq!(violations.len(), 0);
243    }
244
245    #[test]
246    fn test_multiple_wrapped_lines() {
247        // Multiple continuation lines in a single list item
248        let content = "Text\n\n* Item with multiple\n  lines of text\n  spanning across\n  multiple lines\n* Item 2\n\nText after";
249        let parser = MarkdownParser::new(content);
250        let rule = MD032;
251        let violations = rule.check(&parser, None);
252
253        // Should have 0 violations
254        assert_eq!(violations.len(), 0);
255    }
256
257    #[test]
258    fn test_wrapped_with_nested_list() {
259        // Wrapped items with nested list
260        let content =
261            "Text\n\n* Item 1 that\n  wraps across lines\n  * Nested item\n* Item 2\n\nText after";
262        let parser = MarkdownParser::new(content);
263        let rule = MD032;
264        let violations = rule.check(&parser, None);
265
266        // Should have 0 violations
267        assert_eq!(violations.len(), 0);
268    }
269
270    #[test]
271    fn test_list_in_code_block_not_flagged() {
272        let content = "Text before\n\n```markdown\n- item 1\n- item 2\n```\n\nText after";
273        let parser = MarkdownParser::new(content);
274        let rule = MD032;
275        let violations = rule.check(&parser, None);
276
277        assert_eq!(violations.len(), 0);
278    }
279
280    #[test]
281    fn test_mixed_markers_are_separate_lists() {
282        // Different list markers are treated as separate lists
283        let content = "Text\n\n* Item asterisk\n+ Item plus\n- Item dash\n\nText after";
284        let parser = MarkdownParser::new(content);
285        let rule = MD032;
286        let violations = rule.check(&parser, None);
287
288        // Each marker change is a new list needing blank lines
289        // + needs blank before/after (2 violations)
290        // - needs blank before/after (2 violations)
291        assert_eq!(violations.len(), 4);
292    }
293}