1use crate::lint_context::{column_at, 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 neighbour_text<'a>(ctx: &'a crate::lint_context::LintContext, idx: usize, quoted: bool) -> &'a str {
81 let line = ctx.lines[idx].content(ctx.content);
82 if quoted { strip_quote_markers(line) } else { line }
83}
84
85fn strip_quote_markers(line: &str) -> &str {
87 crate::utils::blockquote::parse_blockquote_prefix(line).map_or(line, |parsed| parsed.content)
88}
89
90fn quote_depth(line: &str) -> usize {
93 scan_container_prefix(line, usize::MAX).1
94}
95
96fn blank_line_prefix(line: &str, depth: usize) -> String {
101 if depth == 0 {
102 return String::new();
103 }
104 scan_container_prefix(line, depth).0.trim_end().to_string()
105}
106
107fn scan_container_prefix(line: &str, limit: usize) -> (String, usize) {
110 let mut prefix = String::new();
111 let mut depth = 0;
112 let mut rest = line;
113 while depth < limit {
114 let trimmed = rest.trim_start_matches([' ', '\t']);
115 prefix.push_str(&rest[..rest.len() - trimmed.len()]);
116 rest = trimmed;
117 if let Some(after) = rest.strip_prefix('>') {
118 prefix.push('>');
119 depth += 1;
120 rest = after;
121 continue;
122 }
123 let token = rest.split([' ', '\t']).next().unwrap_or("");
124 if is_list_marker_token(token) {
125 prefix.push_str(&" ".repeat(token.len()));
126 rest = &rest[token.len()..];
127 continue;
128 }
129 break;
130 }
131 (prefix, depth)
132}
133
134fn blank_line_between(heading_line: &str, adjacent_line: &str) -> String {
138 let shared = quote_depth(heading_line).min(quote_depth(adjacent_line));
139 blank_line_prefix(heading_line, shared)
140}
141
142fn blank_line_above<'a>(heading_line: &str, mut above: impl Iterator<Item = &'a str>) -> String {
152 let depth = quote_depth(heading_line);
153 let Some(adjacent) = above.next() else {
154 return blank_line_prefix(heading_line, 0);
155 };
156 let mut shared = depth.min(quote_depth(adjacent));
157 let adjacent_depth = quote_depth(adjacent);
158 if adjacent_depth < depth && !adjacent.trim().is_empty() {
159 let needed = if adjacent_depth == 0 { 1 } else { depth };
163 let anchor = std::iter::once(adjacent)
164 .chain(above)
165 .find(|line| quote_depth(line) >= needed || line.trim_start_matches(['>', ' ', '\t']).is_empty());
166 if anchor
167 .is_some_and(|line| quote_depth(line) >= needed && !line.trim_start_matches(['>', ' ', '\t']).is_empty())
168 {
169 shared = depth;
170 }
171 }
172 blank_line_prefix(heading_line, shared)
173}
174
175fn is_list_marker_token(token: &str) -> bool {
178 match token {
179 "-" | "+" | "*" => true,
180 _ => {
181 let digits = token.trim_end_matches(['.', ')']);
182 token.len() == digits.len() + 1
183 && (1..=9).contains(&digits.len())
184 && digits.bytes().all(|b| b.is_ascii_digit())
185 }
186 }
187}
188
189fn quote_margin_end(line: &str, quotes: usize) -> usize {
192 if quotes == 0 {
193 return 0;
194 }
195 let (prefix, _) = scan_container_prefix(line, quotes);
196 let optional = usize::from(line[prefix.len()..].starts_with([' ', '\t']));
197 column_at(line, prefix.len()) + optional
198}
199
200fn starts_item_under_bare_marker(ctx: &crate::lint_context::LintContext, first_idx: usize) -> bool {
210 let Some(marker_idx) = first_idx.checked_sub(1) else {
211 return false;
212 };
213 let text = ctx.lines[marker_idx].content(ctx.content).trim_end();
214 if text.is_empty()
216 || !text
217 .split_whitespace()
218 .all(|token| token == ">" || is_list_marker_token(token))
219 || !text.split_whitespace().next_back().is_some_and(is_list_marker_token)
220 {
221 return false;
222 }
223 let marker_end = column_at(text, text.len());
224 let line = ctx.lines[first_idx].content(ctx.content);
225 let quotes = quote_depth(line);
226 if quotes > 0 {
227 let marker_quotes = quote_depth(text);
232 if quotes < marker_quotes {
233 return false;
234 }
235 let (prefix, _) = scan_container_prefix(line, marker_quotes);
236 let rest = &line[prefix.len()..];
237 let text_start = prefix.len() + rest.len() - rest.trim_start_matches([' ', '\t']).len();
238 let item_end = marker_end.saturating_sub(quote_margin_end(text, marker_quotes));
241 return column_at(line, text_start).saturating_sub(quote_margin_end(line, marker_quotes)) > item_end;
242 }
243 ctx.heading_container(first_idx + 1)
244 == crate::lint_context::HeadingContainer::Item {
245 content_col: marker_end + 1,
246 }
247}
248
249fn opens_item_on_marker_line(
259 ctx: &crate::lint_context::LintContext,
260 first_idx: usize,
261 heading: &crate::lint_context::HeadingInfo,
262) -> bool {
263 let line = ctx.lines[first_idx].content(ctx.content);
264 let margin = line.len() - line.trim_start_matches([' ', '\t', '>']).len();
265 heading.first_line_prefix_len() > margin
266}
267
268fn heading_is_in_template_code(
271 ctx: &crate::lint_context::LintContext,
272 heading_idx: usize,
273 heading: &crate::lint_context::HeadingInfo,
274) -> bool {
275 let marker_idx = if matches!(
276 heading.style,
277 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
278 ) {
279 heading_idx + 1
280 } else {
281 heading_idx
282 };
283 ctx.lines
284 .get(marker_idx)
285 .is_some_and(|line| ctx.is_inside_template_code(line.byte_offset + line.indent))
286}
287
288fn heading_at_start_idx(ctx: &crate::lint_context::LintContext, is_pandoc: bool) -> Option<usize> {
296 for (index, line) in ctx.lines.iter().enumerate() {
297 if let Some(heading) = line.heading.as_deref() {
298 return Some(first_text_idx(index, heading));
299 }
300 if line.is_blank || line.in_html_comment || line.in_mdx_comment || line.is_setext_heading_text {
301 continue;
302 }
303 let trimmed = line.content(ctx.content).trim();
304 if is_blank_or_comment_only(trimmed)
305 || line.in_kramdown_extension_block
306 || line.is_kramdown_block_ial
307 || (is_pandoc && (pandoc::is_div_open(trimmed) || pandoc::is_div_close(trimmed)))
308 {
309 continue;
310 }
311 return None;
314 }
315 None
316}
317
318#[derive(Clone, Default)]
390pub struct MD022BlanksAroundHeadings {
391 config: MD022Config,
392}
393
394struct Violation {
396 heading_line: usize,
398 first_line: usize,
400 position: &'static str,
402 needed_blanks: usize,
403 heading_level: usize,
404 is_setext: bool,
405 blank_line: String,
408}
409
410impl MD022BlanksAroundHeadings {
411 pub fn new() -> Self {
414 Self {
415 config: MD022Config::default(),
416 }
417 }
418
419 pub fn with_values(lines_above: usize, lines_below: usize) -> Self {
421 use md022_config::HeadingLevelConfig;
422 Self {
423 config: MD022Config {
424 lines_above: HeadingLevelConfig::scalar(lines_above),
425 lines_below: HeadingLevelConfig::scalar(lines_below),
426 allowed_at_start: true,
427 },
428 }
429 }
430
431 pub fn from_config_struct(config: MD022Config) -> Self {
432 Self { config }
433 }
434
435 fn fix_content(&self, ctx: &crate::lint_context::LintContext) -> String {
437 let line_ending = "\n";
440 let had_trailing_newline = ctx.content.ends_with('\n');
441 let is_pandoc = ctx.flavor.is_pandoc_compatible();
442 let is_mdg = ctx.flavor == crate::config::MarkdownFlavor::MDG;
443 let mut result = Vec::new();
444 let mut skip_count: usize = 0;
445
446 let heading_at_start_idx = heading_at_start_idx(ctx, is_pandoc);
447
448 let quoted_headings: std::collections::HashMap<usize, crate::lint_context::ParsedHeading<'_>> = ctx
451 .headings()
452 .filter(crate::lint_context::ParsedHeading::is_blockquote)
453 .map(|parsed| (parsed.first_line_num() - 1, parsed))
454 .collect();
455
456 for (i, line_info) in ctx.lines.iter().enumerate() {
457 if skip_count > 0 {
458 skip_count -= 1;
459 continue;
460 }
461 let line = line_info.content(ctx.content);
462
463 if line_info.in_code_block {
464 result.push(line.to_string());
465 continue;
466 }
467
468 let quoted_heading = quoted_headings.get(&i);
472 let quoted = quoted_heading.is_some();
473 let heading_idx = if let Some(parsed) = quoted_heading {
474 Some(parsed.line_num - 1)
475 } else if line_info.heading.is_some() {
476 Some(i)
477 } else if line_info.is_setext_heading_text {
478 ctx.lines[i..]
479 .iter()
480 .position(|candidate| candidate.heading.is_some())
481 .map(|offset| i + offset)
482 } else {
483 None
484 };
485
486 if let Some(heading_idx) = heading_idx {
487 let heading = match quoted_heading {
488 Some(parsed) => parsed.heading,
489 None => ctx.lines[heading_idx].heading.as_deref().unwrap(),
490 };
491
492 let heading_end_idx = if matches!(
495 heading.style,
496 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
497 ) && heading_idx + 1 < ctx.lines.len()
498 {
499 heading_idx + 1
500 } else {
501 heading_idx
502 };
503
504 if heading_is_in_template_code(ctx, heading_idx, heading)
508 || (i..=heading_end_idx).any(|idx| ctx.inline_config().is_rule_disabled("MD022", idx + 1))
509 {
510 for idx in i..=heading_end_idx {
511 result.push(ctx.lines[idx].content(ctx.content).to_string());
512 }
513 skip_count += heading_end_idx - i;
514 continue;
515 }
516
517 let is_first_heading = Some(i) == heading_at_start_idx;
519 let heading_level = heading.level as usize;
520
521 let mut blank_lines_above = 0;
523 let mut check_idx = result.len();
524 while check_idx > 0 {
525 let prev_line = &result[check_idx - 1];
526 let trimmed = if quoted {
527 strip_quote_markers(prev_line).trim()
528 } else {
529 prev_line.trim()
530 };
531 if is_blank_or_comment_only(prev_line) {
532 blank_lines_above += 1;
534 check_idx -= 1;
535 } else if is_block_attribute_line(trimmed, ctx.flavor) {
536 check_idx -= 1;
538 } else if is_pandoc && (pandoc::is_div_open(trimmed) || pandoc::is_div_close(trimmed)) {
539 check_idx -= 1;
541 } else {
542 break;
543 }
544 }
545
546 let requirement_above = self.config.lines_above.get_for_level(heading_level);
548 let follows_mdg_tags = is_mdg && follows_mdg_tag_line(ctx, i, heading);
549 let needed_blanks_above = if follows_mdg_tags
550 || (is_first_heading && self.config.allowed_at_start)
551 || starts_item_under_bare_marker(ctx, i)
552 || opens_item_on_marker_line(ctx, i, heading)
553 || (quoted && i == 0)
554 {
555 0
556 } else {
557 requirement_above.required_count().unwrap_or(0)
558 };
559
560 if blank_lines_above < needed_blanks_above {
562 let blank = blank_line_above(line, result.iter().rev().map(String::as_str));
563 while blank_lines_above < needed_blanks_above {
564 result.push(blank.clone());
565 blank_lines_above += 1;
566 }
567 }
568
569 for idx in i..=heading_end_idx {
572 result.push(ctx.lines[idx].content(ctx.content).to_string());
573 }
574 skip_count += heading_end_idx - i; let mut effective_end_idx = heading_end_idx;
578
579 let mut ial_count = 0;
582 while effective_end_idx + 1 < ctx.lines.len() {
583 let next_line = &ctx.lines[effective_end_idx + 1];
584 let next_trimmed = neighbour_text(ctx, effective_end_idx + 1, quoted).trim();
585 if is_block_attribute_line(next_trimmed, ctx.flavor) {
586 result.push(if quoted {
588 next_line.content(ctx.content).to_string()
589 } else {
590 next_trimmed.to_string()
591 });
592 effective_end_idx += 1;
593 ial_count += 1;
594 } else {
595 break;
596 }
597 }
598
599 let mut blank_lines_below = 0;
601 let mut next_content_line_idx = None;
602 for j in (effective_end_idx + 1)..ctx.lines.len() {
603 if ctx.lines[j].is_blank || is_blank_or_comment_only(ctx.lines[j].content(ctx.content)) {
604 blank_lines_below += 1;
605 } else {
606 next_content_line_idx = Some(j);
607 break;
608 }
609 }
610
611 let next_is_special = if let Some(idx) = next_content_line_idx {
613 let next_line = &ctx.lines[idx];
614 let trimmed = neighbour_text(ctx, idx, quoted).trim();
615 next_line.list_item.is_some()
616 || starts_with_list_marker(trimmed)
617 || ((trimmed.starts_with("```") || trimmed.starts_with("~~~"))
618 && (trimmed.len() == 3
619 || (trimmed.len() > 3
620 && trimmed
621 .chars()
622 .nth(3)
623 .is_some_and(|c| c.is_whitespace() || c.is_alphabetic()))))
624 } else {
625 false
626 };
627
628 let requirement_below = self.config.lines_below.get_for_level(heading_level);
630 let needed_blanks_below = if next_is_special || next_content_line_idx.is_none() {
633 0
634 } else {
635 requirement_below.required_count().unwrap_or(0)
636 };
637 if blank_lines_below < needed_blanks_below {
638 let blank = blank_line_between(line, ctx.lines[effective_end_idx + 1].content(ctx.content));
639 for _ in 0..(needed_blanks_below - blank_lines_below) {
640 result.push(blank.clone());
641 }
642 }
643
644 skip_count += ial_count;
646 } else {
647 result.push(line.to_string());
649 }
650 }
651
652 let mut joined = result.join(line_ending);
656 if had_trailing_newline {
657 joined.push_str(line_ending);
658 }
659 joined
660 }
661}
662
663impl Rule for MD022BlanksAroundHeadings {
664 fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
665 (self.config == Default::default()).then_some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(22))
666 }
667
668 fn name(&self) -> &'static str {
669 "MD022"
670 }
671
672 fn description(&self) -> &'static str {
673 "Headings should be surrounded by blank lines"
674 }
675
676 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
677 let mut result = Vec::new();
678
679 if ctx.lines.is_empty() {
681 return Ok(result);
682 }
683
684 let line_ending = "\n";
687 let is_pandoc = ctx.flavor.is_pandoc_compatible();
688 let is_mdg = ctx.flavor == crate::config::MarkdownFlavor::MDG;
689
690 let heading_at_start_idx = heading_at_start_idx(ctx, is_pandoc);
691
692 let mut heading_violations = Vec::new();
694
695 for parsed in ctx.headings() {
696 let line_num = parsed.line_num - 1;
697 let line_info = parsed.line_info;
698
699 if line_info.in_pymdown_block {
701 continue;
702 }
703
704 let heading = parsed.heading;
705 if heading_is_in_template_code(ctx, line_num, heading) {
706 continue;
707 }
708
709 let heading_level = heading.level as usize;
710 let quoted = parsed.is_blockquote();
711
712 let first_idx = parsed.first_line_num() - 1;
716
717 let is_first_heading = Some(first_idx) == heading_at_start_idx;
719
720 let required_above_count = self.config.lines_above.get_for_level(heading_level).required_count();
722 let required_below_count = self.config.lines_below.get_for_level(heading_level).required_count();
723
724 let should_check_above = required_above_count.is_some()
726 && first_idx > 0
727 && (!is_first_heading || !self.config.allowed_at_start)
728 && !(is_mdg && follows_mdg_tag_line(ctx, first_idx, heading));
729 let should_check_above = should_check_above
732 && !starts_item_under_bare_marker(ctx, first_idx)
733 && !opens_item_on_marker_line(ctx, first_idx, heading);
734 if should_check_above {
735 let mut blank_lines_above = 0;
736 let mut hit_frontmatter_end = false;
737 for j in (0..first_idx).rev() {
738 let line_content = ctx.lines[j].content(ctx.content);
739 let trimmed = neighbour_text(ctx, j, quoted).trim();
740 if ctx.lines[j].is_blank || is_blank_or_comment_only(line_content) {
741 blank_lines_above += 1;
744 } else if ctx.lines[j].in_html_comment || ctx.lines[j].in_mdx_comment {
745 continue;
747 } else if is_block_attribute_line(trimmed, ctx.flavor) {
748 continue;
750 } else if is_pandoc && (pandoc::is_div_open(trimmed) || pandoc::is_div_close(trimmed)) {
751 continue;
753 } else if ctx.lines[j].in_front_matter {
754 hit_frontmatter_end = true;
759 break;
760 } else {
761 break;
762 }
763 }
764 let required = required_above_count.unwrap();
765 if !hit_frontmatter_end && blank_lines_above < required {
766 let needed_blanks = required - blank_lines_above;
767 heading_violations.push(Violation {
768 heading_line: line_num,
769 first_line: first_idx,
770 position: "above",
771 needed_blanks,
772 heading_level,
773 is_setext: parsed.is_setext(),
774 blank_line: blank_line_above(
775 ctx.lines[first_idx].content(ctx.content),
776 ctx.lines[..first_idx]
777 .iter()
778 .rev()
779 .map(|line| line.content(ctx.content)),
780 ),
781 });
782 }
783 }
784
785 let mut effective_last_line = if matches!(
787 heading.style,
788 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
789 ) {
790 line_num + 1 } else {
792 line_num
793 };
794
795 while effective_last_line + 1 < ctx.lines.len() {
798 let next_trimmed = neighbour_text(ctx, effective_last_line + 1, quoted).trim();
799 if is_block_attribute_line(next_trimmed, ctx.flavor) {
800 effective_last_line += 1;
801 } else {
802 break;
803 }
804 }
805
806 if effective_last_line < ctx.lines.len() - 1 {
808 let mut next_non_blank_idx = effective_last_line + 1;
810 while next_non_blank_idx < ctx.lines.len() {
811 let check_line = &ctx.lines[next_non_blank_idx];
812 let check_trimmed = neighbour_text(ctx, next_non_blank_idx, quoted).trim();
813 if check_line.is_blank {
814 next_non_blank_idx += 1;
815 } else if check_line.in_html_comment
816 || check_line.in_mdx_comment
817 || is_blank_or_comment_only(check_line.content(ctx.content))
818 {
819 next_non_blank_idx += 1;
821 } else if is_pandoc && (pandoc::is_div_open(check_trimmed) || pandoc::is_div_close(check_trimmed)) {
822 next_non_blank_idx += 1;
824 } else {
825 break;
826 }
827 }
828
829 if next_non_blank_idx >= ctx.lines.len() {
831 continue;
833 }
834
835 let next_line_is_special = {
837 let next_line = &ctx.lines[next_non_blank_idx];
838 let next_trimmed = neighbour_text(ctx, next_non_blank_idx, quoted).trim();
839
840 let is_code_fence = (next_trimmed.starts_with("```") || next_trimmed.starts_with("~~~"))
842 && (next_trimmed.len() == 3
843 || (next_trimmed.len() > 3
844 && next_trimmed
845 .chars()
846 .nth(3)
847 .is_some_and(|c| c.is_whitespace() || c.is_alphabetic())));
848
849 let is_list_item = next_line.list_item.is_some() || starts_with_list_marker(next_trimmed);
856
857 is_code_fence || is_list_item
858 };
859
860 if !next_line_is_special && let Some(required) = required_below_count {
862 let mut blank_lines_below = 0;
864 for k in (effective_last_line + 1)..next_non_blank_idx {
865 if ctx.lines[k].is_blank || is_blank_or_comment_only(ctx.lines[k].content(ctx.content)) {
867 blank_lines_below += 1;
868 }
869 }
870
871 if blank_lines_below < required {
872 let needed_blanks = required - blank_lines_below;
873 heading_violations.push(Violation {
874 heading_line: line_num,
875 first_line: first_idx,
876 position: "below",
877 needed_blanks,
878 heading_level,
879 is_setext: parsed.is_setext(),
880 blank_line: blank_line_between(
881 ctx.lines[first_idx].content(ctx.content),
882 ctx.lines[effective_last_line + 1].content(ctx.content),
883 ),
884 });
885 }
886 }
887 }
888 }
889
890 for Violation {
892 heading_line,
893 first_line,
894 position,
895 needed_blanks,
896 heading_level,
897 is_setext,
898 blank_line,
899 } in heading_violations
900 {
901 let line_info = &ctx.lines[heading_line];
902
903 let (start_line, start_col, end_line, end_col) =
905 calculate_heading_range(first_line + 1, heading_line + 1, line_info.content(ctx.content));
906
907 let (message, insertion_point) = match position {
914 "above" => {
915 let Some(required_above_count) =
916 self.config.lines_above.get_for_level(heading_level).required_count()
917 else {
918 continue;
919 };
920 (
921 format!(
922 "Expected {} blank {} above heading",
923 required_above_count,
924 if required_above_count == 1 { "line" } else { "lines" }
925 ),
926 first_line, )
928 }
929 "below" => {
930 let Some(required_below_count) =
931 self.config.lines_below.get_for_level(heading_level).required_count()
932 else {
933 continue;
934 };
935 let insert_after = if is_setext { heading_line + 2 } else { heading_line + 1 };
937
938 (
939 format!(
940 "Expected {} blank {} below heading",
941 required_below_count,
942 if required_below_count == 1 { "line" } else { "lines" }
943 ),
944 insert_after,
945 )
946 }
947 _ => continue,
948 };
949
950 let byte_range = if insertion_point == 0 && position == "above" {
952 0..0
954 } else if position == "above" && insertion_point > 0 {
955 ctx.lines[insertion_point].byte_offset..ctx.lines[insertion_point].byte_offset
957 } else if position == "below" && insertion_point - 1 < ctx.lines.len() {
958 let line_idx = insertion_point - 1;
960 let line_end_offset = if line_idx + 1 < ctx.lines.len() {
961 ctx.lines[line_idx + 1].byte_offset
962 } else {
963 ctx.content.len()
964 };
965 line_end_offset..line_end_offset
966 } else {
967 let content_len = ctx.content.len();
969 content_len..content_len
970 };
971
972 result.push(LintWarning {
973 rule_name: Some(self.name().to_string()),
974 message,
975 line: start_line,
976 column: start_col,
977 end_line,
978 end_column: end_col,
979 severity: Severity::Warning,
980 fix: Some(Fix::new(
981 byte_range,
982 format!("{blank_line}{line_ending}").repeat(needed_blanks),
983 )),
984 });
985 }
986
987 Ok(result)
988 }
989
990 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
991 if ctx.content.is_empty() {
992 return Ok(ctx.content.to_string());
993 }
994
995 let fixed = self.fix_content(ctx);
997
998 Ok(fixed)
999 }
1000
1001 fn category(&self) -> RuleCategory {
1003 RuleCategory::Heading
1004 }
1005
1006 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
1008 if ctx.content.is_empty() || !ctx.likely_has_headings() {
1010 return true;
1011 }
1012 ctx.headings().next().is_none()
1014 }
1015
1016 fn as_any(&self) -> &dyn std::any::Any {
1017 self
1018 }
1019
1020 crate::impl_rule_config_methods!(MD022Config);
1021
1022 fn polymorphic_config_keys(&self) -> &'static [&'static str] {
1023 &["lines-above", "lines-below"]
1028 }
1029}
1030
1031#[cfg(test)]
1032mod tests {
1033 use super::*;
1034 use crate::lint_context::LintContext;
1035
1036 #[test]
1037 fn test_valid_headings() {
1038 let rule = MD022BlanksAroundHeadings::default();
1039 let content = "\n# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.\n";
1040 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1041 let result = rule.check(&ctx).unwrap();
1042 assert!(result.is_empty());
1043 }
1044
1045 #[test]
1046 fn test_missing_blank_above() {
1047 let rule = MD022BlanksAroundHeadings::default();
1048 let content = "# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.\n";
1049 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1050 let result = rule.check(&ctx).unwrap();
1051 assert_eq!(result.len(), 0); let fixed = rule.fix(&ctx).unwrap();
1054
1055 assert!(fixed.contains("# Heading 1"));
1058 assert!(fixed.contains("Some content."));
1059 assert!(fixed.contains("## Heading 2"));
1060 assert!(fixed.contains("More content."));
1061 }
1062
1063 #[test]
1064 fn test_missing_blank_below() {
1065 let rule = MD022BlanksAroundHeadings::default();
1066 let content = "\n# Heading 1\nSome content.\n\n## Heading 2\n\nMore content.\n";
1067 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1068 let result = rule.check(&ctx).unwrap();
1069 assert_eq!(result.len(), 1);
1070 assert_eq!(result[0].line, 2);
1071
1072 let fixed = rule.fix(&ctx).unwrap();
1074 assert!(fixed.contains("# Heading 1\n\nSome content"));
1075 }
1076
1077 #[test]
1078 fn test_missing_blank_above_and_below() {
1079 let rule = MD022BlanksAroundHeadings::default();
1080 let content = "# Heading 1\nSome content.\n## Heading 2\nMore content.\n";
1081 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1082 let result = rule.check(&ctx).unwrap();
1083 assert_eq!(result.len(), 3); let fixed = rule.fix(&ctx).unwrap();
1087 assert!(fixed.contains("# Heading 1\n\nSome content"));
1088 assert!(fixed.contains("Some content.\n\n## Heading 2"));
1089 assert!(fixed.contains("## Heading 2\n\nMore content"));
1090 }
1091
1092 #[test]
1093 fn test_fix_headings() {
1094 let rule = MD022BlanksAroundHeadings::default();
1095 let content = "# Heading 1\nSome content.\n## Heading 2\nMore content.";
1096 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1097 let result = rule.fix(&ctx).unwrap();
1098
1099 let expected = "# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.";
1100 assert_eq!(result, expected);
1101 }
1102
1103 #[test]
1104 fn test_consecutive_headings_pattern() {
1105 let rule = MD022BlanksAroundHeadings::default();
1106 let content = "# Heading 1\n## Heading 2\n### Heading 3";
1107 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1108 let result = rule.fix(&ctx).unwrap();
1109
1110 let lines: Vec<&str> = result.lines().collect();
1112 assert!(!lines.is_empty());
1113
1114 let h1_pos = lines.iter().position(|&l| l == "# Heading 1").unwrap();
1116 let h2_pos = lines.iter().position(|&l| l == "## Heading 2").unwrap();
1117 let h3_pos = lines.iter().position(|&l| l == "### Heading 3").unwrap();
1118
1119 assert!(
1121 h2_pos > h1_pos + 1,
1122 "Should have at least one blank line after first heading"
1123 );
1124 assert!(
1125 h3_pos > h2_pos + 1,
1126 "Should have at least one blank line after second heading"
1127 );
1128
1129 assert!(lines[h1_pos + 1].trim().is_empty(), "Line after h1 should be blank");
1131
1132 assert!(lines[h2_pos + 1].trim().is_empty(), "Line after h2 should be blank");
1134 }
1135
1136 #[test]
1137 fn test_blanks_around_setext_headings() {
1138 let rule = MD022BlanksAroundHeadings::default();
1139 let content = "Heading 1\n=========\nSome content.\nHeading 2\n---------\nMore content.";
1140 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1141 let result = rule.fix(&ctx).unwrap();
1142
1143 let lines: Vec<&str> = result.lines().collect();
1145
1146 assert!(result.contains("Heading 1"));
1148 assert!(result.contains("========="));
1149 assert!(result.contains("Some content."));
1150 assert!(result.contains("Heading 2"));
1151 assert!(result.contains("---------"));
1152 assert!(result.contains("More content."));
1153
1154 let heading1_marker_idx = lines.iter().position(|&l| l == "=========").unwrap();
1156 let some_content_idx = lines.iter().position(|&l| l == "Some content.").unwrap();
1157 assert!(
1158 some_content_idx > heading1_marker_idx + 1,
1159 "Should have a blank line after the first heading"
1160 );
1161
1162 let heading2_marker_idx = lines.iter().position(|&l| l == "---------").unwrap();
1163 let more_content_idx = lines.iter().position(|&l| l == "More content.").unwrap();
1164 assert!(
1165 more_content_idx > heading2_marker_idx + 1,
1166 "Should have a blank line after the second heading"
1167 );
1168
1169 let fixed_ctx = LintContext::new(&result, crate::config::MarkdownFlavor::Standard, None);
1171 let fixed_warnings = rule.check(&fixed_ctx).unwrap();
1172 assert!(fixed_warnings.is_empty(), "Fixed content should have no warnings");
1173 }
1174
1175 #[test]
1176 fn test_fix_specific_blank_line_cases() {
1177 let rule = MD022BlanksAroundHeadings::default();
1178
1179 let content1 = "# Heading 1\n## Heading 2\n### Heading 3";
1181 let ctx1 = LintContext::new(content1, crate::config::MarkdownFlavor::Standard, None);
1182 let result1 = rule.fix(&ctx1).unwrap();
1183 assert!(result1.contains("# Heading 1"));
1185 assert!(result1.contains("## Heading 2"));
1186 assert!(result1.contains("### Heading 3"));
1187 let lines: Vec<&str> = result1.lines().collect();
1189 let h1_pos = lines.iter().position(|&l| l == "# Heading 1").unwrap();
1190 let h2_pos = lines.iter().position(|&l| l == "## Heading 2").unwrap();
1191 assert!(lines[h1_pos + 1].trim().is_empty(), "Should have a blank line after h1");
1192 assert!(lines[h2_pos + 1].trim().is_empty(), "Should have a blank line after h2");
1193
1194 let content2 = "# Heading 1\nContent under heading 1\n## Heading 2";
1196 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1197 let result2 = rule.fix(&ctx2).unwrap();
1198 assert!(result2.contains("# Heading 1"));
1200 assert!(result2.contains("Content under heading 1"));
1201 assert!(result2.contains("## Heading 2"));
1202 let lines2: Vec<&str> = result2.lines().collect();
1204 let h1_pos2 = lines2.iter().position(|&l| l == "# Heading 1").unwrap();
1205 let _content_pos = lines2.iter().position(|&l| l == "Content under heading 1").unwrap();
1206 assert!(
1207 lines2[h1_pos2 + 1].trim().is_empty(),
1208 "Should have a blank line after heading 1"
1209 );
1210
1211 let content3 = "# Heading 1\n\n\n## Heading 2\n\n\n### Heading 3\n\nContent";
1213 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
1214 let result3 = rule.fix(&ctx3).unwrap();
1215 assert!(result3.contains("# Heading 1"));
1217 assert!(result3.contains("## Heading 2"));
1218 assert!(result3.contains("### Heading 3"));
1219 assert!(result3.contains("Content"));
1220 }
1221
1222 #[test]
1223 fn test_fix_preserves_existing_blank_lines() {
1224 let rule = MD022BlanksAroundHeadings::new();
1225 let content = "# Title
1226
1227## Section 1
1228
1229Content here.
1230
1231## Section 2
1232
1233More content.
1234### Missing Blank Above
1235
1236Even more content.
1237
1238## Section 3
1239
1240Final content.";
1241
1242 let expected = "# Title
1243
1244## Section 1
1245
1246Content here.
1247
1248## Section 2
1249
1250More content.
1251
1252### Missing Blank Above
1253
1254Even more content.
1255
1256## Section 3
1257
1258Final content.";
1259
1260 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1261 let result = rule.fix_content(&ctx);
1262 assert_eq!(
1263 result, expected,
1264 "Fix should only add missing blank lines, never remove existing ones"
1265 );
1266 }
1267
1268 #[test]
1269 fn test_fix_preserves_trailing_newline() {
1270 let rule = MD022BlanksAroundHeadings::new();
1271
1272 let content_with_newline = "# Title\nContent here.\n";
1274 let ctx = LintContext::new(content_with_newline, crate::config::MarkdownFlavor::Standard, None);
1275 let result = rule.fix(&ctx).unwrap();
1276 assert!(result.ends_with('\n'), "Should preserve trailing newline");
1277
1278 let content_without_newline = "# Title\nContent here.";
1280 let ctx = LintContext::new(content_without_newline, crate::config::MarkdownFlavor::Standard, None);
1281 let result = rule.fix(&ctx).unwrap();
1282 assert!(
1283 !result.ends_with('\n'),
1284 "Should not add trailing newline if original didn't have one"
1285 );
1286 }
1287
1288 #[test]
1289 fn test_fix_does_not_add_blank_lines_before_lists() {
1290 let rule = MD022BlanksAroundHeadings::new();
1291 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.";
1292
1293 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.";
1294
1295 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1296 let result = rule.fix_content(&ctx);
1297 assert_eq!(result, expected, "Fix should not add blank lines before lists");
1298 }
1299
1300 #[test]
1301 fn test_fix_idempotent_when_heading_follows_a_list_and_precedes_a_marker() {
1302 let rule = MD022BlanksAroundHeadings::default();
1308 let content = "- a\n# H\n2. ";
1309 for flavor in [
1310 crate::config::MarkdownFlavor::Standard,
1311 crate::config::MarkdownFlavor::MkDocs,
1312 crate::config::MarkdownFlavor::MDX,
1313 ] {
1314 let ctx1 = LintContext::new(content, flavor, None);
1315 let fixed1 = rule.fix(&ctx1).unwrap();
1316 let ctx2 = LintContext::new(&fixed1, flavor, None);
1317 let fixed2 = rule.fix(&ctx2).unwrap();
1318 assert_eq!(fixed1, fixed2, "MD022 fix must be idempotent (flavor={flavor:?})");
1319 }
1320 }
1321
1322 #[test]
1323 fn test_thematic_break_below_heading_is_not_a_list_item() {
1324 let rule = MD022BlanksAroundHeadings::default();
1331 for marker in [
1332 "* * *",
1333 "- - -",
1334 "_ _ _",
1335 "***",
1336 "---",
1337 "___",
1338 "- --",
1339 "* ** *",
1340 "---- ----",
1341 ] {
1342 let content = format!("text\n\n# Heading\n{marker}\nafter\n");
1343 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1344 let result = rule.check(&ctx).unwrap();
1345 assert_eq!(
1346 result.len(),
1347 1,
1348 "a heading above `{marker}` needs a blank line below it, got {result:?}"
1349 );
1350 assert_eq!(
1351 rule.fix(&ctx).unwrap(),
1352 format!("text\n\n# Heading\n\n{marker}\nafter\n"),
1353 "fix must insert the blank line below the heading for `{marker}`"
1354 );
1355 }
1356 }
1357
1358 #[test]
1359 fn test_list_item_below_heading_is_still_exempt() {
1360 let rule = MD022BlanksAroundHeadings::default();
1363 for item in ["- item", "* item", "+ item", "1. item", "+ + +"] {
1364 let content = format!("text\n\n# Heading\n{item}\n");
1365 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1366 assert!(
1367 rule.check(&ctx).unwrap().is_empty(),
1368 "a list below a heading stays exempt, but `{item}` was reported"
1369 );
1370 assert_eq!(rule.fix(&ctx).unwrap(), content, "`{item}` must not be rewritten");
1371 }
1372 }
1373
1374 #[test]
1375 fn test_per_level_configuration_no_blank_above_h1() {
1376 use md022_config::HeadingLevelConfig;
1377
1378 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1380 lines_above: HeadingLevelConfig::per_level([0, 1, 1, 1, 1, 1]),
1381 lines_below: HeadingLevelConfig::scalar(1),
1382 allowed_at_start: false, });
1384
1385 let content = "Some text\n# Heading 1\n\nMore text";
1387 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1388 let warnings = rule.check(&ctx).unwrap();
1389 assert_eq!(warnings.len(), 0, "H1 without blank above should not trigger warning");
1390
1391 let content = "Some text\n## Heading 2\n\nMore text";
1393 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1394 let warnings = rule.check(&ctx).unwrap();
1395 assert_eq!(warnings.len(), 1, "H2 without blank above should trigger warning");
1396 assert!(warnings[0].message.contains("above"));
1397 }
1398
1399 #[test]
1400 fn test_unlimited_above_with_limited_below_does_not_panic() {
1401 use md022_config::{HeadingBlankRequirement, HeadingLevelConfig};
1402
1403 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1407 lines_above: HeadingLevelConfig::scalar_requirement(HeadingBlankRequirement::Unlimited),
1408 lines_below: HeadingLevelConfig::scalar(1),
1409 allowed_at_start: false,
1410 });
1411
1412 let content = "# Title\n\nText\n## Banana\nText\n";
1414 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1415
1416 let warnings = rule.check(&ctx).expect("check must not fail");
1417
1418 assert!(
1419 warnings.iter().any(|w| w.message.contains("below")),
1420 "expected a 'below' violation, got: {:?}",
1421 warnings.iter().map(|w| &w.message).collect::<Vec<_>>()
1422 );
1423 assert!(
1424 !warnings.iter().any(|w| w.message.contains("above")),
1425 "an unlimited 'above' requirement must never report: {:?}",
1426 warnings.iter().map(|w| &w.message).collect::<Vec<_>>()
1427 );
1428 }
1429
1430 #[test]
1431 fn test_unlimited_below_with_limited_above_does_not_panic() {
1432 use md022_config::{HeadingBlankRequirement, HeadingLevelConfig};
1433
1434 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1435 lines_above: HeadingLevelConfig::scalar(1),
1436 lines_below: HeadingLevelConfig::scalar_requirement(HeadingBlankRequirement::Unlimited),
1437 allowed_at_start: false,
1438 });
1439
1440 let content = "# Title\n\nText\n## Banana\n\nText\n";
1442 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1443
1444 let warnings = rule.check(&ctx).expect("check must not fail");
1445
1446 assert!(
1447 warnings.iter().any(|w| w.message.contains("above")),
1448 "expected an 'above' violation, got: {:?}",
1449 warnings.iter().map(|w| &w.message).collect::<Vec<_>>()
1450 );
1451 assert!(
1452 !warnings.iter().any(|w| w.message.contains("below")),
1453 "an unlimited 'below' requirement must never report: {:?}",
1454 warnings.iter().map(|w| &w.message).collect::<Vec<_>>()
1455 );
1456 }
1457
1458 #[test]
1459 fn test_per_level_configuration_different_requirements() {
1460 use md022_config::HeadingLevelConfig;
1461
1462 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1464 lines_above: HeadingLevelConfig::per_level([0, 1, 1, 2, 2, 2]),
1465 lines_below: HeadingLevelConfig::scalar(1),
1466 allowed_at_start: false,
1467 });
1468
1469 let content = "Text\n# H1\n\nText\n\n## H2\n\nText\n\n### H3\n\nText\n\n\n#### H4\n\nText";
1470 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1471 let warnings = rule.check(&ctx).unwrap();
1472
1473 assert_eq!(
1475 warnings.len(),
1476 0,
1477 "All headings should satisfy level-specific requirements"
1478 );
1479 }
1480
1481 #[test]
1482 fn test_per_level_configuration_violations() {
1483 use md022_config::HeadingLevelConfig;
1484
1485 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1487 lines_above: HeadingLevelConfig::per_level([1, 1, 1, 2, 1, 1]),
1488 lines_below: HeadingLevelConfig::scalar(1),
1489 allowed_at_start: false,
1490 });
1491
1492 let content = "Text\n\n#### Heading 4\n\nMore text";
1494 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1495 let warnings = rule.check(&ctx).unwrap();
1496
1497 assert_eq!(warnings.len(), 1, "H4 with insufficient blanks should trigger warning");
1498 assert!(warnings[0].message.contains("2 blank lines above"));
1499 }
1500
1501 #[test]
1502 fn test_per_level_fix_different_levels() {
1503 use md022_config::HeadingLevelConfig;
1504
1505 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1507 lines_above: HeadingLevelConfig::per_level([0, 1, 2, 2, 2, 2]),
1508 lines_below: HeadingLevelConfig::scalar(1),
1509 allowed_at_start: false,
1510 });
1511
1512 let content = "Text\n# H1\nContent\n## H2\nContent\n### H3\nContent";
1513 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1514 let fixed = rule.fix(&ctx).unwrap();
1515
1516 assert!(fixed.contains("Text\n# H1\n\nContent"));
1518 assert!(fixed.contains("Content\n\n## H2\n\nContent"));
1519 assert!(fixed.contains("Content\n\n\n### H3\n\nContent"));
1520 }
1521
1522 #[test]
1523 fn test_per_level_below_configuration() {
1524 use md022_config::HeadingLevelConfig;
1525
1526 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1528 lines_above: HeadingLevelConfig::scalar(1),
1529 lines_below: HeadingLevelConfig::per_level([2, 1, 1, 1, 1, 1]), allowed_at_start: true,
1531 });
1532
1533 let content = "# Heading 1\n\nSome text";
1535 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1536 let warnings = rule.check(&ctx).unwrap();
1537
1538 assert_eq!(
1539 warnings.len(),
1540 1,
1541 "H1 with insufficient blanks below should trigger warning"
1542 );
1543 assert!(warnings[0].message.contains("2 blank lines below"));
1544 }
1545
1546 #[test]
1547 fn test_scalar_configuration_still_works() {
1548 use md022_config::HeadingLevelConfig;
1549
1550 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1552 lines_above: HeadingLevelConfig::scalar(2),
1553 lines_below: HeadingLevelConfig::scalar(2),
1554 allowed_at_start: false,
1555 });
1556
1557 let content = "Text\n# H1\nContent\n## H2\nContent";
1558 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1559 let warnings = rule.check(&ctx).unwrap();
1560
1561 assert!(!warnings.is_empty(), "Should have violations for insufficient blanks");
1563 }
1564
1565 #[test]
1566 fn test_unlimited_configuration_skips_requirements() {
1567 use md022_config::{HeadingBlankRequirement, HeadingLevelConfig};
1568
1569 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1571 lines_above: HeadingLevelConfig::per_level_requirements([
1572 HeadingBlankRequirement::unlimited(),
1573 HeadingBlankRequirement::limited(1),
1574 HeadingBlankRequirement::limited(1),
1575 HeadingBlankRequirement::limited(1),
1576 HeadingBlankRequirement::limited(1),
1577 HeadingBlankRequirement::limited(1),
1578 ]),
1579 lines_below: HeadingLevelConfig::per_level_requirements([
1580 HeadingBlankRequirement::unlimited(),
1581 HeadingBlankRequirement::limited(1),
1582 HeadingBlankRequirement::limited(1),
1583 HeadingBlankRequirement::limited(1),
1584 HeadingBlankRequirement::limited(1),
1585 HeadingBlankRequirement::limited(1),
1586 ]),
1587 allowed_at_start: false,
1588 });
1589
1590 let content = "# H1\nParagraph\n## H2\nParagraph";
1591 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1592 let warnings = rule.check(&ctx).unwrap();
1593
1594 assert_eq!(warnings.len(), 2, "Only non-unlimited headings should warn");
1596 assert!(
1597 warnings.iter().all(|w| w.line >= 3),
1598 "Warnings should target later headings"
1599 );
1600
1601 let fixed = rule.fix(&ctx).unwrap();
1603 assert!(
1604 fixed.starts_with("# H1\nParagraph\n\n## H2"),
1605 "H1 should remain unchanged"
1606 );
1607 }
1608
1609 #[test]
1610 fn test_html_comment_transparency() {
1611 let rule = MD022BlanksAroundHeadings::default();
1615
1616 let content = "Some content\n\n<!-- markdownlint-disable-next-line MD001 -->\n#### Heading";
1619 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1620 let warnings = rule.check(&ctx).unwrap();
1621 assert!(
1622 warnings.is_empty(),
1623 "HTML comment is transparent - blank line above it counts for heading"
1624 );
1625
1626 let content_multiline = "Some content\n\n<!-- This is a\nmulti-line comment -->\n#### Heading";
1628 let ctx_multiline = LintContext::new(content_multiline, crate::config::MarkdownFlavor::Standard, None);
1629 let warnings_multiline = rule.check(&ctx_multiline).unwrap();
1630 assert!(
1631 warnings_multiline.is_empty(),
1632 "Multi-line HTML comment is also transparent"
1633 );
1634 }
1635
1636 #[test]
1637 fn test_frontmatter_transparency() {
1638 let rule = MD022BlanksAroundHeadings::default();
1641
1642 let content = "---\ntitle: Test\n---\n# First heading";
1644 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1645 let warnings = rule.check(&ctx).unwrap();
1646 assert!(
1647 warnings.is_empty(),
1648 "Frontmatter is transparent - heading can appear immediately after"
1649 );
1650
1651 let content_with_blank = "---\ntitle: Test\n---\n\n# First heading";
1653 let ctx_with_blank = LintContext::new(content_with_blank, crate::config::MarkdownFlavor::Standard, None);
1654 let warnings_with_blank = rule.check(&ctx_with_blank).unwrap();
1655 assert!(
1656 warnings_with_blank.is_empty(),
1657 "Heading with blank line after frontmatter should also be valid"
1658 );
1659
1660 let content_toml = "+++\ntitle = \"Test\"\n+++\n# First heading";
1662 let ctx_toml = LintContext::new(content_toml, crate::config::MarkdownFlavor::Standard, None);
1663 let warnings_toml = rule.check(&ctx_toml).unwrap();
1664 assert!(
1665 warnings_toml.is_empty(),
1666 "TOML frontmatter is also transparent for MD022"
1667 );
1668 }
1669
1670 #[test]
1671 fn test_horizontal_rule_not_treated_as_frontmatter() {
1672 let rule = MD022BlanksAroundHeadings::default();
1675
1676 let content = "Some content\n\n---\n# Heading after HR";
1678 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1679 let warnings = rule.check(&ctx).unwrap();
1680 assert!(
1681 !warnings.is_empty(),
1682 "Heading after horizontal rule without blank line SHOULD trigger MD022"
1683 );
1684 assert!(
1685 warnings.iter().any(|w| w.line == 4),
1686 "Warning should be on line 4 (the heading line)"
1687 );
1688
1689 let content_with_blank = "Some content\n\n---\n\n# Heading after HR";
1691 let ctx_with_blank = LintContext::new(content_with_blank, crate::config::MarkdownFlavor::Standard, None);
1692 let warnings_with_blank = rule.check(&ctx_with_blank).unwrap();
1693 assert!(
1694 warnings_with_blank.is_empty(),
1695 "Heading with blank line after HR should not trigger MD022"
1696 );
1697
1698 let content_hr_start = "---\n# Heading";
1700 let ctx_hr_start = LintContext::new(content_hr_start, crate::config::MarkdownFlavor::Standard, None);
1701 let warnings_hr_start = rule.check(&ctx_hr_start).unwrap();
1702 assert!(
1703 !warnings_hr_start.is_empty(),
1704 "Heading after HR at document start SHOULD trigger MD022"
1705 );
1706
1707 let content_multi_hr = "Content\n\n---\n\n---\n# Heading";
1709 let ctx_multi_hr = LintContext::new(content_multi_hr, crate::config::MarkdownFlavor::Standard, None);
1710 let warnings_multi_hr = rule.check(&ctx_multi_hr).unwrap();
1711 assert!(
1712 !warnings_multi_hr.is_empty(),
1713 "Heading after multiple HRs without blank line SHOULD trigger MD022"
1714 );
1715 }
1716
1717 #[test]
1718 fn test_all_hr_styles_require_blank_before_heading() {
1719 let rule = MD022BlanksAroundHeadings::default();
1721
1722 let hr_styles = [
1724 "---", "***", "___", "- - -", "* * *", "_ _ _", "----", "****", "____", "- - - -",
1725 "- - -", " ---", " ---", ];
1729
1730 for hr in hr_styles {
1731 let content = format!("Content\n\n{hr}\n# Heading");
1732 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1733 let warnings = rule.check(&ctx).unwrap();
1734 assert!(
1735 !warnings.is_empty(),
1736 "HR style '{hr}' followed by heading should trigger MD022"
1737 );
1738 }
1739 }
1740
1741 #[test]
1742 fn test_setext_heading_after_hr() {
1743 let rule = MD022BlanksAroundHeadings::default();
1745
1746 let content = "Content\n\n---\nHeading\n======";
1748 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1749 let warnings = rule.check(&ctx).unwrap();
1750 assert!(
1751 !warnings.is_empty(),
1752 "Setext heading after HR without blank should trigger MD022"
1753 );
1754
1755 let content_h2 = "Content\n\n---\nHeading\n------";
1757 let ctx_h2 = LintContext::new(content_h2, crate::config::MarkdownFlavor::Standard, None);
1758 let warnings_h2 = rule.check(&ctx_h2).unwrap();
1759 assert!(
1760 !warnings_h2.is_empty(),
1761 "Setext h2 after HR without blank should trigger MD022"
1762 );
1763
1764 let content_ok = "Content\n\n---\n\nHeading\n======";
1766 let ctx_ok = LintContext::new(content_ok, crate::config::MarkdownFlavor::Standard, None);
1767 let warnings_ok = rule.check(&ctx_ok).unwrap();
1768 assert!(
1769 warnings_ok.is_empty(),
1770 "Setext heading with blank after HR should not warn"
1771 );
1772 }
1773
1774 #[test]
1775 fn test_hr_in_code_block_not_treated_as_hr() {
1776 let rule = MD022BlanksAroundHeadings::default();
1778
1779 let content = "```\n---\n```\n# Heading";
1782 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1783 let warnings = rule.check(&ctx).unwrap();
1784 assert!(!warnings.is_empty(), "Heading after code block still needs blank line");
1787
1788 let content_ok = "```\n---\n```\n\n# Heading";
1790 let ctx_ok = LintContext::new(content_ok, crate::config::MarkdownFlavor::Standard, None);
1791 let warnings_ok = rule.check(&ctx_ok).unwrap();
1792 assert!(
1793 warnings_ok.is_empty(),
1794 "Heading with blank after code block should not warn"
1795 );
1796 }
1797
1798 #[test]
1799 fn test_hr_in_html_comment_not_treated_as_hr() {
1800 let rule = MD022BlanksAroundHeadings::default();
1802
1803 let content = "<!-- \n---\n -->\n# Heading";
1805 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1806 let warnings = rule.check(&ctx).unwrap();
1807 assert!(
1809 warnings.is_empty(),
1810 "HR inside HTML comment should be ignored - heading after comment is OK"
1811 );
1812 }
1813
1814 #[test]
1815 fn test_invalid_hr_not_triggering() {
1816 let rule = MD022BlanksAroundHeadings::default();
1818
1819 let invalid_hrs = [
1820 " ---", "\t---", "--", "**", "__", "-*-", "---a", "a---", ];
1829
1830 for invalid in invalid_hrs {
1831 let content = format!("Content\n\n{invalid}\n# Heading");
1834 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1835 let _ = rule.check(&ctx);
1838 }
1839 }
1840
1841 #[test]
1842 fn test_frontmatter_vs_horizontal_rule_distinction() {
1843 let rule = MD022BlanksAroundHeadings::default();
1845
1846 let content = "---\ntitle: Test\n---\n\nSome content\n\n---\n# Heading after HR";
1849 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1850 let warnings = rule.check(&ctx).unwrap();
1851 assert!(
1852 !warnings.is_empty(),
1853 "HR after frontmatter content should still require blank line before heading"
1854 );
1855
1856 let content_ok = "---\ntitle: Test\n---\n\nSome content\n\n---\n\n# Heading after HR";
1858 let ctx_ok = LintContext::new(content_ok, crate::config::MarkdownFlavor::Standard, None);
1859 let warnings_ok = rule.check(&ctx_ok).unwrap();
1860 assert!(
1861 warnings_ok.is_empty(),
1862 "HR with blank line before heading should not warn"
1863 );
1864 }
1865
1866 #[test]
1869 fn test_kramdown_ial_after_heading_no_warning() {
1870 let rule = MD022BlanksAroundHeadings::default();
1872 let content = "## Table of Contents\n{: .hhc-toc-heading}\n\nSome content here.";
1873 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1874 let warnings = rule.check(&ctx).unwrap();
1875
1876 assert!(
1877 warnings.is_empty(),
1878 "IAL after heading should not require blank line between them: {warnings:?}"
1879 );
1880 }
1881
1882 #[test]
1883 fn test_kramdown_ial_with_class() {
1884 let rule = MD022BlanksAroundHeadings::default();
1885 let content = "# Heading\n{:.highlight}\n\nContent.";
1886 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1887 let warnings = rule.check(&ctx).unwrap();
1888
1889 assert!(warnings.is_empty(), "IAL with class should be part of heading");
1890 }
1891
1892 #[test]
1893 fn test_kramdown_ial_with_id() {
1894 let rule = MD022BlanksAroundHeadings::default();
1895 let content = "# Heading\n{:#custom-id}\n\nContent.";
1896 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1897 let warnings = rule.check(&ctx).unwrap();
1898
1899 assert!(warnings.is_empty(), "IAL with id should be part of heading");
1900 }
1901
1902 #[test]
1903 fn test_kramdown_ial_with_multiple_attributes() {
1904 let rule = MD022BlanksAroundHeadings::default();
1905 let content = "# Heading\n{: .class #id style=\"color: red\"}\n\nContent.";
1906 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1907 let warnings = rule.check(&ctx).unwrap();
1908
1909 assert!(
1910 warnings.is_empty(),
1911 "IAL with multiple attributes should be part of heading"
1912 );
1913 }
1914
1915 #[test]
1916 fn test_kramdown_ial_missing_blank_after() {
1917 let rule = MD022BlanksAroundHeadings::default();
1919 let content = "# Heading\n{:.class}\nContent without blank.";
1920 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1921 let warnings = rule.check(&ctx).unwrap();
1922
1923 assert_eq!(
1924 warnings.len(),
1925 1,
1926 "Should warn about missing blank after IAL (part of heading)"
1927 );
1928 assert!(warnings[0].message.contains("below"));
1929 }
1930
1931 #[test]
1932 fn test_kramdown_ial_before_heading_transparent() {
1933 let rule = MD022BlanksAroundHeadings::default();
1935 let content = "Content.\n\n{:.preclass}\n## Heading\n\nMore content.";
1936 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1937 let warnings = rule.check(&ctx).unwrap();
1938
1939 assert!(
1940 warnings.is_empty(),
1941 "IAL before heading should be transparent for blank line count"
1942 );
1943 }
1944
1945 #[test]
1946 fn test_kramdown_ial_setext_heading() {
1947 let rule = MD022BlanksAroundHeadings::default();
1948 let content = "Heading\n=======\n{:.setext-class}\n\nContent.";
1949 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1950 let warnings = rule.check(&ctx).unwrap();
1951
1952 assert!(
1953 warnings.is_empty(),
1954 "IAL after Setext heading should be part of heading"
1955 );
1956 }
1957
1958 #[test]
1959 fn test_kramdown_ial_fix_preserves_ial() {
1960 let rule = MD022BlanksAroundHeadings::default();
1961 let content = "Content.\n# Heading\n{:.class}\nMore content.";
1962 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1963 let fixed = rule.fix(&ctx).unwrap();
1964
1965 assert!(
1967 fixed.contains("# Heading\n{:.class}"),
1968 "IAL should stay attached to heading"
1969 );
1970 assert!(fixed.contains("{:.class}\n\nMore"), "Should add blank after IAL");
1971 }
1972
1973 #[test]
1974 fn test_kramdown_ial_fix_does_not_separate() {
1975 let rule = MD022BlanksAroundHeadings::default();
1976 let content = "# Heading\n{:.class}\nContent.";
1977 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1978 let fixed = rule.fix(&ctx).unwrap();
1979
1980 assert!(
1982 !fixed.contains("# Heading\n\n{:.class}"),
1983 "Should not add blank between heading and IAL"
1984 );
1985 assert!(fixed.contains("# Heading\n{:.class}"), "IAL should remain attached");
1986 }
1987
1988 #[test]
1989 fn test_kramdown_multiple_ial_lines() {
1990 let rule = MD022BlanksAroundHeadings::default();
1992 let content = "# Heading\n{:.class1}\n{:#id}\n\nContent.";
1993 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1994 let warnings = rule.check(&ctx).unwrap();
1995
1996 assert!(
1999 warnings.is_empty(),
2000 "Multiple consecutive IALs should be part of heading"
2001 );
2002 }
2003
2004 #[test]
2005 fn test_kramdown_ial_with_blank_line_not_attached() {
2006 let rule = MD022BlanksAroundHeadings::default();
2008 let content = "# Heading\n\n{:.class}\nContent.";
2009 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2010 let warnings = rule.check(&ctx).unwrap();
2011
2012 assert!(warnings.is_empty(), "Blank line separates heading from IAL");
2016 }
2017
2018 #[test]
2019 fn test_not_kramdown_ial_regular_braces() {
2020 let rule = MD022BlanksAroundHeadings::default();
2022 let content = "# Heading\n{not an ial}\n\nContent.";
2023 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2024 let warnings = rule.check(&ctx).unwrap();
2025
2026 assert_eq!(
2028 warnings.len(),
2029 1,
2030 "Non-IAL braces should be regular content requiring blank"
2031 );
2032 }
2033
2034 #[test]
2035 fn test_kramdown_ial_at_document_end() {
2036 let rule = MD022BlanksAroundHeadings::default();
2037 let content = "# Heading\n{:.class}";
2038 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2039 let warnings = rule.check(&ctx).unwrap();
2040
2041 assert!(warnings.is_empty(), "IAL at document end needs no blank after");
2043 }
2044
2045 #[test]
2046 fn test_kramdown_ial_followed_by_code_fence() {
2047 let rule = MD022BlanksAroundHeadings::default();
2048 let content = "# Heading\n{:.class}\n```\ncode\n```";
2049 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2050 let warnings = rule.check(&ctx).unwrap();
2051
2052 assert!(warnings.is_empty(), "No blank needed between IAL and code fence");
2054 }
2055
2056 #[test]
2057 fn test_kramdown_ial_followed_by_list() {
2058 let rule = MD022BlanksAroundHeadings::default();
2059 let content = "# Heading\n{:.class}\n- List item";
2060 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2061 let warnings = rule.check(&ctx).unwrap();
2062
2063 assert!(warnings.is_empty(), "No blank needed between IAL and list");
2065 }
2066
2067 #[test]
2068 fn test_kramdown_ial_fix_idempotent() {
2069 let rule = MD022BlanksAroundHeadings::default();
2070 let content = "# Heading\n{:.class}\nContent.";
2071 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2072
2073 let fixed_once = rule.fix(&ctx).unwrap();
2074 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
2075 let fixed_twice = rule.fix(&ctx2).unwrap();
2076
2077 assert_eq!(fixed_once, fixed_twice, "Fix should be idempotent");
2078 }
2079
2080 #[test]
2081 fn test_kramdown_ial_whitespace_line_between_not_attached() {
2082 let rule = MD022BlanksAroundHeadings::default();
2085 let content = "# Heading\n \n{:.class}\n\nContent.";
2086 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2087 let warnings = rule.check(&ctx).unwrap();
2088
2089 assert!(
2093 warnings.is_empty(),
2094 "Whitespace between heading and IAL means IAL is not attached"
2095 );
2096 }
2097
2098 #[test]
2099 fn test_kramdown_ial_html_comment_between() {
2100 let rule = MD022BlanksAroundHeadings::default();
2103 let content = "# Heading\n<!-- comment -->\n{:.class}\n\nContent.";
2104 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2105 let warnings = rule.check(&ctx).unwrap();
2106
2107 assert!(
2110 warnings.is_empty(),
2111 "A comment-only line below the heading is its blank line: {warnings:?}"
2112 );
2113 }
2114
2115 #[test]
2116 fn test_kramdown_ial_text_beside_comment_between_is_still_reported() {
2117 let rule = MD022BlanksAroundHeadings::default();
2120 let content = "# Heading\ntext <!-- comment -->\n{:.class}\n\nContent.";
2121 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2122 let warnings = rule.check(&ctx).unwrap();
2123
2124 assert_eq!(warnings.len(), 1, "Heading followed by prose: {warnings:?}");
2125 }
2126
2127 #[test]
2128 fn test_kramdown_ial_generic_attribute() {
2129 let rule = MD022BlanksAroundHeadings::default();
2130 let content = "# Heading\n{:data-toc=\"true\" style=\"color: red\"}\n\nContent.";
2131 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2132 let warnings = rule.check(&ctx).unwrap();
2133
2134 assert!(warnings.is_empty(), "Generic attributes should be recognized as IAL");
2135 }
2136
2137 #[test]
2138 fn test_kramdown_ial_fix_multiple_lines_preserves_all() {
2139 let rule = MD022BlanksAroundHeadings::default();
2140 let content = "# Heading\n{:.class1}\n{:#id}\n{:data-x=\"y\"}\nContent.";
2141 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2142
2143 let fixed = rule.fix(&ctx).unwrap();
2144
2145 assert!(fixed.contains("{:.class1}"), "First IAL should be preserved");
2147 assert!(fixed.contains("{:#id}"), "Second IAL should be preserved");
2148 assert!(fixed.contains("{:data-x=\"y\"}"), "Third IAL should be preserved");
2149 assert!(
2151 fixed.contains("{:data-x=\"y\"}\n\nContent"),
2152 "Blank line should be after all IALs"
2153 );
2154 }
2155
2156 #[test]
2157 fn test_kramdown_ial_crlf_line_endings() {
2158 let rule = MD022BlanksAroundHeadings::default();
2159 let content = "# Heading\r\n{:.class}\r\n\r\nContent.";
2160 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2161 let warnings = rule.check(&ctx).unwrap();
2162
2163 assert!(warnings.is_empty(), "CRLF should work correctly with IAL");
2164 }
2165
2166 #[test]
2167 fn test_kramdown_ial_invalid_patterns_not_recognized() {
2168 let rule = MD022BlanksAroundHeadings::default();
2169
2170 let content = "# Heading\n{ :.class}\n\nContent.";
2172 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2173 let warnings = rule.check(&ctx).unwrap();
2174 assert_eq!(warnings.len(), 1, "Invalid IAL syntax should trigger warning");
2175
2176 let content2 = "# Heading\n{.class}\n\nContent.";
2178 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
2179 let warnings2 = rule.check(&ctx2).unwrap();
2180 assert!(warnings2.is_empty(), "{{.class}} is valid kramdown block attribute");
2182
2183 let content3 = "# Heading\n{just text}\n\nContent.";
2185 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
2186 let warnings3 = rule.check(&ctx3).unwrap();
2187 assert_eq!(
2188 warnings3.len(),
2189 1,
2190 "Text in braces is not IAL and should trigger warning"
2191 );
2192 }
2193
2194 #[test]
2195 fn test_kramdown_ial_toc_marker() {
2196 let rule = MD022BlanksAroundHeadings::default();
2198 let content = "# Heading\n{:toc}\n\nContent.";
2199 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2200 let warnings = rule.check(&ctx).unwrap();
2201
2202 assert!(warnings.is_empty(), "{{:toc}} should be recognized as IAL");
2204 }
2205
2206 #[test]
2207 fn test_kramdown_ial_mixed_headings_in_document() {
2208 let rule = MD022BlanksAroundHeadings::default();
2209 let content = r#"# ATX Heading
2210{:.atx-class}
2211
2212Content after ATX.
2213
2214Setext Heading
2215--------------
2216{:#setext-id}
2217
2218Content after Setext.
2219
2220## Another ATX
2221{:.another}
2222
2223More content."#;
2224 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2225 let warnings = rule.check(&ctx).unwrap();
2226
2227 assert!(
2228 warnings.is_empty(),
2229 "Mixed headings with IAL should all work: {warnings:?}"
2230 );
2231 }
2232
2233 #[test]
2234 fn test_kramdown_extension_block_before_first_heading_is_document_start() {
2235 let rule = MD022BlanksAroundHeadings::default();
2236 let content = "{::comment}\nhidden\n{:/comment}\n# Heading\n\nBody\n";
2237 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Kramdown, None);
2238 let warnings = rule.check(&ctx).unwrap();
2239
2240 assert!(
2241 warnings.is_empty(),
2242 "Kramdown extension preamble should not require blank above first heading: {warnings:?}"
2243 );
2244 }
2245
2246 #[test]
2247 fn test_kramdown_ial_before_first_heading_is_document_start() {
2248 let rule = MD022BlanksAroundHeadings::default();
2249 let content = "{:.doc-class}\n# Heading\n\nBody\n";
2250 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Kramdown, None);
2251 let warnings = rule.check(&ctx).unwrap();
2252
2253 assert!(
2254 warnings.is_empty(),
2255 "Kramdown IAL preamble should not require blank above first heading: {warnings:?}"
2256 );
2257 }
2258
2259 #[test]
2262 fn test_quarto_div_marker_transparent_above_heading() {
2263 let rule = MD022BlanksAroundHeadings::default();
2266 let content = "Content\n\n::: {.callout-note}\n# Heading\n\nMore content\n:::\n";
2268 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
2269 let warnings = rule.check(&ctx).unwrap();
2270 assert!(
2272 warnings.is_empty(),
2273 "Quarto div marker should be transparent above heading: {warnings:?}"
2274 );
2275 }
2276
2277 #[test]
2278 fn test_quarto_div_marker_transparent_below_heading() {
2279 let rule = MD022BlanksAroundHeadings::default();
2281 let content = "# Heading\n\n::: {.callout-note}\nContent\n:::\n";
2282 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
2283 let warnings = rule.check(&ctx).unwrap();
2284 assert!(
2286 warnings.is_empty(),
2287 "Quarto div marker should be transparent below heading: {warnings:?}"
2288 );
2289 }
2290
2291 #[test]
2292 fn test_quarto_heading_inside_callout() {
2293 let rule = MD022BlanksAroundHeadings::default();
2295 let content = "::: {.callout-note}\n\n## Note Title\n\nNote content\n:::\n";
2296 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
2297 let warnings = rule.check(&ctx).unwrap();
2298 assert!(
2299 warnings.is_empty(),
2300 "Heading inside Quarto callout should have no warnings: {warnings:?}"
2301 );
2302 }
2303
2304 #[test]
2305 fn test_quarto_heading_at_start_after_div_open() {
2306 let rule = MD022BlanksAroundHeadings::default();
2309 let content = "::: {.callout-warning}\n# Warning\n\nContent\n:::\n";
2311 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
2312 let warnings = rule.check(&ctx).unwrap();
2313 assert!(
2319 warnings.is_empty(),
2320 "Heading at start after div open should pass: {warnings:?}"
2321 );
2322 }
2323
2324 #[test]
2325 fn test_quarto_heading_before_div_close() {
2326 let rule = MD022BlanksAroundHeadings::default();
2328 let content = "::: {.callout-note}\nIntro\n\n## Section\n:::\n";
2329 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
2330 let warnings = rule.check(&ctx).unwrap();
2331 assert!(
2335 warnings.is_empty(),
2336 "Heading before div close should pass: {warnings:?}"
2337 );
2338 }
2339
2340 #[test]
2341 fn test_quarto_div_markers_not_transparent_in_standard_flavor() {
2342 let rule = MD022BlanksAroundHeadings::default();
2344 let content = "Content\n\n:::\n# Heading\n\n:::\n";
2345 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2346 let warnings = rule.check(&ctx).unwrap();
2347 assert!(
2349 !warnings.is_empty(),
2350 "Standard flavor should not treat ::: as transparent: {warnings:?}"
2351 );
2352 }
2353
2354 #[test]
2355 fn test_quarto_nested_divs_with_heading() {
2356 let rule = MD022BlanksAroundHeadings::default();
2358 let content = "::: {.outer}\n::: {.inner}\n\n# Heading\n\nContent\n:::\n:::\n";
2359 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
2360 let warnings = rule.check(&ctx).unwrap();
2361 assert!(
2362 warnings.is_empty(),
2363 "Nested divs with heading should work: {warnings:?}"
2364 );
2365 }
2366
2367 #[test]
2368 fn test_quarto_fix_preserves_div_markers() {
2369 let rule = MD022BlanksAroundHeadings::default();
2371 let content = "::: {.callout-note}\n\n## Note\n\nContent\n:::\n";
2372 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
2373 let fixed = rule.fix(&ctx).unwrap();
2374 assert!(fixed.contains("::: {.callout-note}"), "Should preserve div opening");
2376 assert!(fixed.contains(":::"), "Should preserve div closing");
2377 assert!(fixed.contains("## Note"), "Should preserve heading");
2378 }
2379
2380 #[test]
2381 fn test_quarto_heading_needs_blank_without_div_transparency() {
2382 let rule = MD022BlanksAroundHeadings::default();
2385 let content = "Content\n::: {.callout-note}\n# Heading\n\nMore\n:::\n";
2387 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
2388 let warnings = rule.check(&ctx).unwrap();
2389 assert!(
2392 !warnings.is_empty(),
2393 "Should still require blank line when not present: {warnings:?}"
2394 );
2395 }
2396
2397 #[test]
2398 fn test_pandoc_div_marker_transparent_above_heading() {
2399 let rule = MD022BlanksAroundHeadings::default();
2402 let content = "Content\n\n::: {.callout-note}\n# Heading\n\nMore content\n:::\n";
2403 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
2404 let warnings = rule.check(&ctx).unwrap();
2405 assert!(
2406 warnings.is_empty(),
2407 "MD022 should treat Pandoc div marker as transparent above heading: {warnings:?}"
2408 );
2409 }
2410
2411 #[test]
2412 fn test_hugo_block_attribute_after_heading_not_flagged() {
2413 let rule = MD022BlanksAroundHeadings::default();
2416 let content = "Intro text.\n\n# Heading\n{class=\"anchor\"}\n\nContent.\n";
2417
2418 for flavor in [
2419 crate::config::MarkdownFlavor::Hugo,
2420 crate::config::MarkdownFlavor::MkDocs,
2421 crate::config::MarkdownFlavor::Kramdown,
2422 ] {
2423 let ctx = LintContext::new(content, flavor, None);
2424 let warnings = rule.check(&ctx).unwrap();
2425 assert!(
2426 warnings.is_empty(),
2427 "MD022 should not flag the block attribute line under {flavor:?}: {warnings:?}"
2428 );
2429 }
2430
2431 let ctx_std = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2434 let warnings_std = rule.check(&ctx_std).unwrap();
2435 assert!(
2436 warnings_std.iter().any(|w| w.message.contains("below heading")),
2437 "MD022 must flag the missing blank below the heading under Standard: {warnings_std:?}"
2438 );
2439 }
2440
2441 #[test]
2442 fn test_mdg_keeps_tags_attached_only_to_structure_headings() {
2443 let rule = MD022BlanksAroundHeadings::default();
2444
2445 let attached = "`@browser`\n`@checkout` `@smoke`\n# Feature: Checkout\n";
2447 let mdg_ctx = LintContext::new(attached, crate::config::MarkdownFlavor::MDG, None);
2448 assert!(rule.check(&mdg_ctx).unwrap().is_empty());
2449 let fixed = rule.fix(&mdg_ctx).unwrap();
2450 assert_eq!(fixed, attached);
2451 let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
2452 assert_eq!(rule.fix(&fixed_ctx).unwrap(), fixed, "MDG fix should be idempotent");
2453
2454 let standard_ctx = LintContext::new(attached, crate::config::MarkdownFlavor::Standard, None);
2455 assert!(
2456 rule.check(&standard_ctx)
2457 .unwrap()
2458 .iter()
2459 .any(|warning| warning.message.contains("above heading"))
2460 );
2461 }
2462
2463 #[test]
2464 fn test_mdg_requires_blank_line_above_a_non_structure_heading() {
2465 let rule = MD022BlanksAroundHeadings::default();
2468 let content = "`@browser`\n# Notes\n";
2469 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
2470
2471 assert!(
2472 rule.check(&ctx)
2473 .unwrap()
2474 .iter()
2475 .any(|warning| warning.message.contains("above heading")),
2476 "a non-Gherkin heading keeps the normal requirement"
2477 );
2478 assert_eq!(rule.fix(&ctx).unwrap(), "`@browser`\n\n# Notes\n");
2479 }
2480
2481 #[test]
2482 fn test_mdg_colon_inside_a_code_span_names_no_structure() {
2483 let rule = MD022BlanksAroundHeadings::default();
2487 let content = "`@browser`\n# See `x: y` Notes\n";
2488 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
2489
2490 assert!(
2491 rule.check(&ctx)
2492 .unwrap()
2493 .iter()
2494 .any(|warning| warning.message.contains("above heading")),
2495 "the code span holds the only colon, so the heading is ordinary prose"
2496 );
2497 assert_eq!(rule.fix(&ctx).unwrap(), "`@browser`\n\n# See `x: y` Notes\n");
2498
2499 let structure = "`@browser`\n# Scenario: use `a: b` here\n";
2501 let structure_ctx = LintContext::new(structure, crate::config::MarkdownFlavor::MDG, None);
2502 assert!(rule.check(&structure_ctx).unwrap().is_empty());
2503 assert_eq!(rule.fix(&structure_ctx).unwrap(), structure);
2504 }
2505
2506 #[test]
2507 fn test_mdg_tag_line_matches_gherkin_reference_scan() {
2508 let rule = MD022BlanksAroundHeadings::default();
2511
2512 for above in [
2513 "`@comment_tag1` #a comment",
2514 "`@comment_tag#2` #a comment",
2515 "`@browser` and prose",
2516 "prose `@browser`",
2517 "`@a b`",
2518 ] {
2519 let content = format!("{above}\n# Feature: Checkout\n");
2520 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::MDG, None);
2521 assert!(rule.check(&ctx).unwrap().is_empty(), "{above:?} is a Gherkin tag line");
2522 assert_eq!(rule.fix(&ctx).unwrap(), content);
2523 }
2524
2525 let prose = "plain prose\n# Feature: Checkout\n";
2526 let ctx = LintContext::new(prose, crate::config::MarkdownFlavor::MDG, None);
2527 assert!(
2528 rule.check(&ctx)
2529 .unwrap()
2530 .iter()
2531 .any(|warning| warning.message.contains("above heading"))
2532 );
2533 }
2534
2535 #[test]
2536 fn test_fix_keeps_a_setext_heading_suppressed_on_a_later_line_as_written() {
2537 let rule = MD022BlanksAroundHeadings::default();
2542 let content = "Intro paragraph.\n# Heading one\nText after.\nTitle\nsecond <!-- rumdl-disable-line MD022 -->\n===\nMore text.\n";
2543 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2544
2545 assert_eq!(
2546 rule.fix(&ctx).unwrap(),
2547 "Intro paragraph.\n\n# Heading one\n\nText after.\nTitle\nsecond <!-- rumdl-disable-line MD022 -->\n===\nMore text.\n"
2548 );
2549 }
2550}
2551
2552#[cfg(test)]
2556mod list_item_tests {
2557 use super::*;
2558 use crate::config::MarkdownFlavor;
2559 use crate::lint_context::LintContext;
2560
2561 fn fixed(content: &str) -> String {
2564 let rule = MD022BlanksAroundHeadings::default();
2565 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
2566 let by_fix = rule.fix(&ctx).unwrap();
2567 let warnings = rule.check(&ctx).unwrap();
2568 let by_warnings = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
2569 assert_eq!(by_fix, by_warnings, "{content:?}: fix and warnings disagree");
2570 by_fix
2571 }
2572
2573 #[test]
2574 fn a_heading_under_a_bare_marker_gets_no_blank_line_above() {
2575 for (content, expected) in [
2576 ("-\n ## a\n text\n", "-\n ## a\n\n text\n"),
2577 ("1.\n ## a\n text\n", "1.\n ## a\n\n text\n"),
2578 ("- \n ## a\n text\n", "- \n ## a\n\n text\n"),
2579 ("-\n ## a\n text\n", "-\n ## a\n\n text\n"),
2580 ("-\n -\n ## a\n text\n", "-\n -\n ## a\n\n text\n"),
2581 ("-\n a\n ---\n text\n", "-\n a\n ---\n\n text\n"),
2582 ("-\n a\n b\n ===\n text\n", "-\n a\n b\n ===\n\n text\n"),
2583 ("1.\n ## a\n", "1.\n ## a\n"),
2584 ("- -\n ## a\n text\n", "- -\n ## a\n\n text\n"),
2586 ("- -\n a\n ---\n text\n", "- -\n a\n ---\n\n text\n"),
2587 ("1. -\n ## a\n", "1. -\n ## a\n"),
2588 ("- 1.\n ## a\n", "- 1.\n ## a\n"),
2589 ] {
2590 assert_eq!(fixed(content), expected, "{content:?}");
2591 assert_eq!(fixed(expected), expected, "{expected:?} is not stable");
2592 }
2593 }
2594
2595 #[test]
2596 fn a_blank_line_is_still_added_where_it_changes_no_container() {
2597 for (content, expected) in [
2598 ("- x\n ## a\n text\n", "- x\n\n ## a\n\n text\n"),
2600 ("-\n ## a\n", "-\n\n ## a\n"),
2602 ("Text\n## a\n", "Text\n\n## a\n"),
2603 ("1. x\n -\n\n ## a\n", "1. x\n -\n\n ## a\n"),
2604 ] {
2605 assert_eq!(fixed(content), expected, "{content:?}");
2606 }
2607 }
2608
2609 #[test]
2610 fn check_does_not_ask_for_a_blank_line_under_a_bare_marker() {
2611 let rule = MD022BlanksAroundHeadings::default();
2612 for content in ["-\n ## a\n", "1.\n ## a\n", "-\n -\n ## a\n", "-\n a\n ---\n"] {
2613 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
2614 let warnings = rule.check(&ctx).unwrap();
2615 assert!(warnings.is_empty(), "{content:?}: {warnings:?}");
2616 }
2617 let ctx = LintContext::new("-\n ## a\n", MarkdownFlavor::Standard, None);
2618 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
2619 }
2620}