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) -> &str {
11        "MD031"
12    }
13
14    fn description(&self) -> &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[line_idx - 1].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_string(),
64                        message:
65                            "Fenced code blocks should be surrounded by blank lines (missing before)"
66                                .to_string(),
67                        fix: Some(Fix {
68                            line_start: start_line,
69                            line_end: start_line,
70                            column_start: None,
71                            column_end: None,
72                            replacement: format!("\n{}", lines[line_idx]),
73                            description: "Add blank line before code block".to_string(),
74                        }),
75                    });
76                }
77            }
78        }
79
80        for &end_line in &code_block_ends {
81            let line_idx = end_line - 1;
82
83            // Check blank line after (skip if last line)
84            if line_idx + 1 < lines.len() {
85                let next_line = lines[line_idx + 1].trim();
86                if !next_line.is_empty() {
87                    // Insert blank line after code block
88                    violations.push(Violation {
89                        line: end_line,
90                        column: Some(1),
91                        rule: self.name().to_string(),
92                        message:
93                            "Fenced code blocks should be surrounded by blank lines (missing after)"
94                                .to_string(),
95                        fix: Some(Fix {
96                            line_start: end_line,
97                            line_end: end_line,
98                            column_start: None,
99                            column_end: None,
100                            replacement: format!("{}\n", lines[line_idx]),
101                            description: "Add blank line after code block".to_string(),
102                        }),
103                    });
104                }
105            }
106        }
107
108        violations
109    }
110
111    fn fixable(&self) -> bool {
112        true
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    #[test]
121    fn test_properly_surrounded() {
122        let content = "Text\n\n```\ncode\n```\n\nMore text";
123        let parser = MarkdownParser::new(content);
124        let rule = MD031;
125        let violations = rule.check(&parser, None);
126
127        assert_eq!(violations.len(), 0);
128    }
129
130    #[test]
131    fn test_missing_blank_before() {
132        let content = "Text\n```\ncode\n```\n\nMore text";
133        let parser = MarkdownParser::new(content);
134        let rule = MD031;
135        let violations = rule.check(&parser, None);
136
137        assert!(!violations.is_empty());
138        assert!(violations.iter().any(|v| v.message.contains("before")));
139    }
140
141    #[test]
142    fn test_missing_blank_after() {
143        let content = "Text\n\n```\ncode\n```\nMore text";
144        let parser = MarkdownParser::new(content);
145        let rule = MD031;
146        let violations = rule.check(&parser, None);
147
148        assert!(!violations.is_empty());
149        assert!(violations.iter().any(|v| v.message.contains("after")));
150    }
151
152    #[test]
153    fn test_first_line() {
154        let content = "```\ncode\n```\n\nText";
155        let parser = MarkdownParser::new(content);
156        let rule = MD031;
157        let violations = rule.check(&parser, None);
158
159        assert_eq!(violations.len(), 0); // First line exempt from "before" check
160    }
161
162    #[test]
163    fn test_numbered_list_with_code_block() {
164        // Test that code blocks in numbered lists get proper fixes
165        let content = "1. **Enable/Disable a rule:**\n   ```toml\n   [rules.MD013]\n   enabled = false\n   ```\n\n2. **Next item**";
166        let parser = MarkdownParser::new(content);
167        let rule = MD031;
168        let violations = rule.check(&parser, None);
169
170        // Should detect missing blank line before code block
171        assert!(!violations.is_empty());
172        assert!(violations.iter().any(|v| v.message.contains("before")));
173
174        // Check that fix has correct line numbers
175        if let Some(fix) = &violations[0].fix {
176            // The code block starts at line 2, so fix should target line 2
177            assert_eq!(fix.line_start, 2);
178            assert_eq!(fix.line_end, 2);
179            // Replacement should be newline + original line content
180            assert!(fix.replacement.starts_with('\n'));
181        }
182    }
183
184    #[test]
185    fn test_fix_creates_blank_line() {
186        use crate::fix::Fixer;
187
188        let content = "Text\n```\ncode\n```\nMore";
189        let parser = MarkdownParser::new(content);
190        let rule = MD031;
191        let violations = rule.check(&parser, None);
192
193        assert_eq!(violations.len(), 2); // Missing before and after
194
195        // Apply fixes
196        let fixes: Vec<_> = violations.iter().filter_map(|v| v.fix.clone()).collect();
197        let fixer = Fixer::new();
198        let fixed = fixer.apply_fixes_to_content(content, &fixes).unwrap();
199
200        // Verify blank lines were added
201        let expected = "Text\n\n```\ncode\n```\n\nMore";
202        assert_eq!(fixed, expected);
203    }
204}