mdlint/lint/rules/
md031.rs1use crate::lint::rule::Rule;
2use crate::markdown::MarkdownParser;
3use crate::types::{Fix, Violation};
4use pulldown_cmark::{CodeBlockKind, Event, Tag, TagEnd};
5use serde_json::Value;
6
7pub struct MD031;
8
9impl Rule for MD031 {
10 fn name(&self) -> &str {
11 "MD031"
12 }
13
14 fn description(&self) -> &str {
15 "Fenced code blocks should be surrounded by blank lines"
16 }
17
18 fn tags(&self) -> &[&str] {
19 &["code", "blank_lines"]
20 }
21
22 fn check(&self, parser: &MarkdownParser, _config: Option<&Value>) -> Vec<Violation> {
23 let mut violations = Vec::new();
24 let lines = parser.lines();
25
26 let mut code_block_starts = Vec::new();
28 let mut code_block_ends = Vec::new();
29 let mut in_fenced_block = false;
30
31 for (event, range) in parser.parse_with_offsets() {
32 match event {
33 Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(_))) => {
34 let line = parser.offset_to_line(range.start);
35 code_block_starts.push(line);
36 in_fenced_block = true;
37 }
38 Event::Start(Tag::CodeBlock(CodeBlockKind::Indented)) => {
39 in_fenced_block = false;
41 }
42 Event::End(TagEnd::CodeBlock) if in_fenced_block => {
43 let line = parser.offset_to_line(range.end);
44 code_block_ends.push(line);
45 in_fenced_block = false;
46 }
47 _ => {}
48 }
49 }
50
51 for &start_line in &code_block_starts {
53 let line_idx = start_line - 1;
54
55 if line_idx > 0 {
57 let prev_line = lines[line_idx - 1].trim();
58 if !prev_line.is_empty() {
59 violations.push(Violation {
61 line: start_line,
62 column: Some(1),
63 rule: self.name().to_string(),
64 message:
65 "Fenced code blocks should be surrounded by blank lines (missing before)"
66 .to_string(),
67 fix: Some(Fix {
68 line_start: start_line,
69 line_end: start_line,
70 column_start: None,
71 column_end: None,
72 replacement: format!("\n{}", lines[line_idx]),
73 description: "Add blank line before code block".to_string(),
74 }),
75 });
76 }
77 }
78 }
79
80 for &end_line in &code_block_ends {
81 let line_idx = end_line - 1;
82
83 if line_idx + 1 < lines.len() {
85 let next_line = lines[line_idx + 1].trim();
86 if !next_line.is_empty() {
87 violations.push(Violation {
89 line: end_line,
90 column: Some(1),
91 rule: self.name().to_string(),
92 message:
93 "Fenced code blocks should be surrounded by blank lines (missing after)"
94 .to_string(),
95 fix: Some(Fix {
96 line_start: end_line,
97 line_end: end_line,
98 column_start: None,
99 column_end: None,
100 replacement: format!("{}\n", lines[line_idx]),
101 description: "Add blank line after code block".to_string(),
102 }),
103 });
104 }
105 }
106 }
107
108 violations
109 }
110
111 fn fixable(&self) -> bool {
112 true
113 }
114}
115
116#[cfg(test)]
117mod tests {
118 use super::*;
119
120 #[test]
121 fn test_properly_surrounded() {
122 let content = "Text\n\n```\ncode\n```\n\nMore text";
123 let parser = MarkdownParser::new(content);
124 let rule = MD031;
125 let violations = rule.check(&parser, None);
126
127 assert_eq!(violations.len(), 0);
128 }
129
130 #[test]
131 fn test_missing_blank_before() {
132 let content = "Text\n```\ncode\n```\n\nMore text";
133 let parser = MarkdownParser::new(content);
134 let rule = MD031;
135 let violations = rule.check(&parser, None);
136
137 assert!(!violations.is_empty());
138 assert!(violations.iter().any(|v| v.message.contains("before")));
139 }
140
141 #[test]
142 fn test_missing_blank_after() {
143 let content = "Text\n\n```\ncode\n```\nMore text";
144 let parser = MarkdownParser::new(content);
145 let rule = MD031;
146 let violations = rule.check(&parser, None);
147
148 assert!(!violations.is_empty());
149 assert!(violations.iter().any(|v| v.message.contains("after")));
150 }
151
152 #[test]
153 fn test_first_line() {
154 let content = "```\ncode\n```\n\nText";
155 let parser = MarkdownParser::new(content);
156 let rule = MD031;
157 let violations = rule.check(&parser, None);
158
159 assert_eq!(violations.len(), 0); }
161
162 #[test]
163 fn test_numbered_list_with_code_block() {
164 let content = "1. **Enable/Disable a rule:**\n ```toml\n [rules.MD013]\n enabled = false\n ```\n\n2. **Next item**";
166 let parser = MarkdownParser::new(content);
167 let rule = MD031;
168 let violations = rule.check(&parser, None);
169
170 assert!(!violations.is_empty());
172 assert!(violations.iter().any(|v| v.message.contains("before")));
173
174 if let Some(fix) = &violations[0].fix {
176 assert_eq!(fix.line_start, 2);
178 assert_eq!(fix.line_end, 2);
179 assert!(fix.replacement.starts_with('\n'));
181 }
182 }
183
184 #[test]
185 fn test_fix_creates_blank_line() {
186 use crate::fix::Fixer;
187
188 let content = "Text\n```\ncode\n```\nMore";
189 let parser = MarkdownParser::new(content);
190 let rule = MD031;
191 let violations = rule.check(&parser, None);
192
193 assert_eq!(violations.len(), 2); let fixes: Vec<_> = violations.iter().filter_map(|v| v.fix.clone()).collect();
197 let fixer = Fixer::new();
198 let fixed = fixer.apply_fixes_to_content(content, &fixes).unwrap();
199
200 let expected = "Text\n\n```\ncode\n```\n\nMore";
202 assert_eq!(fixed, expected);
203 }
204}