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
238 if let Some(last) = last_line_num
241 && line_num > last + 1
242 {
243 let effective_max = if prev_content_line_num.is_some_and(|idx| is_heading_context(ctx, idx)) {
246 self.effective_max_below()
247 } else {
248 self.config.maximum.get()
249 };
250 if blank_count > effective_max {
251 warnings.extend(self.generate_excess_warnings(
252 blank_start,
253 blank_count,
254 effective_max,
255 lines,
256 &lines_to_check,
257 ctx,
258 ));
259 }
260 blank_count = 0;
261 lines_to_check.clear();
262 prev_content_line_num = None;
264 }
265 last_line_num = Some(line_num);
266
267 if line.trim().is_empty() {
268 if blank_count == 0 {
269 blank_start = line_num;
270 }
271 blank_count += 1;
272 if blank_count > self.config.maximum.get() {
274 lines_to_check.insert(line_num);
275 }
276 } else {
277 let heading_below = prev_content_line_num.is_some_and(|idx| is_heading_context(ctx, idx));
283 let heading_above = blank_start > 0 && is_heading_context(ctx, line_num);
284 let effective_max = if heading_below && heading_above {
285 self.effective_max_above().max(self.effective_max_below())
287 } else if heading_below {
288 self.effective_max_below()
289 } else if heading_above {
290 self.effective_max_above()
291 } else {
292 self.config.maximum.get()
293 };
294
295 if blank_count > effective_max {
296 warnings.extend(self.generate_excess_warnings(
297 blank_start,
298 blank_count,
299 effective_max,
300 lines,
301 &lines_to_check,
302 ctx,
303 ));
304 }
305 blank_count = 0;
306 lines_to_check.clear();
307 prev_content_line_num = Some(line_num);
308 }
309 }
310
311 let last_line_is_blank = lines.last().is_some_and(|l| l.trim().is_empty());
318
319 if blank_count > 0 && !last_line_is_blank {
322 let effective_max = if prev_content_line_num.is_some_and(|idx| is_heading_context(ctx, idx)) {
323 self.effective_max_below()
324 } else {
325 self.config.maximum.get()
326 };
327 if blank_count > effective_max {
328 warnings.extend(self.generate_excess_warnings(
329 blank_start,
330 blank_count,
331 effective_max,
332 lines,
333 &lines_to_check,
334 ctx,
335 ));
336 }
337 }
338
339 if blank_count > 0 && last_line_is_blank {
343 let location = "at end of file";
344
345 let report_line = lines.len();
347
348 let fix_start = ctx.line_start_byte(report_line - blank_count + 1).unwrap_or(0);
351 let fix_end = content.len();
352
353 warnings.push(LintWarning {
355 rule_name: Some(self.name().to_string()),
356 severity: Severity::Warning,
357 message: format!("Multiple consecutive blank lines {location}"),
358 line: report_line,
359 column: 1,
360 end_line: report_line,
361 end_column: 1,
362 fix: Some(Fix::new(fix_start..fix_end, String::new())),
363 });
364 }
365
366 Ok(warnings)
367 }
368
369 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
370 if self.should_skip(ctx) {
371 return Ok(ctx.content.to_string());
372 }
373 let warnings = self.check(ctx)?;
374 if warnings.is_empty() {
375 return Ok(ctx.content.to_string());
376 }
377 let warnings =
378 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
379 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
380 .map_err(crate::rule::LintError::InvalidInput)
381 }
382
383 fn as_any(&self) -> &dyn std::any::Any {
384 self
385 }
386
387 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
388 ctx.content.is_empty() || !ctx.has_char('\n')
390 }
391
392 crate::impl_rule_config_sections!(MD012Config);
393
394 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
395 where
396 Self: Sized,
397 {
398 use crate::rules::md022_blanks_around_headings::md022_config::MD022Config;
399
400 let rule_config = crate::rule_config_serde::load_rule_config::<MD012Config>(config);
401
402 let md022_disabled = config.global.disable.iter().any(|r| r == "MD022")
405 || config.global.extend_disable.iter().any(|r| r == "MD022");
406
407 let (heading_above, heading_below) = if md022_disabled {
408 (rule_config.maximum.get(), rule_config.maximum.get())
410 } else {
411 let md022_config = crate::rule_config_serde::load_rule_config::<MD022Config>(config);
412 (
413 max_heading_limit(&md022_config.lines_above),
414 max_heading_limit(&md022_config.lines_below),
415 )
416 };
417
418 Box::new(Self {
419 config: rule_config,
420 heading_blanks_above: heading_above,
421 heading_blanks_below: heading_below,
422 })
423 }
424}
425
426#[cfg(test)]
427mod tests {
428 use super::*;
429 use crate::lint_context::LintContext;
430
431 #[test]
432 fn test_single_blank_line_allowed() {
433 let rule = MD012NoMultipleBlanks::default();
434 let content = "Line 1\n\nLine 2\n\nLine 3";
435 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
436 let result = rule.check(&ctx).unwrap();
437 assert!(result.is_empty());
438 }
439
440 #[test]
441 fn test_multiple_blank_lines_flagged() {
442 let rule = MD012NoMultipleBlanks::default();
443 let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
444 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
445 let result = rule.check(&ctx).unwrap();
446 assert_eq!(result.len(), 3); assert_eq!(result[0].line, 3);
448 assert_eq!(result[1].line, 6);
449 assert_eq!(result[2].line, 7);
450 }
451
452 #[test]
453 fn test_custom_maximum() {
454 let rule = MD012NoMultipleBlanks::new(2);
455 let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
456 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
457 let result = rule.check(&ctx).unwrap();
458 assert_eq!(result.len(), 1); assert_eq!(result[0].line, 7);
460 }
461
462 #[test]
463 fn test_fix_multiple_blank_lines() {
464 let rule = MD012NoMultipleBlanks::default();
465 let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
466 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
467 let fixed = rule.fix(&ctx).unwrap();
468 assert_eq!(fixed, "Line 1\n\nLine 2\n\nLine 3");
469 }
470
471 #[test]
472 fn test_blank_lines_in_code_block() {
473 let rule = MD012NoMultipleBlanks::default();
474 let content = "Before\n\n```\ncode\n\n\n\nmore code\n```\n\nAfter";
475 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
476 let result = rule.check(&ctx).unwrap();
477 assert!(result.is_empty()); }
479
480 #[test]
481 fn test_blank_lines_in_html_comment() {
482 let rule = MD012NoMultipleBlanks::default();
483 let content = "Before\n\n<!--\ncomment\n\n\n\nmore comment\n-->\n\nAfter";
484 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
485 let result = rule.check(&ctx).unwrap();
486 assert!(result.is_empty()); }
488
489 #[test]
490 fn test_blank_lines_in_html_block() {
491 let rule = MD012NoMultipleBlanks::default();
492 let content = "Before\n\n<script>\n\n\n\n</script>\n\nAfter";
493 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
494 let result = rule.check(&ctx).unwrap();
495 assert!(result.is_empty()); }
497
498 #[test]
499 fn test_fix_preserves_code_block_blanks() {
500 let rule = MD012NoMultipleBlanks::default();
501 let content = "Before\n\n\n```\ncode\n\n\n\nmore code\n```\n\n\nAfter";
502 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
503 let fixed = rule.fix(&ctx).unwrap();
504 assert_eq!(fixed, "Before\n\n```\ncode\n\n\n\nmore code\n```\n\nAfter");
505 }
506
507 #[test]
508 fn test_blank_lines_in_front_matter() {
509 let rule = MD012NoMultipleBlanks::default();
510 let content = "---\ntitle: Test\n\n\nauthor: Me\n---\n\nContent";
511 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
512 let result = rule.check(&ctx).unwrap();
513 assert!(result.is_empty()); }
515
516 #[test]
517 fn test_blank_lines_at_start() {
518 let rule = MD012NoMultipleBlanks::default();
519 let content = "\n\n\nContent";
520 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
521 let result = rule.check(&ctx).unwrap();
522 assert_eq!(result.len(), 2);
523 assert!(result[0].message.contains("at start of file"));
524 }
525
526 #[test]
527 fn test_blank_lines_at_end() {
528 let rule = MD012NoMultipleBlanks::default();
529 let content = "Content\n\n\n";
530 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
531 let result = rule.check(&ctx).unwrap();
532 assert_eq!(result.len(), 1);
533 assert!(result[0].message.contains("at end of file"));
534 }
535
536 #[test]
537 fn test_single_blank_at_eof_flagged() {
538 let rule = MD012NoMultipleBlanks::default();
540 let content = "Content\n\n";
541 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
542 let result = rule.check(&ctx).unwrap();
543 assert_eq!(result.len(), 1);
544 assert!(result[0].message.contains("at end of file"));
545 }
546
547 #[test]
548 fn test_whitespace_only_lines() {
549 let rule = MD012NoMultipleBlanks::default();
550 let content = "Line 1\n \n\t\nLine 2";
551 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
552 let result = rule.check(&ctx).unwrap();
553 assert_eq!(result.len(), 1); }
555
556 #[test]
557 fn test_indented_code_blocks() {
558 let rule = MD012NoMultipleBlanks::default();
560 let content = "Text\n\n code\n \n \n more code\n\nText";
561 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
562 let result = rule.check(&ctx).unwrap();
563 assert!(result.is_empty(), "Should not flag blanks inside indented code blocks");
564 }
565
566 #[test]
567 fn test_blanks_in_indented_code_block() {
568 let content = " code line 1\n\n\n code line 2\n";
570 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
571 let rule = MD012NoMultipleBlanks::default();
572 let warnings = rule.check(&ctx).unwrap();
573 assert!(warnings.is_empty(), "Should not flag blanks in indented code");
574 }
575
576 #[test]
577 fn test_blanks_in_indented_code_block_with_heading() {
578 let content = "# Heading\n\n code line 1\n\n\n code line 2\n\nMore text\n";
580 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
581 let rule = MD012NoMultipleBlanks::default();
582 let warnings = rule.check(&ctx).unwrap();
583 assert!(
584 warnings.is_empty(),
585 "Should not flag blanks in indented code after heading"
586 );
587 }
588
589 #[test]
590 fn test_blanks_after_indented_code_block_flagged() {
591 let content = "# Heading\n\n code line\n\n\n\nMore text\n";
593 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
594 let rule = MD012NoMultipleBlanks::default();
595 let warnings = rule.check(&ctx).unwrap();
596 assert_eq!(warnings.len(), 2, "Should flag blanks after indented code block ends");
598 }
599
600 #[test]
601 fn test_fix_with_final_newline() {
602 let rule = MD012NoMultipleBlanks::default();
603 let content = "Line 1\n\n\nLine 2\n";
604 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
605 let fixed = rule.fix(&ctx).unwrap();
606 assert_eq!(fixed, "Line 1\n\nLine 2\n");
607 assert!(fixed.ends_with('\n'));
608 }
609
610 #[test]
611 fn test_empty_content() {
612 let rule = MD012NoMultipleBlanks::default();
613 let content = "";
614 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
615 let result = rule.check(&ctx).unwrap();
616 assert!(result.is_empty());
617 }
618
619 #[test]
620 fn test_nested_code_blocks() {
621 let rule = MD012NoMultipleBlanks::default();
622 let content = "Before\n\n~~~\nouter\n\n```\ninner\n\n\n```\n\n~~~\n\nAfter";
623 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
624 let result = rule.check(&ctx).unwrap();
625 assert!(result.is_empty());
626 }
627
628 #[test]
629 fn test_unclosed_code_block() {
630 let rule = MD012NoMultipleBlanks::default();
631 let content = "Before\n\n```\ncode\n\n\n\nno closing fence";
632 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
633 let result = rule.check(&ctx).unwrap();
634 assert!(result.is_empty()); }
636
637 #[test]
638 fn test_mixed_fence_styles() {
639 let rule = MD012NoMultipleBlanks::default();
640 let content = "Before\n\n```\ncode\n\n\n~~~\n\nAfter";
641 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
642 let result = rule.check(&ctx).unwrap();
643 assert!(result.is_empty()); }
645
646 #[test]
647 fn test_config_from_toml() {
648 let mut config = crate::config::Config::default();
649 let mut rule_config = crate::config::RuleConfig::default();
650 rule_config
651 .values
652 .insert("maximum".to_string(), toml::Value::Integer(3));
653 config.rules.insert("MD012".to_string(), rule_config);
654
655 let rule = MD012NoMultipleBlanks::from_config(&config);
656 let content = "Line 1\n\n\n\nLine 2"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
658 let result = rule.check(&ctx).unwrap();
659 assert!(result.is_empty()); }
661
662 #[test]
663 fn test_blank_lines_between_sections() {
664 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 1);
666 let content = "# Section 1\n\nContent\n\n\n# Section 2\n\nContent";
667 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
668 let result = rule.check(&ctx).unwrap();
669 assert!(
670 result.is_empty(),
671 "2 blanks above heading allowed with heading_blanks_above=2"
672 );
673 }
674
675 #[test]
676 fn test_fix_preserves_indented_code() {
677 let rule = MD012NoMultipleBlanks::default();
678 let content = "Text\n\n\n code\n \n more code\n\n\nText";
679 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
680 let fixed = rule.fix(&ctx).unwrap();
681 assert_eq!(fixed, "Text\n\n code\n \n more code\n\nText");
684 }
685
686 #[test]
687 fn test_edge_case_only_blanks() {
688 let rule = MD012NoMultipleBlanks::default();
689 let content = "\n\n\n";
690 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
691 let result = rule.check(&ctx).unwrap();
692 assert_eq!(result.len(), 1);
694 assert!(result[0].message.contains("at end of file"));
695 }
696
697 #[test]
700 fn test_blanks_after_fenced_code_block_mid_document() {
701 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 1);
703 let content = "## Input\n\n```javascript\ncode\n```\n\n\n## Error\n";
704 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
705 let result = rule.check(&ctx).unwrap();
706 assert!(
707 result.is_empty(),
708 "2 blanks before heading allowed with heading_blanks_above=2"
709 );
710 }
711
712 #[test]
713 fn test_blanks_after_code_block_at_eof() {
714 let rule = MD012NoMultipleBlanks::default();
716 let content = "# Heading\n\n```\ncode\n```\n\n\n";
717 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
718 let result = rule.check(&ctx).unwrap();
719 assert_eq!(result.len(), 1, "Should detect trailing blanks after code block");
721 assert!(result[0].message.contains("at end of file"));
722 }
723
724 #[test]
725 fn test_single_blank_after_code_block_allowed() {
726 let rule = MD012NoMultipleBlanks::default();
728 let content = "## Input\n\n```\ncode\n```\n\n## Output\n";
729 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
730 let result = rule.check(&ctx).unwrap();
731 assert!(result.is_empty(), "Single blank after code block should be allowed");
732 }
733
734 #[test]
735 fn test_multiple_code_blocks_with_blanks() {
736 let rule = MD012NoMultipleBlanks::default();
738 let content = "```\ncode1\n```\n\n\n```\ncode2\n```\n\n\nEnd\n";
739 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
740 let result = rule.check(&ctx).unwrap();
741 assert_eq!(result.len(), 2, "Should detect blanks after both code blocks");
743 }
744
745 #[test]
746 fn test_whitespace_only_lines_after_code_block_at_eof() {
747 let rule = MD012NoMultipleBlanks::default();
750 let content = "```\ncode\n```\n \n \n";
751 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
752 let result = rule.check(&ctx).unwrap();
753 assert_eq!(result.len(), 1, "Should detect whitespace-only trailing blanks");
754 assert!(result[0].message.contains("at end of file"));
755 }
756
757 #[test]
760 fn test_warning_fix_removes_single_trailing_blank() {
761 let rule = MD012NoMultipleBlanks::default();
763 let content = "hello foobar hello.\n\n";
764 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
765 let warnings = rule.check(&ctx).unwrap();
766
767 assert_eq!(warnings.len(), 1);
768 assert!(warnings[0].fix.is_some(), "Warning should have a fix attached");
769
770 let fix = warnings[0].fix.as_ref().unwrap();
771 assert_eq!(fix.replacement, "", "Replacement should be empty");
773
774 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
776 assert_eq!(fixed, "hello foobar hello.\n", "Should end with single newline");
777 }
778
779 #[test]
780 fn test_warning_fix_removes_multiple_trailing_blanks() {
781 let rule = MD012NoMultipleBlanks::default();
782 let content = "content\n\n\n\n";
783 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
784 let warnings = rule.check(&ctx).unwrap();
785
786 assert_eq!(warnings.len(), 1);
787 assert!(warnings[0].fix.is_some());
788
789 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
790 assert_eq!(fixed, "content\n", "Should end with single newline");
791 }
792
793 #[test]
794 fn test_warning_fix_preserves_content_newline() {
795 let rule = MD012NoMultipleBlanks::default();
797 let content = "line1\nline2\n\n";
798 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
799 let warnings = rule.check(&ctx).unwrap();
800
801 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
802 assert_eq!(fixed, "line1\nline2\n", "Should preserve all content lines");
803 }
804
805 #[test]
806 fn test_warning_fix_mid_document_blanks() {
807 let rule = MD012NoMultipleBlanks::default();
809 let content = "# Heading\n\n\n\nParagraph\n";
810 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
811 let warnings = rule.check(&ctx).unwrap();
812 assert_eq!(
813 warnings.len(),
814 2,
815 "Excess heading-adjacent blanks flagged with default limits"
816 );
817 }
818
819 #[test]
824 fn test_heading_aware_blanks_below_with_higher_limit() {
825 let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 2);
827 let content = "# Heading\n\n\nParagraph\n";
828 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
829 let result = rule.check(&ctx).unwrap();
830 assert!(
831 result.is_empty(),
832 "2 blanks below heading allowed with heading_blanks_below=2"
833 );
834 }
835
836 #[test]
837 fn test_heading_aware_blanks_above_with_higher_limit() {
838 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 1);
840 let content = "Paragraph\n\n\n# Heading\n";
841 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
842 let result = rule.check(&ctx).unwrap();
843 assert!(
844 result.is_empty(),
845 "2 blanks above heading allowed with heading_blanks_above=2"
846 );
847 }
848
849 #[test]
850 fn test_heading_aware_blanks_between_headings() {
851 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 2);
853 let content = "# Heading 1\n\n\n## Heading 2\n";
854 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
855 let result = rule.check(&ctx).unwrap();
856 assert!(result.is_empty(), "2 blanks between headings allowed with limits=2");
857 }
858
859 #[test]
860 fn test_heading_aware_excess_still_flagged() {
861 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 2);
863 let content = "# Heading\n\n\n\n\nParagraph\n"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
865 let result = rule.check(&ctx).unwrap();
866 assert_eq!(result.len(), 2, "Excess beyond heading limit should be flagged");
867 }
868
869 #[test]
870 fn test_heading_aware_setext_blanks_below() {
871 let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 2);
873 let content = "Heading\n=======\n\n\nParagraph\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 below Setext heading allowed with limit=2");
877 }
878
879 #[test]
880 fn test_heading_aware_setext_blanks_above() {
881 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 1);
883 let content = "Paragraph\n\n\nHeading\n=======\n";
884 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
885 let result = rule.check(&ctx).unwrap();
886 assert!(result.is_empty(), "2 blanks above Setext heading allowed with limit=2");
887 }
888
889 #[test]
890 fn test_heading_aware_multi_line_setext_blanks_above() {
891 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 1);
895 let content = "Paragraph\n\n\nFirst line\nsecond line\n=====\n";
896 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
897 let result = rule.check(&ctx).unwrap();
898 assert!(
899 result.is_empty(),
900 "2 blanks above a two-line setext heading allowed with limit=2. Got: {result:?}"
901 );
902 }
903
904 #[test]
905 fn test_heading_aware_single_blank_allowed() {
906 let rule = MD012NoMultipleBlanks::default();
908 let content = "# Heading\n\nParagraph\n";
909 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
910 let result = rule.check(&ctx).unwrap();
911 assert!(result.is_empty(), "Single blank near heading should be allowed");
912 }
913
914 #[test]
915 fn test_heading_aware_non_heading_blanks_still_flagged() {
916 let rule = MD012NoMultipleBlanks::default();
918 let content = "Paragraph 1\n\n\nParagraph 2\n";
919 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
920 let result = rule.check(&ctx).unwrap();
921 assert_eq!(result.len(), 1, "Non-heading blanks should still be flagged");
922 }
923
924 #[test]
925 fn test_heading_aware_fix_caps_heading_blanks() {
926 let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 2);
928 let content = "# Heading\n\n\n\nParagraph\n"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
930 let fixed = rule.fix(&ctx).unwrap();
931 assert_eq!(
932 fixed, "# Heading\n\n\nParagraph\n",
933 "Fix caps heading-adjacent blanks at effective max (2)"
934 );
935 }
936
937 #[test]
938 fn test_heading_aware_fix_preserves_allowed_heading_blanks() {
939 let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 3);
941 let content = "# Heading\n\n\n\nParagraph\n"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
943 let fixed = rule.fix(&ctx).unwrap();
944 assert_eq!(
945 fixed, "# Heading\n\n\n\nParagraph\n",
946 "Fix preserves blanks within the heading limit"
947 );
948 }
949
950 #[test]
951 fn test_heading_aware_fix_reduces_non_heading_blanks() {
952 let rule = MD012NoMultipleBlanks::default();
954 let content = "Paragraph 1\n\n\n\nParagraph 2\n";
955 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
956 let fixed = rule.fix(&ctx).unwrap();
957 assert_eq!(
958 fixed, "Paragraph 1\n\nParagraph 2\n",
959 "Fix should reduce non-heading blanks"
960 );
961 }
962
963 #[test]
964 fn test_heading_aware_mixed_heading_and_non_heading() {
965 let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 2);
967 let content = "# Heading\n\n\nParagraph 1\n\n\nParagraph 2\n";
968 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
969 let result = rule.check(&ctx).unwrap();
970 assert_eq!(result.len(), 1, "Only non-heading excess should be flagged");
972 }
973
974 #[test]
975 fn test_heading_aware_blanks_at_start_before_heading_still_flagged() {
976 let rule = MD012NoMultipleBlanks::default().with_heading_limits(3, 3);
979 let content = "\n\n\n# Heading\n";
980 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
981 let result = rule.check(&ctx).unwrap();
982 assert_eq!(
983 result.len(),
984 2,
985 "Start-of-file blanks should be flagged even before heading"
986 );
987 assert!(result[0].message.contains("at start of file"));
988 }
989
990 #[test]
991 fn test_heading_aware_eof_blanks_after_heading_still_flagged() {
992 let rule = MD012NoMultipleBlanks::default();
994 let content = "# Heading\n\n";
995 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
996 let result = rule.check(&ctx).unwrap();
997 assert_eq!(result.len(), 1, "EOF blanks should still be flagged");
998 assert!(result[0].message.contains("at end of file"));
999 }
1000
1001 #[test]
1002 fn test_heading_aware_unlimited_heading_blanks() {
1003 let rule = MD012NoMultipleBlanks::default().with_heading_limits(usize::MAX, usize::MAX);
1005 let content = "# Heading\n\n\n\n\nParagraph\n"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1007 let result = rule.check(&ctx).unwrap();
1008 assert!(
1009 result.is_empty(),
1010 "Unlimited heading limits means MD012 never flags near headings"
1011 );
1012 }
1013
1014 #[test]
1015 fn test_heading_aware_blanks_after_code_then_heading() {
1016 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 2);
1018 let content = "# Heading\n\n```\ncode\n```\n\n\n\nMore text\n";
1019 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1020 let result = rule.check(&ctx).unwrap();
1021 assert_eq!(result.len(), 2, "Non-heading blanks after code block should be flagged");
1023 }
1024
1025 #[test]
1026 fn test_heading_aware_fix_mixed_document() {
1027 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 2);
1029 let content = "# Title\n\n\n## Section\n\n\nPara 1\n\n\nPara 2\n";
1030 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1031 let fixed = rule.fix(&ctx).unwrap();
1032 assert_eq!(fixed, "# Title\n\n\n## Section\n\n\nPara 1\n\nPara 2\n");
1034 }
1035
1036 #[test]
1037 fn test_heading_aware_from_config_reads_md022() {
1038 let mut config = crate::config::Config::default();
1040 let mut md022_config = crate::config::RuleConfig::default();
1041 md022_config
1042 .values
1043 .insert("lines-above".to_string(), toml::Value::Integer(2));
1044 md022_config
1045 .values
1046 .insert("lines-below".to_string(), toml::Value::Integer(3));
1047 config.rules.insert("MD022".to_string(), md022_config);
1048
1049 let rule = MD012NoMultipleBlanks::from_config(&config);
1050 let content = "Paragraph\n\n\n# Heading\n";
1052 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1053 let result = rule.check(&ctx).unwrap();
1054 assert!(
1055 result.is_empty(),
1056 "2 blanks above heading allowed when MD022 lines-above=2"
1057 );
1058 }
1059
1060 #[test]
1061 fn test_heading_aware_from_config_md022_disabled() {
1062 let mut config = crate::config::Config::default();
1064 config.global.disable.push("MD022".to_string());
1065
1066 let mut md022_config = crate::config::RuleConfig::default();
1067 md022_config
1068 .values
1069 .insert("lines-above".to_string(), toml::Value::Integer(3));
1070 config.rules.insert("MD022".to_string(), md022_config);
1071
1072 let rule = MD012NoMultipleBlanks::from_config(&config);
1073 let content = "Paragraph\n\n\n# Heading\n";
1075 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1076 let result = rule.check(&ctx).unwrap();
1077 assert_eq!(
1078 result.len(),
1079 1,
1080 "With MD022 disabled, heading-adjacent blanks are flagged"
1081 );
1082 }
1083
1084 #[test]
1085 fn test_heading_aware_from_config_md022_unlimited() {
1086 let mut config = crate::config::Config::default();
1088 let mut md022_config = crate::config::RuleConfig::default();
1089 md022_config
1090 .values
1091 .insert("lines-above".to_string(), toml::Value::Integer(-1));
1092 config.rules.insert("MD022".to_string(), md022_config);
1093
1094 let rule = MD012NoMultipleBlanks::from_config(&config);
1095 let content = "Paragraph\n\n\n\n\n# Heading\n"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1097 let result = rule.check(&ctx).unwrap();
1098 assert!(
1099 result.is_empty(),
1100 "Unlimited MD022 lines-above means MD012 never flags above headings"
1101 );
1102 }
1103
1104 #[test]
1105 fn test_heading_aware_from_config_per_level() {
1106 let mut config = crate::config::Config::default();
1111 let mut md022_config = crate::config::RuleConfig::default();
1112 md022_config.values.insert(
1113 "lines-above".to_string(),
1114 toml::Value::Array(vec![
1115 toml::Value::Integer(2),
1116 toml::Value::Integer(1),
1117 toml::Value::Integer(1),
1118 toml::Value::Integer(1),
1119 toml::Value::Integer(1),
1120 toml::Value::Integer(1),
1121 ]),
1122 );
1123 config.rules.insert("MD022".to_string(), md022_config);
1124
1125 let rule = MD012NoMultipleBlanks::from_config(&config);
1126
1127 let content = "Paragraph\n\n\n## H2 Heading\n";
1129 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1130 let result = rule.check(&ctx).unwrap();
1131 assert!(result.is_empty(), "Per-level max (2) allows 2 blanks above any heading");
1132
1133 let content = "Paragraph\n\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_eq!(result.len(), 1, "3 blanks exceeds per-level max of 2");
1138 }
1139
1140 #[test]
1141 fn test_issue_449_reproduction() {
1142 let rule = MD012NoMultipleBlanks::default();
1145 let content = "\
1146# Heading
1147
1148
1149Some introductory text.
1150
1151
1152
1153
1154
1155## Heading level 2
1156
1157
1158Some text for this section.
1159
1160Some more text for this section.
1161
1162
1163## Another heading level 2
1164
1165
1166
1167Some text for this section.
1168
1169Some more text for this section.
1170";
1171 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1172 let result = rule.check(&ctx).unwrap();
1173 assert!(
1174 !result.is_empty(),
1175 "Issue #449: excess blanks around headings should be flagged with default settings"
1176 );
1177
1178 let fixed = rule.fix(&ctx).unwrap();
1180 let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1181 let recheck = rule.check(&fixed_ctx).unwrap();
1182 assert!(recheck.is_empty(), "Fix should resolve all excess blank lines");
1183
1184 assert!(fixed.contains("# Heading\n\nSome"), "1 blank below first heading");
1186 assert!(
1187 fixed.contains("text.\n\n## Heading level 2"),
1188 "1 blank above second heading"
1189 );
1190 }
1191
1192 #[test]
1195 fn test_blank_lines_in_quarto_callout() {
1196 let rule = MD012NoMultipleBlanks::default();
1198 let content = "# Heading\n\n::: {.callout-note}\nNote content\n\n\nMore content\n:::\n\nAfter";
1199 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1200 let result = rule.check(&ctx).unwrap();
1201 assert!(result.is_empty(), "Should not flag blanks inside Quarto callouts");
1202 }
1203
1204 #[test]
1205 fn test_blank_lines_in_quarto_div() {
1206 let rule = MD012NoMultipleBlanks::default();
1208 let content = "Text\n\n::: {.bordered}\nContent\n\n\nMore\n:::\n\nText";
1209 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1210 let result = rule.check(&ctx).unwrap();
1211 assert!(result.is_empty(), "Should not flag blanks inside Quarto divs");
1212 }
1213
1214 #[test]
1215 fn test_blank_lines_outside_quarto_div_flagged() {
1216 let rule = MD012NoMultipleBlanks::default();
1218 let content = "Text\n\n\n::: {.callout-note}\nNote\n:::\n\n\nMore";
1219 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1220 let result = rule.check(&ctx).unwrap();
1221 assert!(!result.is_empty(), "Should flag blanks outside Quarto divs");
1222 }
1223
1224 #[test]
1225 fn test_quarto_divs_ignored_in_standard_flavor() {
1226 let rule = MD012NoMultipleBlanks::default();
1228 let content = "::: {.callout-note}\nNote content\n\n\nMore content\n:::\n";
1229 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1230 let result = rule.check(&ctx).unwrap();
1231 assert!(!result.is_empty(), "Standard flavor should flag blanks in 'div'");
1233 }
1234
1235 #[test]
1238 fn test_roundtrip_multiple_blank_lines() {
1239 let rule = MD012NoMultipleBlanks::default();
1240 let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
1241 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1242 let fixed = rule.fix(&ctx).unwrap();
1243 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1244 let recheck = rule.check(&ctx2).unwrap();
1245 assert!(
1246 recheck.is_empty(),
1247 "Roundtrip: fix then check should be clean, got {recheck:?}"
1248 );
1249 }
1250
1251 #[test]
1252 fn test_roundtrip_trailing_blanks() {
1253 let rule = MD012NoMultipleBlanks::default();
1254 let content = "Content\n\n\n\n";
1255 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1256 let fixed = rule.fix(&ctx).unwrap();
1257 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1258 let recheck = rule.check(&ctx2).unwrap();
1259 assert!(recheck.is_empty(), "Roundtrip: trailing blanks, got {recheck:?}");
1260 }
1261
1262 #[test]
1263 fn test_roundtrip_leading_blanks() {
1264 let rule = MD012NoMultipleBlanks::default();
1265 let content = "\n\n\nContent\n";
1266 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1267 let fixed = rule.fix(&ctx).unwrap();
1268 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1269 let recheck = rule.check(&ctx2).unwrap();
1270 assert!(recheck.is_empty(), "Roundtrip: leading blanks, got {recheck:?}");
1271 }
1272
1273 #[test]
1274 fn test_roundtrip_custom_maximum() {
1275 let rule = MD012NoMultipleBlanks::new(2);
1276 let content = "Line 1\n\n\n\n\nLine 2\n";
1277 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1278 let fixed = rule.fix(&ctx).unwrap();
1279 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1280 let recheck = rule.check(&ctx2).unwrap();
1281 assert!(recheck.is_empty(), "Roundtrip: max=2, got {recheck:?}");
1282 }
1283
1284 #[test]
1285 fn test_roundtrip_code_blocks() {
1286 let rule = MD012NoMultipleBlanks::default();
1287 let content = "Before\n\n\n```\ncode\n\n\n\nmore code\n```\n\n\nAfter";
1288 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1289 let fixed = rule.fix(&ctx).unwrap();
1290 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1291 let recheck = rule.check(&ctx2).unwrap();
1292 assert!(recheck.is_empty(), "Roundtrip: code blocks, got {recheck:?}");
1293 }
1294
1295 #[test]
1296 fn test_roundtrip_heading_limits() {
1297 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 2);
1298 let content = "# Heading\n\n\n\n\nParagraph\n\n\n\n## Heading 2\n";
1299 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1300 let fixed = rule.fix(&ctx).unwrap();
1301 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1302 let recheck = rule.check(&ctx2).unwrap();
1303 assert!(recheck.is_empty(), "Roundtrip: heading limits, got {recheck:?}");
1304 }
1305
1306 #[test]
1307 fn test_roundtrip_front_matter() {
1308 let rule = MD012NoMultipleBlanks::default();
1309 let content = "---\ntitle: Test\n\n\nauthor: Me\n---\n\n\n\nContent\n";
1310 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1311 let fixed = rule.fix(&ctx).unwrap();
1312 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1313 let recheck = rule.check(&ctx2).unwrap();
1314 assert!(recheck.is_empty(), "Roundtrip: front matter, got {recheck:?}");
1315 }
1316
1317 #[test]
1318 fn test_roundtrip_only_blanks() {
1319 let rule = MD012NoMultipleBlanks::default();
1320 let content = "\n\n\n";
1321 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1322 let fixed = rule.fix(&ctx).unwrap();
1323 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1324 let recheck = rule.check(&ctx2).unwrap();
1325 assert!(recheck.is_empty(), "Roundtrip: only blanks, got {recheck:?}");
1326 }
1327
1328 #[test]
1329 fn test_roundtrip_single_eof_blank() {
1330 let rule = MD012NoMultipleBlanks::default();
1331 let content = "Content\n\n";
1332 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1333 let fixed = rule.fix(&ctx).unwrap();
1334 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1335 let recheck = rule.check(&ctx2).unwrap();
1336 assert!(recheck.is_empty(), "Roundtrip: single EOF blank, got {recheck:?}");
1337 }
1338
1339 #[test]
1340 fn test_roundtrip_mixed_heading_and_non_heading() {
1341 let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 2);
1342 let content = "# Heading\n\n\n\nParagraph 1\n\n\n\nParagraph 2\n";
1343 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1344 let fixed = rule.fix(&ctx).unwrap();
1345 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1346 let recheck = rule.check(&ctx2).unwrap();
1347 assert!(
1348 recheck.is_empty(),
1349 "Roundtrip: mixed heading/non-heading, got {recheck:?}"
1350 );
1351 }
1352}