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