1use std::rc::Rc;
2use tree_sitter::Node;
3
4use crate::linter::{range_from_tree_sitter, Context, RuleLinter, RuleViolation};
5
6use super::{Rule, RuleType};
7
8const MISSING_BLANK_BEFORE: &str =
10 "Lists should be surrounded by blank lines [Missing blank line before]";
11const MISSING_BLANK_AFTER: &str =
12 "Lists should be surrounded by blank lines [Missing blank line after]";
13
14pub(crate) struct MD032Linter {
15 context: Rc<Context>,
16 violations: Vec<RuleViolation>,
17}
18
19impl MD032Linter {
20 pub fn new(context: Rc<Context>) -> Self {
21 Self {
22 context,
23 violations: Vec::new(),
24 }
25 }
26
27 #[inline]
31 fn is_line_blank_cached(&self, line_number: usize, lines: &[String]) -> bool {
32 if line_number < lines.len() {
33 let line = &lines[line_number];
34 let trimmed = line.trim();
35
36 if trimmed.is_empty() {
38 return true;
39 }
40
41 if trimmed == ">" || trimmed.chars().all(|c| c == '>') {
43 return true;
44 }
45
46 if trimmed.starts_with('>') && trimmed.trim_start_matches('>').trim().is_empty() {
48 return true;
49 }
50
51 false
52 } else {
53 true }
55 }
56
57 #[inline]
61 fn is_top_level_list(&self, node: &Node) -> bool {
62 let mut current = node.parent();
63 while let Some(parent) = current {
64 match parent.kind() {
65 "list" => return false, "document" | "block_quote" => return true,
68 _ => current = parent.parent(),
69 }
70 }
71 true }
73
74 fn find_visual_end_line(&self, node: &Node) -> usize {
77 let start_line = node.start_position().row;
78 let tree_sitter_end_line = node.end_position().row;
79
80 let lines = self.context.lines.borrow();
82
83 if lines
86 .get(start_line)
87 .is_some_and(|line| line.trim_start().starts_with('>'))
88 {
89 for line_idx in (start_line..=tree_sitter_end_line).rev() {
92 if line_idx < lines.len() {
93 let line = &lines[line_idx];
94 let after_quote = line.trim_start_matches('>').trim();
95
96 if !after_quote.is_empty() {
98 return line_idx;
99 }
100 }
101 }
102 } else {
103 for line_idx in (start_line..=tree_sitter_end_line).rev() {
105 if line_idx < lines.len() {
106 let line = &lines[line_idx];
107 let trimmed = line.trim();
108
109 if !trimmed.is_empty() {
111 let is_thematic_break = trimmed.len() >= 3
113 && (trimmed.chars().all(|c| c == '-')
114 || trimmed.chars().all(|c| c == '*')
115 || trimmed.chars().all(|c| c == '_'));
116
117 let is_block_element = trimmed.starts_with('#') || trimmed.starts_with("```") || trimmed.starts_with("~~~") || is_thematic_break; if !is_block_element {
122 return line_idx;
123 }
124 }
125 }
126 }
127 }
128
129 start_line
131 }
132
133 fn check_list(&mut self, node: &Node) {
134 if !self.is_top_level_list(node) {
136 return;
137 }
138
139 let start_line = node.start_position().row;
140 let end_line = self.find_visual_end_line(node);
141
142 let lines = self.context.lines.borrow();
144 let total_lines = lines.len();
145
146 if start_line > 0 {
148 let line_above = start_line - 1;
149 if !self.is_line_blank_cached(line_above, &lines) {
150 self.violations.push(RuleViolation::new(
151 &MD032,
152 MISSING_BLANK_BEFORE.to_string(),
153 self.context.file_path.clone(),
154 range_from_tree_sitter(&node.range()),
155 ));
156 }
157 }
158
159 let line_after_list_idx = end_line + 1;
162 if line_after_list_idx < total_lines {
163 let is_blank = self.is_line_blank_cached(line_after_list_idx, &lines);
164
165 if !is_blank {
168 self.violations.push(RuleViolation::new(
169 &MD032,
170 MISSING_BLANK_AFTER.to_string(),
171 self.context.file_path.clone(),
172 range_from_tree_sitter(&node.range()),
173 ));
174 }
175 }
176 }
177}
178
179impl RuleLinter for MD032Linter {
180 fn feed(&mut self, node: &Node) {
181 if node.kind() == "list" {
182 self.check_list(node);
183 }
184 }
185
186 fn finalize(&mut self) -> Vec<RuleViolation> {
187 std::mem::take(&mut self.violations)
188 }
189}
190
191pub const MD032: Rule = Rule {
192 id: "MD032",
193 alias: "blanks-around-lists",
194 tags: &["blank_lines", "bullet", "ol", "ul"],
195 description: "Lists should be surrounded by blank lines",
196 rule_type: RuleType::Hybrid,
197 required_nodes: &["list"],
198 new_linter: |context| Box::new(MD032Linter::new(context)),
199};
200
201#[cfg(test)]
202mod test {
203 use std::path::PathBuf;
204
205 use crate::config::RuleSeverity;
206 use crate::linter::MultiRuleLinter;
207 use crate::test_utils::test_helpers::test_config_with_settings;
208
209 fn test_config_default() -> crate::config::QuickmarkConfig {
210 test_config_with_settings(
211 vec![
212 ("blanks-around-lists", RuleSeverity::Error),
213 ("heading-style", RuleSeverity::Off),
214 ("heading-increment", RuleSeverity::Off),
215 ],
216 Default::default(),
217 )
218 }
219
220 #[test]
221 fn test_no_violation_proper_blanks() {
222 let config = test_config_default();
223
224 let input = "Some text
225
226* List item
227* List item
228
229More text";
230 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
231 let violations = linter.analyze();
232 assert_eq!(0, violations.len());
233 }
234
235 #[test]
236 fn test_violation_missing_blank_above() {
237 let config = test_config_default();
238
239 let input = "Some text
240* List item
241* List item
242
243More text";
244 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
245 let violations = linter.analyze();
246 assert_eq!(1, violations.len());
247 assert!(violations[0].message().contains("blank line before"));
248 }
249
250 #[test]
251 fn test_violation_missing_blank_below() {
252 let config = test_config_default();
253
254 let input = "Some text
256
257* List item
258* List item
259---";
260 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
261 let violations = linter.analyze();
262 assert_eq!(1, violations.len());
263 assert!(violations[0].message().contains("blank line after"));
264 }
265
266 #[test]
267 fn test_violation_missing_both_blanks() {
268 let config = test_config_default();
269
270 let input = "Some text
272* List item
273* List item
274---";
275 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
276 let violations = linter.analyze();
277 assert_eq!(2, violations.len());
278 assert!(violations[0].message().contains("blank line"));
279 assert!(violations[1].message().contains("blank line"));
280 }
281
282 #[test]
283 fn test_no_violation_at_document_start() {
284 let config = test_config_default();
285
286 let input = "* List item
287* List item
288
289More text";
290 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
291 let violations = linter.analyze();
292 assert_eq!(0, violations.len());
293 }
294
295 #[test]
296 fn test_no_violation_at_document_end() {
297 let config = test_config_default();
298
299 let input = "Some text
300
301* List item
302* List item";
303 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
304 let violations = linter.analyze();
305 assert_eq!(0, violations.len());
306 }
307
308 #[test]
309 fn test_ordered_list_violations() {
310 let config = test_config_default();
311
312 let input = "Some text
3131. List item
3142. List item
315---";
316 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
317 let violations = linter.analyze();
318 assert_eq!(2, violations.len()); }
320
321 #[test]
322 fn test_mixed_list_markers() {
323 let config = test_config_default();
324
325 let input = "Some text
326+ List item
327- List item
328More text";
329 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
330 let violations = linter.analyze();
331 assert_eq!(3, violations.len());
334 }
335
336 #[test]
337 fn test_nested_lists_no_violation() {
338 let config = test_config_default();
339
340 let input = "Some text
341
342* List item
343 * Nested item
344 * Nested item
345* List item
346
347More text";
348 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
349 let violations = linter.analyze();
350 assert_eq!(0, violations.len());
352 }
353
354 #[test]
355 fn test_lists_in_blockquotes() {
356 let config = test_config_default();
357
358 let input = "> Some text
359>
360> * List item
361> * List item
362>
363> More text";
364 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
365 let violations = linter.analyze();
366 assert_eq!(0, violations.len());
368 }
369
370 #[test]
371 fn test_lists_in_blockquotes_violation() {
372 let config = test_config_default();
373
374 let input = "> Some text
375> * List item
376> * List item
377> More text";
378 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
379 let violations = linter.analyze();
380 assert_eq!(1, violations.len());
382 }
383
384 #[test]
385 fn test_list_with_horizontal_rule_before() {
386 let config = test_config_default();
387
388 let input = "Some text
389
390---
391* List item
392* List item
393
394More text";
395 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
396 let violations = linter.analyze();
397 assert_eq!(1, violations.len());
399 assert!(violations[0].message().contains("blank line before"));
400 }
401
402 #[test]
403 fn test_list_with_horizontal_rule_after() {
404 let config = test_config_default();
405
406 let input = "Some text
407
408* List item
409* List item
410---
411
412More text";
413 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
414 let violations = linter.analyze();
415 assert_eq!(1, violations.len());
417 assert!(violations[0].message().contains("blank line after"));
418 }
419
420 #[test]
421 fn test_list_with_code_block_before() {
422 let config = test_config_default();
423
424 let input = "Some text
425
426```
427code
428```
429* List item
430* List item
431
432More text";
433 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
434 let violations = linter.analyze();
435 assert_eq!(1, violations.len());
437 assert!(violations[0].message().contains("blank line before"));
438 }
439
440 #[test]
441 fn test_list_with_code_block_after() {
442 let config = test_config_default();
443
444 let input = "Some text
445
446* List item
447* List item
448```
449code
450```
451
452More text";
453 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
454 let violations = linter.analyze();
455 assert_eq!(1, violations.len());
457 assert!(violations[0].message().contains("blank line after"));
458 }
459
460 #[test]
461 fn test_lazy_continuation_line() {
462 let config = test_config_default();
463
464 let input = "Some text
465
4661. List item
467 More item 1
4682. List item
469More item 2
470
471More text";
472 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
473 let violations = linter.analyze();
474 assert_eq!(0, violations.len());
476 }
477
478 #[test]
479 fn test_list_at_document_boundaries_complete() {
480 let config = test_config_default();
481
482 let input = "* List item
483* List item";
484 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
485 let violations = linter.analyze();
486 assert_eq!(0, violations.len());
488 }
489}