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