Skip to main content

mdlint/lint/rules/
md012.rs

1use crate::lint::rule::Rule;
2use crate::markdown::MarkdownParser;
3use crate::types::{Fix, Violation};
4use serde_json::Value;
5
6pub struct MD012;
7
8impl Rule for MD012 {
9    fn name(&self) -> &'static str {
10        "MD012"
11    }
12
13    fn description(&self) -> &'static str {
14        "Multiple consecutive blank lines"
15    }
16
17    fn tags(&self) -> &[&str] {
18        &["whitespace", "blank_lines"]
19    }
20
21    #[allow(clippy::cast_possible_truncation)] // serde_json gives u64; values are small config counts
22    fn check(&self, parser: &MarkdownParser, config: Option<&Value>) -> Vec<Violation> {
23        let maximum = config
24            .and_then(|c| c.get("maximum"))
25            .and_then(serde_json::Value::as_u64)
26            .unwrap_or(1) as usize;
27
28        let mut violations = Vec::new();
29        let mut consecutive_blank = 0;
30        let mut blank_start_line = 0;
31
32        for (line_num, line) in parser.lines().iter().enumerate() {
33            let line_number = line_num + 1;
34
35            if line.trim().is_empty() {
36                if consecutive_blank == 0 {
37                    blank_start_line = line_number;
38                }
39                consecutive_blank += 1;
40            } else {
41                if consecutive_blank > maximum {
42                    // Report a violation for each excess blank line
43                    for i in maximum..consecutive_blank {
44                        violations.push(Violation {
45                            line: blank_start_line + i,
46                            column: Some(1),
47                            rule: self.name().to_owned(),
48                            message: format!(
49                                "{} [Expected: {}; Actual: {}]",
50                                self.description(),
51                                1usize,
52                                consecutive_blank
53                            ),
54                            fix: Some(Fix {
55                                line_start: blank_start_line + i,
56                                line_end: blank_start_line + i,
57                                column_start: None,
58                                column_end: None,
59                                replacement: String::new(),
60                                description: "Remove excess blank line".to_owned(),
61                            }),
62                        });
63                    }
64                }
65                consecutive_blank = 0;
66            }
67        }
68
69        // Check if file ends with too many blank lines
70        if consecutive_blank > maximum {
71            // Report a violation for each excess blank line
72            for i in maximum..consecutive_blank {
73                violations.push(Violation {
74                    line: blank_start_line + i,
75                    column: Some(1),
76                    rule: self.name().to_owned(),
77                    message: format!("Expected: {}; Actual: {}", 1usize, consecutive_blank),
78                    fix: Some(Fix {
79                        line_start: blank_start_line + i,
80                        line_end: blank_start_line + i,
81                        column_start: None,
82                        column_end: None,
83                        replacement: String::new(),
84                        description: "Remove excess blank line".to_owned(),
85                    }),
86                });
87            }
88        }
89
90        violations
91    }
92
93    fn fixable(&self) -> bool {
94        true
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101    use crate::fix::Fixer;
102
103    fn apply_fixes(content: &str, violations: &[Violation]) -> String {
104        let fixes: Vec<_> = violations.iter().filter_map(|v| v.fix.clone()).collect();
105        Fixer::new()
106            .apply_fixes_to_content(content, &fixes)
107            .unwrap()
108    }
109
110    #[test]
111    fn test_no_consecutive_blanks() {
112        let content = "Line 1\n\nLine 2\n\nLine 3";
113        let parser = MarkdownParser::new(content);
114        let rule = MD012;
115        let violations = rule.check(&parser, None);
116
117        assert_eq!(violations.len(), 0);
118    }
119
120    #[test]
121    fn test_multiple_consecutive_blanks() {
122        let content = "Line 1\n\n\nLine 2";
123        let parser = MarkdownParser::new(content);
124        let rule = MD012;
125        let violations = rule.check(&parser, None);
126
127        assert_eq!(violations.len(), 1);
128        assert_eq!(violations[0].line, 3); // Third line is the excess blank
129    }
130
131    #[test]
132    fn test_custom_maximum() {
133        let content = "Line 1\n\n\nLine 2";
134        let parser = MarkdownParser::new(content);
135        let rule = MD012;
136        let config = serde_json::json!({ "maximum": 2 });
137        let violations = rule.check(&parser, Some(&config));
138
139        assert_eq!(violations.len(), 0); // 2 blank lines allowed
140    }
141
142    #[test]
143    fn test_trailing_blank_lines() {
144        let content = "Line 1\n\n\n";
145        let parser = MarkdownParser::new(content);
146        let rule = MD012;
147        let violations = rule.check(&parser, None);
148
149        assert_eq!(violations.len(), 1);
150    }
151
152    #[test]
153    fn test_fix_removes_excess_blank_line() {
154        let content = "Line 1\n\n\nLine 2\n";
155        let parser = MarkdownParser::new(content);
156        let rule = MD012;
157        let violations = rule.check(&parser, None);
158        assert_eq!(violations.len(), 1);
159        let fixed = apply_fixes(content, &violations);
160        assert_eq!(fixed, "Line 1\n\nLine 2\n");
161    }
162}