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;
7
8use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, Severity};
9use crate::rule_config_serde::RuleConfig;
10
11mod md012_config;
12use md012_config::MD012Config;
13
14#[derive(Debug, Clone)]
19pub struct MD012NoMultipleBlanks {
20 config: MD012Config,
21 heading_blanks_above: usize,
24 heading_blanks_below: usize,
27}
28
29impl Default for MD012NoMultipleBlanks {
30 fn default() -> Self {
31 Self {
32 config: MD012Config::default(),
33 heading_blanks_above: 1,
34 heading_blanks_below: 1,
35 }
36 }
37}
38
39impl MD012NoMultipleBlanks {
40 pub fn new(maximum: usize) -> Self {
41 use crate::types::PositiveUsize;
42 Self {
43 config: MD012Config {
44 maximum: PositiveUsize::new(maximum).unwrap_or(PositiveUsize::from_const(1)),
45 },
46 heading_blanks_above: 1,
47 heading_blanks_below: 1,
48 }
49 }
50
51 pub const fn from_config_struct(config: MD012Config) -> Self {
52 Self {
53 config,
54 heading_blanks_above: 1,
55 heading_blanks_below: 1,
56 }
57 }
58
59 pub fn with_heading_limits(mut self, above: usize, below: usize) -> Self {
62 self.heading_blanks_above = above;
63 self.heading_blanks_below = below;
64 self
65 }
66
67 fn effective_max_above(&self) -> usize {
71 self.config.maximum.get().max(self.heading_blanks_above)
72 }
73
74 fn effective_max_below(&self) -> usize {
75 self.config.maximum.get().max(self.heading_blanks_below)
76 }
77
78 fn generate_excess_warnings(
80 &self,
81 blank_start: usize,
82 blank_count: usize,
83 effective_max: usize,
84 lines: &[&str],
85 lines_to_check: &HashSet<usize>,
86 line_index: &LineIndex,
87 ) -> Vec<LintWarning> {
88 let mut warnings = Vec::new();
89
90 let location = if blank_start == 0 {
91 "at start of file"
92 } else {
93 "between content"
94 };
95
96 for i in effective_max..blank_count {
97 let excess_line_num = blank_start + i;
98 if lines_to_check.contains(&excess_line_num) {
99 let excess_line = excess_line_num + 1;
100 let excess_line_content = lines.get(excess_line_num).unwrap_or(&"");
101 let (start_line, start_col, end_line, end_col) = calculate_line_range(excess_line, excess_line_content);
102 warnings.push(LintWarning {
103 rule_name: Some(self.name().to_string()),
104 severity: Severity::Warning,
105 message: format!("Multiple consecutive blank lines {location}"),
106 line: start_line,
107 column: start_col,
108 end_line,
109 end_column: end_col,
110 fix: Some(Fix {
111 range: {
112 let line_start = line_index.get_line_start_byte(excess_line).unwrap_or(0);
113 let line_end = line_index
114 .get_line_start_byte(excess_line + 1)
115 .unwrap_or(line_start + 1);
116 line_start..line_end
117 },
118 replacement: String::new(),
119 }),
120 });
121 }
122 }
123
124 warnings
125 }
126}
127
128fn is_heading_context(ctx: &LintContext, line_idx: usize) -> bool {
134 if ctx.lines.get(line_idx).is_some_and(|li| li.heading.is_some()) {
135 return true;
136 }
137 if line_idx > 0
139 && let Some(prev_info) = ctx.lines.get(line_idx - 1)
140 && let Some(ref heading) = prev_info.heading
141 && matches!(heading.style, HeadingStyle::Setext1 | HeadingStyle::Setext2)
142 {
143 return true;
144 }
145 false
146}
147
148fn max_heading_limit(
152 level_config: &crate::rules::md022_blanks_around_headings::md022_config::HeadingLevelConfig,
153) -> usize {
154 let mut max_val: usize = 0;
155 for level in 1..=6 {
156 match level_config.get_for_level(level).required_count() {
157 None => return usize::MAX, Some(count) => max_val = max_val.max(count),
159 }
160 }
161 max_val
162}
163
164impl Rule for MD012NoMultipleBlanks {
165 fn name(&self) -> &'static str {
166 "MD012"
167 }
168
169 fn description(&self) -> &'static str {
170 "Multiple consecutive blank lines"
171 }
172
173 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
174 let content = ctx.content;
175
176 if content.is_empty() {
178 return Ok(Vec::new());
179 }
180
181 let lines = ctx.raw_lines();
184 let has_potential_blanks = lines
185 .windows(2)
186 .any(|pair| pair[0].trim().is_empty() && pair[1].trim().is_empty());
187
188 let ends_with_multiple_newlines = content.ends_with("\n\n");
191
192 if !has_potential_blanks && !ends_with_multiple_newlines {
193 return Ok(Vec::new());
194 }
195
196 let line_index = &ctx.line_index;
197
198 let mut warnings = Vec::new();
199
200 let mut blank_count = 0;
202 let mut blank_start = 0;
203 let mut last_line_num: Option<usize> = None;
204 let mut prev_content_line_num: Option<usize> = None;
206
207 let mut lines_to_check: HashSet<usize> = HashSet::new();
209
210 for filtered_line in ctx
217 .filtered_lines()
218 .skip_front_matter()
219 .skip_code_blocks()
220 .skip_quarto_divs()
221 .skip_math_blocks()
222 .skip_obsidian_comments()
223 .skip_pymdown_blocks()
224 {
225 let line_num = filtered_line.line_num - 1; let line = filtered_line.content;
227
228 if let Some(last) = last_line_num
231 && line_num > last + 1
232 {
233 let effective_max = if prev_content_line_num.is_some_and(|idx| is_heading_context(ctx, idx)) {
236 self.effective_max_below()
237 } else {
238 self.config.maximum.get()
239 };
240 if blank_count > effective_max {
241 warnings.extend(self.generate_excess_warnings(
242 blank_start,
243 blank_count,
244 effective_max,
245 lines,
246 &lines_to_check,
247 line_index,
248 ));
249 }
250 blank_count = 0;
251 lines_to_check.clear();
252 prev_content_line_num = None;
254 }
255 last_line_num = Some(line_num);
256
257 if line.trim().is_empty() {
258 if blank_count == 0 {
259 blank_start = line_num;
260 }
261 blank_count += 1;
262 if blank_count > self.config.maximum.get() {
264 lines_to_check.insert(line_num);
265 }
266 } else {
267 let heading_below = prev_content_line_num.is_some_and(|idx| is_heading_context(ctx, idx));
273 let heading_above = blank_start > 0 && is_heading_context(ctx, line_num);
274 let effective_max = if heading_below && heading_above {
275 self.effective_max_above().max(self.effective_max_below())
277 } else if heading_below {
278 self.effective_max_below()
279 } else if heading_above {
280 self.effective_max_above()
281 } else {
282 self.config.maximum.get()
283 };
284
285 if blank_count > effective_max {
286 warnings.extend(self.generate_excess_warnings(
287 blank_start,
288 blank_count,
289 effective_max,
290 lines,
291 &lines_to_check,
292 line_index,
293 ));
294 }
295 blank_count = 0;
296 lines_to_check.clear();
297 prev_content_line_num = Some(line_num);
298 }
299 }
300
301 let last_line_is_blank = lines.last().is_some_and(|l| l.trim().is_empty());
308
309 if blank_count > 0 && last_line_is_blank {
313 let location = "at end of file";
314
315 let report_line = lines.len();
317
318 let fix_start = line_index
321 .get_line_start_byte(report_line - blank_count + 1)
322 .unwrap_or(0);
323 let fix_end = content.len();
324
325 warnings.push(LintWarning {
327 rule_name: Some(self.name().to_string()),
328 severity: Severity::Warning,
329 message: format!("Multiple consecutive blank lines {location}"),
330 line: report_line,
331 column: 1,
332 end_line: report_line,
333 end_column: 1,
334 fix: Some(Fix {
335 range: fix_start..fix_end,
336 replacement: String::new(),
340 }),
341 });
342 }
343
344 Ok(warnings)
345 }
346
347 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
348 let content = ctx.content;
349
350 let mut result = Vec::new();
351 let mut blank_count = 0;
352
353 let mut in_code_block = false;
354 let mut code_block_blanks = Vec::new();
355 let mut in_front_matter = false;
356 let mut last_content_is_heading: bool = false;
358 let mut has_seen_content: bool = false;
360
361 for filtered_line in ctx.filtered_lines() {
363 let line = filtered_line.content;
364 let line_num = filtered_line.line_num;
365 let line_idx = line_num - 1; if ctx.inline_config().is_rule_disabled(self.name(), line_num) {
369 result.push(line);
370 continue;
371 }
372
373 if filtered_line.line_info.in_front_matter {
375 if !in_front_matter {
376 let allowed_blanks = blank_count.min(self.config.maximum.get());
378 if allowed_blanks > 0 {
379 result.extend(vec![""; allowed_blanks]);
380 }
381 blank_count = 0;
382 in_front_matter = true;
383 last_content_is_heading = false;
384 }
385 result.push(line);
386 continue;
387 } else if in_front_matter {
388 in_front_matter = false;
390 last_content_is_heading = false;
391 }
392
393 if line.trim_start().starts_with("```") || line.trim_start().starts_with("~~~") {
395 if !in_code_block {
397 let effective_max = if last_content_is_heading {
399 self.effective_max_below()
400 } else {
401 self.config.maximum.get()
402 };
403 let allowed_blanks = blank_count.min(effective_max);
404 if allowed_blanks > 0 {
405 result.extend(vec![""; allowed_blanks]);
406 }
407 blank_count = 0;
408 last_content_is_heading = false;
409 } else {
410 result.append(&mut code_block_blanks);
412 }
413 in_code_block = !in_code_block;
414 result.push(line);
415 continue;
416 }
417
418 if in_code_block {
419 if line.trim().is_empty() {
420 code_block_blanks.push(line);
421 } else {
422 result.append(&mut code_block_blanks);
423 result.push(line);
424 }
425 } else if line.trim().is_empty() {
426 blank_count += 1;
427 } else {
428 let heading_below = last_content_is_heading;
431 let heading_above = has_seen_content && is_heading_context(ctx, line_idx);
432 let effective_max = if heading_below && heading_above {
433 self.effective_max_above().max(self.effective_max_below())
434 } else if heading_below {
435 self.effective_max_below()
436 } else if heading_above {
437 self.effective_max_above()
438 } else {
439 self.config.maximum.get()
440 };
441 let allowed_blanks = blank_count.min(effective_max);
442 if allowed_blanks > 0 {
443 result.extend(vec![""; allowed_blanks]);
444 }
445 blank_count = 0;
446 last_content_is_heading = is_heading_context(ctx, line_idx);
447 has_seen_content = true;
448 result.push(line);
449 }
450 }
451
452 let mut output = result.join("\n");
456 if content.ends_with('\n') {
457 output.push('\n');
458 }
459
460 Ok(output)
461 }
462
463 fn as_any(&self) -> &dyn std::any::Any {
464 self
465 }
466
467 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
468 ctx.content.is_empty() || !ctx.has_char('\n')
470 }
471
472 fn default_config_section(&self) -> Option<(String, toml::Value)> {
473 let default_config = MD012Config::default();
474 let json_value = serde_json::to_value(&default_config).ok()?;
475 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
476
477 if let toml::Value::Table(table) = toml_value {
478 if !table.is_empty() {
479 Some((MD012Config::RULE_NAME.to_string(), toml::Value::Table(table)))
480 } else {
481 None
482 }
483 } else {
484 None
485 }
486 }
487
488 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
489 where
490 Self: Sized,
491 {
492 use crate::rules::md022_blanks_around_headings::md022_config::MD022Config;
493
494 let rule_config = crate::rule_config_serde::load_rule_config::<MD012Config>(config);
495
496 let md022_disabled = config.global.disable.iter().any(|r| r == "MD022")
499 || config.global.extend_disable.iter().any(|r| r == "MD022");
500
501 let (heading_above, heading_below) = if md022_disabled {
502 (rule_config.maximum.get(), rule_config.maximum.get())
504 } else {
505 let md022_config = crate::rule_config_serde::load_rule_config::<MD022Config>(config);
506 (
507 max_heading_limit(&md022_config.lines_above),
508 max_heading_limit(&md022_config.lines_below),
509 )
510 };
511
512 Box::new(Self {
513 config: rule_config,
514 heading_blanks_above: heading_above,
515 heading_blanks_below: heading_below,
516 })
517 }
518}
519
520#[cfg(test)]
521mod tests {
522 use super::*;
523 use crate::lint_context::LintContext;
524
525 #[test]
526 fn test_single_blank_line_allowed() {
527 let rule = MD012NoMultipleBlanks::default();
528 let content = "Line 1\n\nLine 2\n\nLine 3";
529 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
530 let result = rule.check(&ctx).unwrap();
531 assert!(result.is_empty());
532 }
533
534 #[test]
535 fn test_multiple_blank_lines_flagged() {
536 let rule = MD012NoMultipleBlanks::default();
537 let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
538 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
539 let result = rule.check(&ctx).unwrap();
540 assert_eq!(result.len(), 3); assert_eq!(result[0].line, 3);
542 assert_eq!(result[1].line, 6);
543 assert_eq!(result[2].line, 7);
544 }
545
546 #[test]
547 fn test_custom_maximum() {
548 let rule = MD012NoMultipleBlanks::new(2);
549 let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
550 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
551 let result = rule.check(&ctx).unwrap();
552 assert_eq!(result.len(), 1); assert_eq!(result[0].line, 7);
554 }
555
556 #[test]
557 fn test_fix_multiple_blank_lines() {
558 let rule = MD012NoMultipleBlanks::default();
559 let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
560 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
561 let fixed = rule.fix(&ctx).unwrap();
562 assert_eq!(fixed, "Line 1\n\nLine 2\n\nLine 3");
563 }
564
565 #[test]
566 fn test_blank_lines_in_code_block() {
567 let rule = MD012NoMultipleBlanks::default();
568 let content = "Before\n\n```\ncode\n\n\n\nmore code\n```\n\nAfter";
569 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
570 let result = rule.check(&ctx).unwrap();
571 assert!(result.is_empty()); }
573
574 #[test]
575 fn test_fix_preserves_code_block_blanks() {
576 let rule = MD012NoMultipleBlanks::default();
577 let content = "Before\n\n\n```\ncode\n\n\n\nmore code\n```\n\n\nAfter";
578 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
579 let fixed = rule.fix(&ctx).unwrap();
580 assert_eq!(fixed, "Before\n\n```\ncode\n\n\n\nmore code\n```\n\nAfter");
581 }
582
583 #[test]
584 fn test_blank_lines_in_front_matter() {
585 let rule = MD012NoMultipleBlanks::default();
586 let content = "---\ntitle: Test\n\n\nauthor: Me\n---\n\nContent";
587 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
588 let result = rule.check(&ctx).unwrap();
589 assert!(result.is_empty()); }
591
592 #[test]
593 fn test_blank_lines_at_start() {
594 let rule = MD012NoMultipleBlanks::default();
595 let content = "\n\n\nContent";
596 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
597 let result = rule.check(&ctx).unwrap();
598 assert_eq!(result.len(), 2);
599 assert!(result[0].message.contains("at start of file"));
600 }
601
602 #[test]
603 fn test_blank_lines_at_end() {
604 let rule = MD012NoMultipleBlanks::default();
605 let content = "Content\n\n\n";
606 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
607 let result = rule.check(&ctx).unwrap();
608 assert_eq!(result.len(), 1);
609 assert!(result[0].message.contains("at end of file"));
610 }
611
612 #[test]
613 fn test_single_blank_at_eof_flagged() {
614 let rule = MD012NoMultipleBlanks::default();
616 let content = "Content\n\n";
617 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
618 let result = rule.check(&ctx).unwrap();
619 assert_eq!(result.len(), 1);
620 assert!(result[0].message.contains("at end of file"));
621 }
622
623 #[test]
624 fn test_whitespace_only_lines() {
625 let rule = MD012NoMultipleBlanks::default();
626 let content = "Line 1\n \n\t\nLine 2";
627 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
628 let result = rule.check(&ctx).unwrap();
629 assert_eq!(result.len(), 1); }
631
632 #[test]
633 fn test_indented_code_blocks() {
634 let rule = MD012NoMultipleBlanks::default();
636 let content = "Text\n\n code\n \n \n more code\n\nText";
637 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
638 let result = rule.check(&ctx).unwrap();
639 assert!(result.is_empty(), "Should not flag blanks inside indented code blocks");
640 }
641
642 #[test]
643 fn test_blanks_in_indented_code_block() {
644 let content = " code line 1\n\n\n code line 2\n";
646 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
647 let rule = MD012NoMultipleBlanks::default();
648 let warnings = rule.check(&ctx).unwrap();
649 assert!(warnings.is_empty(), "Should not flag blanks in indented code");
650 }
651
652 #[test]
653 fn test_blanks_in_indented_code_block_with_heading() {
654 let content = "# Heading\n\n code line 1\n\n\n code line 2\n\nMore text\n";
656 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
657 let rule = MD012NoMultipleBlanks::default();
658 let warnings = rule.check(&ctx).unwrap();
659 assert!(
660 warnings.is_empty(),
661 "Should not flag blanks in indented code after heading"
662 );
663 }
664
665 #[test]
666 fn test_blanks_after_indented_code_block_flagged() {
667 let content = "# Heading\n\n code line\n\n\n\nMore text\n";
669 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
670 let rule = MD012NoMultipleBlanks::default();
671 let warnings = rule.check(&ctx).unwrap();
672 assert_eq!(warnings.len(), 2, "Should flag blanks after indented code block ends");
674 }
675
676 #[test]
677 fn test_fix_with_final_newline() {
678 let rule = MD012NoMultipleBlanks::default();
679 let content = "Line 1\n\n\nLine 2\n";
680 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
681 let fixed = rule.fix(&ctx).unwrap();
682 assert_eq!(fixed, "Line 1\n\nLine 2\n");
683 assert!(fixed.ends_with('\n'));
684 }
685
686 #[test]
687 fn test_empty_content() {
688 let rule = MD012NoMultipleBlanks::default();
689 let content = "";
690 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
691 let result = rule.check(&ctx).unwrap();
692 assert!(result.is_empty());
693 }
694
695 #[test]
696 fn test_nested_code_blocks() {
697 let rule = MD012NoMultipleBlanks::default();
698 let content = "Before\n\n~~~\nouter\n\n```\ninner\n\n\n```\n\n~~~\n\nAfter";
699 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
700 let result = rule.check(&ctx).unwrap();
701 assert!(result.is_empty());
702 }
703
704 #[test]
705 fn test_unclosed_code_block() {
706 let rule = MD012NoMultipleBlanks::default();
707 let content = "Before\n\n```\ncode\n\n\n\nno closing fence";
708 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
709 let result = rule.check(&ctx).unwrap();
710 assert!(result.is_empty()); }
712
713 #[test]
714 fn test_mixed_fence_styles() {
715 let rule = MD012NoMultipleBlanks::default();
716 let content = "Before\n\n```\ncode\n\n\n~~~\n\nAfter";
717 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
718 let result = rule.check(&ctx).unwrap();
719 assert!(result.is_empty()); }
721
722 #[test]
723 fn test_config_from_toml() {
724 let mut config = crate::config::Config::default();
725 let mut rule_config = crate::config::RuleConfig::default();
726 rule_config
727 .values
728 .insert("maximum".to_string(), toml::Value::Integer(3));
729 config.rules.insert("MD012".to_string(), rule_config);
730
731 let rule = MD012NoMultipleBlanks::from_config(&config);
732 let content = "Line 1\n\n\n\nLine 2"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
734 let result = rule.check(&ctx).unwrap();
735 assert!(result.is_empty()); }
737
738 #[test]
739 fn test_blank_lines_between_sections() {
740 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 1);
742 let content = "# Section 1\n\nContent\n\n\n# Section 2\n\nContent";
743 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
744 let result = rule.check(&ctx).unwrap();
745 assert!(
746 result.is_empty(),
747 "2 blanks above heading allowed with heading_blanks_above=2"
748 );
749 }
750
751 #[test]
752 fn test_fix_preserves_indented_code() {
753 let rule = MD012NoMultipleBlanks::default();
754 let content = "Text\n\n\n code\n \n more code\n\n\nText";
755 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
756 let fixed = rule.fix(&ctx).unwrap();
757 assert_eq!(fixed, "Text\n\n code\n\n more code\n\nText");
759 }
760
761 #[test]
762 fn test_edge_case_only_blanks() {
763 let rule = MD012NoMultipleBlanks::default();
764 let content = "\n\n\n";
765 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
766 let result = rule.check(&ctx).unwrap();
767 assert_eq!(result.len(), 1);
769 assert!(result[0].message.contains("at end of file"));
770 }
771
772 #[test]
775 fn test_blanks_after_fenced_code_block_mid_document() {
776 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 1);
778 let content = "## Input\n\n```javascript\ncode\n```\n\n\n## Error\n";
779 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
780 let result = rule.check(&ctx).unwrap();
781 assert!(
782 result.is_empty(),
783 "2 blanks before heading allowed with heading_blanks_above=2"
784 );
785 }
786
787 #[test]
788 fn test_blanks_after_code_block_at_eof() {
789 let rule = MD012NoMultipleBlanks::default();
791 let content = "# Heading\n\n```\ncode\n```\n\n\n";
792 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
793 let result = rule.check(&ctx).unwrap();
794 assert_eq!(result.len(), 1, "Should detect trailing blanks after code block");
796 assert!(result[0].message.contains("at end of file"));
797 }
798
799 #[test]
800 fn test_single_blank_after_code_block_allowed() {
801 let rule = MD012NoMultipleBlanks::default();
803 let content = "## Input\n\n```\ncode\n```\n\n## Output\n";
804 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
805 let result = rule.check(&ctx).unwrap();
806 assert!(result.is_empty(), "Single blank after code block should be allowed");
807 }
808
809 #[test]
810 fn test_multiple_code_blocks_with_blanks() {
811 let rule = MD012NoMultipleBlanks::default();
813 let content = "```\ncode1\n```\n\n\n```\ncode2\n```\n\n\nEnd\n";
814 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
815 let result = rule.check(&ctx).unwrap();
816 assert_eq!(result.len(), 2, "Should detect blanks after both code blocks");
818 }
819
820 #[test]
821 fn test_whitespace_only_lines_after_code_block_at_eof() {
822 let rule = MD012NoMultipleBlanks::default();
825 let content = "```\ncode\n```\n \n \n";
826 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
827 let result = rule.check(&ctx).unwrap();
828 assert_eq!(result.len(), 1, "Should detect whitespace-only trailing blanks");
829 assert!(result[0].message.contains("at end of file"));
830 }
831
832 #[test]
835 fn test_warning_fix_removes_single_trailing_blank() {
836 let rule = MD012NoMultipleBlanks::default();
838 let content = "hello foobar hello.\n\n";
839 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
840 let warnings = rule.check(&ctx).unwrap();
841
842 assert_eq!(warnings.len(), 1);
843 assert!(warnings[0].fix.is_some(), "Warning should have a fix attached");
844
845 let fix = warnings[0].fix.as_ref().unwrap();
846 assert_eq!(fix.replacement, "", "Replacement should be empty");
848
849 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
851 assert_eq!(fixed, "hello foobar hello.\n", "Should end with single newline");
852 }
853
854 #[test]
855 fn test_warning_fix_removes_multiple_trailing_blanks() {
856 let rule = MD012NoMultipleBlanks::default();
857 let content = "content\n\n\n\n";
858 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
859 let warnings = rule.check(&ctx).unwrap();
860
861 assert_eq!(warnings.len(), 1);
862 assert!(warnings[0].fix.is_some());
863
864 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
865 assert_eq!(fixed, "content\n", "Should end with single newline");
866 }
867
868 #[test]
869 fn test_warning_fix_preserves_content_newline() {
870 let rule = MD012NoMultipleBlanks::default();
872 let content = "line1\nline2\n\n";
873 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
874 let warnings = rule.check(&ctx).unwrap();
875
876 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
877 assert_eq!(fixed, "line1\nline2\n", "Should preserve all content lines");
878 }
879
880 #[test]
881 fn test_warning_fix_mid_document_blanks() {
882 let rule = MD012NoMultipleBlanks::default();
884 let content = "# Heading\n\n\n\nParagraph\n";
885 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
886 let warnings = rule.check(&ctx).unwrap();
887 assert_eq!(
888 warnings.len(),
889 2,
890 "Excess heading-adjacent blanks flagged with default limits"
891 );
892 }
893
894 #[test]
899 fn test_heading_aware_blanks_below_with_higher_limit() {
900 let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 2);
902 let content = "# Heading\n\n\nParagraph\n";
903 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
904 let result = rule.check(&ctx).unwrap();
905 assert!(
906 result.is_empty(),
907 "2 blanks below heading allowed with heading_blanks_below=2"
908 );
909 }
910
911 #[test]
912 fn test_heading_aware_blanks_above_with_higher_limit() {
913 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 1);
915 let content = "Paragraph\n\n\n# Heading\n";
916 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
917 let result = rule.check(&ctx).unwrap();
918 assert!(
919 result.is_empty(),
920 "2 blanks above heading allowed with heading_blanks_above=2"
921 );
922 }
923
924 #[test]
925 fn test_heading_aware_blanks_between_headings() {
926 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 2);
928 let content = "# Heading 1\n\n\n## Heading 2\n";
929 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
930 let result = rule.check(&ctx).unwrap();
931 assert!(result.is_empty(), "2 blanks between headings allowed with limits=2");
932 }
933
934 #[test]
935 fn test_heading_aware_excess_still_flagged() {
936 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 2);
938 let content = "# Heading\n\n\n\n\nParagraph\n"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
940 let result = rule.check(&ctx).unwrap();
941 assert_eq!(result.len(), 2, "Excess beyond heading limit should be flagged");
942 }
943
944 #[test]
945 fn test_heading_aware_setext_blanks_below() {
946 let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 2);
948 let content = "Heading\n=======\n\n\nParagraph\n";
949 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
950 let result = rule.check(&ctx).unwrap();
951 assert!(result.is_empty(), "2 blanks below Setext heading allowed with limit=2");
952 }
953
954 #[test]
955 fn test_heading_aware_setext_blanks_above() {
956 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 1);
958 let content = "Paragraph\n\n\nHeading\n=======\n";
959 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
960 let result = rule.check(&ctx).unwrap();
961 assert!(result.is_empty(), "2 blanks above Setext heading allowed with limit=2");
962 }
963
964 #[test]
965 fn test_heading_aware_single_blank_allowed() {
966 let rule = MD012NoMultipleBlanks::default();
968 let content = "# Heading\n\nParagraph\n";
969 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
970 let result = rule.check(&ctx).unwrap();
971 assert!(result.is_empty(), "Single blank near heading should be allowed");
972 }
973
974 #[test]
975 fn test_heading_aware_non_heading_blanks_still_flagged() {
976 let rule = MD012NoMultipleBlanks::default();
978 let content = "Paragraph 1\n\n\nParagraph 2\n";
979 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
980 let result = rule.check(&ctx).unwrap();
981 assert_eq!(result.len(), 1, "Non-heading blanks should still be flagged");
982 }
983
984 #[test]
985 fn test_heading_aware_fix_caps_heading_blanks() {
986 let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 2);
988 let content = "# Heading\n\n\n\nParagraph\n"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
990 let fixed = rule.fix(&ctx).unwrap();
991 assert_eq!(
992 fixed, "# Heading\n\n\nParagraph\n",
993 "Fix caps heading-adjacent blanks at effective max (2)"
994 );
995 }
996
997 #[test]
998 fn test_heading_aware_fix_preserves_allowed_heading_blanks() {
999 let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 3);
1001 let content = "# Heading\n\n\n\nParagraph\n"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1003 let fixed = rule.fix(&ctx).unwrap();
1004 assert_eq!(
1005 fixed, "# Heading\n\n\n\nParagraph\n",
1006 "Fix preserves blanks within the heading limit"
1007 );
1008 }
1009
1010 #[test]
1011 fn test_heading_aware_fix_reduces_non_heading_blanks() {
1012 let rule = MD012NoMultipleBlanks::default();
1014 let content = "Paragraph 1\n\n\n\nParagraph 2\n";
1015 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1016 let fixed = rule.fix(&ctx).unwrap();
1017 assert_eq!(
1018 fixed, "Paragraph 1\n\nParagraph 2\n",
1019 "Fix should reduce non-heading blanks"
1020 );
1021 }
1022
1023 #[test]
1024 fn test_heading_aware_mixed_heading_and_non_heading() {
1025 let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 2);
1027 let content = "# Heading\n\n\nParagraph 1\n\n\nParagraph 2\n";
1028 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1029 let result = rule.check(&ctx).unwrap();
1030 assert_eq!(result.len(), 1, "Only non-heading excess should be flagged");
1032 }
1033
1034 #[test]
1035 fn test_heading_aware_blanks_at_start_before_heading_still_flagged() {
1036 let rule = MD012NoMultipleBlanks::default().with_heading_limits(3, 3);
1039 let content = "\n\n\n# Heading\n";
1040 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1041 let result = rule.check(&ctx).unwrap();
1042 assert_eq!(
1043 result.len(),
1044 2,
1045 "Start-of-file blanks should be flagged even before heading"
1046 );
1047 assert!(result[0].message.contains("at start of file"));
1048 }
1049
1050 #[test]
1051 fn test_heading_aware_eof_blanks_after_heading_still_flagged() {
1052 let rule = MD012NoMultipleBlanks::default();
1054 let content = "# Heading\n\n";
1055 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1056 let result = rule.check(&ctx).unwrap();
1057 assert_eq!(result.len(), 1, "EOF blanks should still be flagged");
1058 assert!(result[0].message.contains("at end of file"));
1059 }
1060
1061 #[test]
1062 fn test_heading_aware_unlimited_heading_blanks() {
1063 let rule = MD012NoMultipleBlanks::default().with_heading_limits(usize::MAX, usize::MAX);
1065 let content = "# Heading\n\n\n\n\nParagraph\n"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1067 let result = rule.check(&ctx).unwrap();
1068 assert!(
1069 result.is_empty(),
1070 "Unlimited heading limits means MD012 never flags near headings"
1071 );
1072 }
1073
1074 #[test]
1075 fn test_heading_aware_blanks_after_code_then_heading() {
1076 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 2);
1078 let content = "# Heading\n\n```\ncode\n```\n\n\n\nMore text\n";
1079 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1080 let result = rule.check(&ctx).unwrap();
1081 assert_eq!(result.len(), 2, "Non-heading blanks after code block should be flagged");
1083 }
1084
1085 #[test]
1086 fn test_heading_aware_fix_mixed_document() {
1087 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 2);
1089 let content = "# Title\n\n\n## Section\n\n\nPara 1\n\n\nPara 2\n";
1090 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1091 let fixed = rule.fix(&ctx).unwrap();
1092 assert_eq!(fixed, "# Title\n\n\n## Section\n\n\nPara 1\n\nPara 2\n");
1094 }
1095
1096 #[test]
1097 fn test_heading_aware_from_config_reads_md022() {
1098 let mut config = crate::config::Config::default();
1100 let mut md022_config = crate::config::RuleConfig::default();
1101 md022_config
1102 .values
1103 .insert("lines-above".to_string(), toml::Value::Integer(2));
1104 md022_config
1105 .values
1106 .insert("lines-below".to_string(), toml::Value::Integer(3));
1107 config.rules.insert("MD022".to_string(), md022_config);
1108
1109 let rule = MD012NoMultipleBlanks::from_config(&config);
1110 let content = "Paragraph\n\n\n# Heading\n";
1112 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1113 let result = rule.check(&ctx).unwrap();
1114 assert!(
1115 result.is_empty(),
1116 "2 blanks above heading allowed when MD022 lines-above=2"
1117 );
1118 }
1119
1120 #[test]
1121 fn test_heading_aware_from_config_md022_disabled() {
1122 let mut config = crate::config::Config::default();
1124 config.global.disable.push("MD022".to_string());
1125
1126 let mut md022_config = crate::config::RuleConfig::default();
1127 md022_config
1128 .values
1129 .insert("lines-above".to_string(), toml::Value::Integer(3));
1130 config.rules.insert("MD022".to_string(), md022_config);
1131
1132 let rule = MD012NoMultipleBlanks::from_config(&config);
1133 let content = "Paragraph\n\n\n# Heading\n";
1135 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1136 let result = rule.check(&ctx).unwrap();
1137 assert_eq!(
1138 result.len(),
1139 1,
1140 "With MD022 disabled, heading-adjacent blanks are flagged"
1141 );
1142 }
1143
1144 #[test]
1145 fn test_heading_aware_from_config_md022_unlimited() {
1146 let mut config = crate::config::Config::default();
1148 let mut md022_config = crate::config::RuleConfig::default();
1149 md022_config
1150 .values
1151 .insert("lines-above".to_string(), toml::Value::Integer(-1));
1152 config.rules.insert("MD022".to_string(), md022_config);
1153
1154 let rule = MD012NoMultipleBlanks::from_config(&config);
1155 let content = "Paragraph\n\n\n\n\n# Heading\n"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1157 let result = rule.check(&ctx).unwrap();
1158 assert!(
1159 result.is_empty(),
1160 "Unlimited MD022 lines-above means MD012 never flags above headings"
1161 );
1162 }
1163
1164 #[test]
1165 fn test_heading_aware_from_config_per_level() {
1166 let mut config = crate::config::Config::default();
1171 let mut md022_config = crate::config::RuleConfig::default();
1172 md022_config.values.insert(
1173 "lines-above".to_string(),
1174 toml::Value::Array(vec![
1175 toml::Value::Integer(2),
1176 toml::Value::Integer(1),
1177 toml::Value::Integer(1),
1178 toml::Value::Integer(1),
1179 toml::Value::Integer(1),
1180 toml::Value::Integer(1),
1181 ]),
1182 );
1183 config.rules.insert("MD022".to_string(), md022_config);
1184
1185 let rule = MD012NoMultipleBlanks::from_config(&config);
1186
1187 let content = "Paragraph\n\n\n## H2 Heading\n";
1189 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1190 let result = rule.check(&ctx).unwrap();
1191 assert!(result.is_empty(), "Per-level max (2) allows 2 blanks above any heading");
1192
1193 let content = "Paragraph\n\n\n\n## H2 Heading\n";
1195 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1196 let result = rule.check(&ctx).unwrap();
1197 assert_eq!(result.len(), 1, "3 blanks exceeds per-level max of 2");
1198 }
1199
1200 #[test]
1201 fn test_issue_449_reproduction() {
1202 let rule = MD012NoMultipleBlanks::default();
1205 let content = "\
1206# Heading
1207
1208
1209Some introductory text.
1210
1211
1212
1213
1214
1215## Heading level 2
1216
1217
1218Some text for this section.
1219
1220Some more text for this section.
1221
1222
1223## Another heading level 2
1224
1225
1226
1227Some text for this section.
1228
1229Some more text for this section.
1230";
1231 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1232 let result = rule.check(&ctx).unwrap();
1233 assert!(
1234 !result.is_empty(),
1235 "Issue #449: excess blanks around headings should be flagged with default settings"
1236 );
1237
1238 let fixed = rule.fix(&ctx).unwrap();
1240 let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1241 let recheck = rule.check(&fixed_ctx).unwrap();
1242 assert!(recheck.is_empty(), "Fix should resolve all excess blank lines");
1243
1244 assert!(fixed.contains("# Heading\n\nSome"), "1 blank below first heading");
1246 assert!(
1247 fixed.contains("text.\n\n## Heading level 2"),
1248 "1 blank above second heading"
1249 );
1250 }
1251
1252 #[test]
1255 fn test_blank_lines_in_quarto_callout() {
1256 let rule = MD012NoMultipleBlanks::default();
1258 let content = "# Heading\n\n::: {.callout-note}\nNote content\n\n\nMore content\n:::\n\nAfter";
1259 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1260 let result = rule.check(&ctx).unwrap();
1261 assert!(result.is_empty(), "Should not flag blanks inside Quarto callouts");
1262 }
1263
1264 #[test]
1265 fn test_blank_lines_in_quarto_div() {
1266 let rule = MD012NoMultipleBlanks::default();
1268 let content = "Text\n\n::: {.bordered}\nContent\n\n\nMore\n:::\n\nText";
1269 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1270 let result = rule.check(&ctx).unwrap();
1271 assert!(result.is_empty(), "Should not flag blanks inside Quarto divs");
1272 }
1273
1274 #[test]
1275 fn test_blank_lines_outside_quarto_div_flagged() {
1276 let rule = MD012NoMultipleBlanks::default();
1278 let content = "Text\n\n\n::: {.callout-note}\nNote\n:::\n\n\nMore";
1279 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1280 let result = rule.check(&ctx).unwrap();
1281 assert!(!result.is_empty(), "Should flag blanks outside Quarto divs");
1282 }
1283
1284 #[test]
1285 fn test_quarto_divs_ignored_in_standard_flavor() {
1286 let rule = MD012NoMultipleBlanks::default();
1288 let content = "::: {.callout-note}\nNote content\n\n\nMore content\n:::\n";
1289 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1290 let result = rule.check(&ctx).unwrap();
1291 assert!(!result.is_empty(), "Standard flavor should flag blanks in 'div'");
1293 }
1294}