1use crate::filtered_lines::FilteredLinesExt;
2use crate::lint_context::LintContext;
3use crate::lint_context::types::HeadingStyle;
4use crate::utils::range_utils::calculate_line_range;
5use std::collections::HashSet;
6
7use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
8
9mod md012_config;
10use md012_config::MD012Config;
11
12#[derive(Debug, Clone)]
17pub struct MD012NoMultipleBlanks {
18 config: MD012Config,
19 heading_blanks_above: usize,
22 heading_blanks_below: usize,
25}
26
27impl Default for MD012NoMultipleBlanks {
28 fn default() -> Self {
29 Self {
30 config: MD012Config::default(),
31 heading_blanks_above: 1,
32 heading_blanks_below: 1,
33 }
34 }
35}
36
37impl MD012NoMultipleBlanks {
38 pub fn new(maximum: usize) -> Self {
39 use crate::types::PositiveUsize;
40 Self {
41 config: MD012Config {
42 maximum: PositiveUsize::new(maximum).unwrap_or(PositiveUsize::from_const(1)),
43 },
44 heading_blanks_above: 1,
45 heading_blanks_below: 1,
46 }
47 }
48
49 pub const fn from_config_struct(config: MD012Config) -> Self {
50 Self {
51 config,
52 heading_blanks_above: 1,
53 heading_blanks_below: 1,
54 }
55 }
56
57 pub fn with_heading_limits(mut self, above: usize, below: usize) -> Self {
60 self.heading_blanks_above = above;
61 self.heading_blanks_below = below;
62 self
63 }
64
65 fn effective_max_above(&self) -> usize {
69 self.config.maximum.get().max(self.heading_blanks_above)
70 }
71
72 fn effective_max_below(&self) -> usize {
73 self.config.maximum.get().max(self.heading_blanks_below)
74 }
75
76 fn generate_excess_warnings(
78 &self,
79 blank_start: usize,
80 blank_count: usize,
81 effective_max: usize,
82 lines: &[&str],
83 lines_to_check: &HashSet<usize>,
84 ctx: &LintContext,
85 ) -> Vec<LintWarning> {
86 let mut warnings = Vec::new();
87
88 let location = if blank_start == 0 {
89 "at start of file"
90 } else {
91 "between content"
92 };
93
94 for i in effective_max..blank_count {
95 let excess_line_num = blank_start + i;
96 if lines_to_check.contains(&excess_line_num) {
97 let excess_line = excess_line_num + 1;
98 let excess_line_content = lines.get(excess_line_num).unwrap_or(&"");
99 let (start_line, start_col, end_line, end_col) = calculate_line_range(excess_line, excess_line_content);
100 warnings.push(LintWarning {
101 rule_name: Some(self.name().to_string()),
102 severity: Severity::Warning,
103 message: format!("Multiple consecutive blank lines {location}"),
104 line: start_line,
105 column: start_col,
106 end_line,
107 end_column: end_col,
108 fix: Some(Fix::new(
109 {
110 let line_start = ctx.line_start_byte(excess_line).unwrap_or(0);
111 let line_end = ctx.line_start_byte(excess_line + 1).unwrap_or(line_start + 1);
112 line_start..line_end
113 },
114 String::new(),
115 )),
116 });
117 }
118 }
119
120 warnings
121 }
122}
123
124fn is_heading_context(ctx: &LintContext, line_idx: usize) -> bool {
130 if ctx.lines.get(line_idx).is_some_and(|li| li.heading.is_some()) {
131 return true;
132 }
133 if line_idx > 0
135 && let Some(prev_info) = ctx.lines.get(line_idx - 1)
136 && let Some(ref heading) = prev_info.heading
137 && matches!(heading.style, HeadingStyle::Setext1 | HeadingStyle::Setext2)
138 {
139 return true;
140 }
141 false
142}
143
144fn max_heading_limit(
148 level_config: &crate::rules::md022_blanks_around_headings::md022_config::HeadingLevelConfig,
149) -> usize {
150 let mut max_val: usize = 0;
151 for level in 1..=6 {
152 match level_config.get_for_level(level).required_count() {
153 None => return usize::MAX, Some(count) => max_val = max_val.max(count),
155 }
156 }
157 max_val
158}
159
160impl Rule for MD012NoMultipleBlanks {
161 fn name(&self) -> &'static str {
162 "MD012"
163 }
164
165 fn description(&self) -> &'static str {
166 "Multiple consecutive blank lines"
167 }
168
169 fn category(&self) -> RuleCategory {
170 RuleCategory::Whitespace
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");
192
193 if !has_potential_blanks && !ends_with_multiple_newlines {
194 return Ok(Vec::new());
195 }
196
197 let mut warnings = Vec::new();
198
199 let mut blank_count = 0;
201 let mut blank_start = 0;
202 let mut last_line_num: Option<usize> = None;
203 let mut prev_content_line_num: Option<usize> = None;
205
206 let mut lines_to_check: HashSet<usize> = HashSet::new();
208
209 for filtered_line in ctx
216 .filtered_lines()
217 .skip_front_matter()
218 .skip_code_blocks()
219 .skip_html_comments()
220 .skip_html_blocks()
221 .skip_quarto_divs()
222 .skip_math_blocks()
223 .skip_obsidian_comments()
224 .skip_pymdown_blocks()
225 .skip_jsx_expressions()
226 .skip_mdx_comments()
227 .skip_jsx_blocks()
228 .skip_esm_blocks()
229 {
230 let line_num = filtered_line.line_num - 1; let line = filtered_line.content;
232
233 if let Some(last) = last_line_num
236 && line_num > last + 1
237 {
238 let effective_max = if prev_content_line_num.is_some_and(|idx| is_heading_context(ctx, idx)) {
241 self.effective_max_below()
242 } else {
243 self.config.maximum.get()
244 };
245 if blank_count > effective_max {
246 warnings.extend(self.generate_excess_warnings(
247 blank_start,
248 blank_count,
249 effective_max,
250 lines,
251 &lines_to_check,
252 ctx,
253 ));
254 }
255 blank_count = 0;
256 lines_to_check.clear();
257 prev_content_line_num = None;
259 }
260 last_line_num = Some(line_num);
261
262 if line.trim().is_empty() {
263 if blank_count == 0 {
264 blank_start = line_num;
265 }
266 blank_count += 1;
267 if blank_count > self.config.maximum.get() {
269 lines_to_check.insert(line_num);
270 }
271 } else {
272 let heading_below = prev_content_line_num.is_some_and(|idx| is_heading_context(ctx, idx));
278 let heading_above = blank_start > 0 && is_heading_context(ctx, line_num);
279 let effective_max = if heading_below && heading_above {
280 self.effective_max_above().max(self.effective_max_below())
282 } else if heading_below {
283 self.effective_max_below()
284 } else if heading_above {
285 self.effective_max_above()
286 } else {
287 self.config.maximum.get()
288 };
289
290 if blank_count > effective_max {
291 warnings.extend(self.generate_excess_warnings(
292 blank_start,
293 blank_count,
294 effective_max,
295 lines,
296 &lines_to_check,
297 ctx,
298 ));
299 }
300 blank_count = 0;
301 lines_to_check.clear();
302 prev_content_line_num = Some(line_num);
303 }
304 }
305
306 let last_line_is_blank = lines.last().is_some_and(|l| l.trim().is_empty());
313
314 if blank_count > 0 && !last_line_is_blank {
317 let effective_max = if prev_content_line_num.is_some_and(|idx| is_heading_context(ctx, idx)) {
318 self.effective_max_below()
319 } else {
320 self.config.maximum.get()
321 };
322 if blank_count > effective_max {
323 warnings.extend(self.generate_excess_warnings(
324 blank_start,
325 blank_count,
326 effective_max,
327 lines,
328 &lines_to_check,
329 ctx,
330 ));
331 }
332 }
333
334 if blank_count > 0 && last_line_is_blank {
338 let location = "at end of file";
339
340 let report_line = lines.len();
342
343 let fix_start = ctx.line_start_byte(report_line - blank_count + 1).unwrap_or(0);
346 let fix_end = content.len();
347
348 warnings.push(LintWarning {
350 rule_name: Some(self.name().to_string()),
351 severity: Severity::Warning,
352 message: format!("Multiple consecutive blank lines {location}"),
353 line: report_line,
354 column: 1,
355 end_line: report_line,
356 end_column: 1,
357 fix: Some(Fix::new(fix_start..fix_end, String::new())),
358 });
359 }
360
361 Ok(warnings)
362 }
363
364 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
365 if self.should_skip(ctx) {
366 return Ok(ctx.content.to_string());
367 }
368 let warnings = self.check(ctx)?;
369 if warnings.is_empty() {
370 return Ok(ctx.content.to_string());
371 }
372 let warnings =
373 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
374 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
375 .map_err(crate::rule::LintError::InvalidInput)
376 }
377
378 fn as_any(&self) -> &dyn std::any::Any {
379 self
380 }
381
382 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
383 ctx.content.is_empty() || !ctx.has_char('\n')
385 }
386
387 crate::impl_rule_config_sections!(MD012Config);
388
389 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
390 where
391 Self: Sized,
392 {
393 use crate::rules::md022_blanks_around_headings::md022_config::MD022Config;
394
395 let rule_config = crate::rule_config_serde::load_rule_config::<MD012Config>(config);
396
397 let md022_disabled = config.global.disable.iter().any(|r| r == "MD022")
400 || config.global.extend_disable.iter().any(|r| r == "MD022");
401
402 let (heading_above, heading_below) = if md022_disabled {
403 (rule_config.maximum.get(), rule_config.maximum.get())
405 } else {
406 let md022_config = crate::rule_config_serde::load_rule_config::<MD022Config>(config);
407 (
408 max_heading_limit(&md022_config.lines_above),
409 max_heading_limit(&md022_config.lines_below),
410 )
411 };
412
413 Box::new(Self {
414 config: rule_config,
415 heading_blanks_above: heading_above,
416 heading_blanks_below: heading_below,
417 })
418 }
419}
420
421#[cfg(test)]
422mod tests {
423 use super::*;
424 use crate::lint_context::LintContext;
425
426 #[test]
427 fn test_single_blank_line_allowed() {
428 let rule = MD012NoMultipleBlanks::default();
429 let content = "Line 1\n\nLine 2\n\nLine 3";
430 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
431 let result = rule.check(&ctx).unwrap();
432 assert!(result.is_empty());
433 }
434
435 #[test]
436 fn test_multiple_blank_lines_flagged() {
437 let rule = MD012NoMultipleBlanks::default();
438 let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
439 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
440 let result = rule.check(&ctx).unwrap();
441 assert_eq!(result.len(), 3); assert_eq!(result[0].line, 3);
443 assert_eq!(result[1].line, 6);
444 assert_eq!(result[2].line, 7);
445 }
446
447 #[test]
448 fn test_custom_maximum() {
449 let rule = MD012NoMultipleBlanks::new(2);
450 let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
451 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
452 let result = rule.check(&ctx).unwrap();
453 assert_eq!(result.len(), 1); assert_eq!(result[0].line, 7);
455 }
456
457 #[test]
458 fn test_fix_multiple_blank_lines() {
459 let rule = MD012NoMultipleBlanks::default();
460 let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
461 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
462 let fixed = rule.fix(&ctx).unwrap();
463 assert_eq!(fixed, "Line 1\n\nLine 2\n\nLine 3");
464 }
465
466 #[test]
467 fn test_blank_lines_in_code_block() {
468 let rule = MD012NoMultipleBlanks::default();
469 let content = "Before\n\n```\ncode\n\n\n\nmore code\n```\n\nAfter";
470 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
471 let result = rule.check(&ctx).unwrap();
472 assert!(result.is_empty()); }
474
475 #[test]
476 fn test_blank_lines_in_html_comment() {
477 let rule = MD012NoMultipleBlanks::default();
478 let content = "Before\n\n<!--\ncomment\n\n\n\nmore comment\n-->\n\nAfter";
479 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
480 let result = rule.check(&ctx).unwrap();
481 assert!(result.is_empty()); }
483
484 #[test]
485 fn test_blank_lines_in_html_block() {
486 let rule = MD012NoMultipleBlanks::default();
487 let content = "Before\n\n<script>\n\n\n\n</script>\n\nAfter";
488 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
489 let result = rule.check(&ctx).unwrap();
490 assert!(result.is_empty()); }
492
493 #[test]
494 fn test_fix_preserves_code_block_blanks() {
495 let rule = MD012NoMultipleBlanks::default();
496 let content = "Before\n\n\n```\ncode\n\n\n\nmore code\n```\n\n\nAfter";
497 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
498 let fixed = rule.fix(&ctx).unwrap();
499 assert_eq!(fixed, "Before\n\n```\ncode\n\n\n\nmore code\n```\n\nAfter");
500 }
501
502 #[test]
503 fn test_blank_lines_in_front_matter() {
504 let rule = MD012NoMultipleBlanks::default();
505 let content = "---\ntitle: Test\n\n\nauthor: Me\n---\n\nContent";
506 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
507 let result = rule.check(&ctx).unwrap();
508 assert!(result.is_empty()); }
510
511 #[test]
512 fn test_blank_lines_at_start() {
513 let rule = MD012NoMultipleBlanks::default();
514 let content = "\n\n\nContent";
515 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
516 let result = rule.check(&ctx).unwrap();
517 assert_eq!(result.len(), 2);
518 assert!(result[0].message.contains("at start of file"));
519 }
520
521 #[test]
522 fn test_blank_lines_at_end() {
523 let rule = MD012NoMultipleBlanks::default();
524 let content = "Content\n\n\n";
525 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
526 let result = rule.check(&ctx).unwrap();
527 assert_eq!(result.len(), 1);
528 assert!(result[0].message.contains("at end of file"));
529 }
530
531 #[test]
532 fn test_single_blank_at_eof_flagged() {
533 let rule = MD012NoMultipleBlanks::default();
535 let content = "Content\n\n";
536 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
537 let result = rule.check(&ctx).unwrap();
538 assert_eq!(result.len(), 1);
539 assert!(result[0].message.contains("at end of file"));
540 }
541
542 #[test]
543 fn test_whitespace_only_lines() {
544 let rule = MD012NoMultipleBlanks::default();
545 let content = "Line 1\n \n\t\nLine 2";
546 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
547 let result = rule.check(&ctx).unwrap();
548 assert_eq!(result.len(), 1); }
550
551 #[test]
552 fn test_indented_code_blocks() {
553 let rule = MD012NoMultipleBlanks::default();
555 let content = "Text\n\n code\n \n \n more code\n\nText";
556 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
557 let result = rule.check(&ctx).unwrap();
558 assert!(result.is_empty(), "Should not flag blanks inside indented code blocks");
559 }
560
561 #[test]
562 fn test_blanks_in_indented_code_block() {
563 let content = " code line 1\n\n\n code line 2\n";
565 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
566 let rule = MD012NoMultipleBlanks::default();
567 let warnings = rule.check(&ctx).unwrap();
568 assert!(warnings.is_empty(), "Should not flag blanks in indented code");
569 }
570
571 #[test]
572 fn test_blanks_in_indented_code_block_with_heading() {
573 let content = "# Heading\n\n code line 1\n\n\n code line 2\n\nMore text\n";
575 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
576 let rule = MD012NoMultipleBlanks::default();
577 let warnings = rule.check(&ctx).unwrap();
578 assert!(
579 warnings.is_empty(),
580 "Should not flag blanks in indented code after heading"
581 );
582 }
583
584 #[test]
585 fn test_blanks_after_indented_code_block_flagged() {
586 let content = "# Heading\n\n code line\n\n\n\nMore text\n";
588 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
589 let rule = MD012NoMultipleBlanks::default();
590 let warnings = rule.check(&ctx).unwrap();
591 assert_eq!(warnings.len(), 2, "Should flag blanks after indented code block ends");
593 }
594
595 #[test]
596 fn test_fix_with_final_newline() {
597 let rule = MD012NoMultipleBlanks::default();
598 let content = "Line 1\n\n\nLine 2\n";
599 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
600 let fixed = rule.fix(&ctx).unwrap();
601 assert_eq!(fixed, "Line 1\n\nLine 2\n");
602 assert!(fixed.ends_with('\n'));
603 }
604
605 #[test]
606 fn test_empty_content() {
607 let rule = MD012NoMultipleBlanks::default();
608 let content = "";
609 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
610 let result = rule.check(&ctx).unwrap();
611 assert!(result.is_empty());
612 }
613
614 #[test]
615 fn test_nested_code_blocks() {
616 let rule = MD012NoMultipleBlanks::default();
617 let content = "Before\n\n~~~\nouter\n\n```\ninner\n\n\n```\n\n~~~\n\nAfter";
618 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
619 let result = rule.check(&ctx).unwrap();
620 assert!(result.is_empty());
621 }
622
623 #[test]
624 fn test_unclosed_code_block() {
625 let rule = MD012NoMultipleBlanks::default();
626 let content = "Before\n\n```\ncode\n\n\n\nno closing fence";
627 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
628 let result = rule.check(&ctx).unwrap();
629 assert!(result.is_empty()); }
631
632 #[test]
633 fn test_mixed_fence_styles() {
634 let rule = MD012NoMultipleBlanks::default();
635 let content = "Before\n\n```\ncode\n\n\n~~~\n\nAfter";
636 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
637 let result = rule.check(&ctx).unwrap();
638 assert!(result.is_empty()); }
640
641 #[test]
642 fn test_config_from_toml() {
643 let mut config = crate::config::Config::default();
644 let mut rule_config = crate::config::RuleConfig::default();
645 rule_config
646 .values
647 .insert("maximum".to_string(), toml::Value::Integer(3));
648 config.rules.insert("MD012".to_string(), rule_config);
649
650 let rule = MD012NoMultipleBlanks::from_config(&config);
651 let content = "Line 1\n\n\n\nLine 2"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
653 let result = rule.check(&ctx).unwrap();
654 assert!(result.is_empty()); }
656
657 #[test]
658 fn test_blank_lines_between_sections() {
659 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 1);
661 let content = "# Section 1\n\nContent\n\n\n# Section 2\n\nContent";
662 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
663 let result = rule.check(&ctx).unwrap();
664 assert!(
665 result.is_empty(),
666 "2 blanks above heading allowed with heading_blanks_above=2"
667 );
668 }
669
670 #[test]
671 fn test_fix_preserves_indented_code() {
672 let rule = MD012NoMultipleBlanks::default();
673 let content = "Text\n\n\n code\n \n more code\n\n\nText";
674 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
675 let fixed = rule.fix(&ctx).unwrap();
676 assert_eq!(fixed, "Text\n\n code\n \n more code\n\nText");
679 }
680
681 #[test]
682 fn test_edge_case_only_blanks() {
683 let rule = MD012NoMultipleBlanks::default();
684 let content = "\n\n\n";
685 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
686 let result = rule.check(&ctx).unwrap();
687 assert_eq!(result.len(), 1);
689 assert!(result[0].message.contains("at end of file"));
690 }
691
692 #[test]
695 fn test_blanks_after_fenced_code_block_mid_document() {
696 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 1);
698 let content = "## Input\n\n```javascript\ncode\n```\n\n\n## Error\n";
699 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
700 let result = rule.check(&ctx).unwrap();
701 assert!(
702 result.is_empty(),
703 "2 blanks before heading allowed with heading_blanks_above=2"
704 );
705 }
706
707 #[test]
708 fn test_blanks_after_code_block_at_eof() {
709 let rule = MD012NoMultipleBlanks::default();
711 let content = "# Heading\n\n```\ncode\n```\n\n\n";
712 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
713 let result = rule.check(&ctx).unwrap();
714 assert_eq!(result.len(), 1, "Should detect trailing blanks after code block");
716 assert!(result[0].message.contains("at end of file"));
717 }
718
719 #[test]
720 fn test_single_blank_after_code_block_allowed() {
721 let rule = MD012NoMultipleBlanks::default();
723 let content = "## Input\n\n```\ncode\n```\n\n## Output\n";
724 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
725 let result = rule.check(&ctx).unwrap();
726 assert!(result.is_empty(), "Single blank after code block should be allowed");
727 }
728
729 #[test]
730 fn test_multiple_code_blocks_with_blanks() {
731 let rule = MD012NoMultipleBlanks::default();
733 let content = "```\ncode1\n```\n\n\n```\ncode2\n```\n\n\nEnd\n";
734 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
735 let result = rule.check(&ctx).unwrap();
736 assert_eq!(result.len(), 2, "Should detect blanks after both code blocks");
738 }
739
740 #[test]
741 fn test_whitespace_only_lines_after_code_block_at_eof() {
742 let rule = MD012NoMultipleBlanks::default();
745 let content = "```\ncode\n```\n \n \n";
746 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
747 let result = rule.check(&ctx).unwrap();
748 assert_eq!(result.len(), 1, "Should detect whitespace-only trailing blanks");
749 assert!(result[0].message.contains("at end of file"));
750 }
751
752 #[test]
755 fn test_warning_fix_removes_single_trailing_blank() {
756 let rule = MD012NoMultipleBlanks::default();
758 let content = "hello foobar hello.\n\n";
759 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
760 let warnings = rule.check(&ctx).unwrap();
761
762 assert_eq!(warnings.len(), 1);
763 assert!(warnings[0].fix.is_some(), "Warning should have a fix attached");
764
765 let fix = warnings[0].fix.as_ref().unwrap();
766 assert_eq!(fix.replacement, "", "Replacement should be empty");
768
769 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
771 assert_eq!(fixed, "hello foobar hello.\n", "Should end with single newline");
772 }
773
774 #[test]
775 fn test_warning_fix_removes_multiple_trailing_blanks() {
776 let rule = MD012NoMultipleBlanks::default();
777 let content = "content\n\n\n\n";
778 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
779 let warnings = rule.check(&ctx).unwrap();
780
781 assert_eq!(warnings.len(), 1);
782 assert!(warnings[0].fix.is_some());
783
784 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
785 assert_eq!(fixed, "content\n", "Should end with single newline");
786 }
787
788 #[test]
789 fn test_warning_fix_preserves_content_newline() {
790 let rule = MD012NoMultipleBlanks::default();
792 let content = "line1\nline2\n\n";
793 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
794 let warnings = rule.check(&ctx).unwrap();
795
796 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
797 assert_eq!(fixed, "line1\nline2\n", "Should preserve all content lines");
798 }
799
800 #[test]
801 fn test_warning_fix_mid_document_blanks() {
802 let rule = MD012NoMultipleBlanks::default();
804 let content = "# Heading\n\n\n\nParagraph\n";
805 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
806 let warnings = rule.check(&ctx).unwrap();
807 assert_eq!(
808 warnings.len(),
809 2,
810 "Excess heading-adjacent blanks flagged with default limits"
811 );
812 }
813
814 #[test]
819 fn test_heading_aware_blanks_below_with_higher_limit() {
820 let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 2);
822 let content = "# Heading\n\n\nParagraph\n";
823 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
824 let result = rule.check(&ctx).unwrap();
825 assert!(
826 result.is_empty(),
827 "2 blanks below heading allowed with heading_blanks_below=2"
828 );
829 }
830
831 #[test]
832 fn test_heading_aware_blanks_above_with_higher_limit() {
833 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 1);
835 let content = "Paragraph\n\n\n# Heading\n";
836 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
837 let result = rule.check(&ctx).unwrap();
838 assert!(
839 result.is_empty(),
840 "2 blanks above heading allowed with heading_blanks_above=2"
841 );
842 }
843
844 #[test]
845 fn test_heading_aware_blanks_between_headings() {
846 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 2);
848 let content = "# Heading 1\n\n\n## Heading 2\n";
849 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
850 let result = rule.check(&ctx).unwrap();
851 assert!(result.is_empty(), "2 blanks between headings allowed with limits=2");
852 }
853
854 #[test]
855 fn test_heading_aware_excess_still_flagged() {
856 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 2);
858 let content = "# Heading\n\n\n\n\nParagraph\n"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
860 let result = rule.check(&ctx).unwrap();
861 assert_eq!(result.len(), 2, "Excess beyond heading limit should be flagged");
862 }
863
864 #[test]
865 fn test_heading_aware_setext_blanks_below() {
866 let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 2);
868 let content = "Heading\n=======\n\n\nParagraph\n";
869 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
870 let result = rule.check(&ctx).unwrap();
871 assert!(result.is_empty(), "2 blanks below Setext heading allowed with limit=2");
872 }
873
874 #[test]
875 fn test_heading_aware_setext_blanks_above() {
876 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 1);
878 let content = "Paragraph\n\n\nHeading\n=======\n";
879 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
880 let result = rule.check(&ctx).unwrap();
881 assert!(result.is_empty(), "2 blanks above Setext heading allowed with limit=2");
882 }
883
884 #[test]
885 fn test_heading_aware_single_blank_allowed() {
886 let rule = MD012NoMultipleBlanks::default();
888 let content = "# Heading\n\nParagraph\n";
889 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
890 let result = rule.check(&ctx).unwrap();
891 assert!(result.is_empty(), "Single blank near heading should be allowed");
892 }
893
894 #[test]
895 fn test_heading_aware_non_heading_blanks_still_flagged() {
896 let rule = MD012NoMultipleBlanks::default();
898 let content = "Paragraph 1\n\n\nParagraph 2\n";
899 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
900 let result = rule.check(&ctx).unwrap();
901 assert_eq!(result.len(), 1, "Non-heading blanks should still be flagged");
902 }
903
904 #[test]
905 fn test_heading_aware_fix_caps_heading_blanks() {
906 let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 2);
908 let content = "# Heading\n\n\n\nParagraph\n"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
910 let fixed = rule.fix(&ctx).unwrap();
911 assert_eq!(
912 fixed, "# Heading\n\n\nParagraph\n",
913 "Fix caps heading-adjacent blanks at effective max (2)"
914 );
915 }
916
917 #[test]
918 fn test_heading_aware_fix_preserves_allowed_heading_blanks() {
919 let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 3);
921 let content = "# Heading\n\n\n\nParagraph\n"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
923 let fixed = rule.fix(&ctx).unwrap();
924 assert_eq!(
925 fixed, "# Heading\n\n\n\nParagraph\n",
926 "Fix preserves blanks within the heading limit"
927 );
928 }
929
930 #[test]
931 fn test_heading_aware_fix_reduces_non_heading_blanks() {
932 let rule = MD012NoMultipleBlanks::default();
934 let content = "Paragraph 1\n\n\n\nParagraph 2\n";
935 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
936 let fixed = rule.fix(&ctx).unwrap();
937 assert_eq!(
938 fixed, "Paragraph 1\n\nParagraph 2\n",
939 "Fix should reduce non-heading blanks"
940 );
941 }
942
943 #[test]
944 fn test_heading_aware_mixed_heading_and_non_heading() {
945 let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 2);
947 let content = "# Heading\n\n\nParagraph 1\n\n\nParagraph 2\n";
948 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
949 let result = rule.check(&ctx).unwrap();
950 assert_eq!(result.len(), 1, "Only non-heading excess should be flagged");
952 }
953
954 #[test]
955 fn test_heading_aware_blanks_at_start_before_heading_still_flagged() {
956 let rule = MD012NoMultipleBlanks::default().with_heading_limits(3, 3);
959 let content = "\n\n\n# Heading\n";
960 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
961 let result = rule.check(&ctx).unwrap();
962 assert_eq!(
963 result.len(),
964 2,
965 "Start-of-file blanks should be flagged even before heading"
966 );
967 assert!(result[0].message.contains("at start of file"));
968 }
969
970 #[test]
971 fn test_heading_aware_eof_blanks_after_heading_still_flagged() {
972 let rule = MD012NoMultipleBlanks::default();
974 let content = "# Heading\n\n";
975 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
976 let result = rule.check(&ctx).unwrap();
977 assert_eq!(result.len(), 1, "EOF blanks should still be flagged");
978 assert!(result[0].message.contains("at end of file"));
979 }
980
981 #[test]
982 fn test_heading_aware_unlimited_heading_blanks() {
983 let rule = MD012NoMultipleBlanks::default().with_heading_limits(usize::MAX, usize::MAX);
985 let content = "# Heading\n\n\n\n\nParagraph\n"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
987 let result = rule.check(&ctx).unwrap();
988 assert!(
989 result.is_empty(),
990 "Unlimited heading limits means MD012 never flags near headings"
991 );
992 }
993
994 #[test]
995 fn test_heading_aware_blanks_after_code_then_heading() {
996 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 2);
998 let content = "# Heading\n\n```\ncode\n```\n\n\n\nMore text\n";
999 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1000 let result = rule.check(&ctx).unwrap();
1001 assert_eq!(result.len(), 2, "Non-heading blanks after code block should be flagged");
1003 }
1004
1005 #[test]
1006 fn test_heading_aware_fix_mixed_document() {
1007 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 2);
1009 let content = "# Title\n\n\n## Section\n\n\nPara 1\n\n\nPara 2\n";
1010 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1011 let fixed = rule.fix(&ctx).unwrap();
1012 assert_eq!(fixed, "# Title\n\n\n## Section\n\n\nPara 1\n\nPara 2\n");
1014 }
1015
1016 #[test]
1017 fn test_heading_aware_from_config_reads_md022() {
1018 let mut config = crate::config::Config::default();
1020 let mut md022_config = crate::config::RuleConfig::default();
1021 md022_config
1022 .values
1023 .insert("lines-above".to_string(), toml::Value::Integer(2));
1024 md022_config
1025 .values
1026 .insert("lines-below".to_string(), toml::Value::Integer(3));
1027 config.rules.insert("MD022".to_string(), md022_config);
1028
1029 let rule = MD012NoMultipleBlanks::from_config(&config);
1030 let content = "Paragraph\n\n\n# Heading\n";
1032 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1033 let result = rule.check(&ctx).unwrap();
1034 assert!(
1035 result.is_empty(),
1036 "2 blanks above heading allowed when MD022 lines-above=2"
1037 );
1038 }
1039
1040 #[test]
1041 fn test_heading_aware_from_config_md022_disabled() {
1042 let mut config = crate::config::Config::default();
1044 config.global.disable.push("MD022".to_string());
1045
1046 let mut md022_config = crate::config::RuleConfig::default();
1047 md022_config
1048 .values
1049 .insert("lines-above".to_string(), toml::Value::Integer(3));
1050 config.rules.insert("MD022".to_string(), md022_config);
1051
1052 let rule = MD012NoMultipleBlanks::from_config(&config);
1053 let content = "Paragraph\n\n\n# Heading\n";
1055 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1056 let result = rule.check(&ctx).unwrap();
1057 assert_eq!(
1058 result.len(),
1059 1,
1060 "With MD022 disabled, heading-adjacent blanks are flagged"
1061 );
1062 }
1063
1064 #[test]
1065 fn test_heading_aware_from_config_md022_unlimited() {
1066 let mut config = crate::config::Config::default();
1068 let mut md022_config = crate::config::RuleConfig::default();
1069 md022_config
1070 .values
1071 .insert("lines-above".to_string(), toml::Value::Integer(-1));
1072 config.rules.insert("MD022".to_string(), md022_config);
1073
1074 let rule = MD012NoMultipleBlanks::from_config(&config);
1075 let content = "Paragraph\n\n\n\n\n# Heading\n"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1077 let result = rule.check(&ctx).unwrap();
1078 assert!(
1079 result.is_empty(),
1080 "Unlimited MD022 lines-above means MD012 never flags above headings"
1081 );
1082 }
1083
1084 #[test]
1085 fn test_heading_aware_from_config_per_level() {
1086 let mut config = crate::config::Config::default();
1091 let mut md022_config = crate::config::RuleConfig::default();
1092 md022_config.values.insert(
1093 "lines-above".to_string(),
1094 toml::Value::Array(vec![
1095 toml::Value::Integer(2),
1096 toml::Value::Integer(1),
1097 toml::Value::Integer(1),
1098 toml::Value::Integer(1),
1099 toml::Value::Integer(1),
1100 toml::Value::Integer(1),
1101 ]),
1102 );
1103 config.rules.insert("MD022".to_string(), md022_config);
1104
1105 let rule = MD012NoMultipleBlanks::from_config(&config);
1106
1107 let content = "Paragraph\n\n\n## H2 Heading\n";
1109 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1110 let result = rule.check(&ctx).unwrap();
1111 assert!(result.is_empty(), "Per-level max (2) allows 2 blanks above any heading");
1112
1113 let content = "Paragraph\n\n\n\n## H2 Heading\n";
1115 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1116 let result = rule.check(&ctx).unwrap();
1117 assert_eq!(result.len(), 1, "3 blanks exceeds per-level max of 2");
1118 }
1119
1120 #[test]
1121 fn test_issue_449_reproduction() {
1122 let rule = MD012NoMultipleBlanks::default();
1125 let content = "\
1126# Heading
1127
1128
1129Some introductory text.
1130
1131
1132
1133
1134
1135## Heading level 2
1136
1137
1138Some text for this section.
1139
1140Some more text for this section.
1141
1142
1143## Another heading level 2
1144
1145
1146
1147Some text for this section.
1148
1149Some more text for this section.
1150";
1151 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1152 let result = rule.check(&ctx).unwrap();
1153 assert!(
1154 !result.is_empty(),
1155 "Issue #449: excess blanks around headings should be flagged with default settings"
1156 );
1157
1158 let fixed = rule.fix(&ctx).unwrap();
1160 let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1161 let recheck = rule.check(&fixed_ctx).unwrap();
1162 assert!(recheck.is_empty(), "Fix should resolve all excess blank lines");
1163
1164 assert!(fixed.contains("# Heading\n\nSome"), "1 blank below first heading");
1166 assert!(
1167 fixed.contains("text.\n\n## Heading level 2"),
1168 "1 blank above second heading"
1169 );
1170 }
1171
1172 #[test]
1175 fn test_blank_lines_in_quarto_callout() {
1176 let rule = MD012NoMultipleBlanks::default();
1178 let content = "# Heading\n\n::: {.callout-note}\nNote content\n\n\nMore content\n:::\n\nAfter";
1179 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1180 let result = rule.check(&ctx).unwrap();
1181 assert!(result.is_empty(), "Should not flag blanks inside Quarto callouts");
1182 }
1183
1184 #[test]
1185 fn test_blank_lines_in_quarto_div() {
1186 let rule = MD012NoMultipleBlanks::default();
1188 let content = "Text\n\n::: {.bordered}\nContent\n\n\nMore\n:::\n\nText";
1189 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1190 let result = rule.check(&ctx).unwrap();
1191 assert!(result.is_empty(), "Should not flag blanks inside Quarto divs");
1192 }
1193
1194 #[test]
1195 fn test_blank_lines_outside_quarto_div_flagged() {
1196 let rule = MD012NoMultipleBlanks::default();
1198 let content = "Text\n\n\n::: {.callout-note}\nNote\n:::\n\n\nMore";
1199 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1200 let result = rule.check(&ctx).unwrap();
1201 assert!(!result.is_empty(), "Should flag blanks outside Quarto divs");
1202 }
1203
1204 #[test]
1205 fn test_quarto_divs_ignored_in_standard_flavor() {
1206 let rule = MD012NoMultipleBlanks::default();
1208 let content = "::: {.callout-note}\nNote content\n\n\nMore content\n:::\n";
1209 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1210 let result = rule.check(&ctx).unwrap();
1211 assert!(!result.is_empty(), "Standard flavor should flag blanks in 'div'");
1213 }
1214
1215 #[test]
1218 fn test_roundtrip_multiple_blank_lines() {
1219 let rule = MD012NoMultipleBlanks::default();
1220 let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
1221 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1222 let fixed = rule.fix(&ctx).unwrap();
1223 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1224 let recheck = rule.check(&ctx2).unwrap();
1225 assert!(
1226 recheck.is_empty(),
1227 "Roundtrip: fix then check should be clean, got {recheck:?}"
1228 );
1229 }
1230
1231 #[test]
1232 fn test_roundtrip_trailing_blanks() {
1233 let rule = MD012NoMultipleBlanks::default();
1234 let content = "Content\n\n\n\n";
1235 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1236 let fixed = rule.fix(&ctx).unwrap();
1237 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1238 let recheck = rule.check(&ctx2).unwrap();
1239 assert!(recheck.is_empty(), "Roundtrip: trailing blanks, got {recheck:?}");
1240 }
1241
1242 #[test]
1243 fn test_roundtrip_leading_blanks() {
1244 let rule = MD012NoMultipleBlanks::default();
1245 let content = "\n\n\nContent\n";
1246 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1247 let fixed = rule.fix(&ctx).unwrap();
1248 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1249 let recheck = rule.check(&ctx2).unwrap();
1250 assert!(recheck.is_empty(), "Roundtrip: leading blanks, got {recheck:?}");
1251 }
1252
1253 #[test]
1254 fn test_roundtrip_custom_maximum() {
1255 let rule = MD012NoMultipleBlanks::new(2);
1256 let content = "Line 1\n\n\n\n\nLine 2\n";
1257 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1258 let fixed = rule.fix(&ctx).unwrap();
1259 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1260 let recheck = rule.check(&ctx2).unwrap();
1261 assert!(recheck.is_empty(), "Roundtrip: max=2, got {recheck:?}");
1262 }
1263
1264 #[test]
1265 fn test_roundtrip_code_blocks() {
1266 let rule = MD012NoMultipleBlanks::default();
1267 let content = "Before\n\n\n```\ncode\n\n\n\nmore code\n```\n\n\nAfter";
1268 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1269 let fixed = rule.fix(&ctx).unwrap();
1270 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1271 let recheck = rule.check(&ctx2).unwrap();
1272 assert!(recheck.is_empty(), "Roundtrip: code blocks, got {recheck:?}");
1273 }
1274
1275 #[test]
1276 fn test_roundtrip_heading_limits() {
1277 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 2);
1278 let content = "# Heading\n\n\n\n\nParagraph\n\n\n\n## Heading 2\n";
1279 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1280 let fixed = rule.fix(&ctx).unwrap();
1281 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1282 let recheck = rule.check(&ctx2).unwrap();
1283 assert!(recheck.is_empty(), "Roundtrip: heading limits, got {recheck:?}");
1284 }
1285
1286 #[test]
1287 fn test_roundtrip_front_matter() {
1288 let rule = MD012NoMultipleBlanks::default();
1289 let content = "---\ntitle: Test\n\n\nauthor: Me\n---\n\n\n\nContent\n";
1290 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1291 let fixed = rule.fix(&ctx).unwrap();
1292 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1293 let recheck = rule.check(&ctx2).unwrap();
1294 assert!(recheck.is_empty(), "Roundtrip: front matter, got {recheck:?}");
1295 }
1296
1297 #[test]
1298 fn test_roundtrip_only_blanks() {
1299 let rule = MD012NoMultipleBlanks::default();
1300 let content = "\n\n\n";
1301 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1302 let fixed = rule.fix(&ctx).unwrap();
1303 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1304 let recheck = rule.check(&ctx2).unwrap();
1305 assert!(recheck.is_empty(), "Roundtrip: only blanks, got {recheck:?}");
1306 }
1307
1308 #[test]
1309 fn test_roundtrip_single_eof_blank() {
1310 let rule = MD012NoMultipleBlanks::default();
1311 let content = "Content\n\n";
1312 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1313 let fixed = rule.fix(&ctx).unwrap();
1314 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1315 let recheck = rule.check(&ctx2).unwrap();
1316 assert!(recheck.is_empty(), "Roundtrip: single EOF blank, got {recheck:?}");
1317 }
1318
1319 #[test]
1320 fn test_roundtrip_mixed_heading_and_non_heading() {
1321 let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 2);
1322 let content = "# Heading\n\n\n\nParagraph 1\n\n\n\nParagraph 2\n";
1323 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1324 let fixed = rule.fix(&ctx).unwrap();
1325 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1326 let recheck = rule.check(&ctx2).unwrap();
1327 assert!(
1328 recheck.is_empty(),
1329 "Roundtrip: mixed heading/non-heading, got {recheck:?}"
1330 );
1331 }
1332}