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_idx = filtered_line.line_num - 1; if filtered_line.line_info.in_front_matter {
368 if !in_front_matter {
369 let allowed_blanks = blank_count.min(self.config.maximum.get());
371 if allowed_blanks > 0 {
372 result.extend(vec![""; allowed_blanks]);
373 }
374 blank_count = 0;
375 in_front_matter = true;
376 last_content_is_heading = false;
377 }
378 result.push(line);
379 continue;
380 } else if in_front_matter {
381 in_front_matter = false;
383 last_content_is_heading = false;
384 }
385
386 if line.trim_start().starts_with("```") || line.trim_start().starts_with("~~~") {
388 if !in_code_block {
390 let effective_max = if last_content_is_heading {
392 self.effective_max_below()
393 } else {
394 self.config.maximum.get()
395 };
396 let allowed_blanks = blank_count.min(effective_max);
397 if allowed_blanks > 0 {
398 result.extend(vec![""; allowed_blanks]);
399 }
400 blank_count = 0;
401 last_content_is_heading = false;
402 } else {
403 result.append(&mut code_block_blanks);
405 }
406 in_code_block = !in_code_block;
407 result.push(line);
408 continue;
409 }
410
411 if in_code_block {
412 if line.trim().is_empty() {
413 code_block_blanks.push(line);
414 } else {
415 result.append(&mut code_block_blanks);
416 result.push(line);
417 }
418 } else if line.trim().is_empty() {
419 blank_count += 1;
420 } else {
421 let heading_below = last_content_is_heading;
424 let heading_above = has_seen_content && is_heading_context(ctx, line_idx);
425 let effective_max = if heading_below && heading_above {
426 self.effective_max_above().max(self.effective_max_below())
427 } else if heading_below {
428 self.effective_max_below()
429 } else if heading_above {
430 self.effective_max_above()
431 } else {
432 self.config.maximum.get()
433 };
434 let allowed_blanks = blank_count.min(effective_max);
435 if allowed_blanks > 0 {
436 result.extend(vec![""; allowed_blanks]);
437 }
438 blank_count = 0;
439 last_content_is_heading = is_heading_context(ctx, line_idx);
440 has_seen_content = true;
441 result.push(line);
442 }
443 }
444
445 let mut output = result.join("\n");
449 if content.ends_with('\n') {
450 output.push('\n');
451 }
452
453 Ok(output)
454 }
455
456 fn as_any(&self) -> &dyn std::any::Any {
457 self
458 }
459
460 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
461 ctx.content.is_empty() || !ctx.has_char('\n')
463 }
464
465 fn default_config_section(&self) -> Option<(String, toml::Value)> {
466 let default_config = MD012Config::default();
467 let json_value = serde_json::to_value(&default_config).ok()?;
468 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
469
470 if let toml::Value::Table(table) = toml_value {
471 if !table.is_empty() {
472 Some((MD012Config::RULE_NAME.to_string(), toml::Value::Table(table)))
473 } else {
474 None
475 }
476 } else {
477 None
478 }
479 }
480
481 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
482 where
483 Self: Sized,
484 {
485 use crate::rules::md022_blanks_around_headings::md022_config::MD022Config;
486
487 let rule_config = crate::rule_config_serde::load_rule_config::<MD012Config>(config);
488
489 let md022_disabled = config.global.disable.iter().any(|r| r == "MD022")
492 || config.global.extend_disable.iter().any(|r| r == "MD022");
493
494 let (heading_above, heading_below) = if md022_disabled {
495 (rule_config.maximum.get(), rule_config.maximum.get())
497 } else {
498 let md022_config = crate::rule_config_serde::load_rule_config::<MD022Config>(config);
499 (
500 max_heading_limit(&md022_config.lines_above),
501 max_heading_limit(&md022_config.lines_below),
502 )
503 };
504
505 Box::new(Self {
506 config: rule_config,
507 heading_blanks_above: heading_above,
508 heading_blanks_below: heading_below,
509 })
510 }
511}
512
513#[cfg(test)]
514mod tests {
515 use super::*;
516 use crate::lint_context::LintContext;
517
518 #[test]
519 fn test_single_blank_line_allowed() {
520 let rule = MD012NoMultipleBlanks::default();
521 let content = "Line 1\n\nLine 2\n\nLine 3";
522 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
523 let result = rule.check(&ctx).unwrap();
524 assert!(result.is_empty());
525 }
526
527 #[test]
528 fn test_multiple_blank_lines_flagged() {
529 let rule = MD012NoMultipleBlanks::default();
530 let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
531 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
532 let result = rule.check(&ctx).unwrap();
533 assert_eq!(result.len(), 3); assert_eq!(result[0].line, 3);
535 assert_eq!(result[1].line, 6);
536 assert_eq!(result[2].line, 7);
537 }
538
539 #[test]
540 fn test_custom_maximum() {
541 let rule = MD012NoMultipleBlanks::new(2);
542 let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
543 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
544 let result = rule.check(&ctx).unwrap();
545 assert_eq!(result.len(), 1); assert_eq!(result[0].line, 7);
547 }
548
549 #[test]
550 fn test_fix_multiple_blank_lines() {
551 let rule = MD012NoMultipleBlanks::default();
552 let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
553 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
554 let fixed = rule.fix(&ctx).unwrap();
555 assert_eq!(fixed, "Line 1\n\nLine 2\n\nLine 3");
556 }
557
558 #[test]
559 fn test_blank_lines_in_code_block() {
560 let rule = MD012NoMultipleBlanks::default();
561 let content = "Before\n\n```\ncode\n\n\n\nmore code\n```\n\nAfter";
562 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
563 let result = rule.check(&ctx).unwrap();
564 assert!(result.is_empty()); }
566
567 #[test]
568 fn test_fix_preserves_code_block_blanks() {
569 let rule = MD012NoMultipleBlanks::default();
570 let content = "Before\n\n\n```\ncode\n\n\n\nmore code\n```\n\n\nAfter";
571 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
572 let fixed = rule.fix(&ctx).unwrap();
573 assert_eq!(fixed, "Before\n\n```\ncode\n\n\n\nmore code\n```\n\nAfter");
574 }
575
576 #[test]
577 fn test_blank_lines_in_front_matter() {
578 let rule = MD012NoMultipleBlanks::default();
579 let content = "---\ntitle: Test\n\n\nauthor: Me\n---\n\nContent";
580 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
581 let result = rule.check(&ctx).unwrap();
582 assert!(result.is_empty()); }
584
585 #[test]
586 fn test_blank_lines_at_start() {
587 let rule = MD012NoMultipleBlanks::default();
588 let content = "\n\n\nContent";
589 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
590 let result = rule.check(&ctx).unwrap();
591 assert_eq!(result.len(), 2);
592 assert!(result[0].message.contains("at start of file"));
593 }
594
595 #[test]
596 fn test_blank_lines_at_end() {
597 let rule = MD012NoMultipleBlanks::default();
598 let content = "Content\n\n\n";
599 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
600 let result = rule.check(&ctx).unwrap();
601 assert_eq!(result.len(), 1);
602 assert!(result[0].message.contains("at end of file"));
603 }
604
605 #[test]
606 fn test_single_blank_at_eof_flagged() {
607 let rule = MD012NoMultipleBlanks::default();
609 let content = "Content\n\n";
610 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
611 let result = rule.check(&ctx).unwrap();
612 assert_eq!(result.len(), 1);
613 assert!(result[0].message.contains("at end of file"));
614 }
615
616 #[test]
617 fn test_whitespace_only_lines() {
618 let rule = MD012NoMultipleBlanks::default();
619 let content = "Line 1\n \n\t\nLine 2";
620 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
621 let result = rule.check(&ctx).unwrap();
622 assert_eq!(result.len(), 1); }
624
625 #[test]
626 fn test_indented_code_blocks() {
627 let rule = MD012NoMultipleBlanks::default();
629 let content = "Text\n\n code\n \n \n more code\n\nText";
630 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
631 let result = rule.check(&ctx).unwrap();
632 assert!(result.is_empty(), "Should not flag blanks inside indented code blocks");
633 }
634
635 #[test]
636 fn test_blanks_in_indented_code_block() {
637 let content = " code line 1\n\n\n code line 2\n";
639 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
640 let rule = MD012NoMultipleBlanks::default();
641 let warnings = rule.check(&ctx).unwrap();
642 assert!(warnings.is_empty(), "Should not flag blanks in indented code");
643 }
644
645 #[test]
646 fn test_blanks_in_indented_code_block_with_heading() {
647 let content = "# Heading\n\n code line 1\n\n\n code line 2\n\nMore text\n";
649 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
650 let rule = MD012NoMultipleBlanks::default();
651 let warnings = rule.check(&ctx).unwrap();
652 assert!(
653 warnings.is_empty(),
654 "Should not flag blanks in indented code after heading"
655 );
656 }
657
658 #[test]
659 fn test_blanks_after_indented_code_block_flagged() {
660 let content = "# Heading\n\n code line\n\n\n\nMore text\n";
662 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
663 let rule = MD012NoMultipleBlanks::default();
664 let warnings = rule.check(&ctx).unwrap();
665 assert_eq!(warnings.len(), 2, "Should flag blanks after indented code block ends");
667 }
668
669 #[test]
670 fn test_fix_with_final_newline() {
671 let rule = MD012NoMultipleBlanks::default();
672 let content = "Line 1\n\n\nLine 2\n";
673 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
674 let fixed = rule.fix(&ctx).unwrap();
675 assert_eq!(fixed, "Line 1\n\nLine 2\n");
676 assert!(fixed.ends_with('\n'));
677 }
678
679 #[test]
680 fn test_empty_content() {
681 let rule = MD012NoMultipleBlanks::default();
682 let content = "";
683 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
684 let result = rule.check(&ctx).unwrap();
685 assert!(result.is_empty());
686 }
687
688 #[test]
689 fn test_nested_code_blocks() {
690 let rule = MD012NoMultipleBlanks::default();
691 let content = "Before\n\n~~~\nouter\n\n```\ninner\n\n\n```\n\n~~~\n\nAfter";
692 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
693 let result = rule.check(&ctx).unwrap();
694 assert!(result.is_empty());
695 }
696
697 #[test]
698 fn test_unclosed_code_block() {
699 let rule = MD012NoMultipleBlanks::default();
700 let content = "Before\n\n```\ncode\n\n\n\nno closing fence";
701 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
702 let result = rule.check(&ctx).unwrap();
703 assert!(result.is_empty()); }
705
706 #[test]
707 fn test_mixed_fence_styles() {
708 let rule = MD012NoMultipleBlanks::default();
709 let content = "Before\n\n```\ncode\n\n\n~~~\n\nAfter";
710 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
711 let result = rule.check(&ctx).unwrap();
712 assert!(result.is_empty()); }
714
715 #[test]
716 fn test_config_from_toml() {
717 let mut config = crate::config::Config::default();
718 let mut rule_config = crate::config::RuleConfig::default();
719 rule_config
720 .values
721 .insert("maximum".to_string(), toml::Value::Integer(3));
722 config.rules.insert("MD012".to_string(), rule_config);
723
724 let rule = MD012NoMultipleBlanks::from_config(&config);
725 let content = "Line 1\n\n\n\nLine 2"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
727 let result = rule.check(&ctx).unwrap();
728 assert!(result.is_empty()); }
730
731 #[test]
732 fn test_blank_lines_between_sections() {
733 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 1);
735 let content = "# Section 1\n\nContent\n\n\n# Section 2\n\nContent";
736 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
737 let result = rule.check(&ctx).unwrap();
738 assert!(
739 result.is_empty(),
740 "2 blanks above heading allowed with heading_blanks_above=2"
741 );
742 }
743
744 #[test]
745 fn test_fix_preserves_indented_code() {
746 let rule = MD012NoMultipleBlanks::default();
747 let content = "Text\n\n\n code\n \n more code\n\n\nText";
748 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
749 let fixed = rule.fix(&ctx).unwrap();
750 assert_eq!(fixed, "Text\n\n code\n\n more code\n\nText");
752 }
753
754 #[test]
755 fn test_edge_case_only_blanks() {
756 let rule = MD012NoMultipleBlanks::default();
757 let content = "\n\n\n";
758 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
759 let result = rule.check(&ctx).unwrap();
760 assert_eq!(result.len(), 1);
762 assert!(result[0].message.contains("at end of file"));
763 }
764
765 #[test]
768 fn test_blanks_after_fenced_code_block_mid_document() {
769 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 1);
771 let content = "## Input\n\n```javascript\ncode\n```\n\n\n## Error\n";
772 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
773 let result = rule.check(&ctx).unwrap();
774 assert!(
775 result.is_empty(),
776 "2 blanks before heading allowed with heading_blanks_above=2"
777 );
778 }
779
780 #[test]
781 fn test_blanks_after_code_block_at_eof() {
782 let rule = MD012NoMultipleBlanks::default();
784 let content = "# Heading\n\n```\ncode\n```\n\n\n";
785 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
786 let result = rule.check(&ctx).unwrap();
787 assert_eq!(result.len(), 1, "Should detect trailing blanks after code block");
789 assert!(result[0].message.contains("at end of file"));
790 }
791
792 #[test]
793 fn test_single_blank_after_code_block_allowed() {
794 let rule = MD012NoMultipleBlanks::default();
796 let content = "## Input\n\n```\ncode\n```\n\n## Output\n";
797 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
798 let result = rule.check(&ctx).unwrap();
799 assert!(result.is_empty(), "Single blank after code block should be allowed");
800 }
801
802 #[test]
803 fn test_multiple_code_blocks_with_blanks() {
804 let rule = MD012NoMultipleBlanks::default();
806 let content = "```\ncode1\n```\n\n\n```\ncode2\n```\n\n\nEnd\n";
807 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
808 let result = rule.check(&ctx).unwrap();
809 assert_eq!(result.len(), 2, "Should detect blanks after both code blocks");
811 }
812
813 #[test]
814 fn test_whitespace_only_lines_after_code_block_at_eof() {
815 let rule = MD012NoMultipleBlanks::default();
818 let content = "```\ncode\n```\n \n \n";
819 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
820 let result = rule.check(&ctx).unwrap();
821 assert_eq!(result.len(), 1, "Should detect whitespace-only trailing blanks");
822 assert!(result[0].message.contains("at end of file"));
823 }
824
825 #[test]
828 fn test_warning_fix_removes_single_trailing_blank() {
829 let rule = MD012NoMultipleBlanks::default();
831 let content = "hello foobar hello.\n\n";
832 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
833 let warnings = rule.check(&ctx).unwrap();
834
835 assert_eq!(warnings.len(), 1);
836 assert!(warnings[0].fix.is_some(), "Warning should have a fix attached");
837
838 let fix = warnings[0].fix.as_ref().unwrap();
839 assert_eq!(fix.replacement, "", "Replacement should be empty");
841
842 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
844 assert_eq!(fixed, "hello foobar hello.\n", "Should end with single newline");
845 }
846
847 #[test]
848 fn test_warning_fix_removes_multiple_trailing_blanks() {
849 let rule = MD012NoMultipleBlanks::default();
850 let content = "content\n\n\n\n";
851 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
852 let warnings = rule.check(&ctx).unwrap();
853
854 assert_eq!(warnings.len(), 1);
855 assert!(warnings[0].fix.is_some());
856
857 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
858 assert_eq!(fixed, "content\n", "Should end with single newline");
859 }
860
861 #[test]
862 fn test_warning_fix_preserves_content_newline() {
863 let rule = MD012NoMultipleBlanks::default();
865 let content = "line1\nline2\n\n";
866 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
867 let warnings = rule.check(&ctx).unwrap();
868
869 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
870 assert_eq!(fixed, "line1\nline2\n", "Should preserve all content lines");
871 }
872
873 #[test]
874 fn test_warning_fix_mid_document_blanks() {
875 let rule = MD012NoMultipleBlanks::default();
877 let content = "# Heading\n\n\n\nParagraph\n";
878 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
879 let warnings = rule.check(&ctx).unwrap();
880 assert_eq!(
881 warnings.len(),
882 2,
883 "Excess heading-adjacent blanks flagged with default limits"
884 );
885 }
886
887 #[test]
892 fn test_heading_aware_blanks_below_with_higher_limit() {
893 let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 2);
895 let content = "# Heading\n\n\nParagraph\n";
896 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
897 let result = rule.check(&ctx).unwrap();
898 assert!(
899 result.is_empty(),
900 "2 blanks below heading allowed with heading_blanks_below=2"
901 );
902 }
903
904 #[test]
905 fn test_heading_aware_blanks_above_with_higher_limit() {
906 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 1);
908 let content = "Paragraph\n\n\n# Heading\n";
909 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
910 let result = rule.check(&ctx).unwrap();
911 assert!(
912 result.is_empty(),
913 "2 blanks above heading allowed with heading_blanks_above=2"
914 );
915 }
916
917 #[test]
918 fn test_heading_aware_blanks_between_headings() {
919 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 2);
921 let content = "# Heading 1\n\n\n## Heading 2\n";
922 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
923 let result = rule.check(&ctx).unwrap();
924 assert!(result.is_empty(), "2 blanks between headings allowed with limits=2");
925 }
926
927 #[test]
928 fn test_heading_aware_excess_still_flagged() {
929 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 2);
931 let content = "# Heading\n\n\n\n\nParagraph\n"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
933 let result = rule.check(&ctx).unwrap();
934 assert_eq!(result.len(), 2, "Excess beyond heading limit should be flagged");
935 }
936
937 #[test]
938 fn test_heading_aware_setext_blanks_below() {
939 let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 2);
941 let content = "Heading\n=======\n\n\nParagraph\n";
942 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
943 let result = rule.check(&ctx).unwrap();
944 assert!(result.is_empty(), "2 blanks below Setext heading allowed with limit=2");
945 }
946
947 #[test]
948 fn test_heading_aware_setext_blanks_above() {
949 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 1);
951 let content = "Paragraph\n\n\nHeading\n=======\n";
952 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
953 let result = rule.check(&ctx).unwrap();
954 assert!(result.is_empty(), "2 blanks above Setext heading allowed with limit=2");
955 }
956
957 #[test]
958 fn test_heading_aware_single_blank_allowed() {
959 let rule = MD012NoMultipleBlanks::default();
961 let content = "# Heading\n\nParagraph\n";
962 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
963 let result = rule.check(&ctx).unwrap();
964 assert!(result.is_empty(), "Single blank near heading should be allowed");
965 }
966
967 #[test]
968 fn test_heading_aware_non_heading_blanks_still_flagged() {
969 let rule = MD012NoMultipleBlanks::default();
971 let content = "Paragraph 1\n\n\nParagraph 2\n";
972 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
973 let result = rule.check(&ctx).unwrap();
974 assert_eq!(result.len(), 1, "Non-heading blanks should still be flagged");
975 }
976
977 #[test]
978 fn test_heading_aware_fix_caps_heading_blanks() {
979 let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 2);
981 let content = "# Heading\n\n\n\nParagraph\n"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
983 let fixed = rule.fix(&ctx).unwrap();
984 assert_eq!(
985 fixed, "# Heading\n\n\nParagraph\n",
986 "Fix caps heading-adjacent blanks at effective max (2)"
987 );
988 }
989
990 #[test]
991 fn test_heading_aware_fix_preserves_allowed_heading_blanks() {
992 let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 3);
994 let content = "# Heading\n\n\n\nParagraph\n"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
996 let fixed = rule.fix(&ctx).unwrap();
997 assert_eq!(
998 fixed, "# Heading\n\n\n\nParagraph\n",
999 "Fix preserves blanks within the heading limit"
1000 );
1001 }
1002
1003 #[test]
1004 fn test_heading_aware_fix_reduces_non_heading_blanks() {
1005 let rule = MD012NoMultipleBlanks::default();
1007 let content = "Paragraph 1\n\n\n\nParagraph 2\n";
1008 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1009 let fixed = rule.fix(&ctx).unwrap();
1010 assert_eq!(
1011 fixed, "Paragraph 1\n\nParagraph 2\n",
1012 "Fix should reduce non-heading blanks"
1013 );
1014 }
1015
1016 #[test]
1017 fn test_heading_aware_mixed_heading_and_non_heading() {
1018 let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 2);
1020 let content = "# Heading\n\n\nParagraph 1\n\n\nParagraph 2\n";
1021 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1022 let result = rule.check(&ctx).unwrap();
1023 assert_eq!(result.len(), 1, "Only non-heading excess should be flagged");
1025 }
1026
1027 #[test]
1028 fn test_heading_aware_blanks_at_start_before_heading_still_flagged() {
1029 let rule = MD012NoMultipleBlanks::default().with_heading_limits(3, 3);
1032 let content = "\n\n\n# Heading\n";
1033 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1034 let result = rule.check(&ctx).unwrap();
1035 assert_eq!(
1036 result.len(),
1037 2,
1038 "Start-of-file blanks should be flagged even before heading"
1039 );
1040 assert!(result[0].message.contains("at start of file"));
1041 }
1042
1043 #[test]
1044 fn test_heading_aware_eof_blanks_after_heading_still_flagged() {
1045 let rule = MD012NoMultipleBlanks::default();
1047 let content = "# Heading\n\n";
1048 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1049 let result = rule.check(&ctx).unwrap();
1050 assert_eq!(result.len(), 1, "EOF blanks should still be flagged");
1051 assert!(result[0].message.contains("at end of file"));
1052 }
1053
1054 #[test]
1055 fn test_heading_aware_unlimited_heading_blanks() {
1056 let rule = MD012NoMultipleBlanks::default().with_heading_limits(usize::MAX, usize::MAX);
1058 let content = "# Heading\n\n\n\n\nParagraph\n"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1060 let result = rule.check(&ctx).unwrap();
1061 assert!(
1062 result.is_empty(),
1063 "Unlimited heading limits means MD012 never flags near headings"
1064 );
1065 }
1066
1067 #[test]
1068 fn test_heading_aware_blanks_after_code_then_heading() {
1069 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 2);
1071 let content = "# Heading\n\n```\ncode\n```\n\n\n\nMore text\n";
1072 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1073 let result = rule.check(&ctx).unwrap();
1074 assert_eq!(result.len(), 2, "Non-heading blanks after code block should be flagged");
1076 }
1077
1078 #[test]
1079 fn test_heading_aware_fix_mixed_document() {
1080 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 2);
1082 let content = "# Title\n\n\n## Section\n\n\nPara 1\n\n\nPara 2\n";
1083 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1084 let fixed = rule.fix(&ctx).unwrap();
1085 assert_eq!(fixed, "# Title\n\n\n## Section\n\n\nPara 1\n\nPara 2\n");
1087 }
1088
1089 #[test]
1090 fn test_heading_aware_from_config_reads_md022() {
1091 let mut config = crate::config::Config::default();
1093 let mut md022_config = crate::config::RuleConfig::default();
1094 md022_config
1095 .values
1096 .insert("lines-above".to_string(), toml::Value::Integer(2));
1097 md022_config
1098 .values
1099 .insert("lines-below".to_string(), toml::Value::Integer(3));
1100 config.rules.insert("MD022".to_string(), md022_config);
1101
1102 let rule = MD012NoMultipleBlanks::from_config(&config);
1103 let content = "Paragraph\n\n\n# Heading\n";
1105 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1106 let result = rule.check(&ctx).unwrap();
1107 assert!(
1108 result.is_empty(),
1109 "2 blanks above heading allowed when MD022 lines-above=2"
1110 );
1111 }
1112
1113 #[test]
1114 fn test_heading_aware_from_config_md022_disabled() {
1115 let mut config = crate::config::Config::default();
1117 config.global.disable.push("MD022".to_string());
1118
1119 let mut md022_config = crate::config::RuleConfig::default();
1120 md022_config
1121 .values
1122 .insert("lines-above".to_string(), toml::Value::Integer(3));
1123 config.rules.insert("MD022".to_string(), md022_config);
1124
1125 let rule = MD012NoMultipleBlanks::from_config(&config);
1126 let content = "Paragraph\n\n\n# Heading\n";
1128 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1129 let result = rule.check(&ctx).unwrap();
1130 assert_eq!(
1131 result.len(),
1132 1,
1133 "With MD022 disabled, heading-adjacent blanks are flagged"
1134 );
1135 }
1136
1137 #[test]
1138 fn test_heading_aware_from_config_md022_unlimited() {
1139 let mut config = crate::config::Config::default();
1141 let mut md022_config = crate::config::RuleConfig::default();
1142 md022_config
1143 .values
1144 .insert("lines-above".to_string(), toml::Value::Integer(-1));
1145 config.rules.insert("MD022".to_string(), md022_config);
1146
1147 let rule = MD012NoMultipleBlanks::from_config(&config);
1148 let content = "Paragraph\n\n\n\n\n# Heading\n"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1150 let result = rule.check(&ctx).unwrap();
1151 assert!(
1152 result.is_empty(),
1153 "Unlimited MD022 lines-above means MD012 never flags above headings"
1154 );
1155 }
1156
1157 #[test]
1158 fn test_heading_aware_from_config_per_level() {
1159 let mut config = crate::config::Config::default();
1164 let mut md022_config = crate::config::RuleConfig::default();
1165 md022_config.values.insert(
1166 "lines-above".to_string(),
1167 toml::Value::Array(vec![
1168 toml::Value::Integer(2),
1169 toml::Value::Integer(1),
1170 toml::Value::Integer(1),
1171 toml::Value::Integer(1),
1172 toml::Value::Integer(1),
1173 toml::Value::Integer(1),
1174 ]),
1175 );
1176 config.rules.insert("MD022".to_string(), md022_config);
1177
1178 let rule = MD012NoMultipleBlanks::from_config(&config);
1179
1180 let content = "Paragraph\n\n\n## H2 Heading\n";
1182 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1183 let result = rule.check(&ctx).unwrap();
1184 assert!(result.is_empty(), "Per-level max (2) allows 2 blanks above any heading");
1185
1186 let content = "Paragraph\n\n\n\n## H2 Heading\n";
1188 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1189 let result = rule.check(&ctx).unwrap();
1190 assert_eq!(result.len(), 1, "3 blanks exceeds per-level max of 2");
1191 }
1192
1193 #[test]
1194 fn test_issue_449_reproduction() {
1195 let rule = MD012NoMultipleBlanks::default();
1198 let content = "\
1199# Heading
1200
1201
1202Some introductory text.
1203
1204
1205
1206
1207
1208## Heading level 2
1209
1210
1211Some text for this section.
1212
1213Some more text for this section.
1214
1215
1216## Another heading level 2
1217
1218
1219
1220Some text for this section.
1221
1222Some more text for this section.
1223";
1224 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1225 let result = rule.check(&ctx).unwrap();
1226 assert!(
1227 !result.is_empty(),
1228 "Issue #449: excess blanks around headings should be flagged with default settings"
1229 );
1230
1231 let fixed = rule.fix(&ctx).unwrap();
1233 let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1234 let recheck = rule.check(&fixed_ctx).unwrap();
1235 assert!(recheck.is_empty(), "Fix should resolve all excess blank lines");
1236
1237 assert!(fixed.contains("# Heading\n\nSome"), "1 blank below first heading");
1239 assert!(
1240 fixed.contains("text.\n\n## Heading level 2"),
1241 "1 blank above second heading"
1242 );
1243 }
1244
1245 #[test]
1248 fn test_blank_lines_in_quarto_callout() {
1249 let rule = MD012NoMultipleBlanks::default();
1251 let content = "# Heading\n\n::: {.callout-note}\nNote content\n\n\nMore content\n:::\n\nAfter";
1252 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1253 let result = rule.check(&ctx).unwrap();
1254 assert!(result.is_empty(), "Should not flag blanks inside Quarto callouts");
1255 }
1256
1257 #[test]
1258 fn test_blank_lines_in_quarto_div() {
1259 let rule = MD012NoMultipleBlanks::default();
1261 let content = "Text\n\n::: {.bordered}\nContent\n\n\nMore\n:::\n\nText";
1262 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1263 let result = rule.check(&ctx).unwrap();
1264 assert!(result.is_empty(), "Should not flag blanks inside Quarto divs");
1265 }
1266
1267 #[test]
1268 fn test_blank_lines_outside_quarto_div_flagged() {
1269 let rule = MD012NoMultipleBlanks::default();
1271 let content = "Text\n\n\n::: {.callout-note}\nNote\n:::\n\n\nMore";
1272 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1273 let result = rule.check(&ctx).unwrap();
1274 assert!(!result.is_empty(), "Should flag blanks outside Quarto divs");
1275 }
1276
1277 #[test]
1278 fn test_quarto_divs_ignored_in_standard_flavor() {
1279 let rule = MD012NoMultipleBlanks::default();
1281 let content = "::: {.callout-note}\nNote content\n\n\nMore content\n:::\n";
1282 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1283 let result = rule.check(&ctx).unwrap();
1284 assert!(!result.is_empty(), "Standard flavor should flag blanks in 'div'");
1286 }
1287}