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 fn polymorphic_config_keys(&self) -> &'static [&'static str] {
738 &["lines-above", "lines-below"]
743 }
744}
745
746#[cfg(test)]
747mod tests {
748 use super::*;
749 use crate::lint_context::LintContext;
750
751 #[test]
752 fn test_valid_headings() {
753 let rule = MD022BlanksAroundHeadings::default();
754 let content = "\n# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.\n";
755 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
756 let result = rule.check(&ctx).unwrap();
757 assert!(result.is_empty());
758 }
759
760 #[test]
761 fn test_missing_blank_above() {
762 let rule = MD022BlanksAroundHeadings::default();
763 let content = "# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.\n";
764 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
765 let result = rule.check(&ctx).unwrap();
766 assert_eq!(result.len(), 0); let fixed = rule.fix(&ctx).unwrap();
769
770 assert!(fixed.contains("# Heading 1"));
773 assert!(fixed.contains("Some content."));
774 assert!(fixed.contains("## Heading 2"));
775 assert!(fixed.contains("More content."));
776 }
777
778 #[test]
779 fn test_missing_blank_below() {
780 let rule = MD022BlanksAroundHeadings::default();
781 let content = "\n# Heading 1\nSome content.\n\n## Heading 2\n\nMore content.\n";
782 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
783 let result = rule.check(&ctx).unwrap();
784 assert_eq!(result.len(), 1);
785 assert_eq!(result[0].line, 2);
786
787 let fixed = rule.fix(&ctx).unwrap();
789 assert!(fixed.contains("# Heading 1\n\nSome content"));
790 }
791
792 #[test]
793 fn test_missing_blank_above_and_below() {
794 let rule = MD022BlanksAroundHeadings::default();
795 let content = "# Heading 1\nSome content.\n## Heading 2\nMore content.\n";
796 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
797 let result = rule.check(&ctx).unwrap();
798 assert_eq!(result.len(), 3); let fixed = rule.fix(&ctx).unwrap();
802 assert!(fixed.contains("# Heading 1\n\nSome content"));
803 assert!(fixed.contains("Some content.\n\n## Heading 2"));
804 assert!(fixed.contains("## Heading 2\n\nMore content"));
805 }
806
807 #[test]
808 fn test_fix_headings() {
809 let rule = MD022BlanksAroundHeadings::default();
810 let content = "# Heading 1\nSome content.\n## Heading 2\nMore content.";
811 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
812 let result = rule.fix(&ctx).unwrap();
813
814 let expected = "# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.";
815 assert_eq!(result, expected);
816 }
817
818 #[test]
819 fn test_consecutive_headings_pattern() {
820 let rule = MD022BlanksAroundHeadings::default();
821 let content = "# Heading 1\n## Heading 2\n### Heading 3";
822 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
823 let result = rule.fix(&ctx).unwrap();
824
825 let lines: Vec<&str> = result.lines().collect();
827 assert!(!lines.is_empty());
828
829 let h1_pos = lines.iter().position(|&l| l == "# Heading 1").unwrap();
831 let h2_pos = lines.iter().position(|&l| l == "## Heading 2").unwrap();
832 let h3_pos = lines.iter().position(|&l| l == "### Heading 3").unwrap();
833
834 assert!(
836 h2_pos > h1_pos + 1,
837 "Should have at least one blank line after first heading"
838 );
839 assert!(
840 h3_pos > h2_pos + 1,
841 "Should have at least one blank line after second heading"
842 );
843
844 assert!(lines[h1_pos + 1].trim().is_empty(), "Line after h1 should be blank");
846
847 assert!(lines[h2_pos + 1].trim().is_empty(), "Line after h2 should be blank");
849 }
850
851 #[test]
852 fn test_blanks_around_setext_headings() {
853 let rule = MD022BlanksAroundHeadings::default();
854 let content = "Heading 1\n=========\nSome content.\nHeading 2\n---------\nMore content.";
855 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
856 let result = rule.fix(&ctx).unwrap();
857
858 let lines: Vec<&str> = result.lines().collect();
860
861 assert!(result.contains("Heading 1"));
863 assert!(result.contains("========="));
864 assert!(result.contains("Some content."));
865 assert!(result.contains("Heading 2"));
866 assert!(result.contains("---------"));
867 assert!(result.contains("More content."));
868
869 let heading1_marker_idx = lines.iter().position(|&l| l == "=========").unwrap();
871 let some_content_idx = lines.iter().position(|&l| l == "Some content.").unwrap();
872 assert!(
873 some_content_idx > heading1_marker_idx + 1,
874 "Should have a blank line after the first heading"
875 );
876
877 let heading2_marker_idx = lines.iter().position(|&l| l == "---------").unwrap();
878 let more_content_idx = lines.iter().position(|&l| l == "More content.").unwrap();
879 assert!(
880 more_content_idx > heading2_marker_idx + 1,
881 "Should have a blank line after the second heading"
882 );
883
884 let fixed_ctx = LintContext::new(&result, crate::config::MarkdownFlavor::Standard, None);
886 let fixed_warnings = rule.check(&fixed_ctx).unwrap();
887 assert!(fixed_warnings.is_empty(), "Fixed content should have no warnings");
888 }
889
890 #[test]
891 fn test_fix_specific_blank_line_cases() {
892 let rule = MD022BlanksAroundHeadings::default();
893
894 let content1 = "# Heading 1\n## Heading 2\n### Heading 3";
896 let ctx1 = LintContext::new(content1, crate::config::MarkdownFlavor::Standard, None);
897 let result1 = rule.fix(&ctx1).unwrap();
898 assert!(result1.contains("# Heading 1"));
900 assert!(result1.contains("## Heading 2"));
901 assert!(result1.contains("### Heading 3"));
902 let lines: Vec<&str> = result1.lines().collect();
904 let h1_pos = lines.iter().position(|&l| l == "# Heading 1").unwrap();
905 let h2_pos = lines.iter().position(|&l| l == "## Heading 2").unwrap();
906 assert!(lines[h1_pos + 1].trim().is_empty(), "Should have a blank line after h1");
907 assert!(lines[h2_pos + 1].trim().is_empty(), "Should have a blank line after h2");
908
909 let content2 = "# Heading 1\nContent under heading 1\n## Heading 2";
911 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
912 let result2 = rule.fix(&ctx2).unwrap();
913 assert!(result2.contains("# Heading 1"));
915 assert!(result2.contains("Content under heading 1"));
916 assert!(result2.contains("## Heading 2"));
917 let lines2: Vec<&str> = result2.lines().collect();
919 let h1_pos2 = lines2.iter().position(|&l| l == "# Heading 1").unwrap();
920 let _content_pos = lines2.iter().position(|&l| l == "Content under heading 1").unwrap();
921 assert!(
922 lines2[h1_pos2 + 1].trim().is_empty(),
923 "Should have a blank line after heading 1"
924 );
925
926 let content3 = "# Heading 1\n\n\n## Heading 2\n\n\n### Heading 3\n\nContent";
928 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
929 let result3 = rule.fix(&ctx3).unwrap();
930 assert!(result3.contains("# Heading 1"));
932 assert!(result3.contains("## Heading 2"));
933 assert!(result3.contains("### Heading 3"));
934 assert!(result3.contains("Content"));
935 }
936
937 #[test]
938 fn test_fix_preserves_existing_blank_lines() {
939 let rule = MD022BlanksAroundHeadings::new();
940 let content = "# Title
941
942## Section 1
943
944Content here.
945
946## Section 2
947
948More content.
949### Missing Blank Above
950
951Even more content.
952
953## Section 3
954
955Final content.";
956
957 let expected = "# Title
958
959## Section 1
960
961Content here.
962
963## Section 2
964
965More content.
966
967### Missing Blank Above
968
969Even more content.
970
971## Section 3
972
973Final content.";
974
975 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
976 let result = rule.fix_content(&ctx);
977 assert_eq!(
978 result, expected,
979 "Fix should only add missing blank lines, never remove existing ones"
980 );
981 }
982
983 #[test]
984 fn test_fix_preserves_trailing_newline() {
985 let rule = MD022BlanksAroundHeadings::new();
986
987 let content_with_newline = "# Title\nContent here.\n";
989 let ctx = LintContext::new(content_with_newline, crate::config::MarkdownFlavor::Standard, None);
990 let result = rule.fix(&ctx).unwrap();
991 assert!(result.ends_with('\n'), "Should preserve trailing newline");
992
993 let content_without_newline = "# Title\nContent here.";
995 let ctx = LintContext::new(content_without_newline, crate::config::MarkdownFlavor::Standard, None);
996 let result = rule.fix(&ctx).unwrap();
997 assert!(
998 !result.ends_with('\n'),
999 "Should not add trailing newline if original didn't have one"
1000 );
1001 }
1002
1003 #[test]
1004 fn test_fix_does_not_add_blank_lines_before_lists() {
1005 let rule = MD022BlanksAroundHeadings::new();
1006 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.";
1007
1008 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.";
1009
1010 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1011 let result = rule.fix_content(&ctx);
1012 assert_eq!(result, expected, "Fix should not add blank lines before lists");
1013 }
1014
1015 #[test]
1016 fn test_fix_idempotent_when_heading_follows_a_list_and_precedes_a_marker() {
1017 let rule = MD022BlanksAroundHeadings::default();
1023 let content = "- a\n# H\n2. ";
1024 for flavor in [
1025 crate::config::MarkdownFlavor::Standard,
1026 crate::config::MarkdownFlavor::MkDocs,
1027 crate::config::MarkdownFlavor::MDX,
1028 ] {
1029 let ctx1 = LintContext::new(content, flavor, None);
1030 let fixed1 = rule.fix(&ctx1).unwrap();
1031 let ctx2 = LintContext::new(&fixed1, flavor, None);
1032 let fixed2 = rule.fix(&ctx2).unwrap();
1033 assert_eq!(fixed1, fixed2, "MD022 fix must be idempotent (flavor={flavor:?})");
1034 }
1035 }
1036
1037 #[test]
1038 fn test_thematic_break_below_heading_is_not_a_list_item() {
1039 let rule = MD022BlanksAroundHeadings::default();
1046 for marker in [
1047 "* * *",
1048 "- - -",
1049 "_ _ _",
1050 "***",
1051 "---",
1052 "___",
1053 "- --",
1054 "* ** *",
1055 "---- ----",
1056 ] {
1057 let content = format!("text\n\n# Heading\n{marker}\nafter\n");
1058 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1059 let result = rule.check(&ctx).unwrap();
1060 assert_eq!(
1061 result.len(),
1062 1,
1063 "a heading above `{marker}` needs a blank line below it, got {result:?}"
1064 );
1065 assert_eq!(
1066 rule.fix(&ctx).unwrap(),
1067 format!("text\n\n# Heading\n\n{marker}\nafter\n"),
1068 "fix must insert the blank line below the heading for `{marker}`"
1069 );
1070 }
1071 }
1072
1073 #[test]
1074 fn test_list_item_below_heading_is_still_exempt() {
1075 let rule = MD022BlanksAroundHeadings::default();
1078 for item in ["- item", "* item", "+ item", "1. item", "+ + +"] {
1079 let content = format!("text\n\n# Heading\n{item}\n");
1080 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1081 assert!(
1082 rule.check(&ctx).unwrap().is_empty(),
1083 "a list below a heading stays exempt, but `{item}` was reported"
1084 );
1085 assert_eq!(rule.fix(&ctx).unwrap(), content, "`{item}` must not be rewritten");
1086 }
1087 }
1088
1089 #[test]
1090 fn test_per_level_configuration_no_blank_above_h1() {
1091 use md022_config::HeadingLevelConfig;
1092
1093 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1095 lines_above: HeadingLevelConfig::per_level([0, 1, 1, 1, 1, 1]),
1096 lines_below: HeadingLevelConfig::scalar(1),
1097 allowed_at_start: false, });
1099
1100 let content = "Some text\n# Heading 1\n\nMore text";
1102 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1103 let warnings = rule.check(&ctx).unwrap();
1104 assert_eq!(warnings.len(), 0, "H1 without blank above should not trigger warning");
1105
1106 let content = "Some text\n## Heading 2\n\nMore text";
1108 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1109 let warnings = rule.check(&ctx).unwrap();
1110 assert_eq!(warnings.len(), 1, "H2 without blank above should trigger warning");
1111 assert!(warnings[0].message.contains("above"));
1112 }
1113
1114 #[test]
1115 fn test_unlimited_above_with_limited_below_does_not_panic() {
1116 use md022_config::{HeadingBlankRequirement, HeadingLevelConfig};
1117
1118 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1122 lines_above: HeadingLevelConfig::scalar_requirement(HeadingBlankRequirement::Unlimited),
1123 lines_below: HeadingLevelConfig::scalar(1),
1124 allowed_at_start: false,
1125 });
1126
1127 let content = "# Title\n\nText\n## Banana\nText\n";
1129 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1130
1131 let warnings = rule.check(&ctx).expect("check must not fail");
1132
1133 assert!(
1134 warnings.iter().any(|w| w.message.contains("below")),
1135 "expected a 'below' violation, got: {:?}",
1136 warnings.iter().map(|w| &w.message).collect::<Vec<_>>()
1137 );
1138 assert!(
1139 !warnings.iter().any(|w| w.message.contains("above")),
1140 "an unlimited 'above' requirement must never report: {:?}",
1141 warnings.iter().map(|w| &w.message).collect::<Vec<_>>()
1142 );
1143 }
1144
1145 #[test]
1146 fn test_unlimited_below_with_limited_above_does_not_panic() {
1147 use md022_config::{HeadingBlankRequirement, HeadingLevelConfig};
1148
1149 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1150 lines_above: HeadingLevelConfig::scalar(1),
1151 lines_below: HeadingLevelConfig::scalar_requirement(HeadingBlankRequirement::Unlimited),
1152 allowed_at_start: false,
1153 });
1154
1155 let content = "# Title\n\nText\n## Banana\n\nText\n";
1157 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1158
1159 let warnings = rule.check(&ctx).expect("check must not fail");
1160
1161 assert!(
1162 warnings.iter().any(|w| w.message.contains("above")),
1163 "expected an 'above' violation, got: {:?}",
1164 warnings.iter().map(|w| &w.message).collect::<Vec<_>>()
1165 );
1166 assert!(
1167 !warnings.iter().any(|w| w.message.contains("below")),
1168 "an unlimited 'below' requirement must never report: {:?}",
1169 warnings.iter().map(|w| &w.message).collect::<Vec<_>>()
1170 );
1171 }
1172
1173 #[test]
1174 fn test_per_level_configuration_different_requirements() {
1175 use md022_config::HeadingLevelConfig;
1176
1177 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1179 lines_above: HeadingLevelConfig::per_level([0, 1, 1, 2, 2, 2]),
1180 lines_below: HeadingLevelConfig::scalar(1),
1181 allowed_at_start: false,
1182 });
1183
1184 let content = "Text\n# H1\n\nText\n\n## H2\n\nText\n\n### H3\n\nText\n\n\n#### H4\n\nText";
1185 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1186 let warnings = rule.check(&ctx).unwrap();
1187
1188 assert_eq!(
1190 warnings.len(),
1191 0,
1192 "All headings should satisfy level-specific requirements"
1193 );
1194 }
1195
1196 #[test]
1197 fn test_per_level_configuration_violations() {
1198 use md022_config::HeadingLevelConfig;
1199
1200 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1202 lines_above: HeadingLevelConfig::per_level([1, 1, 1, 2, 1, 1]),
1203 lines_below: HeadingLevelConfig::scalar(1),
1204 allowed_at_start: false,
1205 });
1206
1207 let content = "Text\n\n#### Heading 4\n\nMore text";
1209 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1210 let warnings = rule.check(&ctx).unwrap();
1211
1212 assert_eq!(warnings.len(), 1, "H4 with insufficient blanks should trigger warning");
1213 assert!(warnings[0].message.contains("2 blank lines above"));
1214 }
1215
1216 #[test]
1217 fn test_per_level_fix_different_levels() {
1218 use md022_config::HeadingLevelConfig;
1219
1220 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1222 lines_above: HeadingLevelConfig::per_level([0, 1, 2, 2, 2, 2]),
1223 lines_below: HeadingLevelConfig::scalar(1),
1224 allowed_at_start: false,
1225 });
1226
1227 let content = "Text\n# H1\nContent\n## H2\nContent\n### H3\nContent";
1228 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1229 let fixed = rule.fix(&ctx).unwrap();
1230
1231 assert!(fixed.contains("Text\n# H1\n\nContent"));
1233 assert!(fixed.contains("Content\n\n## H2\n\nContent"));
1234 assert!(fixed.contains("Content\n\n\n### H3\n\nContent"));
1235 }
1236
1237 #[test]
1238 fn test_per_level_below_configuration() {
1239 use md022_config::HeadingLevelConfig;
1240
1241 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1243 lines_above: HeadingLevelConfig::scalar(1),
1244 lines_below: HeadingLevelConfig::per_level([2, 1, 1, 1, 1, 1]), allowed_at_start: true,
1246 });
1247
1248 let content = "# Heading 1\n\nSome text";
1250 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1251 let warnings = rule.check(&ctx).unwrap();
1252
1253 assert_eq!(
1254 warnings.len(),
1255 1,
1256 "H1 with insufficient blanks below should trigger warning"
1257 );
1258 assert!(warnings[0].message.contains("2 blank lines below"));
1259 }
1260
1261 #[test]
1262 fn test_scalar_configuration_still_works() {
1263 use md022_config::HeadingLevelConfig;
1264
1265 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1267 lines_above: HeadingLevelConfig::scalar(2),
1268 lines_below: HeadingLevelConfig::scalar(2),
1269 allowed_at_start: false,
1270 });
1271
1272 let content = "Text\n# H1\nContent\n## H2\nContent";
1273 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1274 let warnings = rule.check(&ctx).unwrap();
1275
1276 assert!(!warnings.is_empty(), "Should have violations for insufficient blanks");
1278 }
1279
1280 #[test]
1281 fn test_unlimited_configuration_skips_requirements() {
1282 use md022_config::{HeadingBlankRequirement, HeadingLevelConfig};
1283
1284 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1286 lines_above: 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 lines_below: HeadingLevelConfig::per_level_requirements([
1295 HeadingBlankRequirement::unlimited(),
1296 HeadingBlankRequirement::limited(1),
1297 HeadingBlankRequirement::limited(1),
1298 HeadingBlankRequirement::limited(1),
1299 HeadingBlankRequirement::limited(1),
1300 HeadingBlankRequirement::limited(1),
1301 ]),
1302 allowed_at_start: false,
1303 });
1304
1305 let content = "# H1\nParagraph\n## H2\nParagraph";
1306 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1307 let warnings = rule.check(&ctx).unwrap();
1308
1309 assert_eq!(warnings.len(), 2, "Only non-unlimited headings should warn");
1311 assert!(
1312 warnings.iter().all(|w| w.line >= 3),
1313 "Warnings should target later headings"
1314 );
1315
1316 let fixed = rule.fix(&ctx).unwrap();
1318 assert!(
1319 fixed.starts_with("# H1\nParagraph\n\n## H2"),
1320 "H1 should remain unchanged"
1321 );
1322 }
1323
1324 #[test]
1325 fn test_html_comment_transparency() {
1326 let rule = MD022BlanksAroundHeadings::default();
1330
1331 let content = "Some content\n\n<!-- markdownlint-disable-next-line MD001 -->\n#### Heading";
1334 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1335 let warnings = rule.check(&ctx).unwrap();
1336 assert!(
1337 warnings.is_empty(),
1338 "HTML comment is transparent - blank line above it counts for heading"
1339 );
1340
1341 let content_multiline = "Some content\n\n<!-- This is a\nmulti-line comment -->\n#### Heading";
1343 let ctx_multiline = LintContext::new(content_multiline, crate::config::MarkdownFlavor::Standard, None);
1344 let warnings_multiline = rule.check(&ctx_multiline).unwrap();
1345 assert!(
1346 warnings_multiline.is_empty(),
1347 "Multi-line HTML comment is also transparent"
1348 );
1349 }
1350
1351 #[test]
1352 fn test_frontmatter_transparency() {
1353 let rule = MD022BlanksAroundHeadings::default();
1356
1357 let content = "---\ntitle: Test\n---\n# First heading";
1359 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1360 let warnings = rule.check(&ctx).unwrap();
1361 assert!(
1362 warnings.is_empty(),
1363 "Frontmatter is transparent - heading can appear immediately after"
1364 );
1365
1366 let content_with_blank = "---\ntitle: Test\n---\n\n# First heading";
1368 let ctx_with_blank = LintContext::new(content_with_blank, crate::config::MarkdownFlavor::Standard, None);
1369 let warnings_with_blank = rule.check(&ctx_with_blank).unwrap();
1370 assert!(
1371 warnings_with_blank.is_empty(),
1372 "Heading with blank line after frontmatter should also be valid"
1373 );
1374
1375 let content_toml = "+++\ntitle = \"Test\"\n+++\n# First heading";
1377 let ctx_toml = LintContext::new(content_toml, crate::config::MarkdownFlavor::Standard, None);
1378 let warnings_toml = rule.check(&ctx_toml).unwrap();
1379 assert!(
1380 warnings_toml.is_empty(),
1381 "TOML frontmatter is also transparent for MD022"
1382 );
1383 }
1384
1385 #[test]
1386 fn test_horizontal_rule_not_treated_as_frontmatter() {
1387 let rule = MD022BlanksAroundHeadings::default();
1390
1391 let content = "Some content\n\n---\n# Heading after HR";
1393 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1394 let warnings = rule.check(&ctx).unwrap();
1395 assert!(
1396 !warnings.is_empty(),
1397 "Heading after horizontal rule without blank line SHOULD trigger MD022"
1398 );
1399 assert!(
1400 warnings.iter().any(|w| w.line == 4),
1401 "Warning should be on line 4 (the heading line)"
1402 );
1403
1404 let content_with_blank = "Some content\n\n---\n\n# Heading after HR";
1406 let ctx_with_blank = LintContext::new(content_with_blank, crate::config::MarkdownFlavor::Standard, None);
1407 let warnings_with_blank = rule.check(&ctx_with_blank).unwrap();
1408 assert!(
1409 warnings_with_blank.is_empty(),
1410 "Heading with blank line after HR should not trigger MD022"
1411 );
1412
1413 let content_hr_start = "---\n# Heading";
1415 let ctx_hr_start = LintContext::new(content_hr_start, crate::config::MarkdownFlavor::Standard, None);
1416 let warnings_hr_start = rule.check(&ctx_hr_start).unwrap();
1417 assert!(
1418 !warnings_hr_start.is_empty(),
1419 "Heading after HR at document start SHOULD trigger MD022"
1420 );
1421
1422 let content_multi_hr = "Content\n\n---\n\n---\n# Heading";
1424 let ctx_multi_hr = LintContext::new(content_multi_hr, crate::config::MarkdownFlavor::Standard, None);
1425 let warnings_multi_hr = rule.check(&ctx_multi_hr).unwrap();
1426 assert!(
1427 !warnings_multi_hr.is_empty(),
1428 "Heading after multiple HRs without blank line SHOULD trigger MD022"
1429 );
1430 }
1431
1432 #[test]
1433 fn test_all_hr_styles_require_blank_before_heading() {
1434 let rule = MD022BlanksAroundHeadings::default();
1436
1437 let hr_styles = [
1439 "---", "***", "___", "- - -", "* * *", "_ _ _", "----", "****", "____", "- - - -",
1440 "- - -", " ---", " ---", ];
1444
1445 for hr in hr_styles {
1446 let content = format!("Content\n\n{hr}\n# Heading");
1447 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1448 let warnings = rule.check(&ctx).unwrap();
1449 assert!(
1450 !warnings.is_empty(),
1451 "HR style '{hr}' followed by heading should trigger MD022"
1452 );
1453 }
1454 }
1455
1456 #[test]
1457 fn test_setext_heading_after_hr() {
1458 let rule = MD022BlanksAroundHeadings::default();
1460
1461 let content = "Content\n\n---\nHeading\n======";
1463 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1464 let warnings = rule.check(&ctx).unwrap();
1465 assert!(
1466 !warnings.is_empty(),
1467 "Setext heading after HR without blank should trigger MD022"
1468 );
1469
1470 let content_h2 = "Content\n\n---\nHeading\n------";
1472 let ctx_h2 = LintContext::new(content_h2, crate::config::MarkdownFlavor::Standard, None);
1473 let warnings_h2 = rule.check(&ctx_h2).unwrap();
1474 assert!(
1475 !warnings_h2.is_empty(),
1476 "Setext h2 after HR without blank should trigger MD022"
1477 );
1478
1479 let content_ok = "Content\n\n---\n\nHeading\n======";
1481 let ctx_ok = LintContext::new(content_ok, crate::config::MarkdownFlavor::Standard, None);
1482 let warnings_ok = rule.check(&ctx_ok).unwrap();
1483 assert!(
1484 warnings_ok.is_empty(),
1485 "Setext heading with blank after HR should not warn"
1486 );
1487 }
1488
1489 #[test]
1490 fn test_hr_in_code_block_not_treated_as_hr() {
1491 let rule = MD022BlanksAroundHeadings::default();
1493
1494 let content = "```\n---\n```\n# Heading";
1497 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1498 let warnings = rule.check(&ctx).unwrap();
1499 assert!(!warnings.is_empty(), "Heading after code block still needs blank line");
1502
1503 let content_ok = "```\n---\n```\n\n# Heading";
1505 let ctx_ok = LintContext::new(content_ok, crate::config::MarkdownFlavor::Standard, None);
1506 let warnings_ok = rule.check(&ctx_ok).unwrap();
1507 assert!(
1508 warnings_ok.is_empty(),
1509 "Heading with blank after code block should not warn"
1510 );
1511 }
1512
1513 #[test]
1514 fn test_hr_in_html_comment_not_treated_as_hr() {
1515 let rule = MD022BlanksAroundHeadings::default();
1517
1518 let content = "<!-- \n---\n -->\n# Heading";
1520 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1521 let warnings = rule.check(&ctx).unwrap();
1522 assert!(
1524 warnings.is_empty(),
1525 "HR inside HTML comment should be ignored - heading after comment is OK"
1526 );
1527 }
1528
1529 #[test]
1530 fn test_invalid_hr_not_triggering() {
1531 let rule = MD022BlanksAroundHeadings::default();
1533
1534 let invalid_hrs = [
1535 " ---", "\t---", "--", "**", "__", "-*-", "---a", "a---", ];
1544
1545 for invalid in invalid_hrs {
1546 let content = format!("Content\n\n{invalid}\n# Heading");
1549 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1550 let _ = rule.check(&ctx);
1553 }
1554 }
1555
1556 #[test]
1557 fn test_frontmatter_vs_horizontal_rule_distinction() {
1558 let rule = MD022BlanksAroundHeadings::default();
1560
1561 let content = "---\ntitle: Test\n---\n\nSome content\n\n---\n# Heading after HR";
1564 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1565 let warnings = rule.check(&ctx).unwrap();
1566 assert!(
1567 !warnings.is_empty(),
1568 "HR after frontmatter content should still require blank line before heading"
1569 );
1570
1571 let content_ok = "---\ntitle: Test\n---\n\nSome content\n\n---\n\n# Heading after HR";
1573 let ctx_ok = LintContext::new(content_ok, crate::config::MarkdownFlavor::Standard, None);
1574 let warnings_ok = rule.check(&ctx_ok).unwrap();
1575 assert!(
1576 warnings_ok.is_empty(),
1577 "HR with blank line before heading should not warn"
1578 );
1579 }
1580
1581 #[test]
1584 fn test_kramdown_ial_after_heading_no_warning() {
1585 let rule = MD022BlanksAroundHeadings::default();
1587 let content = "## Table of Contents\n{: .hhc-toc-heading}\n\nSome content here.";
1588 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1589 let warnings = rule.check(&ctx).unwrap();
1590
1591 assert!(
1592 warnings.is_empty(),
1593 "IAL after heading should not require blank line between them: {warnings:?}"
1594 );
1595 }
1596
1597 #[test]
1598 fn test_kramdown_ial_with_class() {
1599 let rule = MD022BlanksAroundHeadings::default();
1600 let content = "# Heading\n{:.highlight}\n\nContent.";
1601 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1602 let warnings = rule.check(&ctx).unwrap();
1603
1604 assert!(warnings.is_empty(), "IAL with class should be part of heading");
1605 }
1606
1607 #[test]
1608 fn test_kramdown_ial_with_id() {
1609 let rule = MD022BlanksAroundHeadings::default();
1610 let content = "# Heading\n{:#custom-id}\n\nContent.";
1611 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1612 let warnings = rule.check(&ctx).unwrap();
1613
1614 assert!(warnings.is_empty(), "IAL with id should be part of heading");
1615 }
1616
1617 #[test]
1618 fn test_kramdown_ial_with_multiple_attributes() {
1619 let rule = MD022BlanksAroundHeadings::default();
1620 let content = "# Heading\n{: .class #id style=\"color: red\"}\n\nContent.";
1621 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1622 let warnings = rule.check(&ctx).unwrap();
1623
1624 assert!(
1625 warnings.is_empty(),
1626 "IAL with multiple attributes should be part of heading"
1627 );
1628 }
1629
1630 #[test]
1631 fn test_kramdown_ial_missing_blank_after() {
1632 let rule = MD022BlanksAroundHeadings::default();
1634 let content = "# Heading\n{:.class}\nContent without blank.";
1635 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1636 let warnings = rule.check(&ctx).unwrap();
1637
1638 assert_eq!(
1639 warnings.len(),
1640 1,
1641 "Should warn about missing blank after IAL (part of heading)"
1642 );
1643 assert!(warnings[0].message.contains("below"));
1644 }
1645
1646 #[test]
1647 fn test_kramdown_ial_before_heading_transparent() {
1648 let rule = MD022BlanksAroundHeadings::default();
1650 let content = "Content.\n\n{:.preclass}\n## Heading\n\nMore content.";
1651 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1652 let warnings = rule.check(&ctx).unwrap();
1653
1654 assert!(
1655 warnings.is_empty(),
1656 "IAL before heading should be transparent for blank line count"
1657 );
1658 }
1659
1660 #[test]
1661 fn test_kramdown_ial_setext_heading() {
1662 let rule = MD022BlanksAroundHeadings::default();
1663 let content = "Heading\n=======\n{:.setext-class}\n\nContent.";
1664 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1665 let warnings = rule.check(&ctx).unwrap();
1666
1667 assert!(
1668 warnings.is_empty(),
1669 "IAL after Setext heading should be part of heading"
1670 );
1671 }
1672
1673 #[test]
1674 fn test_kramdown_ial_fix_preserves_ial() {
1675 let rule = MD022BlanksAroundHeadings::default();
1676 let content = "Content.\n# Heading\n{:.class}\nMore content.";
1677 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1678 let fixed = rule.fix(&ctx).unwrap();
1679
1680 assert!(
1682 fixed.contains("# Heading\n{:.class}"),
1683 "IAL should stay attached to heading"
1684 );
1685 assert!(fixed.contains("{:.class}\n\nMore"), "Should add blank after IAL");
1686 }
1687
1688 #[test]
1689 fn test_kramdown_ial_fix_does_not_separate() {
1690 let rule = MD022BlanksAroundHeadings::default();
1691 let content = "# Heading\n{:.class}\nContent.";
1692 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1693 let fixed = rule.fix(&ctx).unwrap();
1694
1695 assert!(
1697 !fixed.contains("# Heading\n\n{:.class}"),
1698 "Should not add blank between heading and IAL"
1699 );
1700 assert!(fixed.contains("# Heading\n{:.class}"), "IAL should remain attached");
1701 }
1702
1703 #[test]
1704 fn test_kramdown_multiple_ial_lines() {
1705 let rule = MD022BlanksAroundHeadings::default();
1707 let content = "# Heading\n{:.class1}\n{:#id}\n\nContent.";
1708 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1709 let warnings = rule.check(&ctx).unwrap();
1710
1711 assert!(
1714 warnings.is_empty(),
1715 "Multiple consecutive IALs should be part of heading"
1716 );
1717 }
1718
1719 #[test]
1720 fn test_kramdown_ial_with_blank_line_not_attached() {
1721 let rule = MD022BlanksAroundHeadings::default();
1723 let content = "# Heading\n\n{:.class}\nContent.";
1724 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1725 let warnings = rule.check(&ctx).unwrap();
1726
1727 assert!(warnings.is_empty(), "Blank line separates heading from IAL");
1731 }
1732
1733 #[test]
1734 fn test_not_kramdown_ial_regular_braces() {
1735 let rule = MD022BlanksAroundHeadings::default();
1737 let content = "# Heading\n{not an ial}\n\nContent.";
1738 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1739 let warnings = rule.check(&ctx).unwrap();
1740
1741 assert_eq!(
1743 warnings.len(),
1744 1,
1745 "Non-IAL braces should be regular content requiring blank"
1746 );
1747 }
1748
1749 #[test]
1750 fn test_kramdown_ial_at_document_end() {
1751 let rule = MD022BlanksAroundHeadings::default();
1752 let content = "# Heading\n{:.class}";
1753 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1754 let warnings = rule.check(&ctx).unwrap();
1755
1756 assert!(warnings.is_empty(), "IAL at document end needs no blank after");
1758 }
1759
1760 #[test]
1761 fn test_kramdown_ial_followed_by_code_fence() {
1762 let rule = MD022BlanksAroundHeadings::default();
1763 let content = "# Heading\n{:.class}\n```\ncode\n```";
1764 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1765 let warnings = rule.check(&ctx).unwrap();
1766
1767 assert!(warnings.is_empty(), "No blank needed between IAL and code fence");
1769 }
1770
1771 #[test]
1772 fn test_kramdown_ial_followed_by_list() {
1773 let rule = MD022BlanksAroundHeadings::default();
1774 let content = "# Heading\n{:.class}\n- List item";
1775 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1776 let warnings = rule.check(&ctx).unwrap();
1777
1778 assert!(warnings.is_empty(), "No blank needed between IAL and list");
1780 }
1781
1782 #[test]
1783 fn test_kramdown_ial_fix_idempotent() {
1784 let rule = MD022BlanksAroundHeadings::default();
1785 let content = "# Heading\n{:.class}\nContent.";
1786 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1787
1788 let fixed_once = rule.fix(&ctx).unwrap();
1789 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
1790 let fixed_twice = rule.fix(&ctx2).unwrap();
1791
1792 assert_eq!(fixed_once, fixed_twice, "Fix should be idempotent");
1793 }
1794
1795 #[test]
1796 fn test_kramdown_ial_whitespace_line_between_not_attached() {
1797 let rule = MD022BlanksAroundHeadings::default();
1800 let content = "# Heading\n \n{:.class}\n\nContent.";
1801 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1802 let warnings = rule.check(&ctx).unwrap();
1803
1804 assert!(
1808 warnings.is_empty(),
1809 "Whitespace between heading and IAL means IAL is not attached"
1810 );
1811 }
1812
1813 #[test]
1814 fn test_kramdown_ial_html_comment_between() {
1815 let rule = MD022BlanksAroundHeadings::default();
1818 let content = "# Heading\n<!-- comment -->\n{:.class}\n\nContent.";
1819 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1820 let warnings = rule.check(&ctx).unwrap();
1821
1822 assert_eq!(
1826 warnings.len(),
1827 1,
1828 "IAL not attached when comment is between: {warnings:?}"
1829 );
1830 }
1831
1832 #[test]
1833 fn test_kramdown_ial_generic_attribute() {
1834 let rule = MD022BlanksAroundHeadings::default();
1835 let content = "# Heading\n{:data-toc=\"true\" style=\"color: red\"}\n\nContent.";
1836 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1837 let warnings = rule.check(&ctx).unwrap();
1838
1839 assert!(warnings.is_empty(), "Generic attributes should be recognized as IAL");
1840 }
1841
1842 #[test]
1843 fn test_kramdown_ial_fix_multiple_lines_preserves_all() {
1844 let rule = MD022BlanksAroundHeadings::default();
1845 let content = "# Heading\n{:.class1}\n{:#id}\n{:data-x=\"y\"}\nContent.";
1846 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1847
1848 let fixed = rule.fix(&ctx).unwrap();
1849
1850 assert!(fixed.contains("{:.class1}"), "First IAL should be preserved");
1852 assert!(fixed.contains("{:#id}"), "Second IAL should be preserved");
1853 assert!(fixed.contains("{:data-x=\"y\"}"), "Third IAL should be preserved");
1854 assert!(
1856 fixed.contains("{:data-x=\"y\"}\n\nContent"),
1857 "Blank line should be after all IALs"
1858 );
1859 }
1860
1861 #[test]
1862 fn test_kramdown_ial_crlf_line_endings() {
1863 let rule = MD022BlanksAroundHeadings::default();
1864 let content = "# Heading\r\n{:.class}\r\n\r\nContent.";
1865 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1866 let warnings = rule.check(&ctx).unwrap();
1867
1868 assert!(warnings.is_empty(), "CRLF should work correctly with IAL");
1869 }
1870
1871 #[test]
1872 fn test_kramdown_ial_invalid_patterns_not_recognized() {
1873 let rule = MD022BlanksAroundHeadings::default();
1874
1875 let content = "# Heading\n{ :.class}\n\nContent.";
1877 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1878 let warnings = rule.check(&ctx).unwrap();
1879 assert_eq!(warnings.len(), 1, "Invalid IAL syntax should trigger warning");
1880
1881 let content2 = "# Heading\n{.class}\n\nContent.";
1883 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1884 let warnings2 = rule.check(&ctx2).unwrap();
1885 assert!(warnings2.is_empty(), "{{.class}} is valid kramdown block attribute");
1887
1888 let content3 = "# Heading\n{just text}\n\nContent.";
1890 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
1891 let warnings3 = rule.check(&ctx3).unwrap();
1892 assert_eq!(
1893 warnings3.len(),
1894 1,
1895 "Text in braces is not IAL and should trigger warning"
1896 );
1897 }
1898
1899 #[test]
1900 fn test_kramdown_ial_toc_marker() {
1901 let rule = MD022BlanksAroundHeadings::default();
1903 let content = "# Heading\n{:toc}\n\nContent.";
1904 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1905 let warnings = rule.check(&ctx).unwrap();
1906
1907 assert!(warnings.is_empty(), "{{:toc}} should be recognized as IAL");
1909 }
1910
1911 #[test]
1912 fn test_kramdown_ial_mixed_headings_in_document() {
1913 let rule = MD022BlanksAroundHeadings::default();
1914 let content = r#"# ATX Heading
1915{:.atx-class}
1916
1917Content after ATX.
1918
1919Setext Heading
1920--------------
1921{:#setext-id}
1922
1923Content after Setext.
1924
1925## Another ATX
1926{:.another}
1927
1928More content."#;
1929 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1930 let warnings = rule.check(&ctx).unwrap();
1931
1932 assert!(
1933 warnings.is_empty(),
1934 "Mixed headings with IAL should all work: {warnings:?}"
1935 );
1936 }
1937
1938 #[test]
1939 fn test_kramdown_extension_block_before_first_heading_is_document_start() {
1940 let rule = MD022BlanksAroundHeadings::default();
1941 let content = "{::comment}\nhidden\n{:/comment}\n# Heading\n\nBody\n";
1942 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Kramdown, None);
1943 let warnings = rule.check(&ctx).unwrap();
1944
1945 assert!(
1946 warnings.is_empty(),
1947 "Kramdown extension preamble should not require blank above first heading: {warnings:?}"
1948 );
1949 }
1950
1951 #[test]
1952 fn test_kramdown_ial_before_first_heading_is_document_start() {
1953 let rule = MD022BlanksAroundHeadings::default();
1954 let content = "{:.doc-class}\n# Heading\n\nBody\n";
1955 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Kramdown, None);
1956 let warnings = rule.check(&ctx).unwrap();
1957
1958 assert!(
1959 warnings.is_empty(),
1960 "Kramdown IAL preamble should not require blank above first heading: {warnings:?}"
1961 );
1962 }
1963
1964 #[test]
1967 fn test_quarto_div_marker_transparent_above_heading() {
1968 let rule = MD022BlanksAroundHeadings::default();
1971 let content = "Content\n\n::: {.callout-note}\n# Heading\n\nMore content\n:::\n";
1973 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1974 let warnings = rule.check(&ctx).unwrap();
1975 assert!(
1977 warnings.is_empty(),
1978 "Quarto div marker should be transparent above heading: {warnings:?}"
1979 );
1980 }
1981
1982 #[test]
1983 fn test_quarto_div_marker_transparent_below_heading() {
1984 let rule = MD022BlanksAroundHeadings::default();
1986 let content = "# Heading\n\n::: {.callout-note}\nContent\n:::\n";
1987 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1988 let warnings = rule.check(&ctx).unwrap();
1989 assert!(
1991 warnings.is_empty(),
1992 "Quarto div marker should be transparent below heading: {warnings:?}"
1993 );
1994 }
1995
1996 #[test]
1997 fn test_quarto_heading_inside_callout() {
1998 let rule = MD022BlanksAroundHeadings::default();
2000 let content = "::: {.callout-note}\n\n## Note Title\n\nNote content\n:::\n";
2001 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
2002 let warnings = rule.check(&ctx).unwrap();
2003 assert!(
2004 warnings.is_empty(),
2005 "Heading inside Quarto callout should have no warnings: {warnings:?}"
2006 );
2007 }
2008
2009 #[test]
2010 fn test_quarto_heading_at_start_after_div_open() {
2011 let rule = MD022BlanksAroundHeadings::default();
2014 let content = "::: {.callout-warning}\n# Warning\n\nContent\n:::\n";
2016 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
2017 let warnings = rule.check(&ctx).unwrap();
2018 assert!(
2024 warnings.is_empty(),
2025 "Heading at start after div open should pass: {warnings:?}"
2026 );
2027 }
2028
2029 #[test]
2030 fn test_quarto_heading_before_div_close() {
2031 let rule = MD022BlanksAroundHeadings::default();
2033 let content = "::: {.callout-note}\nIntro\n\n## Section\n:::\n";
2034 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
2035 let warnings = rule.check(&ctx).unwrap();
2036 assert!(
2040 warnings.is_empty(),
2041 "Heading before div close should pass: {warnings:?}"
2042 );
2043 }
2044
2045 #[test]
2046 fn test_quarto_div_markers_not_transparent_in_standard_flavor() {
2047 let rule = MD022BlanksAroundHeadings::default();
2049 let content = "Content\n\n:::\n# Heading\n\n:::\n";
2050 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2051 let warnings = rule.check(&ctx).unwrap();
2052 assert!(
2054 !warnings.is_empty(),
2055 "Standard flavor should not treat ::: as transparent: {warnings:?}"
2056 );
2057 }
2058
2059 #[test]
2060 fn test_quarto_nested_divs_with_heading() {
2061 let rule = MD022BlanksAroundHeadings::default();
2063 let content = "::: {.outer}\n::: {.inner}\n\n# Heading\n\nContent\n:::\n:::\n";
2064 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
2065 let warnings = rule.check(&ctx).unwrap();
2066 assert!(
2067 warnings.is_empty(),
2068 "Nested divs with heading should work: {warnings:?}"
2069 );
2070 }
2071
2072 #[test]
2073 fn test_quarto_fix_preserves_div_markers() {
2074 let rule = MD022BlanksAroundHeadings::default();
2076 let content = "::: {.callout-note}\n\n## Note\n\nContent\n:::\n";
2077 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
2078 let fixed = rule.fix(&ctx).unwrap();
2079 assert!(fixed.contains("::: {.callout-note}"), "Should preserve div opening");
2081 assert!(fixed.contains(":::"), "Should preserve div closing");
2082 assert!(fixed.contains("## Note"), "Should preserve heading");
2083 }
2084
2085 #[test]
2086 fn test_quarto_heading_needs_blank_without_div_transparency() {
2087 let rule = MD022BlanksAroundHeadings::default();
2090 let content = "Content\n::: {.callout-note}\n# Heading\n\nMore\n:::\n";
2092 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
2093 let warnings = rule.check(&ctx).unwrap();
2094 assert!(
2097 !warnings.is_empty(),
2098 "Should still require blank line when not present: {warnings:?}"
2099 );
2100 }
2101
2102 #[test]
2103 fn test_pandoc_div_marker_transparent_above_heading() {
2104 let rule = MD022BlanksAroundHeadings::default();
2107 let content = "Content\n\n::: {.callout-note}\n# Heading\n\nMore content\n:::\n";
2108 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
2109 let warnings = rule.check(&ctx).unwrap();
2110 assert!(
2111 warnings.is_empty(),
2112 "MD022 should treat Pandoc div marker as transparent above heading: {warnings:?}"
2113 );
2114 }
2115
2116 #[test]
2117 fn test_hugo_block_attribute_after_heading_not_flagged() {
2118 let rule = MD022BlanksAroundHeadings::default();
2121 let content = "Intro text.\n\n# Heading\n{class=\"anchor\"}\n\nContent.\n";
2122
2123 for flavor in [
2124 crate::config::MarkdownFlavor::Hugo,
2125 crate::config::MarkdownFlavor::MkDocs,
2126 crate::config::MarkdownFlavor::Kramdown,
2127 ] {
2128 let ctx = LintContext::new(content, flavor, None);
2129 let warnings = rule.check(&ctx).unwrap();
2130 assert!(
2131 warnings.is_empty(),
2132 "MD022 should not flag the block attribute line under {flavor:?}: {warnings:?}"
2133 );
2134 }
2135
2136 let ctx_std = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2139 let warnings_std = rule.check(&ctx_std).unwrap();
2140 assert!(
2141 warnings_std.iter().any(|w| w.message.contains("below heading")),
2142 "MD022 must flag the missing blank below the heading under Standard: {warnings_std:?}"
2143 );
2144 }
2145
2146 #[test]
2147 fn test_mdg_keeps_tags_attached_only_to_structure_headings() {
2148 let rule = MD022BlanksAroundHeadings::default();
2149
2150 let attached = "`@browser`\n`@checkout` `@smoke`\n# Feature: Checkout\n";
2152 let mdg_ctx = LintContext::new(attached, crate::config::MarkdownFlavor::MDG, None);
2153 assert!(rule.check(&mdg_ctx).unwrap().is_empty());
2154 let fixed = rule.fix(&mdg_ctx).unwrap();
2155 assert_eq!(fixed, attached);
2156 let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
2157 assert_eq!(rule.fix(&fixed_ctx).unwrap(), fixed, "MDG fix should be idempotent");
2158
2159 let standard_ctx = LintContext::new(attached, crate::config::MarkdownFlavor::Standard, None);
2160 assert!(
2161 rule.check(&standard_ctx)
2162 .unwrap()
2163 .iter()
2164 .any(|warning| warning.message.contains("above heading"))
2165 );
2166 }
2167
2168 #[test]
2169 fn test_mdg_requires_blank_line_above_a_non_structure_heading() {
2170 let rule = MD022BlanksAroundHeadings::default();
2173 let content = "`@browser`\n# Notes\n";
2174 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
2175
2176 assert!(
2177 rule.check(&ctx)
2178 .unwrap()
2179 .iter()
2180 .any(|warning| warning.message.contains("above heading")),
2181 "a non-Gherkin heading keeps the normal requirement"
2182 );
2183 assert_eq!(rule.fix(&ctx).unwrap(), "`@browser`\n\n# Notes\n");
2184 }
2185
2186 #[test]
2187 fn test_mdg_colon_inside_a_code_span_names_no_structure() {
2188 let rule = MD022BlanksAroundHeadings::default();
2192 let content = "`@browser`\n# See `x: y` Notes\n";
2193 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
2194
2195 assert!(
2196 rule.check(&ctx)
2197 .unwrap()
2198 .iter()
2199 .any(|warning| warning.message.contains("above heading")),
2200 "the code span holds the only colon, so the heading is ordinary prose"
2201 );
2202 assert_eq!(rule.fix(&ctx).unwrap(), "`@browser`\n\n# See `x: y` Notes\n");
2203
2204 let structure = "`@browser`\n# Scenario: use `a: b` here\n";
2206 let structure_ctx = LintContext::new(structure, crate::config::MarkdownFlavor::MDG, None);
2207 assert!(rule.check(&structure_ctx).unwrap().is_empty());
2208 assert_eq!(rule.fix(&structure_ctx).unwrap(), structure);
2209 }
2210
2211 #[test]
2212 fn test_mdg_tag_line_matches_gherkin_reference_scan() {
2213 let rule = MD022BlanksAroundHeadings::default();
2216
2217 for above in [
2218 "`@comment_tag1` #a comment",
2219 "`@comment_tag#2` #a comment",
2220 "`@browser` and prose",
2221 "prose `@browser`",
2222 "`@a b`",
2223 ] {
2224 let content = format!("{above}\n# Feature: Checkout\n");
2225 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::MDG, None);
2226 assert!(rule.check(&ctx).unwrap().is_empty(), "{above:?} is a Gherkin tag line");
2227 assert_eq!(rule.fix(&ctx).unwrap(), content);
2228 }
2229
2230 let prose = "plain prose\n# Feature: Checkout\n";
2231 let ctx = LintContext::new(prose, crate::config::MarkdownFlavor::MDG, None);
2232 assert!(
2233 rule.check(&ctx)
2234 .unwrap()
2235 .iter()
2236 .any(|warning| warning.message.contains("above heading"))
2237 );
2238 }
2239}