1use crate::filtered_lines::FilteredLinesExt;
2use crate::lint_context::LintContext;
3use crate::lint_context::types::HeadingStyle;
4use crate::utils::range_utils::calculate_line_range;
5use std::collections::HashSet;
6
7use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
8
9mod md012_config;
10use md012_config::MD012Config;
11
12#[derive(Debug, Clone)]
17pub struct MD012NoMultipleBlanks {
18 config: MD012Config,
19 heading_blanks_above: usize,
22 heading_blanks_below: usize,
25}
26
27impl Default for MD012NoMultipleBlanks {
28 fn default() -> Self {
29 Self {
30 config: MD012Config::default(),
31 heading_blanks_above: 1,
32 heading_blanks_below: 1,
33 }
34 }
35}
36
37impl MD012NoMultipleBlanks {
38 pub fn new(maximum: usize) -> Self {
39 use crate::types::PositiveUsize;
40 Self {
41 config: MD012Config {
42 maximum: PositiveUsize::new(maximum).unwrap_or(PositiveUsize::from_const(1)),
43 },
44 heading_blanks_above: 1,
45 heading_blanks_below: 1,
46 }
47 }
48
49 pub const fn from_config_struct(config: MD012Config) -> Self {
50 Self {
51 config,
52 heading_blanks_above: 1,
53 heading_blanks_below: 1,
54 }
55 }
56
57 pub fn with_heading_limits(mut self, above: usize, below: usize) -> Self {
60 self.heading_blanks_above = above;
61 self.heading_blanks_below = below;
62 self
63 }
64
65 fn effective_max_above(&self) -> usize {
69 self.config.maximum.get().max(self.heading_blanks_above)
70 }
71
72 fn effective_max_below(&self) -> usize {
73 self.config.maximum.get().max(self.heading_blanks_below)
74 }
75
76 fn generate_excess_warnings(
78 &self,
79 blank_start: usize,
80 blank_count: usize,
81 effective_max: usize,
82 lines: &[&str],
83 lines_to_check: &HashSet<usize>,
84 ctx: &LintContext,
85 ) -> Vec<LintWarning> {
86 let mut warnings = Vec::new();
87
88 let location = if blank_start == 0 {
89 "at start of file"
90 } else {
91 "between content"
92 };
93
94 for i in effective_max..blank_count {
95 let excess_line_num = blank_start + i;
96 if lines_to_check.contains(&excess_line_num) {
97 let excess_line = excess_line_num + 1;
98 let excess_line_content = lines.get(excess_line_num).unwrap_or(&"");
99 let (start_line, start_col, end_line, end_col) = calculate_line_range(excess_line, excess_line_content);
100 warnings.push(LintWarning {
101 rule_name: Some(self.name().to_string()),
102 severity: Severity::Warning,
103 message: format!("Multiple consecutive blank lines {location}"),
104 line: start_line,
105 column: start_col,
106 end_line,
107 end_column: end_col,
108 fix: Some(Fix::new(
109 {
110 let line_start = ctx.line_start_byte(excess_line).unwrap_or(0);
111 let line_end = ctx.line_start_byte(excess_line + 1).unwrap_or(line_start + 1);
112 line_start..line_end
113 },
114 String::new(),
115 )),
116 });
117 }
118 }
119
120 warnings
121 }
122}
123
124fn is_heading_context(ctx: &LintContext, line_idx: usize) -> bool {
131 if ctx
132 .lines
133 .get(line_idx)
134 .is_some_and(|li| li.heading.is_some() || li.is_setext_heading_text)
135 {
136 return true;
137 }
138 if line_idx > 0
140 && let Some(prev_info) = ctx.lines.get(line_idx - 1)
141 && let Some(ref heading) = prev_info.heading
142 && matches!(heading.style, HeadingStyle::Setext1 | HeadingStyle::Setext2)
143 {
144 return true;
145 }
146 false
147}
148
149fn max_heading_limit(
153 level_config: &crate::rules::md022_blanks_around_headings::md022_config::HeadingLevelConfig,
154) -> usize {
155 let mut max_val: usize = 0;
156 for level in 1..=6 {
157 match level_config.get_for_level(level).required_count() {
158 None => return usize::MAX, Some(count) => max_val = max_val.max(count),
160 }
161 }
162 max_val
163}
164
165impl Rule for MD012NoMultipleBlanks {
166 fn name(&self) -> &'static str {
167 "MD012"
168 }
169
170 fn description(&self) -> &'static str {
171 "Multiple consecutive blank lines"
172 }
173
174 fn category(&self) -> RuleCategory {
175 RuleCategory::Whitespace
176 }
177
178 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
179 let content = ctx.content;
180
181 if content.is_empty() {
183 return Ok(Vec::new());
184 }
185
186 let lines = ctx.raw_lines();
189 let has_potential_blanks = lines
190 .windows(2)
191 .any(|pair| pair[0].trim().is_empty() && pair[1].trim().is_empty());
192
193 let ends_with_multiple_newlines = content.ends_with("\n\n");
197
198 if !has_potential_blanks && !ends_with_multiple_newlines {
199 return Ok(Vec::new());
200 }
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_html_comments()
225 .skip_html_blocks()
226 .skip_quarto_divs()
227 .skip_math_blocks()
228 .skip_obsidian_comments()
229 .skip_pymdown_blocks()
230 .skip_jsx_expressions()
231 .skip_mdx_comments()
232 .skip_jsx_blocks()
233 .skip_esm_blocks()
234 {
235 let line_num = filtered_line.line_num - 1; let line = filtered_line.content;
237 let info = &ctx.lines[line_num];
238 if line.trim().is_empty() && ctx.is_inside_template_code(info.byte_offset + info.indent) {
241 continue;
242 }
243
244 if let Some(last) = last_line_num
247 && line_num > last + 1
248 {
249 let effective_max = if prev_content_line_num.is_some_and(|idx| is_heading_context(ctx, idx)) {
252 self.effective_max_below()
253 } else {
254 self.config.maximum.get()
255 };
256 if blank_count > effective_max {
257 warnings.extend(self.generate_excess_warnings(
258 blank_start,
259 blank_count,
260 effective_max,
261 lines,
262 &lines_to_check,
263 ctx,
264 ));
265 }
266 blank_count = 0;
267 lines_to_check.clear();
268 prev_content_line_num = None;
270 }
271 last_line_num = Some(line_num);
272
273 if line.trim().is_empty() {
274 if blank_count == 0 {
275 blank_start = line_num;
276 }
277 blank_count += 1;
278 if blank_count > self.config.maximum.get() {
280 lines_to_check.insert(line_num);
281 }
282 } else {
283 let heading_below = prev_content_line_num.is_some_and(|idx| is_heading_context(ctx, idx));
289 let heading_above = blank_start > 0 && is_heading_context(ctx, line_num);
290 let effective_max = if heading_below && heading_above {
291 self.effective_max_above().max(self.effective_max_below())
293 } else if heading_below {
294 self.effective_max_below()
295 } else if heading_above {
296 self.effective_max_above()
297 } else {
298 self.config.maximum.get()
299 };
300
301 if blank_count > effective_max {
302 warnings.extend(self.generate_excess_warnings(
303 blank_start,
304 blank_count,
305 effective_max,
306 lines,
307 &lines_to_check,
308 ctx,
309 ));
310 }
311 blank_count = 0;
312 lines_to_check.clear();
313 prev_content_line_num = Some(line_num);
314 }
315 }
316
317 let last_line_is_blank = lines.last().is_some_and(|l| l.trim().is_empty());
324
325 if blank_count > 0 && !last_line_is_blank {
328 let effective_max = if prev_content_line_num.is_some_and(|idx| is_heading_context(ctx, idx)) {
329 self.effective_max_below()
330 } else {
331 self.config.maximum.get()
332 };
333 if blank_count > effective_max {
334 warnings.extend(self.generate_excess_warnings(
335 blank_start,
336 blank_count,
337 effective_max,
338 lines,
339 &lines_to_check,
340 ctx,
341 ));
342 }
343 }
344
345 if blank_count > 0 && last_line_is_blank {
349 let location = "at end of file";
350
351 let report_line = lines.len();
353
354 let fix_start = ctx.line_start_byte(report_line - blank_count + 1).unwrap_or(0);
357 let fix_end = content.len();
358
359 warnings.push(LintWarning {
361 rule_name: Some(self.name().to_string()),
362 severity: Severity::Warning,
363 message: format!("Multiple consecutive blank lines {location}"),
364 line: report_line,
365 column: 1,
366 end_line: report_line,
367 end_column: 1,
368 fix: Some(Fix::new(fix_start..fix_end, String::new())),
369 });
370 }
371
372 Ok(warnings)
373 }
374
375 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
376 if self.should_skip(ctx) {
377 return Ok(ctx.content.to_string());
378 }
379 let warnings = self.check(ctx)?;
380 if warnings.is_empty() {
381 return Ok(ctx.content.to_string());
382 }
383 let warnings =
384 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
385 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
386 .map_err(crate::rule::LintError::InvalidInput)
387 }
388
389 fn as_any(&self) -> &dyn std::any::Any {
390 self
391 }
392
393 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
394 ctx.content.is_empty() || !ctx.has_char('\n')
396 }
397
398 crate::impl_rule_config_sections!(MD012Config);
399
400 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
401 where
402 Self: Sized,
403 {
404 use crate::rules::md022_blanks_around_headings::md022_config::MD022Config;
405
406 let rule_config = crate::rule_config_serde::load_rule_config::<MD012Config>(config);
407
408 let md022_disabled = config.global.disable.iter().any(|r| r == "MD022")
411 || config.global.extend_disable.iter().any(|r| r == "MD022");
412
413 let (heading_above, heading_below) = if md022_disabled {
414 (rule_config.maximum.get(), rule_config.maximum.get())
416 } else {
417 let md022_config = crate::rule_config_serde::load_rule_config::<MD022Config>(config);
418 (
419 max_heading_limit(&md022_config.lines_above),
420 max_heading_limit(&md022_config.lines_below),
421 )
422 };
423
424 Box::new(Self {
425 config: rule_config,
426 heading_blanks_above: heading_above,
427 heading_blanks_below: heading_below,
428 })
429 }
430}
431
432#[cfg(test)]
433mod tests {
434 use super::*;
435 use crate::lint_context::LintContext;
436
437 #[test]
438 fn test_single_blank_line_allowed() {
439 let rule = MD012NoMultipleBlanks::default();
440 let content = "Line 1\n\nLine 2\n\nLine 3";
441 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
442 let result = rule.check(&ctx).unwrap();
443 assert!(result.is_empty());
444 }
445
446 #[test]
447 fn test_multiple_blank_lines_flagged() {
448 let rule = MD012NoMultipleBlanks::default();
449 let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
450 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
451 let result = rule.check(&ctx).unwrap();
452 assert_eq!(result.len(), 3); assert_eq!(result[0].line, 3);
454 assert_eq!(result[1].line, 6);
455 assert_eq!(result[2].line, 7);
456 }
457
458 #[test]
459 fn test_custom_maximum() {
460 let rule = MD012NoMultipleBlanks::new(2);
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 result = rule.check(&ctx).unwrap();
464 assert_eq!(result.len(), 1); assert_eq!(result[0].line, 7);
466 }
467
468 #[test]
469 fn test_fix_multiple_blank_lines() {
470 let rule = MD012NoMultipleBlanks::default();
471 let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
472 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
473 let fixed = rule.fix(&ctx).unwrap();
474 assert_eq!(fixed, "Line 1\n\nLine 2\n\nLine 3");
475 }
476
477 #[test]
478 fn test_blank_lines_in_code_block() {
479 let rule = MD012NoMultipleBlanks::default();
480 let content = "Before\n\n```\ncode\n\n\n\nmore code\n```\n\nAfter";
481 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
482 let result = rule.check(&ctx).unwrap();
483 assert!(result.is_empty()); }
485
486 #[test]
487 fn test_blank_lines_in_html_comment() {
488 let rule = MD012NoMultipleBlanks::default();
489 let content = "Before\n\n<!--\ncomment\n\n\n\nmore comment\n-->\n\nAfter";
490 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
491 let result = rule.check(&ctx).unwrap();
492 assert!(result.is_empty()); }
494
495 #[test]
496 fn test_blank_lines_in_html_block() {
497 let rule = MD012NoMultipleBlanks::default();
498 let content = "Before\n\n<script>\n\n\n\n</script>\n\nAfter";
499 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
500 let result = rule.check(&ctx).unwrap();
501 assert!(result.is_empty()); }
503
504 #[test]
505 fn test_fix_preserves_code_block_blanks() {
506 let rule = MD012NoMultipleBlanks::default();
507 let content = "Before\n\n\n```\ncode\n\n\n\nmore code\n```\n\n\nAfter";
508 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
509 let fixed = rule.fix(&ctx).unwrap();
510 assert_eq!(fixed, "Before\n\n```\ncode\n\n\n\nmore code\n```\n\nAfter");
511 }
512
513 #[test]
514 fn test_blank_lines_in_front_matter() {
515 let rule = MD012NoMultipleBlanks::default();
516 let content = "---\ntitle: Test\n\n\nauthor: Me\n---\n\nContent";
517 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
518 let result = rule.check(&ctx).unwrap();
519 assert!(result.is_empty()); }
521
522 #[test]
523 fn test_blank_lines_at_start() {
524 let rule = MD012NoMultipleBlanks::default();
525 let content = "\n\n\nContent";
526 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
527 let result = rule.check(&ctx).unwrap();
528 assert_eq!(result.len(), 2);
529 assert!(result[0].message.contains("at start of file"));
530 }
531
532 #[test]
533 fn test_blank_lines_at_end() {
534 let rule = MD012NoMultipleBlanks::default();
535 let content = "Content\n\n\n";
536 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
537 let result = rule.check(&ctx).unwrap();
538 assert_eq!(result.len(), 1);
539 assert!(result[0].message.contains("at end of file"));
540 }
541
542 #[test]
543 fn test_single_blank_at_eof_flagged() {
544 let rule = MD012NoMultipleBlanks::default();
546 let content = "Content\n\n";
547 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
548 let result = rule.check(&ctx).unwrap();
549 assert_eq!(result.len(), 1);
550 assert!(result[0].message.contains("at end of file"));
551 }
552
553 #[test]
554 fn test_whitespace_only_lines() {
555 let rule = MD012NoMultipleBlanks::default();
556 let content = "Line 1\n \n\t\nLine 2";
557 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
558 let result = rule.check(&ctx).unwrap();
559 assert_eq!(result.len(), 1); }
561
562 #[test]
563 fn test_indented_code_blocks() {
564 let rule = MD012NoMultipleBlanks::default();
566 let content = "Text\n\n code\n \n \n more code\n\nText";
567 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
568 let result = rule.check(&ctx).unwrap();
569 assert!(result.is_empty(), "Should not flag blanks inside indented code blocks");
570 }
571
572 #[test]
573 fn test_blanks_in_indented_code_block() {
574 let content = " code line 1\n\n\n code line 2\n";
576 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
577 let rule = MD012NoMultipleBlanks::default();
578 let warnings = rule.check(&ctx).unwrap();
579 assert!(warnings.is_empty(), "Should not flag blanks in indented code");
580 }
581
582 #[test]
583 fn test_blanks_in_indented_code_block_with_heading() {
584 let content = "# Heading\n\n code line 1\n\n\n code line 2\n\nMore text\n";
586 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
587 let rule = MD012NoMultipleBlanks::default();
588 let warnings = rule.check(&ctx).unwrap();
589 assert!(
590 warnings.is_empty(),
591 "Should not flag blanks in indented code after heading"
592 );
593 }
594
595 #[test]
596 fn test_blanks_after_indented_code_block_flagged() {
597 let content = "# Heading\n\n code line\n\n\n\nMore text\n";
599 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
600 let rule = MD012NoMultipleBlanks::default();
601 let warnings = rule.check(&ctx).unwrap();
602 assert_eq!(warnings.len(), 2, "Should flag blanks after indented code block ends");
604 }
605
606 #[test]
607 fn test_fix_with_final_newline() {
608 let rule = MD012NoMultipleBlanks::default();
609 let content = "Line 1\n\n\nLine 2\n";
610 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
611 let fixed = rule.fix(&ctx).unwrap();
612 assert_eq!(fixed, "Line 1\n\nLine 2\n");
613 assert!(fixed.ends_with('\n'));
614 }
615
616 #[test]
617 fn test_empty_content() {
618 let rule = MD012NoMultipleBlanks::default();
619 let content = "";
620 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
621 let result = rule.check(&ctx).unwrap();
622 assert!(result.is_empty());
623 }
624
625 #[test]
626 fn test_nested_code_blocks() {
627 let rule = MD012NoMultipleBlanks::default();
628 let content = "Before\n\n~~~\nouter\n\n```\ninner\n\n\n```\n\n~~~\n\nAfter";
629 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
630 let result = rule.check(&ctx).unwrap();
631 assert!(result.is_empty());
632 }
633
634 #[test]
635 fn test_unclosed_code_block() {
636 let rule = MD012NoMultipleBlanks::default();
637 let content = "Before\n\n```\ncode\n\n\n\nno closing fence";
638 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_mixed_fence_styles() {
645 let rule = MD012NoMultipleBlanks::default();
646 let content = "Before\n\n```\ncode\n\n\n~~~\n\nAfter";
647 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
648 let result = rule.check(&ctx).unwrap();
649 assert!(result.is_empty()); }
651
652 #[test]
653 fn test_config_from_toml() {
654 let mut config = crate::config::Config::default();
655 let mut rule_config = crate::config::RuleConfig::default();
656 rule_config
657 .values
658 .insert("maximum".to_string(), toml::Value::Integer(3));
659 config.rules.insert("MD012".to_string(), rule_config);
660
661 let rule = MD012NoMultipleBlanks::from_config(&config);
662 let content = "Line 1\n\n\n\nLine 2"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
664 let result = rule.check(&ctx).unwrap();
665 assert!(result.is_empty()); }
667
668 #[test]
669 fn test_blank_lines_between_sections() {
670 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 1);
672 let content = "# Section 1\n\nContent\n\n\n# Section 2\n\nContent";
673 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
674 let result = rule.check(&ctx).unwrap();
675 assert!(
676 result.is_empty(),
677 "2 blanks above heading allowed with heading_blanks_above=2"
678 );
679 }
680
681 #[test]
682 fn test_fix_preserves_indented_code() {
683 let rule = MD012NoMultipleBlanks::default();
684 let content = "Text\n\n\n code\n \n more code\n\n\nText";
685 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
686 let fixed = rule.fix(&ctx).unwrap();
687 assert_eq!(fixed, "Text\n\n code\n \n more code\n\nText");
690 }
691
692 #[test]
693 fn test_edge_case_only_blanks() {
694 let rule = MD012NoMultipleBlanks::default();
695 let content = "\n\n\n";
696 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
697 let result = rule.check(&ctx).unwrap();
698 assert_eq!(result.len(), 1);
700 assert!(result[0].message.contains("at end of file"));
701 }
702
703 #[test]
706 fn test_blanks_after_fenced_code_block_mid_document() {
707 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 1);
709 let content = "## Input\n\n```javascript\ncode\n```\n\n\n## Error\n";
710 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
711 let result = rule.check(&ctx).unwrap();
712 assert!(
713 result.is_empty(),
714 "2 blanks before heading allowed with heading_blanks_above=2"
715 );
716 }
717
718 #[test]
719 fn test_blanks_after_code_block_at_eof() {
720 let rule = MD012NoMultipleBlanks::default();
722 let content = "# Heading\n\n```\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 trailing blanks after code block");
727 assert!(result[0].message.contains("at end of file"));
728 }
729
730 #[test]
731 fn test_single_blank_after_code_block_allowed() {
732 let rule = MD012NoMultipleBlanks::default();
734 let content = "## Input\n\n```\ncode\n```\n\n## Output\n";
735 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
736 let result = rule.check(&ctx).unwrap();
737 assert!(result.is_empty(), "Single blank after code block should be allowed");
738 }
739
740 #[test]
741 fn test_multiple_code_blocks_with_blanks() {
742 let rule = MD012NoMultipleBlanks::default();
744 let content = "```\ncode1\n```\n\n\n```\ncode2\n```\n\n\nEnd\n";
745 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
746 let result = rule.check(&ctx).unwrap();
747 assert_eq!(result.len(), 2, "Should detect blanks after both code blocks");
749 }
750
751 #[test]
752 fn test_whitespace_only_lines_after_code_block_at_eof() {
753 let rule = MD012NoMultipleBlanks::default();
756 let content = "```\ncode\n```\n \n \n";
757 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
758 let result = rule.check(&ctx).unwrap();
759 assert_eq!(result.len(), 1, "Should detect whitespace-only trailing blanks");
760 assert!(result[0].message.contains("at end of file"));
761 }
762
763 #[test]
766 fn test_warning_fix_removes_single_trailing_blank() {
767 let rule = MD012NoMultipleBlanks::default();
769 let content = "hello foobar hello.\n\n";
770 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
771 let warnings = rule.check(&ctx).unwrap();
772
773 assert_eq!(warnings.len(), 1);
774 assert!(warnings[0].fix.is_some(), "Warning should have a fix attached");
775
776 let fix = warnings[0].fix.as_ref().unwrap();
777 assert_eq!(fix.replacement, "", "Replacement should be empty");
779
780 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
782 assert_eq!(fixed, "hello foobar hello.\n", "Should end with single newline");
783 }
784
785 #[test]
786 fn test_warning_fix_removes_multiple_trailing_blanks() {
787 let rule = MD012NoMultipleBlanks::default();
788 let content = "content\n\n\n\n";
789 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
790 let warnings = rule.check(&ctx).unwrap();
791
792 assert_eq!(warnings.len(), 1);
793 assert!(warnings[0].fix.is_some());
794
795 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
796 assert_eq!(fixed, "content\n", "Should end with single newline");
797 }
798
799 #[test]
800 fn test_warning_fix_preserves_content_newline() {
801 let rule = MD012NoMultipleBlanks::default();
803 let content = "line1\nline2\n\n";
804 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
805 let warnings = rule.check(&ctx).unwrap();
806
807 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
808 assert_eq!(fixed, "line1\nline2\n", "Should preserve all content lines");
809 }
810
811 #[test]
812 fn test_warning_fix_mid_document_blanks() {
813 let rule = MD012NoMultipleBlanks::default();
815 let content = "# Heading\n\n\n\nParagraph\n";
816 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
817 let warnings = rule.check(&ctx).unwrap();
818 assert_eq!(
819 warnings.len(),
820 2,
821 "Excess heading-adjacent blanks flagged with default limits"
822 );
823 }
824
825 #[test]
830 fn test_heading_aware_blanks_below_with_higher_limit() {
831 let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 2);
833 let content = "# Heading\n\n\nParagraph\n";
834 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
835 let result = rule.check(&ctx).unwrap();
836 assert!(
837 result.is_empty(),
838 "2 blanks below heading allowed with heading_blanks_below=2"
839 );
840 }
841
842 #[test]
843 fn test_heading_aware_blanks_above_with_higher_limit() {
844 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 1);
846 let content = "Paragraph\n\n\n# Heading\n";
847 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
848 let result = rule.check(&ctx).unwrap();
849 assert!(
850 result.is_empty(),
851 "2 blanks above heading allowed with heading_blanks_above=2"
852 );
853 }
854
855 #[test]
856 fn test_heading_aware_blanks_between_headings() {
857 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 2);
859 let content = "# Heading 1\n\n\n## Heading 2\n";
860 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
861 let result = rule.check(&ctx).unwrap();
862 assert!(result.is_empty(), "2 blanks between headings allowed with limits=2");
863 }
864
865 #[test]
866 fn test_heading_aware_excess_still_flagged() {
867 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 2);
869 let content = "# Heading\n\n\n\n\nParagraph\n"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
871 let result = rule.check(&ctx).unwrap();
872 assert_eq!(result.len(), 2, "Excess beyond heading limit should be flagged");
873 }
874
875 #[test]
876 fn test_heading_aware_setext_blanks_below() {
877 let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 2);
879 let content = "Heading\n=======\n\n\nParagraph\n";
880 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
881 let result = rule.check(&ctx).unwrap();
882 assert!(result.is_empty(), "2 blanks below Setext heading allowed with limit=2");
883 }
884
885 #[test]
886 fn test_heading_aware_setext_blanks_above() {
887 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 1);
889 let content = "Paragraph\n\n\nHeading\n=======\n";
890 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
891 let result = rule.check(&ctx).unwrap();
892 assert!(result.is_empty(), "2 blanks above Setext heading allowed with limit=2");
893 }
894
895 #[test]
896 fn test_heading_aware_multi_line_setext_blanks_above() {
897 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 1);
901 let content = "Paragraph\n\n\nFirst line\nsecond line\n=====\n";
902 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
903 let result = rule.check(&ctx).unwrap();
904 assert!(
905 result.is_empty(),
906 "2 blanks above a two-line setext heading allowed with limit=2. Got: {result:?}"
907 );
908 }
909
910 #[test]
911 fn test_heading_aware_single_blank_allowed() {
912 let rule = MD012NoMultipleBlanks::default();
914 let content = "# Heading\n\nParagraph\n";
915 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
916 let result = rule.check(&ctx).unwrap();
917 assert!(result.is_empty(), "Single blank near heading should be allowed");
918 }
919
920 #[test]
921 fn test_heading_aware_non_heading_blanks_still_flagged() {
922 let rule = MD012NoMultipleBlanks::default();
924 let content = "Paragraph 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, "Non-heading blanks should still be flagged");
928 }
929
930 #[test]
931 fn test_heading_aware_fix_caps_heading_blanks() {
932 let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 2);
934 let content = "# Heading\n\n\n\nParagraph\n"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
936 let fixed = rule.fix(&ctx).unwrap();
937 assert_eq!(
938 fixed, "# Heading\n\n\nParagraph\n",
939 "Fix caps heading-adjacent blanks at effective max (2)"
940 );
941 }
942
943 #[test]
944 fn test_heading_aware_fix_preserves_allowed_heading_blanks() {
945 let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 3);
947 let content = "# Heading\n\n\n\nParagraph\n"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
949 let fixed = rule.fix(&ctx).unwrap();
950 assert_eq!(
951 fixed, "# Heading\n\n\n\nParagraph\n",
952 "Fix preserves blanks within the heading limit"
953 );
954 }
955
956 #[test]
957 fn test_heading_aware_fix_reduces_non_heading_blanks() {
958 let rule = MD012NoMultipleBlanks::default();
960 let content = "Paragraph 1\n\n\n\nParagraph 2\n";
961 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
962 let fixed = rule.fix(&ctx).unwrap();
963 assert_eq!(
964 fixed, "Paragraph 1\n\nParagraph 2\n",
965 "Fix should reduce non-heading blanks"
966 );
967 }
968
969 #[test]
970 fn test_heading_aware_mixed_heading_and_non_heading() {
971 let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 2);
973 let content = "# Heading\n\n\nParagraph 1\n\n\nParagraph 2\n";
974 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
975 let result = rule.check(&ctx).unwrap();
976 assert_eq!(result.len(), 1, "Only non-heading excess should be flagged");
978 }
979
980 #[test]
981 fn test_heading_aware_blanks_at_start_before_heading_still_flagged() {
982 let rule = MD012NoMultipleBlanks::default().with_heading_limits(3, 3);
985 let content = "\n\n\n# Heading\n";
986 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
987 let result = rule.check(&ctx).unwrap();
988 assert_eq!(
989 result.len(),
990 2,
991 "Start-of-file blanks should be flagged even before heading"
992 );
993 assert!(result[0].message.contains("at start of file"));
994 }
995
996 #[test]
997 fn test_heading_aware_eof_blanks_after_heading_still_flagged() {
998 let rule = MD012NoMultipleBlanks::default();
1000 let content = "# Heading\n\n";
1001 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1002 let result = rule.check(&ctx).unwrap();
1003 assert_eq!(result.len(), 1, "EOF blanks should still be flagged");
1004 assert!(result[0].message.contains("at end of file"));
1005 }
1006
1007 #[test]
1008 fn test_heading_aware_unlimited_heading_blanks() {
1009 let rule = MD012NoMultipleBlanks::default().with_heading_limits(usize::MAX, usize::MAX);
1011 let content = "# Heading\n\n\n\n\nParagraph\n"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1013 let result = rule.check(&ctx).unwrap();
1014 assert!(
1015 result.is_empty(),
1016 "Unlimited heading limits means MD012 never flags near headings"
1017 );
1018 }
1019
1020 #[test]
1021 fn test_heading_aware_blanks_after_code_then_heading() {
1022 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 2);
1024 let content = "# Heading\n\n```\ncode\n```\n\n\n\nMore text\n";
1025 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1026 let result = rule.check(&ctx).unwrap();
1027 assert_eq!(result.len(), 2, "Non-heading blanks after code block should be flagged");
1029 }
1030
1031 #[test]
1032 fn test_heading_aware_fix_mixed_document() {
1033 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 2);
1035 let content = "# Title\n\n\n## Section\n\n\nPara 1\n\n\nPara 2\n";
1036 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1037 let fixed = rule.fix(&ctx).unwrap();
1038 assert_eq!(fixed, "# Title\n\n\n## Section\n\n\nPara 1\n\nPara 2\n");
1040 }
1041
1042 #[test]
1043 fn test_heading_aware_from_config_reads_md022() {
1044 let mut config = crate::config::Config::default();
1046 let mut md022_config = crate::config::RuleConfig::default();
1047 md022_config
1048 .values
1049 .insert("lines-above".to_string(), toml::Value::Integer(2));
1050 md022_config
1051 .values
1052 .insert("lines-below".to_string(), toml::Value::Integer(3));
1053 config.rules.insert("MD022".to_string(), md022_config);
1054
1055 let rule = MD012NoMultipleBlanks::from_config(&config);
1056 let content = "Paragraph\n\n\n# Heading\n";
1058 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1059 let result = rule.check(&ctx).unwrap();
1060 assert!(
1061 result.is_empty(),
1062 "2 blanks above heading allowed when MD022 lines-above=2"
1063 );
1064 }
1065
1066 #[test]
1067 fn test_heading_aware_from_config_md022_disabled() {
1068 let mut config = crate::config::Config::default();
1070 config.global.disable.push("MD022".to_string());
1071
1072 let mut md022_config = crate::config::RuleConfig::default();
1073 md022_config
1074 .values
1075 .insert("lines-above".to_string(), toml::Value::Integer(3));
1076 config.rules.insert("MD022".to_string(), md022_config);
1077
1078 let rule = MD012NoMultipleBlanks::from_config(&config);
1079 let content = "Paragraph\n\n\n# Heading\n";
1081 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1082 let result = rule.check(&ctx).unwrap();
1083 assert_eq!(
1084 result.len(),
1085 1,
1086 "With MD022 disabled, heading-adjacent blanks are flagged"
1087 );
1088 }
1089
1090 #[test]
1091 fn test_heading_aware_from_config_md022_unlimited() {
1092 let mut config = crate::config::Config::default();
1094 let mut md022_config = crate::config::RuleConfig::default();
1095 md022_config
1096 .values
1097 .insert("lines-above".to_string(), toml::Value::Integer(-1));
1098 config.rules.insert("MD022".to_string(), md022_config);
1099
1100 let rule = MD012NoMultipleBlanks::from_config(&config);
1101 let content = "Paragraph\n\n\n\n\n# Heading\n"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1103 let result = rule.check(&ctx).unwrap();
1104 assert!(
1105 result.is_empty(),
1106 "Unlimited MD022 lines-above means MD012 never flags above headings"
1107 );
1108 }
1109
1110 #[test]
1111 fn test_heading_aware_from_config_per_level() {
1112 let mut config = crate::config::Config::default();
1117 let mut md022_config = crate::config::RuleConfig::default();
1118 md022_config.values.insert(
1119 "lines-above".to_string(),
1120 toml::Value::Array(vec![
1121 toml::Value::Integer(2),
1122 toml::Value::Integer(1),
1123 toml::Value::Integer(1),
1124 toml::Value::Integer(1),
1125 toml::Value::Integer(1),
1126 toml::Value::Integer(1),
1127 ]),
1128 );
1129 config.rules.insert("MD022".to_string(), md022_config);
1130
1131 let rule = MD012NoMultipleBlanks::from_config(&config);
1132
1133 let content = "Paragraph\n\n\n## H2 Heading\n";
1135 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1136 let result = rule.check(&ctx).unwrap();
1137 assert!(result.is_empty(), "Per-level max (2) allows 2 blanks above any heading");
1138
1139 let content = "Paragraph\n\n\n\n## H2 Heading\n";
1141 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1142 let result = rule.check(&ctx).unwrap();
1143 assert_eq!(result.len(), 1, "3 blanks exceeds per-level max of 2");
1144 }
1145
1146 #[test]
1147 fn test_issue_449_reproduction() {
1148 let rule = MD012NoMultipleBlanks::default();
1151 let content = "\
1152# Heading
1153
1154
1155Some introductory text.
1156
1157
1158
1159
1160
1161## Heading level 2
1162
1163
1164Some text for this section.
1165
1166Some more text for this section.
1167
1168
1169## Another heading level 2
1170
1171
1172
1173Some text for this section.
1174
1175Some more text for this section.
1176";
1177 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1178 let result = rule.check(&ctx).unwrap();
1179 assert!(
1180 !result.is_empty(),
1181 "Issue #449: excess blanks around headings should be flagged with default settings"
1182 );
1183
1184 let fixed = rule.fix(&ctx).unwrap();
1186 let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1187 let recheck = rule.check(&fixed_ctx).unwrap();
1188 assert!(recheck.is_empty(), "Fix should resolve all excess blank lines");
1189
1190 assert!(fixed.contains("# Heading\n\nSome"), "1 blank below first heading");
1192 assert!(
1193 fixed.contains("text.\n\n## Heading level 2"),
1194 "1 blank above second heading"
1195 );
1196 }
1197
1198 #[test]
1201 fn test_blank_lines_in_quarto_callout() {
1202 let rule = MD012NoMultipleBlanks::default();
1204 let content = "# Heading\n\n::: {.callout-note}\nNote content\n\n\nMore content\n:::\n\nAfter";
1205 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1206 let result = rule.check(&ctx).unwrap();
1207 assert!(result.is_empty(), "Should not flag blanks inside Quarto callouts");
1208 }
1209
1210 #[test]
1211 fn test_blank_lines_in_quarto_div() {
1212 let rule = MD012NoMultipleBlanks::default();
1214 let content = "Text\n\n::: {.bordered}\nContent\n\n\nMore\n:::\n\nText";
1215 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1216 let result = rule.check(&ctx).unwrap();
1217 assert!(result.is_empty(), "Should not flag blanks inside Quarto divs");
1218 }
1219
1220 #[test]
1221 fn test_blank_lines_outside_quarto_div_flagged() {
1222 let rule = MD012NoMultipleBlanks::default();
1224 let content = "Text\n\n\n::: {.callout-note}\nNote\n:::\n\n\nMore";
1225 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1226 let result = rule.check(&ctx).unwrap();
1227 assert!(!result.is_empty(), "Should flag blanks outside Quarto divs");
1228 }
1229
1230 #[test]
1231 fn test_quarto_divs_ignored_in_standard_flavor() {
1232 let rule = MD012NoMultipleBlanks::default();
1234 let content = "::: {.callout-note}\nNote content\n\n\nMore content\n:::\n";
1235 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1236 let result = rule.check(&ctx).unwrap();
1237 assert!(!result.is_empty(), "Standard flavor should flag blanks in 'div'");
1239 }
1240
1241 #[test]
1244 fn test_roundtrip_multiple_blank_lines() {
1245 let rule = MD012NoMultipleBlanks::default();
1246 let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
1247 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1248 let fixed = rule.fix(&ctx).unwrap();
1249 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1250 let recheck = rule.check(&ctx2).unwrap();
1251 assert!(
1252 recheck.is_empty(),
1253 "Roundtrip: fix then check should be clean, got {recheck:?}"
1254 );
1255 }
1256
1257 #[test]
1258 fn test_roundtrip_trailing_blanks() {
1259 let rule = MD012NoMultipleBlanks::default();
1260 let content = "Content\n\n\n\n";
1261 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1262 let fixed = rule.fix(&ctx).unwrap();
1263 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1264 let recheck = rule.check(&ctx2).unwrap();
1265 assert!(recheck.is_empty(), "Roundtrip: trailing blanks, got {recheck:?}");
1266 }
1267
1268 #[test]
1269 fn test_roundtrip_leading_blanks() {
1270 let rule = MD012NoMultipleBlanks::default();
1271 let content = "\n\n\nContent\n";
1272 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1273 let fixed = rule.fix(&ctx).unwrap();
1274 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1275 let recheck = rule.check(&ctx2).unwrap();
1276 assert!(recheck.is_empty(), "Roundtrip: leading blanks, got {recheck:?}");
1277 }
1278
1279 #[test]
1280 fn test_roundtrip_custom_maximum() {
1281 let rule = MD012NoMultipleBlanks::new(2);
1282 let content = "Line 1\n\n\n\n\nLine 2\n";
1283 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1284 let fixed = rule.fix(&ctx).unwrap();
1285 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1286 let recheck = rule.check(&ctx2).unwrap();
1287 assert!(recheck.is_empty(), "Roundtrip: max=2, got {recheck:?}");
1288 }
1289
1290 #[test]
1291 fn test_roundtrip_code_blocks() {
1292 let rule = MD012NoMultipleBlanks::default();
1293 let content = "Before\n\n\n```\ncode\n\n\n\nmore code\n```\n\n\nAfter";
1294 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1295 let fixed = rule.fix(&ctx).unwrap();
1296 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1297 let recheck = rule.check(&ctx2).unwrap();
1298 assert!(recheck.is_empty(), "Roundtrip: code blocks, got {recheck:?}");
1299 }
1300
1301 #[test]
1302 fn test_roundtrip_heading_limits() {
1303 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 2);
1304 let content = "# Heading\n\n\n\n\nParagraph\n\n\n\n## Heading 2\n";
1305 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1306 let fixed = rule.fix(&ctx).unwrap();
1307 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1308 let recheck = rule.check(&ctx2).unwrap();
1309 assert!(recheck.is_empty(), "Roundtrip: heading limits, got {recheck:?}");
1310 }
1311
1312 #[test]
1313 fn test_roundtrip_front_matter() {
1314 let rule = MD012NoMultipleBlanks::default();
1315 let content = "---\ntitle: Test\n\n\nauthor: Me\n---\n\n\n\nContent\n";
1316 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1317 let fixed = rule.fix(&ctx).unwrap();
1318 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1319 let recheck = rule.check(&ctx2).unwrap();
1320 assert!(recheck.is_empty(), "Roundtrip: front matter, got {recheck:?}");
1321 }
1322
1323 #[test]
1324 fn test_roundtrip_only_blanks() {
1325 let rule = MD012NoMultipleBlanks::default();
1326 let content = "\n\n\n";
1327 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1328 let fixed = rule.fix(&ctx).unwrap();
1329 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1330 let recheck = rule.check(&ctx2).unwrap();
1331 assert!(recheck.is_empty(), "Roundtrip: only blanks, got {recheck:?}");
1332 }
1333
1334 #[test]
1335 fn test_roundtrip_single_eof_blank() {
1336 let rule = MD012NoMultipleBlanks::default();
1337 let content = "Content\n\n";
1338 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1339 let fixed = rule.fix(&ctx).unwrap();
1340 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1341 let recheck = rule.check(&ctx2).unwrap();
1342 assert!(recheck.is_empty(), "Roundtrip: single EOF blank, got {recheck:?}");
1343 }
1344
1345 #[test]
1346 fn test_roundtrip_mixed_heading_and_non_heading() {
1347 let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 2);
1348 let content = "# Heading\n\n\n\nParagraph 1\n\n\n\nParagraph 2\n";
1349 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1350 let fixed = rule.fix(&ctx).unwrap();
1351 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1352 let recheck = rule.check(&ctx2).unwrap();
1353 assert!(
1354 recheck.is_empty(),
1355 "Roundtrip: mixed heading/non-heading, got {recheck:?}"
1356 );
1357 }
1358
1359 #[test]
1360 fn literal_template_blank_lines_are_preserved_beside_prose_fixes() {
1361 let rule = MD012NoMultipleBlanks::default();
1362 for (flavor, code) in [
1363 (
1364 crate::config::MarkdownFlavor::Standard,
1365 "{% set unused=\"first\n\n\nlast\" %}{{ unused|length }}",
1366 ),
1367 (
1368 crate::config::MarkdownFlavor::Hugo,
1369 "{{< note title=`first\n\n\nlast` >}}",
1370 ),
1371 ] {
1372 for ending in ["\n", "\r\n"] {
1373 let source = format!("{code}\n\n\nVisible.\n").replace('\n', ending);
1374 let expected = format!("{code}\n\nVisible.\n").replace('\n', ending);
1375 let ctx = LintContext::new(&source, flavor, None);
1376 let warnings = rule.check(&ctx).unwrap();
1377 assert_eq!(warnings.len(), 1, "{source}");
1378 assert_eq!(warnings[0].line, code.lines().count() + 2);
1379 assert_eq!(rule.fix(&ctx).unwrap(), expected);
1380 }
1381 }
1382 }
1383
1384 #[test]
1385 fn whitespace_only_template_lines_do_not_join_prose_blank_runs() {
1386 let rule = MD012NoMultipleBlanks::default();
1387 for code in [
1388 "{% set unused=\"first\n \n \nlast\" %}{{ unused|length }}",
1389 "{{< note title=`first\n \n \nlast` >}}",
1390 ] {
1391 let flavor = if code.starts_with("{{<") {
1392 crate::config::MarkdownFlavor::Hugo
1393 } else {
1394 crate::config::MarkdownFlavor::Standard
1395 };
1396 let source = format!("Before.\n\n\n{code}\n\n\nAfter.\n\n");
1397 let expected = format!("Before.\n\n{code}\n\nAfter.\n");
1398 let ctx = LintContext::new(&source, flavor, None);
1399 assert_eq!(rule.check(&ctx).unwrap().len(), 3, "{source}");
1400 assert_eq!(rule.fix(&ctx).unwrap(), expected);
1401 }
1402 }
1403
1404 #[test]
1405 fn template_boundaries_keep_ordinary_blank_limits_and_inline_config() {
1406 let rule = MD012NoMultipleBlanks::default();
1407 for (source, expected) in [
1408 (
1409 "{% if enabled %}\nVisible.\n\n\nText.\n{% endif %}\n",
1410 "{% if enabled %}\nVisible.\n\nText.\n{% endif %}\n",
1411 ),
1412 ("{{ title }}\n\n\nVisible.\n", "{{ title }}\n\nVisible.\n"),
1413 ("{{ title }}\n\n", "{{ title }}\n"),
1414 (
1415 "<!-- rumdl-disable MD012 -->\n{{ title }}\n\n\nVisible.\n",
1416 "<!-- rumdl-disable MD012 -->\n{{ title }}\n\n\nVisible.\n",
1417 ),
1418 ] {
1419 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1420 assert_eq!(rule.fix(&ctx).unwrap(), expected);
1421 }
1422 let rule = rule.with_heading_limits(3, 3);
1423 let source = "# Heading\n\n\n{% set unused=\"first\n\nlast\" %}{{ unused|length }}\n\n\n# Next\n\nBody.\n";
1424 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1425 assert!(rule.check(&ctx).unwrap().is_empty());
1426 assert_eq!(rule.fix(&ctx).unwrap(), source);
1427 }
1428}