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