mdbook_lint_core/rules/standard/
md022.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 MD022;
18
19impl AstRule for MD022 {
20 fn id(&self) -> &'static str {
21 "MD022"
22 }
23
24 fn name(&self) -> &'static str {
25 "blanks-around-headings"
26 }
27
28 fn description(&self) -> &'static str {
29 "Headings should be surrounded by blank lines"
30 }
31
32 fn metadata(&self) -> RuleMetadata {
33 RuleMetadata::stable(RuleCategory::Structure).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
39 for node in ast.descendants() {
41 if let NodeValue::Heading(_) = &node.data.borrow().value
42 && let Some((line, column)) = document.node_position(node)
43 {
44 if !self.has_blank_line_before(document, line) {
46 violations.push(self.create_violation(
47 "Heading should be preceded by a blank line".to_string(),
48 line,
49 column,
50 Severity::Warning,
51 ));
52 }
53
54 if !self.has_blank_line_after(document, line) {
56 violations.push(self.create_violation(
57 "Heading should be followed by a blank line".to_string(),
58 line,
59 column,
60 Severity::Warning,
61 ));
62 }
63 }
64 }
65
66 Ok(violations)
67 }
68}
69
70impl MD022 {
71 fn has_blank_line_before(&self, document: &Document, line_num: usize) -> bool {
73 if line_num <= 1 {
75 return true;
76 }
77
78 if let Some(prev_line) = document.lines.get(line_num - 2) {
80 prev_line.trim().is_empty()
81 } else {
82 true }
84 }
85
86 fn has_blank_line_after(&self, document: &Document, line_num: usize) -> bool {
88 if line_num >= document.lines.len() {
90 return true;
91 }
92
93 if let Some(next_line) = document.lines.get(line_num) {
95 next_line.trim().is_empty()
96 } else {
97 true }
99 }
100}
101
102#[cfg(test)]
103mod tests {
104 use super::*;
105 use crate::test_helpers::*;
106
107 #[test]
108 fn test_md022_valid_headings() {
109 let content = MarkdownBuilder::new()
110 .heading(1, "Title")
111 .blank_line()
112 .paragraph("Some content here.")
113 .blank_line()
114 .heading(2, "Subtitle")
115 .blank_line()
116 .paragraph("More content.")
117 .build();
118
119 assert_no_violations(MD022, &content);
120 }
121
122 #[test]
123 fn test_md022_missing_blank_before() {
124 let content = MarkdownBuilder::new()
125 .paragraph("Some text before.")
126 .heading(1, "Title")
127 .blank_line()
128 .paragraph("Content after.")
129 .build();
130
131 let violations = assert_violation_count(MD022, &content, 1);
132 assert_violation_contains_message(&violations, "preceded by a blank line");
133 assert_violation_at_line(&violations, 2);
134 }
135
136 #[test]
137 fn test_md022_missing_blank_after() {
138 let content = MarkdownBuilder::new()
139 .heading(1, "Title")
140 .paragraph("Content immediately after.")
141 .build();
142
143 let violations = assert_violation_count(MD022, &content, 1);
144 assert_violation_contains_message(&violations, "followed by a blank line");
145 assert_violation_at_line(&violations, 1);
146 }
147
148 #[test]
149 fn test_md022_missing_both_blanks() {
150 let content = MarkdownBuilder::new()
151 .paragraph("Text before.")
152 .heading(1, "Title")
153 .paragraph("Text after.")
154 .build();
155
156 let violations = assert_violation_count(MD022, &content, 2);
157 assert_violation_contains_message(&violations, "preceded by a blank line");
158 assert_violation_contains_message(&violations, "followed by a blank line");
159 }
160
161 #[test]
162 fn test_md022_start_of_document() {
163 let content = MarkdownBuilder::new()
164 .heading(1, "Title")
165 .blank_line()
166 .paragraph("Content after.")
167 .build();
168
169 assert_no_violations(MD022, &content);
171 }
172
173 #[test]
174 fn test_md022_end_of_document() {
175 let content = MarkdownBuilder::new()
176 .paragraph("Some content.")
177 .blank_line()
178 .heading(1, "Final Heading")
179 .build();
180
181 assert_no_violations(MD022, &content);
183 }
184
185 #[test]
186 fn test_md022_multiple_headings() {
187 let content = MarkdownBuilder::new()
188 .heading(1, "Main Title")
189 .blank_line()
190 .paragraph("Introduction text.")
191 .blank_line()
192 .heading(2, "Section 1")
193 .blank_line()
194 .paragraph("Section content.")
195 .blank_line()
196 .heading(2, "Section 2")
197 .blank_line()
198 .paragraph("More content.")
199 .build();
200
201 assert_no_violations(MD022, &content);
202 }
203
204 #[test]
205 fn test_md022_consecutive_headings() {
206 let content = MarkdownBuilder::new()
207 .heading(1, "Main Title")
208 .blank_line()
209 .heading(2, "Subtitle")
210 .blank_line()
211 .paragraph("Content.")
212 .build();
213
214 assert_no_violations(MD022, &content);
215 }
216
217 #[test]
218 fn test_md022_mixed_heading_levels() {
219 let content = MarkdownBuilder::new()
220 .heading(1, "Level 1")
221 .blank_line()
222 .heading(3, "Level 3")
223 .blank_line()
224 .heading(2, "Level 2")
225 .blank_line()
226 .paragraph("Content.")
227 .build();
228
229 assert_no_violations(MD022, &content);
230 }
231
232 #[test]
233 fn test_md022_multiple_violations() {
234 let content = MarkdownBuilder::new()
235 .paragraph("Text before first heading.")
236 .heading(1, "Title")
237 .paragraph("No blank lines around this heading.")
238 .heading(2, "Subtitle")
239 .paragraph("More text.")
240 .build();
241
242 let violations = assert_violation_count(MD022, &content, 4);
243 assert_violation_contains_message(&violations, "preceded by a blank line");
246 assert_violation_contains_message(&violations, "followed by a blank line");
247 }
248
249 #[test]
250 fn test_md022_headings_with_other_elements() {
251 let content = MarkdownBuilder::new()
252 .heading(1, "Document Title")
253 .blank_line()
254 .blockquote("This is a quote before the next heading.")
255 .blank_line()
256 .heading(2, "Section with Quote")
257 .blank_line()
258 .unordered_list(&["Item 1", "Item 2", "Item 3"])
259 .blank_line()
260 .heading(3, "Section with List")
261 .blank_line()
262 .code_block("rust", "fn main() {}")
263 .build();
264
265 assert_no_violations(MD022, &content);
266 }
267
268 #[test]
269 fn test_md022_heading_immediately_after_code_block() {
270 let content = MarkdownBuilder::new()
271 .code_block("rust", "fn main() {}")
272 .heading(1, "Heading")
273 .blank_line()
274 .paragraph("Content.")
275 .build();
276
277 let violations = assert_violation_count(MD022, &content, 1);
278 assert_violation_contains_message(&violations, "preceded by a blank line");
279 }
280
281 #[test]
282 fn test_md022_single_heading_document() {
283 let content = MarkdownBuilder::new().heading(1, "Only Heading").build();
284
285 assert_no_violations(MD022, &content);
287 }
288}