1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
5use crate::rule_config_serde::RuleConfig;
6use crate::utils::kramdown_utils::is_kramdown_block_attribute;
7use crate::utils::pandoc;
8use crate::utils::range_utils::calculate_heading_range;
9use toml;
10
11pub(crate) mod md022_config;
12use md022_config::MD022Config;
13
14#[derive(Clone, Default)]
86pub struct MD022BlanksAroundHeadings {
87 config: MD022Config,
88}
89
90impl MD022BlanksAroundHeadings {
91 pub fn new() -> Self {
94 Self {
95 config: MD022Config::default(),
96 }
97 }
98
99 pub fn with_values(lines_above: usize, lines_below: usize) -> Self {
101 use md022_config::HeadingLevelConfig;
102 Self {
103 config: MD022Config {
104 lines_above: HeadingLevelConfig::scalar(lines_above),
105 lines_below: HeadingLevelConfig::scalar(lines_below),
106 allowed_at_start: true,
107 },
108 }
109 }
110
111 pub fn from_config_struct(config: MD022Config) -> Self {
112 Self { config }
113 }
114
115 fn fix_content(&self, ctx: &crate::lint_context::LintContext) -> String {
117 let line_ending = "\n";
119 let had_trailing_newline = ctx.content.ends_with('\n');
120 let is_pandoc = ctx.flavor.is_pandoc_compatible();
121 let mut result = Vec::new();
122 let mut skip_count: usize = 0;
123
124 let heading_at_start_idx = {
125 let mut found_non_transparent = false;
126 ctx.lines.iter().enumerate().find_map(|(i, line)| {
127 if line.heading.as_ref().is_some_and(|h| h.is_valid) && !found_non_transparent {
129 Some(i)
130 } else {
131 if !line.is_blank && !line.in_html_comment && !line.in_mdx_comment {
134 let trimmed = line.content(ctx.content).trim();
135 if trimmed.starts_with("<!--") && trimmed.ends_with("-->") {
137 } else if line.in_kramdown_extension_block || line.is_kramdown_block_ial {
139 } else if is_pandoc && (pandoc::is_div_open(trimmed) || pandoc::is_div_close(trimmed)) {
141 } else {
143 found_non_transparent = true;
144 }
145 }
146 None
147 }
148 })
149 };
150
151 for (i, line_info) in ctx.lines.iter().enumerate() {
152 if skip_count > 0 {
153 skip_count -= 1;
154 continue;
155 }
156 let line = line_info.content(ctx.content);
157
158 if line_info.in_code_block {
159 result.push(line.to_string());
160 continue;
161 }
162
163 if let Some(heading) = &line_info.heading {
165 if !heading.is_valid {
167 result.push(line.to_string());
168 continue;
169 }
170
171 let line_num = i + 1;
173 if ctx.inline_config().is_rule_disabled("MD022", line_num) {
174 result.push(line.to_string());
175 if matches!(
177 heading.style,
178 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
179 ) && i + 1 < ctx.lines.len()
180 {
181 result.push(ctx.lines[i + 1].content(ctx.content).to_string());
182 skip_count += 1;
183 }
184 continue;
185 }
186
187 let is_first_heading = Some(i) == heading_at_start_idx;
189 let heading_level = heading.level as usize;
190
191 let mut blank_lines_above = 0;
193 let mut check_idx = result.len();
194 while check_idx > 0 {
195 let prev_line = &result[check_idx - 1];
196 let trimmed = prev_line.trim();
197 if trimmed.is_empty() {
198 blank_lines_above += 1;
199 check_idx -= 1;
200 } else if trimmed.starts_with("<!--") && trimmed.ends_with("-->") {
201 check_idx -= 1;
203 } else if is_kramdown_block_attribute(trimmed) {
204 check_idx -= 1;
206 } else if is_pandoc && (pandoc::is_div_open(trimmed) || pandoc::is_div_close(trimmed)) {
207 check_idx -= 1;
209 } else {
210 break;
211 }
212 }
213
214 let requirement_above = self.config.lines_above.get_for_level(heading_level);
216 let needed_blanks_above = if is_first_heading && self.config.allowed_at_start {
217 0
218 } else {
219 requirement_above.required_count().unwrap_or(0)
220 };
221
222 while blank_lines_above < needed_blanks_above {
224 result.push(String::new());
225 blank_lines_above += 1;
226 }
227
228 result.push(line.to_string());
230
231 let mut effective_end_idx = i;
233
234 if matches!(
236 heading.style,
237 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
238 ) {
239 if i + 1 < ctx.lines.len() {
241 result.push(ctx.lines[i + 1].content(ctx.content).to_string());
242 skip_count += 1; effective_end_idx = i + 1;
244 }
245 }
246
247 let mut ial_count = 0;
250 while effective_end_idx + 1 < ctx.lines.len() {
251 let next_line = &ctx.lines[effective_end_idx + 1];
252 let next_trimmed = next_line.content(ctx.content).trim();
253 if is_kramdown_block_attribute(next_trimmed) {
254 result.push(next_trimmed.to_string());
255 effective_end_idx += 1;
256 ial_count += 1;
257 } else {
258 break;
259 }
260 }
261
262 let mut blank_lines_below = 0;
264 let mut next_content_line_idx = None;
265 for j in (effective_end_idx + 1)..ctx.lines.len() {
266 if ctx.lines[j].is_blank {
267 blank_lines_below += 1;
268 } else {
269 next_content_line_idx = Some(j);
270 break;
271 }
272 }
273
274 let next_is_special = if let Some(idx) = next_content_line_idx {
276 let next_line = &ctx.lines[idx];
277 next_line.list_item.is_some() || {
278 let trimmed = next_line.content(ctx.content).trim();
279 (trimmed.starts_with("```") || trimmed.starts_with("~~~"))
280 && (trimmed.len() == 3
281 || (trimmed.len() > 3
282 && trimmed
283 .chars()
284 .nth(3)
285 .is_some_and(|c| c.is_whitespace() || c.is_alphabetic())))
286 }
287 } else {
288 false
289 };
290
291 let requirement_below = self.config.lines_below.get_for_level(heading_level);
293 let needed_blanks_below = if next_is_special {
294 0
295 } else {
296 requirement_below.required_count().unwrap_or(0)
297 };
298 if blank_lines_below < needed_blanks_below {
299 for _ in 0..(needed_blanks_below - blank_lines_below) {
300 result.push(String::new());
301 }
302 }
303
304 skip_count += ial_count;
306 } else {
307 result.push(line.to_string());
309 }
310 }
311
312 let joined = result.join(line_ending);
313
314 if had_trailing_newline && !joined.ends_with('\n') {
317 format!("{joined}{line_ending}")
318 } else if !had_trailing_newline && joined.ends_with('\n') {
319 joined[..joined.len() - 1].to_string()
321 } else {
322 joined
323 }
324 }
325}
326
327impl Rule for MD022BlanksAroundHeadings {
328 fn name(&self) -> &'static str {
329 "MD022"
330 }
331
332 fn description(&self) -> &'static str {
333 "Headings should be surrounded by blank lines"
334 }
335
336 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
337 let mut result = Vec::new();
338
339 if ctx.lines.is_empty() {
341 return Ok(result);
342 }
343
344 let line_ending = "\n";
346 let is_pandoc = ctx.flavor.is_pandoc_compatible();
347
348 let heading_at_start_idx = {
349 let mut found_non_transparent = false;
350 ctx.lines.iter().enumerate().find_map(|(i, line)| {
351 if line.heading.as_ref().is_some_and(|h| h.is_valid) && !found_non_transparent {
353 Some(i)
354 } else {
355 if !line.is_blank && !line.in_html_comment && !line.in_mdx_comment {
358 let trimmed = line.content(ctx.content).trim();
359 if trimmed.starts_with("<!--") && trimmed.ends_with("-->") {
361 } else if line.in_kramdown_extension_block || line.is_kramdown_block_ial {
363 } else if is_pandoc && (pandoc::is_div_open(trimmed) || pandoc::is_div_close(trimmed)) {
365 } else {
367 found_non_transparent = true;
368 }
369 }
370 None
371 }
372 })
373 };
374
375 let mut heading_violations = Vec::new();
377 let mut processed_headings = std::collections::HashSet::new();
378
379 for (line_num, line_info) in ctx.lines.iter().enumerate() {
380 if processed_headings.contains(&line_num) || line_info.heading.is_none() {
382 continue;
383 }
384
385 if line_info.in_pymdown_block {
387 continue;
388 }
389
390 let heading = line_info.heading.as_ref().unwrap();
391
392 if !heading.is_valid {
394 continue;
395 }
396
397 let heading_level = heading.level as usize;
398
399 processed_headings.insert(line_num);
403
404 let is_first_heading = Some(line_num) == heading_at_start_idx;
406
407 let required_above_count = self.config.lines_above.get_for_level(heading_level).required_count();
409 let required_below_count = self.config.lines_below.get_for_level(heading_level).required_count();
410
411 let should_check_above =
413 required_above_count.is_some() && line_num > 0 && (!is_first_heading || !self.config.allowed_at_start);
414 if should_check_above {
415 let mut blank_lines_above = 0;
416 let mut hit_frontmatter_end = false;
417 for j in (0..line_num).rev() {
418 let line_content = ctx.lines[j].content(ctx.content);
419 let trimmed = line_content.trim();
420 if ctx.lines[j].is_blank {
421 blank_lines_above += 1;
422 } else if ctx.lines[j].in_html_comment
423 || ctx.lines[j].in_mdx_comment
424 || (trimmed.starts_with("<!--") && trimmed.ends_with("-->"))
425 {
426 continue;
428 } else if is_kramdown_block_attribute(trimmed) {
429 continue;
431 } else if is_pandoc && (pandoc::is_div_open(trimmed) || pandoc::is_div_close(trimmed)) {
432 continue;
434 } else if ctx.lines[j].in_front_matter {
435 hit_frontmatter_end = true;
440 break;
441 } else {
442 break;
443 }
444 }
445 let required = required_above_count.unwrap();
446 if !hit_frontmatter_end && blank_lines_above < required {
447 let needed_blanks = required - blank_lines_above;
448 heading_violations.push((line_num, "above", needed_blanks, heading_level));
449 }
450 }
451
452 let mut effective_last_line = if matches!(
454 heading.style,
455 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
456 ) {
457 line_num + 1 } else {
459 line_num
460 };
461
462 while effective_last_line + 1 < ctx.lines.len() {
465 let next_line = &ctx.lines[effective_last_line + 1];
466 let next_trimmed = next_line.content(ctx.content).trim();
467 if is_kramdown_block_attribute(next_trimmed) {
468 effective_last_line += 1;
469 } else {
470 break;
471 }
472 }
473
474 if effective_last_line < ctx.lines.len() - 1 {
476 let mut next_non_blank_idx = effective_last_line + 1;
478 while next_non_blank_idx < ctx.lines.len() {
479 let check_line = &ctx.lines[next_non_blank_idx];
480 let check_trimmed = check_line.content(ctx.content).trim();
481 if check_line.is_blank {
482 next_non_blank_idx += 1;
483 } else if check_line.in_html_comment
484 || check_line.in_mdx_comment
485 || (check_trimmed.starts_with("<!--") && check_trimmed.ends_with("-->"))
486 {
487 next_non_blank_idx += 1;
489 } else if is_pandoc && (pandoc::is_div_open(check_trimmed) || pandoc::is_div_close(check_trimmed)) {
490 next_non_blank_idx += 1;
492 } else {
493 break;
494 }
495 }
496
497 if next_non_blank_idx >= ctx.lines.len() {
499 continue;
501 }
502
503 let next_line_is_special = {
505 let next_line = &ctx.lines[next_non_blank_idx];
506 let next_trimmed = next_line.content(ctx.content).trim();
507
508 let is_code_fence = (next_trimmed.starts_with("```") || next_trimmed.starts_with("~~~"))
510 && (next_trimmed.len() == 3
511 || (next_trimmed.len() > 3
512 && next_trimmed
513 .chars()
514 .nth(3)
515 .is_some_and(|c| c.is_whitespace() || c.is_alphabetic())));
516
517 let is_list_item = next_line.list_item.is_some();
519
520 is_code_fence || is_list_item
521 };
522
523 if !next_line_is_special && let Some(required) = required_below_count {
525 let mut blank_lines_below = 0;
527 for k in (effective_last_line + 1)..next_non_blank_idx {
528 if ctx.lines[k].is_blank {
529 blank_lines_below += 1;
530 }
531 }
532
533 if blank_lines_below < required {
534 let needed_blanks = required - blank_lines_below;
535 heading_violations.push((line_num, "below", needed_blanks, heading_level));
536 }
537 }
538 }
539 }
540
541 for (heading_line, position, needed_blanks, heading_level) in heading_violations {
543 let heading_display_line = heading_line + 1; let line_info = &ctx.lines[heading_line];
545
546 let (start_line, start_col, end_line, end_col) =
548 calculate_heading_range(heading_display_line, line_info.content(ctx.content));
549
550 let required_above_count = self
551 .config
552 .lines_above
553 .get_for_level(heading_level)
554 .required_count()
555 .expect("Violations only generated for limited 'above' requirements");
556 let required_below_count = self
557 .config
558 .lines_below
559 .get_for_level(heading_level)
560 .required_count()
561 .expect("Violations only generated for limited 'below' requirements");
562
563 let (message, insertion_point) = match position {
564 "above" => (
565 format!(
566 "Expected {} blank {} above heading",
567 required_above_count,
568 if required_above_count == 1 { "line" } else { "lines" }
569 ),
570 heading_line, ),
572 "below" => {
573 let insert_after = if line_info.heading.as_ref().is_some_and(|h| {
575 matches!(
576 h.style,
577 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
578 )
579 }) {
580 heading_line + 2
581 } else {
582 heading_line + 1
583 };
584
585 (
586 format!(
587 "Expected {} blank {} below heading",
588 required_below_count,
589 if required_below_count == 1 { "line" } else { "lines" }
590 ),
591 insert_after,
592 )
593 }
594 _ => continue,
595 };
596
597 let byte_range = if insertion_point == 0 && position == "above" {
599 0..0
601 } else if position == "above" && insertion_point > 0 {
602 ctx.lines[insertion_point].byte_offset..ctx.lines[insertion_point].byte_offset
604 } else if position == "below" && insertion_point - 1 < ctx.lines.len() {
605 let line_idx = insertion_point - 1;
607 let line_end_offset = if line_idx + 1 < ctx.lines.len() {
608 ctx.lines[line_idx + 1].byte_offset
609 } else {
610 ctx.content.len()
611 };
612 line_end_offset..line_end_offset
613 } else {
614 let content_len = ctx.content.len();
616 content_len..content_len
617 };
618
619 result.push(LintWarning {
620 rule_name: Some(self.name().to_string()),
621 message,
622 line: start_line,
623 column: start_col,
624 end_line,
625 end_column: end_col,
626 severity: Severity::Warning,
627 fix: Some(Fix::new(byte_range, line_ending.repeat(needed_blanks))),
628 });
629 }
630
631 Ok(result)
632 }
633
634 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
635 if ctx.content.is_empty() {
636 return Ok(ctx.content.to_string());
637 }
638
639 let fixed = self.fix_content(ctx);
641
642 Ok(fixed)
643 }
644
645 fn category(&self) -> RuleCategory {
647 RuleCategory::Heading
648 }
649
650 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
652 if ctx.content.is_empty() || !ctx.likely_has_headings() {
654 return true;
655 }
656 ctx.lines.iter().all(|line| line.heading.is_none())
658 }
659
660 fn as_any(&self) -> &dyn std::any::Any {
661 self
662 }
663
664 fn default_config_section(&self) -> Option<(String, toml::Value)> {
665 let default_config = MD022Config::default();
666 let json_value = serde_json::to_value(&default_config).ok()?;
667 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
668
669 if let toml::Value::Table(table) = toml_value {
670 if !table.is_empty() {
671 Some((MD022Config::RULE_NAME.to_string(), toml::Value::Table(table)))
672 } else {
673 None
674 }
675 } else {
676 None
677 }
678 }
679
680 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
681 where
682 Self: Sized,
683 {
684 let rule_config = crate::rule_config_serde::load_rule_config::<MD022Config>(config);
685 Box::new(Self::from_config_struct(rule_config))
686 }
687}
688
689#[cfg(test)]
690mod tests {
691 use super::*;
692 use crate::lint_context::LintContext;
693
694 #[test]
695 fn test_valid_headings() {
696 let rule = MD022BlanksAroundHeadings::default();
697 let content = "\n# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.\n";
698 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
699 let result = rule.check(&ctx).unwrap();
700 assert!(result.is_empty());
701 }
702
703 #[test]
704 fn test_missing_blank_above() {
705 let rule = MD022BlanksAroundHeadings::default();
706 let content = "# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.\n";
707 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
708 let result = rule.check(&ctx).unwrap();
709 assert_eq!(result.len(), 0); let fixed = rule.fix(&ctx).unwrap();
712
713 assert!(fixed.contains("# Heading 1"));
716 assert!(fixed.contains("Some content."));
717 assert!(fixed.contains("## Heading 2"));
718 assert!(fixed.contains("More content."));
719 }
720
721 #[test]
722 fn test_missing_blank_below() {
723 let rule = MD022BlanksAroundHeadings::default();
724 let content = "\n# Heading 1\nSome content.\n\n## Heading 2\n\nMore content.\n";
725 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
726 let result = rule.check(&ctx).unwrap();
727 assert_eq!(result.len(), 1);
728 assert_eq!(result[0].line, 2);
729
730 let fixed = rule.fix(&ctx).unwrap();
732 assert!(fixed.contains("# Heading 1\n\nSome content"));
733 }
734
735 #[test]
736 fn test_missing_blank_above_and_below() {
737 let rule = MD022BlanksAroundHeadings::default();
738 let content = "# Heading 1\nSome content.\n## Heading 2\nMore content.\n";
739 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
740 let result = rule.check(&ctx).unwrap();
741 assert_eq!(result.len(), 3); let fixed = rule.fix(&ctx).unwrap();
745 assert!(fixed.contains("# Heading 1\n\nSome content"));
746 assert!(fixed.contains("Some content.\n\n## Heading 2"));
747 assert!(fixed.contains("## Heading 2\n\nMore content"));
748 }
749
750 #[test]
751 fn test_fix_headings() {
752 let rule = MD022BlanksAroundHeadings::default();
753 let content = "# Heading 1\nSome content.\n## Heading 2\nMore content.";
754 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
755 let result = rule.fix(&ctx).unwrap();
756
757 let expected = "# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.";
758 assert_eq!(result, expected);
759 }
760
761 #[test]
762 fn test_consecutive_headings_pattern() {
763 let rule = MD022BlanksAroundHeadings::default();
764 let content = "# Heading 1\n## Heading 2\n### Heading 3";
765 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
766 let result = rule.fix(&ctx).unwrap();
767
768 let lines: Vec<&str> = result.lines().collect();
770 assert!(!lines.is_empty());
771
772 let h1_pos = lines.iter().position(|&l| l == "# Heading 1").unwrap();
774 let h2_pos = lines.iter().position(|&l| l == "## Heading 2").unwrap();
775 let h3_pos = lines.iter().position(|&l| l == "### Heading 3").unwrap();
776
777 assert!(
779 h2_pos > h1_pos + 1,
780 "Should have at least one blank line after first heading"
781 );
782 assert!(
783 h3_pos > h2_pos + 1,
784 "Should have at least one blank line after second heading"
785 );
786
787 assert!(lines[h1_pos + 1].trim().is_empty(), "Line after h1 should be blank");
789
790 assert!(lines[h2_pos + 1].trim().is_empty(), "Line after h2 should be blank");
792 }
793
794 #[test]
795 fn test_blanks_around_setext_headings() {
796 let rule = MD022BlanksAroundHeadings::default();
797 let content = "Heading 1\n=========\nSome content.\nHeading 2\n---------\nMore content.";
798 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
799 let result = rule.fix(&ctx).unwrap();
800
801 let lines: Vec<&str> = result.lines().collect();
803
804 assert!(result.contains("Heading 1"));
806 assert!(result.contains("========="));
807 assert!(result.contains("Some content."));
808 assert!(result.contains("Heading 2"));
809 assert!(result.contains("---------"));
810 assert!(result.contains("More content."));
811
812 let heading1_marker_idx = lines.iter().position(|&l| l == "=========").unwrap();
814 let some_content_idx = lines.iter().position(|&l| l == "Some content.").unwrap();
815 assert!(
816 some_content_idx > heading1_marker_idx + 1,
817 "Should have a blank line after the first heading"
818 );
819
820 let heading2_marker_idx = lines.iter().position(|&l| l == "---------").unwrap();
821 let more_content_idx = lines.iter().position(|&l| l == "More content.").unwrap();
822 assert!(
823 more_content_idx > heading2_marker_idx + 1,
824 "Should have a blank line after the second heading"
825 );
826
827 let fixed_ctx = LintContext::new(&result, crate::config::MarkdownFlavor::Standard, None);
829 let fixed_warnings = rule.check(&fixed_ctx).unwrap();
830 assert!(fixed_warnings.is_empty(), "Fixed content should have no warnings");
831 }
832
833 #[test]
834 fn test_fix_specific_blank_line_cases() {
835 let rule = MD022BlanksAroundHeadings::default();
836
837 let content1 = "# Heading 1\n## Heading 2\n### Heading 3";
839 let ctx1 = LintContext::new(content1, crate::config::MarkdownFlavor::Standard, None);
840 let result1 = rule.fix(&ctx1).unwrap();
841 assert!(result1.contains("# Heading 1"));
843 assert!(result1.contains("## Heading 2"));
844 assert!(result1.contains("### Heading 3"));
845 let lines: Vec<&str> = result1.lines().collect();
847 let h1_pos = lines.iter().position(|&l| l == "# Heading 1").unwrap();
848 let h2_pos = lines.iter().position(|&l| l == "## Heading 2").unwrap();
849 assert!(lines[h1_pos + 1].trim().is_empty(), "Should have a blank line after h1");
850 assert!(lines[h2_pos + 1].trim().is_empty(), "Should have a blank line after h2");
851
852 let content2 = "# Heading 1\nContent under heading 1\n## Heading 2";
854 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
855 let result2 = rule.fix(&ctx2).unwrap();
856 assert!(result2.contains("# Heading 1"));
858 assert!(result2.contains("Content under heading 1"));
859 assert!(result2.contains("## Heading 2"));
860 let lines2: Vec<&str> = result2.lines().collect();
862 let h1_pos2 = lines2.iter().position(|&l| l == "# Heading 1").unwrap();
863 let _content_pos = lines2.iter().position(|&l| l == "Content under heading 1").unwrap();
864 assert!(
865 lines2[h1_pos2 + 1].trim().is_empty(),
866 "Should have a blank line after heading 1"
867 );
868
869 let content3 = "# Heading 1\n\n\n## Heading 2\n\n\n### Heading 3\n\nContent";
871 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
872 let result3 = rule.fix(&ctx3).unwrap();
873 assert!(result3.contains("# Heading 1"));
875 assert!(result3.contains("## Heading 2"));
876 assert!(result3.contains("### Heading 3"));
877 assert!(result3.contains("Content"));
878 }
879
880 #[test]
881 fn test_fix_preserves_existing_blank_lines() {
882 let rule = MD022BlanksAroundHeadings::new();
883 let content = "# Title
884
885## Section 1
886
887Content here.
888
889## Section 2
890
891More content.
892### Missing Blank Above
893
894Even more content.
895
896## Section 3
897
898Final content.";
899
900 let expected = "# Title
901
902## Section 1
903
904Content here.
905
906## Section 2
907
908More content.
909
910### Missing Blank Above
911
912Even more content.
913
914## Section 3
915
916Final content.";
917
918 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
919 let result = rule.fix_content(&ctx);
920 assert_eq!(
921 result, expected,
922 "Fix should only add missing blank lines, never remove existing ones"
923 );
924 }
925
926 #[test]
927 fn test_fix_preserves_trailing_newline() {
928 let rule = MD022BlanksAroundHeadings::new();
929
930 let content_with_newline = "# Title\nContent here.\n";
932 let ctx = LintContext::new(content_with_newline, crate::config::MarkdownFlavor::Standard, None);
933 let result = rule.fix(&ctx).unwrap();
934 assert!(result.ends_with('\n'), "Should preserve trailing newline");
935
936 let content_without_newline = "# Title\nContent here.";
938 let ctx = LintContext::new(content_without_newline, crate::config::MarkdownFlavor::Standard, None);
939 let result = rule.fix(&ctx).unwrap();
940 assert!(
941 !result.ends_with('\n'),
942 "Should not add trailing newline if original didn't have one"
943 );
944 }
945
946 #[test]
947 fn test_fix_does_not_add_blank_lines_before_lists() {
948 let rule = MD022BlanksAroundHeadings::new();
949 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.";
950
951 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.";
952
953 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
954 let result = rule.fix_content(&ctx);
955 assert_eq!(result, expected, "Fix should not add blank lines before lists");
956 }
957
958 #[test]
959 fn test_per_level_configuration_no_blank_above_h1() {
960 use md022_config::HeadingLevelConfig;
961
962 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
964 lines_above: HeadingLevelConfig::per_level([0, 1, 1, 1, 1, 1]),
965 lines_below: HeadingLevelConfig::scalar(1),
966 allowed_at_start: false, });
968
969 let content = "Some text\n# Heading 1\n\nMore text";
971 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
972 let warnings = rule.check(&ctx).unwrap();
973 assert_eq!(warnings.len(), 0, "H1 without blank above should not trigger warning");
974
975 let content = "Some text\n## Heading 2\n\nMore text";
977 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
978 let warnings = rule.check(&ctx).unwrap();
979 assert_eq!(warnings.len(), 1, "H2 without blank above should trigger warning");
980 assert!(warnings[0].message.contains("above"));
981 }
982
983 #[test]
984 fn test_per_level_configuration_different_requirements() {
985 use md022_config::HeadingLevelConfig;
986
987 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
989 lines_above: HeadingLevelConfig::per_level([0, 1, 1, 2, 2, 2]),
990 lines_below: HeadingLevelConfig::scalar(1),
991 allowed_at_start: false,
992 });
993
994 let content = "Text\n# H1\n\nText\n\n## H2\n\nText\n\n### H3\n\nText\n\n\n#### H4\n\nText";
995 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
996 let warnings = rule.check(&ctx).unwrap();
997
998 assert_eq!(
1000 warnings.len(),
1001 0,
1002 "All headings should satisfy level-specific requirements"
1003 );
1004 }
1005
1006 #[test]
1007 fn test_per_level_configuration_violations() {
1008 use md022_config::HeadingLevelConfig;
1009
1010 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1012 lines_above: HeadingLevelConfig::per_level([1, 1, 1, 2, 1, 1]),
1013 lines_below: HeadingLevelConfig::scalar(1),
1014 allowed_at_start: false,
1015 });
1016
1017 let content = "Text\n\n#### Heading 4\n\nMore text";
1019 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1020 let warnings = rule.check(&ctx).unwrap();
1021
1022 assert_eq!(warnings.len(), 1, "H4 with insufficient blanks should trigger warning");
1023 assert!(warnings[0].message.contains("2 blank lines above"));
1024 }
1025
1026 #[test]
1027 fn test_per_level_fix_different_levels() {
1028 use md022_config::HeadingLevelConfig;
1029
1030 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1032 lines_above: HeadingLevelConfig::per_level([0, 1, 2, 2, 2, 2]),
1033 lines_below: HeadingLevelConfig::scalar(1),
1034 allowed_at_start: false,
1035 });
1036
1037 let content = "Text\n# H1\nContent\n## H2\nContent\n### H3\nContent";
1038 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1039 let fixed = rule.fix(&ctx).unwrap();
1040
1041 assert!(fixed.contains("Text\n# H1\n\nContent"));
1043 assert!(fixed.contains("Content\n\n## H2\n\nContent"));
1044 assert!(fixed.contains("Content\n\n\n### H3\n\nContent"));
1045 }
1046
1047 #[test]
1048 fn test_per_level_below_configuration() {
1049 use md022_config::HeadingLevelConfig;
1050
1051 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1053 lines_above: HeadingLevelConfig::scalar(1),
1054 lines_below: HeadingLevelConfig::per_level([2, 1, 1, 1, 1, 1]), allowed_at_start: true,
1056 });
1057
1058 let content = "# Heading 1\n\nSome text";
1060 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1061 let warnings = rule.check(&ctx).unwrap();
1062
1063 assert_eq!(
1064 warnings.len(),
1065 1,
1066 "H1 with insufficient blanks below should trigger warning"
1067 );
1068 assert!(warnings[0].message.contains("2 blank lines below"));
1069 }
1070
1071 #[test]
1072 fn test_scalar_configuration_still_works() {
1073 use md022_config::HeadingLevelConfig;
1074
1075 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1077 lines_above: HeadingLevelConfig::scalar(2),
1078 lines_below: HeadingLevelConfig::scalar(2),
1079 allowed_at_start: false,
1080 });
1081
1082 let content = "Text\n# H1\nContent\n## H2\nContent";
1083 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1084 let warnings = rule.check(&ctx).unwrap();
1085
1086 assert!(!warnings.is_empty(), "Should have violations for insufficient blanks");
1088 }
1089
1090 #[test]
1091 fn test_unlimited_configuration_skips_requirements() {
1092 use md022_config::{HeadingBlankRequirement, HeadingLevelConfig};
1093
1094 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1096 lines_above: HeadingLevelConfig::per_level_requirements([
1097 HeadingBlankRequirement::unlimited(),
1098 HeadingBlankRequirement::limited(1),
1099 HeadingBlankRequirement::limited(1),
1100 HeadingBlankRequirement::limited(1),
1101 HeadingBlankRequirement::limited(1),
1102 HeadingBlankRequirement::limited(1),
1103 ]),
1104 lines_below: HeadingLevelConfig::per_level_requirements([
1105 HeadingBlankRequirement::unlimited(),
1106 HeadingBlankRequirement::limited(1),
1107 HeadingBlankRequirement::limited(1),
1108 HeadingBlankRequirement::limited(1),
1109 HeadingBlankRequirement::limited(1),
1110 HeadingBlankRequirement::limited(1),
1111 ]),
1112 allowed_at_start: false,
1113 });
1114
1115 let content = "# H1\nParagraph\n## H2\nParagraph";
1116 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1117 let warnings = rule.check(&ctx).unwrap();
1118
1119 assert_eq!(warnings.len(), 2, "Only non-unlimited headings should warn");
1121 assert!(
1122 warnings.iter().all(|w| w.line >= 3),
1123 "Warnings should target later headings"
1124 );
1125
1126 let fixed = rule.fix(&ctx).unwrap();
1128 assert!(
1129 fixed.starts_with("# H1\nParagraph\n\n## H2"),
1130 "H1 should remain unchanged"
1131 );
1132 }
1133
1134 #[test]
1135 fn test_html_comment_transparency() {
1136 let rule = MD022BlanksAroundHeadings::default();
1140
1141 let content = "Some content\n\n<!-- markdownlint-disable-next-line MD001 -->\n#### Heading";
1144 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1145 let warnings = rule.check(&ctx).unwrap();
1146 assert!(
1147 warnings.is_empty(),
1148 "HTML comment is transparent - blank line above it counts for heading"
1149 );
1150
1151 let content_multiline = "Some content\n\n<!-- This is a\nmulti-line comment -->\n#### Heading";
1153 let ctx_multiline = LintContext::new(content_multiline, crate::config::MarkdownFlavor::Standard, None);
1154 let warnings_multiline = rule.check(&ctx_multiline).unwrap();
1155 assert!(
1156 warnings_multiline.is_empty(),
1157 "Multi-line HTML comment is also transparent"
1158 );
1159 }
1160
1161 #[test]
1162 fn test_frontmatter_transparency() {
1163 let rule = MD022BlanksAroundHeadings::default();
1166
1167 let content = "---\ntitle: Test\n---\n# First heading";
1169 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1170 let warnings = rule.check(&ctx).unwrap();
1171 assert!(
1172 warnings.is_empty(),
1173 "Frontmatter is transparent - heading can appear immediately after"
1174 );
1175
1176 let content_with_blank = "---\ntitle: Test\n---\n\n# First heading";
1178 let ctx_with_blank = LintContext::new(content_with_blank, crate::config::MarkdownFlavor::Standard, None);
1179 let warnings_with_blank = rule.check(&ctx_with_blank).unwrap();
1180 assert!(
1181 warnings_with_blank.is_empty(),
1182 "Heading with blank line after frontmatter should also be valid"
1183 );
1184
1185 let content_toml = "+++\ntitle = \"Test\"\n+++\n# First heading";
1187 let ctx_toml = LintContext::new(content_toml, crate::config::MarkdownFlavor::Standard, None);
1188 let warnings_toml = rule.check(&ctx_toml).unwrap();
1189 assert!(
1190 warnings_toml.is_empty(),
1191 "TOML frontmatter is also transparent for MD022"
1192 );
1193 }
1194
1195 #[test]
1196 fn test_horizontal_rule_not_treated_as_frontmatter() {
1197 let rule = MD022BlanksAroundHeadings::default();
1200
1201 let content = "Some content\n\n---\n# Heading after HR";
1203 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1204 let warnings = rule.check(&ctx).unwrap();
1205 assert!(
1206 !warnings.is_empty(),
1207 "Heading after horizontal rule without blank line SHOULD trigger MD022"
1208 );
1209 assert!(
1210 warnings.iter().any(|w| w.line == 4),
1211 "Warning should be on line 4 (the heading line)"
1212 );
1213
1214 let content_with_blank = "Some content\n\n---\n\n# Heading after HR";
1216 let ctx_with_blank = LintContext::new(content_with_blank, crate::config::MarkdownFlavor::Standard, None);
1217 let warnings_with_blank = rule.check(&ctx_with_blank).unwrap();
1218 assert!(
1219 warnings_with_blank.is_empty(),
1220 "Heading with blank line after HR should not trigger MD022"
1221 );
1222
1223 let content_hr_start = "---\n# Heading";
1225 let ctx_hr_start = LintContext::new(content_hr_start, crate::config::MarkdownFlavor::Standard, None);
1226 let warnings_hr_start = rule.check(&ctx_hr_start).unwrap();
1227 assert!(
1228 !warnings_hr_start.is_empty(),
1229 "Heading after HR at document start SHOULD trigger MD022"
1230 );
1231
1232 let content_multi_hr = "Content\n\n---\n\n---\n# Heading";
1234 let ctx_multi_hr = LintContext::new(content_multi_hr, crate::config::MarkdownFlavor::Standard, None);
1235 let warnings_multi_hr = rule.check(&ctx_multi_hr).unwrap();
1236 assert!(
1237 !warnings_multi_hr.is_empty(),
1238 "Heading after multiple HRs without blank line SHOULD trigger MD022"
1239 );
1240 }
1241
1242 #[test]
1243 fn test_all_hr_styles_require_blank_before_heading() {
1244 let rule = MD022BlanksAroundHeadings::default();
1246
1247 let hr_styles = [
1249 "---", "***", "___", "- - -", "* * *", "_ _ _", "----", "****", "____", "- - - -",
1250 "- - -", " ---", " ---", ];
1254
1255 for hr in hr_styles {
1256 let content = format!("Content\n\n{hr}\n# Heading");
1257 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1258 let warnings = rule.check(&ctx).unwrap();
1259 assert!(
1260 !warnings.is_empty(),
1261 "HR style '{hr}' followed by heading should trigger MD022"
1262 );
1263 }
1264 }
1265
1266 #[test]
1267 fn test_setext_heading_after_hr() {
1268 let rule = MD022BlanksAroundHeadings::default();
1270
1271 let content = "Content\n\n---\nHeading\n======";
1273 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1274 let warnings = rule.check(&ctx).unwrap();
1275 assert!(
1276 !warnings.is_empty(),
1277 "Setext heading after HR without blank should trigger MD022"
1278 );
1279
1280 let content_h2 = "Content\n\n---\nHeading\n------";
1282 let ctx_h2 = LintContext::new(content_h2, crate::config::MarkdownFlavor::Standard, None);
1283 let warnings_h2 = rule.check(&ctx_h2).unwrap();
1284 assert!(
1285 !warnings_h2.is_empty(),
1286 "Setext h2 after HR without blank should trigger MD022"
1287 );
1288
1289 let content_ok = "Content\n\n---\n\nHeading\n======";
1291 let ctx_ok = LintContext::new(content_ok, crate::config::MarkdownFlavor::Standard, None);
1292 let warnings_ok = rule.check(&ctx_ok).unwrap();
1293 assert!(
1294 warnings_ok.is_empty(),
1295 "Setext heading with blank after HR should not warn"
1296 );
1297 }
1298
1299 #[test]
1300 fn test_hr_in_code_block_not_treated_as_hr() {
1301 let rule = MD022BlanksAroundHeadings::default();
1303
1304 let content = "```\n---\n```\n# Heading";
1307 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1308 let warnings = rule.check(&ctx).unwrap();
1309 assert!(!warnings.is_empty(), "Heading after code block still needs blank line");
1312
1313 let content_ok = "```\n---\n```\n\n# Heading";
1315 let ctx_ok = LintContext::new(content_ok, crate::config::MarkdownFlavor::Standard, None);
1316 let warnings_ok = rule.check(&ctx_ok).unwrap();
1317 assert!(
1318 warnings_ok.is_empty(),
1319 "Heading with blank after code block should not warn"
1320 );
1321 }
1322
1323 #[test]
1324 fn test_hr_in_html_comment_not_treated_as_hr() {
1325 let rule = MD022BlanksAroundHeadings::default();
1327
1328 let content = "<!-- \n---\n -->\n# Heading";
1330 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1331 let warnings = rule.check(&ctx).unwrap();
1332 assert!(
1334 warnings.is_empty(),
1335 "HR inside HTML comment should be ignored - heading after comment is OK"
1336 );
1337 }
1338
1339 #[test]
1340 fn test_invalid_hr_not_triggering() {
1341 let rule = MD022BlanksAroundHeadings::default();
1343
1344 let invalid_hrs = [
1345 " ---", "\t---", "--", "**", "__", "-*-", "---a", "a---", ];
1354
1355 for invalid in invalid_hrs {
1356 let content = format!("Content\n\n{invalid}\n# Heading");
1359 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1360 let _ = rule.check(&ctx);
1363 }
1364 }
1365
1366 #[test]
1367 fn test_frontmatter_vs_horizontal_rule_distinction() {
1368 let rule = MD022BlanksAroundHeadings::default();
1370
1371 let content = "---\ntitle: Test\n---\n\nSome content\n\n---\n# Heading after HR";
1374 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1375 let warnings = rule.check(&ctx).unwrap();
1376 assert!(
1377 !warnings.is_empty(),
1378 "HR after frontmatter content should still require blank line before heading"
1379 );
1380
1381 let content_ok = "---\ntitle: Test\n---\n\nSome content\n\n---\n\n# Heading after HR";
1383 let ctx_ok = LintContext::new(content_ok, crate::config::MarkdownFlavor::Standard, None);
1384 let warnings_ok = rule.check(&ctx_ok).unwrap();
1385 assert!(
1386 warnings_ok.is_empty(),
1387 "HR with blank line before heading should not warn"
1388 );
1389 }
1390
1391 #[test]
1394 fn test_kramdown_ial_after_heading_no_warning() {
1395 let rule = MD022BlanksAroundHeadings::default();
1397 let content = "## Table of Contents\n{: .hhc-toc-heading}\n\nSome content here.";
1398 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1399 let warnings = rule.check(&ctx).unwrap();
1400
1401 assert!(
1402 warnings.is_empty(),
1403 "IAL after heading should not require blank line between them: {warnings:?}"
1404 );
1405 }
1406
1407 #[test]
1408 fn test_kramdown_ial_with_class() {
1409 let rule = MD022BlanksAroundHeadings::default();
1410 let content = "# Heading\n{:.highlight}\n\nContent.";
1411 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1412 let warnings = rule.check(&ctx).unwrap();
1413
1414 assert!(warnings.is_empty(), "IAL with class should be part of heading");
1415 }
1416
1417 #[test]
1418 fn test_kramdown_ial_with_id() {
1419 let rule = MD022BlanksAroundHeadings::default();
1420 let content = "# Heading\n{:#custom-id}\n\nContent.";
1421 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1422 let warnings = rule.check(&ctx).unwrap();
1423
1424 assert!(warnings.is_empty(), "IAL with id should be part of heading");
1425 }
1426
1427 #[test]
1428 fn test_kramdown_ial_with_multiple_attributes() {
1429 let rule = MD022BlanksAroundHeadings::default();
1430 let content = "# Heading\n{: .class #id style=\"color: red\"}\n\nContent.";
1431 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1432 let warnings = rule.check(&ctx).unwrap();
1433
1434 assert!(
1435 warnings.is_empty(),
1436 "IAL with multiple attributes should be part of heading"
1437 );
1438 }
1439
1440 #[test]
1441 fn test_kramdown_ial_missing_blank_after() {
1442 let rule = MD022BlanksAroundHeadings::default();
1444 let content = "# Heading\n{:.class}\nContent without blank.";
1445 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1446 let warnings = rule.check(&ctx).unwrap();
1447
1448 assert_eq!(
1449 warnings.len(),
1450 1,
1451 "Should warn about missing blank after IAL (part of heading)"
1452 );
1453 assert!(warnings[0].message.contains("below"));
1454 }
1455
1456 #[test]
1457 fn test_kramdown_ial_before_heading_transparent() {
1458 let rule = MD022BlanksAroundHeadings::default();
1460 let content = "Content.\n\n{:.preclass}\n## Heading\n\nMore content.";
1461 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1462 let warnings = rule.check(&ctx).unwrap();
1463
1464 assert!(
1465 warnings.is_empty(),
1466 "IAL before heading should be transparent for blank line count"
1467 );
1468 }
1469
1470 #[test]
1471 fn test_kramdown_ial_setext_heading() {
1472 let rule = MD022BlanksAroundHeadings::default();
1473 let content = "Heading\n=======\n{:.setext-class}\n\nContent.";
1474 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1475 let warnings = rule.check(&ctx).unwrap();
1476
1477 assert!(
1478 warnings.is_empty(),
1479 "IAL after Setext heading should be part of heading"
1480 );
1481 }
1482
1483 #[test]
1484 fn test_kramdown_ial_fix_preserves_ial() {
1485 let rule = MD022BlanksAroundHeadings::default();
1486 let content = "Content.\n# Heading\n{:.class}\nMore content.";
1487 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1488 let fixed = rule.fix(&ctx).unwrap();
1489
1490 assert!(
1492 fixed.contains("# Heading\n{:.class}"),
1493 "IAL should stay attached to heading"
1494 );
1495 assert!(fixed.contains("{:.class}\n\nMore"), "Should add blank after IAL");
1496 }
1497
1498 #[test]
1499 fn test_kramdown_ial_fix_does_not_separate() {
1500 let rule = MD022BlanksAroundHeadings::default();
1501 let content = "# Heading\n{:.class}\nContent.";
1502 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1503 let fixed = rule.fix(&ctx).unwrap();
1504
1505 assert!(
1507 !fixed.contains("# Heading\n\n{:.class}"),
1508 "Should not add blank between heading and IAL"
1509 );
1510 assert!(fixed.contains("# Heading\n{:.class}"), "IAL should remain attached");
1511 }
1512
1513 #[test]
1514 fn test_kramdown_multiple_ial_lines() {
1515 let rule = MD022BlanksAroundHeadings::default();
1517 let content = "# Heading\n{:.class1}\n{:#id}\n\nContent.";
1518 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1519 let warnings = rule.check(&ctx).unwrap();
1520
1521 assert!(
1524 warnings.is_empty(),
1525 "Multiple consecutive IALs should be part of heading"
1526 );
1527 }
1528
1529 #[test]
1530 fn test_kramdown_ial_with_blank_line_not_attached() {
1531 let rule = MD022BlanksAroundHeadings::default();
1533 let content = "# Heading\n\n{:.class}\nContent.";
1534 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1535 let warnings = rule.check(&ctx).unwrap();
1536
1537 assert!(warnings.is_empty(), "Blank line separates heading from IAL");
1541 }
1542
1543 #[test]
1544 fn test_not_kramdown_ial_regular_braces() {
1545 let rule = MD022BlanksAroundHeadings::default();
1547 let content = "# Heading\n{not an ial}\n\nContent.";
1548 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1549 let warnings = rule.check(&ctx).unwrap();
1550
1551 assert_eq!(
1553 warnings.len(),
1554 1,
1555 "Non-IAL braces should be regular content requiring blank"
1556 );
1557 }
1558
1559 #[test]
1560 fn test_kramdown_ial_at_document_end() {
1561 let rule = MD022BlanksAroundHeadings::default();
1562 let content = "# Heading\n{:.class}";
1563 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1564 let warnings = rule.check(&ctx).unwrap();
1565
1566 assert!(warnings.is_empty(), "IAL at document end needs no blank after");
1568 }
1569
1570 #[test]
1571 fn test_kramdown_ial_followed_by_code_fence() {
1572 let rule = MD022BlanksAroundHeadings::default();
1573 let content = "# Heading\n{:.class}\n```\ncode\n```";
1574 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1575 let warnings = rule.check(&ctx).unwrap();
1576
1577 assert!(warnings.is_empty(), "No blank needed between IAL and code fence");
1579 }
1580
1581 #[test]
1582 fn test_kramdown_ial_followed_by_list() {
1583 let rule = MD022BlanksAroundHeadings::default();
1584 let content = "# Heading\n{:.class}\n- List item";
1585 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1586 let warnings = rule.check(&ctx).unwrap();
1587
1588 assert!(warnings.is_empty(), "No blank needed between IAL and list");
1590 }
1591
1592 #[test]
1593 fn test_kramdown_ial_fix_idempotent() {
1594 let rule = MD022BlanksAroundHeadings::default();
1595 let content = "# Heading\n{:.class}\nContent.";
1596 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1597
1598 let fixed_once = rule.fix(&ctx).unwrap();
1599 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
1600 let fixed_twice = rule.fix(&ctx2).unwrap();
1601
1602 assert_eq!(fixed_once, fixed_twice, "Fix should be idempotent");
1603 }
1604
1605 #[test]
1606 fn test_kramdown_ial_whitespace_line_between_not_attached() {
1607 let rule = MD022BlanksAroundHeadings::default();
1610 let content = "# Heading\n \n{:.class}\n\nContent.";
1611 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1612 let warnings = rule.check(&ctx).unwrap();
1613
1614 assert!(
1618 warnings.is_empty(),
1619 "Whitespace between heading and IAL means IAL is not attached"
1620 );
1621 }
1622
1623 #[test]
1624 fn test_kramdown_ial_html_comment_between() {
1625 let rule = MD022BlanksAroundHeadings::default();
1628 let content = "# Heading\n<!-- comment -->\n{:.class}\n\nContent.";
1629 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1630 let warnings = rule.check(&ctx).unwrap();
1631
1632 assert_eq!(
1636 warnings.len(),
1637 1,
1638 "IAL not attached when comment is between: {warnings:?}"
1639 );
1640 }
1641
1642 #[test]
1643 fn test_kramdown_ial_generic_attribute() {
1644 let rule = MD022BlanksAroundHeadings::default();
1645 let content = "# Heading\n{:data-toc=\"true\" style=\"color: red\"}\n\nContent.";
1646 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1647 let warnings = rule.check(&ctx).unwrap();
1648
1649 assert!(warnings.is_empty(), "Generic attributes should be recognized as IAL");
1650 }
1651
1652 #[test]
1653 fn test_kramdown_ial_fix_multiple_lines_preserves_all() {
1654 let rule = MD022BlanksAroundHeadings::default();
1655 let content = "# Heading\n{:.class1}\n{:#id}\n{:data-x=\"y\"}\nContent.";
1656 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1657
1658 let fixed = rule.fix(&ctx).unwrap();
1659
1660 assert!(fixed.contains("{:.class1}"), "First IAL should be preserved");
1662 assert!(fixed.contains("{:#id}"), "Second IAL should be preserved");
1663 assert!(fixed.contains("{:data-x=\"y\"}"), "Third IAL should be preserved");
1664 assert!(
1666 fixed.contains("{:data-x=\"y\"}\n\nContent"),
1667 "Blank line should be after all IALs"
1668 );
1669 }
1670
1671 #[test]
1672 fn test_kramdown_ial_crlf_line_endings() {
1673 let rule = MD022BlanksAroundHeadings::default();
1674 let content = "# Heading\r\n{:.class}\r\n\r\nContent.";
1675 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1676 let warnings = rule.check(&ctx).unwrap();
1677
1678 assert!(warnings.is_empty(), "CRLF should work correctly with IAL");
1679 }
1680
1681 #[test]
1682 fn test_kramdown_ial_invalid_patterns_not_recognized() {
1683 let rule = MD022BlanksAroundHeadings::default();
1684
1685 let content = "# Heading\n{ :.class}\n\nContent.";
1687 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1688 let warnings = rule.check(&ctx).unwrap();
1689 assert_eq!(warnings.len(), 1, "Invalid IAL syntax should trigger warning");
1690
1691 let content2 = "# Heading\n{.class}\n\nContent.";
1693 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1694 let warnings2 = rule.check(&ctx2).unwrap();
1695 assert!(warnings2.is_empty(), "{{.class}} is valid kramdown block attribute");
1697
1698 let content3 = "# Heading\n{just text}\n\nContent.";
1700 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
1701 let warnings3 = rule.check(&ctx3).unwrap();
1702 assert_eq!(
1703 warnings3.len(),
1704 1,
1705 "Text in braces is not IAL and should trigger warning"
1706 );
1707 }
1708
1709 #[test]
1710 fn test_kramdown_ial_toc_marker() {
1711 let rule = MD022BlanksAroundHeadings::default();
1713 let content = "# Heading\n{:toc}\n\nContent.";
1714 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1715 let warnings = rule.check(&ctx).unwrap();
1716
1717 assert!(warnings.is_empty(), "{{:toc}} should be recognized as IAL");
1719 }
1720
1721 #[test]
1722 fn test_kramdown_ial_mixed_headings_in_document() {
1723 let rule = MD022BlanksAroundHeadings::default();
1724 let content = r#"# ATX Heading
1725{:.atx-class}
1726
1727Content after ATX.
1728
1729Setext Heading
1730--------------
1731{:#setext-id}
1732
1733Content after Setext.
1734
1735## Another ATX
1736{:.another}
1737
1738More content."#;
1739 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1740 let warnings = rule.check(&ctx).unwrap();
1741
1742 assert!(
1743 warnings.is_empty(),
1744 "Mixed headings with IAL should all work: {warnings:?}"
1745 );
1746 }
1747
1748 #[test]
1749 fn test_kramdown_extension_block_before_first_heading_is_document_start() {
1750 let rule = MD022BlanksAroundHeadings::default();
1751 let content = "{::comment}\nhidden\n{:/comment}\n# Heading\n\nBody\n";
1752 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Kramdown, None);
1753 let warnings = rule.check(&ctx).unwrap();
1754
1755 assert!(
1756 warnings.is_empty(),
1757 "Kramdown extension preamble should not require blank above first heading: {warnings:?}"
1758 );
1759 }
1760
1761 #[test]
1762 fn test_kramdown_ial_before_first_heading_is_document_start() {
1763 let rule = MD022BlanksAroundHeadings::default();
1764 let content = "{:.doc-class}\n# Heading\n\nBody\n";
1765 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Kramdown, None);
1766 let warnings = rule.check(&ctx).unwrap();
1767
1768 assert!(
1769 warnings.is_empty(),
1770 "Kramdown IAL preamble should not require blank above first heading: {warnings:?}"
1771 );
1772 }
1773
1774 #[test]
1777 fn test_quarto_div_marker_transparent_above_heading() {
1778 let rule = MD022BlanksAroundHeadings::default();
1781 let content = "Content\n\n::: {.callout-note}\n# Heading\n\nMore content\n:::\n";
1783 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1784 let warnings = rule.check(&ctx).unwrap();
1785 assert!(
1787 warnings.is_empty(),
1788 "Quarto div marker should be transparent above heading: {warnings:?}"
1789 );
1790 }
1791
1792 #[test]
1793 fn test_quarto_div_marker_transparent_below_heading() {
1794 let rule = MD022BlanksAroundHeadings::default();
1796 let content = "# Heading\n\n::: {.callout-note}\nContent\n:::\n";
1797 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1798 let warnings = rule.check(&ctx).unwrap();
1799 assert!(
1801 warnings.is_empty(),
1802 "Quarto div marker should be transparent below heading: {warnings:?}"
1803 );
1804 }
1805
1806 #[test]
1807 fn test_quarto_heading_inside_callout() {
1808 let rule = MD022BlanksAroundHeadings::default();
1810 let content = "::: {.callout-note}\n\n## Note Title\n\nNote content\n:::\n";
1811 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1812 let warnings = rule.check(&ctx).unwrap();
1813 assert!(
1814 warnings.is_empty(),
1815 "Heading inside Quarto callout should have no warnings: {warnings:?}"
1816 );
1817 }
1818
1819 #[test]
1820 fn test_quarto_heading_at_start_after_div_open() {
1821 let rule = MD022BlanksAroundHeadings::default();
1824 let content = "::: {.callout-warning}\n# Warning\n\nContent\n:::\n";
1826 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1827 let warnings = rule.check(&ctx).unwrap();
1828 assert!(
1834 warnings.is_empty(),
1835 "Heading at start after div open should pass: {warnings:?}"
1836 );
1837 }
1838
1839 #[test]
1840 fn test_quarto_heading_before_div_close() {
1841 let rule = MD022BlanksAroundHeadings::default();
1843 let content = "::: {.callout-note}\nIntro\n\n## Section\n:::\n";
1844 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1845 let warnings = rule.check(&ctx).unwrap();
1846 assert!(
1850 warnings.is_empty(),
1851 "Heading before div close should pass: {warnings:?}"
1852 );
1853 }
1854
1855 #[test]
1856 fn test_quarto_div_markers_not_transparent_in_standard_flavor() {
1857 let rule = MD022BlanksAroundHeadings::default();
1859 let content = "Content\n\n:::\n# Heading\n\n:::\n";
1860 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1861 let warnings = rule.check(&ctx).unwrap();
1862 assert!(
1864 !warnings.is_empty(),
1865 "Standard flavor should not treat ::: as transparent: {warnings:?}"
1866 );
1867 }
1868
1869 #[test]
1870 fn test_quarto_nested_divs_with_heading() {
1871 let rule = MD022BlanksAroundHeadings::default();
1873 let content = "::: {.outer}\n::: {.inner}\n\n# Heading\n\nContent\n:::\n:::\n";
1874 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1875 let warnings = rule.check(&ctx).unwrap();
1876 assert!(
1877 warnings.is_empty(),
1878 "Nested divs with heading should work: {warnings:?}"
1879 );
1880 }
1881
1882 #[test]
1883 fn test_quarto_fix_preserves_div_markers() {
1884 let rule = MD022BlanksAroundHeadings::default();
1886 let content = "::: {.callout-note}\n\n## Note\n\nContent\n:::\n";
1887 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1888 let fixed = rule.fix(&ctx).unwrap();
1889 assert!(fixed.contains("::: {.callout-note}"), "Should preserve div opening");
1891 assert!(fixed.contains(":::"), "Should preserve div closing");
1892 assert!(fixed.contains("## Note"), "Should preserve heading");
1893 }
1894
1895 #[test]
1896 fn test_quarto_heading_needs_blank_without_div_transparency() {
1897 let rule = MD022BlanksAroundHeadings::default();
1900 let content = "Content\n::: {.callout-note}\n# Heading\n\nMore\n:::\n";
1902 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1903 let warnings = rule.check(&ctx).unwrap();
1904 assert!(
1907 !warnings.is_empty(),
1908 "Should still require blank line when not present: {warnings:?}"
1909 );
1910 }
1911
1912 #[test]
1913 fn test_pandoc_div_marker_transparent_above_heading() {
1914 let rule = MD022BlanksAroundHeadings::default();
1917 let content = "Content\n\n::: {.callout-note}\n# Heading\n\nMore content\n:::\n";
1918 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1919 let warnings = rule.check(&ctx).unwrap();
1920 assert!(
1921 warnings.is_empty(),
1922 "MD022 should treat Pandoc div marker as transparent above heading: {warnings:?}"
1923 );
1924 }
1925}