mdbook_lint_core/rules/standard/
md031.rs

1//! MD031: Fenced code blocks should be surrounded by blank lines
2//!
3//! This rule is triggered when fenced code blocks are not surrounded by blank lines.
4
5use crate::error::Result;
6use crate::rule::{AstRule, RuleCategory, RuleMetadata};
7use crate::{
8    Document,
9    violation::{Severity, Violation},
10};
11use comrak::nodes::{AstNode, NodeValue};
12
13/// MD031: Fenced code blocks should be surrounded by blank lines
14///
15/// This rule checks that fenced code blocks (```) have blank lines before and after them,
16/// unless they are at the start or end of the document.
17pub struct MD031;
18
19impl AstRule for MD031 {
20    fn id(&self) -> &'static str {
21        "MD031"
22    }
23
24    fn name(&self) -> &'static str {
25        "blanks-around-fences"
26    }
27
28    fn description(&self) -> &'static str {
29        "Fenced code blocks should be surrounded by blank lines"
30    }
31
32    fn metadata(&self) -> RuleMetadata {
33        RuleMetadata::stable(RuleCategory::Formatting).introduced_in("markdownlint v0.1.0")
34    }
35
36    fn check_ast<'a>(&self, document: &Document, ast: &'a AstNode<'a>) -> Result<Vec<Violation>> {
37        let mut violations = Vec::new();
38        let code_blocks = document.code_blocks(ast);
39
40        for code_block in code_blocks {
41            // Only check fenced code blocks, not indented ones
42            if let NodeValue::CodeBlock(code_block_data) = &code_block.data.borrow().value
43                && code_block_data.fenced
44                && let Some((line, column)) = document.node_position(code_block)
45            {
46                // Check for blank line before the code block
47                if !self.has_blank_line_before(document, line) {
48                    violations.push(self.create_violation(
49                        "Fenced code block should be preceded by a blank line".to_string(),
50                        line,
51                        column,
52                        Severity::Warning,
53                    ));
54                }
55
56                // Check for blank line after the code block
57                let end_line = self.find_code_block_end_line(document, line);
58                if !self.has_blank_line_after(document, end_line) {
59                    violations.push(self.create_violation(
60                        "Fenced code block should be followed by a blank line".to_string(),
61                        end_line,
62                        1,
63                        Severity::Warning,
64                    ));
65                }
66            }
67        }
68
69        Ok(violations)
70    }
71}
72
73impl MD031 {
74    /// Check if there's a blank line before the given line number
75    fn has_blank_line_before(&self, document: &Document, line_num: usize) -> bool {
76        // If this is the first line, no blank line needed
77        if line_num <= 1 {
78            return true;
79        }
80
81        // Check if the previous line is blank
82        if let Some(prev_line) = document.lines.get(line_num - 2) {
83            prev_line.trim().is_empty()
84        } else {
85            true // Start of document
86        }
87    }
88
89    /// Check if there's a blank line after the given line number
90    fn has_blank_line_after(&self, document: &Document, line_num: usize) -> bool {
91        // If this is the last line, no blank line needed
92        if line_num >= document.lines.len() {
93            return true;
94        }
95
96        // Check if the next line is blank
97        if let Some(next_line) = document.lines.get(line_num) {
98            next_line.trim().is_empty()
99        } else {
100            true // End of document
101        }
102    }
103
104    /// Find the end line of a code block starting at the given line
105    fn find_code_block_end_line(&self, document: &Document, start_line: usize) -> usize {
106        let start_idx = start_line - 1; // Convert to 0-based
107
108        // Look for the opening fence
109        if let Some(start_line_content) = document.lines.get(start_idx) {
110            let trimmed = start_line_content.trim_start();
111            if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
112                let fence_chars = if trimmed.starts_with("```") {
113                    "```"
114                } else {
115                    "~~~"
116                };
117                let fence_length = trimmed
118                    .chars()
119                    .take_while(|&c| c == fence_chars.chars().next().unwrap())
120                    .count();
121
122                // Find the closing fence
123                for (idx, line) in document.lines.iter().enumerate().skip(start_idx + 1) {
124                    let line_trimmed = line.trim();
125                    if line_trimmed.starts_with(fence_chars) {
126                        let closing_fence_length = line_trimmed
127                            .chars()
128                            .take_while(|&c| c == fence_chars.chars().next().unwrap())
129                            .count();
130                        if closing_fence_length >= fence_length
131                            && line_trimmed.len() == closing_fence_length
132                        {
133                            return idx + 1; // Convert back to 1-based
134                        }
135                    }
136                }
137            }
138        }
139
140        // If we can't find the end, assume it's the start line
141        start_line
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148    use crate::rule::Rule;
149    use std::path::PathBuf;
150
151    #[test]
152    fn test_md031_valid_fenced_blocks() {
153        let content = r#"# Title
154
155```rust
156fn main() {
157    println!("Hello, world!");
158}
159```
160
161Some text after.
162"#;
163        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
164        let rule = MD031;
165        let violations = rule.check(&document).unwrap();
166
167        assert_eq!(violations.len(), 0);
168    }
169
170    #[test]
171    fn test_md031_missing_blank_before() {
172        let content = r#"# Title
173```rust
174fn main() {
175    println!("Hello, world!");
176}
177```
178
179Some text after.
180"#;
181        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
182        let rule = MD031;
183        let violations = rule.check(&document).unwrap();
184
185        assert_eq!(violations.len(), 1);
186        assert_eq!(violations[0].rule_id, "MD031");
187        assert!(violations[0].message.contains("preceded by a blank line"));
188        assert_eq!(violations[0].line, 2);
189        assert_eq!(violations[0].severity, Severity::Warning);
190    }
191
192    #[test]
193    fn test_md031_missing_blank_after() {
194        let content = r#"# Title
195
196```rust
197fn main() {
198    println!("Hello, world!");
199}
200```
201Some text after.
202"#;
203        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
204        let rule = MD031;
205        let violations = rule.check(&document).unwrap();
206
207        assert_eq!(violations.len(), 1);
208        assert_eq!(violations[0].rule_id, "MD031");
209        assert!(violations[0].message.contains("followed by a blank line"));
210        assert_eq!(violations[0].severity, Severity::Warning);
211    }
212
213    #[test]
214    fn test_md031_missing_both_blanks() {
215        let content = r#"# Title
216```rust
217fn main() {
218    println!("Hello, world!");
219}
220```
221Some text after.
222"#;
223        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
224        let rule = MD031;
225        let violations = rule.check(&document).unwrap();
226
227        assert_eq!(violations.len(), 2);
228        assert!(violations[0].message.contains("preceded by a blank line"));
229        assert!(violations[1].message.contains("followed by a blank line"));
230    }
231
232    #[test]
233    fn test_md031_start_of_document() {
234        let content = r#"```rust
235fn main() {
236    println!("Hello, world!");
237}
238```
239
240Some text after.
241"#;
242        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
243        let rule = MD031;
244        let violations = rule.check(&document).unwrap();
245
246        // Should be valid at start of document
247        assert_eq!(violations.len(), 0);
248    }
249
250    #[test]
251    fn test_md031_end_of_document() {
252        let content = r#"# Title
253
254```rust
255fn main() {
256    println!("Hello, world!");
257}
258```"#;
259        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
260        let rule = MD031;
261        let violations = rule.check(&document).unwrap();
262
263        // Should be valid at end of document
264        assert_eq!(violations.len(), 0);
265    }
266
267    #[test]
268    fn test_md031_multiple_code_blocks() {
269        let content = r#"# Title
270
271```rust
272fn main() {
273    println!("Hello, world!");
274}
275```
276Some text.
277```bash
278echo "test"
279```
280
281End.
282"#;
283        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
284        let rule = MD031;
285        let violations = rule.check(&document).unwrap();
286
287        assert_eq!(violations.len(), 2);
288        // First block missing blank after
289        assert!(violations[0].message.contains("followed by a blank line"));
290        // Second block missing blank before
291        assert!(violations[1].message.contains("preceded by a blank line"));
292    }
293
294    #[test]
295    fn test_md031_tildes_fenced_blocks() {
296        let content = r#"# Title
297
298~~~rust
299fn main() {
300    println!("Hello, world!");
301}
302~~~
303
304Some text after.
305"#;
306        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
307        let rule = MD031;
308        let violations = rule.check(&document).unwrap();
309
310        assert_eq!(violations.len(), 0);
311    }
312
313    #[test]
314    fn test_md031_indented_code_blocks_ignored() {
315        let content = r#"# Title
316Here is some code:
317
318    def hello():
319        print("Hello, world!")
320
321Some text after.
322"#;
323        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
324        let rule = MD031;
325        let violations = rule.check(&document).unwrap();
326
327        // Indented code blocks should be ignored
328        assert_eq!(violations.len(), 0);
329    }
330
331    #[test]
332    fn test_md031_different_fence_lengths() {
333        let content = r#"# Title
334
335````rust
336fn main() {
337    println!("```");
338}
339````
340
341Some text after.
342"#;
343        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
344        let rule = MD031;
345        let violations = rule.check(&document).unwrap();
346
347        assert_eq!(violations.len(), 0);
348    }
349}