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
12fn is_blank_line(line: &str) -> bool {
16 line.trim_matches([' ', '\t', '\r']).is_empty()
17}
18
19#[derive(Debug, Clone)]
23pub struct MD012NoMultipleBlanks {
24 config: MD012Config,
25 heading_blanks_above: usize,
28 heading_blanks_below: usize,
31}
32
33impl Default for MD012NoMultipleBlanks {
34 fn default() -> Self {
35 Self {
36 config: MD012Config::default(),
37 heading_blanks_above: 1,
38 heading_blanks_below: 1,
39 }
40 }
41}
42
43impl MD012NoMultipleBlanks {
44 pub fn new(maximum: usize) -> Self {
45 use crate::types::PositiveUsize;
46 Self {
47 config: MD012Config {
48 maximum: PositiveUsize::new(maximum).unwrap_or(PositiveUsize::from_const(1)),
49 },
50 heading_blanks_above: 1,
51 heading_blanks_below: 1,
52 }
53 }
54
55 pub const fn from_config_struct(config: MD012Config) -> Self {
56 Self {
57 config,
58 heading_blanks_above: 1,
59 heading_blanks_below: 1,
60 }
61 }
62
63 pub fn with_heading_limits(mut self, above: usize, below: usize) -> Self {
66 self.heading_blanks_above = above;
67 self.heading_blanks_below = below;
68 self
69 }
70
71 fn effective_max_above(&self) -> usize {
75 self.config.maximum.get().max(self.heading_blanks_above)
76 }
77
78 fn effective_max_below(&self) -> usize {
79 self.config.maximum.get().max(self.heading_blanks_below)
80 }
81
82 fn generate_excess_warnings(
84 &self,
85 blank_start: usize,
86 blank_count: usize,
87 effective_max: usize,
88 lines: &[&str],
89 lines_to_check: &HashSet<usize>,
90 ctx: &LintContext,
91 ) -> Vec<LintWarning> {
92 let mut warnings = Vec::new();
93
94 let location = if blank_start == 0 {
95 "at start of file"
96 } else {
97 "between content"
98 };
99
100 for i in effective_max..blank_count {
101 let excess_line_num = blank_start + i;
102 if lines_to_check.contains(&excess_line_num) {
103 let excess_line = excess_line_num + 1;
104 let excess_line_content = lines.get(excess_line_num).unwrap_or(&"");
105 let (start_line, start_col, end_line, end_col) = calculate_line_range(excess_line, excess_line_content);
106 warnings.push(LintWarning {
107 rule_name: Some(self.name().to_string()),
108 severity: Severity::Warning,
109 message: format!("Multiple consecutive blank lines {location}"),
110 line: start_line,
111 column: start_col,
112 end_line,
113 end_column: end_col,
114 fix: Some(Fix::new(
115 {
116 let line_start = ctx.line_start_byte(excess_line).unwrap_or(0);
117 let line_end = ctx.line_start_byte(excess_line + 1).unwrap_or(line_start + 1);
118 line_start..line_end
119 },
120 String::new(),
121 )),
122 });
123 }
124 }
125
126 warnings
127 }
128}
129
130fn is_heading_context(ctx: &LintContext, line_idx: usize) -> bool {
137 if ctx
138 .lines
139 .get(line_idx)
140 .is_some_and(|li| li.heading.is_some() || li.is_setext_heading_text)
141 {
142 return true;
143 }
144 if line_idx > 0
146 && let Some(prev_info) = ctx.lines.get(line_idx - 1)
147 && let Some(ref heading) = prev_info.heading
148 && matches!(heading.style, HeadingStyle::Setext1 | HeadingStyle::Setext2)
149 {
150 return true;
151 }
152 false
153}
154
155fn max_heading_limit(
159 level_config: &crate::rules::md022_blanks_around_headings::md022_config::HeadingLevelConfig,
160) -> usize {
161 let mut max_val: usize = 0;
162 for level in 1..=6 {
163 match level_config.get_for_level(level).required_count() {
164 None => return usize::MAX, Some(count) => max_val = max_val.max(count),
166 }
167 }
168 max_val
169}
170
171impl Rule for MD012NoMultipleBlanks {
172 fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
173 (self.config == Default::default()).then_some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(12))
174 }
175
176 fn name(&self) -> &'static str {
177 "MD012"
178 }
179
180 fn description(&self) -> &'static str {
181 "Multiple consecutive blank lines"
182 }
183
184 fn category(&self) -> RuleCategory {
185 RuleCategory::Whitespace
186 }
187
188 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
189 let content = ctx.content;
190
191 if content.is_empty() {
193 return Ok(Vec::new());
194 }
195
196 let lines = ctx.raw_lines();
199 let has_potential_blanks = lines
200 .windows(2)
201 .any(|pair| is_blank_line(pair[0]) && is_blank_line(pair[1]));
202
203 let ends_with_multiple_newlines = content.ends_with("\n\n");
207
208 if !has_potential_blanks && !ends_with_multiple_newlines {
209 return Ok(Vec::new());
210 }
211
212 let mut warnings = Vec::new();
213
214 let mut blank_count = 0;
216 let mut blank_start = 0;
217 let mut last_line_num: Option<usize> = None;
218 let mut prev_content_line_num: Option<usize> = None;
220
221 let mut lines_to_check: HashSet<usize> = HashSet::new();
223
224 for filtered_line in ctx
231 .filtered_lines()
232 .skip_front_matter()
233 .skip_code_blocks()
234 .skip_html_comments()
235 .skip_html_blocks()
236 .skip_quarto_divs()
237 .skip_math_blocks()
238 .skip_obsidian_comments()
239 .skip_pymdown_blocks()
240 .skip_jsx_expressions()
241 .skip_mdx_comments()
242 .skip_jsx_blocks()
243 .skip_esm_blocks()
244 {
245 let line_num = filtered_line.line_num - 1; let line = filtered_line.content;
247 let info = &ctx.lines[line_num];
248 if is_blank_line(line) && ctx.is_inside_template_code(info.byte_offset + info.indent) {
251 continue;
252 }
253
254 if let Some(last) = last_line_num
257 && line_num > last + 1
258 {
259 let effective_max = if prev_content_line_num.is_some_and(|idx| is_heading_context(ctx, idx)) {
262 self.effective_max_below()
263 } else {
264 self.config.maximum.get()
265 };
266 if blank_count > effective_max {
267 warnings.extend(self.generate_excess_warnings(
268 blank_start,
269 blank_count,
270 effective_max,
271 lines,
272 &lines_to_check,
273 ctx,
274 ));
275 }
276 blank_count = 0;
277 lines_to_check.clear();
278 prev_content_line_num = None;
280 }
281 last_line_num = Some(line_num);
282
283 if is_blank_line(line) {
284 if blank_count == 0 {
285 blank_start = line_num;
286 }
287 blank_count += 1;
288 if blank_count > self.config.maximum.get() {
290 lines_to_check.insert(line_num);
291 }
292 } else {
293 let heading_below = prev_content_line_num.is_some_and(|idx| is_heading_context(ctx, idx));
299 let heading_above = blank_start > 0 && is_heading_context(ctx, line_num);
300 let effective_max = if heading_below && heading_above {
301 self.effective_max_above().max(self.effective_max_below())
303 } else if heading_below {
304 self.effective_max_below()
305 } else if heading_above {
306 self.effective_max_above()
307 } else {
308 self.config.maximum.get()
309 };
310
311 if blank_count > effective_max {
312 warnings.extend(self.generate_excess_warnings(
313 blank_start,
314 blank_count,
315 effective_max,
316 lines,
317 &lines_to_check,
318 ctx,
319 ));
320 }
321 blank_count = 0;
322 lines_to_check.clear();
323 prev_content_line_num = Some(line_num);
324 }
325 }
326
327 let last_line_is_blank = lines.last().is_some_and(|l| is_blank_line(l));
334
335 if blank_count > 0 && !last_line_is_blank {
338 let effective_max = if prev_content_line_num.is_some_and(|idx| is_heading_context(ctx, idx)) {
339 self.effective_max_below()
340 } else {
341 self.config.maximum.get()
342 };
343 if blank_count > effective_max {
344 warnings.extend(self.generate_excess_warnings(
345 blank_start,
346 blank_count,
347 effective_max,
348 lines,
349 &lines_to_check,
350 ctx,
351 ));
352 }
353 }
354
355 if blank_count > 0 && last_line_is_blank {
359 let location = "at end of file";
360
361 let report_line = lines.len();
363
364 let fix_start = ctx.line_start_byte(report_line - blank_count + 1).unwrap_or(0);
367 let fix_end = content.len();
368
369 warnings.push(LintWarning {
371 rule_name: Some(self.name().to_string()),
372 severity: Severity::Warning,
373 message: format!("Multiple consecutive blank lines {location}"),
374 line: report_line,
375 column: 1,
376 end_line: report_line,
377 end_column: 1,
378 fix: Some(Fix::new(fix_start..fix_end, String::new())),
379 });
380 }
381
382 Ok(warnings)
383 }
384
385 fn fixes_from_diagnostics(&self) -> bool {
386 true
387 }
388
389 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
390 if self.should_skip(ctx) {
391 return Ok(ctx.content.to_string());
392 }
393 let warnings = self.check(ctx)?;
394 if warnings.is_empty() {
395 return Ok(ctx.content.to_string());
396 }
397 let warnings =
398 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
399 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
400 .map_err(crate::rule::LintError::InvalidInput)
401 }
402
403 fn as_any(&self) -> &dyn std::any::Any {
404 self
405 }
406
407 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
408 ctx.content.is_empty() || !ctx.has_char('\n')
410 }
411
412 crate::impl_rule_config_sections!(MD012Config);
413
414 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
415 where
416 Self: Sized,
417 {
418 use crate::rules::md022_blanks_around_headings::md022_config::MD022Config;
419
420 let rule_config = crate::rule_config_serde::load_rule_config::<MD012Config>(config);
421
422 let md022_disabled = config.global.disable.iter().any(|r| r == "MD022")
425 || config.global.extend_disable.iter().any(|r| r == "MD022");
426
427 let (heading_above, heading_below) = if md022_disabled {
428 (rule_config.maximum.get(), rule_config.maximum.get())
430 } else {
431 let md022_config = crate::rule_config_serde::load_rule_config::<MD022Config>(config);
432 (
433 max_heading_limit(&md022_config.lines_above),
434 max_heading_limit(&md022_config.lines_below),
435 )
436 };
437
438 Box::new(Self {
439 config: rule_config,
440 heading_blanks_above: heading_above,
441 heading_blanks_below: heading_below,
442 })
443 }
444}
445
446#[cfg(test)]
447mod tests {
448 use super::*;
449 use crate::lint_context::LintContext;
450
451 #[test]
452 fn test_single_blank_line_allowed() {
453 let rule = MD012NoMultipleBlanks::default();
454 let content = "Line 1\n\nLine 2\n\nLine 3";
455 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
456 let result = rule.check(&ctx).unwrap();
457 assert!(result.is_empty());
458 }
459
460 #[test]
461 fn test_multiple_blank_lines_flagged() {
462 let rule = MD012NoMultipleBlanks::default();
463 let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
464 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
465 let result = rule.check(&ctx).unwrap();
466 assert_eq!(result.len(), 3); assert_eq!(result[0].line, 3);
468 assert_eq!(result[1].line, 6);
469 assert_eq!(result[2].line, 7);
470 }
471
472 #[test]
473 fn test_custom_maximum() {
474 let rule = MD012NoMultipleBlanks::new(2);
475 let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
476 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
477 let result = rule.check(&ctx).unwrap();
478 assert_eq!(result.len(), 1); assert_eq!(result[0].line, 7);
480 }
481
482 #[test]
483 fn test_fix_multiple_blank_lines() {
484 let rule = MD012NoMultipleBlanks::default();
485 let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
486 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
487 let fixed = rule.fix(&ctx).unwrap();
488 assert_eq!(fixed, "Line 1\n\nLine 2\n\nLine 3");
489 }
490
491 #[test]
492 fn test_blank_lines_in_code_block() {
493 let rule = MD012NoMultipleBlanks::default();
494 let content = "Before\n\n```\ncode\n\n\n\nmore code\n```\n\nAfter";
495 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
496 let result = rule.check(&ctx).unwrap();
497 assert!(result.is_empty()); }
499
500 #[test]
501 fn test_blank_lines_in_html_comment() {
502 let rule = MD012NoMultipleBlanks::default();
503 let content = "Before\n\n<!--\ncomment\n\n\n\nmore comment\n-->\n\nAfter";
504 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
505 let result = rule.check(&ctx).unwrap();
506 assert!(result.is_empty()); }
508
509 #[test]
510 fn test_blank_lines_in_html_block() {
511 let rule = MD012NoMultipleBlanks::default();
512 let content = "Before\n\n<script>\n\n\n\n</script>\n\nAfter";
513 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
514 let result = rule.check(&ctx).unwrap();
515 assert!(result.is_empty()); }
517
518 #[test]
519 fn test_fix_preserves_code_block_blanks() {
520 let rule = MD012NoMultipleBlanks::default();
521 let content = "Before\n\n\n```\ncode\n\n\n\nmore code\n```\n\n\nAfter";
522 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
523 let fixed = rule.fix(&ctx).unwrap();
524 assert_eq!(fixed, "Before\n\n```\ncode\n\n\n\nmore code\n```\n\nAfter");
525 }
526
527 #[test]
528 fn test_blank_lines_in_front_matter() {
529 let rule = MD012NoMultipleBlanks::default();
530 let content = "---\ntitle: Test\n\n\nauthor: Me\n---\n\nContent";
531 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
532 let result = rule.check(&ctx).unwrap();
533 assert!(result.is_empty()); }
535
536 #[test]
537 fn test_blank_lines_at_start() {
538 let rule = MD012NoMultipleBlanks::default();
539 let content = "\n\n\nContent";
540 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
541 let result = rule.check(&ctx).unwrap();
542 assert_eq!(result.len(), 2);
543 assert!(result[0].message.contains("at start of file"));
544 }
545
546 #[test]
547 fn test_blank_lines_at_end() {
548 let rule = MD012NoMultipleBlanks::default();
549 let content = "Content\n\n\n";
550 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
551 let result = rule.check(&ctx).unwrap();
552 assert_eq!(result.len(), 1);
553 assert!(result[0].message.contains("at end of file"));
554 }
555
556 #[test]
557 fn test_single_blank_at_eof_flagged() {
558 let rule = MD012NoMultipleBlanks::default();
560 let content = "Content\n\n";
561 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
562 let result = rule.check(&ctx).unwrap();
563 assert_eq!(result.len(), 1);
564 assert!(result[0].message.contains("at end of file"));
565 }
566
567 #[test]
568 fn test_whitespace_only_lines() {
569 let rule = MD012NoMultipleBlanks::default();
570 let content = "Line 1\n \n\t\nLine 2";
571 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
572 let result = rule.check(&ctx).unwrap();
573 assert_eq!(result.len(), 1); }
575
576 #[test]
577 fn test_indented_code_blocks() {
578 let rule = MD012NoMultipleBlanks::default();
580 let content = "Text\n\n code\n \n \n more code\n\nText";
581 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
582 let result = rule.check(&ctx).unwrap();
583 assert!(result.is_empty(), "Should not flag blanks inside indented code blocks");
584 }
585
586 #[test]
587 fn test_blanks_in_indented_code_block() {
588 let content = " code line 1\n\n\n code line 2\n";
590 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
591 let rule = MD012NoMultipleBlanks::default();
592 let warnings = rule.check(&ctx).unwrap();
593 assert!(warnings.is_empty(), "Should not flag blanks in indented code");
594 }
595
596 #[test]
597 fn test_blanks_in_indented_code_block_with_heading() {
598 let content = "# Heading\n\n code line 1\n\n\n code line 2\n\nMore text\n";
600 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
601 let rule = MD012NoMultipleBlanks::default();
602 let warnings = rule.check(&ctx).unwrap();
603 assert!(
604 warnings.is_empty(),
605 "Should not flag blanks in indented code after heading"
606 );
607 }
608
609 #[test]
610 fn test_blanks_after_indented_code_block_flagged() {
611 let content = "# Heading\n\n code line\n\n\n\nMore text\n";
613 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
614 let rule = MD012NoMultipleBlanks::default();
615 let warnings = rule.check(&ctx).unwrap();
616 assert_eq!(warnings.len(), 2, "Should flag blanks after indented code block ends");
618 }
619
620 #[test]
621 fn test_fix_with_final_newline() {
622 let rule = MD012NoMultipleBlanks::default();
623 let content = "Line 1\n\n\nLine 2\n";
624 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
625 let fixed = rule.fix(&ctx).unwrap();
626 assert_eq!(fixed, "Line 1\n\nLine 2\n");
627 assert!(fixed.ends_with('\n'));
628 }
629
630 #[test]
631 fn test_empty_content() {
632 let rule = MD012NoMultipleBlanks::default();
633 let content = "";
634 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
635 let result = rule.check(&ctx).unwrap();
636 assert!(result.is_empty());
637 }
638
639 #[test]
640 fn test_nested_code_blocks() {
641 let rule = MD012NoMultipleBlanks::default();
642 let content = "Before\n\n~~~\nouter\n\n```\ninner\n\n\n```\n\n~~~\n\nAfter";
643 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
644 let result = rule.check(&ctx).unwrap();
645 assert!(result.is_empty());
646 }
647
648 #[test]
649 fn test_unclosed_code_block() {
650 let rule = MD012NoMultipleBlanks::default();
651 let content = "Before\n\n```\ncode\n\n\n\nno closing fence";
652 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
653 let result = rule.check(&ctx).unwrap();
654 assert!(result.is_empty()); }
656
657 #[test]
658 fn test_mixed_fence_styles() {
659 let rule = MD012NoMultipleBlanks::default();
660 let content = "Before\n\n```\ncode\n\n\n~~~\n\nAfter";
661 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
662 let result = rule.check(&ctx).unwrap();
663 assert!(result.is_empty()); }
665
666 #[test]
667 fn test_config_from_toml() {
668 let mut config = crate::config::Config::default();
669 let mut rule_config = crate::config::RuleConfig::default();
670 rule_config
671 .values
672 .insert("maximum".to_string(), toml::Value::Integer(3));
673 config.rules.insert("MD012".to_string(), rule_config);
674
675 let rule = MD012NoMultipleBlanks::from_config(&config);
676 let content = "Line 1\n\n\n\nLine 2"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
678 let result = rule.check(&ctx).unwrap();
679 assert!(result.is_empty()); }
681
682 #[test]
683 fn test_blank_lines_between_sections() {
684 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 1);
686 let content = "# Section 1\n\nContent\n\n\n# Section 2\n\nContent";
687 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
688 let result = rule.check(&ctx).unwrap();
689 assert!(
690 result.is_empty(),
691 "2 blanks above heading allowed with heading_blanks_above=2"
692 );
693 }
694
695 #[test]
696 fn test_fix_preserves_indented_code() {
697 let rule = MD012NoMultipleBlanks::default();
698 let content = "Text\n\n\n code\n \n more code\n\n\nText";
699 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
700 let fixed = rule.fix(&ctx).unwrap();
701 assert_eq!(fixed, "Text\n\n code\n \n more code\n\nText");
704 }
705
706 #[test]
707 fn test_edge_case_only_blanks() {
708 let rule = MD012NoMultipleBlanks::default();
709 let content = "\n\n\n";
710 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
711 let result = rule.check(&ctx).unwrap();
712 assert_eq!(result.len(), 1);
714 assert!(result[0].message.contains("at end of file"));
715 }
716
717 #[test]
720 fn test_blanks_after_fenced_code_block_mid_document() {
721 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 1);
723 let content = "## Input\n\n```javascript\ncode\n```\n\n\n## Error\n";
724 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
725 let result = rule.check(&ctx).unwrap();
726 assert!(
727 result.is_empty(),
728 "2 blanks before heading allowed with heading_blanks_above=2"
729 );
730 }
731
732 #[test]
733 fn test_blanks_after_code_block_at_eof() {
734 let rule = MD012NoMultipleBlanks::default();
736 let content = "# Heading\n\n```\ncode\n```\n\n\n";
737 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
738 let result = rule.check(&ctx).unwrap();
739 assert_eq!(result.len(), 1, "Should detect trailing blanks after code block");
741 assert!(result[0].message.contains("at end of file"));
742 }
743
744 #[test]
745 fn test_single_blank_after_code_block_allowed() {
746 let rule = MD012NoMultipleBlanks::default();
748 let content = "## Input\n\n```\ncode\n```\n\n## Output\n";
749 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
750 let result = rule.check(&ctx).unwrap();
751 assert!(result.is_empty(), "Single blank after code block should be allowed");
752 }
753
754 #[test]
755 fn test_multiple_code_blocks_with_blanks() {
756 let rule = MD012NoMultipleBlanks::default();
758 let content = "```\ncode1\n```\n\n\n```\ncode2\n```\n\n\nEnd\n";
759 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
760 let result = rule.check(&ctx).unwrap();
761 assert_eq!(result.len(), 2, "Should detect blanks after both code blocks");
763 }
764
765 #[test]
766 fn test_whitespace_only_lines_after_code_block_at_eof() {
767 let rule = MD012NoMultipleBlanks::default();
770 let content = "```\ncode\n```\n \n \n";
771 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
772 let result = rule.check(&ctx).unwrap();
773 assert_eq!(result.len(), 1, "Should detect whitespace-only trailing blanks");
774 assert!(result[0].message.contains("at end of file"));
775 }
776
777 #[test]
780 fn test_warning_fix_removes_single_trailing_blank() {
781 let rule = MD012NoMultipleBlanks::default();
783 let content = "hello foobar hello.\n\n";
784 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
785 let warnings = rule.check(&ctx).unwrap();
786
787 assert_eq!(warnings.len(), 1);
788 assert!(warnings[0].fix.is_some(), "Warning should have a fix attached");
789
790 let fix = warnings[0].fix.as_ref().unwrap();
791 assert_eq!(fix.replacement, "", "Replacement should be empty");
793
794 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
796 assert_eq!(fixed, "hello foobar hello.\n", "Should end with single newline");
797 }
798
799 #[test]
800 fn test_warning_fix_removes_multiple_trailing_blanks() {
801 let rule = MD012NoMultipleBlanks::default();
802 let content = "content\n\n\n\n";
803 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
804 let warnings = rule.check(&ctx).unwrap();
805
806 assert_eq!(warnings.len(), 1);
807 assert!(warnings[0].fix.is_some());
808
809 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
810 assert_eq!(fixed, "content\n", "Should end with single newline");
811 }
812
813 #[test]
814 fn test_warning_fix_preserves_content_newline() {
815 let rule = MD012NoMultipleBlanks::default();
817 let content = "line1\nline2\n\n";
818 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
819 let warnings = rule.check(&ctx).unwrap();
820
821 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
822 assert_eq!(fixed, "line1\nline2\n", "Should preserve all content lines");
823 }
824
825 #[test]
826 fn test_warning_fix_mid_document_blanks() {
827 let rule = MD012NoMultipleBlanks::default();
829 let content = "# Heading\n\n\n\nParagraph\n";
830 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
831 let warnings = rule.check(&ctx).unwrap();
832 assert_eq!(
833 warnings.len(),
834 2,
835 "Excess heading-adjacent blanks flagged with default limits"
836 );
837 }
838
839 #[test]
844 fn test_heading_aware_blanks_below_with_higher_limit() {
845 let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 2);
847 let content = "# Heading\n\n\nParagraph\n";
848 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
849 let result = rule.check(&ctx).unwrap();
850 assert!(
851 result.is_empty(),
852 "2 blanks below heading allowed with heading_blanks_below=2"
853 );
854 }
855
856 #[test]
857 fn test_heading_aware_blanks_above_with_higher_limit() {
858 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 1);
860 let content = "Paragraph\n\n\n# Heading\n";
861 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
862 let result = rule.check(&ctx).unwrap();
863 assert!(
864 result.is_empty(),
865 "2 blanks above heading allowed with heading_blanks_above=2"
866 );
867 }
868
869 #[test]
870 fn test_heading_aware_blanks_between_headings() {
871 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 2);
873 let content = "# Heading 1\n\n\n## Heading 2\n";
874 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
875 let result = rule.check(&ctx).unwrap();
876 assert!(result.is_empty(), "2 blanks between headings allowed with limits=2");
877 }
878
879 #[test]
880 fn test_heading_aware_excess_still_flagged() {
881 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 2);
883 let content = "# Heading\n\n\n\n\nParagraph\n"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
885 let result = rule.check(&ctx).unwrap();
886 assert_eq!(result.len(), 2, "Excess beyond heading limit should be flagged");
887 }
888
889 #[test]
890 fn test_heading_aware_setext_blanks_below() {
891 let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 2);
893 let content = "Heading\n=======\n\n\nParagraph\n";
894 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
895 let result = rule.check(&ctx).unwrap();
896 assert!(result.is_empty(), "2 blanks below Setext heading allowed with limit=2");
897 }
898
899 #[test]
900 fn test_heading_aware_setext_blanks_above() {
901 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 1);
903 let content = "Paragraph\n\n\nHeading\n=======\n";
904 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
905 let result = rule.check(&ctx).unwrap();
906 assert!(result.is_empty(), "2 blanks above Setext heading allowed with limit=2");
907 }
908
909 #[test]
910 fn test_heading_aware_multi_line_setext_blanks_above() {
911 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 1);
915 let content = "Paragraph\n\n\nFirst line\nsecond line\n=====\n";
916 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
917 let result = rule.check(&ctx).unwrap();
918 assert!(
919 result.is_empty(),
920 "2 blanks above a two-line setext heading allowed with limit=2. Got: {result:?}"
921 );
922 }
923
924 #[test]
925 fn test_heading_aware_single_blank_allowed() {
926 let rule = MD012NoMultipleBlanks::default();
928 let content = "# Heading\n\nParagraph\n";
929 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
930 let result = rule.check(&ctx).unwrap();
931 assert!(result.is_empty(), "Single blank near heading should be allowed");
932 }
933
934 #[test]
935 fn test_heading_aware_non_heading_blanks_still_flagged() {
936 let rule = MD012NoMultipleBlanks::default();
938 let content = "Paragraph 1\n\n\nParagraph 2\n";
939 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
940 let result = rule.check(&ctx).unwrap();
941 assert_eq!(result.len(), 1, "Non-heading blanks should still be flagged");
942 }
943
944 #[test]
945 fn test_heading_aware_fix_caps_heading_blanks() {
946 let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 2);
948 let content = "# Heading\n\n\n\nParagraph\n"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
950 let fixed = rule.fix(&ctx).unwrap();
951 assert_eq!(
952 fixed, "# Heading\n\n\nParagraph\n",
953 "Fix caps heading-adjacent blanks at effective max (2)"
954 );
955 }
956
957 #[test]
958 fn test_heading_aware_fix_preserves_allowed_heading_blanks() {
959 let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 3);
961 let content = "# Heading\n\n\n\nParagraph\n"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
963 let fixed = rule.fix(&ctx).unwrap();
964 assert_eq!(
965 fixed, "# Heading\n\n\n\nParagraph\n",
966 "Fix preserves blanks within the heading limit"
967 );
968 }
969
970 #[test]
971 fn test_heading_aware_fix_reduces_non_heading_blanks() {
972 let rule = MD012NoMultipleBlanks::default();
974 let content = "Paragraph 1\n\n\n\nParagraph 2\n";
975 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
976 let fixed = rule.fix(&ctx).unwrap();
977 assert_eq!(
978 fixed, "Paragraph 1\n\nParagraph 2\n",
979 "Fix should reduce non-heading blanks"
980 );
981 }
982
983 #[test]
984 fn test_heading_aware_mixed_heading_and_non_heading() {
985 let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 2);
987 let content = "# Heading\n\n\nParagraph 1\n\n\nParagraph 2\n";
988 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
989 let result = rule.check(&ctx).unwrap();
990 assert_eq!(result.len(), 1, "Only non-heading excess should be flagged");
992 }
993
994 #[test]
995 fn test_heading_aware_blanks_at_start_before_heading_still_flagged() {
996 let rule = MD012NoMultipleBlanks::default().with_heading_limits(3, 3);
999 let content = "\n\n\n# Heading\n";
1000 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1001 let result = rule.check(&ctx).unwrap();
1002 assert_eq!(
1003 result.len(),
1004 2,
1005 "Start-of-file blanks should be flagged even before heading"
1006 );
1007 assert!(result[0].message.contains("at start of file"));
1008 }
1009
1010 #[test]
1011 fn test_heading_aware_eof_blanks_after_heading_still_flagged() {
1012 let rule = MD012NoMultipleBlanks::default();
1014 let content = "# Heading\n\n";
1015 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1016 let result = rule.check(&ctx).unwrap();
1017 assert_eq!(result.len(), 1, "EOF blanks should still be flagged");
1018 assert!(result[0].message.contains("at end of file"));
1019 }
1020
1021 #[test]
1022 fn test_heading_aware_unlimited_heading_blanks() {
1023 let rule = MD012NoMultipleBlanks::default().with_heading_limits(usize::MAX, usize::MAX);
1025 let content = "# Heading\n\n\n\n\nParagraph\n"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1027 let result = rule.check(&ctx).unwrap();
1028 assert!(
1029 result.is_empty(),
1030 "Unlimited heading limits means MD012 never flags near headings"
1031 );
1032 }
1033
1034 #[test]
1035 fn test_heading_aware_blanks_after_code_then_heading() {
1036 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 2);
1038 let content = "# Heading\n\n```\ncode\n```\n\n\n\nMore text\n";
1039 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1040 let result = rule.check(&ctx).unwrap();
1041 assert_eq!(result.len(), 2, "Non-heading blanks after code block should be flagged");
1043 }
1044
1045 #[test]
1046 fn test_heading_aware_fix_mixed_document() {
1047 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 2);
1049 let content = "# Title\n\n\n## Section\n\n\nPara 1\n\n\nPara 2\n";
1050 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1051 let fixed = rule.fix(&ctx).unwrap();
1052 assert_eq!(fixed, "# Title\n\n\n## Section\n\n\nPara 1\n\nPara 2\n");
1054 }
1055
1056 #[test]
1057 fn test_heading_aware_from_config_reads_md022() {
1058 let mut config = crate::config::Config::default();
1060 let mut md022_config = crate::config::RuleConfig::default();
1061 md022_config
1062 .values
1063 .insert("lines-above".to_string(), toml::Value::Integer(2));
1064 md022_config
1065 .values
1066 .insert("lines-below".to_string(), toml::Value::Integer(3));
1067 config.rules.insert("MD022".to_string(), md022_config);
1068
1069 let rule = MD012NoMultipleBlanks::from_config(&config);
1070 let content = "Paragraph\n\n\n# Heading\n";
1072 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1073 let result = rule.check(&ctx).unwrap();
1074 assert!(
1075 result.is_empty(),
1076 "2 blanks above heading allowed when MD022 lines-above=2"
1077 );
1078 }
1079
1080 #[test]
1081 fn test_heading_aware_from_config_md022_disabled() {
1082 let mut config = crate::config::Config::default();
1084 config.global.disable.push("MD022".to_string());
1085
1086 let mut md022_config = crate::config::RuleConfig::default();
1087 md022_config
1088 .values
1089 .insert("lines-above".to_string(), toml::Value::Integer(3));
1090 config.rules.insert("MD022".to_string(), md022_config);
1091
1092 let rule = MD012NoMultipleBlanks::from_config(&config);
1093 let content = "Paragraph\n\n\n# Heading\n";
1095 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1096 let result = rule.check(&ctx).unwrap();
1097 assert_eq!(
1098 result.len(),
1099 1,
1100 "With MD022 disabled, heading-adjacent blanks are flagged"
1101 );
1102 }
1103
1104 #[test]
1105 fn test_heading_aware_from_config_md022_unlimited() {
1106 let mut config = crate::config::Config::default();
1108 let mut md022_config = crate::config::RuleConfig::default();
1109 md022_config
1110 .values
1111 .insert("lines-above".to_string(), toml::Value::Integer(-1));
1112 config.rules.insert("MD022".to_string(), md022_config);
1113
1114 let rule = MD012NoMultipleBlanks::from_config(&config);
1115 let content = "Paragraph\n\n\n\n\n# Heading\n"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1117 let result = rule.check(&ctx).unwrap();
1118 assert!(
1119 result.is_empty(),
1120 "Unlimited MD022 lines-above means MD012 never flags above headings"
1121 );
1122 }
1123
1124 #[test]
1125 fn test_heading_aware_from_config_per_level() {
1126 let mut config = crate::config::Config::default();
1131 let mut md022_config = crate::config::RuleConfig::default();
1132 md022_config.values.insert(
1133 "lines-above".to_string(),
1134 toml::Value::Array(vec![
1135 toml::Value::Integer(2),
1136 toml::Value::Integer(1),
1137 toml::Value::Integer(1),
1138 toml::Value::Integer(1),
1139 toml::Value::Integer(1),
1140 toml::Value::Integer(1),
1141 ]),
1142 );
1143 config.rules.insert("MD022".to_string(), md022_config);
1144
1145 let rule = MD012NoMultipleBlanks::from_config(&config);
1146
1147 let content = "Paragraph\n\n\n## H2 Heading\n";
1149 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1150 let result = rule.check(&ctx).unwrap();
1151 assert!(result.is_empty(), "Per-level max (2) allows 2 blanks above any heading");
1152
1153 let content = "Paragraph\n\n\n\n## H2 Heading\n";
1155 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1156 let result = rule.check(&ctx).unwrap();
1157 assert_eq!(result.len(), 1, "3 blanks exceeds per-level max of 2");
1158 }
1159
1160 #[test]
1161 fn test_issue_449_reproduction() {
1162 let rule = MD012NoMultipleBlanks::default();
1165 let content = "\
1166# Heading
1167
1168
1169Some introductory text.
1170
1171
1172
1173
1174
1175## Heading level 2
1176
1177
1178Some text for this section.
1179
1180Some more text for this section.
1181
1182
1183## Another heading level 2
1184
1185
1186
1187Some text for this section.
1188
1189Some more text for this section.
1190";
1191 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1192 let result = rule.check(&ctx).unwrap();
1193 assert!(
1194 !result.is_empty(),
1195 "Issue #449: excess blanks around headings should be flagged with default settings"
1196 );
1197
1198 let fixed = rule.fix(&ctx).unwrap();
1200 let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1201 let recheck = rule.check(&fixed_ctx).unwrap();
1202 assert!(recheck.is_empty(), "Fix should resolve all excess blank lines");
1203
1204 assert!(fixed.contains("# Heading\n\nSome"), "1 blank below first heading");
1206 assert!(
1207 fixed.contains("text.\n\n## Heading level 2"),
1208 "1 blank above second heading"
1209 );
1210 }
1211
1212 #[test]
1215 fn test_blank_lines_in_quarto_callout() {
1216 let rule = MD012NoMultipleBlanks::default();
1218 let content = "# Heading\n\n::: {.callout-note}\nNote content\n\n\nMore content\n:::\n\nAfter";
1219 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1220 let result = rule.check(&ctx).unwrap();
1221 assert!(result.is_empty(), "Should not flag blanks inside Quarto callouts");
1222 }
1223
1224 #[test]
1225 fn test_blank_lines_in_quarto_div() {
1226 let rule = MD012NoMultipleBlanks::default();
1228 let content = "Text\n\n::: {.bordered}\nContent\n\n\nMore\n:::\n\nText";
1229 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1230 let result = rule.check(&ctx).unwrap();
1231 assert!(result.is_empty(), "Should not flag blanks inside Quarto divs");
1232 }
1233
1234 #[test]
1235 fn test_blank_lines_outside_quarto_div_flagged() {
1236 let rule = MD012NoMultipleBlanks::default();
1238 let content = "Text\n\n\n::: {.callout-note}\nNote\n:::\n\n\nMore";
1239 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1240 let result = rule.check(&ctx).unwrap();
1241 assert!(!result.is_empty(), "Should flag blanks outside Quarto divs");
1242 }
1243
1244 #[test]
1245 fn test_quarto_divs_ignored_in_standard_flavor() {
1246 let rule = MD012NoMultipleBlanks::default();
1248 let content = "::: {.callout-note}\nNote content\n\n\nMore content\n:::\n";
1249 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1250 let result = rule.check(&ctx).unwrap();
1251 assert!(!result.is_empty(), "Standard flavor should flag blanks in 'div'");
1253 }
1254
1255 #[test]
1258 fn test_roundtrip_multiple_blank_lines() {
1259 let rule = MD012NoMultipleBlanks::default();
1260 let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
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!(
1266 recheck.is_empty(),
1267 "Roundtrip: fix then check should be clean, got {recheck:?}"
1268 );
1269 }
1270
1271 #[test]
1272 fn test_roundtrip_trailing_blanks() {
1273 let rule = MD012NoMultipleBlanks::default();
1274 let content = "Content\n\n\n\n";
1275 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1276 let fixed = rule.fix(&ctx).unwrap();
1277 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1278 let recheck = rule.check(&ctx2).unwrap();
1279 assert!(recheck.is_empty(), "Roundtrip: trailing blanks, got {recheck:?}");
1280 }
1281
1282 #[test]
1283 fn test_roundtrip_leading_blanks() {
1284 let rule = MD012NoMultipleBlanks::default();
1285 let content = "\n\n\nContent\n";
1286 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1287 let fixed = rule.fix(&ctx).unwrap();
1288 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1289 let recheck = rule.check(&ctx2).unwrap();
1290 assert!(recheck.is_empty(), "Roundtrip: leading blanks, got {recheck:?}");
1291 }
1292
1293 #[test]
1294 fn test_roundtrip_custom_maximum() {
1295 let rule = MD012NoMultipleBlanks::new(2);
1296 let content = "Line 1\n\n\n\n\nLine 2\n";
1297 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1298 let fixed = rule.fix(&ctx).unwrap();
1299 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1300 let recheck = rule.check(&ctx2).unwrap();
1301 assert!(recheck.is_empty(), "Roundtrip: max=2, got {recheck:?}");
1302 }
1303
1304 #[test]
1305 fn test_roundtrip_code_blocks() {
1306 let rule = MD012NoMultipleBlanks::default();
1307 let content = "Before\n\n\n```\ncode\n\n\n\nmore code\n```\n\n\nAfter";
1308 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1309 let fixed = rule.fix(&ctx).unwrap();
1310 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1311 let recheck = rule.check(&ctx2).unwrap();
1312 assert!(recheck.is_empty(), "Roundtrip: code blocks, got {recheck:?}");
1313 }
1314
1315 #[test]
1316 fn test_roundtrip_heading_limits() {
1317 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 2);
1318 let content = "# Heading\n\n\n\n\nParagraph\n\n\n\n## Heading 2\n";
1319 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1320 let fixed = rule.fix(&ctx).unwrap();
1321 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1322 let recheck = rule.check(&ctx2).unwrap();
1323 assert!(recheck.is_empty(), "Roundtrip: heading limits, got {recheck:?}");
1324 }
1325
1326 #[test]
1327 fn test_roundtrip_front_matter() {
1328 let rule = MD012NoMultipleBlanks::default();
1329 let content = "---\ntitle: Test\n\n\nauthor: Me\n---\n\n\n\nContent\n";
1330 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1331 let fixed = rule.fix(&ctx).unwrap();
1332 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1333 let recheck = rule.check(&ctx2).unwrap();
1334 assert!(recheck.is_empty(), "Roundtrip: front matter, got {recheck:?}");
1335 }
1336
1337 #[test]
1338 fn test_roundtrip_only_blanks() {
1339 let rule = MD012NoMultipleBlanks::default();
1340 let content = "\n\n\n";
1341 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1342 let fixed = rule.fix(&ctx).unwrap();
1343 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1344 let recheck = rule.check(&ctx2).unwrap();
1345 assert!(recheck.is_empty(), "Roundtrip: only blanks, got {recheck:?}");
1346 }
1347
1348 #[test]
1349 fn test_roundtrip_single_eof_blank() {
1350 let rule = MD012NoMultipleBlanks::default();
1351 let content = "Content\n\n";
1352 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1353 let fixed = rule.fix(&ctx).unwrap();
1354 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1355 let recheck = rule.check(&ctx2).unwrap();
1356 assert!(recheck.is_empty(), "Roundtrip: single EOF blank, got {recheck:?}");
1357 }
1358
1359 #[test]
1360 fn test_roundtrip_mixed_heading_and_non_heading() {
1361 let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 2);
1362 let content = "# Heading\n\n\n\nParagraph 1\n\n\n\nParagraph 2\n";
1363 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1364 let fixed = rule.fix(&ctx).unwrap();
1365 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1366 let recheck = rule.check(&ctx2).unwrap();
1367 assert!(
1368 recheck.is_empty(),
1369 "Roundtrip: mixed heading/non-heading, got {recheck:?}"
1370 );
1371 }
1372
1373 #[test]
1374 fn literal_template_blank_lines_are_preserved_beside_prose_fixes() {
1375 let rule = MD012NoMultipleBlanks::default();
1376 for (flavor, code) in [
1377 (
1378 crate::config::MarkdownFlavor::Standard,
1379 "{% set unused=\"first\n\n\nlast\" %}{{ unused|length }}",
1380 ),
1381 (
1382 crate::config::MarkdownFlavor::Hugo,
1383 "{{< note title=`first\n\n\nlast` >}}",
1384 ),
1385 ] {
1386 for ending in ["\n", "\r\n"] {
1387 let source = format!("{code}\n\n\nVisible.\n").replace('\n', ending);
1388 let expected = format!("{code}\n\nVisible.\n").replace('\n', ending);
1389 let ctx = LintContext::new(&source, flavor, None);
1390 let warnings = rule.check(&ctx).unwrap();
1391 assert_eq!(warnings.len(), 1, "{source}");
1392 assert_eq!(warnings[0].line, code.lines().count() + 2);
1393 assert_eq!(rule.fix(&ctx).unwrap(), expected);
1394 }
1395 }
1396 }
1397
1398 #[test]
1399 fn whitespace_only_template_lines_do_not_join_prose_blank_runs() {
1400 let rule = MD012NoMultipleBlanks::default();
1401 for code in [
1402 "{% set unused=\"first\n \n \nlast\" %}{{ unused|length }}",
1403 "{{< note title=`first\n \n \nlast` >}}",
1404 ] {
1405 let flavor = if code.starts_with("{{<") {
1406 crate::config::MarkdownFlavor::Hugo
1407 } else {
1408 crate::config::MarkdownFlavor::Standard
1409 };
1410 let source = format!("Before.\n\n\n{code}\n\n\nAfter.\n\n");
1411 let expected = format!("Before.\n\n{code}\n\nAfter.\n");
1412 let ctx = LintContext::new(&source, flavor, None);
1413 assert_eq!(rule.check(&ctx).unwrap().len(), 3, "{source}");
1414 assert_eq!(rule.fix(&ctx).unwrap(), expected);
1415 }
1416 }
1417
1418 #[test]
1419 fn template_boundaries_keep_ordinary_blank_limits_and_inline_config() {
1420 let rule = MD012NoMultipleBlanks::default();
1421 for (source, expected) in [
1422 (
1423 "{% if enabled %}\nVisible.\n\n\nText.\n{% endif %}\n",
1424 "{% if enabled %}\nVisible.\n\nText.\n{% endif %}\n",
1425 ),
1426 ("{{ title }}\n\n\nVisible.\n", "{{ title }}\n\nVisible.\n"),
1427 ("{{ title }}\n\n", "{{ title }}\n"),
1428 (
1429 "<!-- rumdl-disable MD012 -->\n{{ title }}\n\n\nVisible.\n",
1430 "<!-- rumdl-disable MD012 -->\n{{ title }}\n\n\nVisible.\n",
1431 ),
1432 ] {
1433 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1434 assert_eq!(rule.fix(&ctx).unwrap(), expected);
1435 }
1436 let rule = rule.with_heading_limits(3, 3);
1437 let source = "# Heading\n\n\n{% set unused=\"first\n\nlast\" %}{{ unused|length }}\n\n\n# Next\n\nBody.\n";
1438 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1439 assert!(rule.check(&ctx).unwrap().is_empty());
1440 assert_eq!(rule.fix(&ctx).unwrap(), source);
1441 }
1442
1443 #[test]
1444 fn test_unicode_space_line_is_a_paragraph_line_not_blank() {
1445 let rule = MD012NoMultipleBlanks::default();
1446 for src in ["a\n\n\u{a0}\n\nb\n", "a\n\n\u{2003}\n\n\nb\n\u{3000}\n"] {
1447 let ctx = LintContext::new(src, crate::config::MarkdownFlavor::Standard, None);
1448 let fixed = rule.fix(&ctx).unwrap();
1449 for c in ['\u{a0}', '\u{2003}', '\u{3000}'] {
1450 assert_eq!(fixed.matches(c).count(), src.matches(c).count(), "{src:?} -> {fixed:?}");
1451 }
1452 }
1453 }
1454
1455 #[test]
1456 fn test_ascii_blank_lines_beside_unicode_space_line_are_still_collapsed() {
1457 let rule = MD012NoMultipleBlanks::default();
1458 let src = "a\n\n \t\n\n\u{a0}\nb\n";
1459 let ctx = LintContext::new(src, crate::config::MarkdownFlavor::Standard, None);
1460 assert_eq!(rule.fix(&ctx).unwrap(), "a\n\n\u{a0}\nb\n");
1461 }
1462}