1use crate::filtered_lines::FilteredLinesExt;
2use crate::lint_context::LintContext;
3use crate::lint_context::types::HeadingStyle;
4
5use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
6
7mod md012_config;
8use md012_config::MD012Config;
9
10fn is_blank_line(line: &str) -> bool {
14 line.trim_matches([' ', '\t', '\r']).is_empty()
15}
16
17#[derive(Debug, Clone)]
21pub struct MD012NoMultipleBlanks {
22 config: MD012Config,
23 heading_blanks_above: usize,
26 heading_blanks_below: usize,
29}
30
31impl Default for MD012NoMultipleBlanks {
32 fn default() -> Self {
33 Self {
34 config: MD012Config::default(),
35 heading_blanks_above: 1,
36 heading_blanks_below: 1,
37 }
38 }
39}
40
41impl MD012NoMultipleBlanks {
42 pub fn new(maximum: usize) -> Self {
43 use crate::types::PositiveUsize;
44 Self {
45 config: MD012Config {
46 maximum: PositiveUsize::new(maximum).unwrap_or(PositiveUsize::from_const(1)),
47 },
48 heading_blanks_above: 1,
49 heading_blanks_below: 1,
50 }
51 }
52
53 pub const fn from_config_struct(config: MD012Config) -> Self {
54 Self {
55 config,
56 heading_blanks_above: 1,
57 heading_blanks_below: 1,
58 }
59 }
60
61 pub fn with_heading_limits(mut self, above: usize, below: usize) -> Self {
64 self.heading_blanks_above = above;
65 self.heading_blanks_below = below;
66 self
67 }
68
69 fn effective_max_above(&self) -> usize {
73 self.config.maximum.get().max(self.heading_blanks_above)
74 }
75
76 fn effective_max_below(&self) -> usize {
77 self.config.maximum.get().max(self.heading_blanks_below)
78 }
79
80 fn excess_run_warning(
89 &self,
90 blank_start: usize,
91 blank_count: usize,
92 effective_max: usize,
93 ctx: &LintContext,
94 ) -> LintWarning {
95 let location = if blank_start == 0 {
96 "at start of file"
97 } else {
98 "between content"
99 };
100
101 let first_excess = blank_start + effective_max + 1;
103 let last_excess = blank_start + blank_count;
104
105 let fix_start = ctx.line_start_byte(first_excess).unwrap_or(0);
106 let fix_end = ctx.line_start_byte(last_excess + 1).unwrap_or(ctx.content.len());
107
108 LintWarning {
109 rule_name: Some(self.name().to_string()),
110 severity: Severity::Warning,
111 message: format!(
112 "Multiple consecutive blank lines {location} ({blank_count} found, {effective_max} allowed)"
113 ),
114 line: first_excess,
115 column: 1,
116 end_line: last_excess + 1,
117 end_column: 1,
118 fix: Some(Fix::new(fix_start..fix_end, String::new())),
119 }
120 }
121}
122
123fn is_heading_context(ctx: &LintContext, line_idx: usize) -> bool {
130 if ctx
131 .lines
132 .get(line_idx)
133 .is_some_and(|li| li.heading.is_some() || li.is_setext_heading_text)
134 {
135 return true;
136 }
137 if line_idx > 0
139 && let Some(prev_info) = ctx.lines.get(line_idx - 1)
140 && let Some(ref heading) = prev_info.heading
141 && matches!(heading.style, HeadingStyle::Setext1 | HeadingStyle::Setext2)
142 {
143 return true;
144 }
145 false
146}
147
148fn max_heading_limit(
152 level_config: &crate::rules::md022_blanks_around_headings::md022_config::HeadingLevelConfig,
153) -> usize {
154 let mut max_val: usize = 0;
155 for level in 1..=6 {
156 match level_config.get_for_level(level).required_count() {
157 None => return usize::MAX, Some(count) => max_val = max_val.max(count),
159 }
160 }
161 max_val
162}
163
164impl Rule for MD012NoMultipleBlanks {
165 fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
166 (self.config == Default::default()).then_some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(12))
167 }
168
169 fn name(&self) -> &'static str {
170 "MD012"
171 }
172
173 fn description(&self) -> &'static str {
174 "Multiple consecutive blank lines"
175 }
176
177 fn category(&self) -> RuleCategory {
178 RuleCategory::Whitespace
179 }
180
181 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
182 let content = ctx.content;
183
184 if content.is_empty() {
186 return Ok(Vec::new());
187 }
188
189 let lines = ctx.raw_lines();
192 let has_potential_blanks = lines
193 .windows(2)
194 .any(|pair| is_blank_line(pair[0]) && is_blank_line(pair[1]));
195
196 let ends_with_multiple_newlines = content.ends_with("\n\n");
200
201 if !has_potential_blanks && !ends_with_multiple_newlines {
202 return Ok(Vec::new());
203 }
204
205 let mut warnings = Vec::new();
206
207 let mut blank_count = 0;
209 let mut blank_start = 0;
210 let mut last_line_num: Option<usize> = None;
211 let mut prev_content_line_num: Option<usize> = None;
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 is_blank_line(line) && 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.push(self.excess_run_warning(blank_start, blank_count, effective_max, ctx));
258 }
259 blank_count = 0;
260 prev_content_line_num = None;
262 }
263 last_line_num = Some(line_num);
264
265 if is_blank_line(line) {
266 if blank_count == 0 {
267 blank_start = line_num;
268 }
269 blank_count += 1;
270 } else {
271 let heading_below = prev_content_line_num.is_some_and(|idx| is_heading_context(ctx, idx));
277 let heading_above = blank_start > 0 && is_heading_context(ctx, line_num);
278 let effective_max = if heading_below && heading_above {
279 self.effective_max_above().max(self.effective_max_below())
281 } else if heading_below {
282 self.effective_max_below()
283 } else if heading_above {
284 self.effective_max_above()
285 } else {
286 self.config.maximum.get()
287 };
288
289 if blank_count > effective_max {
290 warnings.push(self.excess_run_warning(blank_start, blank_count, effective_max, ctx));
291 }
292 blank_count = 0;
293 prev_content_line_num = Some(line_num);
294 }
295 }
296
297 let last_line_is_blank = lines.last().is_some_and(|l| is_blank_line(l));
304
305 if blank_count > 0 && !last_line_is_blank {
308 let effective_max = if prev_content_line_num.is_some_and(|idx| is_heading_context(ctx, idx)) {
309 self.effective_max_below()
310 } else {
311 self.config.maximum.get()
312 };
313 if blank_count > effective_max {
314 warnings.push(self.excess_run_warning(blank_start, blank_count, effective_max, ctx));
315 }
316 }
317
318 if blank_count > 0 && last_line_is_blank {
322 let first_blank = lines.len() - blank_count + 1;
324 let fix_start = ctx.line_start_byte(first_blank).unwrap_or(0);
325 let fix_end = content.len();
326
327 let (end_line, end_column) = if content.ends_with('\n') {
331 (lines.len() + 1, 1)
332 } else {
333 let last = lines.last().copied().unwrap_or("");
334 (lines.len(), last.chars().count() + 1)
335 };
336
337 let message = if blank_count == 1 {
338 "Blank line at end of file (1 found, 0 allowed)".to_string()
339 } else {
340 format!("Blank lines at end of file ({blank_count} found, 0 allowed)")
341 };
342
343 warnings.push(LintWarning {
344 rule_name: Some(self.name().to_string()),
345 severity: Severity::Warning,
346 message,
347 line: first_blank,
348 column: 1,
349 end_line,
350 end_column,
351 fix: Some(Fix::new(fix_start..fix_end, String::new())),
352 });
353 }
354
355 Ok(warnings)
356 }
357
358 fn fixes_from_diagnostics(&self) -> bool {
359 true
360 }
361
362 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
363 if self.should_skip(ctx) {
364 return Ok(ctx.content.to_string());
365 }
366 let warnings = self.check(ctx)?;
367 if warnings.is_empty() {
368 return Ok(ctx.content.to_string());
369 }
370 let warnings =
371 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
372 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
373 .map_err(crate::rule::LintError::InvalidInput)
374 }
375
376 fn as_any(&self) -> &dyn std::any::Any {
377 self
378 }
379
380 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
381 ctx.content.is_empty() || !ctx.has_char('\n')
383 }
384
385 crate::impl_rule_config_sections!(MD012Config);
386
387 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
388 where
389 Self: Sized,
390 {
391 use crate::rules::md022_blanks_around_headings::md022_config::MD022Config;
392
393 let rule_config = crate::rule_config_serde::load_rule_config::<MD012Config>(config);
394
395 let md022_disabled = config.global.disable.iter().any(|r| r == "MD022")
398 || config.global.extend_disable.iter().any(|r| r == "MD022");
399
400 let (heading_above, heading_below) = if md022_disabled {
401 (rule_config.maximum.get(), rule_config.maximum.get())
403 } else {
404 let md022_config = crate::rule_config_serde::load_rule_config::<MD022Config>(config);
405 let nested_limit = md022_config
406 .nested_heading_lines_above
407 .as_ref()
408 .map_or(0, max_heading_limit);
409 (
410 max_heading_limit(&md022_config.lines_above).max(nested_limit),
411 max_heading_limit(&md022_config.lines_below).max(nested_limit),
412 )
413 };
414
415 Box::new(Self {
416 config: rule_config,
417 heading_blanks_above: heading_above,
418 heading_blanks_below: heading_below,
419 })
420 }
421}
422
423#[cfg(test)]
424mod tests {
425 use super::*;
426 use crate::lint_context::LintContext;
427
428 #[test]
429 fn test_single_blank_line_allowed() {
430 let rule = MD012NoMultipleBlanks::default();
431 let content = "Line 1\n\nLine 2\n\nLine 3";
432 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
433 let result = rule.check(&ctx).unwrap();
434 assert!(result.is_empty());
435 }
436
437 #[test]
438 fn test_multiple_blank_lines_flagged() {
439 let rule = MD012NoMultipleBlanks::default();
440 let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
441 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
442 let result = rule.check(&ctx).unwrap();
443 let spans: Vec<_> = result
445 .iter()
446 .map(|w| (w.line, w.column, w.end_line, w.end_column))
447 .collect();
448 assert_eq!(spans, [(3, 1, 4, 1), (6, 1, 8, 1)]);
449 }
450
451 #[test]
452 fn test_custom_maximum() {
453 let rule = MD012NoMultipleBlanks::new(2);
454 let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
455 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
456 let result = rule.check(&ctx).unwrap();
457 assert_eq!(result.len(), 1); assert_eq!((result[0].line, result[0].end_line), (7, 8));
459 }
460
461 #[test]
462 fn test_fix_multiple_blank_lines() {
463 let rule = MD012NoMultipleBlanks::default();
464 let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
465 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
466 let fixed = rule.fix(&ctx).unwrap();
467 assert_eq!(fixed, "Line 1\n\nLine 2\n\nLine 3");
468 }
469
470 #[test]
471 fn test_blank_lines_in_code_block() {
472 let rule = MD012NoMultipleBlanks::default();
473 let content = "Before\n\n```\ncode\n\n\n\nmore code\n```\n\nAfter";
474 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
475 let result = rule.check(&ctx).unwrap();
476 assert!(result.is_empty()); }
478
479 #[test]
480 fn test_blank_lines_in_html_comment() {
481 let rule = MD012NoMultipleBlanks::default();
482 let content = "Before\n\n<!--\ncomment\n\n\n\nmore comment\n-->\n\nAfter";
483 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
484 let result = rule.check(&ctx).unwrap();
485 assert!(result.is_empty()); }
487
488 #[test]
489 fn test_blank_lines_in_html_block() {
490 let rule = MD012NoMultipleBlanks::default();
491 let content = "Before\n\n<script>\n\n\n\n</script>\n\nAfter";
492 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
493 let result = rule.check(&ctx).unwrap();
494 assert!(result.is_empty()); }
496
497 #[test]
498 fn test_fix_preserves_code_block_blanks() {
499 let rule = MD012NoMultipleBlanks::default();
500 let content = "Before\n\n\n```\ncode\n\n\n\nmore code\n```\n\n\nAfter";
501 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
502 let fixed = rule.fix(&ctx).unwrap();
503 assert_eq!(fixed, "Before\n\n```\ncode\n\n\n\nmore code\n```\n\nAfter");
504 }
505
506 #[test]
507 fn test_blank_lines_in_front_matter() {
508 let rule = MD012NoMultipleBlanks::default();
509 let content = "---\ntitle: Test\n\n\nauthor: Me\n---\n\nContent";
510 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
511 let result = rule.check(&ctx).unwrap();
512 assert!(result.is_empty()); }
514
515 #[test]
516 fn test_blank_lines_at_start() {
517 let rule = MD012NoMultipleBlanks::default();
518 let content = "\n\n\nContent";
519 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
520 let result = rule.check(&ctx).unwrap();
521 assert_eq!(result.len(), 1, "one warning for the whole leading run");
522 assert_eq!((result[0].line, result[0].end_line), (2, 4));
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(), 1, "Should flag blanks after indented code block ends");
598 assert_eq!((warnings[0].line, warnings[0].end_line), (5, 7));
599 }
600
601 #[test]
602 fn test_fix_with_final_newline() {
603 let rule = MD012NoMultipleBlanks::default();
604 let content = "Line 1\n\n\nLine 2\n";
605 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
606 let fixed = rule.fix(&ctx).unwrap();
607 assert_eq!(fixed, "Line 1\n\nLine 2\n");
608 assert!(fixed.ends_with('\n'));
609 }
610
611 #[test]
612 fn test_empty_content() {
613 let rule = MD012NoMultipleBlanks::default();
614 let content = "";
615 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
616 let result = rule.check(&ctx).unwrap();
617 assert!(result.is_empty());
618 }
619
620 #[test]
621 fn test_nested_code_blocks() {
622 let rule = MD012NoMultipleBlanks::default();
623 let content = "Before\n\n~~~\nouter\n\n```\ninner\n\n\n```\n\n~~~\n\nAfter";
624 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
625 let result = rule.check(&ctx).unwrap();
626 assert!(result.is_empty());
627 }
628
629 #[test]
630 fn test_unclosed_code_block() {
631 let rule = MD012NoMultipleBlanks::default();
632 let content = "Before\n\n```\ncode\n\n\n\nno closing fence";
633 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
634 let result = rule.check(&ctx).unwrap();
635 assert!(result.is_empty()); }
637
638 #[test]
639 fn test_mixed_fence_styles() {
640 let rule = MD012NoMultipleBlanks::default();
641 let content = "Before\n\n```\ncode\n\n\n~~~\n\nAfter";
642 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
643 let result = rule.check(&ctx).unwrap();
644 assert!(result.is_empty()); }
646
647 #[test]
648 fn test_config_from_toml() {
649 let mut config = crate::config::Config::default();
650 let mut rule_config = crate::config::RuleConfig::default();
651 rule_config
652 .values
653 .insert("maximum".to_string(), toml::Value::Integer(3));
654 config.rules.insert("MD012".to_string(), rule_config);
655
656 let rule = MD012NoMultipleBlanks::from_config(&config);
657 let content = "Line 1\n\n\n\nLine 2"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
659 let result = rule.check(&ctx).unwrap();
660 assert!(result.is_empty()); }
662
663 #[test]
664 fn test_blank_lines_between_sections() {
665 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 1);
667 let content = "# Section 1\n\nContent\n\n\n# Section 2\n\nContent";
668 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
669 let result = rule.check(&ctx).unwrap();
670 assert!(
671 result.is_empty(),
672 "2 blanks above heading allowed with heading_blanks_above=2"
673 );
674 }
675
676 #[test]
677 fn test_fix_preserves_indented_code() {
678 let rule = MD012NoMultipleBlanks::default();
679 let content = "Text\n\n\n code\n \n more code\n\n\nText";
680 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
681 let fixed = rule.fix(&ctx).unwrap();
682 assert_eq!(fixed, "Text\n\n code\n \n more code\n\nText");
685 }
686
687 #[test]
688 fn test_edge_case_only_blanks() {
689 let rule = MD012NoMultipleBlanks::default();
690 let content = "\n\n\n";
691 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
692 let result = rule.check(&ctx).unwrap();
693 assert_eq!(result.len(), 1);
695 assert!(result[0].message.contains("at end of file"));
696 }
697
698 #[test]
701 fn test_blanks_after_fenced_code_block_mid_document() {
702 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 1);
704 let content = "## Input\n\n```javascript\ncode\n```\n\n\n## Error\n";
705 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
706 let result = rule.check(&ctx).unwrap();
707 assert!(
708 result.is_empty(),
709 "2 blanks before heading allowed with heading_blanks_above=2"
710 );
711 }
712
713 #[test]
714 fn test_blanks_after_code_block_at_eof() {
715 let rule = MD012NoMultipleBlanks::default();
717 let content = "# Heading\n\n```\ncode\n```\n\n\n";
718 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
719 let result = rule.check(&ctx).unwrap();
720 assert_eq!(result.len(), 1, "Should detect trailing blanks after code block");
722 assert!(result[0].message.contains("at end of file"));
723 }
724
725 #[test]
726 fn test_single_blank_after_code_block_allowed() {
727 let rule = MD012NoMultipleBlanks::default();
729 let content = "## Input\n\n```\ncode\n```\n\n## Output\n";
730 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
731 let result = rule.check(&ctx).unwrap();
732 assert!(result.is_empty(), "Single blank after code block should be allowed");
733 }
734
735 #[test]
736 fn test_multiple_code_blocks_with_blanks() {
737 let rule = MD012NoMultipleBlanks::default();
739 let content = "```\ncode1\n```\n\n\n```\ncode2\n```\n\n\nEnd\n";
740 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
741 let result = rule.check(&ctx).unwrap();
742 assert_eq!(result.len(), 2, "Should detect blanks after both code blocks");
744 }
745
746 #[test]
747 fn test_whitespace_only_lines_after_code_block_at_eof() {
748 let rule = MD012NoMultipleBlanks::default();
751 let content = "```\ncode\n```\n \n \n";
752 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
753 let result = rule.check(&ctx).unwrap();
754 assert_eq!(result.len(), 1, "Should detect whitespace-only trailing blanks");
755 assert!(result[0].message.contains("at end of file"));
756 }
757
758 #[test]
761 fn test_warning_fix_removes_single_trailing_blank() {
762 let rule = MD012NoMultipleBlanks::default();
764 let content = "hello foobar hello.\n\n";
765 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
766 let warnings = rule.check(&ctx).unwrap();
767
768 assert_eq!(warnings.len(), 1);
769 assert!(warnings[0].fix.is_some(), "Warning should have a fix attached");
770
771 let fix = warnings[0].fix.as_ref().unwrap();
772 assert_eq!(fix.replacement, "", "Replacement should be empty");
774
775 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
777 assert_eq!(fixed, "hello foobar hello.\n", "Should end with single newline");
778 }
779
780 #[test]
781 fn test_warning_fix_removes_multiple_trailing_blanks() {
782 let rule = MD012NoMultipleBlanks::default();
783 let content = "content\n\n\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());
789
790 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
791 assert_eq!(fixed, "content\n", "Should end with single newline");
792 }
793
794 #[test]
795 fn test_warning_fix_preserves_content_newline() {
796 let rule = MD012NoMultipleBlanks::default();
798 let content = "line1\nline2\n\n";
799 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
800 let warnings = rule.check(&ctx).unwrap();
801
802 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
803 assert_eq!(fixed, "line1\nline2\n", "Should preserve all content lines");
804 }
805
806 #[test]
807 fn test_warning_fix_mid_document_blanks() {
808 let rule = MD012NoMultipleBlanks::default();
810 let content = "# Heading\n\n\n\nParagraph\n";
811 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
812 let warnings = rule.check(&ctx).unwrap();
813 assert_eq!(
814 warnings.len(),
815 1,
816 "Excess heading-adjacent blanks flagged with default limits"
817 );
818 assert_eq!((warnings[0].line, warnings[0].end_line), (3, 5));
819 assert_eq!(
820 crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap(),
821 "# Heading\n\nParagraph\n"
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(), 1, "Excess beyond heading limit should be flagged");
873 assert_eq!((result[0].line, result[0].end_line), (4, 6));
874 }
875
876 #[test]
877 fn test_heading_aware_setext_blanks_below() {
878 let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 2);
880 let content = "Heading\n=======\n\n\nParagraph\n";
881 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
882 let result = rule.check(&ctx).unwrap();
883 assert!(result.is_empty(), "2 blanks below Setext heading allowed with limit=2");
884 }
885
886 #[test]
887 fn test_heading_aware_setext_blanks_above() {
888 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 1);
890 let content = "Paragraph\n\n\nHeading\n=======\n";
891 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
892 let result = rule.check(&ctx).unwrap();
893 assert!(result.is_empty(), "2 blanks above Setext heading allowed with limit=2");
894 }
895
896 #[test]
897 fn test_heading_aware_multi_line_setext_blanks_above() {
898 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 1);
902 let content = "Paragraph\n\n\nFirst line\nsecond line\n=====\n";
903 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
904 let result = rule.check(&ctx).unwrap();
905 assert!(
906 result.is_empty(),
907 "2 blanks above a two-line setext heading allowed with limit=2. Got: {result:?}"
908 );
909 }
910
911 #[test]
912 fn test_heading_aware_single_blank_allowed() {
913 let rule = MD012NoMultipleBlanks::default();
915 let content = "# Heading\n\nParagraph\n";
916 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
917 let result = rule.check(&ctx).unwrap();
918 assert!(result.is_empty(), "Single blank near heading should be allowed");
919 }
920
921 #[test]
922 fn test_heading_aware_non_heading_blanks_still_flagged() {
923 let rule = MD012NoMultipleBlanks::default();
925 let content = "Paragraph 1\n\n\nParagraph 2\n";
926 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
927 let result = rule.check(&ctx).unwrap();
928 assert_eq!(result.len(), 1, "Non-heading blanks should still be flagged");
929 }
930
931 #[test]
932 fn test_heading_aware_fix_caps_heading_blanks() {
933 let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 2);
935 let content = "# Heading\n\n\n\nParagraph\n"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
937 let fixed = rule.fix(&ctx).unwrap();
938 assert_eq!(
939 fixed, "# Heading\n\n\nParagraph\n",
940 "Fix caps heading-adjacent blanks at effective max (2)"
941 );
942 }
943
944 #[test]
945 fn test_heading_aware_fix_preserves_allowed_heading_blanks() {
946 let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 3);
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\n\nParagraph\n",
953 "Fix preserves blanks within the heading limit"
954 );
955 }
956
957 #[test]
958 fn test_heading_aware_fix_reduces_non_heading_blanks() {
959 let rule = MD012NoMultipleBlanks::default();
961 let content = "Paragraph 1\n\n\n\nParagraph 2\n";
962 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
963 let fixed = rule.fix(&ctx).unwrap();
964 assert_eq!(
965 fixed, "Paragraph 1\n\nParagraph 2\n",
966 "Fix should reduce non-heading blanks"
967 );
968 }
969
970 #[test]
971 fn test_heading_aware_mixed_heading_and_non_heading() {
972 let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 2);
974 let content = "# Heading\n\n\nParagraph 1\n\n\nParagraph 2\n";
975 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
976 let result = rule.check(&ctx).unwrap();
977 assert_eq!(result.len(), 1, "Only non-heading excess should be flagged");
979 }
980
981 #[test]
982 fn test_heading_aware_blanks_at_start_before_heading_still_flagged() {
983 let rule = MD012NoMultipleBlanks::default().with_heading_limits(3, 3);
986 let content = "\n\n\n# Heading\n";
987 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
988 let result = rule.check(&ctx).unwrap();
989 assert_eq!(
990 result.len(),
991 1,
992 "Start-of-file blanks should be flagged even before heading"
993 );
994 assert_eq!((result[0].line, result[0].end_line), (2, 4));
995 assert!(result[0].message.contains("at start of file"));
996 }
997
998 #[test]
999 fn test_heading_aware_eof_blanks_after_heading_still_flagged() {
1000 let rule = MD012NoMultipleBlanks::default();
1002 let content = "# Heading\n\n";
1003 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1004 let result = rule.check(&ctx).unwrap();
1005 assert_eq!(result.len(), 1, "EOF blanks should still be flagged");
1006 assert!(result[0].message.contains("at end of file"));
1007 }
1008
1009 #[test]
1010 fn test_heading_aware_unlimited_heading_blanks() {
1011 let rule = MD012NoMultipleBlanks::default().with_heading_limits(usize::MAX, usize::MAX);
1013 let content = "# Heading\n\n\n\n\nParagraph\n"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1015 let result = rule.check(&ctx).unwrap();
1016 assert!(
1017 result.is_empty(),
1018 "Unlimited heading limits means MD012 never flags near headings"
1019 );
1020 }
1021
1022 #[test]
1023 fn test_heading_aware_blanks_after_code_then_heading() {
1024 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 2);
1026 let content = "# Heading\n\n```\ncode\n```\n\n\n\nMore text\n";
1027 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1028 let result = rule.check(&ctx).unwrap();
1029 assert_eq!(result.len(), 1, "Non-heading blanks after code block should be flagged");
1031 assert_eq!((result[0].line, result[0].end_line), (7, 9));
1032 }
1033
1034 #[test]
1035 fn test_heading_aware_fix_mixed_document() {
1036 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 2);
1038 let content = "# Title\n\n\n## Section\n\n\nPara 1\n\n\nPara 2\n";
1039 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1040 let fixed = rule.fix(&ctx).unwrap();
1041 assert_eq!(fixed, "# Title\n\n\n## Section\n\n\nPara 1\n\nPara 2\n");
1043 }
1044
1045 #[test]
1046 fn test_heading_aware_from_config_reads_md022() {
1047 let mut config = crate::config::Config::default();
1049 let mut md022_config = crate::config::RuleConfig::default();
1050 md022_config
1051 .values
1052 .insert("lines-above".to_string(), toml::Value::Integer(2));
1053 md022_config
1054 .values
1055 .insert("lines-below".to_string(), toml::Value::Integer(3));
1056 config.rules.insert("MD022".to_string(), md022_config);
1057
1058 let rule = MD012NoMultipleBlanks::from_config(&config);
1059 let content = "Paragraph\n\n\n# Heading\n";
1061 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1062 let result = rule.check(&ctx).unwrap();
1063 assert!(
1064 result.is_empty(),
1065 "2 blanks above heading allowed when MD022 lines-above=2"
1066 );
1067 }
1068
1069 #[test]
1070 fn test_heading_aware_from_config_reads_nested_md022_limit() {
1071 let mut config = crate::config::Config::default();
1072 let mut md022_config = crate::config::RuleConfig::default();
1073 md022_config
1074 .values
1075 .insert("nested-heading-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 = "# Parent\n\n\n\n## Child";
1080 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1081 assert!(rule.check(&ctx).unwrap().is_empty());
1082 }
1083
1084 #[test]
1085 fn test_heading_aware_from_config_md022_disabled() {
1086 let mut config = crate::config::Config::default();
1088 config.global.disable.push("MD022".to_string());
1089
1090 let mut md022_config = crate::config::RuleConfig::default();
1091 md022_config
1092 .values
1093 .insert("lines-above".to_string(), toml::Value::Integer(3));
1094 config.rules.insert("MD022".to_string(), md022_config);
1095
1096 let rule = MD012NoMultipleBlanks::from_config(&config);
1097 let content = "Paragraph\n\n\n# Heading\n";
1099 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1100 let result = rule.check(&ctx).unwrap();
1101 assert_eq!(
1102 result.len(),
1103 1,
1104 "With MD022 disabled, heading-adjacent blanks are flagged"
1105 );
1106 }
1107
1108 #[test]
1109 fn test_heading_aware_from_config_md022_unlimited() {
1110 let mut config = crate::config::Config::default();
1112 let mut md022_config = crate::config::RuleConfig::default();
1113 md022_config
1114 .values
1115 .insert("lines-above".to_string(), toml::Value::Integer(-1));
1116 config.rules.insert("MD022".to_string(), md022_config);
1117
1118 let rule = MD012NoMultipleBlanks::from_config(&config);
1119 let content = "Paragraph\n\n\n\n\n# Heading\n"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1121 let result = rule.check(&ctx).unwrap();
1122 assert!(
1123 result.is_empty(),
1124 "Unlimited MD022 lines-above means MD012 never flags above headings"
1125 );
1126 }
1127
1128 #[test]
1129 fn test_heading_aware_from_config_per_level() {
1130 let mut config = crate::config::Config::default();
1135 let mut md022_config = crate::config::RuleConfig::default();
1136 md022_config.values.insert(
1137 "lines-above".to_string(),
1138 toml::Value::Array(vec![
1139 toml::Value::Integer(2),
1140 toml::Value::Integer(1),
1141 toml::Value::Integer(1),
1142 toml::Value::Integer(1),
1143 toml::Value::Integer(1),
1144 toml::Value::Integer(1),
1145 ]),
1146 );
1147 config.rules.insert("MD022".to_string(), md022_config);
1148
1149 let rule = MD012NoMultipleBlanks::from_config(&config);
1150
1151 let content = "Paragraph\n\n\n## H2 Heading\n";
1153 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1154 let result = rule.check(&ctx).unwrap();
1155 assert!(result.is_empty(), "Per-level max (2) allows 2 blanks above any heading");
1156
1157 let content = "Paragraph\n\n\n\n## H2 Heading\n";
1159 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1160 let result = rule.check(&ctx).unwrap();
1161 assert_eq!(result.len(), 1, "3 blanks exceeds per-level max of 2");
1162 }
1163
1164 #[test]
1165 fn test_issue_449_reproduction() {
1166 let rule = MD012NoMultipleBlanks::default();
1169 let content = "\
1170# Heading
1171
1172
1173Some introductory text.
1174
1175
1176
1177
1178
1179## Heading level 2
1180
1181
1182Some text for this section.
1183
1184Some more text for this section.
1185
1186
1187## Another heading level 2
1188
1189
1190
1191Some text for this section.
1192
1193Some more text for this section.
1194";
1195 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1196 let result = rule.check(&ctx).unwrap();
1197 assert!(
1198 !result.is_empty(),
1199 "Issue #449: excess blanks around headings should be flagged with default settings"
1200 );
1201
1202 let fixed = rule.fix(&ctx).unwrap();
1204 let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1205 let recheck = rule.check(&fixed_ctx).unwrap();
1206 assert!(recheck.is_empty(), "Fix should resolve all excess blank lines");
1207
1208 assert!(fixed.contains("# Heading\n\nSome"), "1 blank below first heading");
1210 assert!(
1211 fixed.contains("text.\n\n## Heading level 2"),
1212 "1 blank above second heading"
1213 );
1214 }
1215
1216 #[test]
1219 fn test_blank_lines_in_quarto_callout() {
1220 let rule = MD012NoMultipleBlanks::default();
1222 let content = "# Heading\n\n::: {.callout-note}\nNote content\n\n\nMore content\n:::\n\nAfter";
1223 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1224 let result = rule.check(&ctx).unwrap();
1225 assert!(result.is_empty(), "Should not flag blanks inside Quarto callouts");
1226 }
1227
1228 #[test]
1229 fn test_blank_lines_in_quarto_div() {
1230 let rule = MD012NoMultipleBlanks::default();
1232 let content = "Text\n\n::: {.bordered}\nContent\n\n\nMore\n:::\n\nText";
1233 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1234 let result = rule.check(&ctx).unwrap();
1235 assert!(result.is_empty(), "Should not flag blanks inside Quarto divs");
1236 }
1237
1238 #[test]
1239 fn test_blank_lines_outside_quarto_div_flagged() {
1240 let rule = MD012NoMultipleBlanks::default();
1242 let content = "Text\n\n\n::: {.callout-note}\nNote\n:::\n\n\nMore";
1243 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1244 let result = rule.check(&ctx).unwrap();
1245 assert!(!result.is_empty(), "Should flag blanks outside Quarto divs");
1246 }
1247
1248 #[test]
1249 fn test_quarto_divs_ignored_in_standard_flavor() {
1250 let rule = MD012NoMultipleBlanks::default();
1252 let content = "::: {.callout-note}\nNote content\n\n\nMore content\n:::\n";
1253 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1254 let result = rule.check(&ctx).unwrap();
1255 assert!(!result.is_empty(), "Standard flavor should flag blanks in 'div'");
1257 }
1258
1259 #[test]
1262 fn test_roundtrip_multiple_blank_lines() {
1263 let rule = MD012NoMultipleBlanks::default();
1264 let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
1265 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1266 let fixed = rule.fix(&ctx).unwrap();
1267 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1268 let recheck = rule.check(&ctx2).unwrap();
1269 assert!(
1270 recheck.is_empty(),
1271 "Roundtrip: fix then check should be clean, got {recheck:?}"
1272 );
1273 }
1274
1275 #[test]
1276 fn test_roundtrip_trailing_blanks() {
1277 let rule = MD012NoMultipleBlanks::default();
1278 let content = "Content\n\n\n\n";
1279 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1280 let fixed = rule.fix(&ctx).unwrap();
1281 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1282 let recheck = rule.check(&ctx2).unwrap();
1283 assert!(recheck.is_empty(), "Roundtrip: trailing blanks, got {recheck:?}");
1284 }
1285
1286 #[test]
1287 fn test_roundtrip_leading_blanks() {
1288 let rule = MD012NoMultipleBlanks::default();
1289 let content = "\n\n\nContent\n";
1290 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1291 let fixed = rule.fix(&ctx).unwrap();
1292 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1293 let recheck = rule.check(&ctx2).unwrap();
1294 assert!(recheck.is_empty(), "Roundtrip: leading blanks, got {recheck:?}");
1295 }
1296
1297 #[test]
1298 fn test_roundtrip_custom_maximum() {
1299 let rule = MD012NoMultipleBlanks::new(2);
1300 let content = "Line 1\n\n\n\n\nLine 2\n";
1301 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1302 let fixed = rule.fix(&ctx).unwrap();
1303 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1304 let recheck = rule.check(&ctx2).unwrap();
1305 assert!(recheck.is_empty(), "Roundtrip: max=2, got {recheck:?}");
1306 }
1307
1308 #[test]
1309 fn test_roundtrip_code_blocks() {
1310 let rule = MD012NoMultipleBlanks::default();
1311 let content = "Before\n\n\n```\ncode\n\n\n\nmore code\n```\n\n\nAfter";
1312 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1313 let fixed = rule.fix(&ctx).unwrap();
1314 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1315 let recheck = rule.check(&ctx2).unwrap();
1316 assert!(recheck.is_empty(), "Roundtrip: code blocks, got {recheck:?}");
1317 }
1318
1319 #[test]
1320 fn test_roundtrip_heading_limits() {
1321 let rule = MD012NoMultipleBlanks::default().with_heading_limits(2, 2);
1322 let content = "# Heading\n\n\n\n\nParagraph\n\n\n\n## Heading 2\n";
1323 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1324 let fixed = rule.fix(&ctx).unwrap();
1325 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1326 let recheck = rule.check(&ctx2).unwrap();
1327 assert!(recheck.is_empty(), "Roundtrip: heading limits, got {recheck:?}");
1328 }
1329
1330 #[test]
1331 fn test_roundtrip_front_matter() {
1332 let rule = MD012NoMultipleBlanks::default();
1333 let content = "---\ntitle: Test\n\n\nauthor: Me\n---\n\n\n\nContent\n";
1334 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1335 let fixed = rule.fix(&ctx).unwrap();
1336 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1337 let recheck = rule.check(&ctx2).unwrap();
1338 assert!(recheck.is_empty(), "Roundtrip: front matter, got {recheck:?}");
1339 }
1340
1341 #[test]
1342 fn test_roundtrip_only_blanks() {
1343 let rule = MD012NoMultipleBlanks::default();
1344 let content = "\n\n\n";
1345 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1346 let fixed = rule.fix(&ctx).unwrap();
1347 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1348 let recheck = rule.check(&ctx2).unwrap();
1349 assert!(recheck.is_empty(), "Roundtrip: only blanks, got {recheck:?}");
1350 }
1351
1352 #[test]
1353 fn test_roundtrip_single_eof_blank() {
1354 let rule = MD012NoMultipleBlanks::default();
1355 let content = "Content\n\n";
1356 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1357 let fixed = rule.fix(&ctx).unwrap();
1358 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1359 let recheck = rule.check(&ctx2).unwrap();
1360 assert!(recheck.is_empty(), "Roundtrip: single EOF blank, got {recheck:?}");
1361 }
1362
1363 #[test]
1364 fn test_roundtrip_mixed_heading_and_non_heading() {
1365 let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 2);
1366 let content = "# Heading\n\n\n\nParagraph 1\n\n\n\nParagraph 2\n";
1367 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1368 let fixed = rule.fix(&ctx).unwrap();
1369 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1370 let recheck = rule.check(&ctx2).unwrap();
1371 assert!(
1372 recheck.is_empty(),
1373 "Roundtrip: mixed heading/non-heading, got {recheck:?}"
1374 );
1375 }
1376
1377 #[test]
1378 fn literal_template_blank_lines_are_preserved_beside_prose_fixes() {
1379 let rule = MD012NoMultipleBlanks::default();
1380 for (flavor, code) in [
1381 (
1382 crate::config::MarkdownFlavor::Standard,
1383 "{% set unused=\"first\n\n\nlast\" %}{{ unused|length }}",
1384 ),
1385 (
1386 crate::config::MarkdownFlavor::Hugo,
1387 "{{< note title=`first\n\n\nlast` >}}",
1388 ),
1389 ] {
1390 for ending in ["\n", "\r\n"] {
1391 let source = format!("{code}\n\n\nVisible.\n").replace('\n', ending);
1392 let expected = format!("{code}\n\nVisible.\n").replace('\n', ending);
1393 let ctx = LintContext::new(&source, flavor, None);
1394 let warnings = rule.check(&ctx).unwrap();
1395 assert_eq!(warnings.len(), 1, "{source}");
1396 assert_eq!(warnings[0].line, code.lines().count() + 2);
1397 assert_eq!(rule.fix(&ctx).unwrap(), expected);
1398 }
1399 }
1400 }
1401
1402 #[test]
1403 fn whitespace_only_template_lines_do_not_join_prose_blank_runs() {
1404 let rule = MD012NoMultipleBlanks::default();
1405 for code in [
1406 "{% set unused=\"first\n \n \nlast\" %}{{ unused|length }}",
1407 "{{< note title=`first\n \n \nlast` >}}",
1408 ] {
1409 let flavor = if code.starts_with("{{<") {
1410 crate::config::MarkdownFlavor::Hugo
1411 } else {
1412 crate::config::MarkdownFlavor::Standard
1413 };
1414 let source = format!("Before.\n\n\n{code}\n\n\nAfter.\n\n");
1415 let expected = format!("Before.\n\n{code}\n\nAfter.\n");
1416 let ctx = LintContext::new(&source, flavor, None);
1417 assert_eq!(rule.check(&ctx).unwrap().len(), 3, "{source}");
1418 assert_eq!(rule.fix(&ctx).unwrap(), expected);
1419 }
1420 }
1421
1422 #[test]
1423 fn template_boundaries_keep_ordinary_blank_limits_and_inline_config() {
1424 let rule = MD012NoMultipleBlanks::default();
1425 for (source, expected) in [
1426 (
1427 "{% if enabled %}\nVisible.\n\n\nText.\n{% endif %}\n",
1428 "{% if enabled %}\nVisible.\n\nText.\n{% endif %}\n",
1429 ),
1430 ("{{ title }}\n\n\nVisible.\n", "{{ title }}\n\nVisible.\n"),
1431 ("{{ title }}\n\n", "{{ title }}\n"),
1432 (
1433 "<!-- rumdl-disable MD012 -->\n{{ title }}\n\n\nVisible.\n",
1434 "<!-- rumdl-disable MD012 -->\n{{ title }}\n\n\nVisible.\n",
1435 ),
1436 ] {
1437 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1438 assert_eq!(rule.fix(&ctx).unwrap(), expected);
1439 }
1440 let rule = rule.with_heading_limits(3, 3);
1441 let source = "# Heading\n\n\n{% set unused=\"first\n\nlast\" %}{{ unused|length }}\n\n\n# Next\n\nBody.\n";
1442 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1443 assert!(rule.check(&ctx).unwrap().is_empty());
1444 assert_eq!(rule.fix(&ctx).unwrap(), source);
1445 }
1446
1447 #[test]
1448 fn test_unicode_space_line_is_a_paragraph_line_not_blank() {
1449 let rule = MD012NoMultipleBlanks::default();
1450 for src in ["a\n\n\u{a0}\n\nb\n", "a\n\n\u{2003}\n\n\nb\n\u{3000}\n"] {
1451 let ctx = LintContext::new(src, crate::config::MarkdownFlavor::Standard, None);
1452 let fixed = rule.fix(&ctx).unwrap();
1453 for c in ['\u{a0}', '\u{2003}', '\u{3000}'] {
1454 assert_eq!(fixed.matches(c).count(), src.matches(c).count(), "{src:?} -> {fixed:?}");
1455 }
1456 }
1457 }
1458
1459 #[test]
1460 fn test_ascii_blank_lines_beside_unicode_space_line_are_still_collapsed() {
1461 let rule = MD012NoMultipleBlanks::default();
1462 let src = "a\n\n \t\n\n\u{a0}\nb\n";
1463 let ctx = LintContext::new(src, crate::config::MarkdownFlavor::Standard, None);
1464 assert_eq!(rule.fix(&ctx).unwrap(), "a\n\n\u{a0}\nb\n");
1465 }
1466
1467 mod run_reporting {
1469 use super::*;
1470 use crate::config::MarkdownFlavor;
1471
1472 fn span(warning: &LintWarning) -> (usize, usize, usize, usize) {
1474 (warning.line, warning.column, warning.end_line, warning.end_column)
1475 }
1476
1477 fn check(rule: &MD012NoMultipleBlanks, content: &str) -> Vec<LintWarning> {
1478 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1479 rule.check(&ctx).unwrap()
1480 }
1481
1482 #[test]
1483 fn mid_document_run_is_one_warning_covering_every_excess_line() {
1484 let warnings = check(&MD012NoMultipleBlanks::default(), "Line 1\n\n\n\nLine 2\n");
1486 assert_eq!(warnings.len(), 1, "{warnings:?}");
1487 assert_eq!(span(&warnings[0]), (3, 1, 5, 1));
1488 assert_eq!(
1489 warnings[0].message,
1490 "Multiple consecutive blank lines between content (3 found, 1 allowed)"
1491 );
1492 }
1493
1494 #[test]
1495 fn single_excess_line_still_has_a_non_empty_range() {
1496 let warnings = check(&MD012NoMultipleBlanks::default(), "Line 1\n\n\nLine 2\n");
1497 assert_eq!(warnings.len(), 1, "{warnings:?}");
1498 assert_eq!(span(&warnings[0]), (3, 1, 4, 1));
1499 assert_eq!(
1500 warnings[0].message,
1501 "Multiple consecutive blank lines between content (2 found, 1 allowed)"
1502 );
1503 }
1504
1505 #[test]
1506 fn separate_runs_are_separate_warnings() {
1507 let warnings = check(&MD012NoMultipleBlanks::default(), "a\n\n\nb\n\n\n\nc\n");
1508 let spans: Vec<_> = warnings.iter().map(span).collect();
1509 assert_eq!(spans, [(3, 1, 4, 1), (6, 1, 8, 1)], "{warnings:?}");
1510 }
1511
1512 #[test]
1513 fn warning_fix_deletes_exactly_the_excess_lines() {
1514 for (source, expected) in [
1515 ("Line 1\n\n\n\nLine 2\n", "Line 1\n\nLine 2\n"),
1516 ("Line 1\r\n\r\n\r\n\r\nLine 2\r\n", "Line 1\r\n\r\nLine 2\r\n"),
1517 ("Line 1\n\n \t\n \nLine 2\n", "Line 1\n\nLine 2\n"),
1518 ] {
1519 let rule = MD012NoMultipleBlanks::default();
1520 let ctx = LintContext::new(source, MarkdownFlavor::Standard, None);
1521 let warnings = rule.check(&ctx).unwrap();
1522 assert_eq!(warnings.len(), 1, "{source:?}: {warnings:?}");
1523 let fixed = crate::utils::fix_utils::apply_warning_fixes(source, &warnings).unwrap();
1524 assert_eq!(fixed, expected, "{source:?}");
1525 }
1526 }
1527
1528 #[test]
1529 fn custom_maximum_reports_the_configured_allowance() {
1530 let warnings = check(&MD012NoMultipleBlanks::new(2), "a\n\n\n\n\nb\n");
1531 assert_eq!(warnings.len(), 1, "{warnings:?}");
1532 assert_eq!(span(&warnings[0]), (4, 1, 6, 1));
1533 assert_eq!(
1534 warnings[0].message,
1535 "Multiple consecutive blank lines between content (4 found, 2 allowed)"
1536 );
1537 }
1538
1539 #[test]
1540 fn heading_adjacent_run_reports_the_md022_derived_allowance() {
1541 let rule = MD012NoMultipleBlanks::default().with_heading_limits(1, 2);
1542 let warnings = check(&rule, "# Title\n\n\n\n\nText\n");
1543 assert_eq!(warnings.len(), 1, "{warnings:?}");
1544 assert_eq!(span(&warnings[0]), (4, 1, 6, 1));
1545 assert_eq!(
1546 warnings[0].message,
1547 "Multiple consecutive blank lines between content (4 found, 2 allowed)"
1548 );
1549 }
1550
1551 #[test]
1552 fn start_of_file_run_is_one_warning() {
1553 let warnings = check(&MD012NoMultipleBlanks::default(), "\n\n\nText\n");
1554 assert_eq!(warnings.len(), 1, "{warnings:?}");
1555 assert_eq!(span(&warnings[0]), (2, 1, 4, 1));
1556 assert_eq!(
1557 warnings[0].message,
1558 "Multiple consecutive blank lines at start of file (3 found, 1 allowed)"
1559 );
1560 }
1561
1562 #[test]
1563 fn run_before_a_skipped_region_is_one_warning() {
1564 let warnings = check(&MD012NoMultipleBlanks::default(), "Text\n\n\n\n```\ncode\n```\n");
1565 assert_eq!(warnings.len(), 1, "{warnings:?}");
1566 assert_eq!(span(&warnings[0]), (3, 1, 5, 1));
1567 }
1568
1569 #[test]
1570 fn end_of_file_range_starts_at_the_first_trailing_blank_and_ends_at_eof() {
1571 for (source, expected_span, message) in [
1572 (
1573 "Text\n\n",
1574 (2, 1, 3, 1),
1575 "Blank line at end of file (1 found, 0 allowed)",
1576 ),
1577 (
1578 "Text\n\n\n",
1579 (2, 1, 4, 1),
1580 "Blank lines at end of file (2 found, 0 allowed)",
1581 ),
1582 (
1583 "Text\r\n\r\n\r\n",
1584 (2, 1, 4, 1),
1585 "Blank lines at end of file (2 found, 0 allowed)",
1586 ),
1587 (
1589 "Text\n\n ",
1590 (2, 1, 3, 3),
1591 "Blank lines at end of file (2 found, 0 allowed)",
1592 ),
1593 ] {
1594 let warnings = check(&MD012NoMultipleBlanks::default(), source);
1595 assert_eq!(warnings.len(), 1, "{source:?}: {warnings:?}");
1596 assert_eq!(span(&warnings[0]), expected_span, "{source:?}");
1597 assert_eq!(warnings[0].message, message, "{source:?}");
1598 }
1599 }
1600
1601 #[test]
1604 fn disable_line_on_the_following_content_line_does_not_suppress_the_run() {
1605 let rules: Vec<Box<dyn Rule>> = vec![Box::new(MD012NoMultipleBlanks::default())];
1606 let source = "Para\n\n\n\nNext <!-- rumdl-disable-line MD012 -->\n";
1607 let warnings = crate::lint(source, &rules, false, MarkdownFlavor::Standard, None, None).unwrap();
1608 assert_eq!(warnings.len(), 1, "{warnings:?}");
1609 assert_eq!(span(&warnings[0]), (3, 1, 5, 1));
1610
1611 let ctx = LintContext::new(source, MarkdownFlavor::Standard, None);
1612 assert_eq!(
1613 MD012NoMultipleBlanks::default().fix(&ctx).unwrap(),
1614 "Para\n\nNext <!-- rumdl-disable-line MD012 -->\n"
1615 );
1616 }
1617
1618 #[test]
1620 fn disable_block_around_the_run_suppresses_it() {
1621 let rules: Vec<Box<dyn Rule>> = vec![Box::new(MD012NoMultipleBlanks::default())];
1622 let source = "Para\n<!-- rumdl-disable MD012 -->\n\n\n\nNext\n<!-- rumdl-enable MD012 -->\n";
1623 let warnings = crate::lint(source, &rules, false, MarkdownFlavor::Standard, None, None).unwrap();
1624 assert!(warnings.is_empty(), "{warnings:?}");
1625 let ctx = LintContext::new(source, MarkdownFlavor::Standard, None);
1626 assert_eq!(MD012NoMultipleBlanks::default().fix(&ctx).unwrap(), source);
1627 }
1628
1629 #[test]
1633 fn disable_next_line_above_trailing_blanks_suppresses_them() {
1634 let rules: Vec<Box<dyn Rule>> = vec![Box::new(MD012NoMultipleBlanks::default())];
1635 for source in [
1636 "Text\n<!-- rumdl-disable-next-line MD012 -->\n\n",
1637 "Text\n<!-- rumdl-disable-next-line MD012 -->\n\n\n\n",
1638 ] {
1639 let warnings = crate::lint(source, &rules, false, MarkdownFlavor::Standard, None, None).unwrap();
1640 assert!(warnings.is_empty(), "{source:?}: {warnings:?}");
1641 let ctx = LintContext::new(source, MarkdownFlavor::Standard, None);
1642 assert_eq!(MD012NoMultipleBlanks::default().fix(&ctx).unwrap(), source);
1643 }
1644 }
1645
1646 #[test]
1647 fn lsp_diagnostic_covers_the_excess_lines_with_their_line_endings() {
1648 let source = "Line 1\n\n\n\nLine 2\n";
1649 let warnings = check(&MD012NoMultipleBlanks::default(), source);
1650 let diagnostic = crate::lsp::types::warning_to_diagnostic(&warnings[0], source);
1651 assert_eq!(
1652 (
1653 diagnostic.range.start.line,
1654 diagnostic.range.start.character,
1655 diagnostic.range.end.line,
1656 diagnostic.range.end.character
1657 ),
1658 (2, 0, 4, 0)
1659 );
1660 }
1661 }
1662}