mdlint/lint/rules/
md022.rs1use 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 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 if line_idx > 0 {
40 let prev_line = lines[line_idx - 1].trim();
41 if !prev_line.is_empty() {
42 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 if line_idx + 1 < lines.len() {
64 let next_line = lines[line_idx + 1].trim();
65 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 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); }
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}