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, RuleCategory, 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 category(&self) -> RuleCategory {
174 RuleCategory::Whitespace
175 }
176
177 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
178 let content = ctx.content;
179
180 if content.is_empty() {
182 return Ok(Vec::new());
183 }
184
185 let lines = ctx.raw_lines();
188 let has_potential_blanks = lines
189 .windows(2)
190 .any(|pair| pair[0].trim().is_empty() && pair[1].trim().is_empty());
191
192 let ends_with_multiple_newlines = content.ends_with("\n\n");
195
196 if !has_potential_blanks && !ends_with_multiple_newlines {
197 return Ok(Vec::new());
198 }
199
200 let line_index = &ctx.line_index;
201
202 let mut warnings = Vec::new();
203
204 let mut blank_count = 0;
206 let mut blank_start = 0;
207 let mut last_line_num: Option<usize> = None;
208 let mut prev_content_line_num: Option<usize> = None;
210
211 let mut lines_to_check: HashSet<usize> = HashSet::new();
213
214 for filtered_line in ctx
221 .filtered_lines()
222 .skip_front_matter()
223 .skip_code_blocks()
224 .skip_quarto_divs()
225 .skip_math_blocks()
226 .skip_obsidian_comments()
227 .skip_pymdown_blocks()
228 {
229 let line_num = filtered_line.line_num - 1; let line = filtered_line.content;
231
232 if let Some(last) = last_line_num
235 && line_num > last + 1
236 {
237 let effective_max = if prev_content_line_num.is_some_and(|idx| is_heading_context(ctx, idx)) {
240 self.effective_max_below()
241 } else {
242 self.config.maximum.get()
243 };
244 if blank_count > effective_max {
245 warnings.extend(self.generate_excess_warnings(
246 blank_start,
247 blank_count,
248 effective_max,
249 lines,
250 &lines_to_check,
251 line_index,
252 ));
253 }
254 blank_count = 0;
255 lines_to_check.clear();
256 prev_content_line_num = None;
258 }
259 last_line_num = Some(line_num);
260
261 if line.trim().is_empty() {
262 if blank_count == 0 {
263 blank_start = line_num;
264 }
265 blank_count += 1;
266 if blank_count > self.config.maximum.get() {
268 lines_to_check.insert(line_num);
269 }
270 } else {
271 let heading_below = prev_content_line_num.is_some_and(|idx| is_heading_context(ctx, idx));
277 let heading_above = blank_start > 0 && is_heading_context(ctx, line_num);
278 let effective_max = if heading_below && heading_above {
279 self.effective_max_above().max(self.effective_max_below())
281 } else if heading_below {
282 self.effective_max_below()
283 } else if heading_above {
284 self.effective_max_above()
285 } else {
286 self.config.maximum.get()
287 };
288
289 if blank_count > effective_max {
290 warnings.extend(self.generate_excess_warnings(
291 blank_start,
292 blank_count,
293 effective_max,
294 lines,
295 &lines_to_check,
296 line_index,
297 ));
298 }
299 blank_count = 0;
300 lines_to_check.clear();
301 prev_content_line_num = Some(line_num);
302 }
303 }
304
305 let last_line_is_blank = lines.last().is_some_and(|l| l.trim().is_empty());
312
313 if blank_count > 0 && last_line_is_blank {
317 let location = "at end of file";
318
319 let report_line = lines.len();
321
322 let fix_start = line_index
325 .get_line_start_byte(report_line - blank_count + 1)
326 .unwrap_or(0);
327 let fix_end = content.len();
328
329 warnings.push(LintWarning {
331 rule_name: Some(self.name().to_string()),
332 severity: Severity::Warning,
333 message: format!("Multiple consecutive blank lines {location}"),
334 line: report_line,
335 column: 1,
336 end_line: report_line,
337 end_column: 1,
338 fix: Some(Fix {
339 range: fix_start..fix_end,
340 replacement: String::new(),
344 }),
345 });
346 }
347
348 Ok(warnings)
349 }
350
351 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
352 let content = ctx.content;
353
354 let mut result = Vec::new();
355 let mut blank_count = 0;
356
357 let mut in_code_block = false;
358 let mut code_block_blanks = Vec::new();
359 let mut in_front_matter = false;
360 let mut last_content_is_heading: bool = false;
362 let mut has_seen_content: bool = false;
364
365 for filtered_line in ctx.filtered_lines() {
367 let line = filtered_line.content;
368 let line_num = filtered_line.line_num;
369 let line_idx = line_num - 1; if ctx.inline_config().is_rule_disabled(self.name(), line_num) {
373 result.push(line);
374 continue;
375 }
376
377 if filtered_line.line_info.in_front_matter {
379 if !in_front_matter {
380 let allowed_blanks = blank_count.min(self.config.maximum.get());
382 if allowed_blanks > 0 {
383 result.extend(vec![""; allowed_blanks]);
384 }
385 blank_count = 0;
386 in_front_matter = true;
387 last_content_is_heading = false;
388 }
389 result.push(line);
390 continue;
391 } else if in_front_matter {
392 in_front_matter = false;
394 last_content_is_heading = false;
395 }
396
397 if line.trim_start().starts_with("```") || line.trim_start().starts_with("~~~") {
399 if !in_code_block {
401 let effective_max = if last_content_is_heading {
403 self.effective_max_below()
404 } else {
405 self.config.maximum.get()
406 };
407 let allowed_blanks = blank_count.min(effective_max);
408 if allowed_blanks > 0 {
409 result.extend(vec![""; allowed_blanks]);
410 }
411 blank_count = 0;
412 last_content_is_heading = false;
413 } else {
414 result.append(&mut code_block_blanks);
416 }
417 in_code_block = !in_code_block;
418 result.push(line);
419 continue;
420 }
421
422 if in_code_block {
423 if line.trim().is_empty() {
424 code_block_blanks.push(line);
425 } else {
426 result.append(&mut code_block_blanks);
427 result.push(line);
428 }
429 } else if line.trim().is_empty() {
430 blank_count += 1;
431 } else {
432 let heading_below = last_content_is_heading;
435 let heading_above = has_seen_content && is_heading_context(ctx, line_idx);
436 let effective_max = if heading_below && heading_above {
437 self.effective_max_above().max(self.effective_max_below())
438 } else if heading_below {
439 self.effective_max_below()
440 } else if heading_above {
441 self.effective_max_above()
442 } else {
443 self.config.maximum.get()
444 };
445 let allowed_blanks = blank_count.min(effective_max);
446 if allowed_blanks > 0 {
447 result.extend(vec![""; allowed_blanks]);
448 }
449 blank_count = 0;
450 last_content_is_heading = is_heading_context(ctx, line_idx);
451 has_seen_content = true;
452 result.push(line);
453 }
454 }
455
456 let mut output = result.join("\n");
460 if content.ends_with('\n') {
461 output.push('\n');
462 }
463
464 Ok(output)
465 }
466
467 fn as_any(&self) -> &dyn std::any::Any {
468 self
469 }
470
471 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
472 ctx.content.is_empty() || !ctx.has_char('\n')
474 }
475
476 fn default_config_section(&self) -> Option<(String, toml::Value)> {
477 let default_config = MD012Config::default();
478 let json_value = serde_json::to_value(&default_config).ok()?;
479 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
480
481 if let toml::Value::Table(table) = toml_value {
482 if !table.is_empty() {
483 Some((MD012Config::RULE_NAME.to_string(), toml::Value::Table(table)))
484 } else {
485 None
486 }
487 } else {
488 None
489 }
490 }
491
492 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
493 where
494 Self: Sized,
495 {
496 use crate::rules::md022_blanks_around_headings::md022_config::MD022Config;
497
498 let rule_config = crate::rule_config_serde::load_rule_config::<MD012Config>(config);
499
500 let md022_disabled = config.global.disable.iter().any(|r| r == "MD022")
503 || config.global.extend_disable.iter().any(|r| r == "MD022");
504
505 let (heading_above, heading_below) = if md022_disabled {
506 (rule_config.maximum.get(), rule_config.maximum.get())
508 } else {
509 let md022_config = crate::rule_config_serde::load_rule_config::<MD022Config>(config);
510 (
511 max_heading_limit(&md022_config.lines_above),
512 max_heading_limit(&md022_config.lines_below),
513 )
514 };
515
516 Box::new(Self {
517 config: rule_config,
518 heading_blanks_above: heading_above,
519 heading_blanks_below: heading_below,
520 })
521 }
522}
523
524#[cfg(test)]
525mod tests {
526 use super::*;
527 use crate::lint_context::LintContext;
528
529 #[test]
530 fn test_single_blank_line_allowed() {
531 let rule = MD012NoMultipleBlanks::default();
532 let content = "Line 1\n\nLine 2\n\nLine 3";
533 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
534 let result = rule.check(&ctx).unwrap();
535 assert!(result.is_empty());
536 }
537
538 #[test]
539 fn test_multiple_blank_lines_flagged() {
540 let rule = MD012NoMultipleBlanks::default();
541 let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
542 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
543 let result = rule.check(&ctx).unwrap();
544 assert_eq!(result.len(), 3); assert_eq!(result[0].line, 3);
546 assert_eq!(result[1].line, 6);
547 assert_eq!(result[2].line, 7);
548 }
549
550 #[test]
551 fn test_custom_maximum() {
552 let rule = MD012NoMultipleBlanks::new(2);
553 let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
554 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
555 let result = rule.check(&ctx).unwrap();
556 assert_eq!(result.len(), 1); assert_eq!(result[0].line, 7);
558 }
559
560 #[test]
561 fn test_fix_multiple_blank_lines() {
562 let rule = MD012NoMultipleBlanks::default();
563 let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
564 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
565 let fixed = rule.fix(&ctx).unwrap();
566 assert_eq!(fixed, "Line 1\n\nLine 2\n\nLine 3");
567 }
568
569 #[test]
570 fn test_blank_lines_in_code_block() {
571 let rule = MD012NoMultipleBlanks::default();
572 let content = "Before\n\n```\ncode\n\n\n\nmore code\n```\n\nAfter";
573 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
574 let result = rule.check(&ctx).unwrap();
575 assert!(result.is_empty()); }
577
578 #[test]
579 fn test_fix_preserves_code_block_blanks() {
580 let rule = MD012NoMultipleBlanks::default();
581 let content = "Before\n\n\n```\ncode\n\n\n\nmore code\n```\n\n\nAfter";
582 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
583 let fixed = rule.fix(&ctx).unwrap();
584 assert_eq!(fixed, "Before\n\n```\ncode\n\n\n\nmore code\n```\n\nAfter");
585 }
586
587 #[test]
588 fn test_blank_lines_in_front_matter() {
589 let rule = MD012NoMultipleBlanks::default();
590 let content = "---\ntitle: Test\n\n\nauthor: Me\n---\n\nContent";
591 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
592 let result = rule.check(&ctx).unwrap();
593 assert!(result.is_empty()); }
595
596 #[test]
597 fn test_blank_lines_at_start() {
598 let rule = MD012NoMultipleBlanks::default();
599 let content = "\n\n\nContent";
600 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
601 let result = rule.check(&ctx).unwrap();
602 assert_eq!(result.len(), 2);
603 assert!(result[0].message.contains("at start of file"));
604 }
605
606 #[test]
607 fn test_blank_lines_at_end() {
608 let rule = MD012NoMultipleBlanks::default();
609 let content = "Content\n\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_single_blank_at_eof_flagged() {
618 let rule = MD012NoMultipleBlanks::default();
620 let content = "Content\n\n";
621 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
622 let result = rule.check(&ctx).unwrap();
623 assert_eq!(result.len(), 1);
624 assert!(result[0].message.contains("at end of file"));
625 }
626
627 #[test]
628 fn test_whitespace_only_lines() {
629 let rule = MD012NoMultipleBlanks::default();
630 let content = "Line 1\n \n\t\nLine 2";
631 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
632 let result = rule.check(&ctx).unwrap();
633 assert_eq!(result.len(), 1); }
635
636 #[test]
637 fn test_indented_code_blocks() {
638 let rule = MD012NoMultipleBlanks::default();
640 let content = "Text\n\n code\n \n \n more code\n\nText";
641 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
642 let result = rule.check(&ctx).unwrap();
643 assert!(result.is_empty(), "Should not flag blanks inside indented code blocks");
644 }
645
646 #[test]
647 fn test_blanks_in_indented_code_block() {
648 let content = " code line 1\n\n\n code line 2\n";
650 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
651 let rule = MD012NoMultipleBlanks::default();
652 let warnings = rule.check(&ctx).unwrap();
653 assert!(warnings.is_empty(), "Should not flag blanks in indented code");
654 }
655
656 #[test]
657 fn test_blanks_in_indented_code_block_with_heading() {
658 let content = "# Heading\n\n code line 1\n\n\n code line 2\n\nMore text\n";
660 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
661 let rule = MD012NoMultipleBlanks::default();
662 let warnings = rule.check(&ctx).unwrap();
663 assert!(
664 warnings.is_empty(),
665 "Should not flag blanks in indented code after heading"
666 );
667 }
668
669 #[test]
670 fn test_blanks_after_indented_code_block_flagged() {
671 let content = "# Heading\n\n code line\n\n\n\nMore text\n";
673 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
674 let rule = MD012NoMultipleBlanks::default();
675 let warnings = rule.check(&ctx).unwrap();
676 assert_eq!(warnings.len(), 2, "Should flag blanks after indented code block ends");
678 }
679
680 #[test]
681 fn test_fix_with_final_newline() {
682 let rule = MD012NoMultipleBlanks::default();
683 let content = "Line 1\n\n\nLine 2\n";
684 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
685 let fixed = rule.fix(&ctx).unwrap();
686 assert_eq!(fixed, "Line 1\n\nLine 2\n");
687 assert!(fixed.ends_with('\n'));
688 }
689
690 #[test]
691 fn test_empty_content() {
692 let rule = MD012NoMultipleBlanks::default();
693 let content = "";
694 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
695 let result = rule.check(&ctx).unwrap();
696 assert!(result.is_empty());
697 }
698
699 #[test]
700 fn test_nested_code_blocks() {
701 let rule = MD012NoMultipleBlanks::default();
702 let content = "Before\n\n~~~\nouter\n\n```\ninner\n\n\n```\n\n~~~\n\nAfter";
703 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
704 let result = rule.check(&ctx).unwrap();
705 assert!(result.is_empty());
706 }
707
708 #[test]
709 fn test_unclosed_code_block() {
710 let rule = MD012NoMultipleBlanks::default();
711 let content = "Before\n\n```\ncode\n\n\n\nno closing fence";
712 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
713 let result = rule.check(&ctx).unwrap();
714 assert!(result.is_empty()); }
716
717 #[test]
718 fn test_mixed_fence_styles() {
719 let rule = MD012NoMultipleBlanks::default();
720 let content = "Before\n\n```\ncode\n\n\n~~~\n\nAfter";
721 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
722 let result = rule.check(&ctx).unwrap();
723 assert!(result.is_empty()); }
725
726 #[test]
727 fn test_config_from_toml() {
728 let mut config = crate::config::Config::default();
729 let mut rule_config = crate::config::RuleConfig::default();
730 rule_config
731 .values
732 .insert("maximum".to_string(), toml::Value::Integer(3));
733 config.rules.insert("MD012".to_string(), rule_config);
734
735 let rule = MD012NoMultipleBlanks::from_config(&config);
736 let content = "Line 1\n\n\n\nLine 2"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
738 let result = rule.check(&ctx).unwrap();
739 assert!(result.is_empty()); }
741
742 #[test]
743 fn test_blank_lines_between_sections() {
744 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 1);
746 let content = "# Section 1\n\nContent\n\n\n# Section 2\n\nContent";
747 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
748 let result = rule.check(&ctx).unwrap();
749 assert!(
750 result.is_empty(),
751 "2 blanks above heading allowed with heading_blanks_above=2"
752 );
753 }
754
755 #[test]
756 fn test_fix_preserves_indented_code() {
757 let rule = MD012NoMultipleBlanks::default();
758 let content = "Text\n\n\n code\n \n more code\n\n\nText";
759 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
760 let fixed = rule.fix(&ctx).unwrap();
761 assert_eq!(fixed, "Text\n\n code\n\n more code\n\nText");
763 }
764
765 #[test]
766 fn test_edge_case_only_blanks() {
767 let rule = MD012NoMultipleBlanks::default();
768 let content = "\n\n\n";
769 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
770 let result = rule.check(&ctx).unwrap();
771 assert_eq!(result.len(), 1);
773 assert!(result[0].message.contains("at end of file"));
774 }
775
776 #[test]
779 fn test_blanks_after_fenced_code_block_mid_document() {
780 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 1);
782 let content = "## Input\n\n```javascript\ncode\n```\n\n\n## Error\n";
783 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
784 let result = rule.check(&ctx).unwrap();
785 assert!(
786 result.is_empty(),
787 "2 blanks before heading allowed with heading_blanks_above=2"
788 );
789 }
790
791 #[test]
792 fn test_blanks_after_code_block_at_eof() {
793 let rule = MD012NoMultipleBlanks::default();
795 let content = "# Heading\n\n```\ncode\n```\n\n\n";
796 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
797 let result = rule.check(&ctx).unwrap();
798 assert_eq!(result.len(), 1, "Should detect trailing blanks after code block");
800 assert!(result[0].message.contains("at end of file"));
801 }
802
803 #[test]
804 fn test_single_blank_after_code_block_allowed() {
805 let rule = MD012NoMultipleBlanks::default();
807 let content = "## Input\n\n```\ncode\n```\n\n## Output\n";
808 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
809 let result = rule.check(&ctx).unwrap();
810 assert!(result.is_empty(), "Single blank after code block should be allowed");
811 }
812
813 #[test]
814 fn test_multiple_code_blocks_with_blanks() {
815 let rule = MD012NoMultipleBlanks::default();
817 let content = "```\ncode1\n```\n\n\n```\ncode2\n```\n\n\nEnd\n";
818 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
819 let result = rule.check(&ctx).unwrap();
820 assert_eq!(result.len(), 2, "Should detect blanks after both code blocks");
822 }
823
824 #[test]
825 fn test_whitespace_only_lines_after_code_block_at_eof() {
826 let rule = MD012NoMultipleBlanks::default();
829 let content = "```\ncode\n```\n \n \n";
830 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
831 let result = rule.check(&ctx).unwrap();
832 assert_eq!(result.len(), 1, "Should detect whitespace-only trailing blanks");
833 assert!(result[0].message.contains("at end of file"));
834 }
835
836 #[test]
839 fn test_warning_fix_removes_single_trailing_blank() {
840 let rule = MD012NoMultipleBlanks::default();
842 let content = "hello foobar hello.\n\n";
843 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
844 let warnings = rule.check(&ctx).unwrap();
845
846 assert_eq!(warnings.len(), 1);
847 assert!(warnings[0].fix.is_some(), "Warning should have a fix attached");
848
849 let fix = warnings[0].fix.as_ref().unwrap();
850 assert_eq!(fix.replacement, "", "Replacement should be empty");
852
853 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
855 assert_eq!(fixed, "hello foobar hello.\n", "Should end with single newline");
856 }
857
858 #[test]
859 fn test_warning_fix_removes_multiple_trailing_blanks() {
860 let rule = MD012NoMultipleBlanks::default();
861 let content = "content\n\n\n\n";
862 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
863 let warnings = rule.check(&ctx).unwrap();
864
865 assert_eq!(warnings.len(), 1);
866 assert!(warnings[0].fix.is_some());
867
868 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
869 assert_eq!(fixed, "content\n", "Should end with single newline");
870 }
871
872 #[test]
873 fn test_warning_fix_preserves_content_newline() {
874 let rule = MD012NoMultipleBlanks::default();
876 let content = "line1\nline2\n\n";
877 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
878 let warnings = rule.check(&ctx).unwrap();
879
880 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
881 assert_eq!(fixed, "line1\nline2\n", "Should preserve all content lines");
882 }
883
884 #[test]
885 fn test_warning_fix_mid_document_blanks() {
886 let rule = MD012NoMultipleBlanks::default();
888 let content = "# Heading\n\n\n\nParagraph\n";
889 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
890 let warnings = rule.check(&ctx).unwrap();
891 assert_eq!(
892 warnings.len(),
893 2,
894 "Excess heading-adjacent blanks flagged with default limits"
895 );
896 }
897
898 #[test]
903 fn test_heading_aware_blanks_below_with_higher_limit() {
904 let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 2);
906 let content = "# Heading\n\n\nParagraph\n";
907 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
908 let result = rule.check(&ctx).unwrap();
909 assert!(
910 result.is_empty(),
911 "2 blanks below heading allowed with heading_blanks_below=2"
912 );
913 }
914
915 #[test]
916 fn test_heading_aware_blanks_above_with_higher_limit() {
917 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 1);
919 let content = "Paragraph\n\n\n# Heading\n";
920 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
921 let result = rule.check(&ctx).unwrap();
922 assert!(
923 result.is_empty(),
924 "2 blanks above heading allowed with heading_blanks_above=2"
925 );
926 }
927
928 #[test]
929 fn test_heading_aware_blanks_between_headings() {
930 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 2);
932 let content = "# Heading 1\n\n\n## Heading 2\n";
933 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
934 let result = rule.check(&ctx).unwrap();
935 assert!(result.is_empty(), "2 blanks between headings allowed with limits=2");
936 }
937
938 #[test]
939 fn test_heading_aware_excess_still_flagged() {
940 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 2);
942 let content = "# Heading\n\n\n\n\nParagraph\n"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
944 let result = rule.check(&ctx).unwrap();
945 assert_eq!(result.len(), 2, "Excess beyond heading limit should be flagged");
946 }
947
948 #[test]
949 fn test_heading_aware_setext_blanks_below() {
950 let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 2);
952 let content = "Heading\n=======\n\n\nParagraph\n";
953 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
954 let result = rule.check(&ctx).unwrap();
955 assert!(result.is_empty(), "2 blanks below Setext heading allowed with limit=2");
956 }
957
958 #[test]
959 fn test_heading_aware_setext_blanks_above() {
960 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 1);
962 let content = "Paragraph\n\n\nHeading\n=======\n";
963 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
964 let result = rule.check(&ctx).unwrap();
965 assert!(result.is_empty(), "2 blanks above Setext heading allowed with limit=2");
966 }
967
968 #[test]
969 fn test_heading_aware_single_blank_allowed() {
970 let rule = MD012NoMultipleBlanks::default();
972 let content = "# Heading\n\nParagraph\n";
973 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
974 let result = rule.check(&ctx).unwrap();
975 assert!(result.is_empty(), "Single blank near heading should be allowed");
976 }
977
978 #[test]
979 fn test_heading_aware_non_heading_blanks_still_flagged() {
980 let rule = MD012NoMultipleBlanks::default();
982 let content = "Paragraph 1\n\n\nParagraph 2\n";
983 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
984 let result = rule.check(&ctx).unwrap();
985 assert_eq!(result.len(), 1, "Non-heading blanks should still be flagged");
986 }
987
988 #[test]
989 fn test_heading_aware_fix_caps_heading_blanks() {
990 let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 2);
992 let content = "# Heading\n\n\n\nParagraph\n"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
994 let fixed = rule.fix(&ctx).unwrap();
995 assert_eq!(
996 fixed, "# Heading\n\n\nParagraph\n",
997 "Fix caps heading-adjacent blanks at effective max (2)"
998 );
999 }
1000
1001 #[test]
1002 fn test_heading_aware_fix_preserves_allowed_heading_blanks() {
1003 let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 3);
1005 let content = "# Heading\n\n\n\nParagraph\n"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1007 let fixed = rule.fix(&ctx).unwrap();
1008 assert_eq!(
1009 fixed, "# Heading\n\n\n\nParagraph\n",
1010 "Fix preserves blanks within the heading limit"
1011 );
1012 }
1013
1014 #[test]
1015 fn test_heading_aware_fix_reduces_non_heading_blanks() {
1016 let rule = MD012NoMultipleBlanks::default();
1018 let content = "Paragraph 1\n\n\n\nParagraph 2\n";
1019 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1020 let fixed = rule.fix(&ctx).unwrap();
1021 assert_eq!(
1022 fixed, "Paragraph 1\n\nParagraph 2\n",
1023 "Fix should reduce non-heading blanks"
1024 );
1025 }
1026
1027 #[test]
1028 fn test_heading_aware_mixed_heading_and_non_heading() {
1029 let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 2);
1031 let content = "# Heading\n\n\nParagraph 1\n\n\nParagraph 2\n";
1032 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1033 let result = rule.check(&ctx).unwrap();
1034 assert_eq!(result.len(), 1, "Only non-heading excess should be flagged");
1036 }
1037
1038 #[test]
1039 fn test_heading_aware_blanks_at_start_before_heading_still_flagged() {
1040 let rule = MD012NoMultipleBlanks::default().with_heading_limits(3, 3);
1043 let content = "\n\n\n# Heading\n";
1044 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1045 let result = rule.check(&ctx).unwrap();
1046 assert_eq!(
1047 result.len(),
1048 2,
1049 "Start-of-file blanks should be flagged even before heading"
1050 );
1051 assert!(result[0].message.contains("at start of file"));
1052 }
1053
1054 #[test]
1055 fn test_heading_aware_eof_blanks_after_heading_still_flagged() {
1056 let rule = MD012NoMultipleBlanks::default();
1058 let content = "# Heading\n\n";
1059 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1060 let result = rule.check(&ctx).unwrap();
1061 assert_eq!(result.len(), 1, "EOF blanks should still be flagged");
1062 assert!(result[0].message.contains("at end of file"));
1063 }
1064
1065 #[test]
1066 fn test_heading_aware_unlimited_heading_blanks() {
1067 let rule = MD012NoMultipleBlanks::default().with_heading_limits(usize::MAX, usize::MAX);
1069 let content = "# Heading\n\n\n\n\nParagraph\n"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1071 let result = rule.check(&ctx).unwrap();
1072 assert!(
1073 result.is_empty(),
1074 "Unlimited heading limits means MD012 never flags near headings"
1075 );
1076 }
1077
1078 #[test]
1079 fn test_heading_aware_blanks_after_code_then_heading() {
1080 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 2);
1082 let content = "# Heading\n\n```\ncode\n```\n\n\n\nMore text\n";
1083 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1084 let result = rule.check(&ctx).unwrap();
1085 assert_eq!(result.len(), 2, "Non-heading blanks after code block should be flagged");
1087 }
1088
1089 #[test]
1090 fn test_heading_aware_fix_mixed_document() {
1091 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 2);
1093 let content = "# Title\n\n\n## Section\n\n\nPara 1\n\n\nPara 2\n";
1094 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1095 let fixed = rule.fix(&ctx).unwrap();
1096 assert_eq!(fixed, "# Title\n\n\n## Section\n\n\nPara 1\n\nPara 2\n");
1098 }
1099
1100 #[test]
1101 fn test_heading_aware_from_config_reads_md022() {
1102 let mut config = crate::config::Config::default();
1104 let mut md022_config = crate::config::RuleConfig::default();
1105 md022_config
1106 .values
1107 .insert("lines-above".to_string(), toml::Value::Integer(2));
1108 md022_config
1109 .values
1110 .insert("lines-below".to_string(), toml::Value::Integer(3));
1111 config.rules.insert("MD022".to_string(), md022_config);
1112
1113 let rule = MD012NoMultipleBlanks::from_config(&config);
1114 let content = "Paragraph\n\n\n# Heading\n";
1116 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1117 let result = rule.check(&ctx).unwrap();
1118 assert!(
1119 result.is_empty(),
1120 "2 blanks above heading allowed when MD022 lines-above=2"
1121 );
1122 }
1123
1124 #[test]
1125 fn test_heading_aware_from_config_md022_disabled() {
1126 let mut config = crate::config::Config::default();
1128 config.global.disable.push("MD022".to_string());
1129
1130 let mut md022_config = crate::config::RuleConfig::default();
1131 md022_config
1132 .values
1133 .insert("lines-above".to_string(), toml::Value::Integer(3));
1134 config.rules.insert("MD022".to_string(), md022_config);
1135
1136 let rule = MD012NoMultipleBlanks::from_config(&config);
1137 let content = "Paragraph\n\n\n# Heading\n";
1139 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1140 let result = rule.check(&ctx).unwrap();
1141 assert_eq!(
1142 result.len(),
1143 1,
1144 "With MD022 disabled, heading-adjacent blanks are flagged"
1145 );
1146 }
1147
1148 #[test]
1149 fn test_heading_aware_from_config_md022_unlimited() {
1150 let mut config = crate::config::Config::default();
1152 let mut md022_config = crate::config::RuleConfig::default();
1153 md022_config
1154 .values
1155 .insert("lines-above".to_string(), toml::Value::Integer(-1));
1156 config.rules.insert("MD022".to_string(), md022_config);
1157
1158 let rule = MD012NoMultipleBlanks::from_config(&config);
1159 let content = "Paragraph\n\n\n\n\n# Heading\n"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1161 let result = rule.check(&ctx).unwrap();
1162 assert!(
1163 result.is_empty(),
1164 "Unlimited MD022 lines-above means MD012 never flags above headings"
1165 );
1166 }
1167
1168 #[test]
1169 fn test_heading_aware_from_config_per_level() {
1170 let mut config = crate::config::Config::default();
1175 let mut md022_config = crate::config::RuleConfig::default();
1176 md022_config.values.insert(
1177 "lines-above".to_string(),
1178 toml::Value::Array(vec![
1179 toml::Value::Integer(2),
1180 toml::Value::Integer(1),
1181 toml::Value::Integer(1),
1182 toml::Value::Integer(1),
1183 toml::Value::Integer(1),
1184 toml::Value::Integer(1),
1185 ]),
1186 );
1187 config.rules.insert("MD022".to_string(), md022_config);
1188
1189 let rule = MD012NoMultipleBlanks::from_config(&config);
1190
1191 let content = "Paragraph\n\n\n## H2 Heading\n";
1193 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1194 let result = rule.check(&ctx).unwrap();
1195 assert!(result.is_empty(), "Per-level max (2) allows 2 blanks above any heading");
1196
1197 let content = "Paragraph\n\n\n\n## H2 Heading\n";
1199 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1200 let result = rule.check(&ctx).unwrap();
1201 assert_eq!(result.len(), 1, "3 blanks exceeds per-level max of 2");
1202 }
1203
1204 #[test]
1205 fn test_issue_449_reproduction() {
1206 let rule = MD012NoMultipleBlanks::default();
1209 let content = "\
1210# Heading
1211
1212
1213Some introductory text.
1214
1215
1216
1217
1218
1219## Heading level 2
1220
1221
1222Some text for this section.
1223
1224Some more text for this section.
1225
1226
1227## Another heading level 2
1228
1229
1230
1231Some text for this section.
1232
1233Some more text for this section.
1234";
1235 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1236 let result = rule.check(&ctx).unwrap();
1237 assert!(
1238 !result.is_empty(),
1239 "Issue #449: excess blanks around headings should be flagged with default settings"
1240 );
1241
1242 let fixed = rule.fix(&ctx).unwrap();
1244 let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1245 let recheck = rule.check(&fixed_ctx).unwrap();
1246 assert!(recheck.is_empty(), "Fix should resolve all excess blank lines");
1247
1248 assert!(fixed.contains("# Heading\n\nSome"), "1 blank below first heading");
1250 assert!(
1251 fixed.contains("text.\n\n## Heading level 2"),
1252 "1 blank above second heading"
1253 );
1254 }
1255
1256 #[test]
1259 fn test_blank_lines_in_quarto_callout() {
1260 let rule = MD012NoMultipleBlanks::default();
1262 let content = "# Heading\n\n::: {.callout-note}\nNote content\n\n\nMore content\n:::\n\nAfter";
1263 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1264 let result = rule.check(&ctx).unwrap();
1265 assert!(result.is_empty(), "Should not flag blanks inside Quarto callouts");
1266 }
1267
1268 #[test]
1269 fn test_blank_lines_in_quarto_div() {
1270 let rule = MD012NoMultipleBlanks::default();
1272 let content = "Text\n\n::: {.bordered}\nContent\n\n\nMore\n:::\n\nText";
1273 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1274 let result = rule.check(&ctx).unwrap();
1275 assert!(result.is_empty(), "Should not flag blanks inside Quarto divs");
1276 }
1277
1278 #[test]
1279 fn test_blank_lines_outside_quarto_div_flagged() {
1280 let rule = MD012NoMultipleBlanks::default();
1282 let content = "Text\n\n\n::: {.callout-note}\nNote\n:::\n\n\nMore";
1283 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1284 let result = rule.check(&ctx).unwrap();
1285 assert!(!result.is_empty(), "Should flag blanks outside Quarto divs");
1286 }
1287
1288 #[test]
1289 fn test_quarto_divs_ignored_in_standard_flavor() {
1290 let rule = MD012NoMultipleBlanks::default();
1292 let content = "::: {.callout-note}\nNote content\n\n\nMore content\n:::\n";
1293 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1294 let result = rule.check(&ctx).unwrap();
1295 assert!(!result.is_empty(), "Standard flavor should flag blanks in 'div'");
1297 }
1298}