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