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