1use serde::Deserialize;
2use std::rc::Rc;
3use tree_sitter::Node;
4
5use crate::linter::{range_from_tree_sitter, Context, RuleLinter, RuleViolation};
6
7use super::{Rule, RuleType};
8
9#[derive(Debug, PartialEq, Clone, Deserialize)]
11pub struct MD022HeadingsBlanksTable {
12 #[serde(default)]
13 pub lines_above: Vec<i32>,
14 #[serde(default)]
15 pub lines_below: Vec<i32>,
16}
17
18impl Default for MD022HeadingsBlanksTable {
19 fn default() -> Self {
20 Self {
21 lines_above: vec![1],
22 lines_below: vec![1],
23 }
24 }
25}
26
27pub(crate) struct MD022Linter {
28 context: Rc<Context>,
29 violations: Vec<RuleViolation>,
30}
31
32impl MD022Linter {
33 pub fn new(context: Rc<Context>) -> Self {
34 Self {
35 context,
36 violations: Vec::new(),
37 }
38 }
39
40 fn get_lines_above(&self, heading_level: usize) -> i32 {
41 let config = &self.context.config.linters.settings.headings_blanks;
42 if heading_level > 0 && heading_level <= config.lines_above.len() {
43 config.lines_above[heading_level - 1]
44 } else if !config.lines_above.is_empty() {
45 config.lines_above[0]
46 } else {
47 1 }
49 }
50
51 fn get_lines_below(&self, heading_level: usize) -> i32 {
52 let config = &self.context.config.linters.settings.headings_blanks;
53 if heading_level > 0 && heading_level <= config.lines_below.len() {
54 config.lines_below[heading_level - 1]
55 } else if !config.lines_below.is_empty() {
56 config.lines_below[0]
57 } else {
58 1 }
60 }
61
62 fn get_heading_level(&self, node: &Node) -> usize {
63 match node.kind() {
64 "atx_heading" => {
65 for i in 0..node.child_count() {
67 let child = node.child(i).unwrap();
68 if child.kind().starts_with("atx_h") && child.kind().ends_with("_marker") {
69 return child.kind().chars().nth(5).unwrap().to_digit(10).unwrap() as usize;
71 }
72 }
73 1 }
75 "setext_heading" => {
76 for i in 0..node.child_count() {
78 let child = node.child(i).unwrap();
79 if child.kind() == "setext_h1_underline" {
80 return 1;
81 } else if child.kind() == "setext_h2_underline" {
82 return 2;
83 }
84 }
85 1 }
87 _ => 1,
88 }
89 }
90
91 fn is_line_blank(&self, line_number: usize) -> bool {
92 let lines = self.context.lines.borrow();
93 if line_number < lines.len() {
94 lines[line_number].trim().is_empty()
95 } else {
96 true }
98 }
99
100 fn count_blank_lines_above(&self, start_line: usize) -> usize {
101 if start_line == 0 {
102 return 0; }
104
105 let mut count = 0;
106 let mut line_idx = start_line - 1;
107
108 loop {
109 if self.is_line_blank(line_idx) {
110 count += 1;
111 if line_idx == 0 {
112 break;
113 }
114 line_idx -= 1;
115 } else {
116 break;
117 }
118 }
119
120 count
121 }
122
123 fn count_blank_lines_below(&self, end_line: usize) -> usize {
124 let lines = self.context.lines.borrow();
125 let mut count = 0;
126 let mut line_idx = end_line + 1;
127
128 while line_idx < lines.len() && self.is_line_blank(line_idx) {
129 count += 1;
130 line_idx += 1;
131 }
132
133 count
134 }
135
136 fn check_heading(&mut self, node: &Node) {
137 let level = self.get_heading_level(node);
138 let required_above = self.get_lines_above(level);
139 let required_below = self.get_lines_below(level);
140
141 let start_line = node.start_position().row;
142 let end_line = node.end_position().row;
143
144 let actual_start_line = if node.kind() == "setext_heading" {
147 let mut heading_text_line = start_line;
149 for i in 0..node.child_count() {
150 let child = node.child(i).unwrap();
151 if child.kind() == "paragraph" {
152 heading_text_line = child.start_position().row;
153 break;
154 }
155 }
156 heading_text_line
157 } else {
158 start_line
159 };
160
161 let lines = self.context.lines.borrow();
162
163 if required_above >= 0 && actual_start_line > 0 {
165 let has_content_above = (0..actual_start_line).any(|i| !self.is_line_blank(i));
167
168 if has_content_above {
169 let actual_above = self.count_blank_lines_above(actual_start_line);
170 if (actual_above as i32) < required_above {
171 self.violations.push(RuleViolation::new(
172 &MD022,
173 format!(
174 "{} [Above: Expected: {}; Actual: {}]",
175 MD022.description, required_above, actual_above
176 ),
177 self.context.file_path.clone(),
178 range_from_tree_sitter(&node.range()),
179 ));
180 }
181 }
182 }
183
184 let effective_end_line = match node.kind() {
188 "atx_heading" => actual_start_line,
189 "setext_heading" => {
190 let mut underline_line = end_line;
192 for i in 0..node.child_count() {
193 let child = node.child(i).unwrap();
194 if child.kind() == "setext_h1_underline"
195 || child.kind() == "setext_h2_underline"
196 {
197 underline_line = child.start_position().row;
198 break;
199 }
200 }
201 underline_line
202 }
203 _ => end_line,
204 };
205
206 if required_below >= 0 && effective_end_line + 1 < lines.len() {
207 let has_content_below =
209 ((effective_end_line + 1)..lines.len()).any(|i| !self.is_line_blank(i));
210
211 if has_content_below {
212 let actual_below = self.count_blank_lines_below(effective_end_line);
213 if (actual_below as i32) < required_below {
214 self.violations.push(RuleViolation::new(
215 &MD022,
216 format!(
217 "{} [Below: Expected: {}; Actual: {}]",
218 MD022.description, required_below, actual_below
219 ),
220 self.context.file_path.clone(),
221 range_from_tree_sitter(&node.range()),
222 ));
223 }
224 }
225 }
226 }
227}
228
229impl RuleLinter for MD022Linter {
230 fn feed(&mut self, node: &Node) {
231 if node.kind() == "atx_heading" || node.kind() == "setext_heading" {
232 self.check_heading(node);
233 }
234 }
235
236 fn finalize(&mut self) -> Vec<RuleViolation> {
237 std::mem::take(&mut self.violations)
238 }
239}
240
241pub const MD022: Rule = Rule {
242 id: "MD022",
243 alias: "blanks-around-headings",
244 tags: &["headings", "blank_lines"],
245 description: "Headings should be surrounded by blank lines",
246 rule_type: RuleType::Hybrid,
247 required_nodes: &["atx_heading", "setext_heading"],
248 new_linter: |context| Box::new(MD022Linter::new(context)),
249};
250
251#[cfg(test)]
252mod test {
253 use std::path::PathBuf;
254
255 use crate::config::{LintersSettingsTable, MD022HeadingsBlanksTable, RuleSeverity};
256 use crate::linter::MultiRuleLinter;
257 use crate::test_utils::test_helpers::test_config_with_settings;
258
259 fn test_config_with_blanks(
260 blanks_config: MD022HeadingsBlanksTable,
261 ) -> crate::config::QuickmarkConfig {
262 test_config_with_settings(
263 vec![
264 ("blanks-around-headings", RuleSeverity::Error),
265 ("heading-style", RuleSeverity::Off),
266 ("heading-increment", RuleSeverity::Off),
267 ],
268 LintersSettingsTable {
269 headings_blanks: blanks_config,
270 ..Default::default()
271 },
272 )
273 }
274
275 #[test]
276 fn test_default_config() {
277 let config = test_config_with_blanks(MD022HeadingsBlanksTable::default());
278
279 let input = "Some text
281# Heading 1
282";
283 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
284 let violations = linter.analyze();
285 assert_eq!(1, violations.len());
286 assert!(violations[0]
287 .message()
288 .contains("Above: Expected: 1; Actual: 0"));
289 }
290
291 #[test]
292 fn test_no_violation_with_correct_blanks() {
293 let config = test_config_with_blanks(MD022HeadingsBlanksTable::default());
294
295 let input = "Some text
296
297# Heading 1
298
299More text";
300 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
301 let violations = linter.analyze();
302 assert_eq!(0, violations.len());
303 }
304
305 #[test]
306 fn test_missing_blank_line_above() {
307 let config = test_config_with_blanks(MD022HeadingsBlanksTable::default());
308
309 let input = "Some text
310# Heading 1
311
312More text";
313 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
314 let violations = linter.analyze();
315 assert_eq!(1, violations.len());
316 assert!(violations[0]
317 .message()
318 .contains("Above: Expected: 1; Actual: 0"));
319 }
320
321 #[test]
322 fn test_missing_blank_line_below() {
323 let config = test_config_with_blanks(MD022HeadingsBlanksTable::default());
324
325 let input = "Some text
326
327# Heading 1
328More text";
329 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
330 let violations = linter.analyze();
331 assert_eq!(1, violations.len());
332 assert!(violations[0]
333 .message()
334 .contains("Below: Expected: 1; Actual: 0"));
335 }
336
337 #[test]
338 fn test_both_missing_blank_lines() {
339 let config = test_config_with_blanks(MD022HeadingsBlanksTable::default());
340
341 let input = "Some text
342# Heading 1
343More text";
344 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
345 let violations = linter.analyze();
346 assert_eq!(2, violations.len());
347 assert!(violations[0]
348 .message()
349 .contains("Above: Expected: 1; Actual: 0"));
350 assert!(violations[1]
351 .message()
352 .contains("Below: Expected: 1; Actual: 0"));
353 }
354
355 #[test]
356 fn test_setext_headings() {
357 let config = test_config_with_blanks(MD022HeadingsBlanksTable::default());
358
359 let input = "Some text
360Heading 1
361=========
362More text";
363 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
364 let violations = linter.analyze();
365
366 assert_eq!(1, violations.len());
369 assert!(violations[0]
370 .message()
371 .contains("Below: Expected: 1; Actual: 0"));
372 }
373
374 #[test]
375 fn test_custom_lines_above() {
376 let config = test_config_with_blanks(MD022HeadingsBlanksTable {
377 lines_above: vec![2],
378 lines_below: vec![1],
379 });
380
381 let input = "Some text
382
383# Heading 1
384
385More text";
386 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
387 let violations = linter.analyze();
388 assert_eq!(1, violations.len());
389 assert!(violations[0]
390 .message()
391 .contains("Above: Expected: 2; Actual: 1"));
392 }
393
394 #[test]
395 fn test_custom_lines_below() {
396 let config = test_config_with_blanks(MD022HeadingsBlanksTable {
397 lines_above: vec![1],
398 lines_below: vec![2],
399 });
400
401 let input = "Some text
402
403# Heading 1
404
405More text";
406 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
407 let violations = linter.analyze();
408 assert_eq!(1, violations.len());
409 assert!(violations[0]
410 .message()
411 .contains("Below: Expected: 2; Actual: 1"));
412 }
413
414 #[test]
415 fn test_heading_at_start_of_document() {
416 let config = test_config_with_blanks(MD022HeadingsBlanksTable::default());
417
418 let input = "# Heading 1
419
420More text";
421 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
422 let violations = linter.analyze();
423 assert_eq!(0, violations.len());
425 }
426
427 #[test]
428 fn test_heading_at_end_of_document() {
429 let config = test_config_with_blanks(MD022HeadingsBlanksTable::default());
430
431 let input = "Some text
432
433# Heading 1";
434 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
435 let violations = linter.analyze();
436 assert_eq!(0, violations.len());
438 }
439
440 #[test]
441 fn test_disable_with_negative_one() {
442 let config = test_config_with_blanks(MD022HeadingsBlanksTable {
443 lines_above: vec![-1], lines_below: vec![1],
445 });
446
447 let input = "Some text
448# Heading 1
449
450More text";
451 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
452 let violations = linter.analyze();
453 assert_eq!(0, violations.len());
455 }
456
457 #[test]
458 fn test_per_heading_level_config() {
459 let config = test_config_with_blanks(MD022HeadingsBlanksTable {
460 lines_above: vec![1, 2, 0], lines_below: vec![1, 1, 1],
462 });
463
464 let input = "Text
465
466# Level 1 - good
467
468
469## Level 2 - good
470
471### Level 3 - good
472
473Text";
474 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
475 let violations = linter.analyze();
476 assert_eq!(0, violations.len());
477 }
478
479 #[test]
480 fn test_per_heading_level_violations() {
481 let config = test_config_with_blanks(MD022HeadingsBlanksTable {
482 lines_above: vec![1, 2, 0], lines_below: vec![1, 1, 1],
484 });
485
486 let input = "Text
487
488# Level 1 - good
489
490## Level 2 - bad (needs 2 above)
491
492### Level 3 - good
493
494Text";
495 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
496 let violations = linter.analyze();
497 assert_eq!(1, violations.len());
498 assert!(violations[0]
499 .message()
500 .contains("Above: Expected: 2; Actual: 1"));
501 }
502}