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) -> &'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 } else if let Some(marker) = list_marker {
58 if !in_list {
59 in_list = true;
61 current_marker = Some(marker);
62 last_list_line = line_num;
63
64 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 if marker == ListMarker::Ordered && !starts_with_one(trimmed) {
73 violations.push(Violation {
76 line: line_num, 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 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 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 last_list_line = line_num;
118 }
119 } else if in_list && is_indented && !line.trim().is_empty() {
120 } else if in_list && !line.trim().is_empty() {
123 in_list = false;
125 current_marker = None;
126
127 violations.push(Violation {
129 line: line_num + 1, 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 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
164fn 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 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 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); }
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); }
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); }
244
245 #[test]
246 fn test_wrapped_list_item() {
247 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 assert_eq!(violations.len(), 0);
255 }
256
257 #[test]
258 fn test_multiple_wrapped_lines() {
259 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 assert_eq!(violations.len(), 0);
267 }
268
269 #[test]
270 fn test_wrapped_with_nested_list() {
271 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 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 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 assert_eq!(violations.len(), 4);
304 }
305
306 #[test]
307 fn test_nested_list_different_marker_tight_not_flagged() {
308 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 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}