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 {
372 0
373 } else {
374 requirement_below.required_count().unwrap_or(0)
375 };
376 if blank_lines_below < needed_blanks_below {
377 for _ in 0..(needed_blanks_below - blank_lines_below) {
378 result.push(String::new());
379 }
380 }
381
382 skip_count += ial_count;
384 } else {
385 result.push(line.to_string());
387 }
388 }
389
390 let joined = result.join(line_ending);
391
392 if had_trailing_newline && !joined.ends_with('\n') {
394 format!("{joined}{line_ending}")
395 } else if !had_trailing_newline && joined.ends_with('\n') {
396 joined[..joined.len() - 1].to_string()
398 } else {
399 joined
400 }
401 }
402}
403
404impl Rule for MD022BlanksAroundHeadings {
405 fn name(&self) -> &'static str {
406 "MD022"
407 }
408
409 fn description(&self) -> &'static str {
410 "Headings should be surrounded by blank lines"
411 }
412
413 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
414 let mut result = Vec::new();
415
416 if ctx.lines.is_empty() {
418 return Ok(result);
419 }
420
421 let line_ending = "\n";
424 let is_pandoc = ctx.flavor.is_pandoc_compatible();
425 let is_mdg = ctx.flavor == crate::config::MarkdownFlavor::MDG;
426
427 let heading_at_start_idx = heading_at_start_idx(ctx, is_pandoc);
428
429 let mut heading_violations = Vec::new();
431 let mut processed_headings = std::collections::HashSet::new();
432
433 for (line_num, line_info) in ctx.lines.iter().enumerate() {
434 if processed_headings.contains(&line_num) || line_info.heading.is_none() {
436 continue;
437 }
438
439 if line_info.in_pymdown_block {
441 continue;
442 }
443
444 let heading = line_info.heading.as_ref().unwrap();
445
446 let heading_level = heading.level as usize;
447
448 processed_headings.insert(line_num);
452
453 let first_idx = first_text_idx(line_num, heading);
457
458 let is_first_heading = Some(first_idx) == heading_at_start_idx;
460
461 let required_above_count = self.config.lines_above.get_for_level(heading_level).required_count();
463 let required_below_count = self.config.lines_below.get_for_level(heading_level).required_count();
464
465 let should_check_above = required_above_count.is_some()
467 && first_idx > 0
468 && (!is_first_heading || !self.config.allowed_at_start)
469 && !(is_mdg && follows_mdg_tag_line(ctx, first_idx, heading));
470 if should_check_above {
471 let mut blank_lines_above = 0;
472 let mut hit_frontmatter_end = false;
473 for j in (0..first_idx).rev() {
474 let line_content = ctx.lines[j].content(ctx.content);
475 let trimmed = line_content.trim();
476 if ctx.lines[j].is_blank || is_blank_or_comment_only(line_content) {
477 blank_lines_above += 1;
480 } else if ctx.lines[j].in_html_comment || ctx.lines[j].in_mdx_comment {
481 continue;
483 } else if is_block_attribute_line(trimmed, ctx.flavor) {
484 continue;
486 } else if is_pandoc && (pandoc::is_div_open(trimmed) || pandoc::is_div_close(trimmed)) {
487 continue;
489 } else if ctx.lines[j].in_front_matter {
490 hit_frontmatter_end = true;
495 break;
496 } else {
497 break;
498 }
499 }
500 let required = required_above_count.unwrap();
501 if !hit_frontmatter_end && blank_lines_above < required {
502 let needed_blanks = required - blank_lines_above;
503 heading_violations.push((line_num, first_idx, "above", needed_blanks, heading_level));
504 }
505 }
506
507 let mut effective_last_line = if matches!(
509 heading.style,
510 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
511 ) {
512 line_num + 1 } else {
514 line_num
515 };
516
517 while effective_last_line + 1 < ctx.lines.len() {
520 let next_line = &ctx.lines[effective_last_line + 1];
521 let next_trimmed = next_line.content(ctx.content).trim();
522 if is_block_attribute_line(next_trimmed, ctx.flavor) {
523 effective_last_line += 1;
524 } else {
525 break;
526 }
527 }
528
529 if effective_last_line < ctx.lines.len() - 1 {
531 let mut next_non_blank_idx = effective_last_line + 1;
533 while next_non_blank_idx < ctx.lines.len() {
534 let check_line = &ctx.lines[next_non_blank_idx];
535 let check_trimmed = check_line.content(ctx.content).trim();
536 if check_line.is_blank {
537 next_non_blank_idx += 1;
538 } else if check_line.in_html_comment
539 || check_line.in_mdx_comment
540 || is_blank_or_comment_only(check_line.content(ctx.content))
541 {
542 next_non_blank_idx += 1;
544 } else if is_pandoc && (pandoc::is_div_open(check_trimmed) || pandoc::is_div_close(check_trimmed)) {
545 next_non_blank_idx += 1;
547 } else {
548 break;
549 }
550 }
551
552 if next_non_blank_idx >= ctx.lines.len() {
554 continue;
556 }
557
558 let next_line_is_special = {
560 let next_line = &ctx.lines[next_non_blank_idx];
561 let next_trimmed = next_line.content(ctx.content).trim();
562
563 let is_code_fence = (next_trimmed.starts_with("```") || next_trimmed.starts_with("~~~"))
565 && (next_trimmed.len() == 3
566 || (next_trimmed.len() > 3
567 && next_trimmed
568 .chars()
569 .nth(3)
570 .is_some_and(|c| c.is_whitespace() || c.is_alphabetic())));
571
572 let is_list_item = next_line.list_item.is_some() || starts_with_list_marker(next_trimmed);
579
580 is_code_fence || is_list_item
581 };
582
583 if !next_line_is_special && let Some(required) = required_below_count {
585 let mut blank_lines_below = 0;
587 for k in (effective_last_line + 1)..next_non_blank_idx {
588 if ctx.lines[k].is_blank || is_blank_or_comment_only(ctx.lines[k].content(ctx.content)) {
590 blank_lines_below += 1;
591 }
592 }
593
594 if blank_lines_below < required {
595 let needed_blanks = required - blank_lines_below;
596 heading_violations.push((line_num, first_idx, "below", needed_blanks, heading_level));
597 }
598 }
599 }
600 }
601
602 for (heading_line, first_line, position, needed_blanks, heading_level) in heading_violations {
604 let line_info = &ctx.lines[heading_line];
605
606 let (start_line, start_col, end_line, end_col) =
608 calculate_heading_range(first_line + 1, heading_line + 1, line_info.content(ctx.content));
609
610 let (message, insertion_point) = match position {
617 "above" => {
618 let Some(required_above_count) =
619 self.config.lines_above.get_for_level(heading_level).required_count()
620 else {
621 continue;
622 };
623 (
624 format!(
625 "Expected {} blank {} above heading",
626 required_above_count,
627 if required_above_count == 1 { "line" } else { "lines" }
628 ),
629 first_line, )
631 }
632 "below" => {
633 let Some(required_below_count) =
634 self.config.lines_below.get_for_level(heading_level).required_count()
635 else {
636 continue;
637 };
638 let insert_after = if line_info.heading.as_ref().is_some_and(|h| {
640 matches!(
641 h.style,
642 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
643 )
644 }) {
645 heading_line + 2
646 } else {
647 heading_line + 1
648 };
649
650 (
651 format!(
652 "Expected {} blank {} below heading",
653 required_below_count,
654 if required_below_count == 1 { "line" } else { "lines" }
655 ),
656 insert_after,
657 )
658 }
659 _ => continue,
660 };
661
662 let byte_range = if insertion_point == 0 && position == "above" {
664 0..0
666 } else if position == "above" && insertion_point > 0 {
667 ctx.lines[insertion_point].byte_offset..ctx.lines[insertion_point].byte_offset
669 } else if position == "below" && insertion_point - 1 < ctx.lines.len() {
670 let line_idx = insertion_point - 1;
672 let line_end_offset = if line_idx + 1 < ctx.lines.len() {
673 ctx.lines[line_idx + 1].byte_offset
674 } else {
675 ctx.content.len()
676 };
677 line_end_offset..line_end_offset
678 } else {
679 let content_len = ctx.content.len();
681 content_len..content_len
682 };
683
684 result.push(LintWarning {
685 rule_name: Some(self.name().to_string()),
686 message,
687 line: start_line,
688 column: start_col,
689 end_line,
690 end_column: end_col,
691 severity: Severity::Warning,
692 fix: Some(Fix::new(byte_range, line_ending.repeat(needed_blanks))),
693 });
694 }
695
696 Ok(result)
697 }
698
699 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
700 if ctx.content.is_empty() {
701 return Ok(ctx.content.to_string());
702 }
703
704 let fixed = self.fix_content(ctx);
706
707 Ok(fixed)
708 }
709
710 fn category(&self) -> RuleCategory {
712 RuleCategory::Heading
713 }
714
715 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
717 if ctx.content.is_empty() || !ctx.likely_has_headings() {
719 return true;
720 }
721 ctx.lines.iter().all(|line| line.heading.is_none())
723 }
724
725 fn as_any(&self) -> &dyn std::any::Any {
726 self
727 }
728
729 crate::impl_rule_config_methods!(MD022Config);
730
731 fn polymorphic_config_keys(&self) -> &'static [&'static str] {
732 &["lines-above", "lines-below"]
737 }
738}
739
740#[cfg(test)]
741mod tests {
742 use super::*;
743 use crate::lint_context::LintContext;
744
745 #[test]
746 fn test_valid_headings() {
747 let rule = MD022BlanksAroundHeadings::default();
748 let content = "\n# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.\n";
749 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
750 let result = rule.check(&ctx).unwrap();
751 assert!(result.is_empty());
752 }
753
754 #[test]
755 fn test_missing_blank_above() {
756 let rule = MD022BlanksAroundHeadings::default();
757 let content = "# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.\n";
758 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
759 let result = rule.check(&ctx).unwrap();
760 assert_eq!(result.len(), 0); let fixed = rule.fix(&ctx).unwrap();
763
764 assert!(fixed.contains("# Heading 1"));
767 assert!(fixed.contains("Some content."));
768 assert!(fixed.contains("## Heading 2"));
769 assert!(fixed.contains("More content."));
770 }
771
772 #[test]
773 fn test_missing_blank_below() {
774 let rule = MD022BlanksAroundHeadings::default();
775 let content = "\n# Heading 1\nSome content.\n\n## Heading 2\n\nMore content.\n";
776 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
777 let result = rule.check(&ctx).unwrap();
778 assert_eq!(result.len(), 1);
779 assert_eq!(result[0].line, 2);
780
781 let fixed = rule.fix(&ctx).unwrap();
783 assert!(fixed.contains("# Heading 1\n\nSome content"));
784 }
785
786 #[test]
787 fn test_missing_blank_above_and_below() {
788 let rule = MD022BlanksAroundHeadings::default();
789 let content = "# Heading 1\nSome content.\n## Heading 2\nMore content.\n";
790 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
791 let result = rule.check(&ctx).unwrap();
792 assert_eq!(result.len(), 3); let fixed = rule.fix(&ctx).unwrap();
796 assert!(fixed.contains("# Heading 1\n\nSome content"));
797 assert!(fixed.contains("Some content.\n\n## Heading 2"));
798 assert!(fixed.contains("## Heading 2\n\nMore content"));
799 }
800
801 #[test]
802 fn test_fix_headings() {
803 let rule = MD022BlanksAroundHeadings::default();
804 let content = "# Heading 1\nSome content.\n## Heading 2\nMore content.";
805 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
806 let result = rule.fix(&ctx).unwrap();
807
808 let expected = "# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.";
809 assert_eq!(result, expected);
810 }
811
812 #[test]
813 fn test_consecutive_headings_pattern() {
814 let rule = MD022BlanksAroundHeadings::default();
815 let content = "# Heading 1\n## Heading 2\n### Heading 3";
816 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
817 let result = rule.fix(&ctx).unwrap();
818
819 let lines: Vec<&str> = result.lines().collect();
821 assert!(!lines.is_empty());
822
823 let h1_pos = lines.iter().position(|&l| l == "# Heading 1").unwrap();
825 let h2_pos = lines.iter().position(|&l| l == "## Heading 2").unwrap();
826 let h3_pos = lines.iter().position(|&l| l == "### Heading 3").unwrap();
827
828 assert!(
830 h2_pos > h1_pos + 1,
831 "Should have at least one blank line after first heading"
832 );
833 assert!(
834 h3_pos > h2_pos + 1,
835 "Should have at least one blank line after second heading"
836 );
837
838 assert!(lines[h1_pos + 1].trim().is_empty(), "Line after h1 should be blank");
840
841 assert!(lines[h2_pos + 1].trim().is_empty(), "Line after h2 should be blank");
843 }
844
845 #[test]
846 fn test_blanks_around_setext_headings() {
847 let rule = MD022BlanksAroundHeadings::default();
848 let content = "Heading 1\n=========\nSome content.\nHeading 2\n---------\nMore content.";
849 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
850 let result = rule.fix(&ctx).unwrap();
851
852 let lines: Vec<&str> = result.lines().collect();
854
855 assert!(result.contains("Heading 1"));
857 assert!(result.contains("========="));
858 assert!(result.contains("Some content."));
859 assert!(result.contains("Heading 2"));
860 assert!(result.contains("---------"));
861 assert!(result.contains("More content."));
862
863 let heading1_marker_idx = lines.iter().position(|&l| l == "=========").unwrap();
865 let some_content_idx = lines.iter().position(|&l| l == "Some content.").unwrap();
866 assert!(
867 some_content_idx > heading1_marker_idx + 1,
868 "Should have a blank line after the first heading"
869 );
870
871 let heading2_marker_idx = lines.iter().position(|&l| l == "---------").unwrap();
872 let more_content_idx = lines.iter().position(|&l| l == "More content.").unwrap();
873 assert!(
874 more_content_idx > heading2_marker_idx + 1,
875 "Should have a blank line after the second heading"
876 );
877
878 let fixed_ctx = LintContext::new(&result, crate::config::MarkdownFlavor::Standard, None);
880 let fixed_warnings = rule.check(&fixed_ctx).unwrap();
881 assert!(fixed_warnings.is_empty(), "Fixed content should have no warnings");
882 }
883
884 #[test]
885 fn test_fix_specific_blank_line_cases() {
886 let rule = MD022BlanksAroundHeadings::default();
887
888 let content1 = "# Heading 1\n## Heading 2\n### Heading 3";
890 let ctx1 = LintContext::new(content1, crate::config::MarkdownFlavor::Standard, None);
891 let result1 = rule.fix(&ctx1).unwrap();
892 assert!(result1.contains("# Heading 1"));
894 assert!(result1.contains("## Heading 2"));
895 assert!(result1.contains("### Heading 3"));
896 let lines: Vec<&str> = result1.lines().collect();
898 let h1_pos = lines.iter().position(|&l| l == "# Heading 1").unwrap();
899 let h2_pos = lines.iter().position(|&l| l == "## Heading 2").unwrap();
900 assert!(lines[h1_pos + 1].trim().is_empty(), "Should have a blank line after h1");
901 assert!(lines[h2_pos + 1].trim().is_empty(), "Should have a blank line after h2");
902
903 let content2 = "# Heading 1\nContent under heading 1\n## Heading 2";
905 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
906 let result2 = rule.fix(&ctx2).unwrap();
907 assert!(result2.contains("# Heading 1"));
909 assert!(result2.contains("Content under heading 1"));
910 assert!(result2.contains("## Heading 2"));
911 let lines2: Vec<&str> = result2.lines().collect();
913 let h1_pos2 = lines2.iter().position(|&l| l == "# Heading 1").unwrap();
914 let _content_pos = lines2.iter().position(|&l| l == "Content under heading 1").unwrap();
915 assert!(
916 lines2[h1_pos2 + 1].trim().is_empty(),
917 "Should have a blank line after heading 1"
918 );
919
920 let content3 = "# Heading 1\n\n\n## Heading 2\n\n\n### Heading 3\n\nContent";
922 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
923 let result3 = rule.fix(&ctx3).unwrap();
924 assert!(result3.contains("# Heading 1"));
926 assert!(result3.contains("## Heading 2"));
927 assert!(result3.contains("### Heading 3"));
928 assert!(result3.contains("Content"));
929 }
930
931 #[test]
932 fn test_fix_preserves_existing_blank_lines() {
933 let rule = MD022BlanksAroundHeadings::new();
934 let content = "# Title
935
936## Section 1
937
938Content here.
939
940## Section 2
941
942More content.
943### Missing Blank Above
944
945Even more content.
946
947## Section 3
948
949Final content.";
950
951 let expected = "# Title
952
953## Section 1
954
955Content here.
956
957## Section 2
958
959More content.
960
961### Missing Blank Above
962
963Even more content.
964
965## Section 3
966
967Final content.";
968
969 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
970 let result = rule.fix_content(&ctx);
971 assert_eq!(
972 result, expected,
973 "Fix should only add missing blank lines, never remove existing ones"
974 );
975 }
976
977 #[test]
978 fn test_fix_preserves_trailing_newline() {
979 let rule = MD022BlanksAroundHeadings::new();
980
981 let content_with_newline = "# Title\nContent here.\n";
983 let ctx = LintContext::new(content_with_newline, crate::config::MarkdownFlavor::Standard, None);
984 let result = rule.fix(&ctx).unwrap();
985 assert!(result.ends_with('\n'), "Should preserve trailing newline");
986
987 let content_without_newline = "# Title\nContent here.";
989 let ctx = LintContext::new(content_without_newline, crate::config::MarkdownFlavor::Standard, None);
990 let result = rule.fix(&ctx).unwrap();
991 assert!(
992 !result.ends_with('\n'),
993 "Should not add trailing newline if original didn't have one"
994 );
995 }
996
997 #[test]
998 fn test_fix_does_not_add_blank_lines_before_lists() {
999 let rule = MD022BlanksAroundHeadings::new();
1000 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.";
1001
1002 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.";
1003
1004 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1005 let result = rule.fix_content(&ctx);
1006 assert_eq!(result, expected, "Fix should not add blank lines before lists");
1007 }
1008
1009 #[test]
1010 fn test_fix_idempotent_when_heading_follows_a_list_and_precedes_a_marker() {
1011 let rule = MD022BlanksAroundHeadings::default();
1017 let content = "- a\n# H\n2. ";
1018 for flavor in [
1019 crate::config::MarkdownFlavor::Standard,
1020 crate::config::MarkdownFlavor::MkDocs,
1021 crate::config::MarkdownFlavor::MDX,
1022 ] {
1023 let ctx1 = LintContext::new(content, flavor, None);
1024 let fixed1 = rule.fix(&ctx1).unwrap();
1025 let ctx2 = LintContext::new(&fixed1, flavor, None);
1026 let fixed2 = rule.fix(&ctx2).unwrap();
1027 assert_eq!(fixed1, fixed2, "MD022 fix must be idempotent (flavor={flavor:?})");
1028 }
1029 }
1030
1031 #[test]
1032 fn test_thematic_break_below_heading_is_not_a_list_item() {
1033 let rule = MD022BlanksAroundHeadings::default();
1040 for marker in [
1041 "* * *",
1042 "- - -",
1043 "_ _ _",
1044 "***",
1045 "---",
1046 "___",
1047 "- --",
1048 "* ** *",
1049 "---- ----",
1050 ] {
1051 let content = format!("text\n\n# Heading\n{marker}\nafter\n");
1052 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1053 let result = rule.check(&ctx).unwrap();
1054 assert_eq!(
1055 result.len(),
1056 1,
1057 "a heading above `{marker}` needs a blank line below it, got {result:?}"
1058 );
1059 assert_eq!(
1060 rule.fix(&ctx).unwrap(),
1061 format!("text\n\n# Heading\n\n{marker}\nafter\n"),
1062 "fix must insert the blank line below the heading for `{marker}`"
1063 );
1064 }
1065 }
1066
1067 #[test]
1068 fn test_list_item_below_heading_is_still_exempt() {
1069 let rule = MD022BlanksAroundHeadings::default();
1072 for item in ["- item", "* item", "+ item", "1. item", "+ + +"] {
1073 let content = format!("text\n\n# Heading\n{item}\n");
1074 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1075 assert!(
1076 rule.check(&ctx).unwrap().is_empty(),
1077 "a list below a heading stays exempt, but `{item}` was reported"
1078 );
1079 assert_eq!(rule.fix(&ctx).unwrap(), content, "`{item}` must not be rewritten");
1080 }
1081 }
1082
1083 #[test]
1084 fn test_per_level_configuration_no_blank_above_h1() {
1085 use md022_config::HeadingLevelConfig;
1086
1087 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1089 lines_above: HeadingLevelConfig::per_level([0, 1, 1, 1, 1, 1]),
1090 lines_below: HeadingLevelConfig::scalar(1),
1091 allowed_at_start: false, });
1093
1094 let content = "Some text\n# Heading 1\n\nMore text";
1096 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1097 let warnings = rule.check(&ctx).unwrap();
1098 assert_eq!(warnings.len(), 0, "H1 without blank above should not trigger warning");
1099
1100 let content = "Some text\n## Heading 2\n\nMore text";
1102 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1103 let warnings = rule.check(&ctx).unwrap();
1104 assert_eq!(warnings.len(), 1, "H2 without blank above should trigger warning");
1105 assert!(warnings[0].message.contains("above"));
1106 }
1107
1108 #[test]
1109 fn test_unlimited_above_with_limited_below_does_not_panic() {
1110 use md022_config::{HeadingBlankRequirement, HeadingLevelConfig};
1111
1112 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1116 lines_above: HeadingLevelConfig::scalar_requirement(HeadingBlankRequirement::Unlimited),
1117 lines_below: HeadingLevelConfig::scalar(1),
1118 allowed_at_start: false,
1119 });
1120
1121 let content = "# Title\n\nText\n## Banana\nText\n";
1123 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1124
1125 let warnings = rule.check(&ctx).expect("check must not fail");
1126
1127 assert!(
1128 warnings.iter().any(|w| w.message.contains("below")),
1129 "expected a 'below' violation, got: {:?}",
1130 warnings.iter().map(|w| &w.message).collect::<Vec<_>>()
1131 );
1132 assert!(
1133 !warnings.iter().any(|w| w.message.contains("above")),
1134 "an unlimited 'above' requirement must never report: {:?}",
1135 warnings.iter().map(|w| &w.message).collect::<Vec<_>>()
1136 );
1137 }
1138
1139 #[test]
1140 fn test_unlimited_below_with_limited_above_does_not_panic() {
1141 use md022_config::{HeadingBlankRequirement, HeadingLevelConfig};
1142
1143 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1144 lines_above: HeadingLevelConfig::scalar(1),
1145 lines_below: HeadingLevelConfig::scalar_requirement(HeadingBlankRequirement::Unlimited),
1146 allowed_at_start: false,
1147 });
1148
1149 let content = "# Title\n\nText\n## Banana\n\nText\n";
1151 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1152
1153 let warnings = rule.check(&ctx).expect("check must not fail");
1154
1155 assert!(
1156 warnings.iter().any(|w| w.message.contains("above")),
1157 "expected an 'above' violation, got: {:?}",
1158 warnings.iter().map(|w| &w.message).collect::<Vec<_>>()
1159 );
1160 assert!(
1161 !warnings.iter().any(|w| w.message.contains("below")),
1162 "an unlimited 'below' requirement must never report: {:?}",
1163 warnings.iter().map(|w| &w.message).collect::<Vec<_>>()
1164 );
1165 }
1166
1167 #[test]
1168 fn test_per_level_configuration_different_requirements() {
1169 use md022_config::HeadingLevelConfig;
1170
1171 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1173 lines_above: HeadingLevelConfig::per_level([0, 1, 1, 2, 2, 2]),
1174 lines_below: HeadingLevelConfig::scalar(1),
1175 allowed_at_start: false,
1176 });
1177
1178 let content = "Text\n# H1\n\nText\n\n## H2\n\nText\n\n### H3\n\nText\n\n\n#### H4\n\nText";
1179 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1180 let warnings = rule.check(&ctx).unwrap();
1181
1182 assert_eq!(
1184 warnings.len(),
1185 0,
1186 "All headings should satisfy level-specific requirements"
1187 );
1188 }
1189
1190 #[test]
1191 fn test_per_level_configuration_violations() {
1192 use md022_config::HeadingLevelConfig;
1193
1194 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1196 lines_above: HeadingLevelConfig::per_level([1, 1, 1, 2, 1, 1]),
1197 lines_below: HeadingLevelConfig::scalar(1),
1198 allowed_at_start: false,
1199 });
1200
1201 let content = "Text\n\n#### Heading 4\n\nMore text";
1203 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1204 let warnings = rule.check(&ctx).unwrap();
1205
1206 assert_eq!(warnings.len(), 1, "H4 with insufficient blanks should trigger warning");
1207 assert!(warnings[0].message.contains("2 blank lines above"));
1208 }
1209
1210 #[test]
1211 fn test_per_level_fix_different_levels() {
1212 use md022_config::HeadingLevelConfig;
1213
1214 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1216 lines_above: HeadingLevelConfig::per_level([0, 1, 2, 2, 2, 2]),
1217 lines_below: HeadingLevelConfig::scalar(1),
1218 allowed_at_start: false,
1219 });
1220
1221 let content = "Text\n# H1\nContent\n## H2\nContent\n### H3\nContent";
1222 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1223 let fixed = rule.fix(&ctx).unwrap();
1224
1225 assert!(fixed.contains("Text\n# H1\n\nContent"));
1227 assert!(fixed.contains("Content\n\n## H2\n\nContent"));
1228 assert!(fixed.contains("Content\n\n\n### H3\n\nContent"));
1229 }
1230
1231 #[test]
1232 fn test_per_level_below_configuration() {
1233 use md022_config::HeadingLevelConfig;
1234
1235 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1237 lines_above: HeadingLevelConfig::scalar(1),
1238 lines_below: HeadingLevelConfig::per_level([2, 1, 1, 1, 1, 1]), allowed_at_start: true,
1240 });
1241
1242 let content = "# Heading 1\n\nSome text";
1244 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1245 let warnings = rule.check(&ctx).unwrap();
1246
1247 assert_eq!(
1248 warnings.len(),
1249 1,
1250 "H1 with insufficient blanks below should trigger warning"
1251 );
1252 assert!(warnings[0].message.contains("2 blank lines below"));
1253 }
1254
1255 #[test]
1256 fn test_scalar_configuration_still_works() {
1257 use md022_config::HeadingLevelConfig;
1258
1259 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1261 lines_above: HeadingLevelConfig::scalar(2),
1262 lines_below: HeadingLevelConfig::scalar(2),
1263 allowed_at_start: false,
1264 });
1265
1266 let content = "Text\n# H1\nContent\n## H2\nContent";
1267 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1268 let warnings = rule.check(&ctx).unwrap();
1269
1270 assert!(!warnings.is_empty(), "Should have violations for insufficient blanks");
1272 }
1273
1274 #[test]
1275 fn test_unlimited_configuration_skips_requirements() {
1276 use md022_config::{HeadingBlankRequirement, HeadingLevelConfig};
1277
1278 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1280 lines_above: HeadingLevelConfig::per_level_requirements([
1281 HeadingBlankRequirement::unlimited(),
1282 HeadingBlankRequirement::limited(1),
1283 HeadingBlankRequirement::limited(1),
1284 HeadingBlankRequirement::limited(1),
1285 HeadingBlankRequirement::limited(1),
1286 HeadingBlankRequirement::limited(1),
1287 ]),
1288 lines_below: HeadingLevelConfig::per_level_requirements([
1289 HeadingBlankRequirement::unlimited(),
1290 HeadingBlankRequirement::limited(1),
1291 HeadingBlankRequirement::limited(1),
1292 HeadingBlankRequirement::limited(1),
1293 HeadingBlankRequirement::limited(1),
1294 HeadingBlankRequirement::limited(1),
1295 ]),
1296 allowed_at_start: false,
1297 });
1298
1299 let content = "# H1\nParagraph\n## H2\nParagraph";
1300 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1301 let warnings = rule.check(&ctx).unwrap();
1302
1303 assert_eq!(warnings.len(), 2, "Only non-unlimited headings should warn");
1305 assert!(
1306 warnings.iter().all(|w| w.line >= 3),
1307 "Warnings should target later headings"
1308 );
1309
1310 let fixed = rule.fix(&ctx).unwrap();
1312 assert!(
1313 fixed.starts_with("# H1\nParagraph\n\n## H2"),
1314 "H1 should remain unchanged"
1315 );
1316 }
1317
1318 #[test]
1319 fn test_html_comment_transparency() {
1320 let rule = MD022BlanksAroundHeadings::default();
1324
1325 let content = "Some content\n\n<!-- markdownlint-disable-next-line MD001 -->\n#### Heading";
1328 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1329 let warnings = rule.check(&ctx).unwrap();
1330 assert!(
1331 warnings.is_empty(),
1332 "HTML comment is transparent - blank line above it counts for heading"
1333 );
1334
1335 let content_multiline = "Some content\n\n<!-- This is a\nmulti-line comment -->\n#### Heading";
1337 let ctx_multiline = LintContext::new(content_multiline, crate::config::MarkdownFlavor::Standard, None);
1338 let warnings_multiline = rule.check(&ctx_multiline).unwrap();
1339 assert!(
1340 warnings_multiline.is_empty(),
1341 "Multi-line HTML comment is also transparent"
1342 );
1343 }
1344
1345 #[test]
1346 fn test_frontmatter_transparency() {
1347 let rule = MD022BlanksAroundHeadings::default();
1350
1351 let content = "---\ntitle: Test\n---\n# First heading";
1353 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1354 let warnings = rule.check(&ctx).unwrap();
1355 assert!(
1356 warnings.is_empty(),
1357 "Frontmatter is transparent - heading can appear immediately after"
1358 );
1359
1360 let content_with_blank = "---\ntitle: Test\n---\n\n# First heading";
1362 let ctx_with_blank = LintContext::new(content_with_blank, crate::config::MarkdownFlavor::Standard, None);
1363 let warnings_with_blank = rule.check(&ctx_with_blank).unwrap();
1364 assert!(
1365 warnings_with_blank.is_empty(),
1366 "Heading with blank line after frontmatter should also be valid"
1367 );
1368
1369 let content_toml = "+++\ntitle = \"Test\"\n+++\n# First heading";
1371 let ctx_toml = LintContext::new(content_toml, crate::config::MarkdownFlavor::Standard, None);
1372 let warnings_toml = rule.check(&ctx_toml).unwrap();
1373 assert!(
1374 warnings_toml.is_empty(),
1375 "TOML frontmatter is also transparent for MD022"
1376 );
1377 }
1378
1379 #[test]
1380 fn test_horizontal_rule_not_treated_as_frontmatter() {
1381 let rule = MD022BlanksAroundHeadings::default();
1384
1385 let content = "Some content\n\n---\n# Heading after HR";
1387 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1388 let warnings = rule.check(&ctx).unwrap();
1389 assert!(
1390 !warnings.is_empty(),
1391 "Heading after horizontal rule without blank line SHOULD trigger MD022"
1392 );
1393 assert!(
1394 warnings.iter().any(|w| w.line == 4),
1395 "Warning should be on line 4 (the heading line)"
1396 );
1397
1398 let content_with_blank = "Some content\n\n---\n\n# Heading after HR";
1400 let ctx_with_blank = LintContext::new(content_with_blank, crate::config::MarkdownFlavor::Standard, None);
1401 let warnings_with_blank = rule.check(&ctx_with_blank).unwrap();
1402 assert!(
1403 warnings_with_blank.is_empty(),
1404 "Heading with blank line after HR should not trigger MD022"
1405 );
1406
1407 let content_hr_start = "---\n# Heading";
1409 let ctx_hr_start = LintContext::new(content_hr_start, crate::config::MarkdownFlavor::Standard, None);
1410 let warnings_hr_start = rule.check(&ctx_hr_start).unwrap();
1411 assert!(
1412 !warnings_hr_start.is_empty(),
1413 "Heading after HR at document start SHOULD trigger MD022"
1414 );
1415
1416 let content_multi_hr = "Content\n\n---\n\n---\n# Heading";
1418 let ctx_multi_hr = LintContext::new(content_multi_hr, crate::config::MarkdownFlavor::Standard, None);
1419 let warnings_multi_hr = rule.check(&ctx_multi_hr).unwrap();
1420 assert!(
1421 !warnings_multi_hr.is_empty(),
1422 "Heading after multiple HRs without blank line SHOULD trigger MD022"
1423 );
1424 }
1425
1426 #[test]
1427 fn test_all_hr_styles_require_blank_before_heading() {
1428 let rule = MD022BlanksAroundHeadings::default();
1430
1431 let hr_styles = [
1433 "---", "***", "___", "- - -", "* * *", "_ _ _", "----", "****", "____", "- - - -",
1434 "- - -", " ---", " ---", ];
1438
1439 for hr in hr_styles {
1440 let content = format!("Content\n\n{hr}\n# Heading");
1441 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1442 let warnings = rule.check(&ctx).unwrap();
1443 assert!(
1444 !warnings.is_empty(),
1445 "HR style '{hr}' followed by heading should trigger MD022"
1446 );
1447 }
1448 }
1449
1450 #[test]
1451 fn test_setext_heading_after_hr() {
1452 let rule = MD022BlanksAroundHeadings::default();
1454
1455 let content = "Content\n\n---\nHeading\n======";
1457 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1458 let warnings = rule.check(&ctx).unwrap();
1459 assert!(
1460 !warnings.is_empty(),
1461 "Setext heading after HR without blank should trigger MD022"
1462 );
1463
1464 let content_h2 = "Content\n\n---\nHeading\n------";
1466 let ctx_h2 = LintContext::new(content_h2, crate::config::MarkdownFlavor::Standard, None);
1467 let warnings_h2 = rule.check(&ctx_h2).unwrap();
1468 assert!(
1469 !warnings_h2.is_empty(),
1470 "Setext h2 after HR without blank should trigger MD022"
1471 );
1472
1473 let content_ok = "Content\n\n---\n\nHeading\n======";
1475 let ctx_ok = LintContext::new(content_ok, crate::config::MarkdownFlavor::Standard, None);
1476 let warnings_ok = rule.check(&ctx_ok).unwrap();
1477 assert!(
1478 warnings_ok.is_empty(),
1479 "Setext heading with blank after HR should not warn"
1480 );
1481 }
1482
1483 #[test]
1484 fn test_hr_in_code_block_not_treated_as_hr() {
1485 let rule = MD022BlanksAroundHeadings::default();
1487
1488 let content = "```\n---\n```\n# Heading";
1491 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1492 let warnings = rule.check(&ctx).unwrap();
1493 assert!(!warnings.is_empty(), "Heading after code block still needs blank line");
1496
1497 let content_ok = "```\n---\n```\n\n# Heading";
1499 let ctx_ok = LintContext::new(content_ok, crate::config::MarkdownFlavor::Standard, None);
1500 let warnings_ok = rule.check(&ctx_ok).unwrap();
1501 assert!(
1502 warnings_ok.is_empty(),
1503 "Heading with blank after code block should not warn"
1504 );
1505 }
1506
1507 #[test]
1508 fn test_hr_in_html_comment_not_treated_as_hr() {
1509 let rule = MD022BlanksAroundHeadings::default();
1511
1512 let content = "<!-- \n---\n -->\n# Heading";
1514 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1515 let warnings = rule.check(&ctx).unwrap();
1516 assert!(
1518 warnings.is_empty(),
1519 "HR inside HTML comment should be ignored - heading after comment is OK"
1520 );
1521 }
1522
1523 #[test]
1524 fn test_invalid_hr_not_triggering() {
1525 let rule = MD022BlanksAroundHeadings::default();
1527
1528 let invalid_hrs = [
1529 " ---", "\t---", "--", "**", "__", "-*-", "---a", "a---", ];
1538
1539 for invalid in invalid_hrs {
1540 let content = format!("Content\n\n{invalid}\n# Heading");
1543 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1544 let _ = rule.check(&ctx);
1547 }
1548 }
1549
1550 #[test]
1551 fn test_frontmatter_vs_horizontal_rule_distinction() {
1552 let rule = MD022BlanksAroundHeadings::default();
1554
1555 let content = "---\ntitle: Test\n---\n\nSome content\n\n---\n# Heading after HR";
1558 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1559 let warnings = rule.check(&ctx).unwrap();
1560 assert!(
1561 !warnings.is_empty(),
1562 "HR after frontmatter content should still require blank line before heading"
1563 );
1564
1565 let content_ok = "---\ntitle: Test\n---\n\nSome content\n\n---\n\n# Heading after HR";
1567 let ctx_ok = LintContext::new(content_ok, crate::config::MarkdownFlavor::Standard, None);
1568 let warnings_ok = rule.check(&ctx_ok).unwrap();
1569 assert!(
1570 warnings_ok.is_empty(),
1571 "HR with blank line before heading should not warn"
1572 );
1573 }
1574
1575 #[test]
1578 fn test_kramdown_ial_after_heading_no_warning() {
1579 let rule = MD022BlanksAroundHeadings::default();
1581 let content = "## Table of Contents\n{: .hhc-toc-heading}\n\nSome content here.";
1582 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1583 let warnings = rule.check(&ctx).unwrap();
1584
1585 assert!(
1586 warnings.is_empty(),
1587 "IAL after heading should not require blank line between them: {warnings:?}"
1588 );
1589 }
1590
1591 #[test]
1592 fn test_kramdown_ial_with_class() {
1593 let rule = MD022BlanksAroundHeadings::default();
1594 let content = "# Heading\n{:.highlight}\n\nContent.";
1595 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1596 let warnings = rule.check(&ctx).unwrap();
1597
1598 assert!(warnings.is_empty(), "IAL with class should be part of heading");
1599 }
1600
1601 #[test]
1602 fn test_kramdown_ial_with_id() {
1603 let rule = MD022BlanksAroundHeadings::default();
1604 let content = "# Heading\n{:#custom-id}\n\nContent.";
1605 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1606 let warnings = rule.check(&ctx).unwrap();
1607
1608 assert!(warnings.is_empty(), "IAL with id should be part of heading");
1609 }
1610
1611 #[test]
1612 fn test_kramdown_ial_with_multiple_attributes() {
1613 let rule = MD022BlanksAroundHeadings::default();
1614 let content = "# Heading\n{: .class #id style=\"color: red\"}\n\nContent.";
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 with multiple attributes should be part of heading"
1621 );
1622 }
1623
1624 #[test]
1625 fn test_kramdown_ial_missing_blank_after() {
1626 let rule = MD022BlanksAroundHeadings::default();
1628 let content = "# Heading\n{:.class}\nContent without blank.";
1629 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1630 let warnings = rule.check(&ctx).unwrap();
1631
1632 assert_eq!(
1633 warnings.len(),
1634 1,
1635 "Should warn about missing blank after IAL (part of heading)"
1636 );
1637 assert!(warnings[0].message.contains("below"));
1638 }
1639
1640 #[test]
1641 fn test_kramdown_ial_before_heading_transparent() {
1642 let rule = MD022BlanksAroundHeadings::default();
1644 let content = "Content.\n\n{:.preclass}\n## Heading\n\nMore content.";
1645 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1646 let warnings = rule.check(&ctx).unwrap();
1647
1648 assert!(
1649 warnings.is_empty(),
1650 "IAL before heading should be transparent for blank line count"
1651 );
1652 }
1653
1654 #[test]
1655 fn test_kramdown_ial_setext_heading() {
1656 let rule = MD022BlanksAroundHeadings::default();
1657 let content = "Heading\n=======\n{:.setext-class}\n\nContent.";
1658 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1659 let warnings = rule.check(&ctx).unwrap();
1660
1661 assert!(
1662 warnings.is_empty(),
1663 "IAL after Setext heading should be part of heading"
1664 );
1665 }
1666
1667 #[test]
1668 fn test_kramdown_ial_fix_preserves_ial() {
1669 let rule = MD022BlanksAroundHeadings::default();
1670 let content = "Content.\n# Heading\n{:.class}\nMore content.";
1671 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1672 let fixed = rule.fix(&ctx).unwrap();
1673
1674 assert!(
1676 fixed.contains("# Heading\n{:.class}"),
1677 "IAL should stay attached to heading"
1678 );
1679 assert!(fixed.contains("{:.class}\n\nMore"), "Should add blank after IAL");
1680 }
1681
1682 #[test]
1683 fn test_kramdown_ial_fix_does_not_separate() {
1684 let rule = MD022BlanksAroundHeadings::default();
1685 let content = "# Heading\n{:.class}\nContent.";
1686 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1687 let fixed = rule.fix(&ctx).unwrap();
1688
1689 assert!(
1691 !fixed.contains("# Heading\n\n{:.class}"),
1692 "Should not add blank between heading and IAL"
1693 );
1694 assert!(fixed.contains("# Heading\n{:.class}"), "IAL should remain attached");
1695 }
1696
1697 #[test]
1698 fn test_kramdown_multiple_ial_lines() {
1699 let rule = MD022BlanksAroundHeadings::default();
1701 let content = "# Heading\n{:.class1}\n{:#id}\n\nContent.";
1702 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1703 let warnings = rule.check(&ctx).unwrap();
1704
1705 assert!(
1708 warnings.is_empty(),
1709 "Multiple consecutive IALs should be part of heading"
1710 );
1711 }
1712
1713 #[test]
1714 fn test_kramdown_ial_with_blank_line_not_attached() {
1715 let rule = MD022BlanksAroundHeadings::default();
1717 let content = "# Heading\n\n{:.class}\nContent.";
1718 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1719 let warnings = rule.check(&ctx).unwrap();
1720
1721 assert!(warnings.is_empty(), "Blank line separates heading from IAL");
1725 }
1726
1727 #[test]
1728 fn test_not_kramdown_ial_regular_braces() {
1729 let rule = MD022BlanksAroundHeadings::default();
1731 let content = "# Heading\n{not an ial}\n\nContent.";
1732 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1733 let warnings = rule.check(&ctx).unwrap();
1734
1735 assert_eq!(
1737 warnings.len(),
1738 1,
1739 "Non-IAL braces should be regular content requiring blank"
1740 );
1741 }
1742
1743 #[test]
1744 fn test_kramdown_ial_at_document_end() {
1745 let rule = MD022BlanksAroundHeadings::default();
1746 let content = "# Heading\n{:.class}";
1747 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1748 let warnings = rule.check(&ctx).unwrap();
1749
1750 assert!(warnings.is_empty(), "IAL at document end needs no blank after");
1752 }
1753
1754 #[test]
1755 fn test_kramdown_ial_followed_by_code_fence() {
1756 let rule = MD022BlanksAroundHeadings::default();
1757 let content = "# Heading\n{:.class}\n```\ncode\n```";
1758 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1759 let warnings = rule.check(&ctx).unwrap();
1760
1761 assert!(warnings.is_empty(), "No blank needed between IAL and code fence");
1763 }
1764
1765 #[test]
1766 fn test_kramdown_ial_followed_by_list() {
1767 let rule = MD022BlanksAroundHeadings::default();
1768 let content = "# Heading\n{:.class}\n- List item";
1769 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1770 let warnings = rule.check(&ctx).unwrap();
1771
1772 assert!(warnings.is_empty(), "No blank needed between IAL and list");
1774 }
1775
1776 #[test]
1777 fn test_kramdown_ial_fix_idempotent() {
1778 let rule = MD022BlanksAroundHeadings::default();
1779 let content = "# Heading\n{:.class}\nContent.";
1780 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1781
1782 let fixed_once = rule.fix(&ctx).unwrap();
1783 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
1784 let fixed_twice = rule.fix(&ctx2).unwrap();
1785
1786 assert_eq!(fixed_once, fixed_twice, "Fix should be idempotent");
1787 }
1788
1789 #[test]
1790 fn test_kramdown_ial_whitespace_line_between_not_attached() {
1791 let rule = MD022BlanksAroundHeadings::default();
1794 let content = "# Heading\n \n{:.class}\n\nContent.";
1795 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1796 let warnings = rule.check(&ctx).unwrap();
1797
1798 assert!(
1802 warnings.is_empty(),
1803 "Whitespace between heading and IAL means IAL is not attached"
1804 );
1805 }
1806
1807 #[test]
1808 fn test_kramdown_ial_html_comment_between() {
1809 let rule = MD022BlanksAroundHeadings::default();
1812 let content = "# Heading\n<!-- comment -->\n{:.class}\n\nContent.";
1813 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1814 let warnings = rule.check(&ctx).unwrap();
1815
1816 assert!(
1819 warnings.is_empty(),
1820 "A comment-only line below the heading is its blank line: {warnings:?}"
1821 );
1822 }
1823
1824 #[test]
1825 fn test_kramdown_ial_text_beside_comment_between_is_still_reported() {
1826 let rule = MD022BlanksAroundHeadings::default();
1829 let content = "# Heading\ntext <!-- comment -->\n{:.class}\n\nContent.";
1830 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1831 let warnings = rule.check(&ctx).unwrap();
1832
1833 assert_eq!(warnings.len(), 1, "Heading followed by prose: {warnings:?}");
1834 }
1835
1836 #[test]
1837 fn test_kramdown_ial_generic_attribute() {
1838 let rule = MD022BlanksAroundHeadings::default();
1839 let content = "# Heading\n{:data-toc=\"true\" style=\"color: red\"}\n\nContent.";
1840 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1841 let warnings = rule.check(&ctx).unwrap();
1842
1843 assert!(warnings.is_empty(), "Generic attributes should be recognized as IAL");
1844 }
1845
1846 #[test]
1847 fn test_kramdown_ial_fix_multiple_lines_preserves_all() {
1848 let rule = MD022BlanksAroundHeadings::default();
1849 let content = "# Heading\n{:.class1}\n{:#id}\n{:data-x=\"y\"}\nContent.";
1850 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1851
1852 let fixed = rule.fix(&ctx).unwrap();
1853
1854 assert!(fixed.contains("{:.class1}"), "First IAL should be preserved");
1856 assert!(fixed.contains("{:#id}"), "Second IAL should be preserved");
1857 assert!(fixed.contains("{:data-x=\"y\"}"), "Third IAL should be preserved");
1858 assert!(
1860 fixed.contains("{:data-x=\"y\"}\n\nContent"),
1861 "Blank line should be after all IALs"
1862 );
1863 }
1864
1865 #[test]
1866 fn test_kramdown_ial_crlf_line_endings() {
1867 let rule = MD022BlanksAroundHeadings::default();
1868 let content = "# Heading\r\n{:.class}\r\n\r\nContent.";
1869 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1870 let warnings = rule.check(&ctx).unwrap();
1871
1872 assert!(warnings.is_empty(), "CRLF should work correctly with IAL");
1873 }
1874
1875 #[test]
1876 fn test_kramdown_ial_invalid_patterns_not_recognized() {
1877 let rule = MD022BlanksAroundHeadings::default();
1878
1879 let content = "# Heading\n{ :.class}\n\nContent.";
1881 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1882 let warnings = rule.check(&ctx).unwrap();
1883 assert_eq!(warnings.len(), 1, "Invalid IAL syntax should trigger warning");
1884
1885 let content2 = "# Heading\n{.class}\n\nContent.";
1887 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1888 let warnings2 = rule.check(&ctx2).unwrap();
1889 assert!(warnings2.is_empty(), "{{.class}} is valid kramdown block attribute");
1891
1892 let content3 = "# Heading\n{just text}\n\nContent.";
1894 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
1895 let warnings3 = rule.check(&ctx3).unwrap();
1896 assert_eq!(
1897 warnings3.len(),
1898 1,
1899 "Text in braces is not IAL and should trigger warning"
1900 );
1901 }
1902
1903 #[test]
1904 fn test_kramdown_ial_toc_marker() {
1905 let rule = MD022BlanksAroundHeadings::default();
1907 let content = "# Heading\n{:toc}\n\nContent.";
1908 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1909 let warnings = rule.check(&ctx).unwrap();
1910
1911 assert!(warnings.is_empty(), "{{:toc}} should be recognized as IAL");
1913 }
1914
1915 #[test]
1916 fn test_kramdown_ial_mixed_headings_in_document() {
1917 let rule = MD022BlanksAroundHeadings::default();
1918 let content = r#"# ATX Heading
1919{:.atx-class}
1920
1921Content after ATX.
1922
1923Setext Heading
1924--------------
1925{:#setext-id}
1926
1927Content after Setext.
1928
1929## Another ATX
1930{:.another}
1931
1932More content."#;
1933 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1934 let warnings = rule.check(&ctx).unwrap();
1935
1936 assert!(
1937 warnings.is_empty(),
1938 "Mixed headings with IAL should all work: {warnings:?}"
1939 );
1940 }
1941
1942 #[test]
1943 fn test_kramdown_extension_block_before_first_heading_is_document_start() {
1944 let rule = MD022BlanksAroundHeadings::default();
1945 let content = "{::comment}\nhidden\n{:/comment}\n# Heading\n\nBody\n";
1946 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Kramdown, None);
1947 let warnings = rule.check(&ctx).unwrap();
1948
1949 assert!(
1950 warnings.is_empty(),
1951 "Kramdown extension preamble should not require blank above first heading: {warnings:?}"
1952 );
1953 }
1954
1955 #[test]
1956 fn test_kramdown_ial_before_first_heading_is_document_start() {
1957 let rule = MD022BlanksAroundHeadings::default();
1958 let content = "{:.doc-class}\n# Heading\n\nBody\n";
1959 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Kramdown, None);
1960 let warnings = rule.check(&ctx).unwrap();
1961
1962 assert!(
1963 warnings.is_empty(),
1964 "Kramdown IAL preamble should not require blank above first heading: {warnings:?}"
1965 );
1966 }
1967
1968 #[test]
1971 fn test_quarto_div_marker_transparent_above_heading() {
1972 let rule = MD022BlanksAroundHeadings::default();
1975 let content = "Content\n\n::: {.callout-note}\n# Heading\n\nMore content\n:::\n";
1977 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1978 let warnings = rule.check(&ctx).unwrap();
1979 assert!(
1981 warnings.is_empty(),
1982 "Quarto div marker should be transparent above heading: {warnings:?}"
1983 );
1984 }
1985
1986 #[test]
1987 fn test_quarto_div_marker_transparent_below_heading() {
1988 let rule = MD022BlanksAroundHeadings::default();
1990 let content = "# Heading\n\n::: {.callout-note}\nContent\n:::\n";
1991 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1992 let warnings = rule.check(&ctx).unwrap();
1993 assert!(
1995 warnings.is_empty(),
1996 "Quarto div marker should be transparent below heading: {warnings:?}"
1997 );
1998 }
1999
2000 #[test]
2001 fn test_quarto_heading_inside_callout() {
2002 let rule = MD022BlanksAroundHeadings::default();
2004 let content = "::: {.callout-note}\n\n## Note Title\n\nNote content\n:::\n";
2005 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
2006 let warnings = rule.check(&ctx).unwrap();
2007 assert!(
2008 warnings.is_empty(),
2009 "Heading inside Quarto callout should have no warnings: {warnings:?}"
2010 );
2011 }
2012
2013 #[test]
2014 fn test_quarto_heading_at_start_after_div_open() {
2015 let rule = MD022BlanksAroundHeadings::default();
2018 let content = "::: {.callout-warning}\n# Warning\n\nContent\n:::\n";
2020 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
2021 let warnings = rule.check(&ctx).unwrap();
2022 assert!(
2028 warnings.is_empty(),
2029 "Heading at start after div open should pass: {warnings:?}"
2030 );
2031 }
2032
2033 #[test]
2034 fn test_quarto_heading_before_div_close() {
2035 let rule = MD022BlanksAroundHeadings::default();
2037 let content = "::: {.callout-note}\nIntro\n\n## Section\n:::\n";
2038 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
2039 let warnings = rule.check(&ctx).unwrap();
2040 assert!(
2044 warnings.is_empty(),
2045 "Heading before div close should pass: {warnings:?}"
2046 );
2047 }
2048
2049 #[test]
2050 fn test_quarto_div_markers_not_transparent_in_standard_flavor() {
2051 let rule = MD022BlanksAroundHeadings::default();
2053 let content = "Content\n\n:::\n# Heading\n\n:::\n";
2054 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2055 let warnings = rule.check(&ctx).unwrap();
2056 assert!(
2058 !warnings.is_empty(),
2059 "Standard flavor should not treat ::: as transparent: {warnings:?}"
2060 );
2061 }
2062
2063 #[test]
2064 fn test_quarto_nested_divs_with_heading() {
2065 let rule = MD022BlanksAroundHeadings::default();
2067 let content = "::: {.outer}\n::: {.inner}\n\n# Heading\n\nContent\n:::\n:::\n";
2068 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
2069 let warnings = rule.check(&ctx).unwrap();
2070 assert!(
2071 warnings.is_empty(),
2072 "Nested divs with heading should work: {warnings:?}"
2073 );
2074 }
2075
2076 #[test]
2077 fn test_quarto_fix_preserves_div_markers() {
2078 let rule = MD022BlanksAroundHeadings::default();
2080 let content = "::: {.callout-note}\n\n## Note\n\nContent\n:::\n";
2081 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
2082 let fixed = rule.fix(&ctx).unwrap();
2083 assert!(fixed.contains("::: {.callout-note}"), "Should preserve div opening");
2085 assert!(fixed.contains(":::"), "Should preserve div closing");
2086 assert!(fixed.contains("## Note"), "Should preserve heading");
2087 }
2088
2089 #[test]
2090 fn test_quarto_heading_needs_blank_without_div_transparency() {
2091 let rule = MD022BlanksAroundHeadings::default();
2094 let content = "Content\n::: {.callout-note}\n# Heading\n\nMore\n:::\n";
2096 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
2097 let warnings = rule.check(&ctx).unwrap();
2098 assert!(
2101 !warnings.is_empty(),
2102 "Should still require blank line when not present: {warnings:?}"
2103 );
2104 }
2105
2106 #[test]
2107 fn test_pandoc_div_marker_transparent_above_heading() {
2108 let rule = MD022BlanksAroundHeadings::default();
2111 let content = "Content\n\n::: {.callout-note}\n# Heading\n\nMore content\n:::\n";
2112 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
2113 let warnings = rule.check(&ctx).unwrap();
2114 assert!(
2115 warnings.is_empty(),
2116 "MD022 should treat Pandoc div marker as transparent above heading: {warnings:?}"
2117 );
2118 }
2119
2120 #[test]
2121 fn test_hugo_block_attribute_after_heading_not_flagged() {
2122 let rule = MD022BlanksAroundHeadings::default();
2125 let content = "Intro text.\n\n# Heading\n{class=\"anchor\"}\n\nContent.\n";
2126
2127 for flavor in [
2128 crate::config::MarkdownFlavor::Hugo,
2129 crate::config::MarkdownFlavor::MkDocs,
2130 crate::config::MarkdownFlavor::Kramdown,
2131 ] {
2132 let ctx = LintContext::new(content, flavor, None);
2133 let warnings = rule.check(&ctx).unwrap();
2134 assert!(
2135 warnings.is_empty(),
2136 "MD022 should not flag the block attribute line under {flavor:?}: {warnings:?}"
2137 );
2138 }
2139
2140 let ctx_std = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2143 let warnings_std = rule.check(&ctx_std).unwrap();
2144 assert!(
2145 warnings_std.iter().any(|w| w.message.contains("below heading")),
2146 "MD022 must flag the missing blank below the heading under Standard: {warnings_std:?}"
2147 );
2148 }
2149
2150 #[test]
2151 fn test_mdg_keeps_tags_attached_only_to_structure_headings() {
2152 let rule = MD022BlanksAroundHeadings::default();
2153
2154 let attached = "`@browser`\n`@checkout` `@smoke`\n# Feature: Checkout\n";
2156 let mdg_ctx = LintContext::new(attached, crate::config::MarkdownFlavor::MDG, None);
2157 assert!(rule.check(&mdg_ctx).unwrap().is_empty());
2158 let fixed = rule.fix(&mdg_ctx).unwrap();
2159 assert_eq!(fixed, attached);
2160 let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
2161 assert_eq!(rule.fix(&fixed_ctx).unwrap(), fixed, "MDG fix should be idempotent");
2162
2163 let standard_ctx = LintContext::new(attached, crate::config::MarkdownFlavor::Standard, None);
2164 assert!(
2165 rule.check(&standard_ctx)
2166 .unwrap()
2167 .iter()
2168 .any(|warning| warning.message.contains("above heading"))
2169 );
2170 }
2171
2172 #[test]
2173 fn test_mdg_requires_blank_line_above_a_non_structure_heading() {
2174 let rule = MD022BlanksAroundHeadings::default();
2177 let content = "`@browser`\n# Notes\n";
2178 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
2179
2180 assert!(
2181 rule.check(&ctx)
2182 .unwrap()
2183 .iter()
2184 .any(|warning| warning.message.contains("above heading")),
2185 "a non-Gherkin heading keeps the normal requirement"
2186 );
2187 assert_eq!(rule.fix(&ctx).unwrap(), "`@browser`\n\n# Notes\n");
2188 }
2189
2190 #[test]
2191 fn test_mdg_colon_inside_a_code_span_names_no_structure() {
2192 let rule = MD022BlanksAroundHeadings::default();
2196 let content = "`@browser`\n# See `x: y` Notes\n";
2197 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
2198
2199 assert!(
2200 rule.check(&ctx)
2201 .unwrap()
2202 .iter()
2203 .any(|warning| warning.message.contains("above heading")),
2204 "the code span holds the only colon, so the heading is ordinary prose"
2205 );
2206 assert_eq!(rule.fix(&ctx).unwrap(), "`@browser`\n\n# See `x: y` Notes\n");
2207
2208 let structure = "`@browser`\n# Scenario: use `a: b` here\n";
2210 let structure_ctx = LintContext::new(structure, crate::config::MarkdownFlavor::MDG, None);
2211 assert!(rule.check(&structure_ctx).unwrap().is_empty());
2212 assert_eq!(rule.fix(&structure_ctx).unwrap(), structure);
2213 }
2214
2215 #[test]
2216 fn test_mdg_tag_line_matches_gherkin_reference_scan() {
2217 let rule = MD022BlanksAroundHeadings::default();
2220
2221 for above in [
2222 "`@comment_tag1` #a comment",
2223 "`@comment_tag#2` #a comment",
2224 "`@browser` and prose",
2225 "prose `@browser`",
2226 "`@a b`",
2227 ] {
2228 let content = format!("{above}\n# Feature: Checkout\n");
2229 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::MDG, None);
2230 assert!(rule.check(&ctx).unwrap().is_empty(), "{above:?} is a Gherkin tag line");
2231 assert_eq!(rule.fix(&ctx).unwrap(), content);
2232 }
2233
2234 let prose = "plain prose\n# Feature: Checkout\n";
2235 let ctx = LintContext::new(prose, crate::config::MarkdownFlavor::MDG, None);
2236 assert!(
2237 rule.check(&ctx)
2238 .unwrap()
2239 .iter()
2240 .any(|warning| warning.message.contains("above heading"))
2241 );
2242 }
2243
2244 #[test]
2245 fn test_fix_keeps_a_setext_heading_suppressed_on_a_later_line_as_written() {
2246 let rule = MD022BlanksAroundHeadings::default();
2251 let content = "Intro paragraph.\n# Heading one\nText after.\nTitle\nsecond <!-- rumdl-disable-line MD022 -->\n===\nMore text.\n";
2252 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2253
2254 assert_eq!(
2255 rule.fix(&ctx).unwrap(),
2256 "Intro paragraph.\n\n# Heading one\n\nText after.\nTitle\nsecond <!-- rumdl-disable-line MD022 -->\n===\nMore text.\n"
2257 );
2258 }
2259}