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");
191
192 if !has_potential_blanks && !ends_with_multiple_newlines {
193 return Ok(Vec::new());
194 }
195
196 let mut warnings = Vec::new();
197
198 let mut blank_count = 0;
200 let mut blank_start = 0;
201 let mut last_line_num: Option<usize> = None;
202 let mut prev_content_line_num: Option<usize> = None;
204
205 let mut lines_to_check: HashSet<usize> = HashSet::new();
207
208 for filtered_line in ctx
215 .filtered_lines()
216 .skip_front_matter()
217 .skip_code_blocks()
218 .skip_html_comments()
219 .skip_html_blocks()
220 .skip_quarto_divs()
221 .skip_math_blocks()
222 .skip_obsidian_comments()
223 .skip_pymdown_blocks()
224 .skip_jsx_expressions()
225 .skip_mdx_comments()
226 .skip_jsx_blocks()
227 .skip_esm_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 ctx,
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 ctx,
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 {
316 let effective_max = if prev_content_line_num.is_some_and(|idx| is_heading_context(ctx, idx)) {
317 self.effective_max_below()
318 } else {
319 self.config.maximum.get()
320 };
321 if blank_count > effective_max {
322 warnings.extend(self.generate_excess_warnings(
323 blank_start,
324 blank_count,
325 effective_max,
326 lines,
327 &lines_to_check,
328 ctx,
329 ));
330 }
331 }
332
333 if blank_count > 0 && last_line_is_blank {
337 let location = "at end of file";
338
339 let report_line = lines.len();
341
342 let fix_start = ctx.line_start_byte(report_line - blank_count + 1).unwrap_or(0);
345 let fix_end = content.len();
346
347 warnings.push(LintWarning {
349 rule_name: Some(self.name().to_string()),
350 severity: Severity::Warning,
351 message: format!("Multiple consecutive blank lines {location}"),
352 line: report_line,
353 column: 1,
354 end_line: report_line,
355 end_column: 1,
356 fix: Some(Fix::new(fix_start..fix_end, String::new())),
357 });
358 }
359
360 Ok(warnings)
361 }
362
363 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
364 if self.should_skip(ctx) {
365 return Ok(ctx.content.to_string());
366 }
367 let warnings = self.check(ctx)?;
368 if warnings.is_empty() {
369 return Ok(ctx.content.to_string());
370 }
371 let warnings =
372 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
373 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
374 .map_err(crate::rule::LintError::InvalidInput)
375 }
376
377 fn as_any(&self) -> &dyn std::any::Any {
378 self
379 }
380
381 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
382 ctx.content.is_empty() || !ctx.has_char('\n')
384 }
385
386 crate::impl_rule_config_sections!(MD012Config);
387
388 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
389 where
390 Self: Sized,
391 {
392 use crate::rules::md022_blanks_around_headings::md022_config::MD022Config;
393
394 let rule_config = crate::rule_config_serde::load_rule_config::<MD012Config>(config);
395
396 let md022_disabled = config.global.disable.iter().any(|r| r == "MD022")
399 || config.global.extend_disable.iter().any(|r| r == "MD022");
400
401 let (heading_above, heading_below) = if md022_disabled {
402 (rule_config.maximum.get(), rule_config.maximum.get())
404 } else {
405 let md022_config = crate::rule_config_serde::load_rule_config::<MD022Config>(config);
406 (
407 max_heading_limit(&md022_config.lines_above),
408 max_heading_limit(&md022_config.lines_below),
409 )
410 };
411
412 Box::new(Self {
413 config: rule_config,
414 heading_blanks_above: heading_above,
415 heading_blanks_below: heading_below,
416 })
417 }
418}
419
420#[cfg(test)]
421mod tests {
422 use super::*;
423 use crate::lint_context::LintContext;
424
425 #[test]
426 fn test_single_blank_line_allowed() {
427 let rule = MD012NoMultipleBlanks::default();
428 let content = "Line 1\n\nLine 2\n\nLine 3";
429 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
430 let result = rule.check(&ctx).unwrap();
431 assert!(result.is_empty());
432 }
433
434 #[test]
435 fn test_multiple_blank_lines_flagged() {
436 let rule = MD012NoMultipleBlanks::default();
437 let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
438 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
439 let result = rule.check(&ctx).unwrap();
440 assert_eq!(result.len(), 3); assert_eq!(result[0].line, 3);
442 assert_eq!(result[1].line, 6);
443 assert_eq!(result[2].line, 7);
444 }
445
446 #[test]
447 fn test_custom_maximum() {
448 let rule = MD012NoMultipleBlanks::new(2);
449 let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
450 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
451 let result = rule.check(&ctx).unwrap();
452 assert_eq!(result.len(), 1); assert_eq!(result[0].line, 7);
454 }
455
456 #[test]
457 fn test_fix_multiple_blank_lines() {
458 let rule = MD012NoMultipleBlanks::default();
459 let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
460 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
461 let fixed = rule.fix(&ctx).unwrap();
462 assert_eq!(fixed, "Line 1\n\nLine 2\n\nLine 3");
463 }
464
465 #[test]
466 fn test_blank_lines_in_code_block() {
467 let rule = MD012NoMultipleBlanks::default();
468 let content = "Before\n\n```\ncode\n\n\n\nmore code\n```\n\nAfter";
469 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
470 let result = rule.check(&ctx).unwrap();
471 assert!(result.is_empty()); }
473
474 #[test]
475 fn test_blank_lines_in_html_comment() {
476 let rule = MD012NoMultipleBlanks::default();
477 let content = "Before\n\n<!--\ncomment\n\n\n\nmore comment\n-->\n\nAfter";
478 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
479 let result = rule.check(&ctx).unwrap();
480 assert!(result.is_empty()); }
482
483 #[test]
484 fn test_blank_lines_in_html_block() {
485 let rule = MD012NoMultipleBlanks::default();
486 let content = "Before\n\n<script>\n\n\n\n</script>\n\nAfter";
487 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
488 let result = rule.check(&ctx).unwrap();
489 assert!(result.is_empty()); }
491
492 #[test]
493 fn test_fix_preserves_code_block_blanks() {
494 let rule = MD012NoMultipleBlanks::default();
495 let content = "Before\n\n\n```\ncode\n\n\n\nmore code\n```\n\n\nAfter";
496 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
497 let fixed = rule.fix(&ctx).unwrap();
498 assert_eq!(fixed, "Before\n\n```\ncode\n\n\n\nmore code\n```\n\nAfter");
499 }
500
501 #[test]
502 fn test_blank_lines_in_front_matter() {
503 let rule = MD012NoMultipleBlanks::default();
504 let content = "---\ntitle: Test\n\n\nauthor: Me\n---\n\nContent";
505 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
506 let result = rule.check(&ctx).unwrap();
507 assert!(result.is_empty()); }
509
510 #[test]
511 fn test_blank_lines_at_start() {
512 let rule = MD012NoMultipleBlanks::default();
513 let content = "\n\n\nContent";
514 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
515 let result = rule.check(&ctx).unwrap();
516 assert_eq!(result.len(), 2);
517 assert!(result[0].message.contains("at start of file"));
518 }
519
520 #[test]
521 fn test_blank_lines_at_end() {
522 let rule = MD012NoMultipleBlanks::default();
523 let content = "Content\n\n\n";
524 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
525 let result = rule.check(&ctx).unwrap();
526 assert_eq!(result.len(), 1);
527 assert!(result[0].message.contains("at end of file"));
528 }
529
530 #[test]
531 fn test_single_blank_at_eof_flagged() {
532 let rule = MD012NoMultipleBlanks::default();
534 let content = "Content\n\n";
535 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
536 let result = rule.check(&ctx).unwrap();
537 assert_eq!(result.len(), 1);
538 assert!(result[0].message.contains("at end of file"));
539 }
540
541 #[test]
542 fn test_whitespace_only_lines() {
543 let rule = MD012NoMultipleBlanks::default();
544 let content = "Line 1\n \n\t\nLine 2";
545 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
546 let result = rule.check(&ctx).unwrap();
547 assert_eq!(result.len(), 1); }
549
550 #[test]
551 fn test_indented_code_blocks() {
552 let rule = MD012NoMultipleBlanks::default();
554 let content = "Text\n\n code\n \n \n more code\n\nText";
555 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
556 let result = rule.check(&ctx).unwrap();
557 assert!(result.is_empty(), "Should not flag blanks inside indented code blocks");
558 }
559
560 #[test]
561 fn test_blanks_in_indented_code_block() {
562 let content = " code line 1\n\n\n code line 2\n";
564 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
565 let rule = MD012NoMultipleBlanks::default();
566 let warnings = rule.check(&ctx).unwrap();
567 assert!(warnings.is_empty(), "Should not flag blanks in indented code");
568 }
569
570 #[test]
571 fn test_blanks_in_indented_code_block_with_heading() {
572 let content = "# Heading\n\n code line 1\n\n\n code line 2\n\nMore text\n";
574 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
575 let rule = MD012NoMultipleBlanks::default();
576 let warnings = rule.check(&ctx).unwrap();
577 assert!(
578 warnings.is_empty(),
579 "Should not flag blanks in indented code after heading"
580 );
581 }
582
583 #[test]
584 fn test_blanks_after_indented_code_block_flagged() {
585 let content = "# Heading\n\n code line\n\n\n\nMore text\n";
587 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
588 let rule = MD012NoMultipleBlanks::default();
589 let warnings = rule.check(&ctx).unwrap();
590 assert_eq!(warnings.len(), 2, "Should flag blanks after indented code block ends");
592 }
593
594 #[test]
595 fn test_fix_with_final_newline() {
596 let rule = MD012NoMultipleBlanks::default();
597 let content = "Line 1\n\n\nLine 2\n";
598 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
599 let fixed = rule.fix(&ctx).unwrap();
600 assert_eq!(fixed, "Line 1\n\nLine 2\n");
601 assert!(fixed.ends_with('\n'));
602 }
603
604 #[test]
605 fn test_empty_content() {
606 let rule = MD012NoMultipleBlanks::default();
607 let content = "";
608 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
609 let result = rule.check(&ctx).unwrap();
610 assert!(result.is_empty());
611 }
612
613 #[test]
614 fn test_nested_code_blocks() {
615 let rule = MD012NoMultipleBlanks::default();
616 let content = "Before\n\n~~~\nouter\n\n```\ninner\n\n\n```\n\n~~~\n\nAfter";
617 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
618 let result = rule.check(&ctx).unwrap();
619 assert!(result.is_empty());
620 }
621
622 #[test]
623 fn test_unclosed_code_block() {
624 let rule = MD012NoMultipleBlanks::default();
625 let content = "Before\n\n```\ncode\n\n\n\nno closing fence";
626 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
627 let result = rule.check(&ctx).unwrap();
628 assert!(result.is_empty()); }
630
631 #[test]
632 fn test_mixed_fence_styles() {
633 let rule = MD012NoMultipleBlanks::default();
634 let content = "Before\n\n```\ncode\n\n\n~~~\n\nAfter";
635 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
636 let result = rule.check(&ctx).unwrap();
637 assert!(result.is_empty()); }
639
640 #[test]
641 fn test_config_from_toml() {
642 let mut config = crate::config::Config::default();
643 let mut rule_config = crate::config::RuleConfig::default();
644 rule_config
645 .values
646 .insert("maximum".to_string(), toml::Value::Integer(3));
647 config.rules.insert("MD012".to_string(), rule_config);
648
649 let rule = MD012NoMultipleBlanks::from_config(&config);
650 let content = "Line 1\n\n\n\nLine 2"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
652 let result = rule.check(&ctx).unwrap();
653 assert!(result.is_empty()); }
655
656 #[test]
657 fn test_blank_lines_between_sections() {
658 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 1);
660 let content = "# Section 1\n\nContent\n\n\n# Section 2\n\nContent";
661 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
662 let result = rule.check(&ctx).unwrap();
663 assert!(
664 result.is_empty(),
665 "2 blanks above heading allowed with heading_blanks_above=2"
666 );
667 }
668
669 #[test]
670 fn test_fix_preserves_indented_code() {
671 let rule = MD012NoMultipleBlanks::default();
672 let content = "Text\n\n\n code\n \n more code\n\n\nText";
673 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
674 let fixed = rule.fix(&ctx).unwrap();
675 assert_eq!(fixed, "Text\n\n code\n \n more code\n\nText");
678 }
679
680 #[test]
681 fn test_edge_case_only_blanks() {
682 let rule = MD012NoMultipleBlanks::default();
683 let content = "\n\n\n";
684 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
685 let result = rule.check(&ctx).unwrap();
686 assert_eq!(result.len(), 1);
688 assert!(result[0].message.contains("at end of file"));
689 }
690
691 #[test]
694 fn test_blanks_after_fenced_code_block_mid_document() {
695 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 1);
697 let content = "## Input\n\n```javascript\ncode\n```\n\n\n## Error\n";
698 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
699 let result = rule.check(&ctx).unwrap();
700 assert!(
701 result.is_empty(),
702 "2 blanks before heading allowed with heading_blanks_above=2"
703 );
704 }
705
706 #[test]
707 fn test_blanks_after_code_block_at_eof() {
708 let rule = MD012NoMultipleBlanks::default();
710 let content = "# Heading\n\n```\ncode\n```\n\n\n";
711 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
712 let result = rule.check(&ctx).unwrap();
713 assert_eq!(result.len(), 1, "Should detect trailing blanks after code block");
715 assert!(result[0].message.contains("at end of file"));
716 }
717
718 #[test]
719 fn test_single_blank_after_code_block_allowed() {
720 let rule = MD012NoMultipleBlanks::default();
722 let content = "## Input\n\n```\ncode\n```\n\n## Output\n";
723 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
724 let result = rule.check(&ctx).unwrap();
725 assert!(result.is_empty(), "Single blank after code block should be allowed");
726 }
727
728 #[test]
729 fn test_multiple_code_blocks_with_blanks() {
730 let rule = MD012NoMultipleBlanks::default();
732 let content = "```\ncode1\n```\n\n\n```\ncode2\n```\n\n\nEnd\n";
733 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
734 let result = rule.check(&ctx).unwrap();
735 assert_eq!(result.len(), 2, "Should detect blanks after both code blocks");
737 }
738
739 #[test]
740 fn test_whitespace_only_lines_after_code_block_at_eof() {
741 let rule = MD012NoMultipleBlanks::default();
744 let content = "```\ncode\n```\n \n \n";
745 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
746 let result = rule.check(&ctx).unwrap();
747 assert_eq!(result.len(), 1, "Should detect whitespace-only trailing blanks");
748 assert!(result[0].message.contains("at end of file"));
749 }
750
751 #[test]
754 fn test_warning_fix_removes_single_trailing_blank() {
755 let rule = MD012NoMultipleBlanks::default();
757 let content = "hello foobar hello.\n\n";
758 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
759 let warnings = rule.check(&ctx).unwrap();
760
761 assert_eq!(warnings.len(), 1);
762 assert!(warnings[0].fix.is_some(), "Warning should have a fix attached");
763
764 let fix = warnings[0].fix.as_ref().unwrap();
765 assert_eq!(fix.replacement, "", "Replacement should be empty");
767
768 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
770 assert_eq!(fixed, "hello foobar hello.\n", "Should end with single newline");
771 }
772
773 #[test]
774 fn test_warning_fix_removes_multiple_trailing_blanks() {
775 let rule = MD012NoMultipleBlanks::default();
776 let content = "content\n\n\n\n";
777 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
778 let warnings = rule.check(&ctx).unwrap();
779
780 assert_eq!(warnings.len(), 1);
781 assert!(warnings[0].fix.is_some());
782
783 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
784 assert_eq!(fixed, "content\n", "Should end with single newline");
785 }
786
787 #[test]
788 fn test_warning_fix_preserves_content_newline() {
789 let rule = MD012NoMultipleBlanks::default();
791 let content = "line1\nline2\n\n";
792 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
793 let warnings = rule.check(&ctx).unwrap();
794
795 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
796 assert_eq!(fixed, "line1\nline2\n", "Should preserve all content lines");
797 }
798
799 #[test]
800 fn test_warning_fix_mid_document_blanks() {
801 let rule = MD012NoMultipleBlanks::default();
803 let content = "# Heading\n\n\n\nParagraph\n";
804 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
805 let warnings = rule.check(&ctx).unwrap();
806 assert_eq!(
807 warnings.len(),
808 2,
809 "Excess heading-adjacent blanks flagged with default limits"
810 );
811 }
812
813 #[test]
818 fn test_heading_aware_blanks_below_with_higher_limit() {
819 let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 2);
821 let content = "# Heading\n\n\nParagraph\n";
822 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
823 let result = rule.check(&ctx).unwrap();
824 assert!(
825 result.is_empty(),
826 "2 blanks below heading allowed with heading_blanks_below=2"
827 );
828 }
829
830 #[test]
831 fn test_heading_aware_blanks_above_with_higher_limit() {
832 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 1);
834 let content = "Paragraph\n\n\n# Heading\n";
835 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
836 let result = rule.check(&ctx).unwrap();
837 assert!(
838 result.is_empty(),
839 "2 blanks above heading allowed with heading_blanks_above=2"
840 );
841 }
842
843 #[test]
844 fn test_heading_aware_blanks_between_headings() {
845 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 2);
847 let content = "# Heading 1\n\n\n## Heading 2\n";
848 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
849 let result = rule.check(&ctx).unwrap();
850 assert!(result.is_empty(), "2 blanks between headings allowed with limits=2");
851 }
852
853 #[test]
854 fn test_heading_aware_excess_still_flagged() {
855 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 2);
857 let content = "# Heading\n\n\n\n\nParagraph\n"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
859 let result = rule.check(&ctx).unwrap();
860 assert_eq!(result.len(), 2, "Excess beyond heading limit should be flagged");
861 }
862
863 #[test]
864 fn test_heading_aware_setext_blanks_below() {
865 let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 2);
867 let content = "Heading\n=======\n\n\nParagraph\n";
868 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
869 let result = rule.check(&ctx).unwrap();
870 assert!(result.is_empty(), "2 blanks below Setext heading allowed with limit=2");
871 }
872
873 #[test]
874 fn test_heading_aware_setext_blanks_above() {
875 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 1);
877 let content = "Paragraph\n\n\nHeading\n=======\n";
878 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
879 let result = rule.check(&ctx).unwrap();
880 assert!(result.is_empty(), "2 blanks above Setext heading allowed with limit=2");
881 }
882
883 #[test]
884 fn test_heading_aware_single_blank_allowed() {
885 let rule = MD012NoMultipleBlanks::default();
887 let content = "# Heading\n\nParagraph\n";
888 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
889 let result = rule.check(&ctx).unwrap();
890 assert!(result.is_empty(), "Single blank near heading should be allowed");
891 }
892
893 #[test]
894 fn test_heading_aware_non_heading_blanks_still_flagged() {
895 let rule = MD012NoMultipleBlanks::default();
897 let content = "Paragraph 1\n\n\nParagraph 2\n";
898 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
899 let result = rule.check(&ctx).unwrap();
900 assert_eq!(result.len(), 1, "Non-heading blanks should still be flagged");
901 }
902
903 #[test]
904 fn test_heading_aware_fix_caps_heading_blanks() {
905 let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 2);
907 let content = "# Heading\n\n\n\nParagraph\n"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
909 let fixed = rule.fix(&ctx).unwrap();
910 assert_eq!(
911 fixed, "# Heading\n\n\nParagraph\n",
912 "Fix caps heading-adjacent blanks at effective max (2)"
913 );
914 }
915
916 #[test]
917 fn test_heading_aware_fix_preserves_allowed_heading_blanks() {
918 let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 3);
920 let content = "# Heading\n\n\n\nParagraph\n"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
922 let fixed = rule.fix(&ctx).unwrap();
923 assert_eq!(
924 fixed, "# Heading\n\n\n\nParagraph\n",
925 "Fix preserves blanks within the heading limit"
926 );
927 }
928
929 #[test]
930 fn test_heading_aware_fix_reduces_non_heading_blanks() {
931 let rule = MD012NoMultipleBlanks::default();
933 let content = "Paragraph 1\n\n\n\nParagraph 2\n";
934 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
935 let fixed = rule.fix(&ctx).unwrap();
936 assert_eq!(
937 fixed, "Paragraph 1\n\nParagraph 2\n",
938 "Fix should reduce non-heading blanks"
939 );
940 }
941
942 #[test]
943 fn test_heading_aware_mixed_heading_and_non_heading() {
944 let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 2);
946 let content = "# Heading\n\n\nParagraph 1\n\n\nParagraph 2\n";
947 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
948 let result = rule.check(&ctx).unwrap();
949 assert_eq!(result.len(), 1, "Only non-heading excess should be flagged");
951 }
952
953 #[test]
954 fn test_heading_aware_blanks_at_start_before_heading_still_flagged() {
955 let rule = MD012NoMultipleBlanks::default().with_heading_limits(3, 3);
958 let content = "\n\n\n# Heading\n";
959 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
960 let result = rule.check(&ctx).unwrap();
961 assert_eq!(
962 result.len(),
963 2,
964 "Start-of-file blanks should be flagged even before heading"
965 );
966 assert!(result[0].message.contains("at start of file"));
967 }
968
969 #[test]
970 fn test_heading_aware_eof_blanks_after_heading_still_flagged() {
971 let rule = MD012NoMultipleBlanks::default();
973 let content = "# Heading\n\n";
974 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
975 let result = rule.check(&ctx).unwrap();
976 assert_eq!(result.len(), 1, "EOF blanks should still be flagged");
977 assert!(result[0].message.contains("at end of file"));
978 }
979
980 #[test]
981 fn test_heading_aware_unlimited_heading_blanks() {
982 let rule = MD012NoMultipleBlanks::default().with_heading_limits(usize::MAX, usize::MAX);
984 let content = "# Heading\n\n\n\n\nParagraph\n"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
986 let result = rule.check(&ctx).unwrap();
987 assert!(
988 result.is_empty(),
989 "Unlimited heading limits means MD012 never flags near headings"
990 );
991 }
992
993 #[test]
994 fn test_heading_aware_blanks_after_code_then_heading() {
995 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 2);
997 let content = "# Heading\n\n```\ncode\n```\n\n\n\nMore text\n";
998 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
999 let result = rule.check(&ctx).unwrap();
1000 assert_eq!(result.len(), 2, "Non-heading blanks after code block should be flagged");
1002 }
1003
1004 #[test]
1005 fn test_heading_aware_fix_mixed_document() {
1006 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 2);
1008 let content = "# Title\n\n\n## Section\n\n\nPara 1\n\n\nPara 2\n";
1009 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1010 let fixed = rule.fix(&ctx).unwrap();
1011 assert_eq!(fixed, "# Title\n\n\n## Section\n\n\nPara 1\n\nPara 2\n");
1013 }
1014
1015 #[test]
1016 fn test_heading_aware_from_config_reads_md022() {
1017 let mut config = crate::config::Config::default();
1019 let mut md022_config = crate::config::RuleConfig::default();
1020 md022_config
1021 .values
1022 .insert("lines-above".to_string(), toml::Value::Integer(2));
1023 md022_config
1024 .values
1025 .insert("lines-below".to_string(), toml::Value::Integer(3));
1026 config.rules.insert("MD022".to_string(), md022_config);
1027
1028 let rule = MD012NoMultipleBlanks::from_config(&config);
1029 let content = "Paragraph\n\n\n# Heading\n";
1031 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1032 let result = rule.check(&ctx).unwrap();
1033 assert!(
1034 result.is_empty(),
1035 "2 blanks above heading allowed when MD022 lines-above=2"
1036 );
1037 }
1038
1039 #[test]
1040 fn test_heading_aware_from_config_md022_disabled() {
1041 let mut config = crate::config::Config::default();
1043 config.global.disable.push("MD022".to_string());
1044
1045 let mut md022_config = crate::config::RuleConfig::default();
1046 md022_config
1047 .values
1048 .insert("lines-above".to_string(), toml::Value::Integer(3));
1049 config.rules.insert("MD022".to_string(), md022_config);
1050
1051 let rule = MD012NoMultipleBlanks::from_config(&config);
1052 let content = "Paragraph\n\n\n# Heading\n";
1054 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1055 let result = rule.check(&ctx).unwrap();
1056 assert_eq!(
1057 result.len(),
1058 1,
1059 "With MD022 disabled, heading-adjacent blanks are flagged"
1060 );
1061 }
1062
1063 #[test]
1064 fn test_heading_aware_from_config_md022_unlimited() {
1065 let mut config = crate::config::Config::default();
1067 let mut md022_config = crate::config::RuleConfig::default();
1068 md022_config
1069 .values
1070 .insert("lines-above".to_string(), toml::Value::Integer(-1));
1071 config.rules.insert("MD022".to_string(), md022_config);
1072
1073 let rule = MD012NoMultipleBlanks::from_config(&config);
1074 let content = "Paragraph\n\n\n\n\n# Heading\n"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1076 let result = rule.check(&ctx).unwrap();
1077 assert!(
1078 result.is_empty(),
1079 "Unlimited MD022 lines-above means MD012 never flags above headings"
1080 );
1081 }
1082
1083 #[test]
1084 fn test_heading_aware_from_config_per_level() {
1085 let mut config = crate::config::Config::default();
1090 let mut md022_config = crate::config::RuleConfig::default();
1091 md022_config.values.insert(
1092 "lines-above".to_string(),
1093 toml::Value::Array(vec![
1094 toml::Value::Integer(2),
1095 toml::Value::Integer(1),
1096 toml::Value::Integer(1),
1097 toml::Value::Integer(1),
1098 toml::Value::Integer(1),
1099 toml::Value::Integer(1),
1100 ]),
1101 );
1102 config.rules.insert("MD022".to_string(), md022_config);
1103
1104 let rule = MD012NoMultipleBlanks::from_config(&config);
1105
1106 let content = "Paragraph\n\n\n## H2 Heading\n";
1108 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1109 let result = rule.check(&ctx).unwrap();
1110 assert!(result.is_empty(), "Per-level max (2) allows 2 blanks above any heading");
1111
1112 let content = "Paragraph\n\n\n\n## H2 Heading\n";
1114 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1115 let result = rule.check(&ctx).unwrap();
1116 assert_eq!(result.len(), 1, "3 blanks exceeds per-level max of 2");
1117 }
1118
1119 #[test]
1120 fn test_issue_449_reproduction() {
1121 let rule = MD012NoMultipleBlanks::default();
1124 let content = "\
1125# Heading
1126
1127
1128Some introductory text.
1129
1130
1131
1132
1133
1134## Heading level 2
1135
1136
1137Some text for this section.
1138
1139Some more text for this section.
1140
1141
1142## Another heading level 2
1143
1144
1145
1146Some text for this section.
1147
1148Some more text for this section.
1149";
1150 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1151 let result = rule.check(&ctx).unwrap();
1152 assert!(
1153 !result.is_empty(),
1154 "Issue #449: excess blanks around headings should be flagged with default settings"
1155 );
1156
1157 let fixed = rule.fix(&ctx).unwrap();
1159 let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1160 let recheck = rule.check(&fixed_ctx).unwrap();
1161 assert!(recheck.is_empty(), "Fix should resolve all excess blank lines");
1162
1163 assert!(fixed.contains("# Heading\n\nSome"), "1 blank below first heading");
1165 assert!(
1166 fixed.contains("text.\n\n## Heading level 2"),
1167 "1 blank above second heading"
1168 );
1169 }
1170
1171 #[test]
1174 fn test_blank_lines_in_quarto_callout() {
1175 let rule = MD012NoMultipleBlanks::default();
1177 let content = "# Heading\n\n::: {.callout-note}\nNote content\n\n\nMore content\n:::\n\nAfter";
1178 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1179 let result = rule.check(&ctx).unwrap();
1180 assert!(result.is_empty(), "Should not flag blanks inside Quarto callouts");
1181 }
1182
1183 #[test]
1184 fn test_blank_lines_in_quarto_div() {
1185 let rule = MD012NoMultipleBlanks::default();
1187 let content = "Text\n\n::: {.bordered}\nContent\n\n\nMore\n:::\n\nText";
1188 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1189 let result = rule.check(&ctx).unwrap();
1190 assert!(result.is_empty(), "Should not flag blanks inside Quarto divs");
1191 }
1192
1193 #[test]
1194 fn test_blank_lines_outside_quarto_div_flagged() {
1195 let rule = MD012NoMultipleBlanks::default();
1197 let content = "Text\n\n\n::: {.callout-note}\nNote\n:::\n\n\nMore";
1198 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1199 let result = rule.check(&ctx).unwrap();
1200 assert!(!result.is_empty(), "Should flag blanks outside Quarto divs");
1201 }
1202
1203 #[test]
1204 fn test_quarto_divs_ignored_in_standard_flavor() {
1205 let rule = MD012NoMultipleBlanks::default();
1207 let content = "::: {.callout-note}\nNote content\n\n\nMore content\n:::\n";
1208 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1209 let result = rule.check(&ctx).unwrap();
1210 assert!(!result.is_empty(), "Standard flavor should flag blanks in 'div'");
1212 }
1213
1214 #[test]
1217 fn test_roundtrip_multiple_blank_lines() {
1218 let rule = MD012NoMultipleBlanks::default();
1219 let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
1220 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1221 let fixed = rule.fix(&ctx).unwrap();
1222 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1223 let recheck = rule.check(&ctx2).unwrap();
1224 assert!(
1225 recheck.is_empty(),
1226 "Roundtrip: fix then check should be clean, got {recheck:?}"
1227 );
1228 }
1229
1230 #[test]
1231 fn test_roundtrip_trailing_blanks() {
1232 let rule = MD012NoMultipleBlanks::default();
1233 let content = "Content\n\n\n\n";
1234 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1235 let fixed = rule.fix(&ctx).unwrap();
1236 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1237 let recheck = rule.check(&ctx2).unwrap();
1238 assert!(recheck.is_empty(), "Roundtrip: trailing blanks, got {recheck:?}");
1239 }
1240
1241 #[test]
1242 fn test_roundtrip_leading_blanks() {
1243 let rule = MD012NoMultipleBlanks::default();
1244 let content = "\n\n\nContent\n";
1245 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1246 let fixed = rule.fix(&ctx).unwrap();
1247 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1248 let recheck = rule.check(&ctx2).unwrap();
1249 assert!(recheck.is_empty(), "Roundtrip: leading blanks, got {recheck:?}");
1250 }
1251
1252 #[test]
1253 fn test_roundtrip_custom_maximum() {
1254 let rule = MD012NoMultipleBlanks::new(2);
1255 let content = "Line 1\n\n\n\n\nLine 2\n";
1256 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1257 let fixed = rule.fix(&ctx).unwrap();
1258 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1259 let recheck = rule.check(&ctx2).unwrap();
1260 assert!(recheck.is_empty(), "Roundtrip: max=2, got {recheck:?}");
1261 }
1262
1263 #[test]
1264 fn test_roundtrip_code_blocks() {
1265 let rule = MD012NoMultipleBlanks::default();
1266 let content = "Before\n\n\n```\ncode\n\n\n\nmore code\n```\n\n\nAfter";
1267 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1268 let fixed = rule.fix(&ctx).unwrap();
1269 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1270 let recheck = rule.check(&ctx2).unwrap();
1271 assert!(recheck.is_empty(), "Roundtrip: code blocks, got {recheck:?}");
1272 }
1273
1274 #[test]
1275 fn test_roundtrip_heading_limits() {
1276 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 2);
1277 let content = "# Heading\n\n\n\n\nParagraph\n\n\n\n## Heading 2\n";
1278 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1279 let fixed = rule.fix(&ctx).unwrap();
1280 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1281 let recheck = rule.check(&ctx2).unwrap();
1282 assert!(recheck.is_empty(), "Roundtrip: heading limits, got {recheck:?}");
1283 }
1284
1285 #[test]
1286 fn test_roundtrip_front_matter() {
1287 let rule = MD012NoMultipleBlanks::default();
1288 let content = "---\ntitle: Test\n\n\nauthor: Me\n---\n\n\n\nContent\n";
1289 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1290 let fixed = rule.fix(&ctx).unwrap();
1291 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1292 let recheck = rule.check(&ctx2).unwrap();
1293 assert!(recheck.is_empty(), "Roundtrip: front matter, got {recheck:?}");
1294 }
1295
1296 #[test]
1297 fn test_roundtrip_only_blanks() {
1298 let rule = MD012NoMultipleBlanks::default();
1299 let content = "\n\n\n";
1300 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1301 let fixed = rule.fix(&ctx).unwrap();
1302 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1303 let recheck = rule.check(&ctx2).unwrap();
1304 assert!(recheck.is_empty(), "Roundtrip: only blanks, got {recheck:?}");
1305 }
1306
1307 #[test]
1308 fn test_roundtrip_single_eof_blank() {
1309 let rule = MD012NoMultipleBlanks::default();
1310 let content = "Content\n\n";
1311 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1312 let fixed = rule.fix(&ctx).unwrap();
1313 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1314 let recheck = rule.check(&ctx2).unwrap();
1315 assert!(recheck.is_empty(), "Roundtrip: single EOF blank, got {recheck:?}");
1316 }
1317
1318 #[test]
1319 fn test_roundtrip_mixed_heading_and_non_heading() {
1320 let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 2);
1321 let content = "# Heading\n\n\n\nParagraph 1\n\n\n\nParagraph 2\n";
1322 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1323 let fixed = rule.fix(&ctx).unwrap();
1324 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1325 let recheck = rule.check(&ctx2).unwrap();
1326 assert!(
1327 recheck.is_empty(),
1328 "Roundtrip: mixed heading/non-heading, got {recheck:?}"
1329 );
1330 }
1331}