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