mdbook_lint_core/rules/standard/
md031.rs1use crate::error::Result;
6use crate::rule::{AstRule, RuleCategory, RuleMetadata};
7use crate::{
8 Document,
9 violation::{Severity, Violation},
10};
11use comrak::nodes::{AstNode, NodeValue};
12
13pub 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 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 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 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 fn has_blank_line_before(&self, document: &Document, line_num: usize) -> bool {
76 if line_num <= 1 {
78 return true;
79 }
80
81 if let Some(prev_line) = document.lines.get(line_num - 2) {
83 prev_line.trim().is_empty()
84 } else {
85 true }
87 }
88
89 fn has_blank_line_after(&self, document: &Document, line_num: usize) -> bool {
91 if line_num >= document.lines.len() {
93 return true;
94 }
95
96 if let Some(next_line) = document.lines.get(line_num) {
98 next_line.trim().is_empty()
99 } else {
100 true }
102 }
103
104 fn find_code_block_end_line(&self, document: &Document, start_line: usize) -> usize {
106 let start_idx = start_line - 1; 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 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; }
135 }
136 }
137 }
138 }
139
140 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 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 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 assert!(violations[0].message.contains("followed by a blank line"));
290 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 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}