Skip to main content

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