1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
5use crate::utils::kramdown_utils::is_kramdown_block_attribute;
6use crate::utils::pandoc;
7use crate::utils::range_utils::calculate_heading_range;
8use toml;
9
10pub(crate) mod md022_config;
11use md022_config::MD022Config;
12
13fn starts_with_list_marker(trimmed: &str) -> bool {
21 let bytes = trimmed.as_bytes();
22 match bytes.first() {
23 Some(b'-' | b'*' | b'+') => matches!(bytes.get(1), None | Some(b' ')),
24 Some(b'0'..=b'9') => {
25 let mut i = 0;
26 while bytes.get(i).is_some_and(u8::is_ascii_digit) {
27 i += 1;
28 }
29 matches!(bytes.get(i), Some(b'.' | b')')) && matches!(bytes.get(i + 1), None | Some(b' '))
30 }
31 _ => false,
32 }
33}
34
35#[derive(Clone, Default)]
107pub struct MD022BlanksAroundHeadings {
108 config: MD022Config,
109}
110
111impl MD022BlanksAroundHeadings {
112 pub fn new() -> Self {
115 Self {
116 config: MD022Config::default(),
117 }
118 }
119
120 pub fn with_values(lines_above: usize, lines_below: usize) -> Self {
122 use md022_config::HeadingLevelConfig;
123 Self {
124 config: MD022Config {
125 lines_above: HeadingLevelConfig::scalar(lines_above),
126 lines_below: HeadingLevelConfig::scalar(lines_below),
127 allowed_at_start: true,
128 },
129 }
130 }
131
132 pub fn from_config_struct(config: MD022Config) -> Self {
133 Self { config }
134 }
135
136 fn fix_content(&self, ctx: &crate::lint_context::LintContext) -> String {
138 let line_ending = "\n";
140 let had_trailing_newline = ctx.content.ends_with('\n');
141 let is_pandoc = ctx.flavor.is_pandoc_compatible();
142 let mut result = Vec::new();
143 let mut skip_count: usize = 0;
144
145 let heading_at_start_idx = {
146 let mut found_non_transparent = false;
147 ctx.lines.iter().enumerate().find_map(|(i, line)| {
148 if line.heading.as_ref().is_some_and(|h| h.is_valid) && !found_non_transparent {
150 Some(i)
151 } else {
152 if !line.is_blank && !line.in_html_comment && !line.in_mdx_comment {
155 let trimmed = line.content(ctx.content).trim();
156 if trimmed.starts_with("<!--") && trimmed.ends_with("-->") {
158 } else if line.in_kramdown_extension_block || line.is_kramdown_block_ial {
160 } else if is_pandoc && (pandoc::is_div_open(trimmed) || pandoc::is_div_close(trimmed)) {
162 } else {
164 found_non_transparent = true;
165 }
166 }
167 None
168 }
169 })
170 };
171
172 for (i, line_info) in ctx.lines.iter().enumerate() {
173 if skip_count > 0 {
174 skip_count -= 1;
175 continue;
176 }
177 let line = line_info.content(ctx.content);
178
179 if line_info.in_code_block {
180 result.push(line.to_string());
181 continue;
182 }
183
184 if let Some(heading) = &line_info.heading {
186 if !heading.is_valid {
188 result.push(line.to_string());
189 continue;
190 }
191
192 let line_num = i + 1;
194 if ctx.inline_config().is_rule_disabled("MD022", line_num) {
195 result.push(line.to_string());
196 if matches!(
198 heading.style,
199 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
200 ) && i + 1 < ctx.lines.len()
201 {
202 result.push(ctx.lines[i + 1].content(ctx.content).to_string());
203 skip_count += 1;
204 }
205 continue;
206 }
207
208 let is_first_heading = Some(i) == heading_at_start_idx;
210 let heading_level = heading.level as usize;
211
212 let mut blank_lines_above = 0;
214 let mut check_idx = result.len();
215 while check_idx > 0 {
216 let prev_line = &result[check_idx - 1];
217 let trimmed = prev_line.trim();
218 if trimmed.is_empty() {
219 blank_lines_above += 1;
220 check_idx -= 1;
221 } else if trimmed.starts_with("<!--") && trimmed.ends_with("-->") {
222 check_idx -= 1;
224 } else if is_kramdown_block_attribute(trimmed) {
225 check_idx -= 1;
227 } else if is_pandoc && (pandoc::is_div_open(trimmed) || pandoc::is_div_close(trimmed)) {
228 check_idx -= 1;
230 } else {
231 break;
232 }
233 }
234
235 let requirement_above = self.config.lines_above.get_for_level(heading_level);
237 let needed_blanks_above = if is_first_heading && self.config.allowed_at_start {
238 0
239 } else {
240 requirement_above.required_count().unwrap_or(0)
241 };
242
243 while blank_lines_above < needed_blanks_above {
245 result.push(String::new());
246 blank_lines_above += 1;
247 }
248
249 result.push(line.to_string());
251
252 let mut effective_end_idx = i;
254
255 if matches!(
257 heading.style,
258 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
259 ) {
260 if i + 1 < ctx.lines.len() {
262 result.push(ctx.lines[i + 1].content(ctx.content).to_string());
263 skip_count += 1; effective_end_idx = i + 1;
265 }
266 }
267
268 let mut ial_count = 0;
271 while effective_end_idx + 1 < ctx.lines.len() {
272 let next_line = &ctx.lines[effective_end_idx + 1];
273 let next_trimmed = next_line.content(ctx.content).trim();
274 if is_kramdown_block_attribute(next_trimmed) {
275 result.push(next_trimmed.to_string());
276 effective_end_idx += 1;
277 ial_count += 1;
278 } else {
279 break;
280 }
281 }
282
283 let mut blank_lines_below = 0;
285 let mut next_content_line_idx = None;
286 for j in (effective_end_idx + 1)..ctx.lines.len() {
287 if ctx.lines[j].is_blank {
288 blank_lines_below += 1;
289 } else {
290 next_content_line_idx = Some(j);
291 break;
292 }
293 }
294
295 let next_is_special = if let Some(idx) = next_content_line_idx {
297 let next_line = &ctx.lines[idx];
298 next_line.list_item.is_some() || {
299 let trimmed = next_line.content(ctx.content).trim();
300 (trimmed.starts_with("```") || trimmed.starts_with("~~~"))
301 && (trimmed.len() == 3
302 || (trimmed.len() > 3
303 && trimmed
304 .chars()
305 .nth(3)
306 .is_some_and(|c| c.is_whitespace() || c.is_alphabetic())))
307 }
308 } else {
309 false
310 };
311
312 let requirement_below = self.config.lines_below.get_for_level(heading_level);
314 let needed_blanks_below = if next_is_special {
315 0
316 } else {
317 requirement_below.required_count().unwrap_or(0)
318 };
319 if blank_lines_below < needed_blanks_below {
320 for _ in 0..(needed_blanks_below - blank_lines_below) {
321 result.push(String::new());
322 }
323 }
324
325 skip_count += ial_count;
327 } else {
328 result.push(line.to_string());
330 }
331 }
332
333 let joined = result.join(line_ending);
334
335 if had_trailing_newline && !joined.ends_with('\n') {
338 format!("{joined}{line_ending}")
339 } else if !had_trailing_newline && joined.ends_with('\n') {
340 joined[..joined.len() - 1].to_string()
342 } else {
343 joined
344 }
345 }
346}
347
348impl Rule for MD022BlanksAroundHeadings {
349 fn name(&self) -> &'static str {
350 "MD022"
351 }
352
353 fn description(&self) -> &'static str {
354 "Headings should be surrounded by blank lines"
355 }
356
357 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
358 let mut result = Vec::new();
359
360 if ctx.lines.is_empty() {
362 return Ok(result);
363 }
364
365 let line_ending = "\n";
367 let is_pandoc = ctx.flavor.is_pandoc_compatible();
368
369 let heading_at_start_idx = {
370 let mut found_non_transparent = false;
371 ctx.lines.iter().enumerate().find_map(|(i, line)| {
372 if line.heading.as_ref().is_some_and(|h| h.is_valid) && !found_non_transparent {
374 Some(i)
375 } else {
376 if !line.is_blank && !line.in_html_comment && !line.in_mdx_comment {
379 let trimmed = line.content(ctx.content).trim();
380 if trimmed.starts_with("<!--") && trimmed.ends_with("-->") {
382 } else if line.in_kramdown_extension_block || line.is_kramdown_block_ial {
384 } else if is_pandoc && (pandoc::is_div_open(trimmed) || pandoc::is_div_close(trimmed)) {
386 } else {
388 found_non_transparent = true;
389 }
390 }
391 None
392 }
393 })
394 };
395
396 let mut heading_violations = Vec::new();
398 let mut processed_headings = std::collections::HashSet::new();
399
400 for (line_num, line_info) in ctx.lines.iter().enumerate() {
401 if processed_headings.contains(&line_num) || line_info.heading.is_none() {
403 continue;
404 }
405
406 if line_info.in_pymdown_block {
408 continue;
409 }
410
411 let heading = line_info.heading.as_ref().unwrap();
412
413 if !heading.is_valid {
415 continue;
416 }
417
418 let heading_level = heading.level as usize;
419
420 processed_headings.insert(line_num);
424
425 let is_first_heading = Some(line_num) == heading_at_start_idx;
427
428 let required_above_count = self.config.lines_above.get_for_level(heading_level).required_count();
430 let required_below_count = self.config.lines_below.get_for_level(heading_level).required_count();
431
432 let should_check_above =
434 required_above_count.is_some() && line_num > 0 && (!is_first_heading || !self.config.allowed_at_start);
435 if should_check_above {
436 let mut blank_lines_above = 0;
437 let mut hit_frontmatter_end = false;
438 for j in (0..line_num).rev() {
439 let line_content = ctx.lines[j].content(ctx.content);
440 let trimmed = line_content.trim();
441 if ctx.lines[j].is_blank {
442 blank_lines_above += 1;
443 } else if ctx.lines[j].in_html_comment
444 || ctx.lines[j].in_mdx_comment
445 || (trimmed.starts_with("<!--") && trimmed.ends_with("-->"))
446 {
447 continue;
449 } else if is_kramdown_block_attribute(trimmed) {
450 continue;
452 } else if is_pandoc && (pandoc::is_div_open(trimmed) || pandoc::is_div_close(trimmed)) {
453 continue;
455 } else if ctx.lines[j].in_front_matter {
456 hit_frontmatter_end = true;
461 break;
462 } else {
463 break;
464 }
465 }
466 let required = required_above_count.unwrap();
467 if !hit_frontmatter_end && blank_lines_above < required {
468 let needed_blanks = required - blank_lines_above;
469 heading_violations.push((line_num, "above", needed_blanks, heading_level));
470 }
471 }
472
473 let mut effective_last_line = if matches!(
475 heading.style,
476 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
477 ) {
478 line_num + 1 } else {
480 line_num
481 };
482
483 while effective_last_line + 1 < ctx.lines.len() {
486 let next_line = &ctx.lines[effective_last_line + 1];
487 let next_trimmed = next_line.content(ctx.content).trim();
488 if is_kramdown_block_attribute(next_trimmed) {
489 effective_last_line += 1;
490 } else {
491 break;
492 }
493 }
494
495 if effective_last_line < ctx.lines.len() - 1 {
497 let mut next_non_blank_idx = effective_last_line + 1;
499 while next_non_blank_idx < ctx.lines.len() {
500 let check_line = &ctx.lines[next_non_blank_idx];
501 let check_trimmed = check_line.content(ctx.content).trim();
502 if check_line.is_blank {
503 next_non_blank_idx += 1;
504 } else if check_line.in_html_comment
505 || check_line.in_mdx_comment
506 || (check_trimmed.starts_with("<!--") && check_trimmed.ends_with("-->"))
507 {
508 next_non_blank_idx += 1;
510 } else if is_pandoc && (pandoc::is_div_open(check_trimmed) || pandoc::is_div_close(check_trimmed)) {
511 next_non_blank_idx += 1;
513 } else {
514 break;
515 }
516 }
517
518 if next_non_blank_idx >= ctx.lines.len() {
520 continue;
522 }
523
524 let next_line_is_special = {
526 let next_line = &ctx.lines[next_non_blank_idx];
527 let next_trimmed = next_line.content(ctx.content).trim();
528
529 let is_code_fence = (next_trimmed.starts_with("```") || next_trimmed.starts_with("~~~"))
531 && (next_trimmed.len() == 3
532 || (next_trimmed.len() > 3
533 && next_trimmed
534 .chars()
535 .nth(3)
536 .is_some_and(|c| c.is_whitespace() || c.is_alphabetic())));
537
538 let is_list_item = next_line.list_item.is_some() || starts_with_list_marker(next_trimmed);
545
546 is_code_fence || is_list_item
547 };
548
549 if !next_line_is_special && let Some(required) = required_below_count {
551 let mut blank_lines_below = 0;
553 for k in (effective_last_line + 1)..next_non_blank_idx {
554 if ctx.lines[k].is_blank {
555 blank_lines_below += 1;
556 }
557 }
558
559 if blank_lines_below < required {
560 let needed_blanks = required - blank_lines_below;
561 heading_violations.push((line_num, "below", needed_blanks, heading_level));
562 }
563 }
564 }
565 }
566
567 for (heading_line, position, needed_blanks, heading_level) in heading_violations {
569 let heading_display_line = heading_line + 1; let line_info = &ctx.lines[heading_line];
571
572 let (start_line, start_col, end_line, end_col) =
574 calculate_heading_range(heading_display_line, line_info.content(ctx.content));
575
576 let required_above_count = self
577 .config
578 .lines_above
579 .get_for_level(heading_level)
580 .required_count()
581 .expect("Violations only generated for limited 'above' requirements");
582 let required_below_count = self
583 .config
584 .lines_below
585 .get_for_level(heading_level)
586 .required_count()
587 .expect("Violations only generated for limited 'below' requirements");
588
589 let (message, insertion_point) = match position {
590 "above" => (
591 format!(
592 "Expected {} blank {} above heading",
593 required_above_count,
594 if required_above_count == 1 { "line" } else { "lines" }
595 ),
596 heading_line, ),
598 "below" => {
599 let insert_after = if line_info.heading.as_ref().is_some_and(|h| {
601 matches!(
602 h.style,
603 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
604 )
605 }) {
606 heading_line + 2
607 } else {
608 heading_line + 1
609 };
610
611 (
612 format!(
613 "Expected {} blank {} below heading",
614 required_below_count,
615 if required_below_count == 1 { "line" } else { "lines" }
616 ),
617 insert_after,
618 )
619 }
620 _ => continue,
621 };
622
623 let byte_range = if insertion_point == 0 && position == "above" {
625 0..0
627 } else if position == "above" && insertion_point > 0 {
628 ctx.lines[insertion_point].byte_offset..ctx.lines[insertion_point].byte_offset
630 } else if position == "below" && insertion_point - 1 < ctx.lines.len() {
631 let line_idx = insertion_point - 1;
633 let line_end_offset = if line_idx + 1 < ctx.lines.len() {
634 ctx.lines[line_idx + 1].byte_offset
635 } else {
636 ctx.content.len()
637 };
638 line_end_offset..line_end_offset
639 } else {
640 let content_len = ctx.content.len();
642 content_len..content_len
643 };
644
645 result.push(LintWarning {
646 rule_name: Some(self.name().to_string()),
647 message,
648 line: start_line,
649 column: start_col,
650 end_line,
651 end_column: end_col,
652 severity: Severity::Warning,
653 fix: Some(Fix::new(byte_range, line_ending.repeat(needed_blanks))),
654 });
655 }
656
657 Ok(result)
658 }
659
660 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
661 if ctx.content.is_empty() {
662 return Ok(ctx.content.to_string());
663 }
664
665 let fixed = self.fix_content(ctx);
667
668 Ok(fixed)
669 }
670
671 fn category(&self) -> RuleCategory {
673 RuleCategory::Heading
674 }
675
676 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
678 if ctx.content.is_empty() || !ctx.likely_has_headings() {
680 return true;
681 }
682 ctx.lines.iter().all(|line| line.heading.is_none())
684 }
685
686 fn as_any(&self) -> &dyn std::any::Any {
687 self
688 }
689
690 crate::impl_rule_config_methods!(MD022Config);
691}
692
693#[cfg(test)]
694mod tests {
695 use super::*;
696 use crate::lint_context::LintContext;
697
698 #[test]
699 fn test_valid_headings() {
700 let rule = MD022BlanksAroundHeadings::default();
701 let content = "\n# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.\n";
702 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
703 let result = rule.check(&ctx).unwrap();
704 assert!(result.is_empty());
705 }
706
707 #[test]
708 fn test_missing_blank_above() {
709 let rule = MD022BlanksAroundHeadings::default();
710 let content = "# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.\n";
711 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
712 let result = rule.check(&ctx).unwrap();
713 assert_eq!(result.len(), 0); let fixed = rule.fix(&ctx).unwrap();
716
717 assert!(fixed.contains("# Heading 1"));
720 assert!(fixed.contains("Some content."));
721 assert!(fixed.contains("## Heading 2"));
722 assert!(fixed.contains("More content."));
723 }
724
725 #[test]
726 fn test_missing_blank_below() {
727 let rule = MD022BlanksAroundHeadings::default();
728 let content = "\n# Heading 1\nSome content.\n\n## Heading 2\n\nMore content.\n";
729 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
730 let result = rule.check(&ctx).unwrap();
731 assert_eq!(result.len(), 1);
732 assert_eq!(result[0].line, 2);
733
734 let fixed = rule.fix(&ctx).unwrap();
736 assert!(fixed.contains("# Heading 1\n\nSome content"));
737 }
738
739 #[test]
740 fn test_missing_blank_above_and_below() {
741 let rule = MD022BlanksAroundHeadings::default();
742 let content = "# Heading 1\nSome content.\n## Heading 2\nMore content.\n";
743 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
744 let result = rule.check(&ctx).unwrap();
745 assert_eq!(result.len(), 3); let fixed = rule.fix(&ctx).unwrap();
749 assert!(fixed.contains("# Heading 1\n\nSome content"));
750 assert!(fixed.contains("Some content.\n\n## Heading 2"));
751 assert!(fixed.contains("## Heading 2\n\nMore content"));
752 }
753
754 #[test]
755 fn test_fix_headings() {
756 let rule = MD022BlanksAroundHeadings::default();
757 let content = "# Heading 1\nSome content.\n## Heading 2\nMore content.";
758 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
759 let result = rule.fix(&ctx).unwrap();
760
761 let expected = "# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.";
762 assert_eq!(result, expected);
763 }
764
765 #[test]
766 fn test_consecutive_headings_pattern() {
767 let rule = MD022BlanksAroundHeadings::default();
768 let content = "# Heading 1\n## Heading 2\n### Heading 3";
769 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
770 let result = rule.fix(&ctx).unwrap();
771
772 let lines: Vec<&str> = result.lines().collect();
774 assert!(!lines.is_empty());
775
776 let h1_pos = lines.iter().position(|&l| l == "# Heading 1").unwrap();
778 let h2_pos = lines.iter().position(|&l| l == "## Heading 2").unwrap();
779 let h3_pos = lines.iter().position(|&l| l == "### Heading 3").unwrap();
780
781 assert!(
783 h2_pos > h1_pos + 1,
784 "Should have at least one blank line after first heading"
785 );
786 assert!(
787 h3_pos > h2_pos + 1,
788 "Should have at least one blank line after second heading"
789 );
790
791 assert!(lines[h1_pos + 1].trim().is_empty(), "Line after h1 should be blank");
793
794 assert!(lines[h2_pos + 1].trim().is_empty(), "Line after h2 should be blank");
796 }
797
798 #[test]
799 fn test_blanks_around_setext_headings() {
800 let rule = MD022BlanksAroundHeadings::default();
801 let content = "Heading 1\n=========\nSome content.\nHeading 2\n---------\nMore content.";
802 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
803 let result = rule.fix(&ctx).unwrap();
804
805 let lines: Vec<&str> = result.lines().collect();
807
808 assert!(result.contains("Heading 1"));
810 assert!(result.contains("========="));
811 assert!(result.contains("Some content."));
812 assert!(result.contains("Heading 2"));
813 assert!(result.contains("---------"));
814 assert!(result.contains("More content."));
815
816 let heading1_marker_idx = lines.iter().position(|&l| l == "=========").unwrap();
818 let some_content_idx = lines.iter().position(|&l| l == "Some content.").unwrap();
819 assert!(
820 some_content_idx > heading1_marker_idx + 1,
821 "Should have a blank line after the first heading"
822 );
823
824 let heading2_marker_idx = lines.iter().position(|&l| l == "---------").unwrap();
825 let more_content_idx = lines.iter().position(|&l| l == "More content.").unwrap();
826 assert!(
827 more_content_idx > heading2_marker_idx + 1,
828 "Should have a blank line after the second heading"
829 );
830
831 let fixed_ctx = LintContext::new(&result, crate::config::MarkdownFlavor::Standard, None);
833 let fixed_warnings = rule.check(&fixed_ctx).unwrap();
834 assert!(fixed_warnings.is_empty(), "Fixed content should have no warnings");
835 }
836
837 #[test]
838 fn test_fix_specific_blank_line_cases() {
839 let rule = MD022BlanksAroundHeadings::default();
840
841 let content1 = "# Heading 1\n## Heading 2\n### Heading 3";
843 let ctx1 = LintContext::new(content1, crate::config::MarkdownFlavor::Standard, None);
844 let result1 = rule.fix(&ctx1).unwrap();
845 assert!(result1.contains("# Heading 1"));
847 assert!(result1.contains("## Heading 2"));
848 assert!(result1.contains("### Heading 3"));
849 let lines: Vec<&str> = result1.lines().collect();
851 let h1_pos = lines.iter().position(|&l| l == "# Heading 1").unwrap();
852 let h2_pos = lines.iter().position(|&l| l == "## Heading 2").unwrap();
853 assert!(lines[h1_pos + 1].trim().is_empty(), "Should have a blank line after h1");
854 assert!(lines[h2_pos + 1].trim().is_empty(), "Should have a blank line after h2");
855
856 let content2 = "# Heading 1\nContent under heading 1\n## Heading 2";
858 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
859 let result2 = rule.fix(&ctx2).unwrap();
860 assert!(result2.contains("# Heading 1"));
862 assert!(result2.contains("Content under heading 1"));
863 assert!(result2.contains("## Heading 2"));
864 let lines2: Vec<&str> = result2.lines().collect();
866 let h1_pos2 = lines2.iter().position(|&l| l == "# Heading 1").unwrap();
867 let _content_pos = lines2.iter().position(|&l| l == "Content under heading 1").unwrap();
868 assert!(
869 lines2[h1_pos2 + 1].trim().is_empty(),
870 "Should have a blank line after heading 1"
871 );
872
873 let content3 = "# Heading 1\n\n\n## Heading 2\n\n\n### Heading 3\n\nContent";
875 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
876 let result3 = rule.fix(&ctx3).unwrap();
877 assert!(result3.contains("# Heading 1"));
879 assert!(result3.contains("## Heading 2"));
880 assert!(result3.contains("### Heading 3"));
881 assert!(result3.contains("Content"));
882 }
883
884 #[test]
885 fn test_fix_preserves_existing_blank_lines() {
886 let rule = MD022BlanksAroundHeadings::new();
887 let content = "# Title
888
889## Section 1
890
891Content here.
892
893## Section 2
894
895More content.
896### Missing Blank Above
897
898Even more content.
899
900## Section 3
901
902Final content.";
903
904 let expected = "# Title
905
906## Section 1
907
908Content here.
909
910## Section 2
911
912More content.
913
914### Missing Blank Above
915
916Even more content.
917
918## Section 3
919
920Final content.";
921
922 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
923 let result = rule.fix_content(&ctx);
924 assert_eq!(
925 result, expected,
926 "Fix should only add missing blank lines, never remove existing ones"
927 );
928 }
929
930 #[test]
931 fn test_fix_preserves_trailing_newline() {
932 let rule = MD022BlanksAroundHeadings::new();
933
934 let content_with_newline = "# Title\nContent here.\n";
936 let ctx = LintContext::new(content_with_newline, crate::config::MarkdownFlavor::Standard, None);
937 let result = rule.fix(&ctx).unwrap();
938 assert!(result.ends_with('\n'), "Should preserve trailing newline");
939
940 let content_without_newline = "# Title\nContent here.";
942 let ctx = LintContext::new(content_without_newline, crate::config::MarkdownFlavor::Standard, None);
943 let result = rule.fix(&ctx).unwrap();
944 assert!(
945 !result.ends_with('\n'),
946 "Should not add trailing newline if original didn't have one"
947 );
948 }
949
950 #[test]
951 fn test_fix_does_not_add_blank_lines_before_lists() {
952 let rule = MD022BlanksAroundHeadings::new();
953 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.";
954
955 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.";
956
957 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
958 let result = rule.fix_content(&ctx);
959 assert_eq!(result, expected, "Fix should not add blank lines before lists");
960 }
961
962 #[test]
963 fn test_fix_idempotent_when_heading_follows_a_list_and_precedes_a_marker() {
964 let rule = MD022BlanksAroundHeadings::default();
970 let content = "- a\n# H\n2. ";
971 for flavor in [
972 crate::config::MarkdownFlavor::Standard,
973 crate::config::MarkdownFlavor::MkDocs,
974 crate::config::MarkdownFlavor::MDX,
975 ] {
976 let ctx1 = LintContext::new(content, flavor, None);
977 let fixed1 = rule.fix(&ctx1).unwrap();
978 let ctx2 = LintContext::new(&fixed1, flavor, None);
979 let fixed2 = rule.fix(&ctx2).unwrap();
980 assert_eq!(fixed1, fixed2, "MD022 fix must be idempotent (flavor={flavor:?})");
981 }
982 }
983
984 #[test]
985 fn test_per_level_configuration_no_blank_above_h1() {
986 use md022_config::HeadingLevelConfig;
987
988 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
990 lines_above: HeadingLevelConfig::per_level([0, 1, 1, 1, 1, 1]),
991 lines_below: HeadingLevelConfig::scalar(1),
992 allowed_at_start: false, });
994
995 let content = "Some text\n# Heading 1\n\nMore text";
997 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
998 let warnings = rule.check(&ctx).unwrap();
999 assert_eq!(warnings.len(), 0, "H1 without blank above should not trigger warning");
1000
1001 let content = "Some text\n## Heading 2\n\nMore text";
1003 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1004 let warnings = rule.check(&ctx).unwrap();
1005 assert_eq!(warnings.len(), 1, "H2 without blank above should trigger warning");
1006 assert!(warnings[0].message.contains("above"));
1007 }
1008
1009 #[test]
1010 fn test_per_level_configuration_different_requirements() {
1011 use md022_config::HeadingLevelConfig;
1012
1013 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1015 lines_above: HeadingLevelConfig::per_level([0, 1, 1, 2, 2, 2]),
1016 lines_below: HeadingLevelConfig::scalar(1),
1017 allowed_at_start: false,
1018 });
1019
1020 let content = "Text\n# H1\n\nText\n\n## H2\n\nText\n\n### H3\n\nText\n\n\n#### H4\n\nText";
1021 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1022 let warnings = rule.check(&ctx).unwrap();
1023
1024 assert_eq!(
1026 warnings.len(),
1027 0,
1028 "All headings should satisfy level-specific requirements"
1029 );
1030 }
1031
1032 #[test]
1033 fn test_per_level_configuration_violations() {
1034 use md022_config::HeadingLevelConfig;
1035
1036 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1038 lines_above: HeadingLevelConfig::per_level([1, 1, 1, 2, 1, 1]),
1039 lines_below: HeadingLevelConfig::scalar(1),
1040 allowed_at_start: false,
1041 });
1042
1043 let content = "Text\n\n#### Heading 4\n\nMore text";
1045 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1046 let warnings = rule.check(&ctx).unwrap();
1047
1048 assert_eq!(warnings.len(), 1, "H4 with insufficient blanks should trigger warning");
1049 assert!(warnings[0].message.contains("2 blank lines above"));
1050 }
1051
1052 #[test]
1053 fn test_per_level_fix_different_levels() {
1054 use md022_config::HeadingLevelConfig;
1055
1056 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1058 lines_above: HeadingLevelConfig::per_level([0, 1, 2, 2, 2, 2]),
1059 lines_below: HeadingLevelConfig::scalar(1),
1060 allowed_at_start: false,
1061 });
1062
1063 let content = "Text\n# H1\nContent\n## H2\nContent\n### H3\nContent";
1064 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1065 let fixed = rule.fix(&ctx).unwrap();
1066
1067 assert!(fixed.contains("Text\n# H1\n\nContent"));
1069 assert!(fixed.contains("Content\n\n## H2\n\nContent"));
1070 assert!(fixed.contains("Content\n\n\n### H3\n\nContent"));
1071 }
1072
1073 #[test]
1074 fn test_per_level_below_configuration() {
1075 use md022_config::HeadingLevelConfig;
1076
1077 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1079 lines_above: HeadingLevelConfig::scalar(1),
1080 lines_below: HeadingLevelConfig::per_level([2, 1, 1, 1, 1, 1]), allowed_at_start: true,
1082 });
1083
1084 let content = "# Heading 1\n\nSome text";
1086 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1087 let warnings = rule.check(&ctx).unwrap();
1088
1089 assert_eq!(
1090 warnings.len(),
1091 1,
1092 "H1 with insufficient blanks below should trigger warning"
1093 );
1094 assert!(warnings[0].message.contains("2 blank lines below"));
1095 }
1096
1097 #[test]
1098 fn test_scalar_configuration_still_works() {
1099 use md022_config::HeadingLevelConfig;
1100
1101 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1103 lines_above: HeadingLevelConfig::scalar(2),
1104 lines_below: HeadingLevelConfig::scalar(2),
1105 allowed_at_start: false,
1106 });
1107
1108 let content = "Text\n# H1\nContent\n## H2\nContent";
1109 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1110 let warnings = rule.check(&ctx).unwrap();
1111
1112 assert!(!warnings.is_empty(), "Should have violations for insufficient blanks");
1114 }
1115
1116 #[test]
1117 fn test_unlimited_configuration_skips_requirements() {
1118 use md022_config::{HeadingBlankRequirement, HeadingLevelConfig};
1119
1120 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1122 lines_above: HeadingLevelConfig::per_level_requirements([
1123 HeadingBlankRequirement::unlimited(),
1124 HeadingBlankRequirement::limited(1),
1125 HeadingBlankRequirement::limited(1),
1126 HeadingBlankRequirement::limited(1),
1127 HeadingBlankRequirement::limited(1),
1128 HeadingBlankRequirement::limited(1),
1129 ]),
1130 lines_below: HeadingLevelConfig::per_level_requirements([
1131 HeadingBlankRequirement::unlimited(),
1132 HeadingBlankRequirement::limited(1),
1133 HeadingBlankRequirement::limited(1),
1134 HeadingBlankRequirement::limited(1),
1135 HeadingBlankRequirement::limited(1),
1136 HeadingBlankRequirement::limited(1),
1137 ]),
1138 allowed_at_start: false,
1139 });
1140
1141 let content = "# H1\nParagraph\n## H2\nParagraph";
1142 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1143 let warnings = rule.check(&ctx).unwrap();
1144
1145 assert_eq!(warnings.len(), 2, "Only non-unlimited headings should warn");
1147 assert!(
1148 warnings.iter().all(|w| w.line >= 3),
1149 "Warnings should target later headings"
1150 );
1151
1152 let fixed = rule.fix(&ctx).unwrap();
1154 assert!(
1155 fixed.starts_with("# H1\nParagraph\n\n## H2"),
1156 "H1 should remain unchanged"
1157 );
1158 }
1159
1160 #[test]
1161 fn test_html_comment_transparency() {
1162 let rule = MD022BlanksAroundHeadings::default();
1166
1167 let content = "Some content\n\n<!-- markdownlint-disable-next-line MD001 -->\n#### Heading";
1170 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1171 let warnings = rule.check(&ctx).unwrap();
1172 assert!(
1173 warnings.is_empty(),
1174 "HTML comment is transparent - blank line above it counts for heading"
1175 );
1176
1177 let content_multiline = "Some content\n\n<!-- This is a\nmulti-line comment -->\n#### Heading";
1179 let ctx_multiline = LintContext::new(content_multiline, crate::config::MarkdownFlavor::Standard, None);
1180 let warnings_multiline = rule.check(&ctx_multiline).unwrap();
1181 assert!(
1182 warnings_multiline.is_empty(),
1183 "Multi-line HTML comment is also transparent"
1184 );
1185 }
1186
1187 #[test]
1188 fn test_frontmatter_transparency() {
1189 let rule = MD022BlanksAroundHeadings::default();
1192
1193 let content = "---\ntitle: Test\n---\n# First heading";
1195 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1196 let warnings = rule.check(&ctx).unwrap();
1197 assert!(
1198 warnings.is_empty(),
1199 "Frontmatter is transparent - heading can appear immediately after"
1200 );
1201
1202 let content_with_blank = "---\ntitle: Test\n---\n\n# First heading";
1204 let ctx_with_blank = LintContext::new(content_with_blank, crate::config::MarkdownFlavor::Standard, None);
1205 let warnings_with_blank = rule.check(&ctx_with_blank).unwrap();
1206 assert!(
1207 warnings_with_blank.is_empty(),
1208 "Heading with blank line after frontmatter should also be valid"
1209 );
1210
1211 let content_toml = "+++\ntitle = \"Test\"\n+++\n# First heading";
1213 let ctx_toml = LintContext::new(content_toml, crate::config::MarkdownFlavor::Standard, None);
1214 let warnings_toml = rule.check(&ctx_toml).unwrap();
1215 assert!(
1216 warnings_toml.is_empty(),
1217 "TOML frontmatter is also transparent for MD022"
1218 );
1219 }
1220
1221 #[test]
1222 fn test_horizontal_rule_not_treated_as_frontmatter() {
1223 let rule = MD022BlanksAroundHeadings::default();
1226
1227 let content = "Some content\n\n---\n# Heading after HR";
1229 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1230 let warnings = rule.check(&ctx).unwrap();
1231 assert!(
1232 !warnings.is_empty(),
1233 "Heading after horizontal rule without blank line SHOULD trigger MD022"
1234 );
1235 assert!(
1236 warnings.iter().any(|w| w.line == 4),
1237 "Warning should be on line 4 (the heading line)"
1238 );
1239
1240 let content_with_blank = "Some content\n\n---\n\n# Heading after HR";
1242 let ctx_with_blank = LintContext::new(content_with_blank, crate::config::MarkdownFlavor::Standard, None);
1243 let warnings_with_blank = rule.check(&ctx_with_blank).unwrap();
1244 assert!(
1245 warnings_with_blank.is_empty(),
1246 "Heading with blank line after HR should not trigger MD022"
1247 );
1248
1249 let content_hr_start = "---\n# Heading";
1251 let ctx_hr_start = LintContext::new(content_hr_start, crate::config::MarkdownFlavor::Standard, None);
1252 let warnings_hr_start = rule.check(&ctx_hr_start).unwrap();
1253 assert!(
1254 !warnings_hr_start.is_empty(),
1255 "Heading after HR at document start SHOULD trigger MD022"
1256 );
1257
1258 let content_multi_hr = "Content\n\n---\n\n---\n# Heading";
1260 let ctx_multi_hr = LintContext::new(content_multi_hr, crate::config::MarkdownFlavor::Standard, None);
1261 let warnings_multi_hr = rule.check(&ctx_multi_hr).unwrap();
1262 assert!(
1263 !warnings_multi_hr.is_empty(),
1264 "Heading after multiple HRs without blank line SHOULD trigger MD022"
1265 );
1266 }
1267
1268 #[test]
1269 fn test_all_hr_styles_require_blank_before_heading() {
1270 let rule = MD022BlanksAroundHeadings::default();
1272
1273 let hr_styles = [
1275 "---", "***", "___", "- - -", "* * *", "_ _ _", "----", "****", "____", "- - - -",
1276 "- - -", " ---", " ---", ];
1280
1281 for hr in hr_styles {
1282 let content = format!("Content\n\n{hr}\n# Heading");
1283 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1284 let warnings = rule.check(&ctx).unwrap();
1285 assert!(
1286 !warnings.is_empty(),
1287 "HR style '{hr}' followed by heading should trigger MD022"
1288 );
1289 }
1290 }
1291
1292 #[test]
1293 fn test_setext_heading_after_hr() {
1294 let rule = MD022BlanksAroundHeadings::default();
1296
1297 let content = "Content\n\n---\nHeading\n======";
1299 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1300 let warnings = rule.check(&ctx).unwrap();
1301 assert!(
1302 !warnings.is_empty(),
1303 "Setext heading after HR without blank should trigger MD022"
1304 );
1305
1306 let content_h2 = "Content\n\n---\nHeading\n------";
1308 let ctx_h2 = LintContext::new(content_h2, crate::config::MarkdownFlavor::Standard, None);
1309 let warnings_h2 = rule.check(&ctx_h2).unwrap();
1310 assert!(
1311 !warnings_h2.is_empty(),
1312 "Setext h2 after HR without blank should trigger MD022"
1313 );
1314
1315 let content_ok = "Content\n\n---\n\nHeading\n======";
1317 let ctx_ok = LintContext::new(content_ok, crate::config::MarkdownFlavor::Standard, None);
1318 let warnings_ok = rule.check(&ctx_ok).unwrap();
1319 assert!(
1320 warnings_ok.is_empty(),
1321 "Setext heading with blank after HR should not warn"
1322 );
1323 }
1324
1325 #[test]
1326 fn test_hr_in_code_block_not_treated_as_hr() {
1327 let rule = MD022BlanksAroundHeadings::default();
1329
1330 let content = "```\n---\n```\n# Heading";
1333 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1334 let warnings = rule.check(&ctx).unwrap();
1335 assert!(!warnings.is_empty(), "Heading after code block still needs blank line");
1338
1339 let content_ok = "```\n---\n```\n\n# Heading";
1341 let ctx_ok = LintContext::new(content_ok, crate::config::MarkdownFlavor::Standard, None);
1342 let warnings_ok = rule.check(&ctx_ok).unwrap();
1343 assert!(
1344 warnings_ok.is_empty(),
1345 "Heading with blank after code block should not warn"
1346 );
1347 }
1348
1349 #[test]
1350 fn test_hr_in_html_comment_not_treated_as_hr() {
1351 let rule = MD022BlanksAroundHeadings::default();
1353
1354 let content = "<!-- \n---\n -->\n# Heading";
1356 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1357 let warnings = rule.check(&ctx).unwrap();
1358 assert!(
1360 warnings.is_empty(),
1361 "HR inside HTML comment should be ignored - heading after comment is OK"
1362 );
1363 }
1364
1365 #[test]
1366 fn test_invalid_hr_not_triggering() {
1367 let rule = MD022BlanksAroundHeadings::default();
1369
1370 let invalid_hrs = [
1371 " ---", "\t---", "--", "**", "__", "-*-", "---a", "a---", ];
1380
1381 for invalid in invalid_hrs {
1382 let content = format!("Content\n\n{invalid}\n# Heading");
1385 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1386 let _ = rule.check(&ctx);
1389 }
1390 }
1391
1392 #[test]
1393 fn test_frontmatter_vs_horizontal_rule_distinction() {
1394 let rule = MD022BlanksAroundHeadings::default();
1396
1397 let content = "---\ntitle: Test\n---\n\nSome content\n\n---\n# Heading after HR";
1400 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1401 let warnings = rule.check(&ctx).unwrap();
1402 assert!(
1403 !warnings.is_empty(),
1404 "HR after frontmatter content should still require blank line before heading"
1405 );
1406
1407 let content_ok = "---\ntitle: Test\n---\n\nSome content\n\n---\n\n# Heading after HR";
1409 let ctx_ok = LintContext::new(content_ok, crate::config::MarkdownFlavor::Standard, None);
1410 let warnings_ok = rule.check(&ctx_ok).unwrap();
1411 assert!(
1412 warnings_ok.is_empty(),
1413 "HR with blank line before heading should not warn"
1414 );
1415 }
1416
1417 #[test]
1420 fn test_kramdown_ial_after_heading_no_warning() {
1421 let rule = MD022BlanksAroundHeadings::default();
1423 let content = "## Table of Contents\n{: .hhc-toc-heading}\n\nSome content here.";
1424 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1425 let warnings = rule.check(&ctx).unwrap();
1426
1427 assert!(
1428 warnings.is_empty(),
1429 "IAL after heading should not require blank line between them: {warnings:?}"
1430 );
1431 }
1432
1433 #[test]
1434 fn test_kramdown_ial_with_class() {
1435 let rule = MD022BlanksAroundHeadings::default();
1436 let content = "# Heading\n{:.highlight}\n\nContent.";
1437 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1438 let warnings = rule.check(&ctx).unwrap();
1439
1440 assert!(warnings.is_empty(), "IAL with class should be part of heading");
1441 }
1442
1443 #[test]
1444 fn test_kramdown_ial_with_id() {
1445 let rule = MD022BlanksAroundHeadings::default();
1446 let content = "# Heading\n{:#custom-id}\n\nContent.";
1447 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1448 let warnings = rule.check(&ctx).unwrap();
1449
1450 assert!(warnings.is_empty(), "IAL with id should be part of heading");
1451 }
1452
1453 #[test]
1454 fn test_kramdown_ial_with_multiple_attributes() {
1455 let rule = MD022BlanksAroundHeadings::default();
1456 let content = "# Heading\n{: .class #id style=\"color: red\"}\n\nContent.";
1457 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1458 let warnings = rule.check(&ctx).unwrap();
1459
1460 assert!(
1461 warnings.is_empty(),
1462 "IAL with multiple attributes should be part of heading"
1463 );
1464 }
1465
1466 #[test]
1467 fn test_kramdown_ial_missing_blank_after() {
1468 let rule = MD022BlanksAroundHeadings::default();
1470 let content = "# Heading\n{:.class}\nContent without blank.";
1471 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1472 let warnings = rule.check(&ctx).unwrap();
1473
1474 assert_eq!(
1475 warnings.len(),
1476 1,
1477 "Should warn about missing blank after IAL (part of heading)"
1478 );
1479 assert!(warnings[0].message.contains("below"));
1480 }
1481
1482 #[test]
1483 fn test_kramdown_ial_before_heading_transparent() {
1484 let rule = MD022BlanksAroundHeadings::default();
1486 let content = "Content.\n\n{:.preclass}\n## Heading\n\nMore content.";
1487 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1488 let warnings = rule.check(&ctx).unwrap();
1489
1490 assert!(
1491 warnings.is_empty(),
1492 "IAL before heading should be transparent for blank line count"
1493 );
1494 }
1495
1496 #[test]
1497 fn test_kramdown_ial_setext_heading() {
1498 let rule = MD022BlanksAroundHeadings::default();
1499 let content = "Heading\n=======\n{:.setext-class}\n\nContent.";
1500 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1501 let warnings = rule.check(&ctx).unwrap();
1502
1503 assert!(
1504 warnings.is_empty(),
1505 "IAL after Setext heading should be part of heading"
1506 );
1507 }
1508
1509 #[test]
1510 fn test_kramdown_ial_fix_preserves_ial() {
1511 let rule = MD022BlanksAroundHeadings::default();
1512 let content = "Content.\n# Heading\n{:.class}\nMore content.";
1513 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1514 let fixed = rule.fix(&ctx).unwrap();
1515
1516 assert!(
1518 fixed.contains("# Heading\n{:.class}"),
1519 "IAL should stay attached to heading"
1520 );
1521 assert!(fixed.contains("{:.class}\n\nMore"), "Should add blank after IAL");
1522 }
1523
1524 #[test]
1525 fn test_kramdown_ial_fix_does_not_separate() {
1526 let rule = MD022BlanksAroundHeadings::default();
1527 let content = "# Heading\n{:.class}\nContent.";
1528 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1529 let fixed = rule.fix(&ctx).unwrap();
1530
1531 assert!(
1533 !fixed.contains("# Heading\n\n{:.class}"),
1534 "Should not add blank between heading and IAL"
1535 );
1536 assert!(fixed.contains("# Heading\n{:.class}"), "IAL should remain attached");
1537 }
1538
1539 #[test]
1540 fn test_kramdown_multiple_ial_lines() {
1541 let rule = MD022BlanksAroundHeadings::default();
1543 let content = "# Heading\n{:.class1}\n{:#id}\n\nContent.";
1544 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1545 let warnings = rule.check(&ctx).unwrap();
1546
1547 assert!(
1550 warnings.is_empty(),
1551 "Multiple consecutive IALs should be part of heading"
1552 );
1553 }
1554
1555 #[test]
1556 fn test_kramdown_ial_with_blank_line_not_attached() {
1557 let rule = MD022BlanksAroundHeadings::default();
1559 let content = "# Heading\n\n{:.class}\nContent.";
1560 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1561 let warnings = rule.check(&ctx).unwrap();
1562
1563 assert!(warnings.is_empty(), "Blank line separates heading from IAL");
1567 }
1568
1569 #[test]
1570 fn test_not_kramdown_ial_regular_braces() {
1571 let rule = MD022BlanksAroundHeadings::default();
1573 let content = "# Heading\n{not an ial}\n\nContent.";
1574 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1575 let warnings = rule.check(&ctx).unwrap();
1576
1577 assert_eq!(
1579 warnings.len(),
1580 1,
1581 "Non-IAL braces should be regular content requiring blank"
1582 );
1583 }
1584
1585 #[test]
1586 fn test_kramdown_ial_at_document_end() {
1587 let rule = MD022BlanksAroundHeadings::default();
1588 let content = "# Heading\n{:.class}";
1589 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1590 let warnings = rule.check(&ctx).unwrap();
1591
1592 assert!(warnings.is_empty(), "IAL at document end needs no blank after");
1594 }
1595
1596 #[test]
1597 fn test_kramdown_ial_followed_by_code_fence() {
1598 let rule = MD022BlanksAroundHeadings::default();
1599 let content = "# Heading\n{:.class}\n```\ncode\n```";
1600 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1601 let warnings = rule.check(&ctx).unwrap();
1602
1603 assert!(warnings.is_empty(), "No blank needed between IAL and code fence");
1605 }
1606
1607 #[test]
1608 fn test_kramdown_ial_followed_by_list() {
1609 let rule = MD022BlanksAroundHeadings::default();
1610 let content = "# Heading\n{:.class}\n- List item";
1611 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1612 let warnings = rule.check(&ctx).unwrap();
1613
1614 assert!(warnings.is_empty(), "No blank needed between IAL and list");
1616 }
1617
1618 #[test]
1619 fn test_kramdown_ial_fix_idempotent() {
1620 let rule = MD022BlanksAroundHeadings::default();
1621 let content = "# Heading\n{:.class}\nContent.";
1622 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1623
1624 let fixed_once = rule.fix(&ctx).unwrap();
1625 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
1626 let fixed_twice = rule.fix(&ctx2).unwrap();
1627
1628 assert_eq!(fixed_once, fixed_twice, "Fix should be idempotent");
1629 }
1630
1631 #[test]
1632 fn test_kramdown_ial_whitespace_line_between_not_attached() {
1633 let rule = MD022BlanksAroundHeadings::default();
1636 let content = "# Heading\n \n{:.class}\n\nContent.";
1637 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1638 let warnings = rule.check(&ctx).unwrap();
1639
1640 assert!(
1644 warnings.is_empty(),
1645 "Whitespace between heading and IAL means IAL is not attached"
1646 );
1647 }
1648
1649 #[test]
1650 fn test_kramdown_ial_html_comment_between() {
1651 let rule = MD022BlanksAroundHeadings::default();
1654 let content = "# Heading\n<!-- comment -->\n{:.class}\n\nContent.";
1655 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1656 let warnings = rule.check(&ctx).unwrap();
1657
1658 assert_eq!(
1662 warnings.len(),
1663 1,
1664 "IAL not attached when comment is between: {warnings:?}"
1665 );
1666 }
1667
1668 #[test]
1669 fn test_kramdown_ial_generic_attribute() {
1670 let rule = MD022BlanksAroundHeadings::default();
1671 let content = "# Heading\n{:data-toc=\"true\" style=\"color: red\"}\n\nContent.";
1672 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1673 let warnings = rule.check(&ctx).unwrap();
1674
1675 assert!(warnings.is_empty(), "Generic attributes should be recognized as IAL");
1676 }
1677
1678 #[test]
1679 fn test_kramdown_ial_fix_multiple_lines_preserves_all() {
1680 let rule = MD022BlanksAroundHeadings::default();
1681 let content = "# Heading\n{:.class1}\n{:#id}\n{:data-x=\"y\"}\nContent.";
1682 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1683
1684 let fixed = rule.fix(&ctx).unwrap();
1685
1686 assert!(fixed.contains("{:.class1}"), "First IAL should be preserved");
1688 assert!(fixed.contains("{:#id}"), "Second IAL should be preserved");
1689 assert!(fixed.contains("{:data-x=\"y\"}"), "Third IAL should be preserved");
1690 assert!(
1692 fixed.contains("{:data-x=\"y\"}\n\nContent"),
1693 "Blank line should be after all IALs"
1694 );
1695 }
1696
1697 #[test]
1698 fn test_kramdown_ial_crlf_line_endings() {
1699 let rule = MD022BlanksAroundHeadings::default();
1700 let content = "# Heading\r\n{:.class}\r\n\r\nContent.";
1701 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1702 let warnings = rule.check(&ctx).unwrap();
1703
1704 assert!(warnings.is_empty(), "CRLF should work correctly with IAL");
1705 }
1706
1707 #[test]
1708 fn test_kramdown_ial_invalid_patterns_not_recognized() {
1709 let rule = MD022BlanksAroundHeadings::default();
1710
1711 let content = "# Heading\n{ :.class}\n\nContent.";
1713 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1714 let warnings = rule.check(&ctx).unwrap();
1715 assert_eq!(warnings.len(), 1, "Invalid IAL syntax should trigger warning");
1716
1717 let content2 = "# Heading\n{.class}\n\nContent.";
1719 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1720 let warnings2 = rule.check(&ctx2).unwrap();
1721 assert!(warnings2.is_empty(), "{{.class}} is valid kramdown block attribute");
1723
1724 let content3 = "# Heading\n{just text}\n\nContent.";
1726 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
1727 let warnings3 = rule.check(&ctx3).unwrap();
1728 assert_eq!(
1729 warnings3.len(),
1730 1,
1731 "Text in braces is not IAL and should trigger warning"
1732 );
1733 }
1734
1735 #[test]
1736 fn test_kramdown_ial_toc_marker() {
1737 let rule = MD022BlanksAroundHeadings::default();
1739 let content = "# Heading\n{:toc}\n\nContent.";
1740 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1741 let warnings = rule.check(&ctx).unwrap();
1742
1743 assert!(warnings.is_empty(), "{{:toc}} should be recognized as IAL");
1745 }
1746
1747 #[test]
1748 fn test_kramdown_ial_mixed_headings_in_document() {
1749 let rule = MD022BlanksAroundHeadings::default();
1750 let content = r#"# ATX Heading
1751{:.atx-class}
1752
1753Content after ATX.
1754
1755Setext Heading
1756--------------
1757{:#setext-id}
1758
1759Content after Setext.
1760
1761## Another ATX
1762{:.another}
1763
1764More content."#;
1765 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1766 let warnings = rule.check(&ctx).unwrap();
1767
1768 assert!(
1769 warnings.is_empty(),
1770 "Mixed headings with IAL should all work: {warnings:?}"
1771 );
1772 }
1773
1774 #[test]
1775 fn test_kramdown_extension_block_before_first_heading_is_document_start() {
1776 let rule = MD022BlanksAroundHeadings::default();
1777 let content = "{::comment}\nhidden\n{:/comment}\n# Heading\n\nBody\n";
1778 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Kramdown, None);
1779 let warnings = rule.check(&ctx).unwrap();
1780
1781 assert!(
1782 warnings.is_empty(),
1783 "Kramdown extension preamble should not require blank above first heading: {warnings:?}"
1784 );
1785 }
1786
1787 #[test]
1788 fn test_kramdown_ial_before_first_heading_is_document_start() {
1789 let rule = MD022BlanksAroundHeadings::default();
1790 let content = "{:.doc-class}\n# Heading\n\nBody\n";
1791 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Kramdown, None);
1792 let warnings = rule.check(&ctx).unwrap();
1793
1794 assert!(
1795 warnings.is_empty(),
1796 "Kramdown IAL preamble should not require blank above first heading: {warnings:?}"
1797 );
1798 }
1799
1800 #[test]
1803 fn test_quarto_div_marker_transparent_above_heading() {
1804 let rule = MD022BlanksAroundHeadings::default();
1807 let content = "Content\n\n::: {.callout-note}\n# Heading\n\nMore content\n:::\n";
1809 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1810 let warnings = rule.check(&ctx).unwrap();
1811 assert!(
1813 warnings.is_empty(),
1814 "Quarto div marker should be transparent above heading: {warnings:?}"
1815 );
1816 }
1817
1818 #[test]
1819 fn test_quarto_div_marker_transparent_below_heading() {
1820 let rule = MD022BlanksAroundHeadings::default();
1822 let content = "# Heading\n\n::: {.callout-note}\nContent\n:::\n";
1823 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1824 let warnings = rule.check(&ctx).unwrap();
1825 assert!(
1827 warnings.is_empty(),
1828 "Quarto div marker should be transparent below heading: {warnings:?}"
1829 );
1830 }
1831
1832 #[test]
1833 fn test_quarto_heading_inside_callout() {
1834 let rule = MD022BlanksAroundHeadings::default();
1836 let content = "::: {.callout-note}\n\n## Note Title\n\nNote content\n:::\n";
1837 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1838 let warnings = rule.check(&ctx).unwrap();
1839 assert!(
1840 warnings.is_empty(),
1841 "Heading inside Quarto callout should have no warnings: {warnings:?}"
1842 );
1843 }
1844
1845 #[test]
1846 fn test_quarto_heading_at_start_after_div_open() {
1847 let rule = MD022BlanksAroundHeadings::default();
1850 let content = "::: {.callout-warning}\n# Warning\n\nContent\n:::\n";
1852 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1853 let warnings = rule.check(&ctx).unwrap();
1854 assert!(
1860 warnings.is_empty(),
1861 "Heading at start after div open should pass: {warnings:?}"
1862 );
1863 }
1864
1865 #[test]
1866 fn test_quarto_heading_before_div_close() {
1867 let rule = MD022BlanksAroundHeadings::default();
1869 let content = "::: {.callout-note}\nIntro\n\n## Section\n:::\n";
1870 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1871 let warnings = rule.check(&ctx).unwrap();
1872 assert!(
1876 warnings.is_empty(),
1877 "Heading before div close should pass: {warnings:?}"
1878 );
1879 }
1880
1881 #[test]
1882 fn test_quarto_div_markers_not_transparent_in_standard_flavor() {
1883 let rule = MD022BlanksAroundHeadings::default();
1885 let content = "Content\n\n:::\n# Heading\n\n:::\n";
1886 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1887 let warnings = rule.check(&ctx).unwrap();
1888 assert!(
1890 !warnings.is_empty(),
1891 "Standard flavor should not treat ::: as transparent: {warnings:?}"
1892 );
1893 }
1894
1895 #[test]
1896 fn test_quarto_nested_divs_with_heading() {
1897 let rule = MD022BlanksAroundHeadings::default();
1899 let content = "::: {.outer}\n::: {.inner}\n\n# Heading\n\nContent\n:::\n:::\n";
1900 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1901 let warnings = rule.check(&ctx).unwrap();
1902 assert!(
1903 warnings.is_empty(),
1904 "Nested divs with heading should work: {warnings:?}"
1905 );
1906 }
1907
1908 #[test]
1909 fn test_quarto_fix_preserves_div_markers() {
1910 let rule = MD022BlanksAroundHeadings::default();
1912 let content = "::: {.callout-note}\n\n## Note\n\nContent\n:::\n";
1913 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1914 let fixed = rule.fix(&ctx).unwrap();
1915 assert!(fixed.contains("::: {.callout-note}"), "Should preserve div opening");
1917 assert!(fixed.contains(":::"), "Should preserve div closing");
1918 assert!(fixed.contains("## Note"), "Should preserve heading");
1919 }
1920
1921 #[test]
1922 fn test_quarto_heading_needs_blank_without_div_transparency() {
1923 let rule = MD022BlanksAroundHeadings::default();
1926 let content = "Content\n::: {.callout-note}\n# Heading\n\nMore\n:::\n";
1928 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1929 let warnings = rule.check(&ctx).unwrap();
1930 assert!(
1933 !warnings.is_empty(),
1934 "Should still require blank line when not present: {warnings:?}"
1935 );
1936 }
1937
1938 #[test]
1939 fn test_pandoc_div_marker_transparent_above_heading() {
1940 let rule = MD022BlanksAroundHeadings::default();
1943 let content = "Content\n\n::: {.callout-note}\n# Heading\n\nMore content\n:::\n";
1944 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1945 let warnings = rule.check(&ctx).unwrap();
1946 assert!(
1947 warnings.is_empty(),
1948 "MD022 should treat Pandoc div marker as transparent above heading: {warnings:?}"
1949 );
1950 }
1951}