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