1use crate::lint_context::is_horizontal_rule_content;
2use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
6use crate::utils::blank_lines::is_blank_or_comment_only;
7use crate::utils::mdg;
8use crate::utils::mkdocs_attr_list::is_block_attribute_line;
9use crate::utils::pandoc;
10use crate::utils::range_utils::calculate_heading_range;
11use toml;
12
13pub(crate) mod md022_config;
14use md022_config::MD022Config;
15
16fn starts_with_list_marker(trimmed: &str) -> bool {
31 if is_horizontal_rule_content(trimmed) {
32 return false;
33 }
34 let bytes = trimmed.as_bytes();
35 match bytes.first() {
36 Some(b'-' | b'*' | b'+') => matches!(bytes.get(1), None | Some(b' ')),
37 Some(b'0'..=b'9') => {
38 let mut i = 0;
39 while bytes.get(i).is_some_and(u8::is_ascii_digit) {
40 i += 1;
41 }
42 matches!(bytes.get(i), Some(b'.' | b')')) && matches!(bytes.get(i + 1), None | Some(b' '))
43 }
44 _ => false,
45 }
46}
47
48fn follows_mdg_tag_line(
61 ctx: &crate::lint_context::LintContext,
62 heading_idx: usize,
63 heading: &crate::lint_context::HeadingInfo,
64) -> bool {
65 heading_idx > 0
66 && mdg::keyword_split(&heading.text).is_some()
67 && mdg::is_tag_line(ctx.lines[heading_idx - 1].content(ctx.content))
68}
69
70fn first_text_idx(heading_idx: usize, heading: &crate::lint_context::HeadingInfo) -> usize {
74 heading_idx + 1 - heading.text_lines
75}
76
77fn heading_at_start_idx(ctx: &crate::lint_context::LintContext, is_pandoc: bool) -> Option<usize> {
85 let mut found_non_transparent = false;
86 ctx.lines.iter().enumerate().find_map(|(i, line)| {
87 match line.heading.as_deref() {
88 Some(heading) if !found_non_transparent => Some(first_text_idx(i, heading)),
89 _ => {
90 if !line.is_blank && !line.in_html_comment && !line.in_mdx_comment && !line.is_setext_heading_text {
91 let trimmed = line.content(ctx.content).trim();
92 if is_blank_or_comment_only(trimmed) {
94 } else if line.in_kramdown_extension_block || line.is_kramdown_block_ial {
96 } else if is_pandoc && (pandoc::is_div_open(trimmed) || pandoc::is_div_close(trimmed)) {
98 } else {
100 found_non_transparent = true;
101 }
102 }
103 None
104 }
105 }
106 })
107}
108
109#[derive(Clone, Default)]
181pub struct MD022BlanksAroundHeadings {
182 config: MD022Config,
183}
184
185impl MD022BlanksAroundHeadings {
186 pub fn new() -> Self {
189 Self {
190 config: MD022Config::default(),
191 }
192 }
193
194 pub fn with_values(lines_above: usize, lines_below: usize) -> Self {
196 use md022_config::HeadingLevelConfig;
197 Self {
198 config: MD022Config {
199 lines_above: HeadingLevelConfig::scalar(lines_above),
200 lines_below: HeadingLevelConfig::scalar(lines_below),
201 allowed_at_start: true,
202 },
203 }
204 }
205
206 pub fn from_config_struct(config: MD022Config) -> Self {
207 Self { config }
208 }
209
210 fn fix_content(&self, ctx: &crate::lint_context::LintContext) -> String {
212 let line_ending = "\n";
215 let had_trailing_newline = ctx.content.ends_with('\n');
216 let is_pandoc = ctx.flavor.is_pandoc_compatible();
217 let is_mdg = ctx.flavor == crate::config::MarkdownFlavor::MDG;
218 let mut result = Vec::new();
219 let mut skip_count: usize = 0;
220
221 let heading_at_start_idx = heading_at_start_idx(ctx, is_pandoc);
222
223 for (i, line_info) in ctx.lines.iter().enumerate() {
224 if skip_count > 0 {
225 skip_count -= 1;
226 continue;
227 }
228 let line = line_info.content(ctx.content);
229
230 if line_info.in_code_block {
231 result.push(line.to_string());
232 continue;
233 }
234
235 let heading_idx = if line_info.heading.is_some() {
239 Some(i)
240 } else if line_info.is_setext_heading_text {
241 ctx.lines[i..]
242 .iter()
243 .position(|candidate| candidate.heading.is_some())
244 .map(|offset| i + offset)
245 } else {
246 None
247 };
248
249 if let Some(heading_idx) = heading_idx {
250 let heading = ctx.lines[heading_idx].heading.as_deref().unwrap();
251
252 let heading_end_idx = if matches!(
255 heading.style,
256 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
257 ) && heading_idx + 1 < ctx.lines.len()
258 {
259 heading_idx + 1
260 } else {
261 heading_idx
262 };
263
264 if (i..=heading_end_idx).any(|idx| ctx.inline_config().is_rule_disabled("MD022", idx + 1)) {
268 for idx in i..=heading_end_idx {
269 result.push(ctx.lines[idx].content(ctx.content).to_string());
270 }
271 skip_count += heading_end_idx - i;
272 continue;
273 }
274
275 let is_first_heading = Some(i) == heading_at_start_idx;
277 let heading_level = heading.level as usize;
278
279 let mut blank_lines_above = 0;
281 let mut check_idx = result.len();
282 while check_idx > 0 {
283 let prev_line = &result[check_idx - 1];
284 let trimmed = prev_line.trim();
285 if is_blank_or_comment_only(prev_line) {
286 blank_lines_above += 1;
288 check_idx -= 1;
289 } else if is_block_attribute_line(trimmed, ctx.flavor) {
290 check_idx -= 1;
292 } else if is_pandoc && (pandoc::is_div_open(trimmed) || pandoc::is_div_close(trimmed)) {
293 check_idx -= 1;
295 } else {
296 break;
297 }
298 }
299
300 let requirement_above = self.config.lines_above.get_for_level(heading_level);
302 let follows_mdg_tags = is_mdg && follows_mdg_tag_line(ctx, i, heading);
303 let needed_blanks_above = if follows_mdg_tags || (is_first_heading && self.config.allowed_at_start) {
304 0
305 } else {
306 requirement_above.required_count().unwrap_or(0)
307 };
308
309 while blank_lines_above < needed_blanks_above {
311 result.push(String::new());
312 blank_lines_above += 1;
313 }
314
315 for idx in i..=heading_end_idx {
318 result.push(ctx.lines[idx].content(ctx.content).to_string());
319 }
320 skip_count += heading_end_idx - i; let mut effective_end_idx = heading_end_idx;
324
325 let mut ial_count = 0;
328 while effective_end_idx + 1 < ctx.lines.len() {
329 let next_line = &ctx.lines[effective_end_idx + 1];
330 let next_trimmed = next_line.content(ctx.content).trim();
331 if is_block_attribute_line(next_trimmed, ctx.flavor) {
332 result.push(next_trimmed.to_string());
333 effective_end_idx += 1;
334 ial_count += 1;
335 } else {
336 break;
337 }
338 }
339
340 let mut blank_lines_below = 0;
342 let mut next_content_line_idx = None;
343 for j in (effective_end_idx + 1)..ctx.lines.len() {
344 if ctx.lines[j].is_blank || is_blank_or_comment_only(ctx.lines[j].content(ctx.content)) {
345 blank_lines_below += 1;
346 } else {
347 next_content_line_idx = Some(j);
348 break;
349 }
350 }
351
352 let next_is_special = if let Some(idx) = next_content_line_idx {
354 let next_line = &ctx.lines[idx];
355 let trimmed = next_line.content(ctx.content).trim();
356 next_line.list_item.is_some()
357 || starts_with_list_marker(trimmed)
358 || ((trimmed.starts_with("```") || trimmed.starts_with("~~~"))
359 && (trimmed.len() == 3
360 || (trimmed.len() > 3
361 && trimmed
362 .chars()
363 .nth(3)
364 .is_some_and(|c| c.is_whitespace() || c.is_alphabetic()))))
365 } else {
366 false
367 };
368
369 let requirement_below = self.config.lines_below.get_for_level(heading_level);
371 let needed_blanks_below = if next_is_special || next_content_line_idx.is_none() {
374 0
375 } else {
376 requirement_below.required_count().unwrap_or(0)
377 };
378 if blank_lines_below < needed_blanks_below {
379 for _ in 0..(needed_blanks_below - blank_lines_below) {
380 result.push(String::new());
381 }
382 }
383
384 skip_count += ial_count;
386 } else {
387 result.push(line.to_string());
389 }
390 }
391
392 let mut joined = result.join(line_ending);
396 if had_trailing_newline {
397 joined.push_str(line_ending);
398 }
399 joined
400 }
401}
402
403impl Rule for MD022BlanksAroundHeadings {
404 fn name(&self) -> &'static str {
405 "MD022"
406 }
407
408 fn description(&self) -> &'static str {
409 "Headings should be surrounded by blank lines"
410 }
411
412 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
413 let mut result = Vec::new();
414
415 if ctx.lines.is_empty() {
417 return Ok(result);
418 }
419
420 let line_ending = "\n";
423 let is_pandoc = ctx.flavor.is_pandoc_compatible();
424 let is_mdg = ctx.flavor == crate::config::MarkdownFlavor::MDG;
425
426 let heading_at_start_idx = heading_at_start_idx(ctx, is_pandoc);
427
428 let mut heading_violations = Vec::new();
430 let mut processed_headings = std::collections::HashSet::new();
431
432 for (line_num, line_info) in ctx.lines.iter().enumerate() {
433 if processed_headings.contains(&line_num) || line_info.heading.is_none() {
435 continue;
436 }
437
438 if line_info.in_pymdown_block {
440 continue;
441 }
442
443 let heading = line_info.heading.as_ref().unwrap();
444
445 let heading_level = heading.level as usize;
446
447 processed_headings.insert(line_num);
451
452 let first_idx = first_text_idx(line_num, heading);
456
457 let is_first_heading = Some(first_idx) == heading_at_start_idx;
459
460 let required_above_count = self.config.lines_above.get_for_level(heading_level).required_count();
462 let required_below_count = self.config.lines_below.get_for_level(heading_level).required_count();
463
464 let should_check_above = required_above_count.is_some()
466 && first_idx > 0
467 && (!is_first_heading || !self.config.allowed_at_start)
468 && !(is_mdg && follows_mdg_tag_line(ctx, first_idx, heading));
469 if should_check_above {
470 let mut blank_lines_above = 0;
471 let mut hit_frontmatter_end = false;
472 for j in (0..first_idx).rev() {
473 let line_content = ctx.lines[j].content(ctx.content);
474 let trimmed = line_content.trim();
475 if ctx.lines[j].is_blank || is_blank_or_comment_only(line_content) {
476 blank_lines_above += 1;
479 } else if ctx.lines[j].in_html_comment || ctx.lines[j].in_mdx_comment {
480 continue;
482 } else if is_block_attribute_line(trimmed, ctx.flavor) {
483 continue;
485 } else if is_pandoc && (pandoc::is_div_open(trimmed) || pandoc::is_div_close(trimmed)) {
486 continue;
488 } else if ctx.lines[j].in_front_matter {
489 hit_frontmatter_end = true;
494 break;
495 } else {
496 break;
497 }
498 }
499 let required = required_above_count.unwrap();
500 if !hit_frontmatter_end && blank_lines_above < required {
501 let needed_blanks = required - blank_lines_above;
502 heading_violations.push((line_num, first_idx, "above", needed_blanks, heading_level));
503 }
504 }
505
506 let mut effective_last_line = if matches!(
508 heading.style,
509 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
510 ) {
511 line_num + 1 } else {
513 line_num
514 };
515
516 while effective_last_line + 1 < ctx.lines.len() {
519 let next_line = &ctx.lines[effective_last_line + 1];
520 let next_trimmed = next_line.content(ctx.content).trim();
521 if is_block_attribute_line(next_trimmed, ctx.flavor) {
522 effective_last_line += 1;
523 } else {
524 break;
525 }
526 }
527
528 if effective_last_line < ctx.lines.len() - 1 {
530 let mut next_non_blank_idx = effective_last_line + 1;
532 while next_non_blank_idx < ctx.lines.len() {
533 let check_line = &ctx.lines[next_non_blank_idx];
534 let check_trimmed = check_line.content(ctx.content).trim();
535 if check_line.is_blank {
536 next_non_blank_idx += 1;
537 } else if check_line.in_html_comment
538 || check_line.in_mdx_comment
539 || is_blank_or_comment_only(check_line.content(ctx.content))
540 {
541 next_non_blank_idx += 1;
543 } else if is_pandoc && (pandoc::is_div_open(check_trimmed) || pandoc::is_div_close(check_trimmed)) {
544 next_non_blank_idx += 1;
546 } else {
547 break;
548 }
549 }
550
551 if next_non_blank_idx >= ctx.lines.len() {
553 continue;
555 }
556
557 let next_line_is_special = {
559 let next_line = &ctx.lines[next_non_blank_idx];
560 let next_trimmed = next_line.content(ctx.content).trim();
561
562 let is_code_fence = (next_trimmed.starts_with("```") || next_trimmed.starts_with("~~~"))
564 && (next_trimmed.len() == 3
565 || (next_trimmed.len() > 3
566 && next_trimmed
567 .chars()
568 .nth(3)
569 .is_some_and(|c| c.is_whitespace() || c.is_alphabetic())));
570
571 let is_list_item = next_line.list_item.is_some() || starts_with_list_marker(next_trimmed);
578
579 is_code_fence || is_list_item
580 };
581
582 if !next_line_is_special && let Some(required) = required_below_count {
584 let mut blank_lines_below = 0;
586 for k in (effective_last_line + 1)..next_non_blank_idx {
587 if ctx.lines[k].is_blank || is_blank_or_comment_only(ctx.lines[k].content(ctx.content)) {
589 blank_lines_below += 1;
590 }
591 }
592
593 if blank_lines_below < required {
594 let needed_blanks = required - blank_lines_below;
595 heading_violations.push((line_num, first_idx, "below", needed_blanks, heading_level));
596 }
597 }
598 }
599 }
600
601 for (heading_line, first_line, position, needed_blanks, heading_level) in heading_violations {
603 let line_info = &ctx.lines[heading_line];
604
605 let (start_line, start_col, end_line, end_col) =
607 calculate_heading_range(first_line + 1, heading_line + 1, line_info.content(ctx.content));
608
609 let (message, insertion_point) = match position {
616 "above" => {
617 let Some(required_above_count) =
618 self.config.lines_above.get_for_level(heading_level).required_count()
619 else {
620 continue;
621 };
622 (
623 format!(
624 "Expected {} blank {} above heading",
625 required_above_count,
626 if required_above_count == 1 { "line" } else { "lines" }
627 ),
628 first_line, )
630 }
631 "below" => {
632 let Some(required_below_count) =
633 self.config.lines_below.get_for_level(heading_level).required_count()
634 else {
635 continue;
636 };
637 let insert_after = if line_info.heading.as_ref().is_some_and(|h| {
639 matches!(
640 h.style,
641 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
642 )
643 }) {
644 heading_line + 2
645 } else {
646 heading_line + 1
647 };
648
649 (
650 format!(
651 "Expected {} blank {} below heading",
652 required_below_count,
653 if required_below_count == 1 { "line" } else { "lines" }
654 ),
655 insert_after,
656 )
657 }
658 _ => continue,
659 };
660
661 let byte_range = if insertion_point == 0 && position == "above" {
663 0..0
665 } else if position == "above" && insertion_point > 0 {
666 ctx.lines[insertion_point].byte_offset..ctx.lines[insertion_point].byte_offset
668 } else if position == "below" && insertion_point - 1 < ctx.lines.len() {
669 let line_idx = insertion_point - 1;
671 let line_end_offset = if line_idx + 1 < ctx.lines.len() {
672 ctx.lines[line_idx + 1].byte_offset
673 } else {
674 ctx.content.len()
675 };
676 line_end_offset..line_end_offset
677 } else {
678 let content_len = ctx.content.len();
680 content_len..content_len
681 };
682
683 result.push(LintWarning {
684 rule_name: Some(self.name().to_string()),
685 message,
686 line: start_line,
687 column: start_col,
688 end_line,
689 end_column: end_col,
690 severity: Severity::Warning,
691 fix: Some(Fix::new(byte_range, line_ending.repeat(needed_blanks))),
692 });
693 }
694
695 Ok(result)
696 }
697
698 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
699 if ctx.content.is_empty() {
700 return Ok(ctx.content.to_string());
701 }
702
703 let fixed = self.fix_content(ctx);
705
706 Ok(fixed)
707 }
708
709 fn category(&self) -> RuleCategory {
711 RuleCategory::Heading
712 }
713
714 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
716 if ctx.content.is_empty() || !ctx.likely_has_headings() {
718 return true;
719 }
720 ctx.lines.iter().all(|line| line.heading.is_none())
722 }
723
724 fn as_any(&self) -> &dyn std::any::Any {
725 self
726 }
727
728 crate::impl_rule_config_methods!(MD022Config);
729
730 fn polymorphic_config_keys(&self) -> &'static [&'static str] {
731 &["lines-above", "lines-below"]
736 }
737}
738
739#[cfg(test)]
740mod tests {
741 use super::*;
742 use crate::lint_context::LintContext;
743
744 #[test]
745 fn test_valid_headings() {
746 let rule = MD022BlanksAroundHeadings::default();
747 let content = "\n# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.\n";
748 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
749 let result = rule.check(&ctx).unwrap();
750 assert!(result.is_empty());
751 }
752
753 #[test]
754 fn test_missing_blank_above() {
755 let rule = MD022BlanksAroundHeadings::default();
756 let content = "# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.\n";
757 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
758 let result = rule.check(&ctx).unwrap();
759 assert_eq!(result.len(), 0); let fixed = rule.fix(&ctx).unwrap();
762
763 assert!(fixed.contains("# Heading 1"));
766 assert!(fixed.contains("Some content."));
767 assert!(fixed.contains("## Heading 2"));
768 assert!(fixed.contains("More content."));
769 }
770
771 #[test]
772 fn test_missing_blank_below() {
773 let rule = MD022BlanksAroundHeadings::default();
774 let content = "\n# Heading 1\nSome content.\n\n## Heading 2\n\nMore content.\n";
775 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
776 let result = rule.check(&ctx).unwrap();
777 assert_eq!(result.len(), 1);
778 assert_eq!(result[0].line, 2);
779
780 let fixed = rule.fix(&ctx).unwrap();
782 assert!(fixed.contains("# Heading 1\n\nSome content"));
783 }
784
785 #[test]
786 fn test_missing_blank_above_and_below() {
787 let rule = MD022BlanksAroundHeadings::default();
788 let content = "# Heading 1\nSome content.\n## Heading 2\nMore content.\n";
789 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
790 let result = rule.check(&ctx).unwrap();
791 assert_eq!(result.len(), 3); let fixed = rule.fix(&ctx).unwrap();
795 assert!(fixed.contains("# Heading 1\n\nSome content"));
796 assert!(fixed.contains("Some content.\n\n## Heading 2"));
797 assert!(fixed.contains("## Heading 2\n\nMore content"));
798 }
799
800 #[test]
801 fn test_fix_headings() {
802 let rule = MD022BlanksAroundHeadings::default();
803 let content = "# Heading 1\nSome content.\n## Heading 2\nMore content.";
804 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
805 let result = rule.fix(&ctx).unwrap();
806
807 let expected = "# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.";
808 assert_eq!(result, expected);
809 }
810
811 #[test]
812 fn test_consecutive_headings_pattern() {
813 let rule = MD022BlanksAroundHeadings::default();
814 let content = "# Heading 1\n## Heading 2\n### Heading 3";
815 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
816 let result = rule.fix(&ctx).unwrap();
817
818 let lines: Vec<&str> = result.lines().collect();
820 assert!(!lines.is_empty());
821
822 let h1_pos = lines.iter().position(|&l| l == "# Heading 1").unwrap();
824 let h2_pos = lines.iter().position(|&l| l == "## Heading 2").unwrap();
825 let h3_pos = lines.iter().position(|&l| l == "### Heading 3").unwrap();
826
827 assert!(
829 h2_pos > h1_pos + 1,
830 "Should have at least one blank line after first heading"
831 );
832 assert!(
833 h3_pos > h2_pos + 1,
834 "Should have at least one blank line after second heading"
835 );
836
837 assert!(lines[h1_pos + 1].trim().is_empty(), "Line after h1 should be blank");
839
840 assert!(lines[h2_pos + 1].trim().is_empty(), "Line after h2 should be blank");
842 }
843
844 #[test]
845 fn test_blanks_around_setext_headings() {
846 let rule = MD022BlanksAroundHeadings::default();
847 let content = "Heading 1\n=========\nSome content.\nHeading 2\n---------\nMore content.";
848 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
849 let result = rule.fix(&ctx).unwrap();
850
851 let lines: Vec<&str> = result.lines().collect();
853
854 assert!(result.contains("Heading 1"));
856 assert!(result.contains("========="));
857 assert!(result.contains("Some content."));
858 assert!(result.contains("Heading 2"));
859 assert!(result.contains("---------"));
860 assert!(result.contains("More content."));
861
862 let heading1_marker_idx = lines.iter().position(|&l| l == "=========").unwrap();
864 let some_content_idx = lines.iter().position(|&l| l == "Some content.").unwrap();
865 assert!(
866 some_content_idx > heading1_marker_idx + 1,
867 "Should have a blank line after the first heading"
868 );
869
870 let heading2_marker_idx = lines.iter().position(|&l| l == "---------").unwrap();
871 let more_content_idx = lines.iter().position(|&l| l == "More content.").unwrap();
872 assert!(
873 more_content_idx > heading2_marker_idx + 1,
874 "Should have a blank line after the second heading"
875 );
876
877 let fixed_ctx = LintContext::new(&result, crate::config::MarkdownFlavor::Standard, None);
879 let fixed_warnings = rule.check(&fixed_ctx).unwrap();
880 assert!(fixed_warnings.is_empty(), "Fixed content should have no warnings");
881 }
882
883 #[test]
884 fn test_fix_specific_blank_line_cases() {
885 let rule = MD022BlanksAroundHeadings::default();
886
887 let content1 = "# Heading 1\n## Heading 2\n### Heading 3";
889 let ctx1 = LintContext::new(content1, crate::config::MarkdownFlavor::Standard, None);
890 let result1 = rule.fix(&ctx1).unwrap();
891 assert!(result1.contains("# Heading 1"));
893 assert!(result1.contains("## Heading 2"));
894 assert!(result1.contains("### Heading 3"));
895 let lines: Vec<&str> = result1.lines().collect();
897 let h1_pos = lines.iter().position(|&l| l == "# Heading 1").unwrap();
898 let h2_pos = lines.iter().position(|&l| l == "## Heading 2").unwrap();
899 assert!(lines[h1_pos + 1].trim().is_empty(), "Should have a blank line after h1");
900 assert!(lines[h2_pos + 1].trim().is_empty(), "Should have a blank line after h2");
901
902 let content2 = "# Heading 1\nContent under heading 1\n## Heading 2";
904 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
905 let result2 = rule.fix(&ctx2).unwrap();
906 assert!(result2.contains("# Heading 1"));
908 assert!(result2.contains("Content under heading 1"));
909 assert!(result2.contains("## Heading 2"));
910 let lines2: Vec<&str> = result2.lines().collect();
912 let h1_pos2 = lines2.iter().position(|&l| l == "# Heading 1").unwrap();
913 let _content_pos = lines2.iter().position(|&l| l == "Content under heading 1").unwrap();
914 assert!(
915 lines2[h1_pos2 + 1].trim().is_empty(),
916 "Should have a blank line after heading 1"
917 );
918
919 let content3 = "# Heading 1\n\n\n## Heading 2\n\n\n### Heading 3\n\nContent";
921 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
922 let result3 = rule.fix(&ctx3).unwrap();
923 assert!(result3.contains("# Heading 1"));
925 assert!(result3.contains("## Heading 2"));
926 assert!(result3.contains("### Heading 3"));
927 assert!(result3.contains("Content"));
928 }
929
930 #[test]
931 fn test_fix_preserves_existing_blank_lines() {
932 let rule = MD022BlanksAroundHeadings::new();
933 let content = "# Title
934
935## Section 1
936
937Content here.
938
939## Section 2
940
941More content.
942### Missing Blank Above
943
944Even more content.
945
946## Section 3
947
948Final content.";
949
950 let expected = "# Title
951
952## Section 1
953
954Content here.
955
956## Section 2
957
958More content.
959
960### Missing Blank Above
961
962Even more content.
963
964## Section 3
965
966Final content.";
967
968 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
969 let result = rule.fix_content(&ctx);
970 assert_eq!(
971 result, expected,
972 "Fix should only add missing blank lines, never remove existing ones"
973 );
974 }
975
976 #[test]
977 fn test_fix_preserves_trailing_newline() {
978 let rule = MD022BlanksAroundHeadings::new();
979
980 let content_with_newline = "# Title\nContent here.\n";
982 let ctx = LintContext::new(content_with_newline, crate::config::MarkdownFlavor::Standard, None);
983 let result = rule.fix(&ctx).unwrap();
984 assert!(result.ends_with('\n'), "Should preserve trailing newline");
985
986 let content_without_newline = "# Title\nContent here.";
988 let ctx = LintContext::new(content_without_newline, crate::config::MarkdownFlavor::Standard, None);
989 let result = rule.fix(&ctx).unwrap();
990 assert!(
991 !result.ends_with('\n'),
992 "Should not add trailing newline if original didn't have one"
993 );
994 }
995
996 #[test]
997 fn test_fix_does_not_add_blank_lines_before_lists() {
998 let rule = MD022BlanksAroundHeadings::new();
999 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.";
1000
1001 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.";
1002
1003 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1004 let result = rule.fix_content(&ctx);
1005 assert_eq!(result, expected, "Fix should not add blank lines before lists");
1006 }
1007
1008 #[test]
1009 fn test_fix_idempotent_when_heading_follows_a_list_and_precedes_a_marker() {
1010 let rule = MD022BlanksAroundHeadings::default();
1016 let content = "- a\n# H\n2. ";
1017 for flavor in [
1018 crate::config::MarkdownFlavor::Standard,
1019 crate::config::MarkdownFlavor::MkDocs,
1020 crate::config::MarkdownFlavor::MDX,
1021 ] {
1022 let ctx1 = LintContext::new(content, flavor, None);
1023 let fixed1 = rule.fix(&ctx1).unwrap();
1024 let ctx2 = LintContext::new(&fixed1, flavor, None);
1025 let fixed2 = rule.fix(&ctx2).unwrap();
1026 assert_eq!(fixed1, fixed2, "MD022 fix must be idempotent (flavor={flavor:?})");
1027 }
1028 }
1029
1030 #[test]
1031 fn test_thematic_break_below_heading_is_not_a_list_item() {
1032 let rule = MD022BlanksAroundHeadings::default();
1039 for marker in [
1040 "* * *",
1041 "- - -",
1042 "_ _ _",
1043 "***",
1044 "---",
1045 "___",
1046 "- --",
1047 "* ** *",
1048 "---- ----",
1049 ] {
1050 let content = format!("text\n\n# Heading\n{marker}\nafter\n");
1051 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1052 let result = rule.check(&ctx).unwrap();
1053 assert_eq!(
1054 result.len(),
1055 1,
1056 "a heading above `{marker}` needs a blank line below it, got {result:?}"
1057 );
1058 assert_eq!(
1059 rule.fix(&ctx).unwrap(),
1060 format!("text\n\n# Heading\n\n{marker}\nafter\n"),
1061 "fix must insert the blank line below the heading for `{marker}`"
1062 );
1063 }
1064 }
1065
1066 #[test]
1067 fn test_list_item_below_heading_is_still_exempt() {
1068 let rule = MD022BlanksAroundHeadings::default();
1071 for item in ["- item", "* item", "+ item", "1. item", "+ + +"] {
1072 let content = format!("text\n\n# Heading\n{item}\n");
1073 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1074 assert!(
1075 rule.check(&ctx).unwrap().is_empty(),
1076 "a list below a heading stays exempt, but `{item}` was reported"
1077 );
1078 assert_eq!(rule.fix(&ctx).unwrap(), content, "`{item}` must not be rewritten");
1079 }
1080 }
1081
1082 #[test]
1083 fn test_per_level_configuration_no_blank_above_h1() {
1084 use md022_config::HeadingLevelConfig;
1085
1086 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1088 lines_above: HeadingLevelConfig::per_level([0, 1, 1, 1, 1, 1]),
1089 lines_below: HeadingLevelConfig::scalar(1),
1090 allowed_at_start: false, });
1092
1093 let content = "Some text\n# Heading 1\n\nMore text";
1095 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1096 let warnings = rule.check(&ctx).unwrap();
1097 assert_eq!(warnings.len(), 0, "H1 without blank above should not trigger warning");
1098
1099 let content = "Some text\n## Heading 2\n\nMore text";
1101 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1102 let warnings = rule.check(&ctx).unwrap();
1103 assert_eq!(warnings.len(), 1, "H2 without blank above should trigger warning");
1104 assert!(warnings[0].message.contains("above"));
1105 }
1106
1107 #[test]
1108 fn test_unlimited_above_with_limited_below_does_not_panic() {
1109 use md022_config::{HeadingBlankRequirement, HeadingLevelConfig};
1110
1111 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1115 lines_above: HeadingLevelConfig::scalar_requirement(HeadingBlankRequirement::Unlimited),
1116 lines_below: HeadingLevelConfig::scalar(1),
1117 allowed_at_start: false,
1118 });
1119
1120 let content = "# Title\n\nText\n## Banana\nText\n";
1122 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1123
1124 let warnings = rule.check(&ctx).expect("check must not fail");
1125
1126 assert!(
1127 warnings.iter().any(|w| w.message.contains("below")),
1128 "expected a 'below' violation, got: {:?}",
1129 warnings.iter().map(|w| &w.message).collect::<Vec<_>>()
1130 );
1131 assert!(
1132 !warnings.iter().any(|w| w.message.contains("above")),
1133 "an unlimited 'above' requirement must never report: {:?}",
1134 warnings.iter().map(|w| &w.message).collect::<Vec<_>>()
1135 );
1136 }
1137
1138 #[test]
1139 fn test_unlimited_below_with_limited_above_does_not_panic() {
1140 use md022_config::{HeadingBlankRequirement, HeadingLevelConfig};
1141
1142 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1143 lines_above: HeadingLevelConfig::scalar(1),
1144 lines_below: HeadingLevelConfig::scalar_requirement(HeadingBlankRequirement::Unlimited),
1145 allowed_at_start: false,
1146 });
1147
1148 let content = "# Title\n\nText\n## Banana\n\nText\n";
1150 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1151
1152 let warnings = rule.check(&ctx).expect("check must not fail");
1153
1154 assert!(
1155 warnings.iter().any(|w| w.message.contains("above")),
1156 "expected an 'above' violation, got: {:?}",
1157 warnings.iter().map(|w| &w.message).collect::<Vec<_>>()
1158 );
1159 assert!(
1160 !warnings.iter().any(|w| w.message.contains("below")),
1161 "an unlimited 'below' requirement must never report: {:?}",
1162 warnings.iter().map(|w| &w.message).collect::<Vec<_>>()
1163 );
1164 }
1165
1166 #[test]
1167 fn test_per_level_configuration_different_requirements() {
1168 use md022_config::HeadingLevelConfig;
1169
1170 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1172 lines_above: HeadingLevelConfig::per_level([0, 1, 1, 2, 2, 2]),
1173 lines_below: HeadingLevelConfig::scalar(1),
1174 allowed_at_start: false,
1175 });
1176
1177 let content = "Text\n# H1\n\nText\n\n## H2\n\nText\n\n### H3\n\nText\n\n\n#### H4\n\nText";
1178 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1179 let warnings = rule.check(&ctx).unwrap();
1180
1181 assert_eq!(
1183 warnings.len(),
1184 0,
1185 "All headings should satisfy level-specific requirements"
1186 );
1187 }
1188
1189 #[test]
1190 fn test_per_level_configuration_violations() {
1191 use md022_config::HeadingLevelConfig;
1192
1193 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1195 lines_above: HeadingLevelConfig::per_level([1, 1, 1, 2, 1, 1]),
1196 lines_below: HeadingLevelConfig::scalar(1),
1197 allowed_at_start: false,
1198 });
1199
1200 let content = "Text\n\n#### Heading 4\n\nMore text";
1202 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1203 let warnings = rule.check(&ctx).unwrap();
1204
1205 assert_eq!(warnings.len(), 1, "H4 with insufficient blanks should trigger warning");
1206 assert!(warnings[0].message.contains("2 blank lines above"));
1207 }
1208
1209 #[test]
1210 fn test_per_level_fix_different_levels() {
1211 use md022_config::HeadingLevelConfig;
1212
1213 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1215 lines_above: HeadingLevelConfig::per_level([0, 1, 2, 2, 2, 2]),
1216 lines_below: HeadingLevelConfig::scalar(1),
1217 allowed_at_start: false,
1218 });
1219
1220 let content = "Text\n# H1\nContent\n## H2\nContent\n### H3\nContent";
1221 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1222 let fixed = rule.fix(&ctx).unwrap();
1223
1224 assert!(fixed.contains("Text\n# H1\n\nContent"));
1226 assert!(fixed.contains("Content\n\n## H2\n\nContent"));
1227 assert!(fixed.contains("Content\n\n\n### H3\n\nContent"));
1228 }
1229
1230 #[test]
1231 fn test_per_level_below_configuration() {
1232 use md022_config::HeadingLevelConfig;
1233
1234 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1236 lines_above: HeadingLevelConfig::scalar(1),
1237 lines_below: HeadingLevelConfig::per_level([2, 1, 1, 1, 1, 1]), allowed_at_start: true,
1239 });
1240
1241 let content = "# Heading 1\n\nSome text";
1243 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1244 let warnings = rule.check(&ctx).unwrap();
1245
1246 assert_eq!(
1247 warnings.len(),
1248 1,
1249 "H1 with insufficient blanks below should trigger warning"
1250 );
1251 assert!(warnings[0].message.contains("2 blank lines below"));
1252 }
1253
1254 #[test]
1255 fn test_scalar_configuration_still_works() {
1256 use md022_config::HeadingLevelConfig;
1257
1258 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1260 lines_above: HeadingLevelConfig::scalar(2),
1261 lines_below: HeadingLevelConfig::scalar(2),
1262 allowed_at_start: false,
1263 });
1264
1265 let content = "Text\n# H1\nContent\n## H2\nContent";
1266 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1267 let warnings = rule.check(&ctx).unwrap();
1268
1269 assert!(!warnings.is_empty(), "Should have violations for insufficient blanks");
1271 }
1272
1273 #[test]
1274 fn test_unlimited_configuration_skips_requirements() {
1275 use md022_config::{HeadingBlankRequirement, HeadingLevelConfig};
1276
1277 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1279 lines_above: HeadingLevelConfig::per_level_requirements([
1280 HeadingBlankRequirement::unlimited(),
1281 HeadingBlankRequirement::limited(1),
1282 HeadingBlankRequirement::limited(1),
1283 HeadingBlankRequirement::limited(1),
1284 HeadingBlankRequirement::limited(1),
1285 HeadingBlankRequirement::limited(1),
1286 ]),
1287 lines_below: HeadingLevelConfig::per_level_requirements([
1288 HeadingBlankRequirement::unlimited(),
1289 HeadingBlankRequirement::limited(1),
1290 HeadingBlankRequirement::limited(1),
1291 HeadingBlankRequirement::limited(1),
1292 HeadingBlankRequirement::limited(1),
1293 HeadingBlankRequirement::limited(1),
1294 ]),
1295 allowed_at_start: false,
1296 });
1297
1298 let content = "# H1\nParagraph\n## H2\nParagraph";
1299 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1300 let warnings = rule.check(&ctx).unwrap();
1301
1302 assert_eq!(warnings.len(), 2, "Only non-unlimited headings should warn");
1304 assert!(
1305 warnings.iter().all(|w| w.line >= 3),
1306 "Warnings should target later headings"
1307 );
1308
1309 let fixed = rule.fix(&ctx).unwrap();
1311 assert!(
1312 fixed.starts_with("# H1\nParagraph\n\n## H2"),
1313 "H1 should remain unchanged"
1314 );
1315 }
1316
1317 #[test]
1318 fn test_html_comment_transparency() {
1319 let rule = MD022BlanksAroundHeadings::default();
1323
1324 let content = "Some content\n\n<!-- markdownlint-disable-next-line MD001 -->\n#### Heading";
1327 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1328 let warnings = rule.check(&ctx).unwrap();
1329 assert!(
1330 warnings.is_empty(),
1331 "HTML comment is transparent - blank line above it counts for heading"
1332 );
1333
1334 let content_multiline = "Some content\n\n<!-- This is a\nmulti-line comment -->\n#### Heading";
1336 let ctx_multiline = LintContext::new(content_multiline, crate::config::MarkdownFlavor::Standard, None);
1337 let warnings_multiline = rule.check(&ctx_multiline).unwrap();
1338 assert!(
1339 warnings_multiline.is_empty(),
1340 "Multi-line HTML comment is also transparent"
1341 );
1342 }
1343
1344 #[test]
1345 fn test_frontmatter_transparency() {
1346 let rule = MD022BlanksAroundHeadings::default();
1349
1350 let content = "---\ntitle: Test\n---\n# First heading";
1352 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1353 let warnings = rule.check(&ctx).unwrap();
1354 assert!(
1355 warnings.is_empty(),
1356 "Frontmatter is transparent - heading can appear immediately after"
1357 );
1358
1359 let content_with_blank = "---\ntitle: Test\n---\n\n# First heading";
1361 let ctx_with_blank = LintContext::new(content_with_blank, crate::config::MarkdownFlavor::Standard, None);
1362 let warnings_with_blank = rule.check(&ctx_with_blank).unwrap();
1363 assert!(
1364 warnings_with_blank.is_empty(),
1365 "Heading with blank line after frontmatter should also be valid"
1366 );
1367
1368 let content_toml = "+++\ntitle = \"Test\"\n+++\n# First heading";
1370 let ctx_toml = LintContext::new(content_toml, crate::config::MarkdownFlavor::Standard, None);
1371 let warnings_toml = rule.check(&ctx_toml).unwrap();
1372 assert!(
1373 warnings_toml.is_empty(),
1374 "TOML frontmatter is also transparent for MD022"
1375 );
1376 }
1377
1378 #[test]
1379 fn test_horizontal_rule_not_treated_as_frontmatter() {
1380 let rule = MD022BlanksAroundHeadings::default();
1383
1384 let content = "Some content\n\n---\n# Heading after HR";
1386 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1387 let warnings = rule.check(&ctx).unwrap();
1388 assert!(
1389 !warnings.is_empty(),
1390 "Heading after horizontal rule without blank line SHOULD trigger MD022"
1391 );
1392 assert!(
1393 warnings.iter().any(|w| w.line == 4),
1394 "Warning should be on line 4 (the heading line)"
1395 );
1396
1397 let content_with_blank = "Some content\n\n---\n\n# Heading after HR";
1399 let ctx_with_blank = LintContext::new(content_with_blank, crate::config::MarkdownFlavor::Standard, None);
1400 let warnings_with_blank = rule.check(&ctx_with_blank).unwrap();
1401 assert!(
1402 warnings_with_blank.is_empty(),
1403 "Heading with blank line after HR should not trigger MD022"
1404 );
1405
1406 let content_hr_start = "---\n# Heading";
1408 let ctx_hr_start = LintContext::new(content_hr_start, crate::config::MarkdownFlavor::Standard, None);
1409 let warnings_hr_start = rule.check(&ctx_hr_start).unwrap();
1410 assert!(
1411 !warnings_hr_start.is_empty(),
1412 "Heading after HR at document start SHOULD trigger MD022"
1413 );
1414
1415 let content_multi_hr = "Content\n\n---\n\n---\n# Heading";
1417 let ctx_multi_hr = LintContext::new(content_multi_hr, crate::config::MarkdownFlavor::Standard, None);
1418 let warnings_multi_hr = rule.check(&ctx_multi_hr).unwrap();
1419 assert!(
1420 !warnings_multi_hr.is_empty(),
1421 "Heading after multiple HRs without blank line SHOULD trigger MD022"
1422 );
1423 }
1424
1425 #[test]
1426 fn test_all_hr_styles_require_blank_before_heading() {
1427 let rule = MD022BlanksAroundHeadings::default();
1429
1430 let hr_styles = [
1432 "---", "***", "___", "- - -", "* * *", "_ _ _", "----", "****", "____", "- - - -",
1433 "- - -", " ---", " ---", ];
1437
1438 for hr in hr_styles {
1439 let content = format!("Content\n\n{hr}\n# Heading");
1440 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1441 let warnings = rule.check(&ctx).unwrap();
1442 assert!(
1443 !warnings.is_empty(),
1444 "HR style '{hr}' followed by heading should trigger MD022"
1445 );
1446 }
1447 }
1448
1449 #[test]
1450 fn test_setext_heading_after_hr() {
1451 let rule = MD022BlanksAroundHeadings::default();
1453
1454 let content = "Content\n\n---\nHeading\n======";
1456 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1457 let warnings = rule.check(&ctx).unwrap();
1458 assert!(
1459 !warnings.is_empty(),
1460 "Setext heading after HR without blank should trigger MD022"
1461 );
1462
1463 let content_h2 = "Content\n\n---\nHeading\n------";
1465 let ctx_h2 = LintContext::new(content_h2, crate::config::MarkdownFlavor::Standard, None);
1466 let warnings_h2 = rule.check(&ctx_h2).unwrap();
1467 assert!(
1468 !warnings_h2.is_empty(),
1469 "Setext h2 after HR without blank should trigger MD022"
1470 );
1471
1472 let content_ok = "Content\n\n---\n\nHeading\n======";
1474 let ctx_ok = LintContext::new(content_ok, crate::config::MarkdownFlavor::Standard, None);
1475 let warnings_ok = rule.check(&ctx_ok).unwrap();
1476 assert!(
1477 warnings_ok.is_empty(),
1478 "Setext heading with blank after HR should not warn"
1479 );
1480 }
1481
1482 #[test]
1483 fn test_hr_in_code_block_not_treated_as_hr() {
1484 let rule = MD022BlanksAroundHeadings::default();
1486
1487 let content = "```\n---\n```\n# Heading";
1490 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1491 let warnings = rule.check(&ctx).unwrap();
1492 assert!(!warnings.is_empty(), "Heading after code block still needs blank line");
1495
1496 let content_ok = "```\n---\n```\n\n# Heading";
1498 let ctx_ok = LintContext::new(content_ok, crate::config::MarkdownFlavor::Standard, None);
1499 let warnings_ok = rule.check(&ctx_ok).unwrap();
1500 assert!(
1501 warnings_ok.is_empty(),
1502 "Heading with blank after code block should not warn"
1503 );
1504 }
1505
1506 #[test]
1507 fn test_hr_in_html_comment_not_treated_as_hr() {
1508 let rule = MD022BlanksAroundHeadings::default();
1510
1511 let content = "<!-- \n---\n -->\n# Heading";
1513 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1514 let warnings = rule.check(&ctx).unwrap();
1515 assert!(
1517 warnings.is_empty(),
1518 "HR inside HTML comment should be ignored - heading after comment is OK"
1519 );
1520 }
1521
1522 #[test]
1523 fn test_invalid_hr_not_triggering() {
1524 let rule = MD022BlanksAroundHeadings::default();
1526
1527 let invalid_hrs = [
1528 " ---", "\t---", "--", "**", "__", "-*-", "---a", "a---", ];
1537
1538 for invalid in invalid_hrs {
1539 let content = format!("Content\n\n{invalid}\n# Heading");
1542 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1543 let _ = rule.check(&ctx);
1546 }
1547 }
1548
1549 #[test]
1550 fn test_frontmatter_vs_horizontal_rule_distinction() {
1551 let rule = MD022BlanksAroundHeadings::default();
1553
1554 let content = "---\ntitle: Test\n---\n\nSome content\n\n---\n# Heading after HR";
1557 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1558 let warnings = rule.check(&ctx).unwrap();
1559 assert!(
1560 !warnings.is_empty(),
1561 "HR after frontmatter content should still require blank line before heading"
1562 );
1563
1564 let content_ok = "---\ntitle: Test\n---\n\nSome content\n\n---\n\n# Heading after HR";
1566 let ctx_ok = LintContext::new(content_ok, crate::config::MarkdownFlavor::Standard, None);
1567 let warnings_ok = rule.check(&ctx_ok).unwrap();
1568 assert!(
1569 warnings_ok.is_empty(),
1570 "HR with blank line before heading should not warn"
1571 );
1572 }
1573
1574 #[test]
1577 fn test_kramdown_ial_after_heading_no_warning() {
1578 let rule = MD022BlanksAroundHeadings::default();
1580 let content = "## Table of Contents\n{: .hhc-toc-heading}\n\nSome content here.";
1581 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1582 let warnings = rule.check(&ctx).unwrap();
1583
1584 assert!(
1585 warnings.is_empty(),
1586 "IAL after heading should not require blank line between them: {warnings:?}"
1587 );
1588 }
1589
1590 #[test]
1591 fn test_kramdown_ial_with_class() {
1592 let rule = MD022BlanksAroundHeadings::default();
1593 let content = "# Heading\n{:.highlight}\n\nContent.";
1594 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1595 let warnings = rule.check(&ctx).unwrap();
1596
1597 assert!(warnings.is_empty(), "IAL with class should be part of heading");
1598 }
1599
1600 #[test]
1601 fn test_kramdown_ial_with_id() {
1602 let rule = MD022BlanksAroundHeadings::default();
1603 let content = "# Heading\n{:#custom-id}\n\nContent.";
1604 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1605 let warnings = rule.check(&ctx).unwrap();
1606
1607 assert!(warnings.is_empty(), "IAL with id should be part of heading");
1608 }
1609
1610 #[test]
1611 fn test_kramdown_ial_with_multiple_attributes() {
1612 let rule = MD022BlanksAroundHeadings::default();
1613 let content = "# Heading\n{: .class #id style=\"color: red\"}\n\nContent.";
1614 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1615 let warnings = rule.check(&ctx).unwrap();
1616
1617 assert!(
1618 warnings.is_empty(),
1619 "IAL with multiple attributes should be part of heading"
1620 );
1621 }
1622
1623 #[test]
1624 fn test_kramdown_ial_missing_blank_after() {
1625 let rule = MD022BlanksAroundHeadings::default();
1627 let content = "# Heading\n{:.class}\nContent without blank.";
1628 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1629 let warnings = rule.check(&ctx).unwrap();
1630
1631 assert_eq!(
1632 warnings.len(),
1633 1,
1634 "Should warn about missing blank after IAL (part of heading)"
1635 );
1636 assert!(warnings[0].message.contains("below"));
1637 }
1638
1639 #[test]
1640 fn test_kramdown_ial_before_heading_transparent() {
1641 let rule = MD022BlanksAroundHeadings::default();
1643 let content = "Content.\n\n{:.preclass}\n## Heading\n\nMore content.";
1644 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1645 let warnings = rule.check(&ctx).unwrap();
1646
1647 assert!(
1648 warnings.is_empty(),
1649 "IAL before heading should be transparent for blank line count"
1650 );
1651 }
1652
1653 #[test]
1654 fn test_kramdown_ial_setext_heading() {
1655 let rule = MD022BlanksAroundHeadings::default();
1656 let content = "Heading\n=======\n{:.setext-class}\n\nContent.";
1657 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1658 let warnings = rule.check(&ctx).unwrap();
1659
1660 assert!(
1661 warnings.is_empty(),
1662 "IAL after Setext heading should be part of heading"
1663 );
1664 }
1665
1666 #[test]
1667 fn test_kramdown_ial_fix_preserves_ial() {
1668 let rule = MD022BlanksAroundHeadings::default();
1669 let content = "Content.\n# Heading\n{:.class}\nMore content.";
1670 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1671 let fixed = rule.fix(&ctx).unwrap();
1672
1673 assert!(
1675 fixed.contains("# Heading\n{:.class}"),
1676 "IAL should stay attached to heading"
1677 );
1678 assert!(fixed.contains("{:.class}\n\nMore"), "Should add blank after IAL");
1679 }
1680
1681 #[test]
1682 fn test_kramdown_ial_fix_does_not_separate() {
1683 let rule = MD022BlanksAroundHeadings::default();
1684 let content = "# Heading\n{:.class}\nContent.";
1685 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1686 let fixed = rule.fix(&ctx).unwrap();
1687
1688 assert!(
1690 !fixed.contains("# Heading\n\n{:.class}"),
1691 "Should not add blank between heading and IAL"
1692 );
1693 assert!(fixed.contains("# Heading\n{:.class}"), "IAL should remain attached");
1694 }
1695
1696 #[test]
1697 fn test_kramdown_multiple_ial_lines() {
1698 let rule = MD022BlanksAroundHeadings::default();
1700 let content = "# Heading\n{:.class1}\n{:#id}\n\nContent.";
1701 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1702 let warnings = rule.check(&ctx).unwrap();
1703
1704 assert!(
1707 warnings.is_empty(),
1708 "Multiple consecutive IALs should be part of heading"
1709 );
1710 }
1711
1712 #[test]
1713 fn test_kramdown_ial_with_blank_line_not_attached() {
1714 let rule = MD022BlanksAroundHeadings::default();
1716 let content = "# Heading\n\n{:.class}\nContent.";
1717 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1718 let warnings = rule.check(&ctx).unwrap();
1719
1720 assert!(warnings.is_empty(), "Blank line separates heading from IAL");
1724 }
1725
1726 #[test]
1727 fn test_not_kramdown_ial_regular_braces() {
1728 let rule = MD022BlanksAroundHeadings::default();
1730 let content = "# Heading\n{not an ial}\n\nContent.";
1731 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1732 let warnings = rule.check(&ctx).unwrap();
1733
1734 assert_eq!(
1736 warnings.len(),
1737 1,
1738 "Non-IAL braces should be regular content requiring blank"
1739 );
1740 }
1741
1742 #[test]
1743 fn test_kramdown_ial_at_document_end() {
1744 let rule = MD022BlanksAroundHeadings::default();
1745 let content = "# Heading\n{:.class}";
1746 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1747 let warnings = rule.check(&ctx).unwrap();
1748
1749 assert!(warnings.is_empty(), "IAL at document end needs no blank after");
1751 }
1752
1753 #[test]
1754 fn test_kramdown_ial_followed_by_code_fence() {
1755 let rule = MD022BlanksAroundHeadings::default();
1756 let content = "# Heading\n{:.class}\n```\ncode\n```";
1757 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1758 let warnings = rule.check(&ctx).unwrap();
1759
1760 assert!(warnings.is_empty(), "No blank needed between IAL and code fence");
1762 }
1763
1764 #[test]
1765 fn test_kramdown_ial_followed_by_list() {
1766 let rule = MD022BlanksAroundHeadings::default();
1767 let content = "# Heading\n{:.class}\n- List item";
1768 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1769 let warnings = rule.check(&ctx).unwrap();
1770
1771 assert!(warnings.is_empty(), "No blank needed between IAL and list");
1773 }
1774
1775 #[test]
1776 fn test_kramdown_ial_fix_idempotent() {
1777 let rule = MD022BlanksAroundHeadings::default();
1778 let content = "# Heading\n{:.class}\nContent.";
1779 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1780
1781 let fixed_once = rule.fix(&ctx).unwrap();
1782 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
1783 let fixed_twice = rule.fix(&ctx2).unwrap();
1784
1785 assert_eq!(fixed_once, fixed_twice, "Fix should be idempotent");
1786 }
1787
1788 #[test]
1789 fn test_kramdown_ial_whitespace_line_between_not_attached() {
1790 let rule = MD022BlanksAroundHeadings::default();
1793 let content = "# Heading\n \n{:.class}\n\nContent.";
1794 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1795 let warnings = rule.check(&ctx).unwrap();
1796
1797 assert!(
1801 warnings.is_empty(),
1802 "Whitespace between heading and IAL means IAL is not attached"
1803 );
1804 }
1805
1806 #[test]
1807 fn test_kramdown_ial_html_comment_between() {
1808 let rule = MD022BlanksAroundHeadings::default();
1811 let content = "# Heading\n<!-- comment -->\n{:.class}\n\nContent.";
1812 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1813 let warnings = rule.check(&ctx).unwrap();
1814
1815 assert!(
1818 warnings.is_empty(),
1819 "A comment-only line below the heading is its blank line: {warnings:?}"
1820 );
1821 }
1822
1823 #[test]
1824 fn test_kramdown_ial_text_beside_comment_between_is_still_reported() {
1825 let rule = MD022BlanksAroundHeadings::default();
1828 let content = "# Heading\ntext <!-- comment -->\n{:.class}\n\nContent.";
1829 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1830 let warnings = rule.check(&ctx).unwrap();
1831
1832 assert_eq!(warnings.len(), 1, "Heading followed by prose: {warnings:?}");
1833 }
1834
1835 #[test]
1836 fn test_kramdown_ial_generic_attribute() {
1837 let rule = MD022BlanksAroundHeadings::default();
1838 let content = "# Heading\n{:data-toc=\"true\" style=\"color: red\"}\n\nContent.";
1839 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1840 let warnings = rule.check(&ctx).unwrap();
1841
1842 assert!(warnings.is_empty(), "Generic attributes should be recognized as IAL");
1843 }
1844
1845 #[test]
1846 fn test_kramdown_ial_fix_multiple_lines_preserves_all() {
1847 let rule = MD022BlanksAroundHeadings::default();
1848 let content = "# Heading\n{:.class1}\n{:#id}\n{:data-x=\"y\"}\nContent.";
1849 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1850
1851 let fixed = rule.fix(&ctx).unwrap();
1852
1853 assert!(fixed.contains("{:.class1}"), "First IAL should be preserved");
1855 assert!(fixed.contains("{:#id}"), "Second IAL should be preserved");
1856 assert!(fixed.contains("{:data-x=\"y\"}"), "Third IAL should be preserved");
1857 assert!(
1859 fixed.contains("{:data-x=\"y\"}\n\nContent"),
1860 "Blank line should be after all IALs"
1861 );
1862 }
1863
1864 #[test]
1865 fn test_kramdown_ial_crlf_line_endings() {
1866 let rule = MD022BlanksAroundHeadings::default();
1867 let content = "# Heading\r\n{:.class}\r\n\r\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(), "CRLF should work correctly with IAL");
1872 }
1873
1874 #[test]
1875 fn test_kramdown_ial_invalid_patterns_not_recognized() {
1876 let rule = MD022BlanksAroundHeadings::default();
1877
1878 let content = "# Heading\n{ :.class}\n\nContent.";
1880 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1881 let warnings = rule.check(&ctx).unwrap();
1882 assert_eq!(warnings.len(), 1, "Invalid IAL syntax should trigger warning");
1883
1884 let content2 = "# Heading\n{.class}\n\nContent.";
1886 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1887 let warnings2 = rule.check(&ctx2).unwrap();
1888 assert!(warnings2.is_empty(), "{{.class}} is valid kramdown block attribute");
1890
1891 let content3 = "# Heading\n{just text}\n\nContent.";
1893 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
1894 let warnings3 = rule.check(&ctx3).unwrap();
1895 assert_eq!(
1896 warnings3.len(),
1897 1,
1898 "Text in braces is not IAL and should trigger warning"
1899 );
1900 }
1901
1902 #[test]
1903 fn test_kramdown_ial_toc_marker() {
1904 let rule = MD022BlanksAroundHeadings::default();
1906 let content = "# Heading\n{:toc}\n\nContent.";
1907 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1908 let warnings = rule.check(&ctx).unwrap();
1909
1910 assert!(warnings.is_empty(), "{{:toc}} should be recognized as IAL");
1912 }
1913
1914 #[test]
1915 fn test_kramdown_ial_mixed_headings_in_document() {
1916 let rule = MD022BlanksAroundHeadings::default();
1917 let content = r#"# ATX Heading
1918{:.atx-class}
1919
1920Content after ATX.
1921
1922Setext Heading
1923--------------
1924{:#setext-id}
1925
1926Content after Setext.
1927
1928## Another ATX
1929{:.another}
1930
1931More content."#;
1932 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1933 let warnings = rule.check(&ctx).unwrap();
1934
1935 assert!(
1936 warnings.is_empty(),
1937 "Mixed headings with IAL should all work: {warnings:?}"
1938 );
1939 }
1940
1941 #[test]
1942 fn test_kramdown_extension_block_before_first_heading_is_document_start() {
1943 let rule = MD022BlanksAroundHeadings::default();
1944 let content = "{::comment}\nhidden\n{:/comment}\n# Heading\n\nBody\n";
1945 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Kramdown, None);
1946 let warnings = rule.check(&ctx).unwrap();
1947
1948 assert!(
1949 warnings.is_empty(),
1950 "Kramdown extension preamble should not require blank above first heading: {warnings:?}"
1951 );
1952 }
1953
1954 #[test]
1955 fn test_kramdown_ial_before_first_heading_is_document_start() {
1956 let rule = MD022BlanksAroundHeadings::default();
1957 let content = "{:.doc-class}\n# Heading\n\nBody\n";
1958 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Kramdown, None);
1959 let warnings = rule.check(&ctx).unwrap();
1960
1961 assert!(
1962 warnings.is_empty(),
1963 "Kramdown IAL preamble should not require blank above first heading: {warnings:?}"
1964 );
1965 }
1966
1967 #[test]
1970 fn test_quarto_div_marker_transparent_above_heading() {
1971 let rule = MD022BlanksAroundHeadings::default();
1974 let content = "Content\n\n::: {.callout-note}\n# Heading\n\nMore content\n:::\n";
1976 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1977 let warnings = rule.check(&ctx).unwrap();
1978 assert!(
1980 warnings.is_empty(),
1981 "Quarto div marker should be transparent above heading: {warnings:?}"
1982 );
1983 }
1984
1985 #[test]
1986 fn test_quarto_div_marker_transparent_below_heading() {
1987 let rule = MD022BlanksAroundHeadings::default();
1989 let content = "# Heading\n\n::: {.callout-note}\nContent\n:::\n";
1990 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1991 let warnings = rule.check(&ctx).unwrap();
1992 assert!(
1994 warnings.is_empty(),
1995 "Quarto div marker should be transparent below heading: {warnings:?}"
1996 );
1997 }
1998
1999 #[test]
2000 fn test_quarto_heading_inside_callout() {
2001 let rule = MD022BlanksAroundHeadings::default();
2003 let content = "::: {.callout-note}\n\n## Note Title\n\nNote content\n:::\n";
2004 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
2005 let warnings = rule.check(&ctx).unwrap();
2006 assert!(
2007 warnings.is_empty(),
2008 "Heading inside Quarto callout should have no warnings: {warnings:?}"
2009 );
2010 }
2011
2012 #[test]
2013 fn test_quarto_heading_at_start_after_div_open() {
2014 let rule = MD022BlanksAroundHeadings::default();
2017 let content = "::: {.callout-warning}\n# Warning\n\nContent\n:::\n";
2019 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
2020 let warnings = rule.check(&ctx).unwrap();
2021 assert!(
2027 warnings.is_empty(),
2028 "Heading at start after div open should pass: {warnings:?}"
2029 );
2030 }
2031
2032 #[test]
2033 fn test_quarto_heading_before_div_close() {
2034 let rule = MD022BlanksAroundHeadings::default();
2036 let content = "::: {.callout-note}\nIntro\n\n## Section\n:::\n";
2037 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
2038 let warnings = rule.check(&ctx).unwrap();
2039 assert!(
2043 warnings.is_empty(),
2044 "Heading before div close should pass: {warnings:?}"
2045 );
2046 }
2047
2048 #[test]
2049 fn test_quarto_div_markers_not_transparent_in_standard_flavor() {
2050 let rule = MD022BlanksAroundHeadings::default();
2052 let content = "Content\n\n:::\n# Heading\n\n:::\n";
2053 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2054 let warnings = rule.check(&ctx).unwrap();
2055 assert!(
2057 !warnings.is_empty(),
2058 "Standard flavor should not treat ::: as transparent: {warnings:?}"
2059 );
2060 }
2061
2062 #[test]
2063 fn test_quarto_nested_divs_with_heading() {
2064 let rule = MD022BlanksAroundHeadings::default();
2066 let content = "::: {.outer}\n::: {.inner}\n\n# Heading\n\nContent\n:::\n:::\n";
2067 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
2068 let warnings = rule.check(&ctx).unwrap();
2069 assert!(
2070 warnings.is_empty(),
2071 "Nested divs with heading should work: {warnings:?}"
2072 );
2073 }
2074
2075 #[test]
2076 fn test_quarto_fix_preserves_div_markers() {
2077 let rule = MD022BlanksAroundHeadings::default();
2079 let content = "::: {.callout-note}\n\n## Note\n\nContent\n:::\n";
2080 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
2081 let fixed = rule.fix(&ctx).unwrap();
2082 assert!(fixed.contains("::: {.callout-note}"), "Should preserve div opening");
2084 assert!(fixed.contains(":::"), "Should preserve div closing");
2085 assert!(fixed.contains("## Note"), "Should preserve heading");
2086 }
2087
2088 #[test]
2089 fn test_quarto_heading_needs_blank_without_div_transparency() {
2090 let rule = MD022BlanksAroundHeadings::default();
2093 let content = "Content\n::: {.callout-note}\n# Heading\n\nMore\n:::\n";
2095 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
2096 let warnings = rule.check(&ctx).unwrap();
2097 assert!(
2100 !warnings.is_empty(),
2101 "Should still require blank line when not present: {warnings:?}"
2102 );
2103 }
2104
2105 #[test]
2106 fn test_pandoc_div_marker_transparent_above_heading() {
2107 let rule = MD022BlanksAroundHeadings::default();
2110 let content = "Content\n\n::: {.callout-note}\n# Heading\n\nMore content\n:::\n";
2111 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
2112 let warnings = rule.check(&ctx).unwrap();
2113 assert!(
2114 warnings.is_empty(),
2115 "MD022 should treat Pandoc div marker as transparent above heading: {warnings:?}"
2116 );
2117 }
2118
2119 #[test]
2120 fn test_hugo_block_attribute_after_heading_not_flagged() {
2121 let rule = MD022BlanksAroundHeadings::default();
2124 let content = "Intro text.\n\n# Heading\n{class=\"anchor\"}\n\nContent.\n";
2125
2126 for flavor in [
2127 crate::config::MarkdownFlavor::Hugo,
2128 crate::config::MarkdownFlavor::MkDocs,
2129 crate::config::MarkdownFlavor::Kramdown,
2130 ] {
2131 let ctx = LintContext::new(content, flavor, None);
2132 let warnings = rule.check(&ctx).unwrap();
2133 assert!(
2134 warnings.is_empty(),
2135 "MD022 should not flag the block attribute line under {flavor:?}: {warnings:?}"
2136 );
2137 }
2138
2139 let ctx_std = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2142 let warnings_std = rule.check(&ctx_std).unwrap();
2143 assert!(
2144 warnings_std.iter().any(|w| w.message.contains("below heading")),
2145 "MD022 must flag the missing blank below the heading under Standard: {warnings_std:?}"
2146 );
2147 }
2148
2149 #[test]
2150 fn test_mdg_keeps_tags_attached_only_to_structure_headings() {
2151 let rule = MD022BlanksAroundHeadings::default();
2152
2153 let attached = "`@browser`\n`@checkout` `@smoke`\n# Feature: Checkout\n";
2155 let mdg_ctx = LintContext::new(attached, crate::config::MarkdownFlavor::MDG, None);
2156 assert!(rule.check(&mdg_ctx).unwrap().is_empty());
2157 let fixed = rule.fix(&mdg_ctx).unwrap();
2158 assert_eq!(fixed, attached);
2159 let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
2160 assert_eq!(rule.fix(&fixed_ctx).unwrap(), fixed, "MDG fix should be idempotent");
2161
2162 let standard_ctx = LintContext::new(attached, crate::config::MarkdownFlavor::Standard, None);
2163 assert!(
2164 rule.check(&standard_ctx)
2165 .unwrap()
2166 .iter()
2167 .any(|warning| warning.message.contains("above heading"))
2168 );
2169 }
2170
2171 #[test]
2172 fn test_mdg_requires_blank_line_above_a_non_structure_heading() {
2173 let rule = MD022BlanksAroundHeadings::default();
2176 let content = "`@browser`\n# Notes\n";
2177 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
2178
2179 assert!(
2180 rule.check(&ctx)
2181 .unwrap()
2182 .iter()
2183 .any(|warning| warning.message.contains("above heading")),
2184 "a non-Gherkin heading keeps the normal requirement"
2185 );
2186 assert_eq!(rule.fix(&ctx).unwrap(), "`@browser`\n\n# Notes\n");
2187 }
2188
2189 #[test]
2190 fn test_mdg_colon_inside_a_code_span_names_no_structure() {
2191 let rule = MD022BlanksAroundHeadings::default();
2195 let content = "`@browser`\n# See `x: y` Notes\n";
2196 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
2197
2198 assert!(
2199 rule.check(&ctx)
2200 .unwrap()
2201 .iter()
2202 .any(|warning| warning.message.contains("above heading")),
2203 "the code span holds the only colon, so the heading is ordinary prose"
2204 );
2205 assert_eq!(rule.fix(&ctx).unwrap(), "`@browser`\n\n# See `x: y` Notes\n");
2206
2207 let structure = "`@browser`\n# Scenario: use `a: b` here\n";
2209 let structure_ctx = LintContext::new(structure, crate::config::MarkdownFlavor::MDG, None);
2210 assert!(rule.check(&structure_ctx).unwrap().is_empty());
2211 assert_eq!(rule.fix(&structure_ctx).unwrap(), structure);
2212 }
2213
2214 #[test]
2215 fn test_mdg_tag_line_matches_gherkin_reference_scan() {
2216 let rule = MD022BlanksAroundHeadings::default();
2219
2220 for above in [
2221 "`@comment_tag1` #a comment",
2222 "`@comment_tag#2` #a comment",
2223 "`@browser` and prose",
2224 "prose `@browser`",
2225 "`@a b`",
2226 ] {
2227 let content = format!("{above}\n# Feature: Checkout\n");
2228 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::MDG, None);
2229 assert!(rule.check(&ctx).unwrap().is_empty(), "{above:?} is a Gherkin tag line");
2230 assert_eq!(rule.fix(&ctx).unwrap(), content);
2231 }
2232
2233 let prose = "plain prose\n# Feature: Checkout\n";
2234 let ctx = LintContext::new(prose, crate::config::MarkdownFlavor::MDG, None);
2235 assert!(
2236 rule.check(&ctx)
2237 .unwrap()
2238 .iter()
2239 .any(|warning| warning.message.contains("above heading"))
2240 );
2241 }
2242
2243 #[test]
2244 fn test_fix_keeps_a_setext_heading_suppressed_on_a_later_line_as_written() {
2245 let rule = MD022BlanksAroundHeadings::default();
2250 let content = "Intro paragraph.\n# Heading one\nText after.\nTitle\nsecond <!-- rumdl-disable-line MD022 -->\n===\nMore text.\n";
2251 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2252
2253 assert_eq!(
2254 rule.fix(&ctx).unwrap(),
2255 "Intro paragraph.\n\n# Heading one\n\nText after.\nTitle\nsecond <!-- rumdl-disable-line MD022 -->\n===\nMore text.\n"
2256 );
2257 }
2258}