Skip to main content

mdlint/lint/rules/
md031.rs

1use crate::lint::rule::Rule;
2use crate::markdown::MarkdownParser;
3use crate::types::{Fix, Violation};
4use pulldown_cmark::{CodeBlockKind, Event, Tag, TagEnd};
5use serde_json::Value;
6
7pub struct MD031;
8
9impl Rule for MD031 {
10    fn name(&self) -> &'static str {
11        "MD031"
12    }
13
14    fn description(&self) -> &'static str {
15        "Fenced code blocks should be surrounded by blank lines"
16    }
17
18    fn tags(&self) -> &[&str] {
19        &["code", "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        // Find fenced code block boundaries
27        let mut code_block_starts = Vec::new();
28        let mut code_block_ends = Vec::new();
29        let mut in_fenced_block = false;
30
31        for (event, range) in parser.parse_with_offsets() {
32            match event {
33                Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(_))) => {
34                    let line = parser.offset_to_line(range.start);
35                    code_block_starts.push(line);
36                    in_fenced_block = true;
37                }
38                Event::Start(Tag::CodeBlock(CodeBlockKind::Indented)) => {
39                    // Track that we're in an indented block, but don't record it
40                    in_fenced_block = false;
41                }
42                Event::End(TagEnd::CodeBlock) if in_fenced_block => {
43                    let line = parser.offset_to_line(range.end);
44                    code_block_ends.push(line);
45                    in_fenced_block = false;
46                }
47                _ => {}
48            }
49        }
50
51        // Check each code block
52        for &start_line in &code_block_starts {
53            let line_idx = start_line - 1;
54
55            // Check blank line before (skip if first line)
56            if line_idx > 0 {
57                let prev_line = lines.get(line_idx - 1).expect("line_idx > 0").trim();
58                if !prev_line.is_empty() {
59                    // Insert blank line before code block
60                    violations.push(Violation {
61                        line: start_line,
62                        column: Some(1),
63                        rule: self.name().to_owned(),
64                        message:
65                            "Fenced code blocks should be surrounded by blank lines (missing before)".to_owned(),
66                        fix: Some(Fix {
67                            line_start: start_line,
68                            line_end: start_line,
69                            column_start: None,
70                            column_end: None,
71                            replacement: format!("\n{}", lines.get(line_idx).expect("line_idx bounded")),
72                            description: "Add blank line before code block".to_owned(),
73                        }),
74                    });
75                }
76            }
77        }
78
79        for &end_line in &code_block_ends {
80            let line_idx = end_line - 1;
81
82            // Check blank line after (skip if last line)
83            if line_idx + 1 < lines.len() {
84                let next_line = lines
85                    .get(line_idx + 1)
86                    .expect("line_idx + 1 < lines.len()")
87                    .trim();
88                if !next_line.is_empty() {
89                    // Insert blank line after code block
90                    violations.push(Violation {
91                        line: end_line,
92                        column: Some(1),
93                        rule: self.name().to_owned(),
94                        message:
95                            "Fenced code blocks should be surrounded by blank lines (missing after)"
96                                .to_owned(),
97                        fix: Some(Fix {
98                            line_start: end_line,
99                            line_end: end_line,
100                            column_start: None,
101                            column_end: None,
102                            replacement: format!(
103                                "{}\n",
104                                lines.get(line_idx).expect("line_idx bounded")
105                            ),
106                            description: "Add blank line after code block".to_owned(),
107                        }),
108                    });
109                }
110            }
111        }
112
113        violations
114    }
115
116    fn fixable(&self) -> bool {
117        true
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    #[test]
126    fn test_properly_surrounded() {
127        let content = "Text\n\n```\ncode\n```\n\nMore text";
128        let parser = MarkdownParser::new(content);
129        let rule = MD031;
130        let violations = rule.check(&parser, None);
131
132        assert_eq!(violations.len(), 0);
133    }
134
135    #[test]
136    fn test_missing_blank_before() {
137        let content = "Text\n```\ncode\n```\n\nMore text";
138        let parser = MarkdownParser::new(content);
139        let rule = MD031;
140        let violations = rule.check(&parser, None);
141
142        assert!(!violations.is_empty());
143        assert!(violations.iter().any(|v| v.message.contains("before")));
144    }
145
146    #[test]
147    fn test_missing_blank_after() {
148        let content = "Text\n\n```\ncode\n```\nMore text";
149        let parser = MarkdownParser::new(content);
150        let rule = MD031;
151        let violations = rule.check(&parser, None);
152
153        assert!(!violations.is_empty());
154        assert!(violations.iter().any(|v| v.message.contains("after")));
155    }
156
157    #[test]
158    fn test_first_line() {
159        let content = "```\ncode\n```\n\nText";
160        let parser = MarkdownParser::new(content);
161        let rule = MD031;
162        let violations = rule.check(&parser, None);
163
164        assert_eq!(violations.len(), 0); // First line exempt from "before" check
165    }
166
167    #[test]
168    fn test_numbered_list_with_code_block() {
169        // Test that code blocks in numbered lists get proper fixes
170        let content = "1. **Enable/Disable a rule:**\n   ```toml\n   [rules.MD013]\n   enabled = false\n   ```\n\n2. **Next item**";
171        let parser = MarkdownParser::new(content);
172        let rule = MD031;
173        let violations = rule.check(&parser, None);
174
175        // Should detect missing blank line before code block
176        assert!(!violations.is_empty());
177        assert!(violations.iter().any(|v| v.message.contains("before")));
178
179        // Check that fix has correct line numbers
180        if let Some(fix) = &violations[0].fix {
181            // The code block starts at line 2, so fix should target line 2
182            assert_eq!(fix.line_start, 2);
183            assert_eq!(fix.line_end, 2);
184            // Replacement should be newline + original line content
185            assert!(fix.replacement.starts_with('\n'));
186        }
187    }
188
189    #[test]
190    fn test_fix_creates_blank_line() {
191        use crate::fix::Fixer;
192
193        let content = "Text\n```\ncode\n```\nMore";
194        let parser = MarkdownParser::new(content);
195        let rule = MD031;
196        let violations = rule.check(&parser, None);
197
198        assert_eq!(violations.len(), 2); // Missing before and after
199
200        // Apply fixes
201        let fixes: Vec<_> = violations.iter().filter_map(|v| v.fix.clone()).collect();
202        let runner = Fixer::new();
203        let result = runner.apply_fixes_to_content(content, &fixes).unwrap();
204
205        // Verify blank lines were added
206        let expected = "Text\n\n```\ncode\n```\n\nMore";
207        assert_eq!(result, expected);
208    }
209}