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        // `raw_lines` holds no entry after the final newline, so the file's own
101        // final newline is restored after the join, even when the last line is
102        // blank.
103        let mut fixed = result.join("\n");
104        if had_trailing_newline {
105            fixed.push('\n');
106        }
107
108        Ok(fixed)
109    }
110
111    fn category(&self) -> RuleCategory {
112        RuleCategory::FrontMatter
113    }
114
115    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
116        ctx.content.is_empty() || !ctx.content.starts_with("---") && !ctx.content.starts_with("+++")
117    }
118
119    fn as_any(&self) -> &dyn std::any::Any {
120        self
121    }
122
123    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
124    where
125        Self: Sized,
126    {
127        Box::new(MD071BlankLineAfterFrontmatter)
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134    use crate::lint_context::LintContext;
135
136    // ==================== Basic Tests ====================
137
138    #[test]
139    fn test_no_frontmatter() {
140        let rule = MD071BlankLineAfterFrontmatter;
141        let content = "# Heading\n\nContent.";
142        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
143        let result = rule.check(&ctx).unwrap();
144
145        assert!(result.is_empty());
146    }
147
148    #[test]
149    fn test_frontmatter_with_blank_line() {
150        let rule = MD071BlankLineAfterFrontmatter;
151        let content = "---\ntitle: Test\n---\n\n# Heading";
152        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
153        let result = rule.check(&ctx).unwrap();
154
155        assert!(result.is_empty());
156    }
157
158    #[test]
159    fn test_frontmatter_without_blank_line() {
160        let rule = MD071BlankLineAfterFrontmatter;
161        let content = "---\ntitle: Test\n---\n# Heading";
162        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
163        let result = rule.check(&ctx).unwrap();
164
165        assert_eq!(result.len(), 1);
166        assert!(result[0].message.contains("Missing blank line"));
167    }
168
169    #[test]
170    fn test_toml_frontmatter_without_blank_line() {
171        let rule = MD071BlankLineAfterFrontmatter;
172        let content = "+++\ntitle = \"Test\"\n+++\n# Heading";
173        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
174        let result = rule.check(&ctx).unwrap();
175
176        assert_eq!(result.len(), 1);
177    }
178
179    #[test]
180    fn test_json_frontmatter_without_blank_line() {
181        let rule = MD071BlankLineAfterFrontmatter;
182        let content = "{\n\"title\": \"Test\"\n}\n# Heading";
183        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
184        let result = rule.check(&ctx).unwrap();
185
186        assert_eq!(result.len(), 1);
187    }
188
189    #[test]
190    fn test_fix_adds_blank_line() {
191        let rule = MD071BlankLineAfterFrontmatter;
192        let content = "---\ntitle: Test\n---\n# Heading\n\nContent.";
193        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
194        let fixed = rule.fix(&ctx).unwrap();
195
196        let expected = "---\ntitle: Test\n---\n\n# Heading\n\nContent.";
197        assert_eq!(fixed, expected);
198    }
199
200    #[test]
201    fn test_fix_keeps_the_blank_lines_at_the_end_of_the_file() {
202        let rule = MD071BlankLineAfterFrontmatter;
203        for (content, expected) in [
204            ("---\na: 1\n---\ntext\n\n", "---\na: 1\n---\n\ntext\n\n"),
205            ("---\na: 1\n---\ntext\n\n\n", "---\na: 1\n---\n\ntext\n\n\n"),
206            ("---\na: 1\n---\ntext\n", "---\na: 1\n---\n\ntext\n"),
207            ("---\na: 1\n---\ntext", "---\na: 1\n---\n\ntext"),
208        ] {
209            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
210            assert_eq!(rule.fix(&ctx).unwrap(), expected, "{content:?}");
211        }
212    }
213
214    #[test]
215    fn test_fix_idempotent() {
216        let rule = MD071BlankLineAfterFrontmatter;
217        let content = "---\ntitle: Test\n---\n# Heading";
218        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
219        let fixed_once = rule.fix(&ctx).unwrap();
220
221        let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
222        let fixed_twice = rule.fix(&ctx2).unwrap();
223
224        assert_eq!(fixed_once, fixed_twice);
225    }
226
227    #[test]
228    fn test_frontmatter_at_end_of_file() {
229        let rule = MD071BlankLineAfterFrontmatter;
230        let content = "---\ntitle: Test\n---";
231        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
232        let result = rule.check(&ctx).unwrap();
233
234        // No content after frontmatter, no warning needed
235        assert!(result.is_empty());
236    }
237
238    #[test]
239    fn test_multiple_blank_lines_ok() {
240        let rule = MD071BlankLineAfterFrontmatter;
241        let content = "---\ntitle: Test\n---\n\n\n# Heading";
242        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
243        let result = rule.check(&ctx).unwrap();
244
245        assert!(result.is_empty());
246    }
247
248    #[test]
249    fn test_empty_content() {
250        let rule = MD071BlankLineAfterFrontmatter;
251        let content = "";
252        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
253        let result = rule.check(&ctx).unwrap();
254
255        assert!(result.is_empty());
256    }
257
258    #[test]
259    fn test_frontmatter_with_text_immediately_after() {
260        let rule = MD071BlankLineAfterFrontmatter;
261        let content = "---\ntitle: Test\n---\nSome paragraph text.";
262        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
263        let result = rule.check(&ctx).unwrap();
264
265        assert_eq!(result.len(), 1);
266    }
267
268    // ==================== Edge Case Tests ====================
269
270    #[test]
271    fn test_whitespace_only_line_after_frontmatter_is_not_blank() {
272        // A line with only spaces is NOT a blank line
273        let rule = MD071BlankLineAfterFrontmatter;
274        let content = "---\ntitle: Test\n---\n   \n# Heading";
275        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
276        let result = rule.check(&ctx).unwrap();
277
278        // Whitespace-only line should be treated as blank (trim().is_empty())
279        assert!(result.is_empty());
280    }
281
282    #[test]
283    fn test_tab_only_line_after_frontmatter() {
284        let rule = MD071BlankLineAfterFrontmatter;
285        let content = "---\ntitle: Test\n---\n\t\n# Heading";
286        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
287        let result = rule.check(&ctx).unwrap();
288
289        // Tab-only line should be treated as blank
290        assert!(result.is_empty());
291    }
292
293    #[test]
294    fn test_crlf_line_endings() {
295        let rule = MD071BlankLineAfterFrontmatter;
296        let content = "---\r\ntitle: Test\r\n---\r\n# Heading";
297        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
298        let result = rule.check(&ctx).unwrap();
299
300        // Should detect missing blank line with CRLF
301        assert_eq!(result.len(), 1);
302    }
303
304    #[test]
305    fn test_crlf_with_blank_line() {
306        let rule = MD071BlankLineAfterFrontmatter;
307        let content = "---\r\ntitle: Test\r\n---\r\n\r\n# Heading";
308        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
309        let result = rule.check(&ctx).unwrap();
310
311        assert!(result.is_empty());
312    }
313
314    #[test]
315    fn test_empty_yaml_frontmatter() {
316        let rule = MD071BlankLineAfterFrontmatter;
317        let content = "---\n---\n# Heading";
318        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
319        let result = rule.check(&ctx).unwrap();
320
321        // Empty frontmatter still needs blank line after
322        assert_eq!(result.len(), 1);
323    }
324
325    #[test]
326    fn test_empty_yaml_frontmatter_with_blank_line() {
327        let rule = MD071BlankLineAfterFrontmatter;
328        let content = "---\n---\n\n# Heading";
329        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
330        let result = rule.check(&ctx).unwrap();
331
332        assert!(result.is_empty());
333    }
334
335    #[test]
336    fn test_frontmatter_with_blank_lines_inside() {
337        let rule = MD071BlankLineAfterFrontmatter;
338        let content = "---\ntitle: Test\n\nauthor: John\n---\n# Heading";
339        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
340        let result = rule.check(&ctx).unwrap();
341
342        // Blank lines inside frontmatter don't affect the rule
343        assert_eq!(result.len(), 1);
344    }
345
346    #[test]
347    fn test_frontmatter_trailing_whitespace_on_delimiter() {
348        let rule = MD071BlankLineAfterFrontmatter;
349        let content = "---\ntitle: Test\n---   \n# Heading";
350        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
351        let result = rule.check(&ctx).unwrap();
352
353        // Trailing whitespace on delimiter should still trigger
354        assert_eq!(result.len(), 1);
355    }
356
357    #[test]
358    fn test_frontmatter_only_file() {
359        let rule = MD071BlankLineAfterFrontmatter;
360        let content = "---\ntitle: Only frontmatter\n---\n";
361        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
362        let result = rule.check(&ctx).unwrap();
363
364        // Trailing newline only, no actual content - no warning needed
365        assert!(result.is_empty());
366    }
367
368    #[test]
369    fn test_frontmatter_with_triple_dash_inside_value() {
370        let rule = MD071BlankLineAfterFrontmatter;
371        let content = "---\ntitle: \"Test --- with dashes\"\n---\n# Heading";
372        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
373        let result = rule.check(&ctx).unwrap();
374
375        // The dashes inside the value shouldn't affect parsing
376        assert_eq!(result.len(), 1);
377    }
378
379    #[test]
380    fn test_fix_preserves_content_after_frontmatter() {
381        let rule = MD071BlankLineAfterFrontmatter;
382        let content = "---\ntitle: Test\n---\n# Heading\n\nParagraph 1.\n\nParagraph 2.\n\n- List item";
383        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
384        let fixed = rule.fix(&ctx).unwrap();
385
386        // Verify content is preserved
387        assert!(fixed.contains("# Heading"));
388        assert!(fixed.contains("Paragraph 1."));
389        assert!(fixed.contains("Paragraph 2."));
390        assert!(fixed.contains("- List item"));
391        // Verify blank line was added
392        assert!(fixed.contains("---\n\n#"));
393    }
394
395    #[test]
396    fn test_fix_toml_frontmatter() {
397        let rule = MD071BlankLineAfterFrontmatter;
398        let content = "+++\ntitle = \"Test\"\n+++\n# Heading";
399        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
400        let fixed = rule.fix(&ctx).unwrap();
401
402        assert!(fixed.contains("+++\n\n#"));
403    }
404
405    #[test]
406    fn test_fix_json_frontmatter() {
407        let rule = MD071BlankLineAfterFrontmatter;
408        let content = "{\n\"title\": \"Test\"\n}\n# Heading";
409        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
410        let fixed = rule.fix(&ctx).unwrap();
411
412        assert!(fixed.contains("}\n\n#"));
413    }
414
415    #[test]
416    fn test_multiline_yaml_values() {
417        let rule = MD071BlankLineAfterFrontmatter;
418        let content = "---\ndescription: |\n  This is a\n  multiline value\ntitle: Test\n---\n# Heading";
419        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
420        let result = rule.check(&ctx).unwrap();
421
422        assert_eq!(result.len(), 1);
423    }
424
425    #[test]
426    fn test_yaml_list_values() {
427        let rule = MD071BlankLineAfterFrontmatter;
428        let content = "---\ntags:\n  - rust\n  - markdown\ntitle: Test\n---\n# Heading";
429        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
430        let result = rule.check(&ctx).unwrap();
431
432        assert_eq!(result.len(), 1);
433    }
434
435    #[test]
436    fn test_unicode_content_after_frontmatter() {
437        let rule = MD071BlankLineAfterFrontmatter;
438        let content = "---\ntitle: Test\n---\n# 日本語の見出し";
439        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
440        let result = rule.check(&ctx).unwrap();
441
442        assert_eq!(result.len(), 1);
443
444        let fixed = rule.fix(&ctx).unwrap();
445        assert!(fixed.contains("# 日本語の見出し"));
446    }
447
448    #[test]
449    fn test_fix_multiple_applications_still_idempotent() {
450        let rule = MD071BlankLineAfterFrontmatter;
451        let content = "---\ntitle: Test\n---\n# Heading";
452
453        // Apply fix 5 times
454        let mut current = content.to_string();
455        for _ in 0..5 {
456            let ctx = LintContext::new(&current, crate::config::MarkdownFlavor::Standard, None);
457            current = rule.fix(&ctx).unwrap();
458        }
459
460        // Should only have one blank line
461        assert_eq!(current.matches("\n\n").count(), 1);
462        assert!(current.contains("---\n\n#"));
463    }
464
465    #[test]
466    fn test_fix_preserves_trailing_newline() {
467        let rule = MD071BlankLineAfterFrontmatter;
468        // Content WITH trailing newline
469        let content = "---\ndate: 2026-01-06\n---\n# Title\n\nSome text.\n";
470        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
471        let fixed = rule.fix(&ctx).unwrap();
472
473        assert!(fixed.ends_with('\n'), "Fix should preserve trailing newline");
474        assert_eq!(fixed, "---\ndate: 2026-01-06\n---\n\n# Title\n\nSome text.\n");
475    }
476
477    #[test]
478    fn test_fix_no_trailing_newline() {
479        let rule = MD071BlankLineAfterFrontmatter;
480        // Content WITHOUT trailing newline
481        let content = "---\ntitle: Test\n---\n# Heading";
482        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
483        let fixed = rule.fix(&ctx).unwrap();
484
485        assert!(
486            !fixed.ends_with('\n'),
487            "Fix should not add trailing newline if original didn't have one"
488        );
489    }
490
491    #[test]
492    fn test_fix_does_not_cause_md047() {
493        // Regression test for issue #262
494        let rule = MD071BlankLineAfterFrontmatter;
495        let content = "---\ndate: 2026-01-06\n---\n# Title\n\nSome text.\n";
496        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
497
498        // First check MD071
499        let warnings = rule.check(&ctx).unwrap();
500        assert_eq!(warnings.len(), 1, "Should detect missing blank line");
501
502        // Fix it
503        let fixed = rule.fix(&ctx).unwrap();
504
505        // The fixed content should still end with a single newline
506        assert!(fixed.ends_with('\n'), "Should preserve trailing newline");
507        assert!(!fixed.ends_with("\n\n"), "Should not end with multiple newlines");
508
509        // Verify MD071 is now clean
510        let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
511        let warnings2 = rule.check(&ctx2).unwrap();
512        assert!(warnings2.is_empty(), "MD071 should be satisfied after fix");
513    }
514}