1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
5use crate::utils::mkdocs_attr_list::is_block_attribute_line;
6use crate::utils::pandoc;
7use crate::utils::range_utils::calculate_heading_range;
8use toml;
9
10pub(crate) mod md022_config;
11use md022_config::MD022Config;
12
13fn starts_with_list_marker(trimmed: &str) -> bool {
21 let bytes = trimmed.as_bytes();
22 match bytes.first() {
23 Some(b'-' | b'*' | b'+') => matches!(bytes.get(1), None | Some(b' ')),
24 Some(b'0'..=b'9') => {
25 let mut i = 0;
26 while bytes.get(i).is_some_and(u8::is_ascii_digit) {
27 i += 1;
28 }
29 matches!(bytes.get(i), Some(b'.' | b')')) && matches!(bytes.get(i + 1), None | Some(b' '))
30 }
31 _ => false,
32 }
33}
34
35#[derive(Clone, Default)]
107pub struct MD022BlanksAroundHeadings {
108 config: MD022Config,
109}
110
111impl MD022BlanksAroundHeadings {
112 pub fn new() -> Self {
115 Self {
116 config: MD022Config::default(),
117 }
118 }
119
120 pub fn with_values(lines_above: usize, lines_below: usize) -> Self {
122 use md022_config::HeadingLevelConfig;
123 Self {
124 config: MD022Config {
125 lines_above: HeadingLevelConfig::scalar(lines_above),
126 lines_below: HeadingLevelConfig::scalar(lines_below),
127 allowed_at_start: true,
128 },
129 }
130 }
131
132 pub fn from_config_struct(config: MD022Config) -> Self {
133 Self { config }
134 }
135
136 fn fix_content(&self, ctx: &crate::lint_context::LintContext) -> String {
138 let line_ending = "\n";
140 let had_trailing_newline = ctx.content.ends_with('\n');
141 let is_pandoc = ctx.flavor.is_pandoc_compatible();
142 let mut result = Vec::new();
143 let mut skip_count: usize = 0;
144
145 let heading_at_start_idx = {
146 let mut found_non_transparent = false;
147 ctx.lines.iter().enumerate().find_map(|(i, line)| {
148 if line.heading.as_ref().is_some_and(|h| h.is_valid) && !found_non_transparent {
150 Some(i)
151 } else {
152 if !line.is_blank && !line.in_html_comment && !line.in_mdx_comment {
155 let trimmed = line.content(ctx.content).trim();
156 if trimmed.starts_with("<!--") && trimmed.ends_with("-->") {
158 } else if line.in_kramdown_extension_block || line.is_kramdown_block_ial {
160 } else if is_pandoc && (pandoc::is_div_open(trimmed) || pandoc::is_div_close(trimmed)) {
162 } else {
164 found_non_transparent = true;
165 }
166 }
167 None
168 }
169 })
170 };
171
172 for (i, line_info) in ctx.lines.iter().enumerate() {
173 if skip_count > 0 {
174 skip_count -= 1;
175 continue;
176 }
177 let line = line_info.content(ctx.content);
178
179 if line_info.in_code_block {
180 result.push(line.to_string());
181 continue;
182 }
183
184 if let Some(heading) = &line_info.heading {
186 if !heading.is_valid {
188 result.push(line.to_string());
189 continue;
190 }
191
192 let line_num = i + 1;
194 if ctx.inline_config().is_rule_disabled("MD022", line_num) {
195 result.push(line.to_string());
196 if matches!(
198 heading.style,
199 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
200 ) && i + 1 < ctx.lines.len()
201 {
202 result.push(ctx.lines[i + 1].content(ctx.content).to_string());
203 skip_count += 1;
204 }
205 continue;
206 }
207
208 let is_first_heading = Some(i) == heading_at_start_idx;
210 let heading_level = heading.level as usize;
211
212 let mut blank_lines_above = 0;
214 let mut check_idx = result.len();
215 while check_idx > 0 {
216 let prev_line = &result[check_idx - 1];
217 let trimmed = prev_line.trim();
218 if trimmed.is_empty() {
219 blank_lines_above += 1;
220 check_idx -= 1;
221 } else if trimmed.starts_with("<!--") && trimmed.ends_with("-->") {
222 check_idx -= 1;
224 } else if is_block_attribute_line(trimmed, ctx.flavor) {
225 check_idx -= 1;
227 } else if is_pandoc && (pandoc::is_div_open(trimmed) || pandoc::is_div_close(trimmed)) {
228 check_idx -= 1;
230 } else {
231 break;
232 }
233 }
234
235 let requirement_above = self.config.lines_above.get_for_level(heading_level);
237 let needed_blanks_above = if is_first_heading && self.config.allowed_at_start {
238 0
239 } else {
240 requirement_above.required_count().unwrap_or(0)
241 };
242
243 while blank_lines_above < needed_blanks_above {
245 result.push(String::new());
246 blank_lines_above += 1;
247 }
248
249 result.push(line.to_string());
251
252 let mut effective_end_idx = i;
254
255 if matches!(
257 heading.style,
258 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
259 ) {
260 if i + 1 < ctx.lines.len() {
262 result.push(ctx.lines[i + 1].content(ctx.content).to_string());
263 skip_count += 1; effective_end_idx = i + 1;
265 }
266 }
267
268 let mut ial_count = 0;
271 while effective_end_idx + 1 < ctx.lines.len() {
272 let next_line = &ctx.lines[effective_end_idx + 1];
273 let next_trimmed = next_line.content(ctx.content).trim();
274 if is_block_attribute_line(next_trimmed, ctx.flavor) {
275 result.push(next_trimmed.to_string());
276 effective_end_idx += 1;
277 ial_count += 1;
278 } else {
279 break;
280 }
281 }
282
283 let mut blank_lines_below = 0;
285 let mut next_content_line_idx = None;
286 for j in (effective_end_idx + 1)..ctx.lines.len() {
287 if ctx.lines[j].is_blank {
288 blank_lines_below += 1;
289 } else {
290 next_content_line_idx = Some(j);
291 break;
292 }
293 }
294
295 let next_is_special = if let Some(idx) = next_content_line_idx {
297 let next_line = &ctx.lines[idx];
298 next_line.list_item.is_some() || {
299 let trimmed = next_line.content(ctx.content).trim();
300 (trimmed.starts_with("```") || trimmed.starts_with("~~~"))
301 && (trimmed.len() == 3
302 || (trimmed.len() > 3
303 && trimmed
304 .chars()
305 .nth(3)
306 .is_some_and(|c| c.is_whitespace() || c.is_alphabetic())))
307 }
308 } else {
309 false
310 };
311
312 let requirement_below = self.config.lines_below.get_for_level(heading_level);
314 let needed_blanks_below = if next_is_special {
315 0
316 } else {
317 requirement_below.required_count().unwrap_or(0)
318 };
319 if blank_lines_below < needed_blanks_below {
320 for _ in 0..(needed_blanks_below - blank_lines_below) {
321 result.push(String::new());
322 }
323 }
324
325 skip_count += ial_count;
327 } else {
328 result.push(line.to_string());
330 }
331 }
332
333 let joined = result.join(line_ending);
334
335 if had_trailing_newline && !joined.ends_with('\n') {
338 format!("{joined}{line_ending}")
339 } else if !had_trailing_newline && joined.ends_with('\n') {
340 joined[..joined.len() - 1].to_string()
342 } else {
343 joined
344 }
345 }
346}
347
348impl Rule for MD022BlanksAroundHeadings {
349 fn name(&self) -> &'static str {
350 "MD022"
351 }
352
353 fn description(&self) -> &'static str {
354 "Headings should be surrounded by blank lines"
355 }
356
357 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
358 let mut result = Vec::new();
359
360 if ctx.lines.is_empty() {
362 return Ok(result);
363 }
364
365 let line_ending = "\n";
367 let is_pandoc = ctx.flavor.is_pandoc_compatible();
368
369 let heading_at_start_idx = {
370 let mut found_non_transparent = false;
371 ctx.lines.iter().enumerate().find_map(|(i, line)| {
372 if line.heading.as_ref().is_some_and(|h| h.is_valid) && !found_non_transparent {
374 Some(i)
375 } else {
376 if !line.is_blank && !line.in_html_comment && !line.in_mdx_comment {
379 let trimmed = line.content(ctx.content).trim();
380 if trimmed.starts_with("<!--") && trimmed.ends_with("-->") {
382 } else if line.in_kramdown_extension_block || line.is_kramdown_block_ial {
384 } else if is_pandoc && (pandoc::is_div_open(trimmed) || pandoc::is_div_close(trimmed)) {
386 } else {
388 found_non_transparent = true;
389 }
390 }
391 None
392 }
393 })
394 };
395
396 let mut heading_violations = Vec::new();
398 let mut processed_headings = std::collections::HashSet::new();
399
400 for (line_num, line_info) in ctx.lines.iter().enumerate() {
401 if processed_headings.contains(&line_num) || line_info.heading.is_none() {
403 continue;
404 }
405
406 if line_info.in_pymdown_block {
408 continue;
409 }
410
411 let heading = line_info.heading.as_ref().unwrap();
412
413 if !heading.is_valid {
415 continue;
416 }
417
418 let heading_level = heading.level as usize;
419
420 processed_headings.insert(line_num);
424
425 let is_first_heading = Some(line_num) == heading_at_start_idx;
427
428 let required_above_count = self.config.lines_above.get_for_level(heading_level).required_count();
430 let required_below_count = self.config.lines_below.get_for_level(heading_level).required_count();
431
432 let should_check_above =
434 required_above_count.is_some() && line_num > 0 && (!is_first_heading || !self.config.allowed_at_start);
435 if should_check_above {
436 let mut blank_lines_above = 0;
437 let mut hit_frontmatter_end = false;
438 for j in (0..line_num).rev() {
439 let line_content = ctx.lines[j].content(ctx.content);
440 let trimmed = line_content.trim();
441 if ctx.lines[j].is_blank {
442 blank_lines_above += 1;
443 } else if ctx.lines[j].in_html_comment
444 || ctx.lines[j].in_mdx_comment
445 || (trimmed.starts_with("<!--") && trimmed.ends_with("-->"))
446 {
447 continue;
449 } else if is_block_attribute_line(trimmed, ctx.flavor) {
450 continue;
452 } else if is_pandoc && (pandoc::is_div_open(trimmed) || pandoc::is_div_close(trimmed)) {
453 continue;
455 } else if ctx.lines[j].in_front_matter {
456 hit_frontmatter_end = true;
461 break;
462 } else {
463 break;
464 }
465 }
466 let required = required_above_count.unwrap();
467 if !hit_frontmatter_end && blank_lines_above < required {
468 let needed_blanks = required - blank_lines_above;
469 heading_violations.push((line_num, "above", needed_blanks, heading_level));
470 }
471 }
472
473 let mut effective_last_line = if matches!(
475 heading.style,
476 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
477 ) {
478 line_num + 1 } else {
480 line_num
481 };
482
483 while effective_last_line + 1 < ctx.lines.len() {
486 let next_line = &ctx.lines[effective_last_line + 1];
487 let next_trimmed = next_line.content(ctx.content).trim();
488 if is_block_attribute_line(next_trimmed, ctx.flavor) {
489 effective_last_line += 1;
490 } else {
491 break;
492 }
493 }
494
495 if effective_last_line < ctx.lines.len() - 1 {
497 let mut next_non_blank_idx = effective_last_line + 1;
499 while next_non_blank_idx < ctx.lines.len() {
500 let check_line = &ctx.lines[next_non_blank_idx];
501 let check_trimmed = check_line.content(ctx.content).trim();
502 if check_line.is_blank {
503 next_non_blank_idx += 1;
504 } else if check_line.in_html_comment
505 || check_line.in_mdx_comment
506 || (check_trimmed.starts_with("<!--") && check_trimmed.ends_with("-->"))
507 {
508 next_non_blank_idx += 1;
510 } else if is_pandoc && (pandoc::is_div_open(check_trimmed) || pandoc::is_div_close(check_trimmed)) {
511 next_non_blank_idx += 1;
513 } else {
514 break;
515 }
516 }
517
518 if next_non_blank_idx >= ctx.lines.len() {
520 continue;
522 }
523
524 let next_line_is_special = {
526 let next_line = &ctx.lines[next_non_blank_idx];
527 let next_trimmed = next_line.content(ctx.content).trim();
528
529 let is_code_fence = (next_trimmed.starts_with("```") || next_trimmed.starts_with("~~~"))
531 && (next_trimmed.len() == 3
532 || (next_trimmed.len() > 3
533 && next_trimmed
534 .chars()
535 .nth(3)
536 .is_some_and(|c| c.is_whitespace() || c.is_alphabetic())));
537
538 let is_list_item = next_line.list_item.is_some() || starts_with_list_marker(next_trimmed);
545
546 is_code_fence || is_list_item
547 };
548
549 if !next_line_is_special && let Some(required) = required_below_count {
551 let mut blank_lines_below = 0;
553 for k in (effective_last_line + 1)..next_non_blank_idx {
554 if ctx.lines[k].is_blank {
555 blank_lines_below += 1;
556 }
557 }
558
559 if blank_lines_below < required {
560 let needed_blanks = required - blank_lines_below;
561 heading_violations.push((line_num, "below", needed_blanks, heading_level));
562 }
563 }
564 }
565 }
566
567 for (heading_line, position, needed_blanks, heading_level) in heading_violations {
569 let heading_display_line = heading_line + 1; let line_info = &ctx.lines[heading_line];
571
572 let (start_line, start_col, end_line, end_col) =
574 calculate_heading_range(heading_display_line, line_info.content(ctx.content));
575
576 let (message, insertion_point) = match position {
583 "above" => {
584 let Some(required_above_count) =
585 self.config.lines_above.get_for_level(heading_level).required_count()
586 else {
587 continue;
588 };
589 (
590 format!(
591 "Expected {} blank {} above heading",
592 required_above_count,
593 if required_above_count == 1 { "line" } else { "lines" }
594 ),
595 heading_line, )
597 }
598 "below" => {
599 let Some(required_below_count) =
600 self.config.lines_below.get_for_level(heading_level).required_count()
601 else {
602 continue;
603 };
604 let insert_after = if line_info.heading.as_ref().is_some_and(|h| {
606 matches!(
607 h.style,
608 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
609 )
610 }) {
611 heading_line + 2
612 } else {
613 heading_line + 1
614 };
615
616 (
617 format!(
618 "Expected {} blank {} below heading",
619 required_below_count,
620 if required_below_count == 1 { "line" } else { "lines" }
621 ),
622 insert_after,
623 )
624 }
625 _ => continue,
626 };
627
628 let byte_range = if insertion_point == 0 && position == "above" {
630 0..0
632 } else if position == "above" && insertion_point > 0 {
633 ctx.lines[insertion_point].byte_offset..ctx.lines[insertion_point].byte_offset
635 } else if position == "below" && insertion_point - 1 < ctx.lines.len() {
636 let line_idx = insertion_point - 1;
638 let line_end_offset = if line_idx + 1 < ctx.lines.len() {
639 ctx.lines[line_idx + 1].byte_offset
640 } else {
641 ctx.content.len()
642 };
643 line_end_offset..line_end_offset
644 } else {
645 let content_len = ctx.content.len();
647 content_len..content_len
648 };
649
650 result.push(LintWarning {
651 rule_name: Some(self.name().to_string()),
652 message,
653 line: start_line,
654 column: start_col,
655 end_line,
656 end_column: end_col,
657 severity: Severity::Warning,
658 fix: Some(Fix::new(byte_range, line_ending.repeat(needed_blanks))),
659 });
660 }
661
662 Ok(result)
663 }
664
665 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
666 if ctx.content.is_empty() {
667 return Ok(ctx.content.to_string());
668 }
669
670 let fixed = self.fix_content(ctx);
672
673 Ok(fixed)
674 }
675
676 fn category(&self) -> RuleCategory {
678 RuleCategory::Heading
679 }
680
681 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
683 if ctx.content.is_empty() || !ctx.likely_has_headings() {
685 return true;
686 }
687 ctx.lines.iter().all(|line| line.heading.is_none())
689 }
690
691 fn as_any(&self) -> &dyn std::any::Any {
692 self
693 }
694
695 crate::impl_rule_config_methods!(MD022Config);
696}
697
698#[cfg(test)]
699mod tests {
700 use super::*;
701 use crate::lint_context::LintContext;
702
703 #[test]
704 fn test_valid_headings() {
705 let rule = MD022BlanksAroundHeadings::default();
706 let content = "\n# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.\n";
707 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
708 let result = rule.check(&ctx).unwrap();
709 assert!(result.is_empty());
710 }
711
712 #[test]
713 fn test_missing_blank_above() {
714 let rule = MD022BlanksAroundHeadings::default();
715 let content = "# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.\n";
716 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
717 let result = rule.check(&ctx).unwrap();
718 assert_eq!(result.len(), 0); let fixed = rule.fix(&ctx).unwrap();
721
722 assert!(fixed.contains("# Heading 1"));
725 assert!(fixed.contains("Some content."));
726 assert!(fixed.contains("## Heading 2"));
727 assert!(fixed.contains("More content."));
728 }
729
730 #[test]
731 fn test_missing_blank_below() {
732 let rule = MD022BlanksAroundHeadings::default();
733 let content = "\n# Heading 1\nSome content.\n\n## Heading 2\n\nMore content.\n";
734 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
735 let result = rule.check(&ctx).unwrap();
736 assert_eq!(result.len(), 1);
737 assert_eq!(result[0].line, 2);
738
739 let fixed = rule.fix(&ctx).unwrap();
741 assert!(fixed.contains("# Heading 1\n\nSome content"));
742 }
743
744 #[test]
745 fn test_missing_blank_above_and_below() {
746 let rule = MD022BlanksAroundHeadings::default();
747 let content = "# Heading 1\nSome content.\n## Heading 2\nMore content.\n";
748 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
749 let result = rule.check(&ctx).unwrap();
750 assert_eq!(result.len(), 3); let fixed = rule.fix(&ctx).unwrap();
754 assert!(fixed.contains("# Heading 1\n\nSome content"));
755 assert!(fixed.contains("Some content.\n\n## Heading 2"));
756 assert!(fixed.contains("## Heading 2\n\nMore content"));
757 }
758
759 #[test]
760 fn test_fix_headings() {
761 let rule = MD022BlanksAroundHeadings::default();
762 let content = "# Heading 1\nSome content.\n## Heading 2\nMore content.";
763 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
764 let result = rule.fix(&ctx).unwrap();
765
766 let expected = "# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.";
767 assert_eq!(result, expected);
768 }
769
770 #[test]
771 fn test_consecutive_headings_pattern() {
772 let rule = MD022BlanksAroundHeadings::default();
773 let content = "# Heading 1\n## Heading 2\n### Heading 3";
774 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
775 let result = rule.fix(&ctx).unwrap();
776
777 let lines: Vec<&str> = result.lines().collect();
779 assert!(!lines.is_empty());
780
781 let h1_pos = lines.iter().position(|&l| l == "# Heading 1").unwrap();
783 let h2_pos = lines.iter().position(|&l| l == "## Heading 2").unwrap();
784 let h3_pos = lines.iter().position(|&l| l == "### Heading 3").unwrap();
785
786 assert!(
788 h2_pos > h1_pos + 1,
789 "Should have at least one blank line after first heading"
790 );
791 assert!(
792 h3_pos > h2_pos + 1,
793 "Should have at least one blank line after second heading"
794 );
795
796 assert!(lines[h1_pos + 1].trim().is_empty(), "Line after h1 should be blank");
798
799 assert!(lines[h2_pos + 1].trim().is_empty(), "Line after h2 should be blank");
801 }
802
803 #[test]
804 fn test_blanks_around_setext_headings() {
805 let rule = MD022BlanksAroundHeadings::default();
806 let content = "Heading 1\n=========\nSome content.\nHeading 2\n---------\nMore content.";
807 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
808 let result = rule.fix(&ctx).unwrap();
809
810 let lines: Vec<&str> = result.lines().collect();
812
813 assert!(result.contains("Heading 1"));
815 assert!(result.contains("========="));
816 assert!(result.contains("Some content."));
817 assert!(result.contains("Heading 2"));
818 assert!(result.contains("---------"));
819 assert!(result.contains("More content."));
820
821 let heading1_marker_idx = lines.iter().position(|&l| l == "=========").unwrap();
823 let some_content_idx = lines.iter().position(|&l| l == "Some content.").unwrap();
824 assert!(
825 some_content_idx > heading1_marker_idx + 1,
826 "Should have a blank line after the first heading"
827 );
828
829 let heading2_marker_idx = lines.iter().position(|&l| l == "---------").unwrap();
830 let more_content_idx = lines.iter().position(|&l| l == "More content.").unwrap();
831 assert!(
832 more_content_idx > heading2_marker_idx + 1,
833 "Should have a blank line after the second heading"
834 );
835
836 let fixed_ctx = LintContext::new(&result, crate::config::MarkdownFlavor::Standard, None);
838 let fixed_warnings = rule.check(&fixed_ctx).unwrap();
839 assert!(fixed_warnings.is_empty(), "Fixed content should have no warnings");
840 }
841
842 #[test]
843 fn test_fix_specific_blank_line_cases() {
844 let rule = MD022BlanksAroundHeadings::default();
845
846 let content1 = "# Heading 1\n## Heading 2\n### Heading 3";
848 let ctx1 = LintContext::new(content1, crate::config::MarkdownFlavor::Standard, None);
849 let result1 = rule.fix(&ctx1).unwrap();
850 assert!(result1.contains("# Heading 1"));
852 assert!(result1.contains("## Heading 2"));
853 assert!(result1.contains("### Heading 3"));
854 let lines: Vec<&str> = result1.lines().collect();
856 let h1_pos = lines.iter().position(|&l| l == "# Heading 1").unwrap();
857 let h2_pos = lines.iter().position(|&l| l == "## Heading 2").unwrap();
858 assert!(lines[h1_pos + 1].trim().is_empty(), "Should have a blank line after h1");
859 assert!(lines[h2_pos + 1].trim().is_empty(), "Should have a blank line after h2");
860
861 let content2 = "# Heading 1\nContent under heading 1\n## Heading 2";
863 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
864 let result2 = rule.fix(&ctx2).unwrap();
865 assert!(result2.contains("# Heading 1"));
867 assert!(result2.contains("Content under heading 1"));
868 assert!(result2.contains("## Heading 2"));
869 let lines2: Vec<&str> = result2.lines().collect();
871 let h1_pos2 = lines2.iter().position(|&l| l == "# Heading 1").unwrap();
872 let _content_pos = lines2.iter().position(|&l| l == "Content under heading 1").unwrap();
873 assert!(
874 lines2[h1_pos2 + 1].trim().is_empty(),
875 "Should have a blank line after heading 1"
876 );
877
878 let content3 = "# Heading 1\n\n\n## Heading 2\n\n\n### Heading 3\n\nContent";
880 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
881 let result3 = rule.fix(&ctx3).unwrap();
882 assert!(result3.contains("# Heading 1"));
884 assert!(result3.contains("## Heading 2"));
885 assert!(result3.contains("### Heading 3"));
886 assert!(result3.contains("Content"));
887 }
888
889 #[test]
890 fn test_fix_preserves_existing_blank_lines() {
891 let rule = MD022BlanksAroundHeadings::new();
892 let content = "# Title
893
894## Section 1
895
896Content here.
897
898## Section 2
899
900More content.
901### Missing Blank Above
902
903Even more content.
904
905## Section 3
906
907Final content.";
908
909 let expected = "# Title
910
911## Section 1
912
913Content here.
914
915## Section 2
916
917More content.
918
919### Missing Blank Above
920
921Even more content.
922
923## Section 3
924
925Final content.";
926
927 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
928 let result = rule.fix_content(&ctx);
929 assert_eq!(
930 result, expected,
931 "Fix should only add missing blank lines, never remove existing ones"
932 );
933 }
934
935 #[test]
936 fn test_fix_preserves_trailing_newline() {
937 let rule = MD022BlanksAroundHeadings::new();
938
939 let content_with_newline = "# Title\nContent here.\n";
941 let ctx = LintContext::new(content_with_newline, crate::config::MarkdownFlavor::Standard, None);
942 let result = rule.fix(&ctx).unwrap();
943 assert!(result.ends_with('\n'), "Should preserve trailing newline");
944
945 let content_without_newline = "# Title\nContent here.";
947 let ctx = LintContext::new(content_without_newline, crate::config::MarkdownFlavor::Standard, None);
948 let result = rule.fix(&ctx).unwrap();
949 assert!(
950 !result.ends_with('\n'),
951 "Should not add trailing newline if original didn't have one"
952 );
953 }
954
955 #[test]
956 fn test_fix_does_not_add_blank_lines_before_lists() {
957 let rule = MD022BlanksAroundHeadings::new();
958 let content = "## Configuration\n\nThis rule has the following configuration options:\n\n- `option1`: Description of option 1.\n- `option2`: Description of option 2.\n\n## Another Section\n\nSome content here.";
959
960 let expected = "## Configuration\n\nThis rule has the following configuration options:\n\n- `option1`: Description of option 1.\n- `option2`: Description of option 2.\n\n## Another Section\n\nSome content here.";
961
962 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
963 let result = rule.fix_content(&ctx);
964 assert_eq!(result, expected, "Fix should not add blank lines before lists");
965 }
966
967 #[test]
968 fn test_fix_idempotent_when_heading_follows_a_list_and_precedes_a_marker() {
969 let rule = MD022BlanksAroundHeadings::default();
975 let content = "- a\n# H\n2. ";
976 for flavor in [
977 crate::config::MarkdownFlavor::Standard,
978 crate::config::MarkdownFlavor::MkDocs,
979 crate::config::MarkdownFlavor::MDX,
980 ] {
981 let ctx1 = LintContext::new(content, flavor, None);
982 let fixed1 = rule.fix(&ctx1).unwrap();
983 let ctx2 = LintContext::new(&fixed1, flavor, None);
984 let fixed2 = rule.fix(&ctx2).unwrap();
985 assert_eq!(fixed1, fixed2, "MD022 fix must be idempotent (flavor={flavor:?})");
986 }
987 }
988
989 #[test]
990 fn test_per_level_configuration_no_blank_above_h1() {
991 use md022_config::HeadingLevelConfig;
992
993 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
995 lines_above: HeadingLevelConfig::per_level([0, 1, 1, 1, 1, 1]),
996 lines_below: HeadingLevelConfig::scalar(1),
997 allowed_at_start: false, });
999
1000 let content = "Some text\n# Heading 1\n\nMore text";
1002 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1003 let warnings = rule.check(&ctx).unwrap();
1004 assert_eq!(warnings.len(), 0, "H1 without blank above should not trigger warning");
1005
1006 let content = "Some text\n## Heading 2\n\nMore text";
1008 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1009 let warnings = rule.check(&ctx).unwrap();
1010 assert_eq!(warnings.len(), 1, "H2 without blank above should trigger warning");
1011 assert!(warnings[0].message.contains("above"));
1012 }
1013
1014 #[test]
1015 fn test_unlimited_above_with_limited_below_does_not_panic() {
1016 use md022_config::{HeadingBlankRequirement, HeadingLevelConfig};
1017
1018 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1022 lines_above: HeadingLevelConfig::scalar_requirement(HeadingBlankRequirement::Unlimited),
1023 lines_below: HeadingLevelConfig::scalar(1),
1024 allowed_at_start: false,
1025 });
1026
1027 let content = "# Title\n\nText\n## Banana\nText\n";
1029 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1030
1031 let warnings = rule.check(&ctx).expect("check must not fail");
1032
1033 assert!(
1034 warnings.iter().any(|w| w.message.contains("below")),
1035 "expected a 'below' violation, got: {:?}",
1036 warnings.iter().map(|w| &w.message).collect::<Vec<_>>()
1037 );
1038 assert!(
1039 !warnings.iter().any(|w| w.message.contains("above")),
1040 "an unlimited 'above' requirement must never report: {:?}",
1041 warnings.iter().map(|w| &w.message).collect::<Vec<_>>()
1042 );
1043 }
1044
1045 #[test]
1046 fn test_unlimited_below_with_limited_above_does_not_panic() {
1047 use md022_config::{HeadingBlankRequirement, HeadingLevelConfig};
1048
1049 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1050 lines_above: HeadingLevelConfig::scalar(1),
1051 lines_below: HeadingLevelConfig::scalar_requirement(HeadingBlankRequirement::Unlimited),
1052 allowed_at_start: false,
1053 });
1054
1055 let content = "# Title\n\nText\n## Banana\n\nText\n";
1057 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1058
1059 let warnings = rule.check(&ctx).expect("check must not fail");
1060
1061 assert!(
1062 warnings.iter().any(|w| w.message.contains("above")),
1063 "expected an 'above' violation, got: {:?}",
1064 warnings.iter().map(|w| &w.message).collect::<Vec<_>>()
1065 );
1066 assert!(
1067 !warnings.iter().any(|w| w.message.contains("below")),
1068 "an unlimited 'below' requirement must never report: {:?}",
1069 warnings.iter().map(|w| &w.message).collect::<Vec<_>>()
1070 );
1071 }
1072
1073 #[test]
1074 fn test_per_level_configuration_different_requirements() {
1075 use md022_config::HeadingLevelConfig;
1076
1077 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1079 lines_above: HeadingLevelConfig::per_level([0, 1, 1, 2, 2, 2]),
1080 lines_below: HeadingLevelConfig::scalar(1),
1081 allowed_at_start: false,
1082 });
1083
1084 let content = "Text\n# H1\n\nText\n\n## H2\n\nText\n\n### H3\n\nText\n\n\n#### H4\n\nText";
1085 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1086 let warnings = rule.check(&ctx).unwrap();
1087
1088 assert_eq!(
1090 warnings.len(),
1091 0,
1092 "All headings should satisfy level-specific requirements"
1093 );
1094 }
1095
1096 #[test]
1097 fn test_per_level_configuration_violations() {
1098 use md022_config::HeadingLevelConfig;
1099
1100 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1102 lines_above: HeadingLevelConfig::per_level([1, 1, 1, 2, 1, 1]),
1103 lines_below: HeadingLevelConfig::scalar(1),
1104 allowed_at_start: false,
1105 });
1106
1107 let content = "Text\n\n#### Heading 4\n\nMore text";
1109 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1110 let warnings = rule.check(&ctx).unwrap();
1111
1112 assert_eq!(warnings.len(), 1, "H4 with insufficient blanks should trigger warning");
1113 assert!(warnings[0].message.contains("2 blank lines above"));
1114 }
1115
1116 #[test]
1117 fn test_per_level_fix_different_levels() {
1118 use md022_config::HeadingLevelConfig;
1119
1120 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1122 lines_above: HeadingLevelConfig::per_level([0, 1, 2, 2, 2, 2]),
1123 lines_below: HeadingLevelConfig::scalar(1),
1124 allowed_at_start: false,
1125 });
1126
1127 let content = "Text\n# H1\nContent\n## H2\nContent\n### H3\nContent";
1128 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1129 let fixed = rule.fix(&ctx).unwrap();
1130
1131 assert!(fixed.contains("Text\n# H1\n\nContent"));
1133 assert!(fixed.contains("Content\n\n## H2\n\nContent"));
1134 assert!(fixed.contains("Content\n\n\n### H3\n\nContent"));
1135 }
1136
1137 #[test]
1138 fn test_per_level_below_configuration() {
1139 use md022_config::HeadingLevelConfig;
1140
1141 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1143 lines_above: HeadingLevelConfig::scalar(1),
1144 lines_below: HeadingLevelConfig::per_level([2, 1, 1, 1, 1, 1]), allowed_at_start: true,
1146 });
1147
1148 let content = "# Heading 1\n\nSome text";
1150 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1151 let warnings = rule.check(&ctx).unwrap();
1152
1153 assert_eq!(
1154 warnings.len(),
1155 1,
1156 "H1 with insufficient blanks below should trigger warning"
1157 );
1158 assert!(warnings[0].message.contains("2 blank lines below"));
1159 }
1160
1161 #[test]
1162 fn test_scalar_configuration_still_works() {
1163 use md022_config::HeadingLevelConfig;
1164
1165 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1167 lines_above: HeadingLevelConfig::scalar(2),
1168 lines_below: HeadingLevelConfig::scalar(2),
1169 allowed_at_start: false,
1170 });
1171
1172 let content = "Text\n# H1\nContent\n## H2\nContent";
1173 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1174 let warnings = rule.check(&ctx).unwrap();
1175
1176 assert!(!warnings.is_empty(), "Should have violations for insufficient blanks");
1178 }
1179
1180 #[test]
1181 fn test_unlimited_configuration_skips_requirements() {
1182 use md022_config::{HeadingBlankRequirement, HeadingLevelConfig};
1183
1184 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1186 lines_above: HeadingLevelConfig::per_level_requirements([
1187 HeadingBlankRequirement::unlimited(),
1188 HeadingBlankRequirement::limited(1),
1189 HeadingBlankRequirement::limited(1),
1190 HeadingBlankRequirement::limited(1),
1191 HeadingBlankRequirement::limited(1),
1192 HeadingBlankRequirement::limited(1),
1193 ]),
1194 lines_below: HeadingLevelConfig::per_level_requirements([
1195 HeadingBlankRequirement::unlimited(),
1196 HeadingBlankRequirement::limited(1),
1197 HeadingBlankRequirement::limited(1),
1198 HeadingBlankRequirement::limited(1),
1199 HeadingBlankRequirement::limited(1),
1200 HeadingBlankRequirement::limited(1),
1201 ]),
1202 allowed_at_start: false,
1203 });
1204
1205 let content = "# H1\nParagraph\n## H2\nParagraph";
1206 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1207 let warnings = rule.check(&ctx).unwrap();
1208
1209 assert_eq!(warnings.len(), 2, "Only non-unlimited headings should warn");
1211 assert!(
1212 warnings.iter().all(|w| w.line >= 3),
1213 "Warnings should target later headings"
1214 );
1215
1216 let fixed = rule.fix(&ctx).unwrap();
1218 assert!(
1219 fixed.starts_with("# H1\nParagraph\n\n## H2"),
1220 "H1 should remain unchanged"
1221 );
1222 }
1223
1224 #[test]
1225 fn test_html_comment_transparency() {
1226 let rule = MD022BlanksAroundHeadings::default();
1230
1231 let content = "Some content\n\n<!-- markdownlint-disable-next-line MD001 -->\n#### Heading";
1234 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1235 let warnings = rule.check(&ctx).unwrap();
1236 assert!(
1237 warnings.is_empty(),
1238 "HTML comment is transparent - blank line above it counts for heading"
1239 );
1240
1241 let content_multiline = "Some content\n\n<!-- This is a\nmulti-line comment -->\n#### Heading";
1243 let ctx_multiline = LintContext::new(content_multiline, crate::config::MarkdownFlavor::Standard, None);
1244 let warnings_multiline = rule.check(&ctx_multiline).unwrap();
1245 assert!(
1246 warnings_multiline.is_empty(),
1247 "Multi-line HTML comment is also transparent"
1248 );
1249 }
1250
1251 #[test]
1252 fn test_frontmatter_transparency() {
1253 let rule = MD022BlanksAroundHeadings::default();
1256
1257 let content = "---\ntitle: Test\n---\n# First heading";
1259 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1260 let warnings = rule.check(&ctx).unwrap();
1261 assert!(
1262 warnings.is_empty(),
1263 "Frontmatter is transparent - heading can appear immediately after"
1264 );
1265
1266 let content_with_blank = "---\ntitle: Test\n---\n\n# First heading";
1268 let ctx_with_blank = LintContext::new(content_with_blank, crate::config::MarkdownFlavor::Standard, None);
1269 let warnings_with_blank = rule.check(&ctx_with_blank).unwrap();
1270 assert!(
1271 warnings_with_blank.is_empty(),
1272 "Heading with blank line after frontmatter should also be valid"
1273 );
1274
1275 let content_toml = "+++\ntitle = \"Test\"\n+++\n# First heading";
1277 let ctx_toml = LintContext::new(content_toml, crate::config::MarkdownFlavor::Standard, None);
1278 let warnings_toml = rule.check(&ctx_toml).unwrap();
1279 assert!(
1280 warnings_toml.is_empty(),
1281 "TOML frontmatter is also transparent for MD022"
1282 );
1283 }
1284
1285 #[test]
1286 fn test_horizontal_rule_not_treated_as_frontmatter() {
1287 let rule = MD022BlanksAroundHeadings::default();
1290
1291 let content = "Some content\n\n---\n# Heading after HR";
1293 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1294 let warnings = rule.check(&ctx).unwrap();
1295 assert!(
1296 !warnings.is_empty(),
1297 "Heading after horizontal rule without blank line SHOULD trigger MD022"
1298 );
1299 assert!(
1300 warnings.iter().any(|w| w.line == 4),
1301 "Warning should be on line 4 (the heading line)"
1302 );
1303
1304 let content_with_blank = "Some content\n\n---\n\n# Heading after HR";
1306 let ctx_with_blank = LintContext::new(content_with_blank, crate::config::MarkdownFlavor::Standard, None);
1307 let warnings_with_blank = rule.check(&ctx_with_blank).unwrap();
1308 assert!(
1309 warnings_with_blank.is_empty(),
1310 "Heading with blank line after HR should not trigger MD022"
1311 );
1312
1313 let content_hr_start = "---\n# Heading";
1315 let ctx_hr_start = LintContext::new(content_hr_start, crate::config::MarkdownFlavor::Standard, None);
1316 let warnings_hr_start = rule.check(&ctx_hr_start).unwrap();
1317 assert!(
1318 !warnings_hr_start.is_empty(),
1319 "Heading after HR at document start SHOULD trigger MD022"
1320 );
1321
1322 let content_multi_hr = "Content\n\n---\n\n---\n# Heading";
1324 let ctx_multi_hr = LintContext::new(content_multi_hr, crate::config::MarkdownFlavor::Standard, None);
1325 let warnings_multi_hr = rule.check(&ctx_multi_hr).unwrap();
1326 assert!(
1327 !warnings_multi_hr.is_empty(),
1328 "Heading after multiple HRs without blank line SHOULD trigger MD022"
1329 );
1330 }
1331
1332 #[test]
1333 fn test_all_hr_styles_require_blank_before_heading() {
1334 let rule = MD022BlanksAroundHeadings::default();
1336
1337 let hr_styles = [
1339 "---", "***", "___", "- - -", "* * *", "_ _ _", "----", "****", "____", "- - - -",
1340 "- - -", " ---", " ---", ];
1344
1345 for hr in hr_styles {
1346 let content = format!("Content\n\n{hr}\n# Heading");
1347 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1348 let warnings = rule.check(&ctx).unwrap();
1349 assert!(
1350 !warnings.is_empty(),
1351 "HR style '{hr}' followed by heading should trigger MD022"
1352 );
1353 }
1354 }
1355
1356 #[test]
1357 fn test_setext_heading_after_hr() {
1358 let rule = MD022BlanksAroundHeadings::default();
1360
1361 let content = "Content\n\n---\nHeading\n======";
1363 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1364 let warnings = rule.check(&ctx).unwrap();
1365 assert!(
1366 !warnings.is_empty(),
1367 "Setext heading after HR without blank should trigger MD022"
1368 );
1369
1370 let content_h2 = "Content\n\n---\nHeading\n------";
1372 let ctx_h2 = LintContext::new(content_h2, crate::config::MarkdownFlavor::Standard, None);
1373 let warnings_h2 = rule.check(&ctx_h2).unwrap();
1374 assert!(
1375 !warnings_h2.is_empty(),
1376 "Setext h2 after HR without blank should trigger MD022"
1377 );
1378
1379 let content_ok = "Content\n\n---\n\nHeading\n======";
1381 let ctx_ok = LintContext::new(content_ok, crate::config::MarkdownFlavor::Standard, None);
1382 let warnings_ok = rule.check(&ctx_ok).unwrap();
1383 assert!(
1384 warnings_ok.is_empty(),
1385 "Setext heading with blank after HR should not warn"
1386 );
1387 }
1388
1389 #[test]
1390 fn test_hr_in_code_block_not_treated_as_hr() {
1391 let rule = MD022BlanksAroundHeadings::default();
1393
1394 let content = "```\n---\n```\n# Heading";
1397 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1398 let warnings = rule.check(&ctx).unwrap();
1399 assert!(!warnings.is_empty(), "Heading after code block still needs blank line");
1402
1403 let content_ok = "```\n---\n```\n\n# Heading";
1405 let ctx_ok = LintContext::new(content_ok, crate::config::MarkdownFlavor::Standard, None);
1406 let warnings_ok = rule.check(&ctx_ok).unwrap();
1407 assert!(
1408 warnings_ok.is_empty(),
1409 "Heading with blank after code block should not warn"
1410 );
1411 }
1412
1413 #[test]
1414 fn test_hr_in_html_comment_not_treated_as_hr() {
1415 let rule = MD022BlanksAroundHeadings::default();
1417
1418 let content = "<!-- \n---\n -->\n# Heading";
1420 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1421 let warnings = rule.check(&ctx).unwrap();
1422 assert!(
1424 warnings.is_empty(),
1425 "HR inside HTML comment should be ignored - heading after comment is OK"
1426 );
1427 }
1428
1429 #[test]
1430 fn test_invalid_hr_not_triggering() {
1431 let rule = MD022BlanksAroundHeadings::default();
1433
1434 let invalid_hrs = [
1435 " ---", "\t---", "--", "**", "__", "-*-", "---a", "a---", ];
1444
1445 for invalid in invalid_hrs {
1446 let content = format!("Content\n\n{invalid}\n# Heading");
1449 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1450 let _ = rule.check(&ctx);
1453 }
1454 }
1455
1456 #[test]
1457 fn test_frontmatter_vs_horizontal_rule_distinction() {
1458 let rule = MD022BlanksAroundHeadings::default();
1460
1461 let content = "---\ntitle: Test\n---\n\nSome content\n\n---\n# Heading after HR";
1464 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1465 let warnings = rule.check(&ctx).unwrap();
1466 assert!(
1467 !warnings.is_empty(),
1468 "HR after frontmatter content should still require blank line before heading"
1469 );
1470
1471 let content_ok = "---\ntitle: Test\n---\n\nSome content\n\n---\n\n# Heading after HR";
1473 let ctx_ok = LintContext::new(content_ok, crate::config::MarkdownFlavor::Standard, None);
1474 let warnings_ok = rule.check(&ctx_ok).unwrap();
1475 assert!(
1476 warnings_ok.is_empty(),
1477 "HR with blank line before heading should not warn"
1478 );
1479 }
1480
1481 #[test]
1484 fn test_kramdown_ial_after_heading_no_warning() {
1485 let rule = MD022BlanksAroundHeadings::default();
1487 let content = "## Table of Contents\n{: .hhc-toc-heading}\n\nSome content here.";
1488 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1489 let warnings = rule.check(&ctx).unwrap();
1490
1491 assert!(
1492 warnings.is_empty(),
1493 "IAL after heading should not require blank line between them: {warnings:?}"
1494 );
1495 }
1496
1497 #[test]
1498 fn test_kramdown_ial_with_class() {
1499 let rule = MD022BlanksAroundHeadings::default();
1500 let content = "# Heading\n{:.highlight}\n\nContent.";
1501 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1502 let warnings = rule.check(&ctx).unwrap();
1503
1504 assert!(warnings.is_empty(), "IAL with class should be part of heading");
1505 }
1506
1507 #[test]
1508 fn test_kramdown_ial_with_id() {
1509 let rule = MD022BlanksAroundHeadings::default();
1510 let content = "# Heading\n{:#custom-id}\n\nContent.";
1511 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1512 let warnings = rule.check(&ctx).unwrap();
1513
1514 assert!(warnings.is_empty(), "IAL with id should be part of heading");
1515 }
1516
1517 #[test]
1518 fn test_kramdown_ial_with_multiple_attributes() {
1519 let rule = MD022BlanksAroundHeadings::default();
1520 let content = "# Heading\n{: .class #id style=\"color: red\"}\n\nContent.";
1521 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1522 let warnings = rule.check(&ctx).unwrap();
1523
1524 assert!(
1525 warnings.is_empty(),
1526 "IAL with multiple attributes should be part of heading"
1527 );
1528 }
1529
1530 #[test]
1531 fn test_kramdown_ial_missing_blank_after() {
1532 let rule = MD022BlanksAroundHeadings::default();
1534 let content = "# Heading\n{:.class}\nContent without blank.";
1535 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1536 let warnings = rule.check(&ctx).unwrap();
1537
1538 assert_eq!(
1539 warnings.len(),
1540 1,
1541 "Should warn about missing blank after IAL (part of heading)"
1542 );
1543 assert!(warnings[0].message.contains("below"));
1544 }
1545
1546 #[test]
1547 fn test_kramdown_ial_before_heading_transparent() {
1548 let rule = MD022BlanksAroundHeadings::default();
1550 let content = "Content.\n\n{:.preclass}\n## Heading\n\nMore content.";
1551 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1552 let warnings = rule.check(&ctx).unwrap();
1553
1554 assert!(
1555 warnings.is_empty(),
1556 "IAL before heading should be transparent for blank line count"
1557 );
1558 }
1559
1560 #[test]
1561 fn test_kramdown_ial_setext_heading() {
1562 let rule = MD022BlanksAroundHeadings::default();
1563 let content = "Heading\n=======\n{:.setext-class}\n\nContent.";
1564 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1565 let warnings = rule.check(&ctx).unwrap();
1566
1567 assert!(
1568 warnings.is_empty(),
1569 "IAL after Setext heading should be part of heading"
1570 );
1571 }
1572
1573 #[test]
1574 fn test_kramdown_ial_fix_preserves_ial() {
1575 let rule = MD022BlanksAroundHeadings::default();
1576 let content = "Content.\n# Heading\n{:.class}\nMore content.";
1577 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1578 let fixed = rule.fix(&ctx).unwrap();
1579
1580 assert!(
1582 fixed.contains("# Heading\n{:.class}"),
1583 "IAL should stay attached to heading"
1584 );
1585 assert!(fixed.contains("{:.class}\n\nMore"), "Should add blank after IAL");
1586 }
1587
1588 #[test]
1589 fn test_kramdown_ial_fix_does_not_separate() {
1590 let rule = MD022BlanksAroundHeadings::default();
1591 let content = "# Heading\n{:.class}\nContent.";
1592 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1593 let fixed = rule.fix(&ctx).unwrap();
1594
1595 assert!(
1597 !fixed.contains("# Heading\n\n{:.class}"),
1598 "Should not add blank between heading and IAL"
1599 );
1600 assert!(fixed.contains("# Heading\n{:.class}"), "IAL should remain attached");
1601 }
1602
1603 #[test]
1604 fn test_kramdown_multiple_ial_lines() {
1605 let rule = MD022BlanksAroundHeadings::default();
1607 let content = "# Heading\n{:.class1}\n{:#id}\n\nContent.";
1608 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1609 let warnings = rule.check(&ctx).unwrap();
1610
1611 assert!(
1614 warnings.is_empty(),
1615 "Multiple consecutive IALs should be part of heading"
1616 );
1617 }
1618
1619 #[test]
1620 fn test_kramdown_ial_with_blank_line_not_attached() {
1621 let rule = MD022BlanksAroundHeadings::default();
1623 let content = "# Heading\n\n{:.class}\nContent.";
1624 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1625 let warnings = rule.check(&ctx).unwrap();
1626
1627 assert!(warnings.is_empty(), "Blank line separates heading from IAL");
1631 }
1632
1633 #[test]
1634 fn test_not_kramdown_ial_regular_braces() {
1635 let rule = MD022BlanksAroundHeadings::default();
1637 let content = "# Heading\n{not an ial}\n\nContent.";
1638 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1639 let warnings = rule.check(&ctx).unwrap();
1640
1641 assert_eq!(
1643 warnings.len(),
1644 1,
1645 "Non-IAL braces should be regular content requiring blank"
1646 );
1647 }
1648
1649 #[test]
1650 fn test_kramdown_ial_at_document_end() {
1651 let rule = MD022BlanksAroundHeadings::default();
1652 let content = "# Heading\n{:.class}";
1653 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1654 let warnings = rule.check(&ctx).unwrap();
1655
1656 assert!(warnings.is_empty(), "IAL at document end needs no blank after");
1658 }
1659
1660 #[test]
1661 fn test_kramdown_ial_followed_by_code_fence() {
1662 let rule = MD022BlanksAroundHeadings::default();
1663 let content = "# Heading\n{:.class}\n```\ncode\n```";
1664 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1665 let warnings = rule.check(&ctx).unwrap();
1666
1667 assert!(warnings.is_empty(), "No blank needed between IAL and code fence");
1669 }
1670
1671 #[test]
1672 fn test_kramdown_ial_followed_by_list() {
1673 let rule = MD022BlanksAroundHeadings::default();
1674 let content = "# Heading\n{:.class}\n- List item";
1675 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1676 let warnings = rule.check(&ctx).unwrap();
1677
1678 assert!(warnings.is_empty(), "No blank needed between IAL and list");
1680 }
1681
1682 #[test]
1683 fn test_kramdown_ial_fix_idempotent() {
1684 let rule = MD022BlanksAroundHeadings::default();
1685 let content = "# Heading\n{:.class}\nContent.";
1686 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1687
1688 let fixed_once = rule.fix(&ctx).unwrap();
1689 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
1690 let fixed_twice = rule.fix(&ctx2).unwrap();
1691
1692 assert_eq!(fixed_once, fixed_twice, "Fix should be idempotent");
1693 }
1694
1695 #[test]
1696 fn test_kramdown_ial_whitespace_line_between_not_attached() {
1697 let rule = MD022BlanksAroundHeadings::default();
1700 let content = "# Heading\n \n{:.class}\n\nContent.";
1701 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1702 let warnings = rule.check(&ctx).unwrap();
1703
1704 assert!(
1708 warnings.is_empty(),
1709 "Whitespace between heading and IAL means IAL is not attached"
1710 );
1711 }
1712
1713 #[test]
1714 fn test_kramdown_ial_html_comment_between() {
1715 let rule = MD022BlanksAroundHeadings::default();
1718 let content = "# Heading\n<!-- comment -->\n{:.class}\n\nContent.";
1719 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1720 let warnings = rule.check(&ctx).unwrap();
1721
1722 assert_eq!(
1726 warnings.len(),
1727 1,
1728 "IAL not attached when comment is between: {warnings:?}"
1729 );
1730 }
1731
1732 #[test]
1733 fn test_kramdown_ial_generic_attribute() {
1734 let rule = MD022BlanksAroundHeadings::default();
1735 let content = "# Heading\n{:data-toc=\"true\" style=\"color: red\"}\n\nContent.";
1736 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1737 let warnings = rule.check(&ctx).unwrap();
1738
1739 assert!(warnings.is_empty(), "Generic attributes should be recognized as IAL");
1740 }
1741
1742 #[test]
1743 fn test_kramdown_ial_fix_multiple_lines_preserves_all() {
1744 let rule = MD022BlanksAroundHeadings::default();
1745 let content = "# Heading\n{:.class1}\n{:#id}\n{:data-x=\"y\"}\nContent.";
1746 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1747
1748 let fixed = rule.fix(&ctx).unwrap();
1749
1750 assert!(fixed.contains("{:.class1}"), "First IAL should be preserved");
1752 assert!(fixed.contains("{:#id}"), "Second IAL should be preserved");
1753 assert!(fixed.contains("{:data-x=\"y\"}"), "Third IAL should be preserved");
1754 assert!(
1756 fixed.contains("{:data-x=\"y\"}\n\nContent"),
1757 "Blank line should be after all IALs"
1758 );
1759 }
1760
1761 #[test]
1762 fn test_kramdown_ial_crlf_line_endings() {
1763 let rule = MD022BlanksAroundHeadings::default();
1764 let content = "# Heading\r\n{:.class}\r\n\r\nContent.";
1765 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1766 let warnings = rule.check(&ctx).unwrap();
1767
1768 assert!(warnings.is_empty(), "CRLF should work correctly with IAL");
1769 }
1770
1771 #[test]
1772 fn test_kramdown_ial_invalid_patterns_not_recognized() {
1773 let rule = MD022BlanksAroundHeadings::default();
1774
1775 let content = "# Heading\n{ :.class}\n\nContent.";
1777 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1778 let warnings = rule.check(&ctx).unwrap();
1779 assert_eq!(warnings.len(), 1, "Invalid IAL syntax should trigger warning");
1780
1781 let content2 = "# Heading\n{.class}\n\nContent.";
1783 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1784 let warnings2 = rule.check(&ctx2).unwrap();
1785 assert!(warnings2.is_empty(), "{{.class}} is valid kramdown block attribute");
1787
1788 let content3 = "# Heading\n{just text}\n\nContent.";
1790 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
1791 let warnings3 = rule.check(&ctx3).unwrap();
1792 assert_eq!(
1793 warnings3.len(),
1794 1,
1795 "Text in braces is not IAL and should trigger warning"
1796 );
1797 }
1798
1799 #[test]
1800 fn test_kramdown_ial_toc_marker() {
1801 let rule = MD022BlanksAroundHeadings::default();
1803 let content = "# Heading\n{:toc}\n\nContent.";
1804 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1805 let warnings = rule.check(&ctx).unwrap();
1806
1807 assert!(warnings.is_empty(), "{{:toc}} should be recognized as IAL");
1809 }
1810
1811 #[test]
1812 fn test_kramdown_ial_mixed_headings_in_document() {
1813 let rule = MD022BlanksAroundHeadings::default();
1814 let content = r#"# ATX Heading
1815{:.atx-class}
1816
1817Content after ATX.
1818
1819Setext Heading
1820--------------
1821{:#setext-id}
1822
1823Content after Setext.
1824
1825## Another ATX
1826{:.another}
1827
1828More content."#;
1829 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1830 let warnings = rule.check(&ctx).unwrap();
1831
1832 assert!(
1833 warnings.is_empty(),
1834 "Mixed headings with IAL should all work: {warnings:?}"
1835 );
1836 }
1837
1838 #[test]
1839 fn test_kramdown_extension_block_before_first_heading_is_document_start() {
1840 let rule = MD022BlanksAroundHeadings::default();
1841 let content = "{::comment}\nhidden\n{:/comment}\n# Heading\n\nBody\n";
1842 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Kramdown, None);
1843 let warnings = rule.check(&ctx).unwrap();
1844
1845 assert!(
1846 warnings.is_empty(),
1847 "Kramdown extension preamble should not require blank above first heading: {warnings:?}"
1848 );
1849 }
1850
1851 #[test]
1852 fn test_kramdown_ial_before_first_heading_is_document_start() {
1853 let rule = MD022BlanksAroundHeadings::default();
1854 let content = "{:.doc-class}\n# Heading\n\nBody\n";
1855 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Kramdown, None);
1856 let warnings = rule.check(&ctx).unwrap();
1857
1858 assert!(
1859 warnings.is_empty(),
1860 "Kramdown IAL preamble should not require blank above first heading: {warnings:?}"
1861 );
1862 }
1863
1864 #[test]
1867 fn test_quarto_div_marker_transparent_above_heading() {
1868 let rule = MD022BlanksAroundHeadings::default();
1871 let content = "Content\n\n::: {.callout-note}\n# Heading\n\nMore content\n:::\n";
1873 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1874 let warnings = rule.check(&ctx).unwrap();
1875 assert!(
1877 warnings.is_empty(),
1878 "Quarto div marker should be transparent above heading: {warnings:?}"
1879 );
1880 }
1881
1882 #[test]
1883 fn test_quarto_div_marker_transparent_below_heading() {
1884 let rule = MD022BlanksAroundHeadings::default();
1886 let content = "# Heading\n\n::: {.callout-note}\nContent\n:::\n";
1887 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1888 let warnings = rule.check(&ctx).unwrap();
1889 assert!(
1891 warnings.is_empty(),
1892 "Quarto div marker should be transparent below heading: {warnings:?}"
1893 );
1894 }
1895
1896 #[test]
1897 fn test_quarto_heading_inside_callout() {
1898 let rule = MD022BlanksAroundHeadings::default();
1900 let content = "::: {.callout-note}\n\n## Note Title\n\nNote content\n:::\n";
1901 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1902 let warnings = rule.check(&ctx).unwrap();
1903 assert!(
1904 warnings.is_empty(),
1905 "Heading inside Quarto callout should have no warnings: {warnings:?}"
1906 );
1907 }
1908
1909 #[test]
1910 fn test_quarto_heading_at_start_after_div_open() {
1911 let rule = MD022BlanksAroundHeadings::default();
1914 let content = "::: {.callout-warning}\n# Warning\n\nContent\n:::\n";
1916 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1917 let warnings = rule.check(&ctx).unwrap();
1918 assert!(
1924 warnings.is_empty(),
1925 "Heading at start after div open should pass: {warnings:?}"
1926 );
1927 }
1928
1929 #[test]
1930 fn test_quarto_heading_before_div_close() {
1931 let rule = MD022BlanksAroundHeadings::default();
1933 let content = "::: {.callout-note}\nIntro\n\n## Section\n:::\n";
1934 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1935 let warnings = rule.check(&ctx).unwrap();
1936 assert!(
1940 warnings.is_empty(),
1941 "Heading before div close should pass: {warnings:?}"
1942 );
1943 }
1944
1945 #[test]
1946 fn test_quarto_div_markers_not_transparent_in_standard_flavor() {
1947 let rule = MD022BlanksAroundHeadings::default();
1949 let content = "Content\n\n:::\n# Heading\n\n:::\n";
1950 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1951 let warnings = rule.check(&ctx).unwrap();
1952 assert!(
1954 !warnings.is_empty(),
1955 "Standard flavor should not treat ::: as transparent: {warnings:?}"
1956 );
1957 }
1958
1959 #[test]
1960 fn test_quarto_nested_divs_with_heading() {
1961 let rule = MD022BlanksAroundHeadings::default();
1963 let content = "::: {.outer}\n::: {.inner}\n\n# Heading\n\nContent\n:::\n:::\n";
1964 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1965 let warnings = rule.check(&ctx).unwrap();
1966 assert!(
1967 warnings.is_empty(),
1968 "Nested divs with heading should work: {warnings:?}"
1969 );
1970 }
1971
1972 #[test]
1973 fn test_quarto_fix_preserves_div_markers() {
1974 let rule = MD022BlanksAroundHeadings::default();
1976 let content = "::: {.callout-note}\n\n## Note\n\nContent\n:::\n";
1977 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1978 let fixed = rule.fix(&ctx).unwrap();
1979 assert!(fixed.contains("::: {.callout-note}"), "Should preserve div opening");
1981 assert!(fixed.contains(":::"), "Should preserve div closing");
1982 assert!(fixed.contains("## Note"), "Should preserve heading");
1983 }
1984
1985 #[test]
1986 fn test_quarto_heading_needs_blank_without_div_transparency() {
1987 let rule = MD022BlanksAroundHeadings::default();
1990 let content = "Content\n::: {.callout-note}\n# Heading\n\nMore\n:::\n";
1992 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1993 let warnings = rule.check(&ctx).unwrap();
1994 assert!(
1997 !warnings.is_empty(),
1998 "Should still require blank line when not present: {warnings:?}"
1999 );
2000 }
2001
2002 #[test]
2003 fn test_pandoc_div_marker_transparent_above_heading() {
2004 let rule = MD022BlanksAroundHeadings::default();
2007 let content = "Content\n\n::: {.callout-note}\n# Heading\n\nMore content\n:::\n";
2008 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
2009 let warnings = rule.check(&ctx).unwrap();
2010 assert!(
2011 warnings.is_empty(),
2012 "MD022 should treat Pandoc div marker as transparent above heading: {warnings:?}"
2013 );
2014 }
2015
2016 #[test]
2017 fn test_hugo_block_attribute_after_heading_not_flagged() {
2018 let rule = MD022BlanksAroundHeadings::default();
2021 let content = "Intro text.\n\n# Heading\n{class=\"anchor\"}\n\nContent.\n";
2022
2023 for flavor in [
2024 crate::config::MarkdownFlavor::Hugo,
2025 crate::config::MarkdownFlavor::MkDocs,
2026 crate::config::MarkdownFlavor::Kramdown,
2027 ] {
2028 let ctx = LintContext::new(content, flavor, None);
2029 let warnings = rule.check(&ctx).unwrap();
2030 assert!(
2031 warnings.is_empty(),
2032 "MD022 should not flag the block attribute line under {flavor:?}: {warnings:?}"
2033 );
2034 }
2035
2036 let ctx_std = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2039 let warnings_std = rule.check(&ctx_std).unwrap();
2040 assert!(
2041 warnings_std.iter().any(|w| w.message.contains("below heading")),
2042 "MD022 must flag the missing blank below the heading under Standard: {warnings_std:?}"
2043 );
2044 }
2045}