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::new(
111 {
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 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::new(fix_start..fix_end, String::new())),
339 });
340 }
341
342 Ok(warnings)
343 }
344
345 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
346 if self.should_skip(ctx) {
347 return Ok(ctx.content.to_string());
348 }
349 let warnings = self.check(ctx)?;
350 if warnings.is_empty() {
351 return Ok(ctx.content.to_string());
352 }
353 let warnings =
354 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
355 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
356 .map_err(crate::rule::LintError::InvalidInput)
357 }
358
359 fn as_any(&self) -> &dyn std::any::Any {
360 self
361 }
362
363 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
364 ctx.content.is_empty() || !ctx.has_char('\n')
366 }
367
368 fn default_config_section(&self) -> Option<(String, toml::Value)> {
369 let default_config = MD012Config::default();
370 let json_value = serde_json::to_value(&default_config).ok()?;
371 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
372
373 if let toml::Value::Table(table) = toml_value {
374 if !table.is_empty() {
375 Some((MD012Config::RULE_NAME.to_string(), toml::Value::Table(table)))
376 } else {
377 None
378 }
379 } else {
380 None
381 }
382 }
383
384 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
385 where
386 Self: Sized,
387 {
388 use crate::rules::md022_blanks_around_headings::md022_config::MD022Config;
389
390 let rule_config = crate::rule_config_serde::load_rule_config::<MD012Config>(config);
391
392 let md022_disabled = config.global.disable.iter().any(|r| r == "MD022")
395 || config.global.extend_disable.iter().any(|r| r == "MD022");
396
397 let (heading_above, heading_below) = if md022_disabled {
398 (rule_config.maximum.get(), rule_config.maximum.get())
400 } else {
401 let md022_config = crate::rule_config_serde::load_rule_config::<MD022Config>(config);
402 (
403 max_heading_limit(&md022_config.lines_above),
404 max_heading_limit(&md022_config.lines_below),
405 )
406 };
407
408 Box::new(Self {
409 config: rule_config,
410 heading_blanks_above: heading_above,
411 heading_blanks_below: heading_below,
412 })
413 }
414}
415
416#[cfg(test)]
417mod tests {
418 use super::*;
419 use crate::lint_context::LintContext;
420
421 #[test]
422 fn test_single_blank_line_allowed() {
423 let rule = MD012NoMultipleBlanks::default();
424 let content = "Line 1\n\nLine 2\n\nLine 3";
425 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
426 let result = rule.check(&ctx).unwrap();
427 assert!(result.is_empty());
428 }
429
430 #[test]
431 fn test_multiple_blank_lines_flagged() {
432 let rule = MD012NoMultipleBlanks::default();
433 let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
434 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
435 let result = rule.check(&ctx).unwrap();
436 assert_eq!(result.len(), 3); assert_eq!(result[0].line, 3);
438 assert_eq!(result[1].line, 6);
439 assert_eq!(result[2].line, 7);
440 }
441
442 #[test]
443 fn test_custom_maximum() {
444 let rule = MD012NoMultipleBlanks::new(2);
445 let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
446 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
447 let result = rule.check(&ctx).unwrap();
448 assert_eq!(result.len(), 1); assert_eq!(result[0].line, 7);
450 }
451
452 #[test]
453 fn test_fix_multiple_blank_lines() {
454 let rule = MD012NoMultipleBlanks::default();
455 let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
456 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
457 let fixed = rule.fix(&ctx).unwrap();
458 assert_eq!(fixed, "Line 1\n\nLine 2\n\nLine 3");
459 }
460
461 #[test]
462 fn test_blank_lines_in_code_block() {
463 let rule = MD012NoMultipleBlanks::default();
464 let content = "Before\n\n```\ncode\n\n\n\nmore code\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_fix_preserves_code_block_blanks() {
472 let rule = MD012NoMultipleBlanks::default();
473 let content = "Before\n\n\n```\ncode\n\n\n\nmore code\n```\n\n\nAfter";
474 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
475 let fixed = rule.fix(&ctx).unwrap();
476 assert_eq!(fixed, "Before\n\n```\ncode\n\n\n\nmore code\n```\n\nAfter");
477 }
478
479 #[test]
480 fn test_blank_lines_in_front_matter() {
481 let rule = MD012NoMultipleBlanks::default();
482 let content = "---\ntitle: Test\n\n\nauthor: Me\n---\n\nContent";
483 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
484 let result = rule.check(&ctx).unwrap();
485 assert!(result.is_empty()); }
487
488 #[test]
489 fn test_blank_lines_at_start() {
490 let rule = MD012NoMultipleBlanks::default();
491 let content = "\n\n\nContent";
492 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
493 let result = rule.check(&ctx).unwrap();
494 assert_eq!(result.len(), 2);
495 assert!(result[0].message.contains("at start of file"));
496 }
497
498 #[test]
499 fn test_blank_lines_at_end() {
500 let rule = MD012NoMultipleBlanks::default();
501 let content = "Content\n\n\n";
502 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
503 let result = rule.check(&ctx).unwrap();
504 assert_eq!(result.len(), 1);
505 assert!(result[0].message.contains("at end of file"));
506 }
507
508 #[test]
509 fn test_single_blank_at_eof_flagged() {
510 let rule = MD012NoMultipleBlanks::default();
512 let content = "Content\n\n";
513 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
514 let result = rule.check(&ctx).unwrap();
515 assert_eq!(result.len(), 1);
516 assert!(result[0].message.contains("at end of file"));
517 }
518
519 #[test]
520 fn test_whitespace_only_lines() {
521 let rule = MD012NoMultipleBlanks::default();
522 let content = "Line 1\n \n\t\nLine 2";
523 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
524 let result = rule.check(&ctx).unwrap();
525 assert_eq!(result.len(), 1); }
527
528 #[test]
529 fn test_indented_code_blocks() {
530 let rule = MD012NoMultipleBlanks::default();
532 let content = "Text\n\n code\n \n \n more code\n\nText";
533 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
534 let result = rule.check(&ctx).unwrap();
535 assert!(result.is_empty(), "Should not flag blanks inside indented code blocks");
536 }
537
538 #[test]
539 fn test_blanks_in_indented_code_block() {
540 let content = " code line 1\n\n\n code line 2\n";
542 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
543 let rule = MD012NoMultipleBlanks::default();
544 let warnings = rule.check(&ctx).unwrap();
545 assert!(warnings.is_empty(), "Should not flag blanks in indented code");
546 }
547
548 #[test]
549 fn test_blanks_in_indented_code_block_with_heading() {
550 let content = "# Heading\n\n code line 1\n\n\n code line 2\n\nMore text\n";
552 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
553 let rule = MD012NoMultipleBlanks::default();
554 let warnings = rule.check(&ctx).unwrap();
555 assert!(
556 warnings.is_empty(),
557 "Should not flag blanks in indented code after heading"
558 );
559 }
560
561 #[test]
562 fn test_blanks_after_indented_code_block_flagged() {
563 let content = "# Heading\n\n code line\n\n\n\nMore text\n";
565 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
566 let rule = MD012NoMultipleBlanks::default();
567 let warnings = rule.check(&ctx).unwrap();
568 assert_eq!(warnings.len(), 2, "Should flag blanks after indented code block ends");
570 }
571
572 #[test]
573 fn test_fix_with_final_newline() {
574 let rule = MD012NoMultipleBlanks::default();
575 let content = "Line 1\n\n\nLine 2\n";
576 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
577 let fixed = rule.fix(&ctx).unwrap();
578 assert_eq!(fixed, "Line 1\n\nLine 2\n");
579 assert!(fixed.ends_with('\n'));
580 }
581
582 #[test]
583 fn test_empty_content() {
584 let rule = MD012NoMultipleBlanks::default();
585 let content = "";
586 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
587 let result = rule.check(&ctx).unwrap();
588 assert!(result.is_empty());
589 }
590
591 #[test]
592 fn test_nested_code_blocks() {
593 let rule = MD012NoMultipleBlanks::default();
594 let content = "Before\n\n~~~\nouter\n\n```\ninner\n\n\n```\n\n~~~\n\nAfter";
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_unclosed_code_block() {
602 let rule = MD012NoMultipleBlanks::default();
603 let content = "Before\n\n```\ncode\n\n\n\nno closing fence";
604 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
605 let result = rule.check(&ctx).unwrap();
606 assert!(result.is_empty()); }
608
609 #[test]
610 fn test_mixed_fence_styles() {
611 let rule = MD012NoMultipleBlanks::default();
612 let content = "Before\n\n```\ncode\n\n\n~~~\n\nAfter";
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_config_from_toml() {
620 let mut config = crate::config::Config::default();
621 let mut rule_config = crate::config::RuleConfig::default();
622 rule_config
623 .values
624 .insert("maximum".to_string(), toml::Value::Integer(3));
625 config.rules.insert("MD012".to_string(), rule_config);
626
627 let rule = MD012NoMultipleBlanks::from_config(&config);
628 let content = "Line 1\n\n\n\nLine 2"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
630 let result = rule.check(&ctx).unwrap();
631 assert!(result.is_empty()); }
633
634 #[test]
635 fn test_blank_lines_between_sections() {
636 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 1);
638 let content = "# Section 1\n\nContent\n\n\n# Section 2\n\nContent";
639 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
640 let result = rule.check(&ctx).unwrap();
641 assert!(
642 result.is_empty(),
643 "2 blanks above heading allowed with heading_blanks_above=2"
644 );
645 }
646
647 #[test]
648 fn test_fix_preserves_indented_code() {
649 let rule = MD012NoMultipleBlanks::default();
650 let content = "Text\n\n\n code\n \n more code\n\n\nText";
651 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
652 let fixed = rule.fix(&ctx).unwrap();
653 assert_eq!(fixed, "Text\n\n code\n \n more code\n\nText");
656 }
657
658 #[test]
659 fn test_edge_case_only_blanks() {
660 let rule = MD012NoMultipleBlanks::default();
661 let content = "\n\n\n";
662 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
663 let result = rule.check(&ctx).unwrap();
664 assert_eq!(result.len(), 1);
666 assert!(result[0].message.contains("at end of file"));
667 }
668
669 #[test]
672 fn test_blanks_after_fenced_code_block_mid_document() {
673 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 1);
675 let content = "## Input\n\n```javascript\ncode\n```\n\n\n## Error\n";
676 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
677 let result = rule.check(&ctx).unwrap();
678 assert!(
679 result.is_empty(),
680 "2 blanks before heading allowed with heading_blanks_above=2"
681 );
682 }
683
684 #[test]
685 fn test_blanks_after_code_block_at_eof() {
686 let rule = MD012NoMultipleBlanks::default();
688 let content = "# Heading\n\n```\ncode\n```\n\n\n";
689 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
690 let result = rule.check(&ctx).unwrap();
691 assert_eq!(result.len(), 1, "Should detect trailing blanks after code block");
693 assert!(result[0].message.contains("at end of file"));
694 }
695
696 #[test]
697 fn test_single_blank_after_code_block_allowed() {
698 let rule = MD012NoMultipleBlanks::default();
700 let content = "## Input\n\n```\ncode\n```\n\n## Output\n";
701 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
702 let result = rule.check(&ctx).unwrap();
703 assert!(result.is_empty(), "Single blank after code block should be allowed");
704 }
705
706 #[test]
707 fn test_multiple_code_blocks_with_blanks() {
708 let rule = MD012NoMultipleBlanks::default();
710 let content = "```\ncode1\n```\n\n\n```\ncode2\n```\n\n\nEnd\n";
711 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
712 let result = rule.check(&ctx).unwrap();
713 assert_eq!(result.len(), 2, "Should detect blanks after both code blocks");
715 }
716
717 #[test]
718 fn test_whitespace_only_lines_after_code_block_at_eof() {
719 let rule = MD012NoMultipleBlanks::default();
722 let content = "```\ncode\n```\n \n \n";
723 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
724 let result = rule.check(&ctx).unwrap();
725 assert_eq!(result.len(), 1, "Should detect whitespace-only trailing blanks");
726 assert!(result[0].message.contains("at end of file"));
727 }
728
729 #[test]
732 fn test_warning_fix_removes_single_trailing_blank() {
733 let rule = MD012NoMultipleBlanks::default();
735 let content = "hello foobar hello.\n\n";
736 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
737 let warnings = rule.check(&ctx).unwrap();
738
739 assert_eq!(warnings.len(), 1);
740 assert!(warnings[0].fix.is_some(), "Warning should have a fix attached");
741
742 let fix = warnings[0].fix.as_ref().unwrap();
743 assert_eq!(fix.replacement, "", "Replacement should be empty");
745
746 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
748 assert_eq!(fixed, "hello foobar hello.\n", "Should end with single newline");
749 }
750
751 #[test]
752 fn test_warning_fix_removes_multiple_trailing_blanks() {
753 let rule = MD012NoMultipleBlanks::default();
754 let content = "content\n\n\n\n";
755 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
756 let warnings = rule.check(&ctx).unwrap();
757
758 assert_eq!(warnings.len(), 1);
759 assert!(warnings[0].fix.is_some());
760
761 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
762 assert_eq!(fixed, "content\n", "Should end with single newline");
763 }
764
765 #[test]
766 fn test_warning_fix_preserves_content_newline() {
767 let rule = MD012NoMultipleBlanks::default();
769 let content = "line1\nline2\n\n";
770 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
771 let warnings = rule.check(&ctx).unwrap();
772
773 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
774 assert_eq!(fixed, "line1\nline2\n", "Should preserve all content lines");
775 }
776
777 #[test]
778 fn test_warning_fix_mid_document_blanks() {
779 let rule = MD012NoMultipleBlanks::default();
781 let content = "# Heading\n\n\n\nParagraph\n";
782 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
783 let warnings = rule.check(&ctx).unwrap();
784 assert_eq!(
785 warnings.len(),
786 2,
787 "Excess heading-adjacent blanks flagged with default limits"
788 );
789 }
790
791 #[test]
796 fn test_heading_aware_blanks_below_with_higher_limit() {
797 let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 2);
799 let content = "# Heading\n\n\nParagraph\n";
800 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
801 let result = rule.check(&ctx).unwrap();
802 assert!(
803 result.is_empty(),
804 "2 blanks below heading allowed with heading_blanks_below=2"
805 );
806 }
807
808 #[test]
809 fn test_heading_aware_blanks_above_with_higher_limit() {
810 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 1);
812 let content = "Paragraph\n\n\n# Heading\n";
813 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
814 let result = rule.check(&ctx).unwrap();
815 assert!(
816 result.is_empty(),
817 "2 blanks above heading allowed with heading_blanks_above=2"
818 );
819 }
820
821 #[test]
822 fn test_heading_aware_blanks_between_headings() {
823 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 2);
825 let content = "# Heading 1\n\n\n## Heading 2\n";
826 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
827 let result = rule.check(&ctx).unwrap();
828 assert!(result.is_empty(), "2 blanks between headings allowed with limits=2");
829 }
830
831 #[test]
832 fn test_heading_aware_excess_still_flagged() {
833 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 2);
835 let content = "# Heading\n\n\n\n\nParagraph\n"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
837 let result = rule.check(&ctx).unwrap();
838 assert_eq!(result.len(), 2, "Excess beyond heading limit should be flagged");
839 }
840
841 #[test]
842 fn test_heading_aware_setext_blanks_below() {
843 let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 2);
845 let content = "Heading\n=======\n\n\nParagraph\n";
846 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
847 let result = rule.check(&ctx).unwrap();
848 assert!(result.is_empty(), "2 blanks below Setext heading allowed with limit=2");
849 }
850
851 #[test]
852 fn test_heading_aware_setext_blanks_above() {
853 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 1);
855 let content = "Paragraph\n\n\nHeading\n=======\n";
856 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
857 let result = rule.check(&ctx).unwrap();
858 assert!(result.is_empty(), "2 blanks above Setext heading allowed with limit=2");
859 }
860
861 #[test]
862 fn test_heading_aware_single_blank_allowed() {
863 let rule = MD012NoMultipleBlanks::default();
865 let content = "# Heading\n\nParagraph\n";
866 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
867 let result = rule.check(&ctx).unwrap();
868 assert!(result.is_empty(), "Single blank near heading should be allowed");
869 }
870
871 #[test]
872 fn test_heading_aware_non_heading_blanks_still_flagged() {
873 let rule = MD012NoMultipleBlanks::default();
875 let content = "Paragraph 1\n\n\nParagraph 2\n";
876 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
877 let result = rule.check(&ctx).unwrap();
878 assert_eq!(result.len(), 1, "Non-heading blanks should still be flagged");
879 }
880
881 #[test]
882 fn test_heading_aware_fix_caps_heading_blanks() {
883 let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 2);
885 let content = "# Heading\n\n\n\nParagraph\n"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
887 let fixed = rule.fix(&ctx).unwrap();
888 assert_eq!(
889 fixed, "# Heading\n\n\nParagraph\n",
890 "Fix caps heading-adjacent blanks at effective max (2)"
891 );
892 }
893
894 #[test]
895 fn test_heading_aware_fix_preserves_allowed_heading_blanks() {
896 let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 3);
898 let content = "# Heading\n\n\n\nParagraph\n"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
900 let fixed = rule.fix(&ctx).unwrap();
901 assert_eq!(
902 fixed, "# Heading\n\n\n\nParagraph\n",
903 "Fix preserves blanks within the heading limit"
904 );
905 }
906
907 #[test]
908 fn test_heading_aware_fix_reduces_non_heading_blanks() {
909 let rule = MD012NoMultipleBlanks::default();
911 let content = "Paragraph 1\n\n\n\nParagraph 2\n";
912 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
913 let fixed = rule.fix(&ctx).unwrap();
914 assert_eq!(
915 fixed, "Paragraph 1\n\nParagraph 2\n",
916 "Fix should reduce non-heading blanks"
917 );
918 }
919
920 #[test]
921 fn test_heading_aware_mixed_heading_and_non_heading() {
922 let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 2);
924 let content = "# Heading\n\n\nParagraph 1\n\n\nParagraph 2\n";
925 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
926 let result = rule.check(&ctx).unwrap();
927 assert_eq!(result.len(), 1, "Only non-heading excess should be flagged");
929 }
930
931 #[test]
932 fn test_heading_aware_blanks_at_start_before_heading_still_flagged() {
933 let rule = MD012NoMultipleBlanks::default().with_heading_limits(3, 3);
936 let content = "\n\n\n# Heading\n";
937 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
938 let result = rule.check(&ctx).unwrap();
939 assert_eq!(
940 result.len(),
941 2,
942 "Start-of-file blanks should be flagged even before heading"
943 );
944 assert!(result[0].message.contains("at start of file"));
945 }
946
947 #[test]
948 fn test_heading_aware_eof_blanks_after_heading_still_flagged() {
949 let rule = MD012NoMultipleBlanks::default();
951 let content = "# Heading\n\n";
952 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
953 let result = rule.check(&ctx).unwrap();
954 assert_eq!(result.len(), 1, "EOF blanks should still be flagged");
955 assert!(result[0].message.contains("at end of file"));
956 }
957
958 #[test]
959 fn test_heading_aware_unlimited_heading_blanks() {
960 let rule = MD012NoMultipleBlanks::default().with_heading_limits(usize::MAX, usize::MAX);
962 let content = "# Heading\n\n\n\n\nParagraph\n"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
964 let result = rule.check(&ctx).unwrap();
965 assert!(
966 result.is_empty(),
967 "Unlimited heading limits means MD012 never flags near headings"
968 );
969 }
970
971 #[test]
972 fn test_heading_aware_blanks_after_code_then_heading() {
973 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 2);
975 let content = "# Heading\n\n```\ncode\n```\n\n\n\nMore text\n";
976 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
977 let result = rule.check(&ctx).unwrap();
978 assert_eq!(result.len(), 2, "Non-heading blanks after code block should be flagged");
980 }
981
982 #[test]
983 fn test_heading_aware_fix_mixed_document() {
984 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 2);
986 let content = "# Title\n\n\n## Section\n\n\nPara 1\n\n\nPara 2\n";
987 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
988 let fixed = rule.fix(&ctx).unwrap();
989 assert_eq!(fixed, "# Title\n\n\n## Section\n\n\nPara 1\n\nPara 2\n");
991 }
992
993 #[test]
994 fn test_heading_aware_from_config_reads_md022() {
995 let mut config = crate::config::Config::default();
997 let mut md022_config = crate::config::RuleConfig::default();
998 md022_config
999 .values
1000 .insert("lines-above".to_string(), toml::Value::Integer(2));
1001 md022_config
1002 .values
1003 .insert("lines-below".to_string(), toml::Value::Integer(3));
1004 config.rules.insert("MD022".to_string(), md022_config);
1005
1006 let rule = MD012NoMultipleBlanks::from_config(&config);
1007 let content = "Paragraph\n\n\n# Heading\n";
1009 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1010 let result = rule.check(&ctx).unwrap();
1011 assert!(
1012 result.is_empty(),
1013 "2 blanks above heading allowed when MD022 lines-above=2"
1014 );
1015 }
1016
1017 #[test]
1018 fn test_heading_aware_from_config_md022_disabled() {
1019 let mut config = crate::config::Config::default();
1021 config.global.disable.push("MD022".to_string());
1022
1023 let mut md022_config = crate::config::RuleConfig::default();
1024 md022_config
1025 .values
1026 .insert("lines-above".to_string(), toml::Value::Integer(3));
1027 config.rules.insert("MD022".to_string(), md022_config);
1028
1029 let rule = MD012NoMultipleBlanks::from_config(&config);
1030 let content = "Paragraph\n\n\n# Heading\n";
1032 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1033 let result = rule.check(&ctx).unwrap();
1034 assert_eq!(
1035 result.len(),
1036 1,
1037 "With MD022 disabled, heading-adjacent blanks are flagged"
1038 );
1039 }
1040
1041 #[test]
1042 fn test_heading_aware_from_config_md022_unlimited() {
1043 let mut config = crate::config::Config::default();
1045 let mut md022_config = crate::config::RuleConfig::default();
1046 md022_config
1047 .values
1048 .insert("lines-above".to_string(), toml::Value::Integer(-1));
1049 config.rules.insert("MD022".to_string(), md022_config);
1050
1051 let rule = MD012NoMultipleBlanks::from_config(&config);
1052 let content = "Paragraph\n\n\n\n\n# Heading\n"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1054 let result = rule.check(&ctx).unwrap();
1055 assert!(
1056 result.is_empty(),
1057 "Unlimited MD022 lines-above means MD012 never flags above headings"
1058 );
1059 }
1060
1061 #[test]
1062 fn test_heading_aware_from_config_per_level() {
1063 let mut config = crate::config::Config::default();
1068 let mut md022_config = crate::config::RuleConfig::default();
1069 md022_config.values.insert(
1070 "lines-above".to_string(),
1071 toml::Value::Array(vec![
1072 toml::Value::Integer(2),
1073 toml::Value::Integer(1),
1074 toml::Value::Integer(1),
1075 toml::Value::Integer(1),
1076 toml::Value::Integer(1),
1077 toml::Value::Integer(1),
1078 ]),
1079 );
1080 config.rules.insert("MD022".to_string(), md022_config);
1081
1082 let rule = MD012NoMultipleBlanks::from_config(&config);
1083
1084 let content = "Paragraph\n\n\n## H2 Heading\n";
1086 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1087 let result = rule.check(&ctx).unwrap();
1088 assert!(result.is_empty(), "Per-level max (2) allows 2 blanks above any heading");
1089
1090 let content = "Paragraph\n\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_eq!(result.len(), 1, "3 blanks exceeds per-level max of 2");
1095 }
1096
1097 #[test]
1098 fn test_issue_449_reproduction() {
1099 let rule = MD012NoMultipleBlanks::default();
1102 let content = "\
1103# Heading
1104
1105
1106Some introductory text.
1107
1108
1109
1110
1111
1112## Heading level 2
1113
1114
1115Some text for this section.
1116
1117Some more text for this section.
1118
1119
1120## Another heading level 2
1121
1122
1123
1124Some text for this section.
1125
1126Some more text for this section.
1127";
1128 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1129 let result = rule.check(&ctx).unwrap();
1130 assert!(
1131 !result.is_empty(),
1132 "Issue #449: excess blanks around headings should be flagged with default settings"
1133 );
1134
1135 let fixed = rule.fix(&ctx).unwrap();
1137 let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1138 let recheck = rule.check(&fixed_ctx).unwrap();
1139 assert!(recheck.is_empty(), "Fix should resolve all excess blank lines");
1140
1141 assert!(fixed.contains("# Heading\n\nSome"), "1 blank below first heading");
1143 assert!(
1144 fixed.contains("text.\n\n## Heading level 2"),
1145 "1 blank above second heading"
1146 );
1147 }
1148
1149 #[test]
1152 fn test_blank_lines_in_quarto_callout() {
1153 let rule = MD012NoMultipleBlanks::default();
1155 let content = "# Heading\n\n::: {.callout-note}\nNote content\n\n\nMore content\n:::\n\nAfter";
1156 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1157 let result = rule.check(&ctx).unwrap();
1158 assert!(result.is_empty(), "Should not flag blanks inside Quarto callouts");
1159 }
1160
1161 #[test]
1162 fn test_blank_lines_in_quarto_div() {
1163 let rule = MD012NoMultipleBlanks::default();
1165 let content = "Text\n\n::: {.bordered}\nContent\n\n\nMore\n:::\n\nText";
1166 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1167 let result = rule.check(&ctx).unwrap();
1168 assert!(result.is_empty(), "Should not flag blanks inside Quarto divs");
1169 }
1170
1171 #[test]
1172 fn test_blank_lines_outside_quarto_div_flagged() {
1173 let rule = MD012NoMultipleBlanks::default();
1175 let content = "Text\n\n\n::: {.callout-note}\nNote\n:::\n\n\nMore";
1176 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1177 let result = rule.check(&ctx).unwrap();
1178 assert!(!result.is_empty(), "Should flag blanks outside Quarto divs");
1179 }
1180
1181 #[test]
1182 fn test_quarto_divs_ignored_in_standard_flavor() {
1183 let rule = MD012NoMultipleBlanks::default();
1185 let content = "::: {.callout-note}\nNote content\n\n\nMore content\n:::\n";
1186 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1187 let result = rule.check(&ctx).unwrap();
1188 assert!(!result.is_empty(), "Standard flavor should flag blanks in 'div'");
1190 }
1191
1192 #[test]
1195 fn test_roundtrip_multiple_blank_lines() {
1196 let rule = MD012NoMultipleBlanks::default();
1197 let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
1198 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1199 let fixed = rule.fix(&ctx).unwrap();
1200 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1201 let recheck = rule.check(&ctx2).unwrap();
1202 assert!(
1203 recheck.is_empty(),
1204 "Roundtrip: fix then check should be clean, got {recheck:?}"
1205 );
1206 }
1207
1208 #[test]
1209 fn test_roundtrip_trailing_blanks() {
1210 let rule = MD012NoMultipleBlanks::default();
1211 let content = "Content\n\n\n\n";
1212 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1213 let fixed = rule.fix(&ctx).unwrap();
1214 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1215 let recheck = rule.check(&ctx2).unwrap();
1216 assert!(recheck.is_empty(), "Roundtrip: trailing blanks, got {recheck:?}");
1217 }
1218
1219 #[test]
1220 fn test_roundtrip_leading_blanks() {
1221 let rule = MD012NoMultipleBlanks::default();
1222 let content = "\n\n\nContent\n";
1223 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1224 let fixed = rule.fix(&ctx).unwrap();
1225 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1226 let recheck = rule.check(&ctx2).unwrap();
1227 assert!(recheck.is_empty(), "Roundtrip: leading blanks, got {recheck:?}");
1228 }
1229
1230 #[test]
1231 fn test_roundtrip_custom_maximum() {
1232 let rule = MD012NoMultipleBlanks::new(2);
1233 let content = "Line 1\n\n\n\n\nLine 2\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: max=2, got {recheck:?}");
1239 }
1240
1241 #[test]
1242 fn test_roundtrip_code_blocks() {
1243 let rule = MD012NoMultipleBlanks::default();
1244 let content = "Before\n\n\n```\ncode\n\n\n\nmore code\n```\n\n\nAfter";
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: code blocks, got {recheck:?}");
1250 }
1251
1252 #[test]
1253 fn test_roundtrip_heading_limits() {
1254 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 2);
1255 let content = "# Heading\n\n\n\n\nParagraph\n\n\n\n## Heading 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: heading limits, got {recheck:?}");
1261 }
1262
1263 #[test]
1264 fn test_roundtrip_front_matter() {
1265 let rule = MD012NoMultipleBlanks::default();
1266 let content = "---\ntitle: Test\n\n\nauthor: Me\n---\n\n\n\nContent\n";
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: front matter, got {recheck:?}");
1272 }
1273
1274 #[test]
1275 fn test_roundtrip_only_blanks() {
1276 let rule = MD012NoMultipleBlanks::default();
1277 let content = "\n\n\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: only blanks, got {recheck:?}");
1283 }
1284
1285 #[test]
1286 fn test_roundtrip_single_eof_blank() {
1287 let rule = MD012NoMultipleBlanks::default();
1288 let content = "Content\n\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: single EOF blank, got {recheck:?}");
1294 }
1295
1296 #[test]
1297 fn test_roundtrip_mixed_heading_and_non_heading() {
1298 let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 2);
1299 let content = "# Heading\n\n\n\nParagraph 1\n\n\n\nParagraph 2\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!(
1305 recheck.is_empty(),
1306 "Roundtrip: mixed heading/non-heading, got {recheck:?}"
1307 );
1308 }
1309}