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