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#[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
22const 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 #[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 }
50 }
51
52 #[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 #[inline]
67 fn is_fence_marker(content: &str) -> bool {
68 content.starts_with("```") || content.starts_with("~~~")
69 }
70
71 #[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 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 let lines = self.context.lines.borrow();
89 let total_lines = lines.len();
90
91 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 if end_line >= total_lines {
109 return; }
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; }
116
117 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()); }
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()); }
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()); }
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()); }
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()); }
355}