1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2
3#[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 return Ok(warnings);
43 }
44
45 let lines = ctx.raw_lines();
46
47 if let Some(next_line) = lines.get(fm_end_line)
49 && !next_line.trim().is_empty()
50 {
51 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, 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 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 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 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 #[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 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 #[test]
275 fn test_whitespace_only_line_after_frontmatter_is_not_blank() {
276 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 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 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 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 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 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 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 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 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 assert!(fixed.contains("# Heading"));
392 assert!(fixed.contains("Paragraph 1."));
393 assert!(fixed.contains("Paragraph 2."));
394 assert!(fixed.contains("- List item"));
395 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 let mut current = content.to_string();
459 for _ in 0..5 {
460 let ctx = LintContext::new(¤t, crate::config::MarkdownFlavor::Standard, None);
461 current = rule.fix(&ctx).unwrap();
462 }
463
464 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 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 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 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 let warnings = rule.check(&ctx).unwrap();
504 assert_eq!(warnings.len(), 1, "Should detect missing blank line");
505
506 let fixed = rule.fix(&ctx).unwrap();
508
509 assert!(fixed.ends_with('\n'), "Should preserve trailing newline");
511 assert!(!fixed.ends_with("\n\n"), "Should not end with multiple newlines");
512
513 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}