1use crate::lint_context::is_horizontal_rule_content;
2use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
6use crate::utils::mkdocs_attr_list::is_block_attribute_line;
7use crate::utils::pandoc;
8use crate::utils::range_utils::calculate_heading_range;
9use toml;
10
11pub(crate) mod md022_config;
12use md022_config::MD022Config;
13
14fn starts_with_list_marker(trimmed: &str) -> bool {
29 if is_horizontal_rule_content(trimmed) {
30 return false;
31 }
32 let bytes = trimmed.as_bytes();
33 match bytes.first() {
34 Some(b'-' | b'*' | b'+') => matches!(bytes.get(1), None | Some(b' ')),
35 Some(b'0'..=b'9') => {
36 let mut i = 0;
37 while bytes.get(i).is_some_and(u8::is_ascii_digit) {
38 i += 1;
39 }
40 matches!(bytes.get(i), Some(b'.' | b')')) && matches!(bytes.get(i + 1), None | Some(b' '))
41 }
42 _ => false,
43 }
44}
45
46#[derive(Clone, Default)]
118pub struct MD022BlanksAroundHeadings {
119 config: MD022Config,
120}
121
122impl MD022BlanksAroundHeadings {
123 pub fn new() -> Self {
126 Self {
127 config: MD022Config::default(),
128 }
129 }
130
131 pub fn with_values(lines_above: usize, lines_below: usize) -> Self {
133 use md022_config::HeadingLevelConfig;
134 Self {
135 config: MD022Config {
136 lines_above: HeadingLevelConfig::scalar(lines_above),
137 lines_below: HeadingLevelConfig::scalar(lines_below),
138 allowed_at_start: true,
139 },
140 }
141 }
142
143 pub fn from_config_struct(config: MD022Config) -> Self {
144 Self { config }
145 }
146
147 fn fix_content(&self, ctx: &crate::lint_context::LintContext) -> String {
149 let line_ending = "\n";
152 let had_trailing_newline = ctx.content.ends_with('\n');
153 let is_pandoc = ctx.flavor.is_pandoc_compatible();
154 let mut result = Vec::new();
155 let mut skip_count: usize = 0;
156
157 let heading_at_start_idx = {
158 let mut found_non_transparent = false;
159 ctx.lines.iter().enumerate().find_map(|(i, line)| {
160 if line.heading.as_ref().is_some_and(|h| h.is_valid) && !found_non_transparent {
162 Some(i)
163 } else {
164 if !line.is_blank && !line.in_html_comment && !line.in_mdx_comment {
167 let trimmed = line.content(ctx.content).trim();
168 if trimmed.starts_with("<!--") && trimmed.ends_with("-->") {
170 } else if line.in_kramdown_extension_block || line.is_kramdown_block_ial {
172 } else if is_pandoc && (pandoc::is_div_open(trimmed) || pandoc::is_div_close(trimmed)) {
174 } else {
176 found_non_transparent = true;
177 }
178 }
179 None
180 }
181 })
182 };
183
184 for (i, line_info) in ctx.lines.iter().enumerate() {
185 if skip_count > 0 {
186 skip_count -= 1;
187 continue;
188 }
189 let line = line_info.content(ctx.content);
190
191 if line_info.in_code_block {
192 result.push(line.to_string());
193 continue;
194 }
195
196 if let Some(heading) = &line_info.heading {
198 if !heading.is_valid {
200 result.push(line.to_string());
201 continue;
202 }
203
204 let line_num = i + 1;
206 if ctx.inline_config().is_rule_disabled("MD022", line_num) {
207 result.push(line.to_string());
208 if matches!(
210 heading.style,
211 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
212 ) && i + 1 < ctx.lines.len()
213 {
214 result.push(ctx.lines[i + 1].content(ctx.content).to_string());
215 skip_count += 1;
216 }
217 continue;
218 }
219
220 let is_first_heading = Some(i) == heading_at_start_idx;
222 let heading_level = heading.level as usize;
223
224 let mut blank_lines_above = 0;
226 let mut check_idx = result.len();
227 while check_idx > 0 {
228 let prev_line = &result[check_idx - 1];
229 let trimmed = prev_line.trim();
230 if trimmed.is_empty() {
231 blank_lines_above += 1;
232 check_idx -= 1;
233 } else if trimmed.starts_with("<!--") && trimmed.ends_with("-->") {
234 check_idx -= 1;
236 } else if is_block_attribute_line(trimmed, ctx.flavor) {
237 check_idx -= 1;
239 } else if is_pandoc && (pandoc::is_div_open(trimmed) || pandoc::is_div_close(trimmed)) {
240 check_idx -= 1;
242 } else {
243 break;
244 }
245 }
246
247 let requirement_above = self.config.lines_above.get_for_level(heading_level);
249 let needed_blanks_above = if is_first_heading && self.config.allowed_at_start {
250 0
251 } else {
252 requirement_above.required_count().unwrap_or(0)
253 };
254
255 while blank_lines_above < needed_blanks_above {
257 result.push(String::new());
258 blank_lines_above += 1;
259 }
260
261 result.push(line.to_string());
263
264 let mut effective_end_idx = i;
266
267 if matches!(
269 heading.style,
270 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
271 ) {
272 if i + 1 < ctx.lines.len() {
274 result.push(ctx.lines[i + 1].content(ctx.content).to_string());
275 skip_count += 1; effective_end_idx = i + 1;
277 }
278 }
279
280 let mut ial_count = 0;
283 while effective_end_idx + 1 < ctx.lines.len() {
284 let next_line = &ctx.lines[effective_end_idx + 1];
285 let next_trimmed = next_line.content(ctx.content).trim();
286 if is_block_attribute_line(next_trimmed, ctx.flavor) {
287 result.push(next_trimmed.to_string());
288 effective_end_idx += 1;
289 ial_count += 1;
290 } else {
291 break;
292 }
293 }
294
295 let mut blank_lines_below = 0;
297 let mut next_content_line_idx = None;
298 for j in (effective_end_idx + 1)..ctx.lines.len() {
299 if ctx.lines[j].is_blank {
300 blank_lines_below += 1;
301 } else {
302 next_content_line_idx = Some(j);
303 break;
304 }
305 }
306
307 let next_is_special = if let Some(idx) = next_content_line_idx {
309 let next_line = &ctx.lines[idx];
310 let trimmed = next_line.content(ctx.content).trim();
311 next_line.list_item.is_some()
312 || starts_with_list_marker(trimmed)
313 || ((trimmed.starts_with("```") || trimmed.starts_with("~~~"))
314 && (trimmed.len() == 3
315 || (trimmed.len() > 3
316 && trimmed
317 .chars()
318 .nth(3)
319 .is_some_and(|c| c.is_whitespace() || c.is_alphabetic()))))
320 } else {
321 false
322 };
323
324 let requirement_below = self.config.lines_below.get_for_level(heading_level);
326 let needed_blanks_below = if next_is_special {
327 0
328 } else {
329 requirement_below.required_count().unwrap_or(0)
330 };
331 if blank_lines_below < needed_blanks_below {
332 for _ in 0..(needed_blanks_below - blank_lines_below) {
333 result.push(String::new());
334 }
335 }
336
337 skip_count += ial_count;
339 } else {
340 result.push(line.to_string());
342 }
343 }
344
345 let joined = result.join(line_ending);
346
347 if had_trailing_newline && !joined.ends_with('\n') {
349 format!("{joined}{line_ending}")
350 } else if !had_trailing_newline && joined.ends_with('\n') {
351 joined[..joined.len() - 1].to_string()
353 } else {
354 joined
355 }
356 }
357}
358
359impl Rule for MD022BlanksAroundHeadings {
360 fn name(&self) -> &'static str {
361 "MD022"
362 }
363
364 fn description(&self) -> &'static str {
365 "Headings should be surrounded by blank lines"
366 }
367
368 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
369 let mut result = Vec::new();
370
371 if ctx.lines.is_empty() {
373 return Ok(result);
374 }
375
376 let line_ending = "\n";
379 let is_pandoc = ctx.flavor.is_pandoc_compatible();
380
381 let heading_at_start_idx = {
382 let mut found_non_transparent = false;
383 ctx.lines.iter().enumerate().find_map(|(i, line)| {
384 if line.heading.as_ref().is_some_and(|h| h.is_valid) && !found_non_transparent {
386 Some(i)
387 } else {
388 if !line.is_blank && !line.in_html_comment && !line.in_mdx_comment {
391 let trimmed = line.content(ctx.content).trim();
392 if trimmed.starts_with("<!--") && trimmed.ends_with("-->") {
394 } else if line.in_kramdown_extension_block || line.is_kramdown_block_ial {
396 } else if is_pandoc && (pandoc::is_div_open(trimmed) || pandoc::is_div_close(trimmed)) {
398 } else {
400 found_non_transparent = true;
401 }
402 }
403 None
404 }
405 })
406 };
407
408 let mut heading_violations = Vec::new();
410 let mut processed_headings = std::collections::HashSet::new();
411
412 for (line_num, line_info) in ctx.lines.iter().enumerate() {
413 if processed_headings.contains(&line_num) || line_info.heading.is_none() {
415 continue;
416 }
417
418 if line_info.in_pymdown_block {
420 continue;
421 }
422
423 let heading = line_info.heading.as_ref().unwrap();
424
425 if !heading.is_valid {
427 continue;
428 }
429
430 let heading_level = heading.level as usize;
431
432 processed_headings.insert(line_num);
436
437 let is_first_heading = Some(line_num) == heading_at_start_idx;
439
440 let required_above_count = self.config.lines_above.get_for_level(heading_level).required_count();
442 let required_below_count = self.config.lines_below.get_for_level(heading_level).required_count();
443
444 let should_check_above =
446 required_above_count.is_some() && line_num > 0 && (!is_first_heading || !self.config.allowed_at_start);
447 if should_check_above {
448 let mut blank_lines_above = 0;
449 let mut hit_frontmatter_end = false;
450 for j in (0..line_num).rev() {
451 let line_content = ctx.lines[j].content(ctx.content);
452 let trimmed = line_content.trim();
453 if ctx.lines[j].is_blank {
454 blank_lines_above += 1;
455 } else if ctx.lines[j].in_html_comment
456 || ctx.lines[j].in_mdx_comment
457 || (trimmed.starts_with("<!--") && trimmed.ends_with("-->"))
458 {
459 continue;
461 } else if is_block_attribute_line(trimmed, ctx.flavor) {
462 continue;
464 } else if is_pandoc && (pandoc::is_div_open(trimmed) || pandoc::is_div_close(trimmed)) {
465 continue;
467 } else if ctx.lines[j].in_front_matter {
468 hit_frontmatter_end = true;
473 break;
474 } else {
475 break;
476 }
477 }
478 let required = required_above_count.unwrap();
479 if !hit_frontmatter_end && blank_lines_above < required {
480 let needed_blanks = required - blank_lines_above;
481 heading_violations.push((line_num, "above", needed_blanks, heading_level));
482 }
483 }
484
485 let mut effective_last_line = if matches!(
487 heading.style,
488 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
489 ) {
490 line_num + 1 } else {
492 line_num
493 };
494
495 while effective_last_line + 1 < ctx.lines.len() {
498 let next_line = &ctx.lines[effective_last_line + 1];
499 let next_trimmed = next_line.content(ctx.content).trim();
500 if is_block_attribute_line(next_trimmed, ctx.flavor) {
501 effective_last_line += 1;
502 } else {
503 break;
504 }
505 }
506
507 if effective_last_line < ctx.lines.len() - 1 {
509 let mut next_non_blank_idx = effective_last_line + 1;
511 while next_non_blank_idx < ctx.lines.len() {
512 let check_line = &ctx.lines[next_non_blank_idx];
513 let check_trimmed = check_line.content(ctx.content).trim();
514 if check_line.is_blank {
515 next_non_blank_idx += 1;
516 } else if check_line.in_html_comment
517 || check_line.in_mdx_comment
518 || (check_trimmed.starts_with("<!--") && check_trimmed.ends_with("-->"))
519 {
520 next_non_blank_idx += 1;
522 } else if is_pandoc && (pandoc::is_div_open(check_trimmed) || pandoc::is_div_close(check_trimmed)) {
523 next_non_blank_idx += 1;
525 } else {
526 break;
527 }
528 }
529
530 if next_non_blank_idx >= ctx.lines.len() {
532 continue;
534 }
535
536 let next_line_is_special = {
538 let next_line = &ctx.lines[next_non_blank_idx];
539 let next_trimmed = next_line.content(ctx.content).trim();
540
541 let is_code_fence = (next_trimmed.starts_with("```") || next_trimmed.starts_with("~~~"))
543 && (next_trimmed.len() == 3
544 || (next_trimmed.len() > 3
545 && next_trimmed
546 .chars()
547 .nth(3)
548 .is_some_and(|c| c.is_whitespace() || c.is_alphabetic())));
549
550 let is_list_item = next_line.list_item.is_some() || starts_with_list_marker(next_trimmed);
557
558 is_code_fence || is_list_item
559 };
560
561 if !next_line_is_special && let Some(required) = required_below_count {
563 let mut blank_lines_below = 0;
565 for k in (effective_last_line + 1)..next_non_blank_idx {
566 if ctx.lines[k].is_blank {
567 blank_lines_below += 1;
568 }
569 }
570
571 if blank_lines_below < required {
572 let needed_blanks = required - blank_lines_below;
573 heading_violations.push((line_num, "below", needed_blanks, heading_level));
574 }
575 }
576 }
577 }
578
579 for (heading_line, position, needed_blanks, heading_level) in heading_violations {
581 let heading_display_line = heading_line + 1; let line_info = &ctx.lines[heading_line];
583
584 let (start_line, start_col, end_line, end_col) =
586 calculate_heading_range(heading_display_line, line_info.content(ctx.content));
587
588 let (message, insertion_point) = match position {
595 "above" => {
596 let Some(required_above_count) =
597 self.config.lines_above.get_for_level(heading_level).required_count()
598 else {
599 continue;
600 };
601 (
602 format!(
603 "Expected {} blank {} above heading",
604 required_above_count,
605 if required_above_count == 1 { "line" } else { "lines" }
606 ),
607 heading_line, )
609 }
610 "below" => {
611 let Some(required_below_count) =
612 self.config.lines_below.get_for_level(heading_level).required_count()
613 else {
614 continue;
615 };
616 let insert_after = if line_info.heading.as_ref().is_some_and(|h| {
618 matches!(
619 h.style,
620 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
621 )
622 }) {
623 heading_line + 2
624 } else {
625 heading_line + 1
626 };
627
628 (
629 format!(
630 "Expected {} blank {} below heading",
631 required_below_count,
632 if required_below_count == 1 { "line" } else { "lines" }
633 ),
634 insert_after,
635 )
636 }
637 _ => continue,
638 };
639
640 let byte_range = if insertion_point == 0 && position == "above" {
642 0..0
644 } else if position == "above" && insertion_point > 0 {
645 ctx.lines[insertion_point].byte_offset..ctx.lines[insertion_point].byte_offset
647 } else if position == "below" && insertion_point - 1 < ctx.lines.len() {
648 let line_idx = insertion_point - 1;
650 let line_end_offset = if line_idx + 1 < ctx.lines.len() {
651 ctx.lines[line_idx + 1].byte_offset
652 } else {
653 ctx.content.len()
654 };
655 line_end_offset..line_end_offset
656 } else {
657 let content_len = ctx.content.len();
659 content_len..content_len
660 };
661
662 result.push(LintWarning {
663 rule_name: Some(self.name().to_string()),
664 message,
665 line: start_line,
666 column: start_col,
667 end_line,
668 end_column: end_col,
669 severity: Severity::Warning,
670 fix: Some(Fix::new(byte_range, line_ending.repeat(needed_blanks))),
671 });
672 }
673
674 Ok(result)
675 }
676
677 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
678 if ctx.content.is_empty() {
679 return Ok(ctx.content.to_string());
680 }
681
682 let fixed = self.fix_content(ctx);
684
685 Ok(fixed)
686 }
687
688 fn category(&self) -> RuleCategory {
690 RuleCategory::Heading
691 }
692
693 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
695 if ctx.content.is_empty() || !ctx.likely_has_headings() {
697 return true;
698 }
699 ctx.lines.iter().all(|line| line.heading.is_none())
701 }
702
703 fn as_any(&self) -> &dyn std::any::Any {
704 self
705 }
706
707 crate::impl_rule_config_methods!(MD022Config);
708}
709
710#[cfg(test)]
711mod tests {
712 use super::*;
713 use crate::lint_context::LintContext;
714
715 #[test]
716 fn test_valid_headings() {
717 let rule = MD022BlanksAroundHeadings::default();
718 let content = "\n# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.\n";
719 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
720 let result = rule.check(&ctx).unwrap();
721 assert!(result.is_empty());
722 }
723
724 #[test]
725 fn test_missing_blank_above() {
726 let rule = MD022BlanksAroundHeadings::default();
727 let content = "# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.\n";
728 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
729 let result = rule.check(&ctx).unwrap();
730 assert_eq!(result.len(), 0); let fixed = rule.fix(&ctx).unwrap();
733
734 assert!(fixed.contains("# Heading 1"));
737 assert!(fixed.contains("Some content."));
738 assert!(fixed.contains("## Heading 2"));
739 assert!(fixed.contains("More content."));
740 }
741
742 #[test]
743 fn test_missing_blank_below() {
744 let rule = MD022BlanksAroundHeadings::default();
745 let content = "\n# Heading 1\nSome content.\n\n## Heading 2\n\nMore content.\n";
746 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
747 let result = rule.check(&ctx).unwrap();
748 assert_eq!(result.len(), 1);
749 assert_eq!(result[0].line, 2);
750
751 let fixed = rule.fix(&ctx).unwrap();
753 assert!(fixed.contains("# Heading 1\n\nSome content"));
754 }
755
756 #[test]
757 fn test_missing_blank_above_and_below() {
758 let rule = MD022BlanksAroundHeadings::default();
759 let content = "# Heading 1\nSome content.\n## Heading 2\nMore content.\n";
760 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
761 let result = rule.check(&ctx).unwrap();
762 assert_eq!(result.len(), 3); let fixed = rule.fix(&ctx).unwrap();
766 assert!(fixed.contains("# Heading 1\n\nSome content"));
767 assert!(fixed.contains("Some content.\n\n## Heading 2"));
768 assert!(fixed.contains("## Heading 2\n\nMore content"));
769 }
770
771 #[test]
772 fn test_fix_headings() {
773 let rule = MD022BlanksAroundHeadings::default();
774 let content = "# Heading 1\nSome content.\n## Heading 2\nMore content.";
775 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
776 let result = rule.fix(&ctx).unwrap();
777
778 let expected = "# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.";
779 assert_eq!(result, expected);
780 }
781
782 #[test]
783 fn test_consecutive_headings_pattern() {
784 let rule = MD022BlanksAroundHeadings::default();
785 let content = "# Heading 1\n## Heading 2\n### Heading 3";
786 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
787 let result = rule.fix(&ctx).unwrap();
788
789 let lines: Vec<&str> = result.lines().collect();
791 assert!(!lines.is_empty());
792
793 let h1_pos = lines.iter().position(|&l| l == "# Heading 1").unwrap();
795 let h2_pos = lines.iter().position(|&l| l == "## Heading 2").unwrap();
796 let h3_pos = lines.iter().position(|&l| l == "### Heading 3").unwrap();
797
798 assert!(
800 h2_pos > h1_pos + 1,
801 "Should have at least one blank line after first heading"
802 );
803 assert!(
804 h3_pos > h2_pos + 1,
805 "Should have at least one blank line after second heading"
806 );
807
808 assert!(lines[h1_pos + 1].trim().is_empty(), "Line after h1 should be blank");
810
811 assert!(lines[h2_pos + 1].trim().is_empty(), "Line after h2 should be blank");
813 }
814
815 #[test]
816 fn test_blanks_around_setext_headings() {
817 let rule = MD022BlanksAroundHeadings::default();
818 let content = "Heading 1\n=========\nSome content.\nHeading 2\n---------\nMore content.";
819 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
820 let result = rule.fix(&ctx).unwrap();
821
822 let lines: Vec<&str> = result.lines().collect();
824
825 assert!(result.contains("Heading 1"));
827 assert!(result.contains("========="));
828 assert!(result.contains("Some content."));
829 assert!(result.contains("Heading 2"));
830 assert!(result.contains("---------"));
831 assert!(result.contains("More content."));
832
833 let heading1_marker_idx = lines.iter().position(|&l| l == "=========").unwrap();
835 let some_content_idx = lines.iter().position(|&l| l == "Some content.").unwrap();
836 assert!(
837 some_content_idx > heading1_marker_idx + 1,
838 "Should have a blank line after the first heading"
839 );
840
841 let heading2_marker_idx = lines.iter().position(|&l| l == "---------").unwrap();
842 let more_content_idx = lines.iter().position(|&l| l == "More content.").unwrap();
843 assert!(
844 more_content_idx > heading2_marker_idx + 1,
845 "Should have a blank line after the second heading"
846 );
847
848 let fixed_ctx = LintContext::new(&result, crate::config::MarkdownFlavor::Standard, None);
850 let fixed_warnings = rule.check(&fixed_ctx).unwrap();
851 assert!(fixed_warnings.is_empty(), "Fixed content should have no warnings");
852 }
853
854 #[test]
855 fn test_fix_specific_blank_line_cases() {
856 let rule = MD022BlanksAroundHeadings::default();
857
858 let content1 = "# Heading 1\n## Heading 2\n### Heading 3";
860 let ctx1 = LintContext::new(content1, crate::config::MarkdownFlavor::Standard, None);
861 let result1 = rule.fix(&ctx1).unwrap();
862 assert!(result1.contains("# Heading 1"));
864 assert!(result1.contains("## Heading 2"));
865 assert!(result1.contains("### Heading 3"));
866 let lines: Vec<&str> = result1.lines().collect();
868 let h1_pos = lines.iter().position(|&l| l == "# Heading 1").unwrap();
869 let h2_pos = lines.iter().position(|&l| l == "## Heading 2").unwrap();
870 assert!(lines[h1_pos + 1].trim().is_empty(), "Should have a blank line after h1");
871 assert!(lines[h2_pos + 1].trim().is_empty(), "Should have a blank line after h2");
872
873 let content2 = "# Heading 1\nContent under heading 1\n## Heading 2";
875 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
876 let result2 = rule.fix(&ctx2).unwrap();
877 assert!(result2.contains("# Heading 1"));
879 assert!(result2.contains("Content under heading 1"));
880 assert!(result2.contains("## Heading 2"));
881 let lines2: Vec<&str> = result2.lines().collect();
883 let h1_pos2 = lines2.iter().position(|&l| l == "# Heading 1").unwrap();
884 let _content_pos = lines2.iter().position(|&l| l == "Content under heading 1").unwrap();
885 assert!(
886 lines2[h1_pos2 + 1].trim().is_empty(),
887 "Should have a blank line after heading 1"
888 );
889
890 let content3 = "# Heading 1\n\n\n## Heading 2\n\n\n### Heading 3\n\nContent";
892 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
893 let result3 = rule.fix(&ctx3).unwrap();
894 assert!(result3.contains("# Heading 1"));
896 assert!(result3.contains("## Heading 2"));
897 assert!(result3.contains("### Heading 3"));
898 assert!(result3.contains("Content"));
899 }
900
901 #[test]
902 fn test_fix_preserves_existing_blank_lines() {
903 let rule = MD022BlanksAroundHeadings::new();
904 let content = "# Title
905
906## Section 1
907
908Content here.
909
910## Section 2
911
912More content.
913### Missing Blank Above
914
915Even more content.
916
917## Section 3
918
919Final content.";
920
921 let expected = "# Title
922
923## Section 1
924
925Content here.
926
927## Section 2
928
929More content.
930
931### Missing Blank Above
932
933Even more content.
934
935## Section 3
936
937Final content.";
938
939 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
940 let result = rule.fix_content(&ctx);
941 assert_eq!(
942 result, expected,
943 "Fix should only add missing blank lines, never remove existing ones"
944 );
945 }
946
947 #[test]
948 fn test_fix_preserves_trailing_newline() {
949 let rule = MD022BlanksAroundHeadings::new();
950
951 let content_with_newline = "# Title\nContent here.\n";
953 let ctx = LintContext::new(content_with_newline, crate::config::MarkdownFlavor::Standard, None);
954 let result = rule.fix(&ctx).unwrap();
955 assert!(result.ends_with('\n'), "Should preserve trailing newline");
956
957 let content_without_newline = "# Title\nContent here.";
959 let ctx = LintContext::new(content_without_newline, crate::config::MarkdownFlavor::Standard, None);
960 let result = rule.fix(&ctx).unwrap();
961 assert!(
962 !result.ends_with('\n'),
963 "Should not add trailing newline if original didn't have one"
964 );
965 }
966
967 #[test]
968 fn test_fix_does_not_add_blank_lines_before_lists() {
969 let rule = MD022BlanksAroundHeadings::new();
970 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.";
971
972 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.";
973
974 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
975 let result = rule.fix_content(&ctx);
976 assert_eq!(result, expected, "Fix should not add blank lines before lists");
977 }
978
979 #[test]
980 fn test_fix_idempotent_when_heading_follows_a_list_and_precedes_a_marker() {
981 let rule = MD022BlanksAroundHeadings::default();
987 let content = "- a\n# H\n2. ";
988 for flavor in [
989 crate::config::MarkdownFlavor::Standard,
990 crate::config::MarkdownFlavor::MkDocs,
991 crate::config::MarkdownFlavor::MDX,
992 ] {
993 let ctx1 = LintContext::new(content, flavor, None);
994 let fixed1 = rule.fix(&ctx1).unwrap();
995 let ctx2 = LintContext::new(&fixed1, flavor, None);
996 let fixed2 = rule.fix(&ctx2).unwrap();
997 assert_eq!(fixed1, fixed2, "MD022 fix must be idempotent (flavor={flavor:?})");
998 }
999 }
1000
1001 #[test]
1002 fn test_thematic_break_below_heading_is_not_a_list_item() {
1003 let rule = MD022BlanksAroundHeadings::default();
1010 for marker in [
1011 "* * *",
1012 "- - -",
1013 "_ _ _",
1014 "***",
1015 "---",
1016 "___",
1017 "- --",
1018 "* ** *",
1019 "---- ----",
1020 ] {
1021 let content = format!("text\n\n# Heading\n{marker}\nafter\n");
1022 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1023 let result = rule.check(&ctx).unwrap();
1024 assert_eq!(
1025 result.len(),
1026 1,
1027 "a heading above `{marker}` needs a blank line below it, got {result:?}"
1028 );
1029 assert_eq!(
1030 rule.fix(&ctx).unwrap(),
1031 format!("text\n\n# Heading\n\n{marker}\nafter\n"),
1032 "fix must insert the blank line below the heading for `{marker}`"
1033 );
1034 }
1035 }
1036
1037 #[test]
1038 fn test_list_item_below_heading_is_still_exempt() {
1039 let rule = MD022BlanksAroundHeadings::default();
1042 for item in ["- item", "* item", "+ item", "1. item", "+ + +"] {
1043 let content = format!("text\n\n# Heading\n{item}\n");
1044 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1045 assert!(
1046 rule.check(&ctx).unwrap().is_empty(),
1047 "a list below a heading stays exempt, but `{item}` was reported"
1048 );
1049 assert_eq!(rule.fix(&ctx).unwrap(), content, "`{item}` must not be rewritten");
1050 }
1051 }
1052
1053 #[test]
1054 fn test_per_level_configuration_no_blank_above_h1() {
1055 use md022_config::HeadingLevelConfig;
1056
1057 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1059 lines_above: HeadingLevelConfig::per_level([0, 1, 1, 1, 1, 1]),
1060 lines_below: HeadingLevelConfig::scalar(1),
1061 allowed_at_start: false, });
1063
1064 let content = "Some text\n# Heading 1\n\nMore text";
1066 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1067 let warnings = rule.check(&ctx).unwrap();
1068 assert_eq!(warnings.len(), 0, "H1 without blank above should not trigger warning");
1069
1070 let content = "Some text\n## Heading 2\n\nMore text";
1072 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1073 let warnings = rule.check(&ctx).unwrap();
1074 assert_eq!(warnings.len(), 1, "H2 without blank above should trigger warning");
1075 assert!(warnings[0].message.contains("above"));
1076 }
1077
1078 #[test]
1079 fn test_unlimited_above_with_limited_below_does_not_panic() {
1080 use md022_config::{HeadingBlankRequirement, HeadingLevelConfig};
1081
1082 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1086 lines_above: HeadingLevelConfig::scalar_requirement(HeadingBlankRequirement::Unlimited),
1087 lines_below: HeadingLevelConfig::scalar(1),
1088 allowed_at_start: false,
1089 });
1090
1091 let content = "# Title\n\nText\n## Banana\nText\n";
1093 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1094
1095 let warnings = rule.check(&ctx).expect("check must not fail");
1096
1097 assert!(
1098 warnings.iter().any(|w| w.message.contains("below")),
1099 "expected a 'below' violation, got: {:?}",
1100 warnings.iter().map(|w| &w.message).collect::<Vec<_>>()
1101 );
1102 assert!(
1103 !warnings.iter().any(|w| w.message.contains("above")),
1104 "an unlimited 'above' requirement must never report: {:?}",
1105 warnings.iter().map(|w| &w.message).collect::<Vec<_>>()
1106 );
1107 }
1108
1109 #[test]
1110 fn test_unlimited_below_with_limited_above_does_not_panic() {
1111 use md022_config::{HeadingBlankRequirement, HeadingLevelConfig};
1112
1113 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1114 lines_above: HeadingLevelConfig::scalar(1),
1115 lines_below: HeadingLevelConfig::scalar_requirement(HeadingBlankRequirement::Unlimited),
1116 allowed_at_start: false,
1117 });
1118
1119 let content = "# Title\n\nText\n## Banana\n\nText\n";
1121 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1122
1123 let warnings = rule.check(&ctx).expect("check must not fail");
1124
1125 assert!(
1126 warnings.iter().any(|w| w.message.contains("above")),
1127 "expected an 'above' violation, got: {:?}",
1128 warnings.iter().map(|w| &w.message).collect::<Vec<_>>()
1129 );
1130 assert!(
1131 !warnings.iter().any(|w| w.message.contains("below")),
1132 "an unlimited 'below' requirement must never report: {:?}",
1133 warnings.iter().map(|w| &w.message).collect::<Vec<_>>()
1134 );
1135 }
1136
1137 #[test]
1138 fn test_per_level_configuration_different_requirements() {
1139 use md022_config::HeadingLevelConfig;
1140
1141 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1143 lines_above: HeadingLevelConfig::per_level([0, 1, 1, 2, 2, 2]),
1144 lines_below: HeadingLevelConfig::scalar(1),
1145 allowed_at_start: false,
1146 });
1147
1148 let content = "Text\n# H1\n\nText\n\n## H2\n\nText\n\n### H3\n\nText\n\n\n#### H4\n\nText";
1149 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1150 let warnings = rule.check(&ctx).unwrap();
1151
1152 assert_eq!(
1154 warnings.len(),
1155 0,
1156 "All headings should satisfy level-specific requirements"
1157 );
1158 }
1159
1160 #[test]
1161 fn test_per_level_configuration_violations() {
1162 use md022_config::HeadingLevelConfig;
1163
1164 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1166 lines_above: HeadingLevelConfig::per_level([1, 1, 1, 2, 1, 1]),
1167 lines_below: HeadingLevelConfig::scalar(1),
1168 allowed_at_start: false,
1169 });
1170
1171 let content = "Text\n\n#### Heading 4\n\nMore text";
1173 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1174 let warnings = rule.check(&ctx).unwrap();
1175
1176 assert_eq!(warnings.len(), 1, "H4 with insufficient blanks should trigger warning");
1177 assert!(warnings[0].message.contains("2 blank lines above"));
1178 }
1179
1180 #[test]
1181 fn test_per_level_fix_different_levels() {
1182 use md022_config::HeadingLevelConfig;
1183
1184 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1186 lines_above: HeadingLevelConfig::per_level([0, 1, 2, 2, 2, 2]),
1187 lines_below: HeadingLevelConfig::scalar(1),
1188 allowed_at_start: false,
1189 });
1190
1191 let content = "Text\n# H1\nContent\n## H2\nContent\n### H3\nContent";
1192 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1193 let fixed = rule.fix(&ctx).unwrap();
1194
1195 assert!(fixed.contains("Text\n# H1\n\nContent"));
1197 assert!(fixed.contains("Content\n\n## H2\n\nContent"));
1198 assert!(fixed.contains("Content\n\n\n### H3\n\nContent"));
1199 }
1200
1201 #[test]
1202 fn test_per_level_below_configuration() {
1203 use md022_config::HeadingLevelConfig;
1204
1205 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1207 lines_above: HeadingLevelConfig::scalar(1),
1208 lines_below: HeadingLevelConfig::per_level([2, 1, 1, 1, 1, 1]), allowed_at_start: true,
1210 });
1211
1212 let content = "# Heading 1\n\nSome text";
1214 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1215 let warnings = rule.check(&ctx).unwrap();
1216
1217 assert_eq!(
1218 warnings.len(),
1219 1,
1220 "H1 with insufficient blanks below should trigger warning"
1221 );
1222 assert!(warnings[0].message.contains("2 blank lines below"));
1223 }
1224
1225 #[test]
1226 fn test_scalar_configuration_still_works() {
1227 use md022_config::HeadingLevelConfig;
1228
1229 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1231 lines_above: HeadingLevelConfig::scalar(2),
1232 lines_below: HeadingLevelConfig::scalar(2),
1233 allowed_at_start: false,
1234 });
1235
1236 let content = "Text\n# H1\nContent\n## H2\nContent";
1237 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1238 let warnings = rule.check(&ctx).unwrap();
1239
1240 assert!(!warnings.is_empty(), "Should have violations for insufficient blanks");
1242 }
1243
1244 #[test]
1245 fn test_unlimited_configuration_skips_requirements() {
1246 use md022_config::{HeadingBlankRequirement, HeadingLevelConfig};
1247
1248 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1250 lines_above: HeadingLevelConfig::per_level_requirements([
1251 HeadingBlankRequirement::unlimited(),
1252 HeadingBlankRequirement::limited(1),
1253 HeadingBlankRequirement::limited(1),
1254 HeadingBlankRequirement::limited(1),
1255 HeadingBlankRequirement::limited(1),
1256 HeadingBlankRequirement::limited(1),
1257 ]),
1258 lines_below: HeadingLevelConfig::per_level_requirements([
1259 HeadingBlankRequirement::unlimited(),
1260 HeadingBlankRequirement::limited(1),
1261 HeadingBlankRequirement::limited(1),
1262 HeadingBlankRequirement::limited(1),
1263 HeadingBlankRequirement::limited(1),
1264 HeadingBlankRequirement::limited(1),
1265 ]),
1266 allowed_at_start: false,
1267 });
1268
1269 let content = "# H1\nParagraph\n## H2\nParagraph";
1270 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1271 let warnings = rule.check(&ctx).unwrap();
1272
1273 assert_eq!(warnings.len(), 2, "Only non-unlimited headings should warn");
1275 assert!(
1276 warnings.iter().all(|w| w.line >= 3),
1277 "Warnings should target later headings"
1278 );
1279
1280 let fixed = rule.fix(&ctx).unwrap();
1282 assert!(
1283 fixed.starts_with("# H1\nParagraph\n\n## H2"),
1284 "H1 should remain unchanged"
1285 );
1286 }
1287
1288 #[test]
1289 fn test_html_comment_transparency() {
1290 let rule = MD022BlanksAroundHeadings::default();
1294
1295 let content = "Some content\n\n<!-- markdownlint-disable-next-line MD001 -->\n#### Heading";
1298 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1299 let warnings = rule.check(&ctx).unwrap();
1300 assert!(
1301 warnings.is_empty(),
1302 "HTML comment is transparent - blank line above it counts for heading"
1303 );
1304
1305 let content_multiline = "Some content\n\n<!-- This is a\nmulti-line comment -->\n#### Heading";
1307 let ctx_multiline = LintContext::new(content_multiline, crate::config::MarkdownFlavor::Standard, None);
1308 let warnings_multiline = rule.check(&ctx_multiline).unwrap();
1309 assert!(
1310 warnings_multiline.is_empty(),
1311 "Multi-line HTML comment is also transparent"
1312 );
1313 }
1314
1315 #[test]
1316 fn test_frontmatter_transparency() {
1317 let rule = MD022BlanksAroundHeadings::default();
1320
1321 let content = "---\ntitle: Test\n---\n# First heading";
1323 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1324 let warnings = rule.check(&ctx).unwrap();
1325 assert!(
1326 warnings.is_empty(),
1327 "Frontmatter is transparent - heading can appear immediately after"
1328 );
1329
1330 let content_with_blank = "---\ntitle: Test\n---\n\n# First heading";
1332 let ctx_with_blank = LintContext::new(content_with_blank, crate::config::MarkdownFlavor::Standard, None);
1333 let warnings_with_blank = rule.check(&ctx_with_blank).unwrap();
1334 assert!(
1335 warnings_with_blank.is_empty(),
1336 "Heading with blank line after frontmatter should also be valid"
1337 );
1338
1339 let content_toml = "+++\ntitle = \"Test\"\n+++\n# First heading";
1341 let ctx_toml = LintContext::new(content_toml, crate::config::MarkdownFlavor::Standard, None);
1342 let warnings_toml = rule.check(&ctx_toml).unwrap();
1343 assert!(
1344 warnings_toml.is_empty(),
1345 "TOML frontmatter is also transparent for MD022"
1346 );
1347 }
1348
1349 #[test]
1350 fn test_horizontal_rule_not_treated_as_frontmatter() {
1351 let rule = MD022BlanksAroundHeadings::default();
1354
1355 let content = "Some content\n\n---\n# Heading after HR";
1357 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1358 let warnings = rule.check(&ctx).unwrap();
1359 assert!(
1360 !warnings.is_empty(),
1361 "Heading after horizontal rule without blank line SHOULD trigger MD022"
1362 );
1363 assert!(
1364 warnings.iter().any(|w| w.line == 4),
1365 "Warning should be on line 4 (the heading line)"
1366 );
1367
1368 let content_with_blank = "Some content\n\n---\n\n# Heading after HR";
1370 let ctx_with_blank = LintContext::new(content_with_blank, crate::config::MarkdownFlavor::Standard, None);
1371 let warnings_with_blank = rule.check(&ctx_with_blank).unwrap();
1372 assert!(
1373 warnings_with_blank.is_empty(),
1374 "Heading with blank line after HR should not trigger MD022"
1375 );
1376
1377 let content_hr_start = "---\n# Heading";
1379 let ctx_hr_start = LintContext::new(content_hr_start, crate::config::MarkdownFlavor::Standard, None);
1380 let warnings_hr_start = rule.check(&ctx_hr_start).unwrap();
1381 assert!(
1382 !warnings_hr_start.is_empty(),
1383 "Heading after HR at document start SHOULD trigger MD022"
1384 );
1385
1386 let content_multi_hr = "Content\n\n---\n\n---\n# Heading";
1388 let ctx_multi_hr = LintContext::new(content_multi_hr, crate::config::MarkdownFlavor::Standard, None);
1389 let warnings_multi_hr = rule.check(&ctx_multi_hr).unwrap();
1390 assert!(
1391 !warnings_multi_hr.is_empty(),
1392 "Heading after multiple HRs without blank line SHOULD trigger MD022"
1393 );
1394 }
1395
1396 #[test]
1397 fn test_all_hr_styles_require_blank_before_heading() {
1398 let rule = MD022BlanksAroundHeadings::default();
1400
1401 let hr_styles = [
1403 "---", "***", "___", "- - -", "* * *", "_ _ _", "----", "****", "____", "- - - -",
1404 "- - -", " ---", " ---", ];
1408
1409 for hr in hr_styles {
1410 let content = format!("Content\n\n{hr}\n# Heading");
1411 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1412 let warnings = rule.check(&ctx).unwrap();
1413 assert!(
1414 !warnings.is_empty(),
1415 "HR style '{hr}' followed by heading should trigger MD022"
1416 );
1417 }
1418 }
1419
1420 #[test]
1421 fn test_setext_heading_after_hr() {
1422 let rule = MD022BlanksAroundHeadings::default();
1424
1425 let content = "Content\n\n---\nHeading\n======";
1427 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1428 let warnings = rule.check(&ctx).unwrap();
1429 assert!(
1430 !warnings.is_empty(),
1431 "Setext heading after HR without blank should trigger MD022"
1432 );
1433
1434 let content_h2 = "Content\n\n---\nHeading\n------";
1436 let ctx_h2 = LintContext::new(content_h2, crate::config::MarkdownFlavor::Standard, None);
1437 let warnings_h2 = rule.check(&ctx_h2).unwrap();
1438 assert!(
1439 !warnings_h2.is_empty(),
1440 "Setext h2 after HR without blank should trigger MD022"
1441 );
1442
1443 let content_ok = "Content\n\n---\n\nHeading\n======";
1445 let ctx_ok = LintContext::new(content_ok, crate::config::MarkdownFlavor::Standard, None);
1446 let warnings_ok = rule.check(&ctx_ok).unwrap();
1447 assert!(
1448 warnings_ok.is_empty(),
1449 "Setext heading with blank after HR should not warn"
1450 );
1451 }
1452
1453 #[test]
1454 fn test_hr_in_code_block_not_treated_as_hr() {
1455 let rule = MD022BlanksAroundHeadings::default();
1457
1458 let content = "```\n---\n```\n# Heading";
1461 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1462 let warnings = rule.check(&ctx).unwrap();
1463 assert!(!warnings.is_empty(), "Heading after code block still needs blank line");
1466
1467 let content_ok = "```\n---\n```\n\n# Heading";
1469 let ctx_ok = LintContext::new(content_ok, crate::config::MarkdownFlavor::Standard, None);
1470 let warnings_ok = rule.check(&ctx_ok).unwrap();
1471 assert!(
1472 warnings_ok.is_empty(),
1473 "Heading with blank after code block should not warn"
1474 );
1475 }
1476
1477 #[test]
1478 fn test_hr_in_html_comment_not_treated_as_hr() {
1479 let rule = MD022BlanksAroundHeadings::default();
1481
1482 let content = "<!-- \n---\n -->\n# Heading";
1484 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1485 let warnings = rule.check(&ctx).unwrap();
1486 assert!(
1488 warnings.is_empty(),
1489 "HR inside HTML comment should be ignored - heading after comment is OK"
1490 );
1491 }
1492
1493 #[test]
1494 fn test_invalid_hr_not_triggering() {
1495 let rule = MD022BlanksAroundHeadings::default();
1497
1498 let invalid_hrs = [
1499 " ---", "\t---", "--", "**", "__", "-*-", "---a", "a---", ];
1508
1509 for invalid in invalid_hrs {
1510 let content = format!("Content\n\n{invalid}\n# Heading");
1513 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1514 let _ = rule.check(&ctx);
1517 }
1518 }
1519
1520 #[test]
1521 fn test_frontmatter_vs_horizontal_rule_distinction() {
1522 let rule = MD022BlanksAroundHeadings::default();
1524
1525 let content = "---\ntitle: Test\n---\n\nSome content\n\n---\n# Heading after HR";
1528 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1529 let warnings = rule.check(&ctx).unwrap();
1530 assert!(
1531 !warnings.is_empty(),
1532 "HR after frontmatter content should still require blank line before heading"
1533 );
1534
1535 let content_ok = "---\ntitle: Test\n---\n\nSome content\n\n---\n\n# Heading after HR";
1537 let ctx_ok = LintContext::new(content_ok, crate::config::MarkdownFlavor::Standard, None);
1538 let warnings_ok = rule.check(&ctx_ok).unwrap();
1539 assert!(
1540 warnings_ok.is_empty(),
1541 "HR with blank line before heading should not warn"
1542 );
1543 }
1544
1545 #[test]
1548 fn test_kramdown_ial_after_heading_no_warning() {
1549 let rule = MD022BlanksAroundHeadings::default();
1551 let content = "## Table of Contents\n{: .hhc-toc-heading}\n\nSome content here.";
1552 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1553 let warnings = rule.check(&ctx).unwrap();
1554
1555 assert!(
1556 warnings.is_empty(),
1557 "IAL after heading should not require blank line between them: {warnings:?}"
1558 );
1559 }
1560
1561 #[test]
1562 fn test_kramdown_ial_with_class() {
1563 let rule = MD022BlanksAroundHeadings::default();
1564 let content = "# Heading\n{:.highlight}\n\nContent.";
1565 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1566 let warnings = rule.check(&ctx).unwrap();
1567
1568 assert!(warnings.is_empty(), "IAL with class should be part of heading");
1569 }
1570
1571 #[test]
1572 fn test_kramdown_ial_with_id() {
1573 let rule = MD022BlanksAroundHeadings::default();
1574 let content = "# Heading\n{:#custom-id}\n\nContent.";
1575 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1576 let warnings = rule.check(&ctx).unwrap();
1577
1578 assert!(warnings.is_empty(), "IAL with id should be part of heading");
1579 }
1580
1581 #[test]
1582 fn test_kramdown_ial_with_multiple_attributes() {
1583 let rule = MD022BlanksAroundHeadings::default();
1584 let content = "# Heading\n{: .class #id style=\"color: red\"}\n\nContent.";
1585 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1586 let warnings = rule.check(&ctx).unwrap();
1587
1588 assert!(
1589 warnings.is_empty(),
1590 "IAL with multiple attributes should be part of heading"
1591 );
1592 }
1593
1594 #[test]
1595 fn test_kramdown_ial_missing_blank_after() {
1596 let rule = MD022BlanksAroundHeadings::default();
1598 let content = "# Heading\n{:.class}\nContent without blank.";
1599 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1600 let warnings = rule.check(&ctx).unwrap();
1601
1602 assert_eq!(
1603 warnings.len(),
1604 1,
1605 "Should warn about missing blank after IAL (part of heading)"
1606 );
1607 assert!(warnings[0].message.contains("below"));
1608 }
1609
1610 #[test]
1611 fn test_kramdown_ial_before_heading_transparent() {
1612 let rule = MD022BlanksAroundHeadings::default();
1614 let content = "Content.\n\n{:.preclass}\n## Heading\n\nMore content.";
1615 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1616 let warnings = rule.check(&ctx).unwrap();
1617
1618 assert!(
1619 warnings.is_empty(),
1620 "IAL before heading should be transparent for blank line count"
1621 );
1622 }
1623
1624 #[test]
1625 fn test_kramdown_ial_setext_heading() {
1626 let rule = MD022BlanksAroundHeadings::default();
1627 let content = "Heading\n=======\n{:.setext-class}\n\nContent.";
1628 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1629 let warnings = rule.check(&ctx).unwrap();
1630
1631 assert!(
1632 warnings.is_empty(),
1633 "IAL after Setext heading should be part of heading"
1634 );
1635 }
1636
1637 #[test]
1638 fn test_kramdown_ial_fix_preserves_ial() {
1639 let rule = MD022BlanksAroundHeadings::default();
1640 let content = "Content.\n# Heading\n{:.class}\nMore content.";
1641 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1642 let fixed = rule.fix(&ctx).unwrap();
1643
1644 assert!(
1646 fixed.contains("# Heading\n{:.class}"),
1647 "IAL should stay attached to heading"
1648 );
1649 assert!(fixed.contains("{:.class}\n\nMore"), "Should add blank after IAL");
1650 }
1651
1652 #[test]
1653 fn test_kramdown_ial_fix_does_not_separate() {
1654 let rule = MD022BlanksAroundHeadings::default();
1655 let content = "# Heading\n{:.class}\nContent.";
1656 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1657 let fixed = rule.fix(&ctx).unwrap();
1658
1659 assert!(
1661 !fixed.contains("# Heading\n\n{:.class}"),
1662 "Should not add blank between heading and IAL"
1663 );
1664 assert!(fixed.contains("# Heading\n{:.class}"), "IAL should remain attached");
1665 }
1666
1667 #[test]
1668 fn test_kramdown_multiple_ial_lines() {
1669 let rule = MD022BlanksAroundHeadings::default();
1671 let content = "# Heading\n{:.class1}\n{:#id}\n\nContent.";
1672 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1673 let warnings = rule.check(&ctx).unwrap();
1674
1675 assert!(
1678 warnings.is_empty(),
1679 "Multiple consecutive IALs should be part of heading"
1680 );
1681 }
1682
1683 #[test]
1684 fn test_kramdown_ial_with_blank_line_not_attached() {
1685 let rule = MD022BlanksAroundHeadings::default();
1687 let content = "# Heading\n\n{:.class}\nContent.";
1688 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1689 let warnings = rule.check(&ctx).unwrap();
1690
1691 assert!(warnings.is_empty(), "Blank line separates heading from IAL");
1695 }
1696
1697 #[test]
1698 fn test_not_kramdown_ial_regular_braces() {
1699 let rule = MD022BlanksAroundHeadings::default();
1701 let content = "# Heading\n{not an ial}\n\nContent.";
1702 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1703 let warnings = rule.check(&ctx).unwrap();
1704
1705 assert_eq!(
1707 warnings.len(),
1708 1,
1709 "Non-IAL braces should be regular content requiring blank"
1710 );
1711 }
1712
1713 #[test]
1714 fn test_kramdown_ial_at_document_end() {
1715 let rule = MD022BlanksAroundHeadings::default();
1716 let content = "# Heading\n{:.class}";
1717 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1718 let warnings = rule.check(&ctx).unwrap();
1719
1720 assert!(warnings.is_empty(), "IAL at document end needs no blank after");
1722 }
1723
1724 #[test]
1725 fn test_kramdown_ial_followed_by_code_fence() {
1726 let rule = MD022BlanksAroundHeadings::default();
1727 let content = "# Heading\n{:.class}\n```\ncode\n```";
1728 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1729 let warnings = rule.check(&ctx).unwrap();
1730
1731 assert!(warnings.is_empty(), "No blank needed between IAL and code fence");
1733 }
1734
1735 #[test]
1736 fn test_kramdown_ial_followed_by_list() {
1737 let rule = MD022BlanksAroundHeadings::default();
1738 let content = "# Heading\n{:.class}\n- List item";
1739 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1740 let warnings = rule.check(&ctx).unwrap();
1741
1742 assert!(warnings.is_empty(), "No blank needed between IAL and list");
1744 }
1745
1746 #[test]
1747 fn test_kramdown_ial_fix_idempotent() {
1748 let rule = MD022BlanksAroundHeadings::default();
1749 let content = "# Heading\n{:.class}\nContent.";
1750 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1751
1752 let fixed_once = rule.fix(&ctx).unwrap();
1753 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
1754 let fixed_twice = rule.fix(&ctx2).unwrap();
1755
1756 assert_eq!(fixed_once, fixed_twice, "Fix should be idempotent");
1757 }
1758
1759 #[test]
1760 fn test_kramdown_ial_whitespace_line_between_not_attached() {
1761 let rule = MD022BlanksAroundHeadings::default();
1764 let content = "# Heading\n \n{:.class}\n\nContent.";
1765 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1766 let warnings = rule.check(&ctx).unwrap();
1767
1768 assert!(
1772 warnings.is_empty(),
1773 "Whitespace between heading and IAL means IAL is not attached"
1774 );
1775 }
1776
1777 #[test]
1778 fn test_kramdown_ial_html_comment_between() {
1779 let rule = MD022BlanksAroundHeadings::default();
1782 let content = "# Heading\n<!-- comment -->\n{:.class}\n\nContent.";
1783 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1784 let warnings = rule.check(&ctx).unwrap();
1785
1786 assert_eq!(
1790 warnings.len(),
1791 1,
1792 "IAL not attached when comment is between: {warnings:?}"
1793 );
1794 }
1795
1796 #[test]
1797 fn test_kramdown_ial_generic_attribute() {
1798 let rule = MD022BlanksAroundHeadings::default();
1799 let content = "# Heading\n{:data-toc=\"true\" style=\"color: red\"}\n\nContent.";
1800 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1801 let warnings = rule.check(&ctx).unwrap();
1802
1803 assert!(warnings.is_empty(), "Generic attributes should be recognized as IAL");
1804 }
1805
1806 #[test]
1807 fn test_kramdown_ial_fix_multiple_lines_preserves_all() {
1808 let rule = MD022BlanksAroundHeadings::default();
1809 let content = "# Heading\n{:.class1}\n{:#id}\n{:data-x=\"y\"}\nContent.";
1810 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1811
1812 let fixed = rule.fix(&ctx).unwrap();
1813
1814 assert!(fixed.contains("{:.class1}"), "First IAL should be preserved");
1816 assert!(fixed.contains("{:#id}"), "Second IAL should be preserved");
1817 assert!(fixed.contains("{:data-x=\"y\"}"), "Third IAL should be preserved");
1818 assert!(
1820 fixed.contains("{:data-x=\"y\"}\n\nContent"),
1821 "Blank line should be after all IALs"
1822 );
1823 }
1824
1825 #[test]
1826 fn test_kramdown_ial_crlf_line_endings() {
1827 let rule = MD022BlanksAroundHeadings::default();
1828 let content = "# Heading\r\n{:.class}\r\n\r\nContent.";
1829 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1830 let warnings = rule.check(&ctx).unwrap();
1831
1832 assert!(warnings.is_empty(), "CRLF should work correctly with IAL");
1833 }
1834
1835 #[test]
1836 fn test_kramdown_ial_invalid_patterns_not_recognized() {
1837 let rule = MD022BlanksAroundHeadings::default();
1838
1839 let content = "# Heading\n{ :.class}\n\nContent.";
1841 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1842 let warnings = rule.check(&ctx).unwrap();
1843 assert_eq!(warnings.len(), 1, "Invalid IAL syntax should trigger warning");
1844
1845 let content2 = "# Heading\n{.class}\n\nContent.";
1847 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1848 let warnings2 = rule.check(&ctx2).unwrap();
1849 assert!(warnings2.is_empty(), "{{.class}} is valid kramdown block attribute");
1851
1852 let content3 = "# Heading\n{just text}\n\nContent.";
1854 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
1855 let warnings3 = rule.check(&ctx3).unwrap();
1856 assert_eq!(
1857 warnings3.len(),
1858 1,
1859 "Text in braces is not IAL and should trigger warning"
1860 );
1861 }
1862
1863 #[test]
1864 fn test_kramdown_ial_toc_marker() {
1865 let rule = MD022BlanksAroundHeadings::default();
1867 let content = "# Heading\n{:toc}\n\nContent.";
1868 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1869 let warnings = rule.check(&ctx).unwrap();
1870
1871 assert!(warnings.is_empty(), "{{:toc}} should be recognized as IAL");
1873 }
1874
1875 #[test]
1876 fn test_kramdown_ial_mixed_headings_in_document() {
1877 let rule = MD022BlanksAroundHeadings::default();
1878 let content = r#"# ATX Heading
1879{:.atx-class}
1880
1881Content after ATX.
1882
1883Setext Heading
1884--------------
1885{:#setext-id}
1886
1887Content after Setext.
1888
1889## Another ATX
1890{:.another}
1891
1892More content."#;
1893 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1894 let warnings = rule.check(&ctx).unwrap();
1895
1896 assert!(
1897 warnings.is_empty(),
1898 "Mixed headings with IAL should all work: {warnings:?}"
1899 );
1900 }
1901
1902 #[test]
1903 fn test_kramdown_extension_block_before_first_heading_is_document_start() {
1904 let rule = MD022BlanksAroundHeadings::default();
1905 let content = "{::comment}\nhidden\n{:/comment}\n# Heading\n\nBody\n";
1906 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Kramdown, None);
1907 let warnings = rule.check(&ctx).unwrap();
1908
1909 assert!(
1910 warnings.is_empty(),
1911 "Kramdown extension preamble should not require blank above first heading: {warnings:?}"
1912 );
1913 }
1914
1915 #[test]
1916 fn test_kramdown_ial_before_first_heading_is_document_start() {
1917 let rule = MD022BlanksAroundHeadings::default();
1918 let content = "{:.doc-class}\n# Heading\n\nBody\n";
1919 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Kramdown, None);
1920 let warnings = rule.check(&ctx).unwrap();
1921
1922 assert!(
1923 warnings.is_empty(),
1924 "Kramdown IAL preamble should not require blank above first heading: {warnings:?}"
1925 );
1926 }
1927
1928 #[test]
1931 fn test_quarto_div_marker_transparent_above_heading() {
1932 let rule = MD022BlanksAroundHeadings::default();
1935 let content = "Content\n\n::: {.callout-note}\n# Heading\n\nMore content\n:::\n";
1937 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1938 let warnings = rule.check(&ctx).unwrap();
1939 assert!(
1941 warnings.is_empty(),
1942 "Quarto div marker should be transparent above heading: {warnings:?}"
1943 );
1944 }
1945
1946 #[test]
1947 fn test_quarto_div_marker_transparent_below_heading() {
1948 let rule = MD022BlanksAroundHeadings::default();
1950 let content = "# Heading\n\n::: {.callout-note}\nContent\n:::\n";
1951 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1952 let warnings = rule.check(&ctx).unwrap();
1953 assert!(
1955 warnings.is_empty(),
1956 "Quarto div marker should be transparent below heading: {warnings:?}"
1957 );
1958 }
1959
1960 #[test]
1961 fn test_quarto_heading_inside_callout() {
1962 let rule = MD022BlanksAroundHeadings::default();
1964 let content = "::: {.callout-note}\n\n## Note Title\n\nNote content\n:::\n";
1965 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1966 let warnings = rule.check(&ctx).unwrap();
1967 assert!(
1968 warnings.is_empty(),
1969 "Heading inside Quarto callout should have no warnings: {warnings:?}"
1970 );
1971 }
1972
1973 #[test]
1974 fn test_quarto_heading_at_start_after_div_open() {
1975 let rule = MD022BlanksAroundHeadings::default();
1978 let content = "::: {.callout-warning}\n# Warning\n\nContent\n:::\n";
1980 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1981 let warnings = rule.check(&ctx).unwrap();
1982 assert!(
1988 warnings.is_empty(),
1989 "Heading at start after div open should pass: {warnings:?}"
1990 );
1991 }
1992
1993 #[test]
1994 fn test_quarto_heading_before_div_close() {
1995 let rule = MD022BlanksAroundHeadings::default();
1997 let content = "::: {.callout-note}\nIntro\n\n## Section\n:::\n";
1998 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1999 let warnings = rule.check(&ctx).unwrap();
2000 assert!(
2004 warnings.is_empty(),
2005 "Heading before div close should pass: {warnings:?}"
2006 );
2007 }
2008
2009 #[test]
2010 fn test_quarto_div_markers_not_transparent_in_standard_flavor() {
2011 let rule = MD022BlanksAroundHeadings::default();
2013 let content = "Content\n\n:::\n# Heading\n\n:::\n";
2014 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2015 let warnings = rule.check(&ctx).unwrap();
2016 assert!(
2018 !warnings.is_empty(),
2019 "Standard flavor should not treat ::: as transparent: {warnings:?}"
2020 );
2021 }
2022
2023 #[test]
2024 fn test_quarto_nested_divs_with_heading() {
2025 let rule = MD022BlanksAroundHeadings::default();
2027 let content = "::: {.outer}\n::: {.inner}\n\n# Heading\n\nContent\n:::\n:::\n";
2028 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
2029 let warnings = rule.check(&ctx).unwrap();
2030 assert!(
2031 warnings.is_empty(),
2032 "Nested divs with heading should work: {warnings:?}"
2033 );
2034 }
2035
2036 #[test]
2037 fn test_quarto_fix_preserves_div_markers() {
2038 let rule = MD022BlanksAroundHeadings::default();
2040 let content = "::: {.callout-note}\n\n## Note\n\nContent\n:::\n";
2041 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
2042 let fixed = rule.fix(&ctx).unwrap();
2043 assert!(fixed.contains("::: {.callout-note}"), "Should preserve div opening");
2045 assert!(fixed.contains(":::"), "Should preserve div closing");
2046 assert!(fixed.contains("## Note"), "Should preserve heading");
2047 }
2048
2049 #[test]
2050 fn test_quarto_heading_needs_blank_without_div_transparency() {
2051 let rule = MD022BlanksAroundHeadings::default();
2054 let content = "Content\n::: {.callout-note}\n# Heading\n\nMore\n:::\n";
2056 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
2057 let warnings = rule.check(&ctx).unwrap();
2058 assert!(
2061 !warnings.is_empty(),
2062 "Should still require blank line when not present: {warnings:?}"
2063 );
2064 }
2065
2066 #[test]
2067 fn test_pandoc_div_marker_transparent_above_heading() {
2068 let rule = MD022BlanksAroundHeadings::default();
2071 let content = "Content\n\n::: {.callout-note}\n# Heading\n\nMore content\n:::\n";
2072 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
2073 let warnings = rule.check(&ctx).unwrap();
2074 assert!(
2075 warnings.is_empty(),
2076 "MD022 should treat Pandoc div marker as transparent above heading: {warnings:?}"
2077 );
2078 }
2079
2080 #[test]
2081 fn test_hugo_block_attribute_after_heading_not_flagged() {
2082 let rule = MD022BlanksAroundHeadings::default();
2085 let content = "Intro text.\n\n# Heading\n{class=\"anchor\"}\n\nContent.\n";
2086
2087 for flavor in [
2088 crate::config::MarkdownFlavor::Hugo,
2089 crate::config::MarkdownFlavor::MkDocs,
2090 crate::config::MarkdownFlavor::Kramdown,
2091 ] {
2092 let ctx = LintContext::new(content, flavor, None);
2093 let warnings = rule.check(&ctx).unwrap();
2094 assert!(
2095 warnings.is_empty(),
2096 "MD022 should not flag the block attribute line under {flavor:?}: {warnings:?}"
2097 );
2098 }
2099
2100 let ctx_std = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2103 let warnings_std = rule.check(&ctx_std).unwrap();
2104 assert!(
2105 warnings_std.iter().any(|w| w.message.contains("below heading")),
2106 "MD022 must flag the missing blank below the heading under Standard: {warnings_std:?}"
2107 );
2108 }
2109}