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) -> &'static str {
18        "MD032"
19    }
20
21    fn description(&self) -> &'static 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()
44                && line
45                    .chars()
46                    .next()
47                    .expect("non-empty, checked above")
48                    .is_whitespace();
49
50            if list_marker.is_some() && in_list && is_indented {
51                // Indented list-marker line while already inside a list: this is a
52                // nested sub-list, not a sibling list at the same level. CommonMark
53                // and markdownlint don't require blank lines around nested lists —
54                // only around the outermost list — so treat it like any other
55                // indented continuation line and leave the enclosing list's
56                // marker/state untouched.
57            } else if let Some(marker) = list_marker {
58                if !in_list {
59                    // Starting a new list
60                    in_list = true;
61                    current_marker = Some(marker);
62                    last_list_line = line_num;
63
64                    // Check if previous line is blank (unless it's the first line)
65                    if line_num > 0 {
66                        let prev_line = lines.get(line_num - 1).expect("line_num > 0");
67                        if !prev_line.trim().is_empty() {
68                            // Detect broken ordered list continuation: a line that
69                            // looks like an ordered list item (e.g. "6.") following
70                            // non-list text won't be parsed as a list item because
71                            // only "1." can interrupt a paragraph (CommonMark §5.2).
72                            if marker == ListMarker::Ordered && !starts_with_one(trimmed) {
73                                // Report on the interrupting line (previous line),
74                                // not the list-like line — that's where the break is.
75                                violations.push(Violation {
76                                    line: line_num, // previous line (0-indexed → 1-indexed)
77                                    column: Some(1),
78                                    rule: self.name().to_owned(),
79                                    message: "Line breaks ordered list continuation; subsequent \
80                                         numbered items are parsed as text, not list items"
81                                        .to_owned(),
82                                    fix: None,
83                                });
84                            } else {
85                                violations.push(Violation {
86                                    line: line_num + 1,
87                                    column: Some(1),
88                                    rule: self.name().to_owned(),
89                                    message: "List should be surrounded by blank lines".to_owned(),
90                                    fix: None,
91                                });
92                            }
93                        }
94                    }
95                } else if Some(marker) != current_marker {
96                    // Different list marker - this is a new list!
97                    // The previous list needs a blank line after it (report at previous list line)
98                    violations.push(Violation {
99                        line: last_list_line + 1,
100                        column: Some(1),
101                        rule: self.name().to_owned(),
102                        message: "List should be surrounded by blank lines".to_owned(),
103                        fix: None,
104                    });
105                    // Also this new list needs a blank line before it (report at new list line)
106                    violations.push(Violation {
107                        line: line_num + 1,
108                        column: Some(1),
109                        rule: self.name().to_owned(),
110                        message: "List should be surrounded by blank lines".to_owned(),
111                        fix: None,
112                    });
113                    current_marker = Some(marker);
114                    last_list_line = line_num;
115                } else {
116                    // Same marker, continue in list
117                    last_list_line = line_num;
118                }
119            } else if in_list && is_indented && !line.trim().is_empty() {
120                // Indented non-list line - this is a continuation of the list item
121                // Do nothing, stay in list
122            } else if in_list && !line.trim().is_empty() {
123                // Ending a list (non-blank, non-indented, non-list line)
124                in_list = false;
125                current_marker = None;
126
127                // Check if next line should have been blank
128                violations.push(Violation {
129                    line: line_num + 1, // The line after the list
130                    column: Some(1),
131                    rule: self.name().to_owned(),
132                    message: "List should be surrounded by blank lines".to_owned(),
133                    fix: None,
134                });
135            } else if in_list && line.trim().is_empty() {
136                // Blank line during list - might be end
137                // Look ahead to see if list continues with same marker
138                let mut continues = false;
139                for future_line in lines.iter().skip(line_num + 1) {
140                    if let Some(future_marker) = get_list_marker(future_line.trim_start()) {
141                        if Some(future_marker) == current_marker {
142                            continues = true;
143                        }
144                        break;
145                    } else if !future_line.trim().is_empty() {
146                        break;
147                    }
148                }
149                if !continues {
150                    in_list = false;
151                    current_marker = None;
152                }
153            }
154        }
155
156        violations
157    }
158
159    fn fixable(&self) -> bool {
160        false
161    }
162}
163
164/// Returns true if the line starts with `1.` or `1)` (the only ordered marker
165/// that can interrupt a paragraph in `CommonMark`).
166fn starts_with_one(trimmed: &str) -> bool {
167    let check = trimmed.strip_prefix('\\').unwrap_or(trimmed);
168    check.starts_with("1. ") || check.starts_with("1) ")
169}
170
171fn get_list_marker(trimmed: &str) -> Option<ListMarker> {
172    // Check for unordered list markers
173    if trimmed.starts_with("* ") {
174        return Some(ListMarker::Asterisk);
175    }
176    if trimmed.starts_with("+ ") {
177        return Some(ListMarker::Plus);
178    }
179    if trimmed.starts_with("- ") {
180        return Some(ListMarker::Dash);
181    }
182
183    // Check for ordered list markers (also detect escaped markers like \6.)
184    let check = if let Some(stripped) = trimmed.strip_prefix('\\') {
185        stripped
186    } else {
187        trimmed
188    };
189    if let Some(dot_pos) = check.find(". ") {
190        let prefix = &check[..dot_pos];
191        if !prefix.is_empty() && prefix.chars().all(|c| c.is_ascii_digit()) {
192            return Some(ListMarker::Ordered);
193        }
194    }
195
196    None
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    #[test]
204    fn test_properly_surrounded() {
205        let content = "Text before\n\n* Item 1\n* Item 2\n\nText after";
206        let parser = MarkdownParser::new(content);
207        let rule = MD032;
208        let violations = rule.check(&parser, None);
209
210        assert_eq!(violations.len(), 0);
211    }
212
213    #[test]
214    fn test_missing_blank_before() {
215        let content = "Text before\n* Item 1\n* Item 2\n\nText after";
216        let parser = MarkdownParser::new(content);
217        let rule = MD032;
218        let violations = rule.check(&parser, None);
219
220        assert_eq!(violations.len(), 1);
221        assert_eq!(violations[0].line, 2); // List starts on line 2
222    }
223
224    #[test]
225    fn test_missing_blank_after() {
226        let content = "Text before\n\n* Item 1\n* Item 2\nText after";
227        let parser = MarkdownParser::new(content);
228        let rule = MD032;
229        let violations = rule.check(&parser, None);
230
231        assert_eq!(violations.len(), 1);
232        assert_eq!(violations[0].line, 5); // Text after list
233    }
234
235    #[test]
236    fn test_first_line() {
237        let content = "* Item 1\n* Item 2\n\nText after";
238        let parser = MarkdownParser::new(content);
239        let rule = MD032;
240        let violations = rule.check(&parser, None);
241
242        assert_eq!(violations.len(), 0); // First line is OK
243    }
244
245    #[test]
246    fn test_wrapped_list_item() {
247        // List items that wrap to multiple lines should not be treated as list ending
248        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";
249        let parser = MarkdownParser::new(content);
250        let rule = MD032;
251        let violations = rule.check(&parser, None);
252
253        // Should have 0 violations - the wrapped line is a continuation, not a new paragraph
254        assert_eq!(violations.len(), 0);
255    }
256
257    #[test]
258    fn test_multiple_wrapped_lines() {
259        // Multiple continuation lines in a single list item
260        let content = "Text\n\n* Item with multiple\n  lines of text\n  spanning across\n  multiple lines\n* Item 2\n\nText after";
261        let parser = MarkdownParser::new(content);
262        let rule = MD032;
263        let violations = rule.check(&parser, None);
264
265        // Should have 0 violations
266        assert_eq!(violations.len(), 0);
267    }
268
269    #[test]
270    fn test_wrapped_with_nested_list() {
271        // Wrapped items with nested list
272        let content =
273            "Text\n\n* Item 1 that\n  wraps across lines\n  * Nested item\n* Item 2\n\nText after";
274        let parser = MarkdownParser::new(content);
275        let rule = MD032;
276        let violations = rule.check(&parser, None);
277
278        // Should have 0 violations
279        assert_eq!(violations.len(), 0);
280    }
281
282    #[test]
283    fn test_list_in_code_block_not_flagged() {
284        let content = "Text before\n\n```markdown\n- item 1\n- item 2\n```\n\nText after";
285        let parser = MarkdownParser::new(content);
286        let rule = MD032;
287        let violations = rule.check(&parser, None);
288
289        assert_eq!(violations.len(), 0);
290    }
291
292    #[test]
293    fn test_mixed_markers_are_separate_lists() {
294        // Different list markers are treated as separate lists
295        let content = "Text\n\n* Item asterisk\n+ Item plus\n- Item dash\n\nText after";
296        let parser = MarkdownParser::new(content);
297        let rule = MD032;
298        let violations = rule.check(&parser, None);
299
300        // Each marker change is a new list needing blank lines
301        // + needs blank before/after (2 violations)
302        // - needs blank before/after (2 violations)
303        assert_eq!(violations.len(), 4);
304    }
305
306    #[test]
307    fn test_nested_list_different_marker_tight_not_flagged() {
308        // Regression test for issue #67: a nested ordered list directly under a
309        // bullet item, with no blank lines separating it from the parent item's
310        // text or the next sibling item, is not a set of separate top-level lists
311        // and must not be flagged. This is exactly the output `mdlint format`
312        // produces for this construct.
313        let content = "# Example\n\n- First item:\n  1. One\n  2. Two\n- Second item\n";
314        let parser = MarkdownParser::new(content);
315        let rule = MD032;
316        let violations = rule.check(&parser, None);
317
318        assert_eq!(violations.len(), 0);
319    }
320
321    #[test]
322    fn test_nested_list_different_marker_loose_not_flagged() {
323        // Same construct as above, but with the blank lines that make the outer
324        // list loose. Both forms are valid CommonMark and neither should be
325        // flagged by MD032.
326        let content = "# Example\n\n- First item:\n\n  1. One\n  2. Two\n\n- Second item\n";
327        let parser = MarkdownParser::new(content);
328        let rule = MD032;
329        let violations = rule.check(&parser, None);
330
331        assert_eq!(violations.len(), 0);
332    }
333}