1use crate::filtered_lines::FilteredLinesExt;
2use crate::lint_context::LintContext;
3use crate::lint_context::types::HeadingStyle;
4use crate::utils::LineIndex;
5use crate::utils::range_utils::calculate_line_range;
6use std::collections::HashSet;
7use toml;
8
9use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, Severity};
10use crate::rule_config_serde::RuleConfig;
11
12mod md012_config;
13use md012_config::MD012Config;
14
15#[derive(Debug, Clone, Default)]
20pub struct MD012NoMultipleBlanks {
21 config: MD012Config,
22}
23
24impl MD012NoMultipleBlanks {
25 pub fn new(maximum: usize) -> Self {
26 use crate::types::PositiveUsize;
27 Self {
28 config: MD012Config {
29 maximum: PositiveUsize::new(maximum).unwrap_or(PositiveUsize::from_const(1)),
30 },
31 }
32 }
33
34 pub const fn from_config_struct(config: MD012Config) -> Self {
35 Self { config }
36 }
37
38 fn generate_excess_warnings(
40 &self,
41 blank_start: usize,
42 blank_count: usize,
43 lines: &[&str],
44 lines_to_check: &HashSet<usize>,
45 line_index: &LineIndex,
46 ) -> Vec<LintWarning> {
47 let mut warnings = Vec::new();
48
49 let location = if blank_start == 0 {
50 "at start of file"
51 } else {
52 "between content"
53 };
54
55 for i in self.config.maximum.get()..blank_count {
56 let excess_line_num = blank_start + i;
57 if lines_to_check.contains(&excess_line_num) {
58 let excess_line = excess_line_num + 1;
59 let excess_line_content = lines.get(excess_line_num).unwrap_or(&"");
60 let (start_line, start_col, end_line, end_col) = calculate_line_range(excess_line, excess_line_content);
61 warnings.push(LintWarning {
62 rule_name: Some(self.name().to_string()),
63 severity: Severity::Warning,
64 message: format!("Multiple consecutive blank lines {location}"),
65 line: start_line,
66 column: start_col,
67 end_line,
68 end_column: end_col,
69 fix: Some(Fix {
70 range: {
71 let line_start = line_index.get_line_start_byte(excess_line).unwrap_or(0);
72 let line_end = line_index
73 .get_line_start_byte(excess_line + 1)
74 .unwrap_or(line_start + 1);
75 line_start..line_end
76 },
77 replacement: String::new(),
78 }),
79 });
80 }
81 }
82
83 warnings
84 }
85}
86
87fn is_heading_context(ctx: &LintContext, line_idx: usize) -> bool {
93 if ctx.lines.get(line_idx).is_some_and(|li| li.heading.is_some()) {
94 return true;
95 }
96 if line_idx > 0
98 && let Some(prev_info) = ctx.lines.get(line_idx - 1)
99 && let Some(ref heading) = prev_info.heading
100 && matches!(heading.style, HeadingStyle::Setext1 | HeadingStyle::Setext2)
101 {
102 return true;
103 }
104 false
105}
106
107impl Rule for MD012NoMultipleBlanks {
108 fn name(&self) -> &'static str {
109 "MD012"
110 }
111
112 fn description(&self) -> &'static str {
113 "Multiple consecutive blank lines"
114 }
115
116 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
117 let content = ctx.content;
118
119 if content.is_empty() {
121 return Ok(Vec::new());
122 }
123
124 let lines = ctx.raw_lines();
127 let has_potential_blanks = lines
128 .windows(2)
129 .any(|pair| pair[0].trim().is_empty() && pair[1].trim().is_empty());
130
131 let ends_with_multiple_newlines = content.ends_with("\n\n");
134
135 if !has_potential_blanks && !ends_with_multiple_newlines {
136 return Ok(Vec::new());
137 }
138
139 let line_index = &ctx.line_index;
140
141 let mut warnings = Vec::new();
142
143 let mut blank_count = 0;
145 let mut blank_start = 0;
146 let mut last_line_num: Option<usize> = None;
147 let mut prev_content_line_num: Option<usize> = None;
149
150 let mut lines_to_check: HashSet<usize> = HashSet::new();
152
153 for filtered_line in ctx
160 .filtered_lines()
161 .skip_front_matter()
162 .skip_code_blocks()
163 .skip_quarto_divs()
164 .skip_math_blocks()
165 .skip_obsidian_comments()
166 .skip_pymdown_blocks()
167 {
168 let line_num = filtered_line.line_num - 1; let line = filtered_line.content;
170
171 if let Some(last) = last_line_num
174 && line_num > last + 1
175 {
176 if blank_count > self.config.maximum.get() {
179 let heading_adjacent = prev_content_line_num.is_some_and(|idx| is_heading_context(ctx, idx));
180 if !heading_adjacent {
181 warnings.extend(self.generate_excess_warnings(
182 blank_start,
183 blank_count,
184 lines,
185 &lines_to_check,
186 line_index,
187 ));
188 }
189 }
190 blank_count = 0;
191 lines_to_check.clear();
192 prev_content_line_num = None;
194 }
195 last_line_num = Some(line_num);
196
197 if line.trim().is_empty() {
198 if blank_count == 0 {
199 blank_start = line_num;
200 }
201 blank_count += 1;
202 if blank_count > self.config.maximum.get() {
204 lines_to_check.insert(line_num);
205 }
206 } else {
207 if blank_count > self.config.maximum.get() {
208 let heading_adjacent = prev_content_line_num.is_some_and(|idx| is_heading_context(ctx, idx))
212 || (blank_start > 0 && is_heading_context(ctx, line_num));
213 if !heading_adjacent {
214 warnings.extend(self.generate_excess_warnings(
215 blank_start,
216 blank_count,
217 lines,
218 &lines_to_check,
219 line_index,
220 ));
221 }
222 }
223 blank_count = 0;
224 lines_to_check.clear();
225 prev_content_line_num = Some(line_num);
226 }
227 }
228
229 let last_line_is_blank = lines.last().is_some_and(|l| l.trim().is_empty());
236
237 if blank_count > 0 && last_line_is_blank {
241 let location = "at end of file";
242
243 let report_line = lines.len();
245
246 let fix_start = line_index
249 .get_line_start_byte(report_line - blank_count + 1)
250 .unwrap_or(0);
251 let fix_end = content.len();
252
253 warnings.push(LintWarning {
255 rule_name: Some(self.name().to_string()),
256 severity: Severity::Warning,
257 message: format!("Multiple consecutive blank lines {location}"),
258 line: report_line,
259 column: 1,
260 end_line: report_line,
261 end_column: 1,
262 fix: Some(Fix {
263 range: fix_start..fix_end,
264 replacement: String::new(),
268 }),
269 });
270 }
271
272 Ok(warnings)
273 }
274
275 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
276 let content = ctx.content;
277
278 let mut result = Vec::new();
279 let mut blank_count = 0;
280
281 let mut in_code_block = false;
282 let mut code_block_blanks = Vec::new();
283 let mut in_front_matter = false;
284 let mut last_content_is_heading: bool = false;
286 let mut has_seen_content: bool = false;
288
289 for filtered_line in ctx.filtered_lines() {
291 let line = filtered_line.content;
292 let line_idx = filtered_line.line_num - 1; if filtered_line.line_info.in_front_matter {
296 if !in_front_matter {
297 let allowed_blanks = blank_count.min(self.config.maximum.get());
299 if allowed_blanks > 0 {
300 result.extend(vec![""; allowed_blanks]);
301 }
302 blank_count = 0;
303 in_front_matter = true;
304 last_content_is_heading = false;
305 }
306 result.push(line);
307 continue;
308 } else if in_front_matter {
309 in_front_matter = false;
311 last_content_is_heading = false;
312 }
313
314 if line.trim_start().starts_with("```") || line.trim_start().starts_with("~~~") {
316 if !in_code_block {
318 let heading_adjacent = last_content_is_heading;
319 if heading_adjacent {
320 result.extend(std::iter::repeat_n("", blank_count));
322 } else {
323 let allowed_blanks = blank_count.min(self.config.maximum.get());
324 if allowed_blanks > 0 {
325 result.extend(vec![""; allowed_blanks]);
326 }
327 }
328 blank_count = 0;
329 last_content_is_heading = false;
330 } else {
331 result.append(&mut code_block_blanks);
333 }
334 in_code_block = !in_code_block;
335 result.push(line);
336 continue;
337 }
338
339 if in_code_block {
340 if line.trim().is_empty() {
341 code_block_blanks.push(line);
342 } else {
343 result.append(&mut code_block_blanks);
344 result.push(line);
345 }
346 } else if line.trim().is_empty() {
347 blank_count += 1;
348 } else {
349 let heading_adjacent =
352 last_content_is_heading || (has_seen_content && is_heading_context(ctx, line_idx));
353 if heading_adjacent {
354 result.extend(std::iter::repeat_n("", blank_count));
356 } else {
357 let allowed_blanks = blank_count.min(self.config.maximum.get());
359 if allowed_blanks > 0 {
360 result.extend(vec![""; allowed_blanks]);
361 }
362 }
363 blank_count = 0;
364 last_content_is_heading = is_heading_context(ctx, line_idx);
365 has_seen_content = true;
366 result.push(line);
367 }
368 }
369
370 let mut output = result.join("\n");
374 if content.ends_with('\n') {
375 output.push('\n');
376 }
377
378 Ok(output)
379 }
380
381 fn as_any(&self) -> &dyn std::any::Any {
382 self
383 }
384
385 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
386 ctx.content.is_empty() || !ctx.has_char('\n')
388 }
389
390 fn default_config_section(&self) -> Option<(String, toml::Value)> {
391 let default_config = MD012Config::default();
392 let json_value = serde_json::to_value(&default_config).ok()?;
393 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
394
395 if let toml::Value::Table(table) = toml_value {
396 if !table.is_empty() {
397 Some((MD012Config::RULE_NAME.to_string(), toml::Value::Table(table)))
398 } else {
399 None
400 }
401 } else {
402 None
403 }
404 }
405
406 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
407 where
408 Self: Sized,
409 {
410 let rule_config = crate::rule_config_serde::load_rule_config::<MD012Config>(config);
411 Box::new(Self::from_config_struct(rule_config))
412 }
413}
414
415#[cfg(test)]
416mod tests {
417 use super::*;
418 use crate::lint_context::LintContext;
419
420 #[test]
421 fn test_single_blank_line_allowed() {
422 let rule = MD012NoMultipleBlanks::default();
423 let content = "Line 1\n\nLine 2\n\nLine 3";
424 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
425 let result = rule.check(&ctx).unwrap();
426 assert!(result.is_empty());
427 }
428
429 #[test]
430 fn test_multiple_blank_lines_flagged() {
431 let rule = MD012NoMultipleBlanks::default();
432 let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
433 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
434 let result = rule.check(&ctx).unwrap();
435 assert_eq!(result.len(), 3); assert_eq!(result[0].line, 3);
437 assert_eq!(result[1].line, 6);
438 assert_eq!(result[2].line, 7);
439 }
440
441 #[test]
442 fn test_custom_maximum() {
443 let rule = MD012NoMultipleBlanks::new(2);
444 let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
445 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
446 let result = rule.check(&ctx).unwrap();
447 assert_eq!(result.len(), 1); assert_eq!(result[0].line, 7);
449 }
450
451 #[test]
452 fn test_fix_multiple_blank_lines() {
453 let rule = MD012NoMultipleBlanks::default();
454 let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
455 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
456 let fixed = rule.fix(&ctx).unwrap();
457 assert_eq!(fixed, "Line 1\n\nLine 2\n\nLine 3");
458 }
459
460 #[test]
461 fn test_blank_lines_in_code_block() {
462 let rule = MD012NoMultipleBlanks::default();
463 let content = "Before\n\n```\ncode\n\n\n\nmore code\n```\n\nAfter";
464 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
465 let result = rule.check(&ctx).unwrap();
466 assert!(result.is_empty()); }
468
469 #[test]
470 fn test_fix_preserves_code_block_blanks() {
471 let rule = MD012NoMultipleBlanks::default();
472 let content = "Before\n\n\n```\ncode\n\n\n\nmore code\n```\n\n\nAfter";
473 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
474 let fixed = rule.fix(&ctx).unwrap();
475 assert_eq!(fixed, "Before\n\n```\ncode\n\n\n\nmore code\n```\n\nAfter");
476 }
477
478 #[test]
479 fn test_blank_lines_in_front_matter() {
480 let rule = MD012NoMultipleBlanks::default();
481 let content = "---\ntitle: Test\n\n\nauthor: Me\n---\n\nContent";
482 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
483 let result = rule.check(&ctx).unwrap();
484 assert!(result.is_empty()); }
486
487 #[test]
488 fn test_blank_lines_at_start() {
489 let rule = MD012NoMultipleBlanks::default();
490 let content = "\n\n\nContent";
491 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
492 let result = rule.check(&ctx).unwrap();
493 assert_eq!(result.len(), 2);
494 assert!(result[0].message.contains("at start of file"));
495 }
496
497 #[test]
498 fn test_blank_lines_at_end() {
499 let rule = MD012NoMultipleBlanks::default();
500 let content = "Content\n\n\n";
501 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
502 let result = rule.check(&ctx).unwrap();
503 assert_eq!(result.len(), 1);
504 assert!(result[0].message.contains("at end of file"));
505 }
506
507 #[test]
508 fn test_single_blank_at_eof_flagged() {
509 let rule = MD012NoMultipleBlanks::default();
511 let content = "Content\n\n";
512 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
513 let result = rule.check(&ctx).unwrap();
514 assert_eq!(result.len(), 1);
515 assert!(result[0].message.contains("at end of file"));
516 }
517
518 #[test]
519 fn test_whitespace_only_lines() {
520 let rule = MD012NoMultipleBlanks::default();
521 let content = "Line 1\n \n\t\nLine 2";
522 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
523 let result = rule.check(&ctx).unwrap();
524 assert_eq!(result.len(), 1); }
526
527 #[test]
528 fn test_indented_code_blocks() {
529 let rule = MD012NoMultipleBlanks::default();
531 let content = "Text\n\n code\n \n \n more code\n\nText";
532 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
533 let result = rule.check(&ctx).unwrap();
534 assert!(result.is_empty(), "Should not flag blanks inside indented code blocks");
535 }
536
537 #[test]
538 fn test_blanks_in_indented_code_block() {
539 let content = " code line 1\n\n\n code line 2\n";
541 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
542 let rule = MD012NoMultipleBlanks::default();
543 let warnings = rule.check(&ctx).unwrap();
544 assert!(warnings.is_empty(), "Should not flag blanks in indented code");
545 }
546
547 #[test]
548 fn test_blanks_in_indented_code_block_with_heading() {
549 let content = "# Heading\n\n code line 1\n\n\n code line 2\n\nMore text\n";
551 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
552 let rule = MD012NoMultipleBlanks::default();
553 let warnings = rule.check(&ctx).unwrap();
554 assert!(
555 warnings.is_empty(),
556 "Should not flag blanks in indented code after heading"
557 );
558 }
559
560 #[test]
561 fn test_blanks_after_indented_code_block_flagged() {
562 let content = "# Heading\n\n code line\n\n\n\nMore text\n";
564 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
565 let rule = MD012NoMultipleBlanks::default();
566 let warnings = rule.check(&ctx).unwrap();
567 assert_eq!(warnings.len(), 2, "Should flag blanks after indented code block ends");
569 }
570
571 #[test]
572 fn test_fix_with_final_newline() {
573 let rule = MD012NoMultipleBlanks::default();
574 let content = "Line 1\n\n\nLine 2\n";
575 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
576 let fixed = rule.fix(&ctx).unwrap();
577 assert_eq!(fixed, "Line 1\n\nLine 2\n");
578 assert!(fixed.ends_with('\n'));
579 }
580
581 #[test]
582 fn test_empty_content() {
583 let rule = MD012NoMultipleBlanks::default();
584 let content = "";
585 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
586 let result = rule.check(&ctx).unwrap();
587 assert!(result.is_empty());
588 }
589
590 #[test]
591 fn test_nested_code_blocks() {
592 let rule = MD012NoMultipleBlanks::default();
593 let content = "Before\n\n~~~\nouter\n\n```\ninner\n\n\n```\n\n~~~\n\nAfter";
594 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
595 let result = rule.check(&ctx).unwrap();
596 assert!(result.is_empty());
597 }
598
599 #[test]
600 fn test_unclosed_code_block() {
601 let rule = MD012NoMultipleBlanks::default();
602 let content = "Before\n\n```\ncode\n\n\n\nno closing fence";
603 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
604 let result = rule.check(&ctx).unwrap();
605 assert!(result.is_empty()); }
607
608 #[test]
609 fn test_mixed_fence_styles() {
610 let rule = MD012NoMultipleBlanks::default();
611 let content = "Before\n\n```\ncode\n\n\n~~~\n\nAfter";
612 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
613 let result = rule.check(&ctx).unwrap();
614 assert!(result.is_empty()); }
616
617 #[test]
618 fn test_config_from_toml() {
619 let mut config = crate::config::Config::default();
620 let mut rule_config = crate::config::RuleConfig::default();
621 rule_config
622 .values
623 .insert("maximum".to_string(), toml::Value::Integer(3));
624 config.rules.insert("MD012".to_string(), rule_config);
625
626 let rule = MD012NoMultipleBlanks::from_config(&config);
627 let content = "Line 1\n\n\n\nLine 2"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
629 let result = rule.check(&ctx).unwrap();
630 assert!(result.is_empty()); }
632
633 #[test]
634 fn test_blank_lines_between_sections() {
635 let rule = MD012NoMultipleBlanks::default();
637 let content = "# Section 1\n\nContent\n\n\n# Section 2\n\nContent";
638 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
639 let result = rule.check(&ctx).unwrap();
640 assert!(result.is_empty(), "Blanks adjacent to headings should not be flagged");
641 }
642
643 #[test]
644 fn test_fix_preserves_indented_code() {
645 let rule = MD012NoMultipleBlanks::default();
646 let content = "Text\n\n\n code\n \n more code\n\n\nText";
647 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
648 let fixed = rule.fix(&ctx).unwrap();
649 assert_eq!(fixed, "Text\n\n code\n\n more code\n\nText");
651 }
652
653 #[test]
654 fn test_edge_case_only_blanks() {
655 let rule = MD012NoMultipleBlanks::default();
656 let content = "\n\n\n";
657 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
658 let result = rule.check(&ctx).unwrap();
659 assert_eq!(result.len(), 1);
661 assert!(result[0].message.contains("at end of file"));
662 }
663
664 #[test]
667 fn test_blanks_after_fenced_code_block_mid_document() {
668 let rule = MD012NoMultipleBlanks::default();
670 let content = "## Input\n\n```javascript\ncode\n```\n\n\n## Error\n";
671 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
672 let result = rule.check(&ctx).unwrap();
673 assert!(result.is_empty(), "Blanks adjacent to heading should not be flagged");
674 }
675
676 #[test]
677 fn test_blanks_after_code_block_at_eof() {
678 let rule = MD012NoMultipleBlanks::default();
680 let content = "# Heading\n\n```\ncode\n```\n\n\n";
681 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
682 let result = rule.check(&ctx).unwrap();
683 assert_eq!(result.len(), 1, "Should detect trailing blanks after code block");
685 assert!(result[0].message.contains("at end of file"));
686 }
687
688 #[test]
689 fn test_single_blank_after_code_block_allowed() {
690 let rule = MD012NoMultipleBlanks::default();
692 let content = "## Input\n\n```\ncode\n```\n\n## Output\n";
693 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
694 let result = rule.check(&ctx).unwrap();
695 assert!(result.is_empty(), "Single blank after code block should be allowed");
696 }
697
698 #[test]
699 fn test_multiple_code_blocks_with_blanks() {
700 let rule = MD012NoMultipleBlanks::default();
702 let content = "```\ncode1\n```\n\n\n```\ncode2\n```\n\n\nEnd\n";
703 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
704 let result = rule.check(&ctx).unwrap();
705 assert_eq!(result.len(), 2, "Should detect blanks after both code blocks");
707 }
708
709 #[test]
710 fn test_whitespace_only_lines_after_code_block_at_eof() {
711 let rule = MD012NoMultipleBlanks::default();
714 let content = "```\ncode\n```\n \n \n";
715 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
716 let result = rule.check(&ctx).unwrap();
717 assert_eq!(result.len(), 1, "Should detect whitespace-only trailing blanks");
718 assert!(result[0].message.contains("at end of file"));
719 }
720
721 #[test]
724 fn test_warning_fix_removes_single_trailing_blank() {
725 let rule = MD012NoMultipleBlanks::default();
727 let content = "hello foobar hello.\n\n";
728 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
729 let warnings = rule.check(&ctx).unwrap();
730
731 assert_eq!(warnings.len(), 1);
732 assert!(warnings[0].fix.is_some(), "Warning should have a fix attached");
733
734 let fix = warnings[0].fix.as_ref().unwrap();
735 assert_eq!(fix.replacement, "", "Replacement should be empty");
737
738 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
740 assert_eq!(fixed, "hello foobar hello.\n", "Should end with single newline");
741 }
742
743 #[test]
744 fn test_warning_fix_removes_multiple_trailing_blanks() {
745 let rule = MD012NoMultipleBlanks::default();
746 let content = "content\n\n\n\n";
747 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
748 let warnings = rule.check(&ctx).unwrap();
749
750 assert_eq!(warnings.len(), 1);
751 assert!(warnings[0].fix.is_some());
752
753 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
754 assert_eq!(fixed, "content\n", "Should end with single newline");
755 }
756
757 #[test]
758 fn test_warning_fix_preserves_content_newline() {
759 let rule = MD012NoMultipleBlanks::default();
761 let content = "line1\nline2\n\n";
762 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
763 let warnings = rule.check(&ctx).unwrap();
764
765 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
766 assert_eq!(fixed, "line1\nline2\n", "Should preserve all content lines");
767 }
768
769 #[test]
770 fn test_warning_fix_mid_document_blanks() {
771 let rule = MD012NoMultipleBlanks::default();
773 let content = "# Heading\n\n\n\nParagraph\n";
774 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
775 let warnings = rule.check(&ctx).unwrap();
776
777 assert!(warnings.is_empty(), "Blanks adjacent to heading should not be flagged");
779 }
780
781 #[test]
785 fn test_heading_aware_atx_blanks_below() {
786 let rule = MD012NoMultipleBlanks::default();
788 let content = "# Heading\n\n\nParagraph\n";
789 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
790 let result = rule.check(&ctx).unwrap();
791 assert!(result.is_empty(), "Blanks below ATX heading should not be flagged");
792 }
793
794 #[test]
795 fn test_heading_aware_atx_blanks_above() {
796 let rule = MD012NoMultipleBlanks::default();
798 let content = "Paragraph\n\n\n# Heading\n";
799 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
800 let result = rule.check(&ctx).unwrap();
801 assert!(result.is_empty(), "Blanks above ATX heading should not be flagged");
802 }
803
804 #[test]
805 fn test_heading_aware_atx_blanks_between() {
806 let rule = MD012NoMultipleBlanks::default();
808 let content = "# Heading 1\n\n\n## Heading 2\n";
809 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
810 let result = rule.check(&ctx).unwrap();
811 assert!(result.is_empty(), "Blanks between headings should not be flagged");
812 }
813
814 #[test]
815 fn test_heading_aware_setext_equals_blanks_below() {
816 let rule = MD012NoMultipleBlanks::default();
818 let content = "Heading\n=======\n\n\nParagraph\n";
819 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
820 let result = rule.check(&ctx).unwrap();
821 assert!(
822 result.is_empty(),
823 "Blanks below Setext === heading should not be flagged"
824 );
825 }
826
827 #[test]
828 fn test_heading_aware_setext_dashes_blanks_below() {
829 let rule = MD012NoMultipleBlanks::default();
831 let content = "Heading\n-------\n\n\nParagraph\n";
832 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
833 let result = rule.check(&ctx).unwrap();
834 assert!(
835 result.is_empty(),
836 "Blanks below Setext --- heading should not be flagged"
837 );
838 }
839
840 #[test]
841 fn test_heading_aware_setext_blanks_above() {
842 let rule = MD012NoMultipleBlanks::default();
844 let content = "Paragraph\n\n\nHeading\n=======\n";
845 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
846 let result = rule.check(&ctx).unwrap();
847 assert!(result.is_empty(), "Blanks above Setext heading should not be flagged");
848 }
849
850 #[test]
851 fn test_heading_aware_non_heading_blanks_still_flagged() {
852 let rule = MD012NoMultipleBlanks::default();
854 let content = "Paragraph 1\n\n\nParagraph 2\n";
855 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
856 let result = rule.check(&ctx).unwrap();
857 assert_eq!(result.len(), 1, "Non-heading blanks should still be flagged");
858 }
859
860 #[test]
861 fn test_heading_aware_md022_coexistence() {
862 let rule = MD012NoMultipleBlanks::default();
864 let content = "# Title\n\n\n## Subtitle\n\nContent\n";
865 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
866 let result = rule.check(&ctx).unwrap();
867 assert!(result.is_empty(), "Should allow blanks for MD022 heading spacing");
868 }
869
870 #[test]
871 fn test_heading_aware_fix_preserves_heading_blanks() {
872 let rule = MD012NoMultipleBlanks::default();
874 let content = "# Heading\n\n\n\nParagraph\n";
875 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
876 let fixed = rule.fix(&ctx).unwrap();
877 assert_eq!(
878 fixed, "# Heading\n\n\n\nParagraph\n",
879 "Fix should preserve heading-adjacent blanks"
880 );
881 }
882
883 #[test]
884 fn test_heading_aware_fix_reduces_non_heading_blanks() {
885 let rule = MD012NoMultipleBlanks::default();
887 let content = "Paragraph 1\n\n\n\nParagraph 2\n";
888 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
889 let fixed = rule.fix(&ctx).unwrap();
890 assert_eq!(
891 fixed, "Paragraph 1\n\nParagraph 2\n",
892 "Fix should reduce non-heading blanks"
893 );
894 }
895
896 #[test]
897 fn test_heading_aware_mixed_heading_and_non_heading() {
898 let rule = MD012NoMultipleBlanks::default();
900 let content = "# Heading\n\n\nParagraph 1\n\n\nParagraph 2\n";
901 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
902 let result = rule.check(&ctx).unwrap();
903 assert_eq!(result.len(), 1, "Should flag only non-heading blanks");
905 assert_eq!(result[0].line, 6, "Warning should be on the non-heading blank");
906 }
907
908 #[test]
909 fn test_heading_aware_blanks_at_start_before_heading_still_flagged() {
910 let rule = MD012NoMultipleBlanks::default();
913 let content = "\n\n\n# Heading\n";
914 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
915 let result = rule.check(&ctx).unwrap();
916 assert_eq!(
917 result.len(),
918 2,
919 "Start-of-file blanks should be flagged even before heading"
920 );
921 assert!(result[0].message.contains("at start of file"));
922 }
923
924 #[test]
925 fn test_heading_aware_eof_blanks_after_heading_still_flagged() {
926 let rule = MD012NoMultipleBlanks::default();
928 let content = "# Heading\n\n";
929 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
930 let result = rule.check(&ctx).unwrap();
931 assert_eq!(result.len(), 1, "EOF blanks should still be flagged");
932 assert!(result[0].message.contains("at end of file"));
933 }
934
935 #[test]
936 fn test_heading_aware_custom_maximum_with_headings() {
937 let rule = MD012NoMultipleBlanks::new(2);
939 let content = "# Heading\n\n\n\n\nParagraph\n";
940 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
941 let result = rule.check(&ctx).unwrap();
942 assert!(
943 result.is_empty(),
944 "Any number of heading-adjacent blanks should be allowed"
945 );
946 }
947
948 #[test]
949 fn test_heading_aware_blanks_after_code_then_heading() {
950 let rule = MD012NoMultipleBlanks::default();
953 let content = "# Heading\n\n```\ncode\n```\n\n\n\nMore text\n";
954 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
955 let result = rule.check(&ctx).unwrap();
956 assert_eq!(result.len(), 2, "Non-heading blanks after code block should be flagged");
958 }
959
960 #[test]
961 fn test_heading_aware_fix_mixed_document() {
962 let rule = MD012NoMultipleBlanks::default();
964 let content = "# Title\n\n\n## Section\n\n\nPara 1\n\n\nPara 2\n";
965 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
966 let fixed = rule.fix(&ctx).unwrap();
967 assert_eq!(fixed, "# Title\n\n\n## Section\n\n\nPara 1\n\nPara 2\n");
969 }
970
971 #[test]
974 fn test_blank_lines_in_quarto_callout() {
975 let rule = MD012NoMultipleBlanks::default();
977 let content = "# Heading\n\n::: {.callout-note}\nNote content\n\n\nMore content\n:::\n\nAfter";
978 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
979 let result = rule.check(&ctx).unwrap();
980 assert!(result.is_empty(), "Should not flag blanks inside Quarto callouts");
981 }
982
983 #[test]
984 fn test_blank_lines_in_quarto_div() {
985 let rule = MD012NoMultipleBlanks::default();
987 let content = "Text\n\n::: {.bordered}\nContent\n\n\nMore\n:::\n\nText";
988 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
989 let result = rule.check(&ctx).unwrap();
990 assert!(result.is_empty(), "Should not flag blanks inside Quarto divs");
991 }
992
993 #[test]
994 fn test_blank_lines_outside_quarto_div_flagged() {
995 let rule = MD012NoMultipleBlanks::default();
997 let content = "Text\n\n\n::: {.callout-note}\nNote\n:::\n\n\nMore";
998 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
999 let result = rule.check(&ctx).unwrap();
1000 assert!(!result.is_empty(), "Should flag blanks outside Quarto divs");
1001 }
1002
1003 #[test]
1004 fn test_quarto_divs_ignored_in_standard_flavor() {
1005 let rule = MD012NoMultipleBlanks::default();
1007 let content = "::: {.callout-note}\nNote content\n\n\nMore content\n:::\n";
1008 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1009 let result = rule.check(&ctx).unwrap();
1010 assert!(!result.is_empty(), "Standard flavor should flag blanks in 'div'");
1012 }
1013}