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