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 lines: Vec<&str> = content.lines().collect();
82 let mut result = Vec::new();
83
84 for (i, line) in lines.iter().enumerate() {
85 result.push((*line).to_string());
86
87 if i == fm_end_line - 1
89 && let Some(next_line) = lines.get(i + 1)
90 && !next_line.trim().is_empty()
91 {
92 result.push(String::new());
93 }
94 }
95
96 Ok(result.join("\n"))
97 }
98
99 fn category(&self) -> RuleCategory {
100 RuleCategory::Whitespace
101 }
102
103 fn as_any(&self) -> &dyn std::any::Any {
104 self
105 }
106
107 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
108 where
109 Self: Sized,
110 {
111 Box::new(MD071BlankLineAfterFrontmatter)
112 }
113}
114
115#[cfg(test)]
116mod tests {
117 use super::*;
118 use crate::lint_context::LintContext;
119
120 #[test]
123 fn test_no_frontmatter() {
124 let rule = MD071BlankLineAfterFrontmatter;
125 let content = "# Heading\n\nContent.";
126 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
127 let result = rule.check(&ctx).unwrap();
128
129 assert!(result.is_empty());
130 }
131
132 #[test]
133 fn test_frontmatter_with_blank_line() {
134 let rule = MD071BlankLineAfterFrontmatter;
135 let content = "---\ntitle: Test\n---\n\n# Heading";
136 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
137 let result = rule.check(&ctx).unwrap();
138
139 assert!(result.is_empty());
140 }
141
142 #[test]
143 fn test_frontmatter_without_blank_line() {
144 let rule = MD071BlankLineAfterFrontmatter;
145 let content = "---\ntitle: Test\n---\n# Heading";
146 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
147 let result = rule.check(&ctx).unwrap();
148
149 assert_eq!(result.len(), 1);
150 assert!(result[0].message.contains("Missing blank line"));
151 }
152
153 #[test]
154 fn test_toml_frontmatter_without_blank_line() {
155 let rule = MD071BlankLineAfterFrontmatter;
156 let content = "+++\ntitle = \"Test\"\n+++\n# Heading";
157 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
158 let result = rule.check(&ctx).unwrap();
159
160 assert_eq!(result.len(), 1);
161 }
162
163 #[test]
164 fn test_json_frontmatter_without_blank_line() {
165 let rule = MD071BlankLineAfterFrontmatter;
166 let content = "{\n\"title\": \"Test\"\n}\n# Heading";
167 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
168 let result = rule.check(&ctx).unwrap();
169
170 assert_eq!(result.len(), 1);
171 }
172
173 #[test]
174 fn test_fix_adds_blank_line() {
175 let rule = MD071BlankLineAfterFrontmatter;
176 let content = "---\ntitle: Test\n---\n# Heading\n\nContent.";
177 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
178 let fixed = rule.fix(&ctx).unwrap();
179
180 let expected = "---\ntitle: Test\n---\n\n# Heading\n\nContent.";
181 assert_eq!(fixed, expected);
182 }
183
184 #[test]
185 fn test_fix_idempotent() {
186 let rule = MD071BlankLineAfterFrontmatter;
187 let content = "---\ntitle: Test\n---\n# Heading";
188 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
189 let fixed_once = rule.fix(&ctx).unwrap();
190
191 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
192 let fixed_twice = rule.fix(&ctx2).unwrap();
193
194 assert_eq!(fixed_once, fixed_twice);
195 }
196
197 #[test]
198 fn test_frontmatter_at_end_of_file() {
199 let rule = MD071BlankLineAfterFrontmatter;
200 let content = "---\ntitle: Test\n---";
201 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
202 let result = rule.check(&ctx).unwrap();
203
204 assert!(result.is_empty());
206 }
207
208 #[test]
209 fn test_multiple_blank_lines_ok() {
210 let rule = MD071BlankLineAfterFrontmatter;
211 let content = "---\ntitle: Test\n---\n\n\n# Heading";
212 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
213 let result = rule.check(&ctx).unwrap();
214
215 assert!(result.is_empty());
216 }
217
218 #[test]
219 fn test_empty_content() {
220 let rule = MD071BlankLineAfterFrontmatter;
221 let content = "";
222 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
223 let result = rule.check(&ctx).unwrap();
224
225 assert!(result.is_empty());
226 }
227
228 #[test]
229 fn test_frontmatter_with_text_immediately_after() {
230 let rule = MD071BlankLineAfterFrontmatter;
231 let content = "---\ntitle: Test\n---\nSome paragraph text.";
232 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
233 let result = rule.check(&ctx).unwrap();
234
235 assert_eq!(result.len(), 1);
236 }
237
238 #[test]
241 fn test_whitespace_only_line_after_frontmatter_is_not_blank() {
242 let rule = MD071BlankLineAfterFrontmatter;
244 let content = "---\ntitle: Test\n---\n \n# Heading";
245 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
246 let result = rule.check(&ctx).unwrap();
247
248 assert!(result.is_empty());
250 }
251
252 #[test]
253 fn test_tab_only_line_after_frontmatter() {
254 let rule = MD071BlankLineAfterFrontmatter;
255 let content = "---\ntitle: Test\n---\n\t\n# Heading";
256 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
257 let result = rule.check(&ctx).unwrap();
258
259 assert!(result.is_empty());
261 }
262
263 #[test]
264 fn test_crlf_line_endings() {
265 let rule = MD071BlankLineAfterFrontmatter;
266 let content = "---\r\ntitle: Test\r\n---\r\n# Heading";
267 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
268 let result = rule.check(&ctx).unwrap();
269
270 assert_eq!(result.len(), 1);
272 }
273
274 #[test]
275 fn test_crlf_with_blank_line() {
276 let rule = MD071BlankLineAfterFrontmatter;
277 let content = "---\r\ntitle: Test\r\n---\r\n\r\n# Heading";
278 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
279 let result = rule.check(&ctx).unwrap();
280
281 assert!(result.is_empty());
282 }
283
284 #[test]
285 fn test_empty_yaml_frontmatter() {
286 let rule = MD071BlankLineAfterFrontmatter;
287 let content = "---\n---\n# Heading";
288 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
289 let result = rule.check(&ctx).unwrap();
290
291 assert_eq!(result.len(), 1);
293 }
294
295 #[test]
296 fn test_empty_yaml_frontmatter_with_blank_line() {
297 let rule = MD071BlankLineAfterFrontmatter;
298 let content = "---\n---\n\n# Heading";
299 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
300 let result = rule.check(&ctx).unwrap();
301
302 assert!(result.is_empty());
303 }
304
305 #[test]
306 fn test_frontmatter_with_blank_lines_inside() {
307 let rule = MD071BlankLineAfterFrontmatter;
308 let content = "---\ntitle: Test\n\nauthor: John\n---\n# Heading";
309 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
310 let result = rule.check(&ctx).unwrap();
311
312 assert_eq!(result.len(), 1);
314 }
315
316 #[test]
317 fn test_frontmatter_trailing_whitespace_on_delimiter() {
318 let rule = MD071BlankLineAfterFrontmatter;
319 let content = "---\ntitle: Test\n--- \n# Heading";
320 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
321 let result = rule.check(&ctx).unwrap();
322
323 assert_eq!(result.len(), 1);
325 }
326
327 #[test]
328 fn test_frontmatter_only_file() {
329 let rule = MD071BlankLineAfterFrontmatter;
330 let content = "---\ntitle: Only frontmatter\n---\n";
331 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
332 let result = rule.check(&ctx).unwrap();
333
334 assert!(result.is_empty());
336 }
337
338 #[test]
339 fn test_frontmatter_with_triple_dash_inside_value() {
340 let rule = MD071BlankLineAfterFrontmatter;
341 let content = "---\ntitle: \"Test --- with dashes\"\n---\n# Heading";
342 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
343 let result = rule.check(&ctx).unwrap();
344
345 assert_eq!(result.len(), 1);
347 }
348
349 #[test]
350 fn test_fix_preserves_content_after_frontmatter() {
351 let rule = MD071BlankLineAfterFrontmatter;
352 let content = "---\ntitle: Test\n---\n# Heading\n\nParagraph 1.\n\nParagraph 2.\n\n- List item";
353 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
354 let fixed = rule.fix(&ctx).unwrap();
355
356 assert!(fixed.contains("# Heading"));
358 assert!(fixed.contains("Paragraph 1."));
359 assert!(fixed.contains("Paragraph 2."));
360 assert!(fixed.contains("- List item"));
361 assert!(fixed.contains("---\n\n#"));
363 }
364
365 #[test]
366 fn test_fix_toml_frontmatter() {
367 let rule = MD071BlankLineAfterFrontmatter;
368 let content = "+++\ntitle = \"Test\"\n+++\n# Heading";
369 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
370 let fixed = rule.fix(&ctx).unwrap();
371
372 assert!(fixed.contains("+++\n\n#"));
373 }
374
375 #[test]
376 fn test_fix_json_frontmatter() {
377 let rule = MD071BlankLineAfterFrontmatter;
378 let content = "{\n\"title\": \"Test\"\n}\n# Heading";
379 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
380 let fixed = rule.fix(&ctx).unwrap();
381
382 assert!(fixed.contains("}\n\n#"));
383 }
384
385 #[test]
386 fn test_multiline_yaml_values() {
387 let rule = MD071BlankLineAfterFrontmatter;
388 let content = "---\ndescription: |\n This is a\n multiline value\ntitle: Test\n---\n# Heading";
389 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
390 let result = rule.check(&ctx).unwrap();
391
392 assert_eq!(result.len(), 1);
393 }
394
395 #[test]
396 fn test_yaml_list_values() {
397 let rule = MD071BlankLineAfterFrontmatter;
398 let content = "---\ntags:\n - rust\n - markdown\ntitle: Test\n---\n# Heading";
399 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
400 let result = rule.check(&ctx).unwrap();
401
402 assert_eq!(result.len(), 1);
403 }
404
405 #[test]
406 fn test_unicode_content_after_frontmatter() {
407 let rule = MD071BlankLineAfterFrontmatter;
408 let content = "---\ntitle: Test\n---\n# 日本語の見出し";
409 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
410 let result = rule.check(&ctx).unwrap();
411
412 assert_eq!(result.len(), 1);
413
414 let fixed = rule.fix(&ctx).unwrap();
415 assert!(fixed.contains("# 日本語の見出し"));
416 }
417
418 #[test]
419 fn test_fix_multiple_applications_still_idempotent() {
420 let rule = MD071BlankLineAfterFrontmatter;
421 let content = "---\ntitle: Test\n---\n# Heading";
422
423 let mut current = content.to_string();
425 for _ in 0..5 {
426 let ctx = LintContext::new(¤t, crate::config::MarkdownFlavor::Standard, None);
427 current = rule.fix(&ctx).unwrap();
428 }
429
430 assert_eq!(current.matches("\n\n").count(), 1);
432 assert!(current.contains("---\n\n#"));
433 }
434}