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() {
89 Some(heading) if heading.is_valid && !found_non_transparent => Some(first_text_idx(i, heading)),
90 _ => {
91 if !line.is_blank && !line.in_html_comment && !line.in_mdx_comment && !line.is_setext_heading_text {
92 let trimmed = line.content(ctx.content).trim();
93 if is_blank_or_comment_only(trimmed) {
95 } else if line.in_kramdown_extension_block || line.is_kramdown_block_ial {
97 } else if is_pandoc && (pandoc::is_div_open(trimmed) || pandoc::is_div_close(trimmed)) {
99 } else {
101 found_non_transparent = true;
102 }
103 }
104 None
105 }
106 }
107 })
108}
109
110#[derive(Clone, Default)]
182pub struct MD022BlanksAroundHeadings {
183 config: MD022Config,
184}
185
186impl MD022BlanksAroundHeadings {
187 pub fn new() -> Self {
190 Self {
191 config: MD022Config::default(),
192 }
193 }
194
195 pub fn with_values(lines_above: usize, lines_below: usize) -> Self {
197 use md022_config::HeadingLevelConfig;
198 Self {
199 config: MD022Config {
200 lines_above: HeadingLevelConfig::scalar(lines_above),
201 lines_below: HeadingLevelConfig::scalar(lines_below),
202 allowed_at_start: true,
203 },
204 }
205 }
206
207 pub fn from_config_struct(config: MD022Config) -> Self {
208 Self { config }
209 }
210
211 fn fix_content(&self, ctx: &crate::lint_context::LintContext) -> String {
213 let line_ending = "\n";
216 let had_trailing_newline = ctx.content.ends_with('\n');
217 let is_pandoc = ctx.flavor.is_pandoc_compatible();
218 let is_mdg = ctx.flavor == crate::config::MarkdownFlavor::MDG;
219 let mut result = Vec::new();
220 let mut skip_count: usize = 0;
221
222 let heading_at_start_idx = heading_at_start_idx(ctx, is_pandoc);
223
224 for (i, line_info) in ctx.lines.iter().enumerate() {
225 if skip_count > 0 {
226 skip_count -= 1;
227 continue;
228 }
229 let line = line_info.content(ctx.content);
230
231 if line_info.in_code_block {
232 result.push(line.to_string());
233 continue;
234 }
235
236 let heading_idx = if line_info.heading.is_some() {
240 Some(i)
241 } else if line_info.is_setext_heading_text {
242 ctx.lines[i..]
243 .iter()
244 .position(|candidate| candidate.heading.is_some())
245 .map(|offset| i + offset)
246 } else {
247 None
248 };
249
250 if let Some(heading_idx) = heading_idx {
251 let heading = ctx.lines[heading_idx].heading.as_deref().unwrap();
252 if !heading.is_valid {
254 result.push(line.to_string());
255 continue;
256 }
257
258 let heading_end_idx = if matches!(
261 heading.style,
262 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
263 ) && heading_idx + 1 < ctx.lines.len()
264 {
265 heading_idx + 1
266 } else {
267 heading_idx
268 };
269
270 if (i..=heading_end_idx).any(|idx| ctx.inline_config().is_rule_disabled("MD022", idx + 1)) {
274 for idx in i..=heading_end_idx {
275 result.push(ctx.lines[idx].content(ctx.content).to_string());
276 }
277 skip_count += heading_end_idx - i;
278 continue;
279 }
280
281 let is_first_heading = Some(i) == heading_at_start_idx;
283 let heading_level = heading.level as usize;
284
285 let mut blank_lines_above = 0;
287 let mut check_idx = result.len();
288 while check_idx > 0 {
289 let prev_line = &result[check_idx - 1];
290 let trimmed = prev_line.trim();
291 if is_blank_or_comment_only(prev_line) {
292 blank_lines_above += 1;
294 check_idx -= 1;
295 } else if is_block_attribute_line(trimmed, ctx.flavor) {
296 check_idx -= 1;
298 } else if is_pandoc && (pandoc::is_div_open(trimmed) || pandoc::is_div_close(trimmed)) {
299 check_idx -= 1;
301 } else {
302 break;
303 }
304 }
305
306 let requirement_above = self.config.lines_above.get_for_level(heading_level);
308 let follows_mdg_tags = is_mdg && follows_mdg_tag_line(ctx, i, heading);
309 let needed_blanks_above = if follows_mdg_tags || (is_first_heading && self.config.allowed_at_start) {
310 0
311 } else {
312 requirement_above.required_count().unwrap_or(0)
313 };
314
315 while blank_lines_above < needed_blanks_above {
317 result.push(String::new());
318 blank_lines_above += 1;
319 }
320
321 for idx in i..=heading_end_idx {
324 result.push(ctx.lines[idx].content(ctx.content).to_string());
325 }
326 skip_count += heading_end_idx - i; let mut effective_end_idx = heading_end_idx;
330
331 let mut ial_count = 0;
334 while effective_end_idx + 1 < ctx.lines.len() {
335 let next_line = &ctx.lines[effective_end_idx + 1];
336 let next_trimmed = next_line.content(ctx.content).trim();
337 if is_block_attribute_line(next_trimmed, ctx.flavor) {
338 result.push(next_trimmed.to_string());
339 effective_end_idx += 1;
340 ial_count += 1;
341 } else {
342 break;
343 }
344 }
345
346 let mut blank_lines_below = 0;
348 let mut next_content_line_idx = None;
349 for j in (effective_end_idx + 1)..ctx.lines.len() {
350 if ctx.lines[j].is_blank || is_blank_or_comment_only(ctx.lines[j].content(ctx.content)) {
351 blank_lines_below += 1;
352 } else {
353 next_content_line_idx = Some(j);
354 break;
355 }
356 }
357
358 let next_is_special = if let Some(idx) = next_content_line_idx {
360 let next_line = &ctx.lines[idx];
361 let trimmed = next_line.content(ctx.content).trim();
362 next_line.list_item.is_some()
363 || starts_with_list_marker(trimmed)
364 || ((trimmed.starts_with("```") || trimmed.starts_with("~~~"))
365 && (trimmed.len() == 3
366 || (trimmed.len() > 3
367 && trimmed
368 .chars()
369 .nth(3)
370 .is_some_and(|c| c.is_whitespace() || c.is_alphabetic()))))
371 } else {
372 false
373 };
374
375 let requirement_below = self.config.lines_below.get_for_level(heading_level);
377 let needed_blanks_below = if next_is_special {
378 0
379 } else {
380 requirement_below.required_count().unwrap_or(0)
381 };
382 if blank_lines_below < needed_blanks_below {
383 for _ in 0..(needed_blanks_below - blank_lines_below) {
384 result.push(String::new());
385 }
386 }
387
388 skip_count += ial_count;
390 } else {
391 result.push(line.to_string());
393 }
394 }
395
396 let joined = result.join(line_ending);
397
398 if had_trailing_newline && !joined.ends_with('\n') {
400 format!("{joined}{line_ending}")
401 } else if !had_trailing_newline && joined.ends_with('\n') {
402 joined[..joined.len() - 1].to_string()
404 } else {
405 joined
406 }
407 }
408}
409
410impl Rule for MD022BlanksAroundHeadings {
411 fn name(&self) -> &'static str {
412 "MD022"
413 }
414
415 fn description(&self) -> &'static str {
416 "Headings should be surrounded by blank lines"
417 }
418
419 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
420 let mut result = Vec::new();
421
422 if ctx.lines.is_empty() {
424 return Ok(result);
425 }
426
427 let line_ending = "\n";
430 let is_pandoc = ctx.flavor.is_pandoc_compatible();
431 let is_mdg = ctx.flavor == crate::config::MarkdownFlavor::MDG;
432
433 let heading_at_start_idx = heading_at_start_idx(ctx, is_pandoc);
434
435 let mut heading_violations = Vec::new();
437 let mut processed_headings = std::collections::HashSet::new();
438
439 for (line_num, line_info) in ctx.lines.iter().enumerate() {
440 if processed_headings.contains(&line_num) || line_info.heading.is_none() {
442 continue;
443 }
444
445 if line_info.in_pymdown_block {
447 continue;
448 }
449
450 let heading = line_info.heading.as_ref().unwrap();
451
452 if !heading.is_valid {
454 continue;
455 }
456
457 let heading_level = heading.level as usize;
458
459 processed_headings.insert(line_num);
463
464 let first_idx = first_text_idx(line_num, heading);
468
469 let is_first_heading = Some(first_idx) == heading_at_start_idx;
471
472 let required_above_count = self.config.lines_above.get_for_level(heading_level).required_count();
474 let required_below_count = self.config.lines_below.get_for_level(heading_level).required_count();
475
476 let should_check_above = required_above_count.is_some()
478 && first_idx > 0
479 && (!is_first_heading || !self.config.allowed_at_start)
480 && !(is_mdg && follows_mdg_tag_line(ctx, first_idx, heading));
481 if should_check_above {
482 let mut blank_lines_above = 0;
483 let mut hit_frontmatter_end = false;
484 for j in (0..first_idx).rev() {
485 let line_content = ctx.lines[j].content(ctx.content);
486 let trimmed = line_content.trim();
487 if ctx.lines[j].is_blank || is_blank_or_comment_only(line_content) {
488 blank_lines_above += 1;
491 } else if ctx.lines[j].in_html_comment || ctx.lines[j].in_mdx_comment {
492 continue;
494 } else if is_block_attribute_line(trimmed, ctx.flavor) {
495 continue;
497 } else if is_pandoc && (pandoc::is_div_open(trimmed) || pandoc::is_div_close(trimmed)) {
498 continue;
500 } else if ctx.lines[j].in_front_matter {
501 hit_frontmatter_end = true;
506 break;
507 } else {
508 break;
509 }
510 }
511 let required = required_above_count.unwrap();
512 if !hit_frontmatter_end && blank_lines_above < required {
513 let needed_blanks = required - blank_lines_above;
514 heading_violations.push((line_num, first_idx, "above", needed_blanks, heading_level));
515 }
516 }
517
518 let mut effective_last_line = if matches!(
520 heading.style,
521 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
522 ) {
523 line_num + 1 } else {
525 line_num
526 };
527
528 while effective_last_line + 1 < ctx.lines.len() {
531 let next_line = &ctx.lines[effective_last_line + 1];
532 let next_trimmed = next_line.content(ctx.content).trim();
533 if is_block_attribute_line(next_trimmed, ctx.flavor) {
534 effective_last_line += 1;
535 } else {
536 break;
537 }
538 }
539
540 if effective_last_line < ctx.lines.len() - 1 {
542 let mut next_non_blank_idx = effective_last_line + 1;
544 while next_non_blank_idx < ctx.lines.len() {
545 let check_line = &ctx.lines[next_non_blank_idx];
546 let check_trimmed = check_line.content(ctx.content).trim();
547 if check_line.is_blank {
548 next_non_blank_idx += 1;
549 } else if check_line.in_html_comment
550 || check_line.in_mdx_comment
551 || is_blank_or_comment_only(check_line.content(ctx.content))
552 {
553 next_non_blank_idx += 1;
555 } else if is_pandoc && (pandoc::is_div_open(check_trimmed) || pandoc::is_div_close(check_trimmed)) {
556 next_non_blank_idx += 1;
558 } else {
559 break;
560 }
561 }
562
563 if next_non_blank_idx >= ctx.lines.len() {
565 continue;
567 }
568
569 let next_line_is_special = {
571 let next_line = &ctx.lines[next_non_blank_idx];
572 let next_trimmed = next_line.content(ctx.content).trim();
573
574 let is_code_fence = (next_trimmed.starts_with("```") || next_trimmed.starts_with("~~~"))
576 && (next_trimmed.len() == 3
577 || (next_trimmed.len() > 3
578 && next_trimmed
579 .chars()
580 .nth(3)
581 .is_some_and(|c| c.is_whitespace() || c.is_alphabetic())));
582
583 let is_list_item = next_line.list_item.is_some() || starts_with_list_marker(next_trimmed);
590
591 is_code_fence || is_list_item
592 };
593
594 if !next_line_is_special && let Some(required) = required_below_count {
596 let mut blank_lines_below = 0;
598 for k in (effective_last_line + 1)..next_non_blank_idx {
599 if ctx.lines[k].is_blank || is_blank_or_comment_only(ctx.lines[k].content(ctx.content)) {
601 blank_lines_below += 1;
602 }
603 }
604
605 if blank_lines_below < required {
606 let needed_blanks = required - blank_lines_below;
607 heading_violations.push((line_num, first_idx, "below", needed_blanks, heading_level));
608 }
609 }
610 }
611 }
612
613 for (heading_line, first_line, position, needed_blanks, heading_level) in heading_violations {
615 let line_info = &ctx.lines[heading_line];
616
617 let (start_line, start_col, end_line, end_col) =
619 calculate_heading_range(first_line + 1, heading_line + 1, line_info.content(ctx.content));
620
621 let (message, insertion_point) = match position {
628 "above" => {
629 let Some(required_above_count) =
630 self.config.lines_above.get_for_level(heading_level).required_count()
631 else {
632 continue;
633 };
634 (
635 format!(
636 "Expected {} blank {} above heading",
637 required_above_count,
638 if required_above_count == 1 { "line" } else { "lines" }
639 ),
640 first_line, )
642 }
643 "below" => {
644 let Some(required_below_count) =
645 self.config.lines_below.get_for_level(heading_level).required_count()
646 else {
647 continue;
648 };
649 let insert_after = if line_info.heading.as_ref().is_some_and(|h| {
651 matches!(
652 h.style,
653 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
654 )
655 }) {
656 heading_line + 2
657 } else {
658 heading_line + 1
659 };
660
661 (
662 format!(
663 "Expected {} blank {} below heading",
664 required_below_count,
665 if required_below_count == 1 { "line" } else { "lines" }
666 ),
667 insert_after,
668 )
669 }
670 _ => continue,
671 };
672
673 let byte_range = if insertion_point == 0 && position == "above" {
675 0..0
677 } else if position == "above" && insertion_point > 0 {
678 ctx.lines[insertion_point].byte_offset..ctx.lines[insertion_point].byte_offset
680 } else if position == "below" && insertion_point - 1 < ctx.lines.len() {
681 let line_idx = insertion_point - 1;
683 let line_end_offset = if line_idx + 1 < ctx.lines.len() {
684 ctx.lines[line_idx + 1].byte_offset
685 } else {
686 ctx.content.len()
687 };
688 line_end_offset..line_end_offset
689 } else {
690 let content_len = ctx.content.len();
692 content_len..content_len
693 };
694
695 result.push(LintWarning {
696 rule_name: Some(self.name().to_string()),
697 message,
698 line: start_line,
699 column: start_col,
700 end_line,
701 end_column: end_col,
702 severity: Severity::Warning,
703 fix: Some(Fix::new(byte_range, line_ending.repeat(needed_blanks))),
704 });
705 }
706
707 Ok(result)
708 }
709
710 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
711 if ctx.content.is_empty() {
712 return Ok(ctx.content.to_string());
713 }
714
715 let fixed = self.fix_content(ctx);
717
718 Ok(fixed)
719 }
720
721 fn category(&self) -> RuleCategory {
723 RuleCategory::Heading
724 }
725
726 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
728 if ctx.content.is_empty() || !ctx.likely_has_headings() {
730 return true;
731 }
732 ctx.lines.iter().all(|line| line.heading.is_none())
734 }
735
736 fn as_any(&self) -> &dyn std::any::Any {
737 self
738 }
739
740 crate::impl_rule_config_methods!(MD022Config);
741
742 fn polymorphic_config_keys(&self) -> &'static [&'static str] {
743 &["lines-above", "lines-below"]
748 }
749}
750
751#[cfg(test)]
752mod tests {
753 use super::*;
754 use crate::lint_context::LintContext;
755
756 #[test]
757 fn test_valid_headings() {
758 let rule = MD022BlanksAroundHeadings::default();
759 let content = "\n# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.\n";
760 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
761 let result = rule.check(&ctx).unwrap();
762 assert!(result.is_empty());
763 }
764
765 #[test]
766 fn test_missing_blank_above() {
767 let rule = MD022BlanksAroundHeadings::default();
768 let content = "# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.\n";
769 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
770 let result = rule.check(&ctx).unwrap();
771 assert_eq!(result.len(), 0); let fixed = rule.fix(&ctx).unwrap();
774
775 assert!(fixed.contains("# Heading 1"));
778 assert!(fixed.contains("Some content."));
779 assert!(fixed.contains("## Heading 2"));
780 assert!(fixed.contains("More content."));
781 }
782
783 #[test]
784 fn test_missing_blank_below() {
785 let rule = MD022BlanksAroundHeadings::default();
786 let content = "\n# Heading 1\nSome content.\n\n## Heading 2\n\nMore content.\n";
787 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
788 let result = rule.check(&ctx).unwrap();
789 assert_eq!(result.len(), 1);
790 assert_eq!(result[0].line, 2);
791
792 let fixed = rule.fix(&ctx).unwrap();
794 assert!(fixed.contains("# Heading 1\n\nSome content"));
795 }
796
797 #[test]
798 fn test_missing_blank_above_and_below() {
799 let rule = MD022BlanksAroundHeadings::default();
800 let content = "# Heading 1\nSome content.\n## Heading 2\nMore content.\n";
801 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
802 let result = rule.check(&ctx).unwrap();
803 assert_eq!(result.len(), 3); let fixed = rule.fix(&ctx).unwrap();
807 assert!(fixed.contains("# Heading 1\n\nSome content"));
808 assert!(fixed.contains("Some content.\n\n## Heading 2"));
809 assert!(fixed.contains("## Heading 2\n\nMore content"));
810 }
811
812 #[test]
813 fn test_fix_headings() {
814 let rule = MD022BlanksAroundHeadings::default();
815 let content = "# Heading 1\nSome content.\n## Heading 2\nMore content.";
816 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
817 let result = rule.fix(&ctx).unwrap();
818
819 let expected = "# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.";
820 assert_eq!(result, expected);
821 }
822
823 #[test]
824 fn test_consecutive_headings_pattern() {
825 let rule = MD022BlanksAroundHeadings::default();
826 let content = "# Heading 1\n## Heading 2\n### Heading 3";
827 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
828 let result = rule.fix(&ctx).unwrap();
829
830 let lines: Vec<&str> = result.lines().collect();
832 assert!(!lines.is_empty());
833
834 let h1_pos = lines.iter().position(|&l| l == "# Heading 1").unwrap();
836 let h2_pos = lines.iter().position(|&l| l == "## Heading 2").unwrap();
837 let h3_pos = lines.iter().position(|&l| l == "### Heading 3").unwrap();
838
839 assert!(
841 h2_pos > h1_pos + 1,
842 "Should have at least one blank line after first heading"
843 );
844 assert!(
845 h3_pos > h2_pos + 1,
846 "Should have at least one blank line after second heading"
847 );
848
849 assert!(lines[h1_pos + 1].trim().is_empty(), "Line after h1 should be blank");
851
852 assert!(lines[h2_pos + 1].trim().is_empty(), "Line after h2 should be blank");
854 }
855
856 #[test]
857 fn test_blanks_around_setext_headings() {
858 let rule = MD022BlanksAroundHeadings::default();
859 let content = "Heading 1\n=========\nSome content.\nHeading 2\n---------\nMore content.";
860 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
861 let result = rule.fix(&ctx).unwrap();
862
863 let lines: Vec<&str> = result.lines().collect();
865
866 assert!(result.contains("Heading 1"));
868 assert!(result.contains("========="));
869 assert!(result.contains("Some content."));
870 assert!(result.contains("Heading 2"));
871 assert!(result.contains("---------"));
872 assert!(result.contains("More content."));
873
874 let heading1_marker_idx = lines.iter().position(|&l| l == "=========").unwrap();
876 let some_content_idx = lines.iter().position(|&l| l == "Some content.").unwrap();
877 assert!(
878 some_content_idx > heading1_marker_idx + 1,
879 "Should have a blank line after the first heading"
880 );
881
882 let heading2_marker_idx = lines.iter().position(|&l| l == "---------").unwrap();
883 let more_content_idx = lines.iter().position(|&l| l == "More content.").unwrap();
884 assert!(
885 more_content_idx > heading2_marker_idx + 1,
886 "Should have a blank line after the second heading"
887 );
888
889 let fixed_ctx = LintContext::new(&result, crate::config::MarkdownFlavor::Standard, None);
891 let fixed_warnings = rule.check(&fixed_ctx).unwrap();
892 assert!(fixed_warnings.is_empty(), "Fixed content should have no warnings");
893 }
894
895 #[test]
896 fn test_fix_specific_blank_line_cases() {
897 let rule = MD022BlanksAroundHeadings::default();
898
899 let content1 = "# Heading 1\n## Heading 2\n### Heading 3";
901 let ctx1 = LintContext::new(content1, crate::config::MarkdownFlavor::Standard, None);
902 let result1 = rule.fix(&ctx1).unwrap();
903 assert!(result1.contains("# Heading 1"));
905 assert!(result1.contains("## Heading 2"));
906 assert!(result1.contains("### Heading 3"));
907 let lines: Vec<&str> = result1.lines().collect();
909 let h1_pos = lines.iter().position(|&l| l == "# Heading 1").unwrap();
910 let h2_pos = lines.iter().position(|&l| l == "## Heading 2").unwrap();
911 assert!(lines[h1_pos + 1].trim().is_empty(), "Should have a blank line after h1");
912 assert!(lines[h2_pos + 1].trim().is_empty(), "Should have a blank line after h2");
913
914 let content2 = "# Heading 1\nContent under heading 1\n## Heading 2";
916 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
917 let result2 = rule.fix(&ctx2).unwrap();
918 assert!(result2.contains("# Heading 1"));
920 assert!(result2.contains("Content under heading 1"));
921 assert!(result2.contains("## Heading 2"));
922 let lines2: Vec<&str> = result2.lines().collect();
924 let h1_pos2 = lines2.iter().position(|&l| l == "# Heading 1").unwrap();
925 let _content_pos = lines2.iter().position(|&l| l == "Content under heading 1").unwrap();
926 assert!(
927 lines2[h1_pos2 + 1].trim().is_empty(),
928 "Should have a blank line after heading 1"
929 );
930
931 let content3 = "# Heading 1\n\n\n## Heading 2\n\n\n### Heading 3\n\nContent";
933 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
934 let result3 = rule.fix(&ctx3).unwrap();
935 assert!(result3.contains("# Heading 1"));
937 assert!(result3.contains("## Heading 2"));
938 assert!(result3.contains("### Heading 3"));
939 assert!(result3.contains("Content"));
940 }
941
942 #[test]
943 fn test_fix_preserves_existing_blank_lines() {
944 let rule = MD022BlanksAroundHeadings::new();
945 let content = "# Title
946
947## Section 1
948
949Content here.
950
951## Section 2
952
953More content.
954### Missing Blank Above
955
956Even more content.
957
958## Section 3
959
960Final content.";
961
962 let expected = "# Title
963
964## Section 1
965
966Content here.
967
968## Section 2
969
970More content.
971
972### Missing Blank Above
973
974Even more content.
975
976## Section 3
977
978Final content.";
979
980 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
981 let result = rule.fix_content(&ctx);
982 assert_eq!(
983 result, expected,
984 "Fix should only add missing blank lines, never remove existing ones"
985 );
986 }
987
988 #[test]
989 fn test_fix_preserves_trailing_newline() {
990 let rule = MD022BlanksAroundHeadings::new();
991
992 let content_with_newline = "# Title\nContent here.\n";
994 let ctx = LintContext::new(content_with_newline, crate::config::MarkdownFlavor::Standard, None);
995 let result = rule.fix(&ctx).unwrap();
996 assert!(result.ends_with('\n'), "Should preserve trailing newline");
997
998 let content_without_newline = "# Title\nContent here.";
1000 let ctx = LintContext::new(content_without_newline, crate::config::MarkdownFlavor::Standard, None);
1001 let result = rule.fix(&ctx).unwrap();
1002 assert!(
1003 !result.ends_with('\n'),
1004 "Should not add trailing newline if original didn't have one"
1005 );
1006 }
1007
1008 #[test]
1009 fn test_fix_does_not_add_blank_lines_before_lists() {
1010 let rule = MD022BlanksAroundHeadings::new();
1011 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.";
1012
1013 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.";
1014
1015 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1016 let result = rule.fix_content(&ctx);
1017 assert_eq!(result, expected, "Fix should not add blank lines before lists");
1018 }
1019
1020 #[test]
1021 fn test_fix_idempotent_when_heading_follows_a_list_and_precedes_a_marker() {
1022 let rule = MD022BlanksAroundHeadings::default();
1028 let content = "- a\n# H\n2. ";
1029 for flavor in [
1030 crate::config::MarkdownFlavor::Standard,
1031 crate::config::MarkdownFlavor::MkDocs,
1032 crate::config::MarkdownFlavor::MDX,
1033 ] {
1034 let ctx1 = LintContext::new(content, flavor, None);
1035 let fixed1 = rule.fix(&ctx1).unwrap();
1036 let ctx2 = LintContext::new(&fixed1, flavor, None);
1037 let fixed2 = rule.fix(&ctx2).unwrap();
1038 assert_eq!(fixed1, fixed2, "MD022 fix must be idempotent (flavor={flavor:?})");
1039 }
1040 }
1041
1042 #[test]
1043 fn test_thematic_break_below_heading_is_not_a_list_item() {
1044 let rule = MD022BlanksAroundHeadings::default();
1051 for marker in [
1052 "* * *",
1053 "- - -",
1054 "_ _ _",
1055 "***",
1056 "---",
1057 "___",
1058 "- --",
1059 "* ** *",
1060 "---- ----",
1061 ] {
1062 let content = format!("text\n\n# Heading\n{marker}\nafter\n");
1063 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1064 let result = rule.check(&ctx).unwrap();
1065 assert_eq!(
1066 result.len(),
1067 1,
1068 "a heading above `{marker}` needs a blank line below it, got {result:?}"
1069 );
1070 assert_eq!(
1071 rule.fix(&ctx).unwrap(),
1072 format!("text\n\n# Heading\n\n{marker}\nafter\n"),
1073 "fix must insert the blank line below the heading for `{marker}`"
1074 );
1075 }
1076 }
1077
1078 #[test]
1079 fn test_list_item_below_heading_is_still_exempt() {
1080 let rule = MD022BlanksAroundHeadings::default();
1083 for item in ["- item", "* item", "+ item", "1. item", "+ + +"] {
1084 let content = format!("text\n\n# Heading\n{item}\n");
1085 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1086 assert!(
1087 rule.check(&ctx).unwrap().is_empty(),
1088 "a list below a heading stays exempt, but `{item}` was reported"
1089 );
1090 assert_eq!(rule.fix(&ctx).unwrap(), content, "`{item}` must not be rewritten");
1091 }
1092 }
1093
1094 #[test]
1095 fn test_per_level_configuration_no_blank_above_h1() {
1096 use md022_config::HeadingLevelConfig;
1097
1098 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1100 lines_above: HeadingLevelConfig::per_level([0, 1, 1, 1, 1, 1]),
1101 lines_below: HeadingLevelConfig::scalar(1),
1102 allowed_at_start: false, });
1104
1105 let content = "Some text\n# Heading 1\n\nMore text";
1107 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1108 let warnings = rule.check(&ctx).unwrap();
1109 assert_eq!(warnings.len(), 0, "H1 without blank above should not trigger warning");
1110
1111 let content = "Some text\n## Heading 2\n\nMore text";
1113 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1114 let warnings = rule.check(&ctx).unwrap();
1115 assert_eq!(warnings.len(), 1, "H2 without blank above should trigger warning");
1116 assert!(warnings[0].message.contains("above"));
1117 }
1118
1119 #[test]
1120 fn test_unlimited_above_with_limited_below_does_not_panic() {
1121 use md022_config::{HeadingBlankRequirement, HeadingLevelConfig};
1122
1123 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1127 lines_above: HeadingLevelConfig::scalar_requirement(HeadingBlankRequirement::Unlimited),
1128 lines_below: HeadingLevelConfig::scalar(1),
1129 allowed_at_start: false,
1130 });
1131
1132 let content = "# Title\n\nText\n## Banana\nText\n";
1134 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1135
1136 let warnings = rule.check(&ctx).expect("check must not fail");
1137
1138 assert!(
1139 warnings.iter().any(|w| w.message.contains("below")),
1140 "expected a 'below' violation, got: {:?}",
1141 warnings.iter().map(|w| &w.message).collect::<Vec<_>>()
1142 );
1143 assert!(
1144 !warnings.iter().any(|w| w.message.contains("above")),
1145 "an unlimited 'above' requirement must never report: {:?}",
1146 warnings.iter().map(|w| &w.message).collect::<Vec<_>>()
1147 );
1148 }
1149
1150 #[test]
1151 fn test_unlimited_below_with_limited_above_does_not_panic() {
1152 use md022_config::{HeadingBlankRequirement, HeadingLevelConfig};
1153
1154 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1155 lines_above: HeadingLevelConfig::scalar(1),
1156 lines_below: HeadingLevelConfig::scalar_requirement(HeadingBlankRequirement::Unlimited),
1157 allowed_at_start: false,
1158 });
1159
1160 let content = "# Title\n\nText\n## Banana\n\nText\n";
1162 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1163
1164 let warnings = rule.check(&ctx).expect("check must not fail");
1165
1166 assert!(
1167 warnings.iter().any(|w| w.message.contains("above")),
1168 "expected an 'above' violation, got: {:?}",
1169 warnings.iter().map(|w| &w.message).collect::<Vec<_>>()
1170 );
1171 assert!(
1172 !warnings.iter().any(|w| w.message.contains("below")),
1173 "an unlimited 'below' requirement must never report: {:?}",
1174 warnings.iter().map(|w| &w.message).collect::<Vec<_>>()
1175 );
1176 }
1177
1178 #[test]
1179 fn test_per_level_configuration_different_requirements() {
1180 use md022_config::HeadingLevelConfig;
1181
1182 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1184 lines_above: HeadingLevelConfig::per_level([0, 1, 1, 2, 2, 2]),
1185 lines_below: HeadingLevelConfig::scalar(1),
1186 allowed_at_start: false,
1187 });
1188
1189 let content = "Text\n# H1\n\nText\n\n## H2\n\nText\n\n### H3\n\nText\n\n\n#### H4\n\nText";
1190 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1191 let warnings = rule.check(&ctx).unwrap();
1192
1193 assert_eq!(
1195 warnings.len(),
1196 0,
1197 "All headings should satisfy level-specific requirements"
1198 );
1199 }
1200
1201 #[test]
1202 fn test_per_level_configuration_violations() {
1203 use md022_config::HeadingLevelConfig;
1204
1205 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1207 lines_above: HeadingLevelConfig::per_level([1, 1, 1, 2, 1, 1]),
1208 lines_below: HeadingLevelConfig::scalar(1),
1209 allowed_at_start: false,
1210 });
1211
1212 let content = "Text\n\n#### Heading 4\n\nMore text";
1214 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1215 let warnings = rule.check(&ctx).unwrap();
1216
1217 assert_eq!(warnings.len(), 1, "H4 with insufficient blanks should trigger warning");
1218 assert!(warnings[0].message.contains("2 blank lines above"));
1219 }
1220
1221 #[test]
1222 fn test_per_level_fix_different_levels() {
1223 use md022_config::HeadingLevelConfig;
1224
1225 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1227 lines_above: HeadingLevelConfig::per_level([0, 1, 2, 2, 2, 2]),
1228 lines_below: HeadingLevelConfig::scalar(1),
1229 allowed_at_start: false,
1230 });
1231
1232 let content = "Text\n# H1\nContent\n## H2\nContent\n### H3\nContent";
1233 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1234 let fixed = rule.fix(&ctx).unwrap();
1235
1236 assert!(fixed.contains("Text\n# H1\n\nContent"));
1238 assert!(fixed.contains("Content\n\n## H2\n\nContent"));
1239 assert!(fixed.contains("Content\n\n\n### H3\n\nContent"));
1240 }
1241
1242 #[test]
1243 fn test_per_level_below_configuration() {
1244 use md022_config::HeadingLevelConfig;
1245
1246 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1248 lines_above: HeadingLevelConfig::scalar(1),
1249 lines_below: HeadingLevelConfig::per_level([2, 1, 1, 1, 1, 1]), allowed_at_start: true,
1251 });
1252
1253 let content = "# Heading 1\n\nSome text";
1255 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1256 let warnings = rule.check(&ctx).unwrap();
1257
1258 assert_eq!(
1259 warnings.len(),
1260 1,
1261 "H1 with insufficient blanks below should trigger warning"
1262 );
1263 assert!(warnings[0].message.contains("2 blank lines below"));
1264 }
1265
1266 #[test]
1267 fn test_scalar_configuration_still_works() {
1268 use md022_config::HeadingLevelConfig;
1269
1270 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1272 lines_above: HeadingLevelConfig::scalar(2),
1273 lines_below: HeadingLevelConfig::scalar(2),
1274 allowed_at_start: false,
1275 });
1276
1277 let content = "Text\n# H1\nContent\n## H2\nContent";
1278 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1279 let warnings = rule.check(&ctx).unwrap();
1280
1281 assert!(!warnings.is_empty(), "Should have violations for insufficient blanks");
1283 }
1284
1285 #[test]
1286 fn test_unlimited_configuration_skips_requirements() {
1287 use md022_config::{HeadingBlankRequirement, HeadingLevelConfig};
1288
1289 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1291 lines_above: HeadingLevelConfig::per_level_requirements([
1292 HeadingBlankRequirement::unlimited(),
1293 HeadingBlankRequirement::limited(1),
1294 HeadingBlankRequirement::limited(1),
1295 HeadingBlankRequirement::limited(1),
1296 HeadingBlankRequirement::limited(1),
1297 HeadingBlankRequirement::limited(1),
1298 ]),
1299 lines_below: HeadingLevelConfig::per_level_requirements([
1300 HeadingBlankRequirement::unlimited(),
1301 HeadingBlankRequirement::limited(1),
1302 HeadingBlankRequirement::limited(1),
1303 HeadingBlankRequirement::limited(1),
1304 HeadingBlankRequirement::limited(1),
1305 HeadingBlankRequirement::limited(1),
1306 ]),
1307 allowed_at_start: false,
1308 });
1309
1310 let content = "# H1\nParagraph\n## H2\nParagraph";
1311 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1312 let warnings = rule.check(&ctx).unwrap();
1313
1314 assert_eq!(warnings.len(), 2, "Only non-unlimited headings should warn");
1316 assert!(
1317 warnings.iter().all(|w| w.line >= 3),
1318 "Warnings should target later headings"
1319 );
1320
1321 let fixed = rule.fix(&ctx).unwrap();
1323 assert!(
1324 fixed.starts_with("# H1\nParagraph\n\n## H2"),
1325 "H1 should remain unchanged"
1326 );
1327 }
1328
1329 #[test]
1330 fn test_html_comment_transparency() {
1331 let rule = MD022BlanksAroundHeadings::default();
1335
1336 let content = "Some content\n\n<!-- markdownlint-disable-next-line MD001 -->\n#### Heading";
1339 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1340 let warnings = rule.check(&ctx).unwrap();
1341 assert!(
1342 warnings.is_empty(),
1343 "HTML comment is transparent - blank line above it counts for heading"
1344 );
1345
1346 let content_multiline = "Some content\n\n<!-- This is a\nmulti-line comment -->\n#### Heading";
1348 let ctx_multiline = LintContext::new(content_multiline, crate::config::MarkdownFlavor::Standard, None);
1349 let warnings_multiline = rule.check(&ctx_multiline).unwrap();
1350 assert!(
1351 warnings_multiline.is_empty(),
1352 "Multi-line HTML comment is also transparent"
1353 );
1354 }
1355
1356 #[test]
1357 fn test_frontmatter_transparency() {
1358 let rule = MD022BlanksAroundHeadings::default();
1361
1362 let content = "---\ntitle: Test\n---\n# First heading";
1364 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1365 let warnings = rule.check(&ctx).unwrap();
1366 assert!(
1367 warnings.is_empty(),
1368 "Frontmatter is transparent - heading can appear immediately after"
1369 );
1370
1371 let content_with_blank = "---\ntitle: Test\n---\n\n# First heading";
1373 let ctx_with_blank = LintContext::new(content_with_blank, crate::config::MarkdownFlavor::Standard, None);
1374 let warnings_with_blank = rule.check(&ctx_with_blank).unwrap();
1375 assert!(
1376 warnings_with_blank.is_empty(),
1377 "Heading with blank line after frontmatter should also be valid"
1378 );
1379
1380 let content_toml = "+++\ntitle = \"Test\"\n+++\n# First heading";
1382 let ctx_toml = LintContext::new(content_toml, crate::config::MarkdownFlavor::Standard, None);
1383 let warnings_toml = rule.check(&ctx_toml).unwrap();
1384 assert!(
1385 warnings_toml.is_empty(),
1386 "TOML frontmatter is also transparent for MD022"
1387 );
1388 }
1389
1390 #[test]
1391 fn test_horizontal_rule_not_treated_as_frontmatter() {
1392 let rule = MD022BlanksAroundHeadings::default();
1395
1396 let content = "Some content\n\n---\n# Heading after HR";
1398 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1399 let warnings = rule.check(&ctx).unwrap();
1400 assert!(
1401 !warnings.is_empty(),
1402 "Heading after horizontal rule without blank line SHOULD trigger MD022"
1403 );
1404 assert!(
1405 warnings.iter().any(|w| w.line == 4),
1406 "Warning should be on line 4 (the heading line)"
1407 );
1408
1409 let content_with_blank = "Some content\n\n---\n\n# Heading after HR";
1411 let ctx_with_blank = LintContext::new(content_with_blank, crate::config::MarkdownFlavor::Standard, None);
1412 let warnings_with_blank = rule.check(&ctx_with_blank).unwrap();
1413 assert!(
1414 warnings_with_blank.is_empty(),
1415 "Heading with blank line after HR should not trigger MD022"
1416 );
1417
1418 let content_hr_start = "---\n# Heading";
1420 let ctx_hr_start = LintContext::new(content_hr_start, crate::config::MarkdownFlavor::Standard, None);
1421 let warnings_hr_start = rule.check(&ctx_hr_start).unwrap();
1422 assert!(
1423 !warnings_hr_start.is_empty(),
1424 "Heading after HR at document start SHOULD trigger MD022"
1425 );
1426
1427 let content_multi_hr = "Content\n\n---\n\n---\n# Heading";
1429 let ctx_multi_hr = LintContext::new(content_multi_hr, crate::config::MarkdownFlavor::Standard, None);
1430 let warnings_multi_hr = rule.check(&ctx_multi_hr).unwrap();
1431 assert!(
1432 !warnings_multi_hr.is_empty(),
1433 "Heading after multiple HRs without blank line SHOULD trigger MD022"
1434 );
1435 }
1436
1437 #[test]
1438 fn test_all_hr_styles_require_blank_before_heading() {
1439 let rule = MD022BlanksAroundHeadings::default();
1441
1442 let hr_styles = [
1444 "---", "***", "___", "- - -", "* * *", "_ _ _", "----", "****", "____", "- - - -",
1445 "- - -", " ---", " ---", ];
1449
1450 for hr in hr_styles {
1451 let content = format!("Content\n\n{hr}\n# Heading");
1452 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1453 let warnings = rule.check(&ctx).unwrap();
1454 assert!(
1455 !warnings.is_empty(),
1456 "HR style '{hr}' followed by heading should trigger MD022"
1457 );
1458 }
1459 }
1460
1461 #[test]
1462 fn test_setext_heading_after_hr() {
1463 let rule = MD022BlanksAroundHeadings::default();
1465
1466 let content = "Content\n\n---\nHeading\n======";
1468 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1469 let warnings = rule.check(&ctx).unwrap();
1470 assert!(
1471 !warnings.is_empty(),
1472 "Setext heading after HR without blank should trigger MD022"
1473 );
1474
1475 let content_h2 = "Content\n\n---\nHeading\n------";
1477 let ctx_h2 = LintContext::new(content_h2, crate::config::MarkdownFlavor::Standard, None);
1478 let warnings_h2 = rule.check(&ctx_h2).unwrap();
1479 assert!(
1480 !warnings_h2.is_empty(),
1481 "Setext h2 after HR without blank should trigger MD022"
1482 );
1483
1484 let content_ok = "Content\n\n---\n\nHeading\n======";
1486 let ctx_ok = LintContext::new(content_ok, crate::config::MarkdownFlavor::Standard, None);
1487 let warnings_ok = rule.check(&ctx_ok).unwrap();
1488 assert!(
1489 warnings_ok.is_empty(),
1490 "Setext heading with blank after HR should not warn"
1491 );
1492 }
1493
1494 #[test]
1495 fn test_hr_in_code_block_not_treated_as_hr() {
1496 let rule = MD022BlanksAroundHeadings::default();
1498
1499 let content = "```\n---\n```\n# Heading";
1502 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1503 let warnings = rule.check(&ctx).unwrap();
1504 assert!(!warnings.is_empty(), "Heading after code block still needs blank line");
1507
1508 let content_ok = "```\n---\n```\n\n# Heading";
1510 let ctx_ok = LintContext::new(content_ok, crate::config::MarkdownFlavor::Standard, None);
1511 let warnings_ok = rule.check(&ctx_ok).unwrap();
1512 assert!(
1513 warnings_ok.is_empty(),
1514 "Heading with blank after code block should not warn"
1515 );
1516 }
1517
1518 #[test]
1519 fn test_hr_in_html_comment_not_treated_as_hr() {
1520 let rule = MD022BlanksAroundHeadings::default();
1522
1523 let content = "<!-- \n---\n -->\n# Heading";
1525 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1526 let warnings = rule.check(&ctx).unwrap();
1527 assert!(
1529 warnings.is_empty(),
1530 "HR inside HTML comment should be ignored - heading after comment is OK"
1531 );
1532 }
1533
1534 #[test]
1535 fn test_invalid_hr_not_triggering() {
1536 let rule = MD022BlanksAroundHeadings::default();
1538
1539 let invalid_hrs = [
1540 " ---", "\t---", "--", "**", "__", "-*-", "---a", "a---", ];
1549
1550 for invalid in invalid_hrs {
1551 let content = format!("Content\n\n{invalid}\n# Heading");
1554 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1555 let _ = rule.check(&ctx);
1558 }
1559 }
1560
1561 #[test]
1562 fn test_frontmatter_vs_horizontal_rule_distinction() {
1563 let rule = MD022BlanksAroundHeadings::default();
1565
1566 let content = "---\ntitle: Test\n---\n\nSome content\n\n---\n# Heading after HR";
1569 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1570 let warnings = rule.check(&ctx).unwrap();
1571 assert!(
1572 !warnings.is_empty(),
1573 "HR after frontmatter content should still require blank line before heading"
1574 );
1575
1576 let content_ok = "---\ntitle: Test\n---\n\nSome content\n\n---\n\n# Heading after HR";
1578 let ctx_ok = LintContext::new(content_ok, crate::config::MarkdownFlavor::Standard, None);
1579 let warnings_ok = rule.check(&ctx_ok).unwrap();
1580 assert!(
1581 warnings_ok.is_empty(),
1582 "HR with blank line before heading should not warn"
1583 );
1584 }
1585
1586 #[test]
1589 fn test_kramdown_ial_after_heading_no_warning() {
1590 let rule = MD022BlanksAroundHeadings::default();
1592 let content = "## Table of Contents\n{: .hhc-toc-heading}\n\nSome content here.";
1593 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1594 let warnings = rule.check(&ctx).unwrap();
1595
1596 assert!(
1597 warnings.is_empty(),
1598 "IAL after heading should not require blank line between them: {warnings:?}"
1599 );
1600 }
1601
1602 #[test]
1603 fn test_kramdown_ial_with_class() {
1604 let rule = MD022BlanksAroundHeadings::default();
1605 let content = "# Heading\n{:.highlight}\n\nContent.";
1606 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1607 let warnings = rule.check(&ctx).unwrap();
1608
1609 assert!(warnings.is_empty(), "IAL with class should be part of heading");
1610 }
1611
1612 #[test]
1613 fn test_kramdown_ial_with_id() {
1614 let rule = MD022BlanksAroundHeadings::default();
1615 let content = "# Heading\n{:#custom-id}\n\nContent.";
1616 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1617 let warnings = rule.check(&ctx).unwrap();
1618
1619 assert!(warnings.is_empty(), "IAL with id should be part of heading");
1620 }
1621
1622 #[test]
1623 fn test_kramdown_ial_with_multiple_attributes() {
1624 let rule = MD022BlanksAroundHeadings::default();
1625 let content = "# Heading\n{: .class #id style=\"color: red\"}\n\nContent.";
1626 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1627 let warnings = rule.check(&ctx).unwrap();
1628
1629 assert!(
1630 warnings.is_empty(),
1631 "IAL with multiple attributes should be part of heading"
1632 );
1633 }
1634
1635 #[test]
1636 fn test_kramdown_ial_missing_blank_after() {
1637 let rule = MD022BlanksAroundHeadings::default();
1639 let content = "# Heading\n{:.class}\nContent without blank.";
1640 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1641 let warnings = rule.check(&ctx).unwrap();
1642
1643 assert_eq!(
1644 warnings.len(),
1645 1,
1646 "Should warn about missing blank after IAL (part of heading)"
1647 );
1648 assert!(warnings[0].message.contains("below"));
1649 }
1650
1651 #[test]
1652 fn test_kramdown_ial_before_heading_transparent() {
1653 let rule = MD022BlanksAroundHeadings::default();
1655 let content = "Content.\n\n{:.preclass}\n## Heading\n\nMore content.";
1656 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1657 let warnings = rule.check(&ctx).unwrap();
1658
1659 assert!(
1660 warnings.is_empty(),
1661 "IAL before heading should be transparent for blank line count"
1662 );
1663 }
1664
1665 #[test]
1666 fn test_kramdown_ial_setext_heading() {
1667 let rule = MD022BlanksAroundHeadings::default();
1668 let content = "Heading\n=======\n{:.setext-class}\n\nContent.";
1669 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1670 let warnings = rule.check(&ctx).unwrap();
1671
1672 assert!(
1673 warnings.is_empty(),
1674 "IAL after Setext heading should be part of heading"
1675 );
1676 }
1677
1678 #[test]
1679 fn test_kramdown_ial_fix_preserves_ial() {
1680 let rule = MD022BlanksAroundHeadings::default();
1681 let content = "Content.\n# Heading\n{:.class}\nMore content.";
1682 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1683 let fixed = rule.fix(&ctx).unwrap();
1684
1685 assert!(
1687 fixed.contains("# Heading\n{:.class}"),
1688 "IAL should stay attached to heading"
1689 );
1690 assert!(fixed.contains("{:.class}\n\nMore"), "Should add blank after IAL");
1691 }
1692
1693 #[test]
1694 fn test_kramdown_ial_fix_does_not_separate() {
1695 let rule = MD022BlanksAroundHeadings::default();
1696 let content = "# Heading\n{:.class}\nContent.";
1697 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1698 let fixed = rule.fix(&ctx).unwrap();
1699
1700 assert!(
1702 !fixed.contains("# Heading\n\n{:.class}"),
1703 "Should not add blank between heading and IAL"
1704 );
1705 assert!(fixed.contains("# Heading\n{:.class}"), "IAL should remain attached");
1706 }
1707
1708 #[test]
1709 fn test_kramdown_multiple_ial_lines() {
1710 let rule = MD022BlanksAroundHeadings::default();
1712 let content = "# Heading\n{:.class1}\n{:#id}\n\nContent.";
1713 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1714 let warnings = rule.check(&ctx).unwrap();
1715
1716 assert!(
1719 warnings.is_empty(),
1720 "Multiple consecutive IALs should be part of heading"
1721 );
1722 }
1723
1724 #[test]
1725 fn test_kramdown_ial_with_blank_line_not_attached() {
1726 let rule = MD022BlanksAroundHeadings::default();
1728 let content = "# Heading\n\n{:.class}\nContent.";
1729 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1730 let warnings = rule.check(&ctx).unwrap();
1731
1732 assert!(warnings.is_empty(), "Blank line separates heading from IAL");
1736 }
1737
1738 #[test]
1739 fn test_not_kramdown_ial_regular_braces() {
1740 let rule = MD022BlanksAroundHeadings::default();
1742 let content = "# Heading\n{not an ial}\n\nContent.";
1743 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1744 let warnings = rule.check(&ctx).unwrap();
1745
1746 assert_eq!(
1748 warnings.len(),
1749 1,
1750 "Non-IAL braces should be regular content requiring blank"
1751 );
1752 }
1753
1754 #[test]
1755 fn test_kramdown_ial_at_document_end() {
1756 let rule = MD022BlanksAroundHeadings::default();
1757 let content = "# Heading\n{:.class}";
1758 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1759 let warnings = rule.check(&ctx).unwrap();
1760
1761 assert!(warnings.is_empty(), "IAL at document end needs no blank after");
1763 }
1764
1765 #[test]
1766 fn test_kramdown_ial_followed_by_code_fence() {
1767 let rule = MD022BlanksAroundHeadings::default();
1768 let content = "# Heading\n{:.class}\n```\ncode\n```";
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 code fence");
1774 }
1775
1776 #[test]
1777 fn test_kramdown_ial_followed_by_list() {
1778 let rule = MD022BlanksAroundHeadings::default();
1779 let content = "# Heading\n{:.class}\n- List item";
1780 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1781 let warnings = rule.check(&ctx).unwrap();
1782
1783 assert!(warnings.is_empty(), "No blank needed between IAL and list");
1785 }
1786
1787 #[test]
1788 fn test_kramdown_ial_fix_idempotent() {
1789 let rule = MD022BlanksAroundHeadings::default();
1790 let content = "# Heading\n{:.class}\nContent.";
1791 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1792
1793 let fixed_once = rule.fix(&ctx).unwrap();
1794 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
1795 let fixed_twice = rule.fix(&ctx2).unwrap();
1796
1797 assert_eq!(fixed_once, fixed_twice, "Fix should be idempotent");
1798 }
1799
1800 #[test]
1801 fn test_kramdown_ial_whitespace_line_between_not_attached() {
1802 let rule = MD022BlanksAroundHeadings::default();
1805 let content = "# Heading\n \n{:.class}\n\nContent.";
1806 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1807 let warnings = rule.check(&ctx).unwrap();
1808
1809 assert!(
1813 warnings.is_empty(),
1814 "Whitespace between heading and IAL means IAL is not attached"
1815 );
1816 }
1817
1818 #[test]
1819 fn test_kramdown_ial_html_comment_between() {
1820 let rule = MD022BlanksAroundHeadings::default();
1823 let content = "# Heading\n<!-- comment -->\n{:.class}\n\nContent.";
1824 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1825 let warnings = rule.check(&ctx).unwrap();
1826
1827 assert!(
1830 warnings.is_empty(),
1831 "A comment-only line below the heading is its blank line: {warnings:?}"
1832 );
1833 }
1834
1835 #[test]
1836 fn test_kramdown_ial_text_beside_comment_between_is_still_reported() {
1837 let rule = MD022BlanksAroundHeadings::default();
1840 let content = "# Heading\ntext <!-- comment -->\n{:.class}\n\nContent.";
1841 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1842 let warnings = rule.check(&ctx).unwrap();
1843
1844 assert_eq!(warnings.len(), 1, "Heading followed by prose: {warnings:?}");
1845 }
1846
1847 #[test]
1848 fn test_kramdown_ial_generic_attribute() {
1849 let rule = MD022BlanksAroundHeadings::default();
1850 let content = "# Heading\n{:data-toc=\"true\" style=\"color: red\"}\n\nContent.";
1851 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1852 let warnings = rule.check(&ctx).unwrap();
1853
1854 assert!(warnings.is_empty(), "Generic attributes should be recognized as IAL");
1855 }
1856
1857 #[test]
1858 fn test_kramdown_ial_fix_multiple_lines_preserves_all() {
1859 let rule = MD022BlanksAroundHeadings::default();
1860 let content = "# Heading\n{:.class1}\n{:#id}\n{:data-x=\"y\"}\nContent.";
1861 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1862
1863 let fixed = rule.fix(&ctx).unwrap();
1864
1865 assert!(fixed.contains("{:.class1}"), "First IAL should be preserved");
1867 assert!(fixed.contains("{:#id}"), "Second IAL should be preserved");
1868 assert!(fixed.contains("{:data-x=\"y\"}"), "Third IAL should be preserved");
1869 assert!(
1871 fixed.contains("{:data-x=\"y\"}\n\nContent"),
1872 "Blank line should be after all IALs"
1873 );
1874 }
1875
1876 #[test]
1877 fn test_kramdown_ial_crlf_line_endings() {
1878 let rule = MD022BlanksAroundHeadings::default();
1879 let content = "# Heading\r\n{:.class}\r\n\r\nContent.";
1880 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1881 let warnings = rule.check(&ctx).unwrap();
1882
1883 assert!(warnings.is_empty(), "CRLF should work correctly with IAL");
1884 }
1885
1886 #[test]
1887 fn test_kramdown_ial_invalid_patterns_not_recognized() {
1888 let rule = MD022BlanksAroundHeadings::default();
1889
1890 let content = "# Heading\n{ :.class}\n\nContent.";
1892 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1893 let warnings = rule.check(&ctx).unwrap();
1894 assert_eq!(warnings.len(), 1, "Invalid IAL syntax should trigger warning");
1895
1896 let content2 = "# Heading\n{.class}\n\nContent.";
1898 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1899 let warnings2 = rule.check(&ctx2).unwrap();
1900 assert!(warnings2.is_empty(), "{{.class}} is valid kramdown block attribute");
1902
1903 let content3 = "# Heading\n{just text}\n\nContent.";
1905 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
1906 let warnings3 = rule.check(&ctx3).unwrap();
1907 assert_eq!(
1908 warnings3.len(),
1909 1,
1910 "Text in braces is not IAL and should trigger warning"
1911 );
1912 }
1913
1914 #[test]
1915 fn test_kramdown_ial_toc_marker() {
1916 let rule = MD022BlanksAroundHeadings::default();
1918 let content = "# Heading\n{:toc}\n\nContent.";
1919 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1920 let warnings = rule.check(&ctx).unwrap();
1921
1922 assert!(warnings.is_empty(), "{{:toc}} should be recognized as IAL");
1924 }
1925
1926 #[test]
1927 fn test_kramdown_ial_mixed_headings_in_document() {
1928 let rule = MD022BlanksAroundHeadings::default();
1929 let content = r#"# ATX Heading
1930{:.atx-class}
1931
1932Content after ATX.
1933
1934Setext Heading
1935--------------
1936{:#setext-id}
1937
1938Content after Setext.
1939
1940## Another ATX
1941{:.another}
1942
1943More content."#;
1944 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1945 let warnings = rule.check(&ctx).unwrap();
1946
1947 assert!(
1948 warnings.is_empty(),
1949 "Mixed headings with IAL should all work: {warnings:?}"
1950 );
1951 }
1952
1953 #[test]
1954 fn test_kramdown_extension_block_before_first_heading_is_document_start() {
1955 let rule = MD022BlanksAroundHeadings::default();
1956 let content = "{::comment}\nhidden\n{:/comment}\n# Heading\n\nBody\n";
1957 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Kramdown, None);
1958 let warnings = rule.check(&ctx).unwrap();
1959
1960 assert!(
1961 warnings.is_empty(),
1962 "Kramdown extension preamble should not require blank above first heading: {warnings:?}"
1963 );
1964 }
1965
1966 #[test]
1967 fn test_kramdown_ial_before_first_heading_is_document_start() {
1968 let rule = MD022BlanksAroundHeadings::default();
1969 let content = "{:.doc-class}\n# Heading\n\nBody\n";
1970 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Kramdown, None);
1971 let warnings = rule.check(&ctx).unwrap();
1972
1973 assert!(
1974 warnings.is_empty(),
1975 "Kramdown IAL preamble should not require blank above first heading: {warnings:?}"
1976 );
1977 }
1978
1979 #[test]
1982 fn test_quarto_div_marker_transparent_above_heading() {
1983 let rule = MD022BlanksAroundHeadings::default();
1986 let content = "Content\n\n::: {.callout-note}\n# Heading\n\nMore content\n:::\n";
1988 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1989 let warnings = rule.check(&ctx).unwrap();
1990 assert!(
1992 warnings.is_empty(),
1993 "Quarto div marker should be transparent above heading: {warnings:?}"
1994 );
1995 }
1996
1997 #[test]
1998 fn test_quarto_div_marker_transparent_below_heading() {
1999 let rule = MD022BlanksAroundHeadings::default();
2001 let content = "# Heading\n\n::: {.callout-note}\nContent\n:::\n";
2002 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
2003 let warnings = rule.check(&ctx).unwrap();
2004 assert!(
2006 warnings.is_empty(),
2007 "Quarto div marker should be transparent below heading: {warnings:?}"
2008 );
2009 }
2010
2011 #[test]
2012 fn test_quarto_heading_inside_callout() {
2013 let rule = MD022BlanksAroundHeadings::default();
2015 let content = "::: {.callout-note}\n\n## Note Title\n\nNote content\n:::\n";
2016 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
2017 let warnings = rule.check(&ctx).unwrap();
2018 assert!(
2019 warnings.is_empty(),
2020 "Heading inside Quarto callout should have no warnings: {warnings:?}"
2021 );
2022 }
2023
2024 #[test]
2025 fn test_quarto_heading_at_start_after_div_open() {
2026 let rule = MD022BlanksAroundHeadings::default();
2029 let content = "::: {.callout-warning}\n# Warning\n\nContent\n:::\n";
2031 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
2032 let warnings = rule.check(&ctx).unwrap();
2033 assert!(
2039 warnings.is_empty(),
2040 "Heading at start after div open should pass: {warnings:?}"
2041 );
2042 }
2043
2044 #[test]
2045 fn test_quarto_heading_before_div_close() {
2046 let rule = MD022BlanksAroundHeadings::default();
2048 let content = "::: {.callout-note}\nIntro\n\n## Section\n:::\n";
2049 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
2050 let warnings = rule.check(&ctx).unwrap();
2051 assert!(
2055 warnings.is_empty(),
2056 "Heading before div close should pass: {warnings:?}"
2057 );
2058 }
2059
2060 #[test]
2061 fn test_quarto_div_markers_not_transparent_in_standard_flavor() {
2062 let rule = MD022BlanksAroundHeadings::default();
2064 let content = "Content\n\n:::\n# Heading\n\n:::\n";
2065 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2066 let warnings = rule.check(&ctx).unwrap();
2067 assert!(
2069 !warnings.is_empty(),
2070 "Standard flavor should not treat ::: as transparent: {warnings:?}"
2071 );
2072 }
2073
2074 #[test]
2075 fn test_quarto_nested_divs_with_heading() {
2076 let rule = MD022BlanksAroundHeadings::default();
2078 let content = "::: {.outer}\n::: {.inner}\n\n# Heading\n\nContent\n:::\n:::\n";
2079 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
2080 let warnings = rule.check(&ctx).unwrap();
2081 assert!(
2082 warnings.is_empty(),
2083 "Nested divs with heading should work: {warnings:?}"
2084 );
2085 }
2086
2087 #[test]
2088 fn test_quarto_fix_preserves_div_markers() {
2089 let rule = MD022BlanksAroundHeadings::default();
2091 let content = "::: {.callout-note}\n\n## Note\n\nContent\n:::\n";
2092 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
2093 let fixed = rule.fix(&ctx).unwrap();
2094 assert!(fixed.contains("::: {.callout-note}"), "Should preserve div opening");
2096 assert!(fixed.contains(":::"), "Should preserve div closing");
2097 assert!(fixed.contains("## Note"), "Should preserve heading");
2098 }
2099
2100 #[test]
2101 fn test_quarto_heading_needs_blank_without_div_transparency() {
2102 let rule = MD022BlanksAroundHeadings::default();
2105 let content = "Content\n::: {.callout-note}\n# Heading\n\nMore\n:::\n";
2107 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
2108 let warnings = rule.check(&ctx).unwrap();
2109 assert!(
2112 !warnings.is_empty(),
2113 "Should still require blank line when not present: {warnings:?}"
2114 );
2115 }
2116
2117 #[test]
2118 fn test_pandoc_div_marker_transparent_above_heading() {
2119 let rule = MD022BlanksAroundHeadings::default();
2122 let content = "Content\n\n::: {.callout-note}\n# Heading\n\nMore content\n:::\n";
2123 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
2124 let warnings = rule.check(&ctx).unwrap();
2125 assert!(
2126 warnings.is_empty(),
2127 "MD022 should treat Pandoc div marker as transparent above heading: {warnings:?}"
2128 );
2129 }
2130
2131 #[test]
2132 fn test_hugo_block_attribute_after_heading_not_flagged() {
2133 let rule = MD022BlanksAroundHeadings::default();
2136 let content = "Intro text.\n\n# Heading\n{class=\"anchor\"}\n\nContent.\n";
2137
2138 for flavor in [
2139 crate::config::MarkdownFlavor::Hugo,
2140 crate::config::MarkdownFlavor::MkDocs,
2141 crate::config::MarkdownFlavor::Kramdown,
2142 ] {
2143 let ctx = LintContext::new(content, flavor, None);
2144 let warnings = rule.check(&ctx).unwrap();
2145 assert!(
2146 warnings.is_empty(),
2147 "MD022 should not flag the block attribute line under {flavor:?}: {warnings:?}"
2148 );
2149 }
2150
2151 let ctx_std = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2154 let warnings_std = rule.check(&ctx_std).unwrap();
2155 assert!(
2156 warnings_std.iter().any(|w| w.message.contains("below heading")),
2157 "MD022 must flag the missing blank below the heading under Standard: {warnings_std:?}"
2158 );
2159 }
2160
2161 #[test]
2162 fn test_mdg_keeps_tags_attached_only_to_structure_headings() {
2163 let rule = MD022BlanksAroundHeadings::default();
2164
2165 let attached = "`@browser`\n`@checkout` `@smoke`\n# Feature: Checkout\n";
2167 let mdg_ctx = LintContext::new(attached, crate::config::MarkdownFlavor::MDG, None);
2168 assert!(rule.check(&mdg_ctx).unwrap().is_empty());
2169 let fixed = rule.fix(&mdg_ctx).unwrap();
2170 assert_eq!(fixed, attached);
2171 let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
2172 assert_eq!(rule.fix(&fixed_ctx).unwrap(), fixed, "MDG fix should be idempotent");
2173
2174 let standard_ctx = LintContext::new(attached, crate::config::MarkdownFlavor::Standard, None);
2175 assert!(
2176 rule.check(&standard_ctx)
2177 .unwrap()
2178 .iter()
2179 .any(|warning| warning.message.contains("above heading"))
2180 );
2181 }
2182
2183 #[test]
2184 fn test_mdg_requires_blank_line_above_a_non_structure_heading() {
2185 let rule = MD022BlanksAroundHeadings::default();
2188 let content = "`@browser`\n# Notes\n";
2189 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
2190
2191 assert!(
2192 rule.check(&ctx)
2193 .unwrap()
2194 .iter()
2195 .any(|warning| warning.message.contains("above heading")),
2196 "a non-Gherkin heading keeps the normal requirement"
2197 );
2198 assert_eq!(rule.fix(&ctx).unwrap(), "`@browser`\n\n# Notes\n");
2199 }
2200
2201 #[test]
2202 fn test_mdg_colon_inside_a_code_span_names_no_structure() {
2203 let rule = MD022BlanksAroundHeadings::default();
2207 let content = "`@browser`\n# See `x: y` Notes\n";
2208 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
2209
2210 assert!(
2211 rule.check(&ctx)
2212 .unwrap()
2213 .iter()
2214 .any(|warning| warning.message.contains("above heading")),
2215 "the code span holds the only colon, so the heading is ordinary prose"
2216 );
2217 assert_eq!(rule.fix(&ctx).unwrap(), "`@browser`\n\n# See `x: y` Notes\n");
2218
2219 let structure = "`@browser`\n# Scenario: use `a: b` here\n";
2221 let structure_ctx = LintContext::new(structure, crate::config::MarkdownFlavor::MDG, None);
2222 assert!(rule.check(&structure_ctx).unwrap().is_empty());
2223 assert_eq!(rule.fix(&structure_ctx).unwrap(), structure);
2224 }
2225
2226 #[test]
2227 fn test_mdg_tag_line_matches_gherkin_reference_scan() {
2228 let rule = MD022BlanksAroundHeadings::default();
2231
2232 for above in [
2233 "`@comment_tag1` #a comment",
2234 "`@comment_tag#2` #a comment",
2235 "`@browser` and prose",
2236 "prose `@browser`",
2237 "`@a b`",
2238 ] {
2239 let content = format!("{above}\n# Feature: Checkout\n");
2240 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::MDG, None);
2241 assert!(rule.check(&ctx).unwrap().is_empty(), "{above:?} is a Gherkin tag line");
2242 assert_eq!(rule.fix(&ctx).unwrap(), content);
2243 }
2244
2245 let prose = "plain prose\n# Feature: Checkout\n";
2246 let ctx = LintContext::new(prose, crate::config::MarkdownFlavor::MDG, None);
2247 assert!(
2248 rule.check(&ctx)
2249 .unwrap()
2250 .iter()
2251 .any(|warning| warning.message.contains("above heading"))
2252 );
2253 }
2254
2255 #[test]
2256 fn test_fix_keeps_a_setext_heading_suppressed_on_a_later_line_as_written() {
2257 let rule = MD022BlanksAroundHeadings::default();
2262 let content = "Intro paragraph.\n# Heading one\nText after.\nTitle\nsecond <!-- rumdl-disable-line MD022 -->\n===\nMore text.\n";
2263 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2264
2265 assert_eq!(
2266 rule.fix(&ctx).unwrap(),
2267 "Intro paragraph.\n\n# Heading one\n\nText after.\nTitle\nsecond <!-- rumdl-disable-line MD022 -->\n===\nMore text.\n"
2268 );
2269 }
2270}