Skip to main content

rumdl_lib/rules/
md071_blank_line_after_frontmatter.rs

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