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) -> &'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 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.get(line_idx - 1).expect("line_idx > 0").trim();
41 if !prev_line.is_empty() {
42 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 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 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 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); }
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}