mdbook_lint_core/rules/standard/
md032.rs

1//! MD032: Lists should be surrounded by blank lines
2//!
3//! This rule is triggered when lists are not surrounded by blank lines.
4
5use crate::error::Result;
6use crate::rule::{AstRule, RuleCategory, RuleMetadata};
7use crate::{
8    Document,
9    violation::{Severity, Violation},
10};
11use comrak::nodes::{AstNode, NodeValue};
12
13/// MD032: Lists should be surrounded by blank lines
14///
15/// This rule checks that lists have blank lines before and after them,
16/// unless they are at the start or end of the document, or are nested within other lists.
17pub struct MD032;
18
19impl AstRule for MD032 {
20    fn id(&self) -> &'static str {
21        "MD032"
22    }
23
24    fn name(&self) -> &'static str {
25        "blanks-around-lists"
26    }
27
28    fn description(&self) -> &'static str {
29        "Lists should be surrounded by blank lines"
30    }
31
32    fn metadata(&self) -> RuleMetadata {
33        RuleMetadata::stable(RuleCategory::Structure).introduced_in("markdownlint v0.1.0")
34    }
35
36    fn check_ast<'a>(&self, document: &Document, ast: &'a AstNode<'a>) -> Result<Vec<Violation>> {
37        let mut violations = Vec::new();
38
39        // Find all list nodes in the AST
40        for node in ast.descendants() {
41            if let NodeValue::List(_) = &node.data.borrow().value {
42                // Skip nested lists - only check top-level lists
43                if !self.is_nested_list(node)
44                    && let Some((start_line, start_column)) = document.node_position(node)
45                {
46                    // Check for blank line before the list
47                    if !self.has_blank_line_before(document, start_line) {
48                        violations.push(self.create_violation(
49                            "List should be preceded by a blank line".to_string(),
50                            start_line,
51                            start_column,
52                            Severity::Warning,
53                        ));
54                    }
55
56                    // Find the end line of the list by checking all its descendants
57                    let end_line = self.find_list_end_line(document, node);
58                    if !self.has_blank_line_after(document, end_line) {
59                        violations.push(self.create_violation(
60                            "List should be followed by a blank line".to_string(),
61                            end_line,
62                            1,
63                            Severity::Warning,
64                        ));
65                    }
66                }
67            }
68        }
69
70        Ok(violations)
71    }
72}
73
74impl MD032 {
75    /// Check if a list is nested within another list
76    fn is_nested_list(&self, list_node: &AstNode) -> bool {
77        let mut current = list_node.parent();
78        while let Some(parent) = current {
79            match &parent.data.borrow().value {
80                NodeValue::List(_) => return true,
81                NodeValue::Item(_) => {
82                    // Check if this item's parent is a list
83                    if let Some(grandparent) = parent.parent()
84                        && let NodeValue::List(_) = &grandparent.data.borrow().value
85                    {
86                        return true;
87                    }
88                }
89                _ => {}
90            }
91            current = parent.parent();
92        }
93        false
94    }
95
96    /// Check if there's a blank line before the given line number
97    fn has_blank_line_before(&self, document: &Document, line_num: usize) -> bool {
98        // If this is the first line, no blank line needed
99        if line_num <= 1 {
100            return true;
101        }
102
103        // Check if the previous line is blank
104        if let Some(prev_line) = document.lines.get(line_num - 2) {
105            prev_line.trim().is_empty()
106        } else {
107            true // Start of document
108        }
109    }
110
111    /// Check if there's a blank line after the given line number
112    fn has_blank_line_after(&self, document: &Document, line_num: usize) -> bool {
113        // If this is the last line, no blank line needed
114        if line_num >= document.lines.len() {
115            return true;
116        }
117
118        // Check if the next line is blank
119        if let Some(next_line) = document.lines.get(line_num) {
120            next_line.trim().is_empty()
121        } else {
122            true // End of document
123        }
124    }
125
126    /// Find the end line of a list by examining all its descendants
127    fn find_list_end_line<'a>(&self, document: &Document, list_node: &'a AstNode<'a>) -> usize {
128        let mut max_line = 1;
129
130        // Walk through all descendants to find the maximum line number
131        for descendant in list_node.descendants() {
132            if let Some((line, _)) = document.node_position(descendant) {
133                max_line = max_line.max(line);
134            }
135        }
136
137        max_line
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    use crate::test_helpers::*;
145
146    #[test]
147    fn test_md032_valid_unordered_list() {
148        let content = MarkdownBuilder::new()
149            .heading(1, "Title")
150            .blank_line()
151            .unordered_list(&["Item 1", "Item 2", "Item 3"])
152            .blank_line()
153            .paragraph("Some text after.")
154            .build();
155
156        assert_no_violations(MD032, &content);
157    }
158
159    #[test]
160    fn test_md032_valid_ordered_list() {
161        let content = MarkdownBuilder::new()
162            .heading(1, "Title")
163            .blank_line()
164            .ordered_list(&["First item", "Second item", "Third item"])
165            .blank_line()
166            .paragraph("Some text after.")
167            .build();
168
169        assert_no_violations(MD032, &content);
170    }
171
172    #[test]
173    fn test_md032_missing_blank_before() {
174        let content = MarkdownBuilder::new()
175            .heading(1, "Title")
176            .unordered_list(&["Item 1", "Item 2", "Item 3"])
177            .blank_line()
178            .paragraph("Some text after.")
179            .build();
180
181        let violations = assert_violation_count(MD032, &content, 1);
182        assert_violation_contains_message(&violations, "preceded by a blank line");
183    }
184
185    #[test]
186    fn test_md032_missing_blank_after() {
187        // When there's no blank line after a list, markdown parsers treat
188        // the following text as part of the last list item, so no violation occurs
189        let content = MarkdownBuilder::new()
190            .heading(1, "Title")
191            .blank_line()
192            .unordered_list(&["Item 1", "Item 2", "Item 3"])
193            .paragraph("Some text after.")
194            .build();
195
196        // This is actually valid markdown - no violations expected
197        assert_no_violations(MD032, &content);
198    }
199
200    #[test]
201    fn test_md032_missing_both_blanks() {
202        let content = MarkdownBuilder::new()
203            .heading(1, "Title")
204            .unordered_list(&["Item 1", "Item 2", "Item 3"])
205            .paragraph("Some text after.")
206            .build();
207
208        // Only the "before" violation is detected since "after" becomes part of the list
209        let violations = assert_violation_count(MD032, &content, 1);
210        assert_violation_contains_message(&violations, "preceded by a blank line");
211    }
212
213    #[test]
214    fn test_md032_start_of_document() {
215        let content = MarkdownBuilder::new()
216            .unordered_list(&["Item 1", "Item 2", "Item 3"])
217            .blank_line()
218            .paragraph("Some text after.")
219            .build();
220
221        // Should be valid at start of document
222        assert_no_violations(MD032, &content);
223    }
224
225    #[test]
226    fn test_md032_end_of_document() {
227        let content = MarkdownBuilder::new()
228            .heading(1, "Title")
229            .blank_line()
230            .unordered_list(&["Item 1", "Item 2", "Item 3"])
231            .build();
232
233        // Should be valid at end of document
234        assert_no_violations(MD032, &content);
235    }
236
237    #[test]
238    fn test_md032_nested_lists_ignored() {
239        let content = r#"# Title
240
241- Item 1
242  - Nested item 1
243  - Nested item 2
244- Item 2
245- Item 3
246
247Some text after.
248"#;
249        // Only the top-level list should be checked, nested lists are ignored
250        assert_no_violations(MD032, content);
251    }
252
253    #[test]
254    fn test_md032_multiple_lists() {
255        let content = MarkdownBuilder::new()
256            .heading(1, "Title")
257            .blank_line()
258            .unordered_list(&["First list item 1", "First list item 2"])
259            .blank_line()
260            .paragraph("Some text in between.")
261            .blank_line()
262            .ordered_list(&["Second list item 1", "Second list item 2"])
263            .blank_line()
264            .paragraph("End.")
265            .build();
266
267        assert_no_violations(MD032, &content);
268    }
269
270    #[test]
271    fn test_md032_mixed_list_types() {
272        // Different list markers create separate lists in markdown
273        let content = r#"# Title
274
275- Unordered item
276
277* Different marker
278
279+ Another marker
280
281Some text.
282
2831. Ordered item
2842. Another ordered item
285
286End.
287"#;
288        assert_no_violations(MD032, content);
289    }
290
291    #[test]
292    fn test_md032_list_with_multiline_items() {
293        let content = r#"# Title
294
295- Item 1 with a very long line that wraps
296  to multiple lines
297- Item 2 which also has
298  multiple lines of content
299- Item 3
300
301Some text after.
302"#;
303        assert_no_violations(MD032, content);
304    }
305
306    #[test]
307    fn test_md032_numbered_list_variations() {
308        let content = MarkdownBuilder::new()
309            .heading(1, "Title")
310            .blank_line()
311            .ordered_list(&["Item one", "Item two", "Item three"])
312            .blank_line()
313            .paragraph("Text between.")
314            .blank_line()
315            .line("1) Parenthesis style")
316            .line("2) Another item")
317            .line("3) Third item")
318            .blank_line()
319            .paragraph("End.")
320            .build();
321
322        assert_no_violations(MD032, &content);
323    }
324
325    #[test]
326    fn test_md032_markdown_parsing_behavior() {
327        // This test documents how markdown parsers handle lists without blank lines
328        let content = "# Title\n\n- Item 1\n- Item 2\n- Item 3\nText immediately after.";
329
330        // In markdown, text without a blank line after a list becomes part of the last item
331        // So this is actually valid markdown structure - no violations expected
332        assert_no_violations(MD032, content);
333    }
334}