mdlint/lint/rules/
md032.rs1use 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 in_list = true;
49 current_marker = Some(marker);
50 last_list_line = line_num;
51
52 if line_num > 0 {
54 let prev_line = &lines[line_num - 1];
55 if !prev_line.trim().is_empty() {
56 if marker == ListMarker::Ordered && !starts_with_one(trimmed) {
61 violations.push(Violation {
64 line: line_num, 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 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 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 last_list_line = line_num;
106 }
107 } else if in_list && is_indented && !line.trim().is_empty() {
108 } else if in_list && !line.trim().is_empty() {
111 in_list = false;
113 current_marker = None;
114
115 violations.push(Violation {
117 line: line_num + 1, 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 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
152fn 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 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 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); }
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); }
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); }
232
233 #[test]
234 fn test_wrapped_list_item() {
235 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 assert_eq!(violations.len(), 0);
243 }
244
245 #[test]
246 fn test_multiple_wrapped_lines() {
247 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 assert_eq!(violations.len(), 0);
255 }
256
257 #[test]
258 fn test_wrapped_with_nested_list() {
259 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 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 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 assert_eq!(violations.len(), 4);
292 }
293}