quickmark_core/rules/
md022.rs

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// MD022-specific configuration types
10#[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 // Default
48        }
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 // Default
59        }
60    }
61
62    fn get_heading_level(&self, node: &Node) -> usize {
63        match node.kind() {
64            "atx_heading" => {
65                // Look for atx_hX_marker
66                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                        // "atx_h3_marker" => 3
70                        return child.kind().chars().nth(5).unwrap().to_digit(10).unwrap() as usize;
71                    }
72                }
73                1 // fallback
74            }
75            "setext_heading" => {
76                // Look for setext_h1_underline or setext_h2_underline
77                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 // fallback
86            }
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 // Consider out-of-bounds lines as blank
97        }
98    }
99
100    fn count_blank_lines_above(&self, start_line: usize) -> usize {
101        if start_line == 0 {
102            return 0; // No lines above first line
103        }
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        // For setext headings, tree-sitter sometimes includes preceding content
145        // We need to find the actual heading text line
146        let actual_start_line = if node.kind() == "setext_heading" {
147            // For setext headings, find the paragraph child which contains the heading text
148            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        // Check lines above (only if required_above >= 0 and there's content above)
164        if required_above >= 0 && actual_start_line > 0 {
165            // Check if there's actual content above (not just blank lines)
166            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        // Check lines below (only if required_below >= 0 and there's content below)
185        // For ATX headings, they span one line (start_line)
186        // For setext headings, they span two lines (text line + underline line)
187        let effective_end_line = match node.kind() {
188            "atx_heading" => actual_start_line,
189            "setext_heading" => {
190                // Find the underline line (setext_h1_underline or setext_h2_underline)
191                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            // Check if there's actual content below (not just blank lines)
208            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        // Test violation: missing blank line above
280        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        // Original markdownlint only finds the "Below" violation for this case
367        // because tree-sitter includes preceding content in setext heading
368        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        // Should not violate - no content above to require blank line
424        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        // Should not violate - no content below to require blank line
437        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], // -1 means allow any number of blank lines
444            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        // Should not violate for lines above since -1 allows any number
454        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], // Level 1: 1 line, Level 2: 2 lines, Level 3: 0 lines
461            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], // Level 1: 1 line, Level 2: 2 lines, Level 3: 0 lines
483            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}