1use crate::filtered_lines::FilteredLinesExt;
2use crate::lint_context::LintContext;
3use crate::lint_context::types::HeadingStyle;
4use crate::utils::LineIndex;
5use crate::utils::range_utils::calculate_line_range;
6use std::collections::HashSet;
7use toml;
8
9use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, Severity};
10use crate::rule_config_serde::RuleConfig;
11
12mod md012_config;
13use md012_config::MD012Config;
14
15#[derive(Debug, Clone, Default)]
20pub struct MD012NoMultipleBlanks {
21 config: MD012Config,
22}
23
24impl MD012NoMultipleBlanks {
25 pub fn new(maximum: usize) -> Self {
26 use crate::types::PositiveUsize;
27 Self {
28 config: MD012Config {
29 maximum: PositiveUsize::new(maximum).unwrap_or(PositiveUsize::from_const(1)),
30 },
31 }
32 }
33
34 pub const fn from_config_struct(config: MD012Config) -> Self {
35 Self { config }
36 }
37
38 fn generate_excess_warnings(
40 &self,
41 blank_start: usize,
42 blank_count: usize,
43 lines: &[&str],
44 lines_to_check: &HashSet<usize>,
45 line_index: &LineIndex,
46 ) -> Vec<LintWarning> {
47 let mut warnings = Vec::new();
48
49 let location = if blank_start == 0 {
50 "at start of file"
51 } else {
52 "between content"
53 };
54
55 for i in self.config.maximum.get()..blank_count {
56 let excess_line_num = blank_start + i;
57 if lines_to_check.contains(&excess_line_num) {
58 let excess_line = excess_line_num + 1;
59 let excess_line_content = lines.get(excess_line_num).unwrap_or(&"");
60 let (start_line, start_col, end_line, end_col) = calculate_line_range(excess_line, excess_line_content);
61 warnings.push(LintWarning {
62 rule_name: Some(self.name().to_string()),
63 severity: Severity::Warning,
64 message: format!("Multiple consecutive blank lines {location}"),
65 line: start_line,
66 column: start_col,
67 end_line,
68 end_column: end_col,
69 fix: Some(Fix {
70 range: {
71 let line_start = line_index.get_line_start_byte(excess_line).unwrap_or(0);
72 let line_end = line_index
73 .get_line_start_byte(excess_line + 1)
74 .unwrap_or(line_start + 1);
75 line_start..line_end
76 },
77 replacement: String::new(),
78 }),
79 });
80 }
81 }
82
83 warnings
84 }
85}
86
87fn is_heading_context(ctx: &LintContext, line_idx: usize) -> bool {
93 if let Some(line_info) = ctx.lines.get(line_idx) {
94 if line_info.heading.is_some() {
95 return true;
96 }
97 }
98 if line_idx > 0 {
100 if let Some(prev_info) = ctx.lines.get(line_idx - 1) {
101 if let Some(ref heading) = prev_info.heading {
102 if matches!(heading.style, HeadingStyle::Setext1 | HeadingStyle::Setext2) {
103 return true;
104 }
105 }
106 }
107 }
108 false
109}
110
111impl Rule for MD012NoMultipleBlanks {
112 fn name(&self) -> &'static str {
113 "MD012"
114 }
115
116 fn description(&self) -> &'static str {
117 "Multiple consecutive blank lines"
118 }
119
120 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
121 let content = ctx.content;
122
123 if content.is_empty() {
125 return Ok(Vec::new());
126 }
127
128 let lines = ctx.raw_lines();
131 let has_potential_blanks = lines
132 .windows(2)
133 .any(|pair| pair[0].trim().is_empty() && pair[1].trim().is_empty());
134
135 let ends_with_multiple_newlines = content.ends_with("\n\n");
138
139 if !has_potential_blanks && !ends_with_multiple_newlines {
140 return Ok(Vec::new());
141 }
142
143 let line_index = &ctx.line_index;
144
145 let mut warnings = Vec::new();
146
147 let mut blank_count = 0;
149 let mut blank_start = 0;
150 let mut last_line_num: Option<usize> = None;
151 let mut prev_content_line_num: Option<usize> = None;
153
154 let mut lines_to_check: HashSet<usize> = HashSet::new();
156
157 for filtered_line in ctx
164 .filtered_lines()
165 .skip_front_matter()
166 .skip_code_blocks()
167 .skip_quarto_divs()
168 .skip_math_blocks()
169 .skip_obsidian_comments()
170 .skip_pymdown_blocks()
171 {
172 let line_num = filtered_line.line_num - 1; let line = filtered_line.content;
174
175 if let Some(last) = last_line_num
178 && line_num > last + 1
179 {
180 if blank_count > self.config.maximum.get() {
183 let heading_adjacent = prev_content_line_num.is_some_and(|idx| is_heading_context(ctx, idx));
184 if !heading_adjacent {
185 warnings.extend(self.generate_excess_warnings(
186 blank_start,
187 blank_count,
188 lines,
189 &lines_to_check,
190 line_index,
191 ));
192 }
193 }
194 blank_count = 0;
195 lines_to_check.clear();
196 prev_content_line_num = None;
198 }
199 last_line_num = Some(line_num);
200
201 if line.trim().is_empty() {
202 if blank_count == 0 {
203 blank_start = line_num;
204 }
205 blank_count += 1;
206 if blank_count > self.config.maximum.get() {
208 lines_to_check.insert(line_num);
209 }
210 } else {
211 if blank_count > self.config.maximum.get() {
212 let heading_adjacent = prev_content_line_num.is_some_and(|idx| is_heading_context(ctx, idx))
216 || (blank_start > 0 && is_heading_context(ctx, line_num));
217 if !heading_adjacent {
218 warnings.extend(self.generate_excess_warnings(
219 blank_start,
220 blank_count,
221 lines,
222 &lines_to_check,
223 line_index,
224 ));
225 }
226 }
227 blank_count = 0;
228 lines_to_check.clear();
229 prev_content_line_num = Some(line_num);
230 }
231 }
232
233 let last_line_is_blank = lines.last().is_some_and(|l| l.trim().is_empty());
240
241 if blank_count > 0 && last_line_is_blank {
245 let location = "at end of file";
246
247 let report_line = lines.len();
249
250 let fix_start = line_index
253 .get_line_start_byte(report_line - blank_count + 1)
254 .unwrap_or(0);
255 let fix_end = content.len();
256
257 warnings.push(LintWarning {
259 rule_name: Some(self.name().to_string()),
260 severity: Severity::Warning,
261 message: format!("Multiple consecutive blank lines {location}"),
262 line: report_line,
263 column: 1,
264 end_line: report_line,
265 end_column: 1,
266 fix: Some(Fix {
267 range: fix_start..fix_end,
268 replacement: String::new(),
272 }),
273 });
274 }
275
276 Ok(warnings)
277 }
278
279 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
280 let content = ctx.content;
281
282 let mut result = Vec::new();
283 let mut blank_count = 0;
284
285 let mut in_code_block = false;
286 let mut code_block_blanks = Vec::new();
287 let mut in_front_matter = false;
288 let mut last_content_is_heading: bool = false;
290 let mut has_seen_content: bool = false;
292
293 for filtered_line in ctx.filtered_lines() {
295 let line = filtered_line.content;
296 let line_idx = filtered_line.line_num - 1; if filtered_line.line_info.in_front_matter {
300 if !in_front_matter {
301 let allowed_blanks = blank_count.min(self.config.maximum.get());
303 if allowed_blanks > 0 {
304 result.extend(vec![""; allowed_blanks]);
305 }
306 blank_count = 0;
307 in_front_matter = true;
308 last_content_is_heading = false;
309 }
310 result.push(line);
311 continue;
312 } else if in_front_matter {
313 in_front_matter = false;
315 last_content_is_heading = false;
316 }
317
318 if line.trim_start().starts_with("```") || line.trim_start().starts_with("~~~") {
320 if !in_code_block {
322 let heading_adjacent = last_content_is_heading;
323 if heading_adjacent {
324 for _ in 0..blank_count {
326 result.push("");
327 }
328 } else {
329 let allowed_blanks = blank_count.min(self.config.maximum.get());
330 if allowed_blanks > 0 {
331 result.extend(vec![""; allowed_blanks]);
332 }
333 }
334 blank_count = 0;
335 last_content_is_heading = false;
336 } else {
337 result.append(&mut code_block_blanks);
339 }
340 in_code_block = !in_code_block;
341 result.push(line);
342 continue;
343 }
344
345 if in_code_block {
346 if line.trim().is_empty() {
347 code_block_blanks.push(line);
348 } else {
349 result.append(&mut code_block_blanks);
350 result.push(line);
351 }
352 } else if line.trim().is_empty() {
353 blank_count += 1;
354 } else {
355 let heading_adjacent =
358 last_content_is_heading || (has_seen_content && is_heading_context(ctx, line_idx));
359 if heading_adjacent {
360 for _ in 0..blank_count {
362 result.push("");
363 }
364 } else {
365 let allowed_blanks = blank_count.min(self.config.maximum.get());
367 if allowed_blanks > 0 {
368 result.extend(vec![""; allowed_blanks]);
369 }
370 }
371 blank_count = 0;
372 last_content_is_heading = is_heading_context(ctx, line_idx);
373 has_seen_content = true;
374 result.push(line);
375 }
376 }
377
378 let mut output = result.join("\n");
382 if content.ends_with('\n') {
383 output.push('\n');
384 }
385
386 Ok(output)
387 }
388
389 fn as_any(&self) -> &dyn std::any::Any {
390 self
391 }
392
393 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
394 ctx.content.is_empty() || !ctx.has_char('\n')
396 }
397
398 fn default_config_section(&self) -> Option<(String, toml::Value)> {
399 let default_config = MD012Config::default();
400 let json_value = serde_json::to_value(&default_config).ok()?;
401 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
402
403 if let toml::Value::Table(table) = toml_value {
404 if !table.is_empty() {
405 Some((MD012Config::RULE_NAME.to_string(), toml::Value::Table(table)))
406 } else {
407 None
408 }
409 } else {
410 None
411 }
412 }
413
414 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
415 where
416 Self: Sized,
417 {
418 let rule_config = crate::rule_config_serde::load_rule_config::<MD012Config>(config);
419 Box::new(Self::from_config_struct(rule_config))
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 assert_eq!(result.len(), 3); assert_eq!(result[0].line, 3);
445 assert_eq!(result[1].line, 6);
446 assert_eq!(result[2].line, 7);
447 }
448
449 #[test]
450 fn test_custom_maximum() {
451 let rule = MD012NoMultipleBlanks::new(2);
452 let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
453 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
454 let result = rule.check(&ctx).unwrap();
455 assert_eq!(result.len(), 1); assert_eq!(result[0].line, 7);
457 }
458
459 #[test]
460 fn test_fix_multiple_blank_lines() {
461 let rule = MD012NoMultipleBlanks::default();
462 let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
463 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
464 let fixed = rule.fix(&ctx).unwrap();
465 assert_eq!(fixed, "Line 1\n\nLine 2\n\nLine 3");
466 }
467
468 #[test]
469 fn test_blank_lines_in_code_block() {
470 let rule = MD012NoMultipleBlanks::default();
471 let content = "Before\n\n```\ncode\n\n\n\nmore code\n```\n\nAfter";
472 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
473 let result = rule.check(&ctx).unwrap();
474 assert!(result.is_empty()); }
476
477 #[test]
478 fn test_fix_preserves_code_block_blanks() {
479 let rule = MD012NoMultipleBlanks::default();
480 let content = "Before\n\n\n```\ncode\n\n\n\nmore code\n```\n\n\nAfter";
481 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
482 let fixed = rule.fix(&ctx).unwrap();
483 assert_eq!(fixed, "Before\n\n```\ncode\n\n\n\nmore code\n```\n\nAfter");
484 }
485
486 #[test]
487 fn test_blank_lines_in_front_matter() {
488 let rule = MD012NoMultipleBlanks::default();
489 let content = "---\ntitle: Test\n\n\nauthor: Me\n---\n\nContent";
490 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
491 let result = rule.check(&ctx).unwrap();
492 assert!(result.is_empty()); }
494
495 #[test]
496 fn test_blank_lines_at_start() {
497 let rule = MD012NoMultipleBlanks::default();
498 let content = "\n\n\nContent";
499 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
500 let result = rule.check(&ctx).unwrap();
501 assert_eq!(result.len(), 2);
502 assert!(result[0].message.contains("at start of file"));
503 }
504
505 #[test]
506 fn test_blank_lines_at_end() {
507 let rule = MD012NoMultipleBlanks::default();
508 let content = "Content\n\n\n";
509 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
510 let result = rule.check(&ctx).unwrap();
511 assert_eq!(result.len(), 1);
512 assert!(result[0].message.contains("at end of file"));
513 }
514
515 #[test]
516 fn test_single_blank_at_eof_flagged() {
517 let rule = MD012NoMultipleBlanks::default();
519 let content = "Content\n\n";
520 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
521 let result = rule.check(&ctx).unwrap();
522 assert_eq!(result.len(), 1);
523 assert!(result[0].message.contains("at end of file"));
524 }
525
526 #[test]
527 fn test_whitespace_only_lines() {
528 let rule = MD012NoMultipleBlanks::default();
529 let content = "Line 1\n \n\t\nLine 2";
530 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
531 let result = rule.check(&ctx).unwrap();
532 assert_eq!(result.len(), 1); }
534
535 #[test]
536 fn test_indented_code_blocks() {
537 let rule = MD012NoMultipleBlanks::default();
539 let content = "Text\n\n code\n \n \n more code\n\nText";
540 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
541 let result = rule.check(&ctx).unwrap();
542 assert!(result.is_empty(), "Should not flag blanks inside indented code blocks");
543 }
544
545 #[test]
546 fn test_blanks_in_indented_code_block() {
547 let content = " code line 1\n\n\n code line 2\n";
549 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
550 let rule = MD012NoMultipleBlanks::default();
551 let warnings = rule.check(&ctx).unwrap();
552 assert!(warnings.is_empty(), "Should not flag blanks in indented code");
553 }
554
555 #[test]
556 fn test_blanks_in_indented_code_block_with_heading() {
557 let content = "# Heading\n\n code line 1\n\n\n code line 2\n\nMore text\n";
559 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
560 let rule = MD012NoMultipleBlanks::default();
561 let warnings = rule.check(&ctx).unwrap();
562 assert!(
563 warnings.is_empty(),
564 "Should not flag blanks in indented code after heading"
565 );
566 }
567
568 #[test]
569 fn test_blanks_after_indented_code_block_flagged() {
570 let content = "# Heading\n\n code line\n\n\n\nMore text\n";
572 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
573 let rule = MD012NoMultipleBlanks::default();
574 let warnings = rule.check(&ctx).unwrap();
575 assert_eq!(warnings.len(), 2, "Should flag blanks after indented code block ends");
577 }
578
579 #[test]
580 fn test_fix_with_final_newline() {
581 let rule = MD012NoMultipleBlanks::default();
582 let content = "Line 1\n\n\nLine 2\n";
583 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
584 let fixed = rule.fix(&ctx).unwrap();
585 assert_eq!(fixed, "Line 1\n\nLine 2\n");
586 assert!(fixed.ends_with('\n'));
587 }
588
589 #[test]
590 fn test_empty_content() {
591 let rule = MD012NoMultipleBlanks::default();
592 let content = "";
593 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
594 let result = rule.check(&ctx).unwrap();
595 assert!(result.is_empty());
596 }
597
598 #[test]
599 fn test_nested_code_blocks() {
600 let rule = MD012NoMultipleBlanks::default();
601 let content = "Before\n\n~~~\nouter\n\n```\ninner\n\n\n```\n\n~~~\n\nAfter";
602 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
603 let result = rule.check(&ctx).unwrap();
604 assert!(result.is_empty());
605 }
606
607 #[test]
608 fn test_unclosed_code_block() {
609 let rule = MD012NoMultipleBlanks::default();
610 let content = "Before\n\n```\ncode\n\n\n\nno closing fence";
611 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
612 let result = rule.check(&ctx).unwrap();
613 assert!(result.is_empty()); }
615
616 #[test]
617 fn test_mixed_fence_styles() {
618 let rule = MD012NoMultipleBlanks::default();
619 let content = "Before\n\n```\ncode\n\n\n~~~\n\nAfter";
620 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
621 let result = rule.check(&ctx).unwrap();
622 assert!(result.is_empty()); }
624
625 #[test]
626 fn test_config_from_toml() {
627 let mut config = crate::config::Config::default();
628 let mut rule_config = crate::config::RuleConfig::default();
629 rule_config
630 .values
631 .insert("maximum".to_string(), toml::Value::Integer(3));
632 config.rules.insert("MD012".to_string(), rule_config);
633
634 let rule = MD012NoMultipleBlanks::from_config(&config);
635 let content = "Line 1\n\n\n\nLine 2"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
637 let result = rule.check(&ctx).unwrap();
638 assert!(result.is_empty()); }
640
641 #[test]
642 fn test_blank_lines_between_sections() {
643 let rule = MD012NoMultipleBlanks::default();
645 let content = "# Section 1\n\nContent\n\n\n# Section 2\n\nContent";
646 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
647 let result = rule.check(&ctx).unwrap();
648 assert!(result.is_empty(), "Blanks adjacent to headings should not be flagged");
649 }
650
651 #[test]
652 fn test_fix_preserves_indented_code() {
653 let rule = MD012NoMultipleBlanks::default();
654 let content = "Text\n\n\n code\n \n more code\n\n\nText";
655 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
656 let fixed = rule.fix(&ctx).unwrap();
657 assert_eq!(fixed, "Text\n\n code\n\n more code\n\nText");
659 }
660
661 #[test]
662 fn test_edge_case_only_blanks() {
663 let rule = MD012NoMultipleBlanks::default();
664 let content = "\n\n\n";
665 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
666 let result = rule.check(&ctx).unwrap();
667 assert_eq!(result.len(), 1);
669 assert!(result[0].message.contains("at end of file"));
670 }
671
672 #[test]
675 fn test_blanks_after_fenced_code_block_mid_document() {
676 let rule = MD012NoMultipleBlanks::default();
678 let content = "## Input\n\n```javascript\ncode\n```\n\n\n## Error\n";
679 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
680 let result = rule.check(&ctx).unwrap();
681 assert!(result.is_empty(), "Blanks adjacent to heading should not be flagged");
682 }
683
684 #[test]
685 fn test_blanks_after_code_block_at_eof() {
686 let rule = MD012NoMultipleBlanks::default();
688 let content = "# Heading\n\n```\ncode\n```\n\n\n";
689 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
690 let result = rule.check(&ctx).unwrap();
691 assert_eq!(result.len(), 1, "Should detect trailing blanks after code block");
693 assert!(result[0].message.contains("at end of file"));
694 }
695
696 #[test]
697 fn test_single_blank_after_code_block_allowed() {
698 let rule = MD012NoMultipleBlanks::default();
700 let content = "## Input\n\n```\ncode\n```\n\n## Output\n";
701 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
702 let result = rule.check(&ctx).unwrap();
703 assert!(result.is_empty(), "Single blank after code block should be allowed");
704 }
705
706 #[test]
707 fn test_multiple_code_blocks_with_blanks() {
708 let rule = MD012NoMultipleBlanks::default();
710 let content = "```\ncode1\n```\n\n\n```\ncode2\n```\n\n\nEnd\n";
711 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
712 let result = rule.check(&ctx).unwrap();
713 assert_eq!(result.len(), 2, "Should detect blanks after both code blocks");
715 }
716
717 #[test]
718 fn test_whitespace_only_lines_after_code_block_at_eof() {
719 let rule = MD012NoMultipleBlanks::default();
722 let content = "```\ncode\n```\n \n \n";
723 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
724 let result = rule.check(&ctx).unwrap();
725 assert_eq!(result.len(), 1, "Should detect whitespace-only trailing blanks");
726 assert!(result[0].message.contains("at end of file"));
727 }
728
729 #[test]
732 fn test_warning_fix_removes_single_trailing_blank() {
733 let rule = MD012NoMultipleBlanks::default();
735 let content = "hello foobar hello.\n\n";
736 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
737 let warnings = rule.check(&ctx).unwrap();
738
739 assert_eq!(warnings.len(), 1);
740 assert!(warnings[0].fix.is_some(), "Warning should have a fix attached");
741
742 let fix = warnings[0].fix.as_ref().unwrap();
743 assert_eq!(fix.replacement, "", "Replacement should be empty");
745
746 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
748 assert_eq!(fixed, "hello foobar hello.\n", "Should end with single newline");
749 }
750
751 #[test]
752 fn test_warning_fix_removes_multiple_trailing_blanks() {
753 let rule = MD012NoMultipleBlanks::default();
754 let content = "content\n\n\n\n";
755 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
756 let warnings = rule.check(&ctx).unwrap();
757
758 assert_eq!(warnings.len(), 1);
759 assert!(warnings[0].fix.is_some());
760
761 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
762 assert_eq!(fixed, "content\n", "Should end with single newline");
763 }
764
765 #[test]
766 fn test_warning_fix_preserves_content_newline() {
767 let rule = MD012NoMultipleBlanks::default();
769 let content = "line1\nline2\n\n";
770 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
771 let warnings = rule.check(&ctx).unwrap();
772
773 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
774 assert_eq!(fixed, "line1\nline2\n", "Should preserve all content lines");
775 }
776
777 #[test]
778 fn test_warning_fix_mid_document_blanks() {
779 let rule = MD012NoMultipleBlanks::default();
781 let content = "# Heading\n\n\n\nParagraph\n";
782 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
783 let warnings = rule.check(&ctx).unwrap();
784
785 assert!(warnings.is_empty(), "Blanks adjacent to heading should not be flagged");
787 }
788
789 #[test]
793 fn test_heading_aware_atx_blanks_below() {
794 let rule = MD012NoMultipleBlanks::default();
796 let content = "# Heading\n\n\nParagraph\n";
797 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
798 let result = rule.check(&ctx).unwrap();
799 assert!(result.is_empty(), "Blanks below ATX heading should not be flagged");
800 }
801
802 #[test]
803 fn test_heading_aware_atx_blanks_above() {
804 let rule = MD012NoMultipleBlanks::default();
806 let content = "Paragraph\n\n\n# Heading\n";
807 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
808 let result = rule.check(&ctx).unwrap();
809 assert!(result.is_empty(), "Blanks above ATX heading should not be flagged");
810 }
811
812 #[test]
813 fn test_heading_aware_atx_blanks_between() {
814 let rule = MD012NoMultipleBlanks::default();
816 let content = "# Heading 1\n\n\n## Heading 2\n";
817 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
818 let result = rule.check(&ctx).unwrap();
819 assert!(result.is_empty(), "Blanks between headings should not be flagged");
820 }
821
822 #[test]
823 fn test_heading_aware_setext_equals_blanks_below() {
824 let rule = MD012NoMultipleBlanks::default();
826 let content = "Heading\n=======\n\n\nParagraph\n";
827 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
828 let result = rule.check(&ctx).unwrap();
829 assert!(
830 result.is_empty(),
831 "Blanks below Setext === heading should not be flagged"
832 );
833 }
834
835 #[test]
836 fn test_heading_aware_setext_dashes_blanks_below() {
837 let rule = MD012NoMultipleBlanks::default();
839 let content = "Heading\n-------\n\n\nParagraph\n";
840 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
841 let result = rule.check(&ctx).unwrap();
842 assert!(
843 result.is_empty(),
844 "Blanks below Setext --- heading should not be flagged"
845 );
846 }
847
848 #[test]
849 fn test_heading_aware_setext_blanks_above() {
850 let rule = MD012NoMultipleBlanks::default();
852 let content = "Paragraph\n\n\nHeading\n=======\n";
853 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
854 let result = rule.check(&ctx).unwrap();
855 assert!(result.is_empty(), "Blanks above Setext heading should not be flagged");
856 }
857
858 #[test]
859 fn test_heading_aware_non_heading_blanks_still_flagged() {
860 let rule = MD012NoMultipleBlanks::default();
862 let content = "Paragraph 1\n\n\nParagraph 2\n";
863 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
864 let result = rule.check(&ctx).unwrap();
865 assert_eq!(result.len(), 1, "Non-heading blanks should still be flagged");
866 }
867
868 #[test]
869 fn test_heading_aware_md022_coexistence() {
870 let rule = MD012NoMultipleBlanks::default();
872 let content = "# Title\n\n\n## Subtitle\n\nContent\n";
873 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
874 let result = rule.check(&ctx).unwrap();
875 assert!(result.is_empty(), "Should allow blanks for MD022 heading spacing");
876 }
877
878 #[test]
879 fn test_heading_aware_fix_preserves_heading_blanks() {
880 let rule = MD012NoMultipleBlanks::default();
882 let content = "# Heading\n\n\n\nParagraph\n";
883 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
884 let fixed = rule.fix(&ctx).unwrap();
885 assert_eq!(
886 fixed, "# Heading\n\n\n\nParagraph\n",
887 "Fix should preserve heading-adjacent blanks"
888 );
889 }
890
891 #[test]
892 fn test_heading_aware_fix_reduces_non_heading_blanks() {
893 let rule = MD012NoMultipleBlanks::default();
895 let content = "Paragraph 1\n\n\n\nParagraph 2\n";
896 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
897 let fixed = rule.fix(&ctx).unwrap();
898 assert_eq!(
899 fixed, "Paragraph 1\n\nParagraph 2\n",
900 "Fix should reduce non-heading blanks"
901 );
902 }
903
904 #[test]
905 fn test_heading_aware_mixed_heading_and_non_heading() {
906 let rule = MD012NoMultipleBlanks::default();
908 let content = "# Heading\n\n\nParagraph 1\n\n\nParagraph 2\n";
909 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
910 let result = rule.check(&ctx).unwrap();
911 assert_eq!(result.len(), 1, "Should flag only non-heading blanks");
913 assert_eq!(result[0].line, 6, "Warning should be on the non-heading blank");
914 }
915
916 #[test]
917 fn test_heading_aware_blanks_at_start_before_heading_still_flagged() {
918 let rule = MD012NoMultipleBlanks::default();
921 let content = "\n\n\n# Heading\n";
922 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
923 let result = rule.check(&ctx).unwrap();
924 assert_eq!(
925 result.len(),
926 2,
927 "Start-of-file blanks should be flagged even before heading"
928 );
929 assert!(result[0].message.contains("at start of file"));
930 }
931
932 #[test]
933 fn test_heading_aware_eof_blanks_after_heading_still_flagged() {
934 let rule = MD012NoMultipleBlanks::default();
936 let content = "# Heading\n\n";
937 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
938 let result = rule.check(&ctx).unwrap();
939 assert_eq!(result.len(), 1, "EOF blanks should still be flagged");
940 assert!(result[0].message.contains("at end of file"));
941 }
942
943 #[test]
944 fn test_heading_aware_custom_maximum_with_headings() {
945 let rule = MD012NoMultipleBlanks::new(2);
947 let content = "# Heading\n\n\n\n\nParagraph\n";
948 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
949 let result = rule.check(&ctx).unwrap();
950 assert!(
951 result.is_empty(),
952 "Any number of heading-adjacent blanks should be allowed"
953 );
954 }
955
956 #[test]
957 fn test_heading_aware_blanks_after_code_then_heading() {
958 let rule = MD012NoMultipleBlanks::default();
961 let content = "# Heading\n\n```\ncode\n```\n\n\n\nMore text\n";
962 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
963 let result = rule.check(&ctx).unwrap();
964 assert_eq!(result.len(), 2, "Non-heading blanks after code block should be flagged");
966 }
967
968 #[test]
969 fn test_heading_aware_fix_mixed_document() {
970 let rule = MD012NoMultipleBlanks::default();
972 let content = "# Title\n\n\n## Section\n\n\nPara 1\n\n\nPara 2\n";
973 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
974 let fixed = rule.fix(&ctx).unwrap();
975 assert_eq!(fixed, "# Title\n\n\n## Section\n\n\nPara 1\n\nPara 2\n");
977 }
978
979 #[test]
982 fn test_blank_lines_in_quarto_callout() {
983 let rule = MD012NoMultipleBlanks::default();
985 let content = "# Heading\n\n::: {.callout-note}\nNote content\n\n\nMore content\n:::\n\nAfter";
986 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
987 let result = rule.check(&ctx).unwrap();
988 assert!(result.is_empty(), "Should not flag blanks inside Quarto callouts");
989 }
990
991 #[test]
992 fn test_blank_lines_in_quarto_div() {
993 let rule = MD012NoMultipleBlanks::default();
995 let content = "Text\n\n::: {.bordered}\nContent\n\n\nMore\n:::\n\nText";
996 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
997 let result = rule.check(&ctx).unwrap();
998 assert!(result.is_empty(), "Should not flag blanks inside Quarto divs");
999 }
1000
1001 #[test]
1002 fn test_blank_lines_outside_quarto_div_flagged() {
1003 let rule = MD012NoMultipleBlanks::default();
1005 let content = "Text\n\n\n::: {.callout-note}\nNote\n:::\n\n\nMore";
1006 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1007 let result = rule.check(&ctx).unwrap();
1008 assert!(!result.is_empty(), "Should flag blanks outside Quarto divs");
1009 }
1010
1011 #[test]
1012 fn test_quarto_divs_ignored_in_standard_flavor() {
1013 let rule = MD012NoMultipleBlanks::default();
1015 let content = "::: {.callout-note}\nNote content\n\n\nMore content\n:::\n";
1016 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1017 let result = rule.check(&ctx).unwrap();
1018 assert!(!result.is_empty(), "Standard flavor should flag blanks in 'div'");
1020 }
1021}