mdbook_lint_core/rules/standard/
md032.rs1use crate::error::Result;
6use crate::rule::{AstRule, RuleCategory, RuleMetadata};
7use crate::{
8 Document,
9 violation::{Severity, Violation},
10};
11use comrak::nodes::{AstNode, NodeValue};
12
13pub 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 for node in ast.descendants() {
41 if let NodeValue::List(_) = &node.data.borrow().value {
42 if !self.is_nested_list(node)
44 && let Some((start_line, start_column)) = document.node_position(node)
45 {
46 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 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 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 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 fn has_blank_line_before(&self, document: &Document, line_num: usize) -> bool {
98 if line_num <= 1 {
100 return true;
101 }
102
103 if let Some(prev_line) = document.lines.get(line_num - 2) {
105 prev_line.trim().is_empty()
106 } else {
107 true }
109 }
110
111 fn has_blank_line_after(&self, document: &Document, line_num: usize) -> bool {
113 if line_num >= document.lines.len() {
115 return true;
116 }
117
118 if let Some(next_line) = document.lines.get(line_num) {
120 next_line.trim().is_empty()
121 } else {
122 true }
124 }
125
126 fn find_list_end_line<'a>(&self, document: &Document, list_node: &'a AstNode<'a>) -> usize {
128 let mut max_line = 1;
129
130 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 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 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 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 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 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 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 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 let content = "# Title\n\n- Item 1\n- Item 2\n- Item 3\nText immediately after.";
329
330 assert_no_violations(MD032, content);
333 }
334}