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