rumdl_lib/rules/
md071_blank_line_after_frontmatter.rs

1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2use crate::rules::front_matter_utils::FrontMatterUtils;
3
4/// Rule MD071: Blank line after frontmatter
5///
6/// Ensures there is a blank line after YAML/TOML/JSON frontmatter.
7/// This improves readability and prevents issues with some markdown parsers.
8///
9/// See [docs/md071.md](../../docs/md071.md) for full documentation.
10#[derive(Clone, Default)]
11pub struct MD071BlankLineAfterFrontmatter;
12
13impl MD071BlankLineAfterFrontmatter {
14    pub fn new() -> Self {
15        Self
16    }
17}
18
19impl Rule for MD071BlankLineAfterFrontmatter {
20    fn name(&self) -> &'static str {
21        "MD071"
22    }
23
24    fn description(&self) -> &'static str {
25        "Blank line after frontmatter"
26    }
27
28    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
29        let content = ctx.content;
30        let mut warnings = Vec::new();
31
32        if content.is_empty() {
33            return Ok(warnings);
34        }
35
36        let fm_end_line = FrontMatterUtils::get_front_matter_end_line(content);
37        if fm_end_line == 0 {
38            // No frontmatter
39            return Ok(warnings);
40        }
41
42        let lines: Vec<&str> = content.lines().collect();
43
44        // fm_end_line is 1-indexed, so the line after frontmatter is at index fm_end_line
45        if let Some(next_line) = lines.get(fm_end_line)
46            && !next_line.trim().is_empty()
47        {
48            // Missing blank line after frontmatter
49            let end_col = lines.get(fm_end_line - 1).map_or(1, |l| l.len() + 1);
50            warnings.push(LintWarning {
51                rule_name: Some(self.name().to_string()),
52                message: "Missing blank line after frontmatter".to_string(),
53                line: fm_end_line, // Report on the closing delimiter line
54                column: 1,
55                end_line: fm_end_line,
56                end_column: end_col,
57                severity: Severity::Warning,
58                fix: Some(Fix {
59                    range: ctx.line_index.line_col_to_byte_range(fm_end_line, end_col),
60                    replacement: "\n".to_string(),
61                }),
62            });
63        }
64
65        Ok(warnings)
66    }
67
68    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
69        let content = ctx.content;
70        let warnings = self.check(ctx)?;
71
72        if warnings.is_empty() {
73            return Ok(content.to_string());
74        }
75
76        let fm_end_line = FrontMatterUtils::get_front_matter_end_line(content);
77        if fm_end_line == 0 {
78            return Ok(content.to_string());
79        }
80
81        let lines: Vec<&str> = content.lines().collect();
82        let mut result = Vec::new();
83
84        for (i, line) in lines.iter().enumerate() {
85            result.push((*line).to_string());
86
87            // Insert blank line after frontmatter closing delimiter (index fm_end_line - 1)
88            if i == fm_end_line - 1
89                && let Some(next_line) = lines.get(i + 1)
90                && !next_line.trim().is_empty()
91            {
92                result.push(String::new());
93            }
94        }
95
96        Ok(result.join("\n"))
97    }
98
99    fn category(&self) -> RuleCategory {
100        RuleCategory::Whitespace
101    }
102
103    fn as_any(&self) -> &dyn std::any::Any {
104        self
105    }
106
107    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
108    where
109        Self: Sized,
110    {
111        Box::new(MD071BlankLineAfterFrontmatter)
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118    use crate::lint_context::LintContext;
119
120    // ==================== Basic Tests ====================
121
122    #[test]
123    fn test_no_frontmatter() {
124        let rule = MD071BlankLineAfterFrontmatter;
125        let content = "# Heading\n\nContent.";
126        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
127        let result = rule.check(&ctx).unwrap();
128
129        assert!(result.is_empty());
130    }
131
132    #[test]
133    fn test_frontmatter_with_blank_line() {
134        let rule = MD071BlankLineAfterFrontmatter;
135        let content = "---\ntitle: Test\n---\n\n# Heading";
136        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
137        let result = rule.check(&ctx).unwrap();
138
139        assert!(result.is_empty());
140    }
141
142    #[test]
143    fn test_frontmatter_without_blank_line() {
144        let rule = MD071BlankLineAfterFrontmatter;
145        let content = "---\ntitle: Test\n---\n# Heading";
146        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
147        let result = rule.check(&ctx).unwrap();
148
149        assert_eq!(result.len(), 1);
150        assert!(result[0].message.contains("Missing blank line"));
151    }
152
153    #[test]
154    fn test_toml_frontmatter_without_blank_line() {
155        let rule = MD071BlankLineAfterFrontmatter;
156        let content = "+++\ntitle = \"Test\"\n+++\n# Heading";
157        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
158        let result = rule.check(&ctx).unwrap();
159
160        assert_eq!(result.len(), 1);
161    }
162
163    #[test]
164    fn test_json_frontmatter_without_blank_line() {
165        let rule = MD071BlankLineAfterFrontmatter;
166        let content = "{\n\"title\": \"Test\"\n}\n# Heading";
167        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
168        let result = rule.check(&ctx).unwrap();
169
170        assert_eq!(result.len(), 1);
171    }
172
173    #[test]
174    fn test_fix_adds_blank_line() {
175        let rule = MD071BlankLineAfterFrontmatter;
176        let content = "---\ntitle: Test\n---\n# Heading\n\nContent.";
177        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
178        let fixed = rule.fix(&ctx).unwrap();
179
180        let expected = "---\ntitle: Test\n---\n\n# Heading\n\nContent.";
181        assert_eq!(fixed, expected);
182    }
183
184    #[test]
185    fn test_fix_idempotent() {
186        let rule = MD071BlankLineAfterFrontmatter;
187        let content = "---\ntitle: Test\n---\n# Heading";
188        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
189        let fixed_once = rule.fix(&ctx).unwrap();
190
191        let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
192        let fixed_twice = rule.fix(&ctx2).unwrap();
193
194        assert_eq!(fixed_once, fixed_twice);
195    }
196
197    #[test]
198    fn test_frontmatter_at_end_of_file() {
199        let rule = MD071BlankLineAfterFrontmatter;
200        let content = "---\ntitle: Test\n---";
201        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
202        let result = rule.check(&ctx).unwrap();
203
204        // No content after frontmatter, no warning needed
205        assert!(result.is_empty());
206    }
207
208    #[test]
209    fn test_multiple_blank_lines_ok() {
210        let rule = MD071BlankLineAfterFrontmatter;
211        let content = "---\ntitle: Test\n---\n\n\n# Heading";
212        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
213        let result = rule.check(&ctx).unwrap();
214
215        assert!(result.is_empty());
216    }
217
218    #[test]
219    fn test_empty_content() {
220        let rule = MD071BlankLineAfterFrontmatter;
221        let content = "";
222        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
223        let result = rule.check(&ctx).unwrap();
224
225        assert!(result.is_empty());
226    }
227
228    #[test]
229    fn test_frontmatter_with_text_immediately_after() {
230        let rule = MD071BlankLineAfterFrontmatter;
231        let content = "---\ntitle: Test\n---\nSome paragraph text.";
232        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
233        let result = rule.check(&ctx).unwrap();
234
235        assert_eq!(result.len(), 1);
236    }
237
238    // ==================== Edge Case Tests ====================
239
240    #[test]
241    fn test_whitespace_only_line_after_frontmatter_is_not_blank() {
242        // A line with only spaces is NOT a blank line
243        let rule = MD071BlankLineAfterFrontmatter;
244        let content = "---\ntitle: Test\n---\n   \n# Heading";
245        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
246        let result = rule.check(&ctx).unwrap();
247
248        // Whitespace-only line should be treated as blank (trim().is_empty())
249        assert!(result.is_empty());
250    }
251
252    #[test]
253    fn test_tab_only_line_after_frontmatter() {
254        let rule = MD071BlankLineAfterFrontmatter;
255        let content = "---\ntitle: Test\n---\n\t\n# Heading";
256        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
257        let result = rule.check(&ctx).unwrap();
258
259        // Tab-only line should be treated as blank
260        assert!(result.is_empty());
261    }
262
263    #[test]
264    fn test_crlf_line_endings() {
265        let rule = MD071BlankLineAfterFrontmatter;
266        let content = "---\r\ntitle: Test\r\n---\r\n# Heading";
267        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
268        let result = rule.check(&ctx).unwrap();
269
270        // Should detect missing blank line with CRLF
271        assert_eq!(result.len(), 1);
272    }
273
274    #[test]
275    fn test_crlf_with_blank_line() {
276        let rule = MD071BlankLineAfterFrontmatter;
277        let content = "---\r\ntitle: Test\r\n---\r\n\r\n# Heading";
278        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
279        let result = rule.check(&ctx).unwrap();
280
281        assert!(result.is_empty());
282    }
283
284    #[test]
285    fn test_empty_yaml_frontmatter() {
286        let rule = MD071BlankLineAfterFrontmatter;
287        let content = "---\n---\n# Heading";
288        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
289        let result = rule.check(&ctx).unwrap();
290
291        // Empty frontmatter still needs blank line after
292        assert_eq!(result.len(), 1);
293    }
294
295    #[test]
296    fn test_empty_yaml_frontmatter_with_blank_line() {
297        let rule = MD071BlankLineAfterFrontmatter;
298        let content = "---\n---\n\n# Heading";
299        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
300        let result = rule.check(&ctx).unwrap();
301
302        assert!(result.is_empty());
303    }
304
305    #[test]
306    fn test_frontmatter_with_blank_lines_inside() {
307        let rule = MD071BlankLineAfterFrontmatter;
308        let content = "---\ntitle: Test\n\nauthor: John\n---\n# Heading";
309        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
310        let result = rule.check(&ctx).unwrap();
311
312        // Blank lines inside frontmatter don't affect the rule
313        assert_eq!(result.len(), 1);
314    }
315
316    #[test]
317    fn test_frontmatter_trailing_whitespace_on_delimiter() {
318        let rule = MD071BlankLineAfterFrontmatter;
319        let content = "---\ntitle: Test\n---   \n# Heading";
320        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
321        let result = rule.check(&ctx).unwrap();
322
323        // Trailing whitespace on delimiter should still trigger
324        assert_eq!(result.len(), 1);
325    }
326
327    #[test]
328    fn test_frontmatter_only_file() {
329        let rule = MD071BlankLineAfterFrontmatter;
330        let content = "---\ntitle: Only frontmatter\n---\n";
331        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
332        let result = rule.check(&ctx).unwrap();
333
334        // Trailing newline only, no actual content - no warning needed
335        assert!(result.is_empty());
336    }
337
338    #[test]
339    fn test_frontmatter_with_triple_dash_inside_value() {
340        let rule = MD071BlankLineAfterFrontmatter;
341        let content = "---\ntitle: \"Test --- with dashes\"\n---\n# Heading";
342        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
343        let result = rule.check(&ctx).unwrap();
344
345        // The dashes inside the value shouldn't affect parsing
346        assert_eq!(result.len(), 1);
347    }
348
349    #[test]
350    fn test_fix_preserves_content_after_frontmatter() {
351        let rule = MD071BlankLineAfterFrontmatter;
352        let content = "---\ntitle: Test\n---\n# Heading\n\nParagraph 1.\n\nParagraph 2.\n\n- List item";
353        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
354        let fixed = rule.fix(&ctx).unwrap();
355
356        // Verify content is preserved
357        assert!(fixed.contains("# Heading"));
358        assert!(fixed.contains("Paragraph 1."));
359        assert!(fixed.contains("Paragraph 2."));
360        assert!(fixed.contains("- List item"));
361        // Verify blank line was added
362        assert!(fixed.contains("---\n\n#"));
363    }
364
365    #[test]
366    fn test_fix_toml_frontmatter() {
367        let rule = MD071BlankLineAfterFrontmatter;
368        let content = "+++\ntitle = \"Test\"\n+++\n# Heading";
369        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
370        let fixed = rule.fix(&ctx).unwrap();
371
372        assert!(fixed.contains("+++\n\n#"));
373    }
374
375    #[test]
376    fn test_fix_json_frontmatter() {
377        let rule = MD071BlankLineAfterFrontmatter;
378        let content = "{\n\"title\": \"Test\"\n}\n# Heading";
379        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
380        let fixed = rule.fix(&ctx).unwrap();
381
382        assert!(fixed.contains("}\n\n#"));
383    }
384
385    #[test]
386    fn test_multiline_yaml_values() {
387        let rule = MD071BlankLineAfterFrontmatter;
388        let content = "---\ndescription: |\n  This is a\n  multiline value\ntitle: Test\n---\n# Heading";
389        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
390        let result = rule.check(&ctx).unwrap();
391
392        assert_eq!(result.len(), 1);
393    }
394
395    #[test]
396    fn test_yaml_list_values() {
397        let rule = MD071BlankLineAfterFrontmatter;
398        let content = "---\ntags:\n  - rust\n  - markdown\ntitle: Test\n---\n# Heading";
399        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
400        let result = rule.check(&ctx).unwrap();
401
402        assert_eq!(result.len(), 1);
403    }
404
405    #[test]
406    fn test_unicode_content_after_frontmatter() {
407        let rule = MD071BlankLineAfterFrontmatter;
408        let content = "---\ntitle: Test\n---\n# 日本語の見出し";
409        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
410        let result = rule.check(&ctx).unwrap();
411
412        assert_eq!(result.len(), 1);
413
414        let fixed = rule.fix(&ctx).unwrap();
415        assert!(fixed.contains("# 日本語の見出し"));
416    }
417
418    #[test]
419    fn test_fix_multiple_applications_still_idempotent() {
420        let rule = MD071BlankLineAfterFrontmatter;
421        let content = "---\ntitle: Test\n---\n# Heading";
422
423        // Apply fix 5 times
424        let mut current = content.to_string();
425        for _ in 0..5 {
426            let ctx = LintContext::new(&current, crate::config::MarkdownFlavor::Standard, None);
427            current = rule.fix(&ctx).unwrap();
428        }
429
430        // Should only have one blank line
431        assert_eq!(current.matches("\n\n").count(), 1);
432        assert!(current.contains("---\n\n#"));
433    }
434}