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) -> &'static str {
11 "MD031"
12 }
13
14 fn description(&self) -> &'static 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.get(line_idx - 1).expect("line_idx > 0").trim();
58 if !prev_line.is_empty() {
59 violations.push(Violation {
61 line: start_line,
62 column: Some(1),
63 rule: self.name().to_owned(),
64 message:
65 "Fenced code blocks should be surrounded by blank lines (missing before)".to_owned(),
66 fix: Some(Fix {
67 line_start: start_line,
68 line_end: start_line,
69 column_start: None,
70 column_end: None,
71 replacement: format!("\n{}", lines.get(line_idx).expect("line_idx bounded")),
72 description: "Add blank line before code block".to_owned(),
73 }),
74 });
75 }
76 }
77 }
78
79 for &end_line in &code_block_ends {
80 let line_idx = end_line - 1;
81
82 if line_idx + 1 < lines.len() {
84 let next_line = lines
85 .get(line_idx + 1)
86 .expect("line_idx + 1 < lines.len()")
87 .trim();
88 if !next_line.is_empty() {
89 violations.push(Violation {
91 line: end_line,
92 column: Some(1),
93 rule: self.name().to_owned(),
94 message:
95 "Fenced code blocks should be surrounded by blank lines (missing after)"
96 .to_owned(),
97 fix: Some(Fix {
98 line_start: end_line,
99 line_end: end_line,
100 column_start: None,
101 column_end: None,
102 replacement: format!(
103 "{}\n",
104 lines.get(line_idx).expect("line_idx bounded")
105 ),
106 description: "Add blank line after code block".to_owned(),
107 }),
108 });
109 }
110 }
111 }
112
113 violations
114 }
115
116 fn fixable(&self) -> bool {
117 true
118 }
119}
120
121#[cfg(test)]
122mod tests {
123 use super::*;
124
125 #[test]
126 fn test_properly_surrounded() {
127 let content = "Text\n\n```\ncode\n```\n\nMore text";
128 let parser = MarkdownParser::new(content);
129 let rule = MD031;
130 let violations = rule.check(&parser, None);
131
132 assert_eq!(violations.len(), 0);
133 }
134
135 #[test]
136 fn test_missing_blank_before() {
137 let content = "Text\n```\ncode\n```\n\nMore text";
138 let parser = MarkdownParser::new(content);
139 let rule = MD031;
140 let violations = rule.check(&parser, None);
141
142 assert!(!violations.is_empty());
143 assert!(violations.iter().any(|v| v.message.contains("before")));
144 }
145
146 #[test]
147 fn test_missing_blank_after() {
148 let content = "Text\n\n```\ncode\n```\nMore text";
149 let parser = MarkdownParser::new(content);
150 let rule = MD031;
151 let violations = rule.check(&parser, None);
152
153 assert!(!violations.is_empty());
154 assert!(violations.iter().any(|v| v.message.contains("after")));
155 }
156
157 #[test]
158 fn test_first_line() {
159 let content = "```\ncode\n```\n\nText";
160 let parser = MarkdownParser::new(content);
161 let rule = MD031;
162 let violations = rule.check(&parser, None);
163
164 assert_eq!(violations.len(), 0); }
166
167 #[test]
168 fn test_numbered_list_with_code_block() {
169 let content = "1. **Enable/Disable a rule:**\n ```toml\n [rules.MD013]\n enabled = false\n ```\n\n2. **Next item**";
171 let parser = MarkdownParser::new(content);
172 let rule = MD031;
173 let violations = rule.check(&parser, None);
174
175 assert!(!violations.is_empty());
177 assert!(violations.iter().any(|v| v.message.contains("before")));
178
179 if let Some(fix) = &violations[0].fix {
181 assert_eq!(fix.line_start, 2);
183 assert_eq!(fix.line_end, 2);
184 assert!(fix.replacement.starts_with('\n'));
186 }
187 }
188
189 #[test]
190 fn test_fix_creates_blank_line() {
191 use crate::fix::Fixer;
192
193 let content = "Text\n```\ncode\n```\nMore";
194 let parser = MarkdownParser::new(content);
195 let rule = MD031;
196 let violations = rule.check(&parser, None);
197
198 assert_eq!(violations.len(), 2); let fixes: Vec<_> = violations.iter().filter_map(|v| v.fix.clone()).collect();
202 let runner = Fixer::new();
203 let result = runner.apply_fixes_to_content(content, &fixes).unwrap();
204
205 let expected = "Text\n\n```\ncode\n```\n\nMore";
207 assert_eq!(result, expected);
208 }
209}