quickmark_core/rules/
md031.rs

1use serde::Deserialize;
2use std::rc::Rc;
3use tree_sitter::Node;
4
5use crate::linter::{range_from_tree_sitter, Context, RuleLinter, RuleViolation};
6
7use super::{Rule, RuleType};
8
9// MD031-specific configuration types
10#[derive(Debug, PartialEq, Clone, Deserialize)]
11pub struct MD031FencedCodeBlanksTable {
12    #[serde(default)]
13    pub list_items: bool,
14}
15
16impl Default for MD031FencedCodeBlanksTable {
17    fn default() -> Self {
18        Self { list_items: true }
19    }
20}
21
22// Pre-computed violation messages to avoid format! allocations
23const MISSING_BLANK_BEFORE: &str =
24    "Fenced code blocks should be surrounded by blank lines [Missing blank line before]";
25const MISSING_BLANK_AFTER: &str =
26    "Fenced code blocks should be surrounded by blank lines [Missing blank line after]";
27
28pub(crate) struct MD031Linter {
29    context: Rc<Context>,
30    violations: Vec<RuleViolation>,
31}
32
33impl MD031Linter {
34    pub fn new(context: Rc<Context>) -> Self {
35        Self {
36            context,
37            violations: Vec::new(),
38        }
39    }
40
41    /// Check if a line is blank, handling out-of-bounds safely.
42    /// Out-of-bounds lines are considered blank to avoid false violations at document boundaries.
43    #[inline]
44    fn is_line_blank_cached(&self, line_number: usize, lines: &[String]) -> bool {
45        if line_number < lines.len() {
46            lines[line_number].trim().is_empty()
47        } else {
48            true // Consider out-of-bounds lines as blank
49        }
50    }
51
52    /// Check if a node is within a list structure by traversing up the AST.
53    #[inline]
54    fn is_in_list(&self, node: &Node) -> bool {
55        let mut current = node.parent();
56        while let Some(parent) = current {
57            match parent.kind() {
58                "list_item" | "list" => return true,
59                _ => current = parent.parent(),
60            }
61        }
62        false
63    }
64
65    /// Check if content represents a fence closing marker.
66    #[inline]
67    fn is_fence_marker(content: &str) -> bool {
68        content.starts_with("```") || content.starts_with("~~~")
69    }
70
71    /// Determine if the code block ends at the document boundary with a fence marker.
72    #[inline]
73    fn is_at_document_end_with_fence(end_line: usize, total_lines: usize, content: &str) -> bool {
74        end_line >= total_lines - 1 && Self::is_fence_marker(content)
75    }
76
77    fn check_fenced_code_block(&mut self, node: &Node) {
78        let config = &self.context.config.linters.settings.fenced_code_blanks;
79
80        // Skip if list_items is false and this code block is in a list
81        if !config.list_items && self.is_in_list(node) {
82            return;
83        }
84
85        let start_line = node.start_position().row;
86        let end_line = node.end_position().row;
87        // Single borrow for the entire function to avoid multiple RefCell runtime checks
88        let lines = self.context.lines.borrow();
89        let total_lines = lines.len();
90
91        // Check blank line above (only if not at document start)
92        if start_line > 0 {
93            let line_above = start_line - 1;
94            if !self.is_line_blank_cached(line_above, &lines) {
95                self.violations.push(RuleViolation::new(
96                    &MD031,
97                    MISSING_BLANK_BEFORE.to_string(),
98                    self.context.file_path.clone(),
99                    range_from_tree_sitter(&node.range()),
100                ));
101            }
102        }
103
104        // Check blank line below using optimized logic
105        // Original markdownlint: !isBlankLine(lines[codeBlock.endLine]) && !isBlankLine(lines[codeBlock.endLine - 1])
106
107        // Fast path: Early return if we're at document end with a fence marker
108        if end_line >= total_lines {
109            return; // Beyond document bounds
110        }
111
112        let end_line_content = lines[end_line].trim();
113        if Self::is_at_document_end_with_fence(end_line, total_lines, end_line_content) {
114            return; // At document end with fence closing - no violation
115        }
116
117        // Check for violation using cached line access
118        let end_line_blank = self.is_line_blank_cached(end_line, &lines);
119        let prev_line_blank = self.is_line_blank_cached(end_line.saturating_sub(1), &lines);
120
121        if !end_line_blank && !prev_line_blank {
122            self.violations.push(RuleViolation::new(
123                &MD031,
124                MISSING_BLANK_AFTER.to_string(),
125                self.context.file_path.clone(),
126                range_from_tree_sitter(&node.range()),
127            ));
128        }
129    }
130}
131
132impl RuleLinter for MD031Linter {
133    fn feed(&mut self, node: &Node) {
134        if node.kind() == "fenced_code_block" {
135            self.check_fenced_code_block(node);
136        }
137    }
138
139    fn finalize(&mut self) -> Vec<RuleViolation> {
140        std::mem::take(&mut self.violations)
141    }
142}
143
144pub const MD031: Rule = Rule {
145    id: "MD031",
146    alias: "blanks-around-fences",
147    tags: &["blank_lines", "code"],
148    description: "Fenced code blocks should be surrounded by blank lines",
149    rule_type: RuleType::Hybrid,
150    required_nodes: &["fenced_code_block"],
151    new_linter: |context| Box::new(MD031Linter::new(context)),
152};
153
154#[cfg(test)]
155mod test {
156    use std::path::PathBuf;
157
158    use crate::config::{LintersSettingsTable, RuleSeverity};
159    use crate::linter::MultiRuleLinter;
160    use crate::test_utils::test_helpers::test_config_with_settings;
161
162    fn test_config_with_list_items(list_items: bool) -> crate::config::QuickmarkConfig {
163        test_config_with_settings(
164            vec![
165                ("blanks-around-fences", RuleSeverity::Error),
166                ("heading-style", RuleSeverity::Off),
167                ("heading-increment", RuleSeverity::Off),
168            ],
169            LintersSettingsTable {
170                fenced_code_blanks: crate::config::MD031FencedCodeBlanksTable { list_items },
171                ..Default::default()
172            },
173        )
174    }
175
176    fn test_config_default() -> crate::config::QuickmarkConfig {
177        test_config_with_list_items(true)
178    }
179
180    #[test]
181    fn test_no_violation_proper_blanks() {
182        let config = test_config_default();
183
184        let input = "Some text
185
186```javascript
187const x = 1;
188```
189
190More text";
191        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
192        let violations = linter.analyze();
193        assert_eq!(0, violations.len());
194    }
195
196    #[test]
197    fn test_violation_missing_blank_above() {
198        let config = test_config_default();
199
200        let input = "Some text
201```javascript
202const x = 1;
203```
204
205More text";
206        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
207        let violations = linter.analyze();
208        assert_eq!(1, violations.len());
209        assert!(violations[0].message().contains("blank line"));
210    }
211
212    #[test]
213    fn test_violation_missing_blank_below() {
214        let config = test_config_default();
215
216        let input = "Some text
217
218```javascript
219const x = 1;
220```
221More text";
222        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
223        let violations = linter.analyze();
224        assert_eq!(1, violations.len());
225        assert!(violations[0].message().contains("blank line"));
226    }
227
228    #[test]
229    fn test_violation_missing_both_blanks() {
230        let config = test_config_default();
231
232        let input = "Some text
233```javascript
234const x = 1;
235```
236More text";
237        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
238        let violations = linter.analyze();
239        assert_eq!(2, violations.len());
240        assert!(violations[0].message().contains("blank line"));
241        assert!(violations[1].message().contains("blank line"));
242    }
243
244    #[test]
245    fn test_no_violation_at_document_start() {
246        let config = test_config_default();
247
248        let input = "```javascript
249const x = 1;
250```
251
252More text";
253        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
254        let violations = linter.analyze();
255        assert_eq!(0, violations.len());
256    }
257
258    #[test]
259    fn test_no_violation_at_document_end() {
260        let config = test_config_default();
261
262        let input = "Some text
263
264```javascript
265const x = 1;
266```";
267        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
268        let violations = linter.analyze();
269        assert_eq!(0, violations.len());
270    }
271
272    #[test]
273    fn test_tilde_fences() {
274        let config = test_config_default();
275
276        let input = "Some text
277~~~javascript
278const x = 1;
279~~~
280More text";
281        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
282        let violations = linter.analyze();
283        assert_eq!(2, violations.len());
284    }
285
286    #[test]
287    fn test_violation_in_lists_when_enabled() {
288        let config = test_config_with_list_items(true);
289
290        let input = "1. First item
291   ```javascript
292   const x = 1;
293   ```
2942. Second item";
295        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
296        let violations = linter.analyze();
297        assert_eq!(2, violations.len()); // Should have violations in list items
298    }
299
300    #[test]
301    fn test_no_violation_in_lists_when_disabled() {
302        let config = test_config_with_list_items(false);
303
304        let input = "1. First item
305   ```javascript
306   const x = 1;
307   ```
3082. Second item";
309        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
310        let violations = linter.analyze();
311        assert_eq!(0, violations.len()); // Should NOT have violations in list items
312    }
313
314    #[test]
315    fn test_violation_outside_lists_when_list_items_disabled() {
316        let config = test_config_with_list_items(false);
317
318        let input = "Some text
319```javascript
320const x = 1;
321```
322More text";
323        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
324        let violations = linter.analyze();
325        assert_eq!(2, violations.len()); // Should still have violations outside lists
326    }
327
328    #[test]
329    fn test_blockquote_fences() {
330        let config = test_config_default();
331
332        let input = "> Some text
333> ```javascript
334> const x = 1;
335> ```
336> More text";
337        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
338        let violations = linter.analyze();
339        assert_eq!(2, violations.len()); // Should detect violations in blockquotes
340    }
341
342    #[test]
343    fn test_nested_blockquote_lists() {
344        let config = test_config_with_list_items(true);
345
346        let input = "> 1. Item
347>    ```javascript
348>    const x = 1;
349>    ```
350> 2. Item";
351        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
352        let violations = linter.analyze();
353        assert_eq!(2, violations.len()); // Should detect violations in nested structures
354    }
355}