1use crate::lint_context::is_horizontal_rule_content;
2use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
6use crate::utils::mdg;
7use crate::utils::mkdocs_attr_list::is_block_attribute_line;
8use crate::utils::pandoc;
9use crate::utils::range_utils::calculate_heading_range;
10use toml;
11
12pub(crate) mod md022_config;
13use md022_config::MD022Config;
14
15fn starts_with_list_marker(trimmed: &str) -> bool {
30 if is_horizontal_rule_content(trimmed) {
31 return false;
32 }
33 let bytes = trimmed.as_bytes();
34 match bytes.first() {
35 Some(b'-' | b'*' | b'+') => matches!(bytes.get(1), None | Some(b' ')),
36 Some(b'0'..=b'9') => {
37 let mut i = 0;
38 while bytes.get(i).is_some_and(u8::is_ascii_digit) {
39 i += 1;
40 }
41 matches!(bytes.get(i), Some(b'.' | b')')) && matches!(bytes.get(i + 1), None | Some(b' '))
42 }
43 _ => false,
44 }
45}
46
47fn follows_mdg_tag_line(
60 ctx: &crate::lint_context::LintContext,
61 heading_idx: usize,
62 heading: &crate::lint_context::HeadingInfo,
63) -> bool {
64 heading_idx > 0
65 && mdg::keyword_split(&heading.text).is_some()
66 && mdg::is_tag_line(ctx.lines[heading_idx - 1].content(ctx.content))
67}
68
69#[derive(Clone, Default)]
141pub struct MD022BlanksAroundHeadings {
142 config: MD022Config,
143}
144
145impl MD022BlanksAroundHeadings {
146 pub fn new() -> Self {
149 Self {
150 config: MD022Config::default(),
151 }
152 }
153
154 pub fn with_values(lines_above: usize, lines_below: usize) -> Self {
156 use md022_config::HeadingLevelConfig;
157 Self {
158 config: MD022Config {
159 lines_above: HeadingLevelConfig::scalar(lines_above),
160 lines_below: HeadingLevelConfig::scalar(lines_below),
161 allowed_at_start: true,
162 },
163 }
164 }
165
166 pub fn from_config_struct(config: MD022Config) -> Self {
167 Self { config }
168 }
169
170 fn fix_content(&self, ctx: &crate::lint_context::LintContext) -> String {
172 let line_ending = "\n";
175 let had_trailing_newline = ctx.content.ends_with('\n');
176 let is_pandoc = ctx.flavor.is_pandoc_compatible();
177 let is_mdg = ctx.flavor == crate::config::MarkdownFlavor::MDG;
178 let mut result = Vec::new();
179 let mut skip_count: usize = 0;
180
181 let heading_at_start_idx = {
182 let mut found_non_transparent = false;
183 ctx.lines.iter().enumerate().find_map(|(i, line)| {
184 if line.heading.as_ref().is_some_and(|h| h.is_valid) && !found_non_transparent {
186 Some(i)
187 } else {
188 if !line.is_blank && !line.in_html_comment && !line.in_mdx_comment {
191 let trimmed = line.content(ctx.content).trim();
192 if trimmed.starts_with("<!--") && trimmed.ends_with("-->") {
194 } else if line.in_kramdown_extension_block || line.is_kramdown_block_ial {
196 } else if is_pandoc && (pandoc::is_div_open(trimmed) || pandoc::is_div_close(trimmed)) {
198 } else {
200 found_non_transparent = true;
201 }
202 }
203 None
204 }
205 })
206 };
207
208 for (i, line_info) in ctx.lines.iter().enumerate() {
209 if skip_count > 0 {
210 skip_count -= 1;
211 continue;
212 }
213 let line = line_info.content(ctx.content);
214
215 if line_info.in_code_block {
216 result.push(line.to_string());
217 continue;
218 }
219
220 if let Some(heading) = &line_info.heading {
222 if !heading.is_valid {
224 result.push(line.to_string());
225 continue;
226 }
227
228 let line_num = i + 1;
230 if ctx.inline_config().is_rule_disabled("MD022", line_num) {
231 result.push(line.to_string());
232 if matches!(
234 heading.style,
235 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
236 ) && i + 1 < ctx.lines.len()
237 {
238 result.push(ctx.lines[i + 1].content(ctx.content).to_string());
239 skip_count += 1;
240 }
241 continue;
242 }
243
244 let is_first_heading = Some(i) == heading_at_start_idx;
246 let heading_level = heading.level as usize;
247
248 let mut blank_lines_above = 0;
250 let mut check_idx = result.len();
251 while check_idx > 0 {
252 let prev_line = &result[check_idx - 1];
253 let trimmed = prev_line.trim();
254 if trimmed.is_empty() {
255 blank_lines_above += 1;
256 check_idx -= 1;
257 } else if trimmed.starts_with("<!--") && trimmed.ends_with("-->") {
258 check_idx -= 1;
260 } else if is_block_attribute_line(trimmed, ctx.flavor) {
261 check_idx -= 1;
263 } else if is_pandoc && (pandoc::is_div_open(trimmed) || pandoc::is_div_close(trimmed)) {
264 check_idx -= 1;
266 } else {
267 break;
268 }
269 }
270
271 let requirement_above = self.config.lines_above.get_for_level(heading_level);
273 let follows_mdg_tags = is_mdg && follows_mdg_tag_line(ctx, i, heading);
274 let needed_blanks_above = if follows_mdg_tags || (is_first_heading && self.config.allowed_at_start) {
275 0
276 } else {
277 requirement_above.required_count().unwrap_or(0)
278 };
279
280 while blank_lines_above < needed_blanks_above {
282 result.push(String::new());
283 blank_lines_above += 1;
284 }
285
286 result.push(line.to_string());
288
289 let mut effective_end_idx = i;
291
292 if matches!(
294 heading.style,
295 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
296 ) {
297 if i + 1 < ctx.lines.len() {
299 result.push(ctx.lines[i + 1].content(ctx.content).to_string());
300 skip_count += 1; effective_end_idx = i + 1;
302 }
303 }
304
305 let mut ial_count = 0;
308 while effective_end_idx + 1 < ctx.lines.len() {
309 let next_line = &ctx.lines[effective_end_idx + 1];
310 let next_trimmed = next_line.content(ctx.content).trim();
311 if is_block_attribute_line(next_trimmed, ctx.flavor) {
312 result.push(next_trimmed.to_string());
313 effective_end_idx += 1;
314 ial_count += 1;
315 } else {
316 break;
317 }
318 }
319
320 let mut blank_lines_below = 0;
322 let mut next_content_line_idx = None;
323 for j in (effective_end_idx + 1)..ctx.lines.len() {
324 if ctx.lines[j].is_blank {
325 blank_lines_below += 1;
326 } else {
327 next_content_line_idx = Some(j);
328 break;
329 }
330 }
331
332 let next_is_special = if let Some(idx) = next_content_line_idx {
334 let next_line = &ctx.lines[idx];
335 let trimmed = next_line.content(ctx.content).trim();
336 next_line.list_item.is_some()
337 || starts_with_list_marker(trimmed)
338 || ((trimmed.starts_with("```") || trimmed.starts_with("~~~"))
339 && (trimmed.len() == 3
340 || (trimmed.len() > 3
341 && trimmed
342 .chars()
343 .nth(3)
344 .is_some_and(|c| c.is_whitespace() || c.is_alphabetic()))))
345 } else {
346 false
347 };
348
349 let requirement_below = self.config.lines_below.get_for_level(heading_level);
351 let needed_blanks_below = if next_is_special {
352 0
353 } else {
354 requirement_below.required_count().unwrap_or(0)
355 };
356 if blank_lines_below < needed_blanks_below {
357 for _ in 0..(needed_blanks_below - blank_lines_below) {
358 result.push(String::new());
359 }
360 }
361
362 skip_count += ial_count;
364 } else {
365 result.push(line.to_string());
367 }
368 }
369
370 let joined = result.join(line_ending);
371
372 if had_trailing_newline && !joined.ends_with('\n') {
374 format!("{joined}{line_ending}")
375 } else if !had_trailing_newline && joined.ends_with('\n') {
376 joined[..joined.len() - 1].to_string()
378 } else {
379 joined
380 }
381 }
382}
383
384impl Rule for MD022BlanksAroundHeadings {
385 fn name(&self) -> &'static str {
386 "MD022"
387 }
388
389 fn description(&self) -> &'static str {
390 "Headings should be surrounded by blank lines"
391 }
392
393 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
394 let mut result = Vec::new();
395
396 if ctx.lines.is_empty() {
398 return Ok(result);
399 }
400
401 let line_ending = "\n";
404 let is_pandoc = ctx.flavor.is_pandoc_compatible();
405 let is_mdg = ctx.flavor == crate::config::MarkdownFlavor::MDG;
406
407 let heading_at_start_idx = {
408 let mut found_non_transparent = false;
409 ctx.lines.iter().enumerate().find_map(|(i, line)| {
410 if line.heading.as_ref().is_some_and(|h| h.is_valid) && !found_non_transparent {
412 Some(i)
413 } else {
414 if !line.is_blank && !line.in_html_comment && !line.in_mdx_comment {
417 let trimmed = line.content(ctx.content).trim();
418 if trimmed.starts_with("<!--") && trimmed.ends_with("-->") {
420 } else if line.in_kramdown_extension_block || line.is_kramdown_block_ial {
422 } else if is_pandoc && (pandoc::is_div_open(trimmed) || pandoc::is_div_close(trimmed)) {
424 } else {
426 found_non_transparent = true;
427 }
428 }
429 None
430 }
431 })
432 };
433
434 let mut heading_violations = Vec::new();
436 let mut processed_headings = std::collections::HashSet::new();
437
438 for (line_num, line_info) in ctx.lines.iter().enumerate() {
439 if processed_headings.contains(&line_num) || line_info.heading.is_none() {
441 continue;
442 }
443
444 if line_info.in_pymdown_block {
446 continue;
447 }
448
449 let heading = line_info.heading.as_ref().unwrap();
450
451 if !heading.is_valid {
453 continue;
454 }
455
456 let heading_level = heading.level as usize;
457
458 processed_headings.insert(line_num);
462
463 let is_first_heading = Some(line_num) == heading_at_start_idx;
465
466 let required_above_count = self.config.lines_above.get_for_level(heading_level).required_count();
468 let required_below_count = self.config.lines_below.get_for_level(heading_level).required_count();
469
470 let should_check_above = required_above_count.is_some()
472 && line_num > 0
473 && (!is_first_heading || !self.config.allowed_at_start)
474 && !(is_mdg && follows_mdg_tag_line(ctx, line_num, heading));
475 if should_check_above {
476 let mut blank_lines_above = 0;
477 let mut hit_frontmatter_end = false;
478 for j in (0..line_num).rev() {
479 let line_content = ctx.lines[j].content(ctx.content);
480 let trimmed = line_content.trim();
481 if ctx.lines[j].is_blank {
482 blank_lines_above += 1;
483 } else if ctx.lines[j].in_html_comment
484 || ctx.lines[j].in_mdx_comment
485 || (trimmed.starts_with("<!--") && trimmed.ends_with("-->"))
486 {
487 continue;
489 } else if is_block_attribute_line(trimmed, ctx.flavor) {
490 continue;
492 } else if is_pandoc && (pandoc::is_div_open(trimmed) || pandoc::is_div_close(trimmed)) {
493 continue;
495 } else if ctx.lines[j].in_front_matter {
496 hit_frontmatter_end = true;
501 break;
502 } else {
503 break;
504 }
505 }
506 let required = required_above_count.unwrap();
507 if !hit_frontmatter_end && blank_lines_above < required {
508 let needed_blanks = required - blank_lines_above;
509 heading_violations.push((line_num, "above", needed_blanks, heading_level));
510 }
511 }
512
513 let mut effective_last_line = if matches!(
515 heading.style,
516 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
517 ) {
518 line_num + 1 } else {
520 line_num
521 };
522
523 while effective_last_line + 1 < ctx.lines.len() {
526 let next_line = &ctx.lines[effective_last_line + 1];
527 let next_trimmed = next_line.content(ctx.content).trim();
528 if is_block_attribute_line(next_trimmed, ctx.flavor) {
529 effective_last_line += 1;
530 } else {
531 break;
532 }
533 }
534
535 if effective_last_line < ctx.lines.len() - 1 {
537 let mut next_non_blank_idx = effective_last_line + 1;
539 while next_non_blank_idx < ctx.lines.len() {
540 let check_line = &ctx.lines[next_non_blank_idx];
541 let check_trimmed = check_line.content(ctx.content).trim();
542 if check_line.is_blank {
543 next_non_blank_idx += 1;
544 } else if check_line.in_html_comment
545 || check_line.in_mdx_comment
546 || (check_trimmed.starts_with("<!--") && check_trimmed.ends_with("-->"))
547 {
548 next_non_blank_idx += 1;
550 } else if is_pandoc && (pandoc::is_div_open(check_trimmed) || pandoc::is_div_close(check_trimmed)) {
551 next_non_blank_idx += 1;
553 } else {
554 break;
555 }
556 }
557
558 if next_non_blank_idx >= ctx.lines.len() {
560 continue;
562 }
563
564 let next_line_is_special = {
566 let next_line = &ctx.lines[next_non_blank_idx];
567 let next_trimmed = next_line.content(ctx.content).trim();
568
569 let is_code_fence = (next_trimmed.starts_with("```") || next_trimmed.starts_with("~~~"))
571 && (next_trimmed.len() == 3
572 || (next_trimmed.len() > 3
573 && next_trimmed
574 .chars()
575 .nth(3)
576 .is_some_and(|c| c.is_whitespace() || c.is_alphabetic())));
577
578 let is_list_item = next_line.list_item.is_some() || starts_with_list_marker(next_trimmed);
585
586 is_code_fence || is_list_item
587 };
588
589 if !next_line_is_special && let Some(required) = required_below_count {
591 let mut blank_lines_below = 0;
593 for k in (effective_last_line + 1)..next_non_blank_idx {
594 if ctx.lines[k].is_blank {
595 blank_lines_below += 1;
596 }
597 }
598
599 if blank_lines_below < required {
600 let needed_blanks = required - blank_lines_below;
601 heading_violations.push((line_num, "below", needed_blanks, heading_level));
602 }
603 }
604 }
605 }
606
607 for (heading_line, position, needed_blanks, heading_level) in heading_violations {
609 let heading_display_line = heading_line + 1; let line_info = &ctx.lines[heading_line];
611
612 let (start_line, start_col, end_line, end_col) =
614 calculate_heading_range(heading_display_line, line_info.content(ctx.content));
615
616 let (message, insertion_point) = match position {
623 "above" => {
624 let Some(required_above_count) =
625 self.config.lines_above.get_for_level(heading_level).required_count()
626 else {
627 continue;
628 };
629 (
630 format!(
631 "Expected {} blank {} above heading",
632 required_above_count,
633 if required_above_count == 1 { "line" } else { "lines" }
634 ),
635 heading_line, )
637 }
638 "below" => {
639 let Some(required_below_count) =
640 self.config.lines_below.get_for_level(heading_level).required_count()
641 else {
642 continue;
643 };
644 let insert_after = if line_info.heading.as_ref().is_some_and(|h| {
646 matches!(
647 h.style,
648 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
649 )
650 }) {
651 heading_line + 2
652 } else {
653 heading_line + 1
654 };
655
656 (
657 format!(
658 "Expected {} blank {} below heading",
659 required_below_count,
660 if required_below_count == 1 { "line" } else { "lines" }
661 ),
662 insert_after,
663 )
664 }
665 _ => continue,
666 };
667
668 let byte_range = if insertion_point == 0 && position == "above" {
670 0..0
672 } else if position == "above" && insertion_point > 0 {
673 ctx.lines[insertion_point].byte_offset..ctx.lines[insertion_point].byte_offset
675 } else if position == "below" && insertion_point - 1 < ctx.lines.len() {
676 let line_idx = insertion_point - 1;
678 let line_end_offset = if line_idx + 1 < ctx.lines.len() {
679 ctx.lines[line_idx + 1].byte_offset
680 } else {
681 ctx.content.len()
682 };
683 line_end_offset..line_end_offset
684 } else {
685 let content_len = ctx.content.len();
687 content_len..content_len
688 };
689
690 result.push(LintWarning {
691 rule_name: Some(self.name().to_string()),
692 message,
693 line: start_line,
694 column: start_col,
695 end_line,
696 end_column: end_col,
697 severity: Severity::Warning,
698 fix: Some(Fix::new(byte_range, line_ending.repeat(needed_blanks))),
699 });
700 }
701
702 Ok(result)
703 }
704
705 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
706 if ctx.content.is_empty() {
707 return Ok(ctx.content.to_string());
708 }
709
710 let fixed = self.fix_content(ctx);
712
713 Ok(fixed)
714 }
715
716 fn category(&self) -> RuleCategory {
718 RuleCategory::Heading
719 }
720
721 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
723 if ctx.content.is_empty() || !ctx.likely_has_headings() {
725 return true;
726 }
727 ctx.lines.iter().all(|line| line.heading.is_none())
729 }
730
731 fn as_any(&self) -> &dyn std::any::Any {
732 self
733 }
734
735 crate::impl_rule_config_methods!(MD022Config);
736}
737
738#[cfg(test)]
739mod tests {
740 use super::*;
741 use crate::lint_context::LintContext;
742
743 #[test]
744 fn test_valid_headings() {
745 let rule = MD022BlanksAroundHeadings::default();
746 let content = "\n# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.\n";
747 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
748 let result = rule.check(&ctx).unwrap();
749 assert!(result.is_empty());
750 }
751
752 #[test]
753 fn test_missing_blank_above() {
754 let rule = MD022BlanksAroundHeadings::default();
755 let content = "# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.\n";
756 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
757 let result = rule.check(&ctx).unwrap();
758 assert_eq!(result.len(), 0); let fixed = rule.fix(&ctx).unwrap();
761
762 assert!(fixed.contains("# Heading 1"));
765 assert!(fixed.contains("Some content."));
766 assert!(fixed.contains("## Heading 2"));
767 assert!(fixed.contains("More content."));
768 }
769
770 #[test]
771 fn test_missing_blank_below() {
772 let rule = MD022BlanksAroundHeadings::default();
773 let content = "\n# Heading 1\nSome content.\n\n## Heading 2\n\nMore content.\n";
774 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
775 let result = rule.check(&ctx).unwrap();
776 assert_eq!(result.len(), 1);
777 assert_eq!(result[0].line, 2);
778
779 let fixed = rule.fix(&ctx).unwrap();
781 assert!(fixed.contains("# Heading 1\n\nSome content"));
782 }
783
784 #[test]
785 fn test_missing_blank_above_and_below() {
786 let rule = MD022BlanksAroundHeadings::default();
787 let content = "# Heading 1\nSome content.\n## Heading 2\nMore content.\n";
788 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
789 let result = rule.check(&ctx).unwrap();
790 assert_eq!(result.len(), 3); let fixed = rule.fix(&ctx).unwrap();
794 assert!(fixed.contains("# Heading 1\n\nSome content"));
795 assert!(fixed.contains("Some content.\n\n## Heading 2"));
796 assert!(fixed.contains("## Heading 2\n\nMore content"));
797 }
798
799 #[test]
800 fn test_fix_headings() {
801 let rule = MD022BlanksAroundHeadings::default();
802 let content = "# Heading 1\nSome content.\n## Heading 2\nMore content.";
803 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
804 let result = rule.fix(&ctx).unwrap();
805
806 let expected = "# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.";
807 assert_eq!(result, expected);
808 }
809
810 #[test]
811 fn test_consecutive_headings_pattern() {
812 let rule = MD022BlanksAroundHeadings::default();
813 let content = "# Heading 1\n## Heading 2\n### Heading 3";
814 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
815 let result = rule.fix(&ctx).unwrap();
816
817 let lines: Vec<&str> = result.lines().collect();
819 assert!(!lines.is_empty());
820
821 let h1_pos = lines.iter().position(|&l| l == "# Heading 1").unwrap();
823 let h2_pos = lines.iter().position(|&l| l == "## Heading 2").unwrap();
824 let h3_pos = lines.iter().position(|&l| l == "### Heading 3").unwrap();
825
826 assert!(
828 h2_pos > h1_pos + 1,
829 "Should have at least one blank line after first heading"
830 );
831 assert!(
832 h3_pos > h2_pos + 1,
833 "Should have at least one blank line after second heading"
834 );
835
836 assert!(lines[h1_pos + 1].trim().is_empty(), "Line after h1 should be blank");
838
839 assert!(lines[h2_pos + 1].trim().is_empty(), "Line after h2 should be blank");
841 }
842
843 #[test]
844 fn test_blanks_around_setext_headings() {
845 let rule = MD022BlanksAroundHeadings::default();
846 let content = "Heading 1\n=========\nSome content.\nHeading 2\n---------\nMore content.";
847 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
848 let result = rule.fix(&ctx).unwrap();
849
850 let lines: Vec<&str> = result.lines().collect();
852
853 assert!(result.contains("Heading 1"));
855 assert!(result.contains("========="));
856 assert!(result.contains("Some content."));
857 assert!(result.contains("Heading 2"));
858 assert!(result.contains("---------"));
859 assert!(result.contains("More content."));
860
861 let heading1_marker_idx = lines.iter().position(|&l| l == "=========").unwrap();
863 let some_content_idx = lines.iter().position(|&l| l == "Some content.").unwrap();
864 assert!(
865 some_content_idx > heading1_marker_idx + 1,
866 "Should have a blank line after the first heading"
867 );
868
869 let heading2_marker_idx = lines.iter().position(|&l| l == "---------").unwrap();
870 let more_content_idx = lines.iter().position(|&l| l == "More content.").unwrap();
871 assert!(
872 more_content_idx > heading2_marker_idx + 1,
873 "Should have a blank line after the second heading"
874 );
875
876 let fixed_ctx = LintContext::new(&result, crate::config::MarkdownFlavor::Standard, None);
878 let fixed_warnings = rule.check(&fixed_ctx).unwrap();
879 assert!(fixed_warnings.is_empty(), "Fixed content should have no warnings");
880 }
881
882 #[test]
883 fn test_fix_specific_blank_line_cases() {
884 let rule = MD022BlanksAroundHeadings::default();
885
886 let content1 = "# Heading 1\n## Heading 2\n### Heading 3";
888 let ctx1 = LintContext::new(content1, crate::config::MarkdownFlavor::Standard, None);
889 let result1 = rule.fix(&ctx1).unwrap();
890 assert!(result1.contains("# Heading 1"));
892 assert!(result1.contains("## Heading 2"));
893 assert!(result1.contains("### Heading 3"));
894 let lines: Vec<&str> = result1.lines().collect();
896 let h1_pos = lines.iter().position(|&l| l == "# Heading 1").unwrap();
897 let h2_pos = lines.iter().position(|&l| l == "## Heading 2").unwrap();
898 assert!(lines[h1_pos + 1].trim().is_empty(), "Should have a blank line after h1");
899 assert!(lines[h2_pos + 1].trim().is_empty(), "Should have a blank line after h2");
900
901 let content2 = "# Heading 1\nContent under heading 1\n## Heading 2";
903 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
904 let result2 = rule.fix(&ctx2).unwrap();
905 assert!(result2.contains("# Heading 1"));
907 assert!(result2.contains("Content under heading 1"));
908 assert!(result2.contains("## Heading 2"));
909 let lines2: Vec<&str> = result2.lines().collect();
911 let h1_pos2 = lines2.iter().position(|&l| l == "# Heading 1").unwrap();
912 let _content_pos = lines2.iter().position(|&l| l == "Content under heading 1").unwrap();
913 assert!(
914 lines2[h1_pos2 + 1].trim().is_empty(),
915 "Should have a blank line after heading 1"
916 );
917
918 let content3 = "# Heading 1\n\n\n## Heading 2\n\n\n### Heading 3\n\nContent";
920 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
921 let result3 = rule.fix(&ctx3).unwrap();
922 assert!(result3.contains("# Heading 1"));
924 assert!(result3.contains("## Heading 2"));
925 assert!(result3.contains("### Heading 3"));
926 assert!(result3.contains("Content"));
927 }
928
929 #[test]
930 fn test_fix_preserves_existing_blank_lines() {
931 let rule = MD022BlanksAroundHeadings::new();
932 let content = "# Title
933
934## Section 1
935
936Content here.
937
938## Section 2
939
940More content.
941### Missing Blank Above
942
943Even more content.
944
945## Section 3
946
947Final content.";
948
949 let expected = "# Title
950
951## Section 1
952
953Content here.
954
955## Section 2
956
957More content.
958
959### Missing Blank Above
960
961Even more content.
962
963## Section 3
964
965Final content.";
966
967 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
968 let result = rule.fix_content(&ctx);
969 assert_eq!(
970 result, expected,
971 "Fix should only add missing blank lines, never remove existing ones"
972 );
973 }
974
975 #[test]
976 fn test_fix_preserves_trailing_newline() {
977 let rule = MD022BlanksAroundHeadings::new();
978
979 let content_with_newline = "# Title\nContent here.\n";
981 let ctx = LintContext::new(content_with_newline, crate::config::MarkdownFlavor::Standard, None);
982 let result = rule.fix(&ctx).unwrap();
983 assert!(result.ends_with('\n'), "Should preserve trailing newline");
984
985 let content_without_newline = "# Title\nContent here.";
987 let ctx = LintContext::new(content_without_newline, crate::config::MarkdownFlavor::Standard, None);
988 let result = rule.fix(&ctx).unwrap();
989 assert!(
990 !result.ends_with('\n'),
991 "Should not add trailing newline if original didn't have one"
992 );
993 }
994
995 #[test]
996 fn test_fix_does_not_add_blank_lines_before_lists() {
997 let rule = MD022BlanksAroundHeadings::new();
998 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.";
999
1000 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.";
1001
1002 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1003 let result = rule.fix_content(&ctx);
1004 assert_eq!(result, expected, "Fix should not add blank lines before lists");
1005 }
1006
1007 #[test]
1008 fn test_fix_idempotent_when_heading_follows_a_list_and_precedes_a_marker() {
1009 let rule = MD022BlanksAroundHeadings::default();
1015 let content = "- a\n# H\n2. ";
1016 for flavor in [
1017 crate::config::MarkdownFlavor::Standard,
1018 crate::config::MarkdownFlavor::MkDocs,
1019 crate::config::MarkdownFlavor::MDX,
1020 ] {
1021 let ctx1 = LintContext::new(content, flavor, None);
1022 let fixed1 = rule.fix(&ctx1).unwrap();
1023 let ctx2 = LintContext::new(&fixed1, flavor, None);
1024 let fixed2 = rule.fix(&ctx2).unwrap();
1025 assert_eq!(fixed1, fixed2, "MD022 fix must be idempotent (flavor={flavor:?})");
1026 }
1027 }
1028
1029 #[test]
1030 fn test_thematic_break_below_heading_is_not_a_list_item() {
1031 let rule = MD022BlanksAroundHeadings::default();
1038 for marker in [
1039 "* * *",
1040 "- - -",
1041 "_ _ _",
1042 "***",
1043 "---",
1044 "___",
1045 "- --",
1046 "* ** *",
1047 "---- ----",
1048 ] {
1049 let content = format!("text\n\n# Heading\n{marker}\nafter\n");
1050 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1051 let result = rule.check(&ctx).unwrap();
1052 assert_eq!(
1053 result.len(),
1054 1,
1055 "a heading above `{marker}` needs a blank line below it, got {result:?}"
1056 );
1057 assert_eq!(
1058 rule.fix(&ctx).unwrap(),
1059 format!("text\n\n# Heading\n\n{marker}\nafter\n"),
1060 "fix must insert the blank line below the heading for `{marker}`"
1061 );
1062 }
1063 }
1064
1065 #[test]
1066 fn test_list_item_below_heading_is_still_exempt() {
1067 let rule = MD022BlanksAroundHeadings::default();
1070 for item in ["- item", "* item", "+ item", "1. item", "+ + +"] {
1071 let content = format!("text\n\n# Heading\n{item}\n");
1072 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1073 assert!(
1074 rule.check(&ctx).unwrap().is_empty(),
1075 "a list below a heading stays exempt, but `{item}` was reported"
1076 );
1077 assert_eq!(rule.fix(&ctx).unwrap(), content, "`{item}` must not be rewritten");
1078 }
1079 }
1080
1081 #[test]
1082 fn test_per_level_configuration_no_blank_above_h1() {
1083 use md022_config::HeadingLevelConfig;
1084
1085 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1087 lines_above: HeadingLevelConfig::per_level([0, 1, 1, 1, 1, 1]),
1088 lines_below: HeadingLevelConfig::scalar(1),
1089 allowed_at_start: false, });
1091
1092 let content = "Some text\n# Heading 1\n\nMore text";
1094 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1095 let warnings = rule.check(&ctx).unwrap();
1096 assert_eq!(warnings.len(), 0, "H1 without blank above should not trigger warning");
1097
1098 let content = "Some text\n## Heading 2\n\nMore text";
1100 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1101 let warnings = rule.check(&ctx).unwrap();
1102 assert_eq!(warnings.len(), 1, "H2 without blank above should trigger warning");
1103 assert!(warnings[0].message.contains("above"));
1104 }
1105
1106 #[test]
1107 fn test_unlimited_above_with_limited_below_does_not_panic() {
1108 use md022_config::{HeadingBlankRequirement, HeadingLevelConfig};
1109
1110 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1114 lines_above: HeadingLevelConfig::scalar_requirement(HeadingBlankRequirement::Unlimited),
1115 lines_below: HeadingLevelConfig::scalar(1),
1116 allowed_at_start: false,
1117 });
1118
1119 let content = "# Title\n\nText\n## Banana\nText\n";
1121 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1122
1123 let warnings = rule.check(&ctx).expect("check must not fail");
1124
1125 assert!(
1126 warnings.iter().any(|w| w.message.contains("below")),
1127 "expected a 'below' violation, got: {:?}",
1128 warnings.iter().map(|w| &w.message).collect::<Vec<_>>()
1129 );
1130 assert!(
1131 !warnings.iter().any(|w| w.message.contains("above")),
1132 "an unlimited 'above' requirement must never report: {:?}",
1133 warnings.iter().map(|w| &w.message).collect::<Vec<_>>()
1134 );
1135 }
1136
1137 #[test]
1138 fn test_unlimited_below_with_limited_above_does_not_panic() {
1139 use md022_config::{HeadingBlankRequirement, HeadingLevelConfig};
1140
1141 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1142 lines_above: HeadingLevelConfig::scalar(1),
1143 lines_below: HeadingLevelConfig::scalar_requirement(HeadingBlankRequirement::Unlimited),
1144 allowed_at_start: false,
1145 });
1146
1147 let content = "# Title\n\nText\n## Banana\n\nText\n";
1149 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1150
1151 let warnings = rule.check(&ctx).expect("check must not fail");
1152
1153 assert!(
1154 warnings.iter().any(|w| w.message.contains("above")),
1155 "expected an 'above' violation, got: {:?}",
1156 warnings.iter().map(|w| &w.message).collect::<Vec<_>>()
1157 );
1158 assert!(
1159 !warnings.iter().any(|w| w.message.contains("below")),
1160 "an unlimited 'below' requirement must never report: {:?}",
1161 warnings.iter().map(|w| &w.message).collect::<Vec<_>>()
1162 );
1163 }
1164
1165 #[test]
1166 fn test_per_level_configuration_different_requirements() {
1167 use md022_config::HeadingLevelConfig;
1168
1169 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1171 lines_above: HeadingLevelConfig::per_level([0, 1, 1, 2, 2, 2]),
1172 lines_below: HeadingLevelConfig::scalar(1),
1173 allowed_at_start: false,
1174 });
1175
1176 let content = "Text\n# H1\n\nText\n\n## H2\n\nText\n\n### H3\n\nText\n\n\n#### H4\n\nText";
1177 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1178 let warnings = rule.check(&ctx).unwrap();
1179
1180 assert_eq!(
1182 warnings.len(),
1183 0,
1184 "All headings should satisfy level-specific requirements"
1185 );
1186 }
1187
1188 #[test]
1189 fn test_per_level_configuration_violations() {
1190 use md022_config::HeadingLevelConfig;
1191
1192 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1194 lines_above: HeadingLevelConfig::per_level([1, 1, 1, 2, 1, 1]),
1195 lines_below: HeadingLevelConfig::scalar(1),
1196 allowed_at_start: false,
1197 });
1198
1199 let content = "Text\n\n#### Heading 4\n\nMore text";
1201 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1202 let warnings = rule.check(&ctx).unwrap();
1203
1204 assert_eq!(warnings.len(), 1, "H4 with insufficient blanks should trigger warning");
1205 assert!(warnings[0].message.contains("2 blank lines above"));
1206 }
1207
1208 #[test]
1209 fn test_per_level_fix_different_levels() {
1210 use md022_config::HeadingLevelConfig;
1211
1212 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1214 lines_above: HeadingLevelConfig::per_level([0, 1, 2, 2, 2, 2]),
1215 lines_below: HeadingLevelConfig::scalar(1),
1216 allowed_at_start: false,
1217 });
1218
1219 let content = "Text\n# H1\nContent\n## H2\nContent\n### H3\nContent";
1220 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1221 let fixed = rule.fix(&ctx).unwrap();
1222
1223 assert!(fixed.contains("Text\n# H1\n\nContent"));
1225 assert!(fixed.contains("Content\n\n## H2\n\nContent"));
1226 assert!(fixed.contains("Content\n\n\n### H3\n\nContent"));
1227 }
1228
1229 #[test]
1230 fn test_per_level_below_configuration() {
1231 use md022_config::HeadingLevelConfig;
1232
1233 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1235 lines_above: HeadingLevelConfig::scalar(1),
1236 lines_below: HeadingLevelConfig::per_level([2, 1, 1, 1, 1, 1]), allowed_at_start: true,
1238 });
1239
1240 let content = "# Heading 1\n\nSome text";
1242 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1243 let warnings = rule.check(&ctx).unwrap();
1244
1245 assert_eq!(
1246 warnings.len(),
1247 1,
1248 "H1 with insufficient blanks below should trigger warning"
1249 );
1250 assert!(warnings[0].message.contains("2 blank lines below"));
1251 }
1252
1253 #[test]
1254 fn test_scalar_configuration_still_works() {
1255 use md022_config::HeadingLevelConfig;
1256
1257 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1259 lines_above: HeadingLevelConfig::scalar(2),
1260 lines_below: HeadingLevelConfig::scalar(2),
1261 allowed_at_start: false,
1262 });
1263
1264 let content = "Text\n# H1\nContent\n## H2\nContent";
1265 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1266 let warnings = rule.check(&ctx).unwrap();
1267
1268 assert!(!warnings.is_empty(), "Should have violations for insufficient blanks");
1270 }
1271
1272 #[test]
1273 fn test_unlimited_configuration_skips_requirements() {
1274 use md022_config::{HeadingBlankRequirement, HeadingLevelConfig};
1275
1276 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1278 lines_above: HeadingLevelConfig::per_level_requirements([
1279 HeadingBlankRequirement::unlimited(),
1280 HeadingBlankRequirement::limited(1),
1281 HeadingBlankRequirement::limited(1),
1282 HeadingBlankRequirement::limited(1),
1283 HeadingBlankRequirement::limited(1),
1284 HeadingBlankRequirement::limited(1),
1285 ]),
1286 lines_below: HeadingLevelConfig::per_level_requirements([
1287 HeadingBlankRequirement::unlimited(),
1288 HeadingBlankRequirement::limited(1),
1289 HeadingBlankRequirement::limited(1),
1290 HeadingBlankRequirement::limited(1),
1291 HeadingBlankRequirement::limited(1),
1292 HeadingBlankRequirement::limited(1),
1293 ]),
1294 allowed_at_start: false,
1295 });
1296
1297 let content = "# H1\nParagraph\n## H2\nParagraph";
1298 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1299 let warnings = rule.check(&ctx).unwrap();
1300
1301 assert_eq!(warnings.len(), 2, "Only non-unlimited headings should warn");
1303 assert!(
1304 warnings.iter().all(|w| w.line >= 3),
1305 "Warnings should target later headings"
1306 );
1307
1308 let fixed = rule.fix(&ctx).unwrap();
1310 assert!(
1311 fixed.starts_with("# H1\nParagraph\n\n## H2"),
1312 "H1 should remain unchanged"
1313 );
1314 }
1315
1316 #[test]
1317 fn test_html_comment_transparency() {
1318 let rule = MD022BlanksAroundHeadings::default();
1322
1323 let content = "Some content\n\n<!-- markdownlint-disable-next-line MD001 -->\n#### Heading";
1326 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1327 let warnings = rule.check(&ctx).unwrap();
1328 assert!(
1329 warnings.is_empty(),
1330 "HTML comment is transparent - blank line above it counts for heading"
1331 );
1332
1333 let content_multiline = "Some content\n\n<!-- This is a\nmulti-line comment -->\n#### Heading";
1335 let ctx_multiline = LintContext::new(content_multiline, crate::config::MarkdownFlavor::Standard, None);
1336 let warnings_multiline = rule.check(&ctx_multiline).unwrap();
1337 assert!(
1338 warnings_multiline.is_empty(),
1339 "Multi-line HTML comment is also transparent"
1340 );
1341 }
1342
1343 #[test]
1344 fn test_frontmatter_transparency() {
1345 let rule = MD022BlanksAroundHeadings::default();
1348
1349 let content = "---\ntitle: Test\n---\n# First heading";
1351 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1352 let warnings = rule.check(&ctx).unwrap();
1353 assert!(
1354 warnings.is_empty(),
1355 "Frontmatter is transparent - heading can appear immediately after"
1356 );
1357
1358 let content_with_blank = "---\ntitle: Test\n---\n\n# First heading";
1360 let ctx_with_blank = LintContext::new(content_with_blank, crate::config::MarkdownFlavor::Standard, None);
1361 let warnings_with_blank = rule.check(&ctx_with_blank).unwrap();
1362 assert!(
1363 warnings_with_blank.is_empty(),
1364 "Heading with blank line after frontmatter should also be valid"
1365 );
1366
1367 let content_toml = "+++\ntitle = \"Test\"\n+++\n# First heading";
1369 let ctx_toml = LintContext::new(content_toml, crate::config::MarkdownFlavor::Standard, None);
1370 let warnings_toml = rule.check(&ctx_toml).unwrap();
1371 assert!(
1372 warnings_toml.is_empty(),
1373 "TOML frontmatter is also transparent for MD022"
1374 );
1375 }
1376
1377 #[test]
1378 fn test_horizontal_rule_not_treated_as_frontmatter() {
1379 let rule = MD022BlanksAroundHeadings::default();
1382
1383 let content = "Some content\n\n---\n# Heading after HR";
1385 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1386 let warnings = rule.check(&ctx).unwrap();
1387 assert!(
1388 !warnings.is_empty(),
1389 "Heading after horizontal rule without blank line SHOULD trigger MD022"
1390 );
1391 assert!(
1392 warnings.iter().any(|w| w.line == 4),
1393 "Warning should be on line 4 (the heading line)"
1394 );
1395
1396 let content_with_blank = "Some content\n\n---\n\n# Heading after HR";
1398 let ctx_with_blank = LintContext::new(content_with_blank, crate::config::MarkdownFlavor::Standard, None);
1399 let warnings_with_blank = rule.check(&ctx_with_blank).unwrap();
1400 assert!(
1401 warnings_with_blank.is_empty(),
1402 "Heading with blank line after HR should not trigger MD022"
1403 );
1404
1405 let content_hr_start = "---\n# Heading";
1407 let ctx_hr_start = LintContext::new(content_hr_start, crate::config::MarkdownFlavor::Standard, None);
1408 let warnings_hr_start = rule.check(&ctx_hr_start).unwrap();
1409 assert!(
1410 !warnings_hr_start.is_empty(),
1411 "Heading after HR at document start SHOULD trigger MD022"
1412 );
1413
1414 let content_multi_hr = "Content\n\n---\n\n---\n# Heading";
1416 let ctx_multi_hr = LintContext::new(content_multi_hr, crate::config::MarkdownFlavor::Standard, None);
1417 let warnings_multi_hr = rule.check(&ctx_multi_hr).unwrap();
1418 assert!(
1419 !warnings_multi_hr.is_empty(),
1420 "Heading after multiple HRs without blank line SHOULD trigger MD022"
1421 );
1422 }
1423
1424 #[test]
1425 fn test_all_hr_styles_require_blank_before_heading() {
1426 let rule = MD022BlanksAroundHeadings::default();
1428
1429 let hr_styles = [
1431 "---", "***", "___", "- - -", "* * *", "_ _ _", "----", "****", "____", "- - - -",
1432 "- - -", " ---", " ---", ];
1436
1437 for hr in hr_styles {
1438 let content = format!("Content\n\n{hr}\n# Heading");
1439 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1440 let warnings = rule.check(&ctx).unwrap();
1441 assert!(
1442 !warnings.is_empty(),
1443 "HR style '{hr}' followed by heading should trigger MD022"
1444 );
1445 }
1446 }
1447
1448 #[test]
1449 fn test_setext_heading_after_hr() {
1450 let rule = MD022BlanksAroundHeadings::default();
1452
1453 let content = "Content\n\n---\nHeading\n======";
1455 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1456 let warnings = rule.check(&ctx).unwrap();
1457 assert!(
1458 !warnings.is_empty(),
1459 "Setext heading after HR without blank should trigger MD022"
1460 );
1461
1462 let content_h2 = "Content\n\n---\nHeading\n------";
1464 let ctx_h2 = LintContext::new(content_h2, crate::config::MarkdownFlavor::Standard, None);
1465 let warnings_h2 = rule.check(&ctx_h2).unwrap();
1466 assert!(
1467 !warnings_h2.is_empty(),
1468 "Setext h2 after HR without blank should trigger MD022"
1469 );
1470
1471 let content_ok = "Content\n\n---\n\nHeading\n======";
1473 let ctx_ok = LintContext::new(content_ok, crate::config::MarkdownFlavor::Standard, None);
1474 let warnings_ok = rule.check(&ctx_ok).unwrap();
1475 assert!(
1476 warnings_ok.is_empty(),
1477 "Setext heading with blank after HR should not warn"
1478 );
1479 }
1480
1481 #[test]
1482 fn test_hr_in_code_block_not_treated_as_hr() {
1483 let rule = MD022BlanksAroundHeadings::default();
1485
1486 let content = "```\n---\n```\n# Heading";
1489 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1490 let warnings = rule.check(&ctx).unwrap();
1491 assert!(!warnings.is_empty(), "Heading after code block still needs blank line");
1494
1495 let content_ok = "```\n---\n```\n\n# Heading";
1497 let ctx_ok = LintContext::new(content_ok, crate::config::MarkdownFlavor::Standard, None);
1498 let warnings_ok = rule.check(&ctx_ok).unwrap();
1499 assert!(
1500 warnings_ok.is_empty(),
1501 "Heading with blank after code block should not warn"
1502 );
1503 }
1504
1505 #[test]
1506 fn test_hr_in_html_comment_not_treated_as_hr() {
1507 let rule = MD022BlanksAroundHeadings::default();
1509
1510 let content = "<!-- \n---\n -->\n# Heading";
1512 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1513 let warnings = rule.check(&ctx).unwrap();
1514 assert!(
1516 warnings.is_empty(),
1517 "HR inside HTML comment should be ignored - heading after comment is OK"
1518 );
1519 }
1520
1521 #[test]
1522 fn test_invalid_hr_not_triggering() {
1523 let rule = MD022BlanksAroundHeadings::default();
1525
1526 let invalid_hrs = [
1527 " ---", "\t---", "--", "**", "__", "-*-", "---a", "a---", ];
1536
1537 for invalid in invalid_hrs {
1538 let content = format!("Content\n\n{invalid}\n# Heading");
1541 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1542 let _ = rule.check(&ctx);
1545 }
1546 }
1547
1548 #[test]
1549 fn test_frontmatter_vs_horizontal_rule_distinction() {
1550 let rule = MD022BlanksAroundHeadings::default();
1552
1553 let content = "---\ntitle: Test\n---\n\nSome content\n\n---\n# Heading after HR";
1556 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1557 let warnings = rule.check(&ctx).unwrap();
1558 assert!(
1559 !warnings.is_empty(),
1560 "HR after frontmatter content should still require blank line before heading"
1561 );
1562
1563 let content_ok = "---\ntitle: Test\n---\n\nSome content\n\n---\n\n# Heading after HR";
1565 let ctx_ok = LintContext::new(content_ok, crate::config::MarkdownFlavor::Standard, None);
1566 let warnings_ok = rule.check(&ctx_ok).unwrap();
1567 assert!(
1568 warnings_ok.is_empty(),
1569 "HR with blank line before heading should not warn"
1570 );
1571 }
1572
1573 #[test]
1576 fn test_kramdown_ial_after_heading_no_warning() {
1577 let rule = MD022BlanksAroundHeadings::default();
1579 let content = "## Table of Contents\n{: .hhc-toc-heading}\n\nSome content here.";
1580 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1581 let warnings = rule.check(&ctx).unwrap();
1582
1583 assert!(
1584 warnings.is_empty(),
1585 "IAL after heading should not require blank line between them: {warnings:?}"
1586 );
1587 }
1588
1589 #[test]
1590 fn test_kramdown_ial_with_class() {
1591 let rule = MD022BlanksAroundHeadings::default();
1592 let content = "# Heading\n{:.highlight}\n\nContent.";
1593 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1594 let warnings = rule.check(&ctx).unwrap();
1595
1596 assert!(warnings.is_empty(), "IAL with class should be part of heading");
1597 }
1598
1599 #[test]
1600 fn test_kramdown_ial_with_id() {
1601 let rule = MD022BlanksAroundHeadings::default();
1602 let content = "# Heading\n{:#custom-id}\n\nContent.";
1603 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1604 let warnings = rule.check(&ctx).unwrap();
1605
1606 assert!(warnings.is_empty(), "IAL with id should be part of heading");
1607 }
1608
1609 #[test]
1610 fn test_kramdown_ial_with_multiple_attributes() {
1611 let rule = MD022BlanksAroundHeadings::default();
1612 let content = "# Heading\n{: .class #id style=\"color: red\"}\n\nContent.";
1613 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1614 let warnings = rule.check(&ctx).unwrap();
1615
1616 assert!(
1617 warnings.is_empty(),
1618 "IAL with multiple attributes should be part of heading"
1619 );
1620 }
1621
1622 #[test]
1623 fn test_kramdown_ial_missing_blank_after() {
1624 let rule = MD022BlanksAroundHeadings::default();
1626 let content = "# Heading\n{:.class}\nContent without blank.";
1627 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1628 let warnings = rule.check(&ctx).unwrap();
1629
1630 assert_eq!(
1631 warnings.len(),
1632 1,
1633 "Should warn about missing blank after IAL (part of heading)"
1634 );
1635 assert!(warnings[0].message.contains("below"));
1636 }
1637
1638 #[test]
1639 fn test_kramdown_ial_before_heading_transparent() {
1640 let rule = MD022BlanksAroundHeadings::default();
1642 let content = "Content.\n\n{:.preclass}\n## Heading\n\nMore content.";
1643 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1644 let warnings = rule.check(&ctx).unwrap();
1645
1646 assert!(
1647 warnings.is_empty(),
1648 "IAL before heading should be transparent for blank line count"
1649 );
1650 }
1651
1652 #[test]
1653 fn test_kramdown_ial_setext_heading() {
1654 let rule = MD022BlanksAroundHeadings::default();
1655 let content = "Heading\n=======\n{:.setext-class}\n\nContent.";
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 after Setext heading should be part of heading"
1662 );
1663 }
1664
1665 #[test]
1666 fn test_kramdown_ial_fix_preserves_ial() {
1667 let rule = MD022BlanksAroundHeadings::default();
1668 let content = "Content.\n# Heading\n{:.class}\nMore content.";
1669 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1670 let fixed = rule.fix(&ctx).unwrap();
1671
1672 assert!(
1674 fixed.contains("# Heading\n{:.class}"),
1675 "IAL should stay attached to heading"
1676 );
1677 assert!(fixed.contains("{:.class}\n\nMore"), "Should add blank after IAL");
1678 }
1679
1680 #[test]
1681 fn test_kramdown_ial_fix_does_not_separate() {
1682 let rule = MD022BlanksAroundHeadings::default();
1683 let content = "# Heading\n{:.class}\nContent.";
1684 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1685 let fixed = rule.fix(&ctx).unwrap();
1686
1687 assert!(
1689 !fixed.contains("# Heading\n\n{:.class}"),
1690 "Should not add blank between heading and IAL"
1691 );
1692 assert!(fixed.contains("# Heading\n{:.class}"), "IAL should remain attached");
1693 }
1694
1695 #[test]
1696 fn test_kramdown_multiple_ial_lines() {
1697 let rule = MD022BlanksAroundHeadings::default();
1699 let content = "# Heading\n{:.class1}\n{:#id}\n\nContent.";
1700 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1701 let warnings = rule.check(&ctx).unwrap();
1702
1703 assert!(
1706 warnings.is_empty(),
1707 "Multiple consecutive IALs should be part of heading"
1708 );
1709 }
1710
1711 #[test]
1712 fn test_kramdown_ial_with_blank_line_not_attached() {
1713 let rule = MD022BlanksAroundHeadings::default();
1715 let content = "# Heading\n\n{:.class}\nContent.";
1716 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1717 let warnings = rule.check(&ctx).unwrap();
1718
1719 assert!(warnings.is_empty(), "Blank line separates heading from IAL");
1723 }
1724
1725 #[test]
1726 fn test_not_kramdown_ial_regular_braces() {
1727 let rule = MD022BlanksAroundHeadings::default();
1729 let content = "# Heading\n{not an ial}\n\nContent.";
1730 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1731 let warnings = rule.check(&ctx).unwrap();
1732
1733 assert_eq!(
1735 warnings.len(),
1736 1,
1737 "Non-IAL braces should be regular content requiring blank"
1738 );
1739 }
1740
1741 #[test]
1742 fn test_kramdown_ial_at_document_end() {
1743 let rule = MD022BlanksAroundHeadings::default();
1744 let content = "# Heading\n{:.class}";
1745 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1746 let warnings = rule.check(&ctx).unwrap();
1747
1748 assert!(warnings.is_empty(), "IAL at document end needs no blank after");
1750 }
1751
1752 #[test]
1753 fn test_kramdown_ial_followed_by_code_fence() {
1754 let rule = MD022BlanksAroundHeadings::default();
1755 let content = "# Heading\n{:.class}\n```\ncode\n```";
1756 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1757 let warnings = rule.check(&ctx).unwrap();
1758
1759 assert!(warnings.is_empty(), "No blank needed between IAL and code fence");
1761 }
1762
1763 #[test]
1764 fn test_kramdown_ial_followed_by_list() {
1765 let rule = MD022BlanksAroundHeadings::default();
1766 let content = "# Heading\n{:.class}\n- List item";
1767 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1768 let warnings = rule.check(&ctx).unwrap();
1769
1770 assert!(warnings.is_empty(), "No blank needed between IAL and list");
1772 }
1773
1774 #[test]
1775 fn test_kramdown_ial_fix_idempotent() {
1776 let rule = MD022BlanksAroundHeadings::default();
1777 let content = "# Heading\n{:.class}\nContent.";
1778 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1779
1780 let fixed_once = rule.fix(&ctx).unwrap();
1781 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
1782 let fixed_twice = rule.fix(&ctx2).unwrap();
1783
1784 assert_eq!(fixed_once, fixed_twice, "Fix should be idempotent");
1785 }
1786
1787 #[test]
1788 fn test_kramdown_ial_whitespace_line_between_not_attached() {
1789 let rule = MD022BlanksAroundHeadings::default();
1792 let content = "# Heading\n \n{:.class}\n\nContent.";
1793 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1794 let warnings = rule.check(&ctx).unwrap();
1795
1796 assert!(
1800 warnings.is_empty(),
1801 "Whitespace between heading and IAL means IAL is not attached"
1802 );
1803 }
1804
1805 #[test]
1806 fn test_kramdown_ial_html_comment_between() {
1807 let rule = MD022BlanksAroundHeadings::default();
1810 let content = "# Heading\n<!-- comment -->\n{:.class}\n\nContent.";
1811 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1812 let warnings = rule.check(&ctx).unwrap();
1813
1814 assert_eq!(
1818 warnings.len(),
1819 1,
1820 "IAL not attached when comment is between: {warnings:?}"
1821 );
1822 }
1823
1824 #[test]
1825 fn test_kramdown_ial_generic_attribute() {
1826 let rule = MD022BlanksAroundHeadings::default();
1827 let content = "# Heading\n{:data-toc=\"true\" style=\"color: red\"}\n\nContent.";
1828 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1829 let warnings = rule.check(&ctx).unwrap();
1830
1831 assert!(warnings.is_empty(), "Generic attributes should be recognized as IAL");
1832 }
1833
1834 #[test]
1835 fn test_kramdown_ial_fix_multiple_lines_preserves_all() {
1836 let rule = MD022BlanksAroundHeadings::default();
1837 let content = "# Heading\n{:.class1}\n{:#id}\n{:data-x=\"y\"}\nContent.";
1838 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1839
1840 let fixed = rule.fix(&ctx).unwrap();
1841
1842 assert!(fixed.contains("{:.class1}"), "First IAL should be preserved");
1844 assert!(fixed.contains("{:#id}"), "Second IAL should be preserved");
1845 assert!(fixed.contains("{:data-x=\"y\"}"), "Third IAL should be preserved");
1846 assert!(
1848 fixed.contains("{:data-x=\"y\"}\n\nContent"),
1849 "Blank line should be after all IALs"
1850 );
1851 }
1852
1853 #[test]
1854 fn test_kramdown_ial_crlf_line_endings() {
1855 let rule = MD022BlanksAroundHeadings::default();
1856 let content = "# Heading\r\n{:.class}\r\n\r\nContent.";
1857 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1858 let warnings = rule.check(&ctx).unwrap();
1859
1860 assert!(warnings.is_empty(), "CRLF should work correctly with IAL");
1861 }
1862
1863 #[test]
1864 fn test_kramdown_ial_invalid_patterns_not_recognized() {
1865 let rule = MD022BlanksAroundHeadings::default();
1866
1867 let content = "# Heading\n{ :.class}\n\nContent.";
1869 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1870 let warnings = rule.check(&ctx).unwrap();
1871 assert_eq!(warnings.len(), 1, "Invalid IAL syntax should trigger warning");
1872
1873 let content2 = "# Heading\n{.class}\n\nContent.";
1875 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1876 let warnings2 = rule.check(&ctx2).unwrap();
1877 assert!(warnings2.is_empty(), "{{.class}} is valid kramdown block attribute");
1879
1880 let content3 = "# Heading\n{just text}\n\nContent.";
1882 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
1883 let warnings3 = rule.check(&ctx3).unwrap();
1884 assert_eq!(
1885 warnings3.len(),
1886 1,
1887 "Text in braces is not IAL and should trigger warning"
1888 );
1889 }
1890
1891 #[test]
1892 fn test_kramdown_ial_toc_marker() {
1893 let rule = MD022BlanksAroundHeadings::default();
1895 let content = "# Heading\n{:toc}\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(), "{{:toc}} should be recognized as IAL");
1901 }
1902
1903 #[test]
1904 fn test_kramdown_ial_mixed_headings_in_document() {
1905 let rule = MD022BlanksAroundHeadings::default();
1906 let content = r#"# ATX Heading
1907{:.atx-class}
1908
1909Content after ATX.
1910
1911Setext Heading
1912--------------
1913{:#setext-id}
1914
1915Content after Setext.
1916
1917## Another ATX
1918{:.another}
1919
1920More content."#;
1921 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1922 let warnings = rule.check(&ctx).unwrap();
1923
1924 assert!(
1925 warnings.is_empty(),
1926 "Mixed headings with IAL should all work: {warnings:?}"
1927 );
1928 }
1929
1930 #[test]
1931 fn test_kramdown_extension_block_before_first_heading_is_document_start() {
1932 let rule = MD022BlanksAroundHeadings::default();
1933 let content = "{::comment}\nhidden\n{:/comment}\n# Heading\n\nBody\n";
1934 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Kramdown, None);
1935 let warnings = rule.check(&ctx).unwrap();
1936
1937 assert!(
1938 warnings.is_empty(),
1939 "Kramdown extension preamble should not require blank above first heading: {warnings:?}"
1940 );
1941 }
1942
1943 #[test]
1944 fn test_kramdown_ial_before_first_heading_is_document_start() {
1945 let rule = MD022BlanksAroundHeadings::default();
1946 let content = "{:.doc-class}\n# Heading\n\nBody\n";
1947 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Kramdown, None);
1948 let warnings = rule.check(&ctx).unwrap();
1949
1950 assert!(
1951 warnings.is_empty(),
1952 "Kramdown IAL preamble should not require blank above first heading: {warnings:?}"
1953 );
1954 }
1955
1956 #[test]
1959 fn test_quarto_div_marker_transparent_above_heading() {
1960 let rule = MD022BlanksAroundHeadings::default();
1963 let content = "Content\n\n::: {.callout-note}\n# Heading\n\nMore content\n:::\n";
1965 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1966 let warnings = rule.check(&ctx).unwrap();
1967 assert!(
1969 warnings.is_empty(),
1970 "Quarto div marker should be transparent above heading: {warnings:?}"
1971 );
1972 }
1973
1974 #[test]
1975 fn test_quarto_div_marker_transparent_below_heading() {
1976 let rule = MD022BlanksAroundHeadings::default();
1978 let content = "# Heading\n\n::: {.callout-note}\nContent\n:::\n";
1979 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1980 let warnings = rule.check(&ctx).unwrap();
1981 assert!(
1983 warnings.is_empty(),
1984 "Quarto div marker should be transparent below heading: {warnings:?}"
1985 );
1986 }
1987
1988 #[test]
1989 fn test_quarto_heading_inside_callout() {
1990 let rule = MD022BlanksAroundHeadings::default();
1992 let content = "::: {.callout-note}\n\n## Note Title\n\nNote content\n:::\n";
1993 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1994 let warnings = rule.check(&ctx).unwrap();
1995 assert!(
1996 warnings.is_empty(),
1997 "Heading inside Quarto callout should have no warnings: {warnings:?}"
1998 );
1999 }
2000
2001 #[test]
2002 fn test_quarto_heading_at_start_after_div_open() {
2003 let rule = MD022BlanksAroundHeadings::default();
2006 let content = "::: {.callout-warning}\n# Warning\n\nContent\n:::\n";
2008 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
2009 let warnings = rule.check(&ctx).unwrap();
2010 assert!(
2016 warnings.is_empty(),
2017 "Heading at start after div open should pass: {warnings:?}"
2018 );
2019 }
2020
2021 #[test]
2022 fn test_quarto_heading_before_div_close() {
2023 let rule = MD022BlanksAroundHeadings::default();
2025 let content = "::: {.callout-note}\nIntro\n\n## Section\n:::\n";
2026 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
2027 let warnings = rule.check(&ctx).unwrap();
2028 assert!(
2032 warnings.is_empty(),
2033 "Heading before div close should pass: {warnings:?}"
2034 );
2035 }
2036
2037 #[test]
2038 fn test_quarto_div_markers_not_transparent_in_standard_flavor() {
2039 let rule = MD022BlanksAroundHeadings::default();
2041 let content = "Content\n\n:::\n# Heading\n\n:::\n";
2042 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2043 let warnings = rule.check(&ctx).unwrap();
2044 assert!(
2046 !warnings.is_empty(),
2047 "Standard flavor should not treat ::: as transparent: {warnings:?}"
2048 );
2049 }
2050
2051 #[test]
2052 fn test_quarto_nested_divs_with_heading() {
2053 let rule = MD022BlanksAroundHeadings::default();
2055 let content = "::: {.outer}\n::: {.inner}\n\n# Heading\n\nContent\n:::\n:::\n";
2056 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
2057 let warnings = rule.check(&ctx).unwrap();
2058 assert!(
2059 warnings.is_empty(),
2060 "Nested divs with heading should work: {warnings:?}"
2061 );
2062 }
2063
2064 #[test]
2065 fn test_quarto_fix_preserves_div_markers() {
2066 let rule = MD022BlanksAroundHeadings::default();
2068 let content = "::: {.callout-note}\n\n## Note\n\nContent\n:::\n";
2069 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
2070 let fixed = rule.fix(&ctx).unwrap();
2071 assert!(fixed.contains("::: {.callout-note}"), "Should preserve div opening");
2073 assert!(fixed.contains(":::"), "Should preserve div closing");
2074 assert!(fixed.contains("## Note"), "Should preserve heading");
2075 }
2076
2077 #[test]
2078 fn test_quarto_heading_needs_blank_without_div_transparency() {
2079 let rule = MD022BlanksAroundHeadings::default();
2082 let content = "Content\n::: {.callout-note}\n# Heading\n\nMore\n:::\n";
2084 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
2085 let warnings = rule.check(&ctx).unwrap();
2086 assert!(
2089 !warnings.is_empty(),
2090 "Should still require blank line when not present: {warnings:?}"
2091 );
2092 }
2093
2094 #[test]
2095 fn test_pandoc_div_marker_transparent_above_heading() {
2096 let rule = MD022BlanksAroundHeadings::default();
2099 let content = "Content\n\n::: {.callout-note}\n# Heading\n\nMore content\n:::\n";
2100 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
2101 let warnings = rule.check(&ctx).unwrap();
2102 assert!(
2103 warnings.is_empty(),
2104 "MD022 should treat Pandoc div marker as transparent above heading: {warnings:?}"
2105 );
2106 }
2107
2108 #[test]
2109 fn test_hugo_block_attribute_after_heading_not_flagged() {
2110 let rule = MD022BlanksAroundHeadings::default();
2113 let content = "Intro text.\n\n# Heading\n{class=\"anchor\"}\n\nContent.\n";
2114
2115 for flavor in [
2116 crate::config::MarkdownFlavor::Hugo,
2117 crate::config::MarkdownFlavor::MkDocs,
2118 crate::config::MarkdownFlavor::Kramdown,
2119 ] {
2120 let ctx = LintContext::new(content, flavor, None);
2121 let warnings = rule.check(&ctx).unwrap();
2122 assert!(
2123 warnings.is_empty(),
2124 "MD022 should not flag the block attribute line under {flavor:?}: {warnings:?}"
2125 );
2126 }
2127
2128 let ctx_std = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2131 let warnings_std = rule.check(&ctx_std).unwrap();
2132 assert!(
2133 warnings_std.iter().any(|w| w.message.contains("below heading")),
2134 "MD022 must flag the missing blank below the heading under Standard: {warnings_std:?}"
2135 );
2136 }
2137
2138 #[test]
2139 fn test_mdg_keeps_tags_attached_only_to_structure_headings() {
2140 let rule = MD022BlanksAroundHeadings::default();
2141
2142 let attached = "`@browser`\n`@checkout` `@smoke`\n# Feature: Checkout\n";
2144 let mdg_ctx = LintContext::new(attached, crate::config::MarkdownFlavor::MDG, None);
2145 assert!(rule.check(&mdg_ctx).unwrap().is_empty());
2146 let fixed = rule.fix(&mdg_ctx).unwrap();
2147 assert_eq!(fixed, attached);
2148 let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
2149 assert_eq!(rule.fix(&fixed_ctx).unwrap(), fixed, "MDG fix should be idempotent");
2150
2151 let standard_ctx = LintContext::new(attached, crate::config::MarkdownFlavor::Standard, None);
2152 assert!(
2153 rule.check(&standard_ctx)
2154 .unwrap()
2155 .iter()
2156 .any(|warning| warning.message.contains("above heading"))
2157 );
2158 }
2159
2160 #[test]
2161 fn test_mdg_requires_blank_line_above_a_non_structure_heading() {
2162 let rule = MD022BlanksAroundHeadings::default();
2165 let content = "`@browser`\n# Notes\n";
2166 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
2167
2168 assert!(
2169 rule.check(&ctx)
2170 .unwrap()
2171 .iter()
2172 .any(|warning| warning.message.contains("above heading")),
2173 "a non-Gherkin heading keeps the normal requirement"
2174 );
2175 assert_eq!(rule.fix(&ctx).unwrap(), "`@browser`\n\n# Notes\n");
2176 }
2177
2178 #[test]
2179 fn test_mdg_colon_inside_a_code_span_names_no_structure() {
2180 let rule = MD022BlanksAroundHeadings::default();
2184 let content = "`@browser`\n# See `x: y` Notes\n";
2185 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
2186
2187 assert!(
2188 rule.check(&ctx)
2189 .unwrap()
2190 .iter()
2191 .any(|warning| warning.message.contains("above heading")),
2192 "the code span holds the only colon, so the heading is ordinary prose"
2193 );
2194 assert_eq!(rule.fix(&ctx).unwrap(), "`@browser`\n\n# See `x: y` Notes\n");
2195
2196 let structure = "`@browser`\n# Scenario: use `a: b` here\n";
2198 let structure_ctx = LintContext::new(structure, crate::config::MarkdownFlavor::MDG, None);
2199 assert!(rule.check(&structure_ctx).unwrap().is_empty());
2200 assert_eq!(rule.fix(&structure_ctx).unwrap(), structure);
2201 }
2202
2203 #[test]
2204 fn test_mdg_tag_line_matches_gherkin_reference_scan() {
2205 let rule = MD022BlanksAroundHeadings::default();
2208
2209 for above in [
2210 "`@comment_tag1` #a comment",
2211 "`@comment_tag#2` #a comment",
2212 "`@browser` and prose",
2213 "prose `@browser`",
2214 "`@a b`",
2215 ] {
2216 let content = format!("{above}\n# Feature: Checkout\n");
2217 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::MDG, None);
2218 assert!(rule.check(&ctx).unwrap().is_empty(), "{above:?} is a Gherkin tag line");
2219 assert_eq!(rule.fix(&ctx).unwrap(), content);
2220 }
2221
2222 let prose = "plain prose\n# Feature: Checkout\n";
2223 let ctx = LintContext::new(prose, crate::config::MarkdownFlavor::MDG, None);
2224 assert!(
2225 rule.check(&ctx)
2226 .unwrap()
2227 .iter()
2228 .any(|warning| warning.message.contains("above heading"))
2229 );
2230 }
2231}