quickmark_core/rules/
md032.rs

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
8// Pre-computed violation messages to avoid format! allocations
9const 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    /// Check if a line is blank, handling out-of-bounds safely and considering blockquote context.
28    /// Out-of-bounds lines are considered blank to avoid false violations at document boundaries.
29    /// Lines containing only blockquote markers (e.g., "> " or ">") are considered blank.
30    #[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            // Regular blank line
37            if trimmed.is_empty() {
38                return true;
39            }
40
41            // Check if this is a blockquote marker line (just >, >>, etc.)
42            if trimmed == ">" || trimmed.chars().all(|c| c == '>') {
43                return true;
44            }
45
46            // Check if this is a blockquote with only spaces ("> ", ">> ", etc.)
47            if trimmed.starts_with('>') && trimmed.trim_start_matches('>').trim().is_empty() {
48                return true;
49            }
50
51            false
52        } else {
53            true // Consider out-of-bounds lines as blank
54        }
55    }
56
57    /// Check if a node is within another list structure by traversing up the AST.
58    /// This helps identify top-level lists vs nested lists.
59    /// Lists within blockquotes are still considered "top-level" for MD032 purposes.
60    #[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, // Found parent list, so this is nested
66                // Stop searching when we hit document-level containers
67                "document" | "block_quote" => return true,
68                _ => current = parent.parent(),
69            }
70        }
71        true // No parent list found, this is top-level
72    }
73
74    /// Find the visual end line of the list by examining actual content
75    /// This approach looks at the lines themselves rather than relying solely on tree-sitter boundaries
76    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        // Borrow lines to examine content
81        let lines = self.context.lines.borrow();
82
83        // For blockquoted lists, we need to handle them differently
84        // If this is a blockquoted list, trust tree-sitter more
85        if lines
86            .get(start_line)
87            .is_some_and(|line| line.trim_start().starts_with('>'))
88        {
89            // This is a blockquoted list - be more conservative with tree-sitter boundaries
90            // but still exclude trailing blank blockquote lines
91            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 this line has meaningful content within the blockquote
97                    if !after_quote.is_empty() {
98                        return line_idx;
99                    }
100                }
101            }
102        } else {
103            // Regular list - use the existing content-based detection
104            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 this line has content and looks like it could be part of a list item
110                    if !trimmed.is_empty() {
111                        // Check if it's definitely NOT a block element
112                        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('#') || // headings
118                            trimmed.starts_with("```") || trimmed.starts_with("~~~") || // code blocks
119                            is_thematic_break; // thematic breaks
120
121                        if !is_block_element {
122                            return line_idx;
123                        }
124                    }
125                }
126            }
127        }
128
129        // Fallback to node's start line if no content found
130        start_line
131    }
132
133    fn check_list(&mut self, node: &Node) {
134        // Only check top-level lists
135        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        // Single borrow for the entire function to avoid multiple RefCell runtime checks
143        let lines = self.context.lines.borrow();
144        let total_lines = lines.len();
145
146        // Check blank line above (only if not at document start)
147        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        // Check blank line below (following original markdownlint logic)
160        // The original checks lines[lastLineNumber] where lastLineNumber is the line after the list
161        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 the line immediately after the list is not blank, report a violation
166            // This matches the original markdownlint behavior exactly
167            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        // Use a thematic break instead of paragraph text to avoid lazy continuation
255        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        // Use a thematic break to avoid lazy continuation
271        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()); // Both missing blank above and below
319    }
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        // Original markdownlint detects 3 violations:
332        // + List item (missing blank before and after), - List item (missing blank before)
333        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        // Should not report violations for nested lists, only top-level
351        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        // Should handle blockquote context properly
367        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        // Should detect violations even in blockquotes (only missing blank before due to lazy continuation)
381        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        // HR immediately before list should trigger violation
398        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        // HR immediately after list should trigger violation
416        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        // Code block immediately before list should trigger violation
436        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        // Code block immediately after list should trigger violation
456        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        // "More item 2" is a lazy continuation line, should not trigger violation
475        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        // List spans entire document - no violations expected
487        assert_eq!(0, violations.len());
488    }
489}