Skip to main content

mdlint/lint/rules/
md022.rs

1use crate::lint::rule::Rule;
2use crate::markdown::MarkdownParser;
3use crate::types::{Fix, Violation};
4use pulldown_cmark::{Event, Tag};
5use serde_json::Value;
6
7pub struct MD022;
8
9impl Rule for MD022 {
10    fn name(&self) -> &str {
11        "MD022"
12    }
13
14    fn description(&self) -> &str {
15        "Headings should be surrounded by blank lines"
16    }
17
18    fn tags(&self) -> &[&str] {
19        &["headings", "headers", "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        // Find all heading lines using the AST
27        let mut heading_lines = Vec::new();
28        for (event, range) in parser.parse_with_offsets() {
29            if let Event::Start(Tag::Heading { .. }) = event {
30                let line = parser.offset_to_line(range.start);
31                heading_lines.push(line);
32            }
33        }
34
35        for &heading_line in &heading_lines {
36            let line_idx = heading_line - 1;
37
38            // Check if there's a blank line before (skip if first line or after blank)
39            if line_idx > 0 {
40                let prev_line = lines[line_idx - 1].trim();
41                if !prev_line.is_empty() {
42                    // Replace the heading line with "\n<heading>" — the embedded newline
43                    // causes the Fixer to produce a blank line before the heading.
44                    violations.push(Violation {
45                        line: heading_line,
46                        column: Some(1),
47                        rule: self.name().to_string(),
48                        message: "Heading should be surrounded by blank lines (missing before)"
49                            .to_string(),
50                        fix: Some(Fix {
51                            line_start: heading_line,
52                            line_end: heading_line,
53                            column_start: None,
54                            column_end: None,
55                            replacement: format!("\n{}", lines[line_idx]),
56                            description: "Add blank line before heading".to_string(),
57                        }),
58                    });
59                }
60            }
61
62            // Check if there's a blank line after (skip if last line)
63            if line_idx + 1 < lines.len() {
64                let next_line = lines[line_idx + 1].trim();
65                // Allow another heading right after (for closed headings or setext underlines)
66                if !next_line.is_empty()
67                    && !next_line.starts_with('#')
68                    && !next_line
69                        .chars()
70                        .all(|c| c == '=' || c == '-' || c.is_whitespace())
71                {
72                    // Replace the heading line with "<heading>\n" — the embedded newline
73                    // causes the Fixer to produce a blank line after the heading.
74                    violations.push(Violation {
75                        line: heading_line,
76                        column: Some(1),
77                        rule: self.name().to_string(),
78                        message: "Heading should be surrounded by blank lines (missing after)"
79                            .to_string(),
80                        fix: Some(Fix {
81                            line_start: heading_line,
82                            line_end: heading_line,
83                            column_start: None,
84                            column_end: None,
85                            replacement: format!("{}\n", lines[line_idx]),
86                            description: "Add blank line after heading".to_string(),
87                        }),
88                    });
89                }
90            }
91        }
92
93        violations
94    }
95
96    fn fixable(&self) -> bool {
97        true
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104    use crate::fix::Fixer;
105
106    fn apply_fixes(content: &str, violations: &[Violation]) -> String {
107        let fixes: Vec<_> = violations.iter().filter_map(|v| v.fix.clone()).collect();
108        Fixer::new()
109            .apply_fixes_to_content(content, &fixes)
110            .unwrap()
111    }
112
113    #[test]
114    fn test_properly_surrounded() {
115        let content = "Paragraph\n\n# Heading\n\nAnother paragraph";
116        let parser = MarkdownParser::new(content);
117        let rule = MD022;
118        let violations = rule.check(&parser, None);
119
120        assert_eq!(violations.len(), 0);
121    }
122
123    #[test]
124    fn test_missing_blank_before() {
125        let content = "Paragraph\n# Heading\n\nContent";
126        let parser = MarkdownParser::new(content);
127        let rule = MD022;
128        let violations = rule.check(&parser, None);
129
130        assert_eq!(violations.len(), 1);
131        assert!(violations[0].message.contains("before"));
132    }
133
134    #[test]
135    fn test_missing_blank_after() {
136        let content = "\n# Heading\nContent";
137        let parser = MarkdownParser::new(content);
138        let rule = MD022;
139        let violations = rule.check(&parser, None);
140
141        assert_eq!(violations.len(), 1);
142        assert!(violations[0].message.contains("after"));
143    }
144
145    #[test]
146    fn test_first_line() {
147        let content = "# Heading\n\nContent";
148        let parser = MarkdownParser::new(content);
149        let rule = MD022;
150        let violations = rule.check(&parser, None);
151
152        assert_eq!(violations.len(), 0); // First line is exempt from "before" check
153    }
154
155    #[test]
156    fn test_fix_inserts_blank_before_heading() {
157        let content = "Paragraph\n# Heading\n\nContent\n";
158        let parser = MarkdownParser::new(content);
159        let rule = MD022;
160        let violations = rule.check(&parser, None);
161        assert_eq!(violations.len(), 1);
162        assert!(violations[0].message.contains("before"));
163        let fixed = apply_fixes(content, &violations);
164        assert_eq!(fixed, "Paragraph\n\n# Heading\n\nContent\n");
165    }
166
167    #[test]
168    fn test_fix_inserts_blank_after_heading() {
169        let content = "# Heading\nContent\n";
170        let parser = MarkdownParser::new(content);
171        let rule = MD022;
172        let violations = rule.check(&parser, None);
173        assert_eq!(violations.len(), 1);
174        assert!(violations[0].message.contains("after"));
175        let fixed = apply_fixes(content, &violations);
176        assert_eq!(fixed, "# Heading\n\nContent\n");
177    }
178}