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