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) -> &'static str {
11        "MD022"
12    }
13
14    fn description(&self) -> &'static 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.get(line_idx - 1).expect("line_idx > 0").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_owned(),
48                        message: "Heading should be surrounded by blank lines (missing before)"
49                            .to_owned(),
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!(
56                                "\n{}",
57                                lines.get(line_idx).expect("heading line is valid")
58                            ),
59                            description: "Add blank line before heading".to_owned(),
60                        }),
61                    });
62                }
63            }
64
65            // Check if there's a blank line after (skip if last line)
66            if line_idx + 1 < lines.len() {
67                let next_line = lines
68                    .get(line_idx + 1)
69                    .expect("line_idx + 1 < lines.len()")
70                    .trim();
71                // Allow another heading right after (for closed headings or setext underlines)
72                if !next_line.is_empty()
73                    && !next_line.starts_with('#')
74                    && !next_line
75                        .chars()
76                        .all(|c| c == '=' || c == '-' || c.is_whitespace())
77                {
78                    // Replace the heading line with "<heading>\n" — the embedded newline
79                    // causes the Fixer to produce a blank line after the heading.
80                    violations.push(Violation {
81                        line: heading_line,
82                        column: Some(1),
83                        rule: self.name().to_owned(),
84                        message: "Heading should be surrounded by blank lines (missing after)"
85                            .to_owned(),
86                        fix: Some(Fix {
87                            line_start: heading_line,
88                            line_end: heading_line,
89                            column_start: None,
90                            column_end: None,
91                            replacement: format!(
92                                "{}\n",
93                                lines.get(line_idx).expect("heading line is valid")
94                            ),
95                            description: "Add blank line after heading".to_owned(),
96                        }),
97                    });
98                }
99            }
100        }
101
102        violations
103    }
104
105    fn fixable(&self) -> bool {
106        true
107    }
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113    use crate::fix::Fixer;
114
115    fn apply_fixes(content: &str, violations: &[Violation]) -> String {
116        let fixes: Vec<_> = violations.iter().filter_map(|v| v.fix.clone()).collect();
117        Fixer::new()
118            .apply_fixes_to_content(content, &fixes)
119            .unwrap()
120    }
121
122    #[test]
123    fn test_properly_surrounded() {
124        let content = "Paragraph\n\n# Heading\n\nAnother paragraph";
125        let parser = MarkdownParser::new(content);
126        let rule = MD022;
127        let violations = rule.check(&parser, None);
128
129        assert_eq!(violations.len(), 0);
130    }
131
132    #[test]
133    fn test_missing_blank_before() {
134        let content = "Paragraph\n# Heading\n\nContent";
135        let parser = MarkdownParser::new(content);
136        let rule = MD022;
137        let violations = rule.check(&parser, None);
138
139        assert_eq!(violations.len(), 1);
140        assert!(violations[0].message.contains("before"));
141    }
142
143    #[test]
144    fn test_missing_blank_after() {
145        let content = "\n# Heading\nContent";
146        let parser = MarkdownParser::new(content);
147        let rule = MD022;
148        let violations = rule.check(&parser, None);
149
150        assert_eq!(violations.len(), 1);
151        assert!(violations[0].message.contains("after"));
152    }
153
154    #[test]
155    fn test_first_line() {
156        let content = "# Heading\n\nContent";
157        let parser = MarkdownParser::new(content);
158        let rule = MD022;
159        let violations = rule.check(&parser, None);
160
161        assert_eq!(violations.len(), 0); // First line is exempt from "before" check
162    }
163
164    #[test]
165    fn test_fix_inserts_blank_before_heading() {
166        let content = "Paragraph\n# Heading\n\nContent\n";
167        let parser = MarkdownParser::new(content);
168        let rule = MD022;
169        let violations = rule.check(&parser, None);
170        assert_eq!(violations.len(), 1);
171        assert!(violations[0].message.contains("before"));
172        let fixed = apply_fixes(content, &violations);
173        assert_eq!(fixed, "Paragraph\n\n# Heading\n\nContent\n");
174    }
175
176    #[test]
177    fn test_fix_inserts_blank_after_heading() {
178        let content = "# Heading\nContent\n";
179        let parser = MarkdownParser::new(content);
180        let rule = MD022;
181        let violations = rule.check(&parser, None);
182        assert_eq!(violations.len(), 1);
183        assert!(violations[0].message.contains("after"));
184        let fixed = apply_fixes(content, &violations);
185        assert_eq!(fixed, "# Heading\n\nContent\n");
186    }
187}