mdbook_lint_core/rules/standard/
md022.rs

1//! MD022: Headings should be surrounded by blank lines
2//!
3//! This rule is triggered when headings 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/// MD022: Headings should be surrounded by blank lines
14///
15/// This rule checks that headings have blank lines before and after them,
16/// unless they are at the start or end of the document.
17pub 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        // Find all heading nodes in the AST
40        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                // Check for blank line before the heading
45                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                // Check for blank line after the heading
55                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    /// Check if there's a blank line before the given line number
72    fn has_blank_line_before(&self, document: &Document, line_num: usize) -> bool {
73        // If this is the first line, no blank line needed
74        if line_num <= 1 {
75            return true;
76        }
77
78        // Check if the previous line is blank
79        if let Some(prev_line) = document.lines.get(line_num - 2) {
80            prev_line.trim().is_empty()
81        } else {
82            true // Start of document
83        }
84    }
85
86    /// Check if there's a blank line after the given line number
87    fn has_blank_line_after(&self, document: &Document, line_num: usize) -> bool {
88        // If this is the last line, no blank line needed
89        if line_num >= document.lines.len() {
90            return true;
91        }
92
93        // Check if the next line is blank
94        if let Some(next_line) = document.lines.get(line_num) {
95            next_line.trim().is_empty()
96        } else {
97            true // End of document
98        }
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        // Should be valid at start of document
170        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        // Should be valid at end of document
182        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        // First heading: missing before and after
244        // Second heading: missing before and after
245        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        // Single heading at start and end of document should be valid
286        assert_no_violations(MD022, &content);
287    }
288}