1use crate::lint_context::is_horizontal_rule_content;
2use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
6use crate::utils::mkdocs_attr_list::is_block_attribute_line;
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
14fn starts_with_list_marker(trimmed: &str) -> bool {
29 if is_horizontal_rule_content(trimmed) {
30 return false;
31 }
32 let bytes = trimmed.as_bytes();
33 match bytes.first() {
34 Some(b'-' | b'*' | b'+') => matches!(bytes.get(1), None | Some(b' ')),
35 Some(b'0'..=b'9') => {
36 let mut i = 0;
37 while bytes.get(i).is_some_and(u8::is_ascii_digit) {
38 i += 1;
39 }
40 matches!(bytes.get(i), Some(b'.' | b')')) && matches!(bytes.get(i + 1), None | Some(b' '))
41 }
42 _ => false,
43 }
44}
45
46#[derive(Clone, Default)]
118pub struct MD022BlanksAroundHeadings {
119 config: MD022Config,
120}
121
122impl MD022BlanksAroundHeadings {
123 pub fn new() -> Self {
126 Self {
127 config: MD022Config::default(),
128 }
129 }
130
131 pub fn with_values(lines_above: usize, lines_below: usize) -> Self {
133 use md022_config::HeadingLevelConfig;
134 Self {
135 config: MD022Config {
136 lines_above: HeadingLevelConfig::scalar(lines_above),
137 lines_below: HeadingLevelConfig::scalar(lines_below),
138 allowed_at_start: true,
139 },
140 }
141 }
142
143 pub fn from_config_struct(config: MD022Config) -> Self {
144 Self { config }
145 }
146
147 fn fix_content(&self, ctx: &crate::lint_context::LintContext) -> String {
149 let line_ending = "\n";
151 let had_trailing_newline = ctx.content.ends_with('\n');
152 let is_pandoc = ctx.flavor.is_pandoc_compatible();
153 let mut result = Vec::new();
154 let mut skip_count: usize = 0;
155
156 let heading_at_start_idx = {
157 let mut found_non_transparent = false;
158 ctx.lines.iter().enumerate().find_map(|(i, line)| {
159 if line.heading.as_ref().is_some_and(|h| h.is_valid) && !found_non_transparent {
161 Some(i)
162 } else {
163 if !line.is_blank && !line.in_html_comment && !line.in_mdx_comment {
166 let trimmed = line.content(ctx.content).trim();
167 if trimmed.starts_with("<!--") && trimmed.ends_with("-->") {
169 } else if line.in_kramdown_extension_block || line.is_kramdown_block_ial {
171 } else if is_pandoc && (pandoc::is_div_open(trimmed) || pandoc::is_div_close(trimmed)) {
173 } else {
175 found_non_transparent = true;
176 }
177 }
178 None
179 }
180 })
181 };
182
183 for (i, line_info) in ctx.lines.iter().enumerate() {
184 if skip_count > 0 {
185 skip_count -= 1;
186 continue;
187 }
188 let line = line_info.content(ctx.content);
189
190 if line_info.in_code_block {
191 result.push(line.to_string());
192 continue;
193 }
194
195 if let Some(heading) = &line_info.heading {
197 if !heading.is_valid {
199 result.push(line.to_string());
200 continue;
201 }
202
203 let line_num = i + 1;
205 if ctx.inline_config().is_rule_disabled("MD022", line_num) {
206 result.push(line.to_string());
207 if matches!(
209 heading.style,
210 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
211 ) && i + 1 < ctx.lines.len()
212 {
213 result.push(ctx.lines[i + 1].content(ctx.content).to_string());
214 skip_count += 1;
215 }
216 continue;
217 }
218
219 let is_first_heading = Some(i) == heading_at_start_idx;
221 let heading_level = heading.level as usize;
222
223 let mut blank_lines_above = 0;
225 let mut check_idx = result.len();
226 while check_idx > 0 {
227 let prev_line = &result[check_idx - 1];
228 let trimmed = prev_line.trim();
229 if trimmed.is_empty() {
230 blank_lines_above += 1;
231 check_idx -= 1;
232 } else if trimmed.starts_with("<!--") && trimmed.ends_with("-->") {
233 check_idx -= 1;
235 } else if is_block_attribute_line(trimmed, ctx.flavor) {
236 check_idx -= 1;
238 } else if is_pandoc && (pandoc::is_div_open(trimmed) || pandoc::is_div_close(trimmed)) {
239 check_idx -= 1;
241 } else {
242 break;
243 }
244 }
245
246 let requirement_above = self.config.lines_above.get_for_level(heading_level);
248 let needed_blanks_above = if is_first_heading && self.config.allowed_at_start {
249 0
250 } else {
251 requirement_above.required_count().unwrap_or(0)
252 };
253
254 while blank_lines_above < needed_blanks_above {
256 result.push(String::new());
257 blank_lines_above += 1;
258 }
259
260 result.push(line.to_string());
262
263 let mut effective_end_idx = i;
265
266 if matches!(
268 heading.style,
269 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
270 ) {
271 if i + 1 < ctx.lines.len() {
273 result.push(ctx.lines[i + 1].content(ctx.content).to_string());
274 skip_count += 1; effective_end_idx = i + 1;
276 }
277 }
278
279 let mut ial_count = 0;
282 while effective_end_idx + 1 < ctx.lines.len() {
283 let next_line = &ctx.lines[effective_end_idx + 1];
284 let next_trimmed = next_line.content(ctx.content).trim();
285 if is_block_attribute_line(next_trimmed, ctx.flavor) {
286 result.push(next_trimmed.to_string());
287 effective_end_idx += 1;
288 ial_count += 1;
289 } else {
290 break;
291 }
292 }
293
294 let mut blank_lines_below = 0;
296 let mut next_content_line_idx = None;
297 for j in (effective_end_idx + 1)..ctx.lines.len() {
298 if ctx.lines[j].is_blank {
299 blank_lines_below += 1;
300 } else {
301 next_content_line_idx = Some(j);
302 break;
303 }
304 }
305
306 let next_is_special = if let Some(idx) = next_content_line_idx {
308 let next_line = &ctx.lines[idx];
309 let trimmed = next_line.content(ctx.content).trim();
310 next_line.list_item.is_some()
311 || starts_with_list_marker(trimmed)
312 || ((trimmed.starts_with("```") || trimmed.starts_with("~~~"))
313 && (trimmed.len() == 3
314 || (trimmed.len() > 3
315 && trimmed
316 .chars()
317 .nth(3)
318 .is_some_and(|c| c.is_whitespace() || c.is_alphabetic()))))
319 } else {
320 false
321 };
322
323 let requirement_below = self.config.lines_below.get_for_level(heading_level);
325 let needed_blanks_below = if next_is_special {
326 0
327 } else {
328 requirement_below.required_count().unwrap_or(0)
329 };
330 if blank_lines_below < needed_blanks_below {
331 for _ in 0..(needed_blanks_below - blank_lines_below) {
332 result.push(String::new());
333 }
334 }
335
336 skip_count += ial_count;
338 } else {
339 result.push(line.to_string());
341 }
342 }
343
344 let joined = result.join(line_ending);
345
346 if had_trailing_newline && !joined.ends_with('\n') {
349 format!("{joined}{line_ending}")
350 } else if !had_trailing_newline && joined.ends_with('\n') {
351 joined[..joined.len() - 1].to_string()
353 } else {
354 joined
355 }
356 }
357}
358
359impl Rule for MD022BlanksAroundHeadings {
360 fn name(&self) -> &'static str {
361 "MD022"
362 }
363
364 fn description(&self) -> &'static str {
365 "Headings should be surrounded by blank lines"
366 }
367
368 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
369 let mut result = Vec::new();
370
371 if ctx.lines.is_empty() {
373 return Ok(result);
374 }
375
376 let line_ending = "\n";
378 let is_pandoc = ctx.flavor.is_pandoc_compatible();
379
380 let heading_at_start_idx = {
381 let mut found_non_transparent = false;
382 ctx.lines.iter().enumerate().find_map(|(i, line)| {
383 if line.heading.as_ref().is_some_and(|h| h.is_valid) && !found_non_transparent {
385 Some(i)
386 } else {
387 if !line.is_blank && !line.in_html_comment && !line.in_mdx_comment {
390 let trimmed = line.content(ctx.content).trim();
391 if trimmed.starts_with("<!--") && trimmed.ends_with("-->") {
393 } else if line.in_kramdown_extension_block || line.is_kramdown_block_ial {
395 } else if is_pandoc && (pandoc::is_div_open(trimmed) || pandoc::is_div_close(trimmed)) {
397 } else {
399 found_non_transparent = true;
400 }
401 }
402 None
403 }
404 })
405 };
406
407 let mut heading_violations = Vec::new();
409 let mut processed_headings = std::collections::HashSet::new();
410
411 for (line_num, line_info) in ctx.lines.iter().enumerate() {
412 if processed_headings.contains(&line_num) || line_info.heading.is_none() {
414 continue;
415 }
416
417 if line_info.in_pymdown_block {
419 continue;
420 }
421
422 let heading = line_info.heading.as_ref().unwrap();
423
424 if !heading.is_valid {
426 continue;
427 }
428
429 let heading_level = heading.level as usize;
430
431 processed_headings.insert(line_num);
435
436 let is_first_heading = Some(line_num) == heading_at_start_idx;
438
439 let required_above_count = self.config.lines_above.get_for_level(heading_level).required_count();
441 let required_below_count = self.config.lines_below.get_for_level(heading_level).required_count();
442
443 let should_check_above =
445 required_above_count.is_some() && line_num > 0 && (!is_first_heading || !self.config.allowed_at_start);
446 if should_check_above {
447 let mut blank_lines_above = 0;
448 let mut hit_frontmatter_end = false;
449 for j in (0..line_num).rev() {
450 let line_content = ctx.lines[j].content(ctx.content);
451 let trimmed = line_content.trim();
452 if ctx.lines[j].is_blank {
453 blank_lines_above += 1;
454 } else if ctx.lines[j].in_html_comment
455 || ctx.lines[j].in_mdx_comment
456 || (trimmed.starts_with("<!--") && trimmed.ends_with("-->"))
457 {
458 continue;
460 } else if is_block_attribute_line(trimmed, ctx.flavor) {
461 continue;
463 } else if is_pandoc && (pandoc::is_div_open(trimmed) || pandoc::is_div_close(trimmed)) {
464 continue;
466 } else if ctx.lines[j].in_front_matter {
467 hit_frontmatter_end = true;
472 break;
473 } else {
474 break;
475 }
476 }
477 let required = required_above_count.unwrap();
478 if !hit_frontmatter_end && blank_lines_above < required {
479 let needed_blanks = required - blank_lines_above;
480 heading_violations.push((line_num, "above", needed_blanks, heading_level));
481 }
482 }
483
484 let mut effective_last_line = if matches!(
486 heading.style,
487 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
488 ) {
489 line_num + 1 } else {
491 line_num
492 };
493
494 while effective_last_line + 1 < ctx.lines.len() {
497 let next_line = &ctx.lines[effective_last_line + 1];
498 let next_trimmed = next_line.content(ctx.content).trim();
499 if is_block_attribute_line(next_trimmed, ctx.flavor) {
500 effective_last_line += 1;
501 } else {
502 break;
503 }
504 }
505
506 if effective_last_line < ctx.lines.len() - 1 {
508 let mut next_non_blank_idx = effective_last_line + 1;
510 while next_non_blank_idx < ctx.lines.len() {
511 let check_line = &ctx.lines[next_non_blank_idx];
512 let check_trimmed = check_line.content(ctx.content).trim();
513 if check_line.is_blank {
514 next_non_blank_idx += 1;
515 } else if check_line.in_html_comment
516 || check_line.in_mdx_comment
517 || (check_trimmed.starts_with("<!--") && check_trimmed.ends_with("-->"))
518 {
519 next_non_blank_idx += 1;
521 } else if is_pandoc && (pandoc::is_div_open(check_trimmed) || pandoc::is_div_close(check_trimmed)) {
522 next_non_blank_idx += 1;
524 } else {
525 break;
526 }
527 }
528
529 if next_non_blank_idx >= ctx.lines.len() {
531 continue;
533 }
534
535 let next_line_is_special = {
537 let next_line = &ctx.lines[next_non_blank_idx];
538 let next_trimmed = next_line.content(ctx.content).trim();
539
540 let is_code_fence = (next_trimmed.starts_with("```") || next_trimmed.starts_with("~~~"))
542 && (next_trimmed.len() == 3
543 || (next_trimmed.len() > 3
544 && next_trimmed
545 .chars()
546 .nth(3)
547 .is_some_and(|c| c.is_whitespace() || c.is_alphabetic())));
548
549 let is_list_item = next_line.list_item.is_some() || starts_with_list_marker(next_trimmed);
556
557 is_code_fence || is_list_item
558 };
559
560 if !next_line_is_special && let Some(required) = required_below_count {
562 let mut blank_lines_below = 0;
564 for k in (effective_last_line + 1)..next_non_blank_idx {
565 if ctx.lines[k].is_blank {
566 blank_lines_below += 1;
567 }
568 }
569
570 if blank_lines_below < required {
571 let needed_blanks = required - blank_lines_below;
572 heading_violations.push((line_num, "below", needed_blanks, heading_level));
573 }
574 }
575 }
576 }
577
578 for (heading_line, position, needed_blanks, heading_level) in heading_violations {
580 let heading_display_line = heading_line + 1; let line_info = &ctx.lines[heading_line];
582
583 let (start_line, start_col, end_line, end_col) =
585 calculate_heading_range(heading_display_line, line_info.content(ctx.content));
586
587 let (message, insertion_point) = match position {
594 "above" => {
595 let Some(required_above_count) =
596 self.config.lines_above.get_for_level(heading_level).required_count()
597 else {
598 continue;
599 };
600 (
601 format!(
602 "Expected {} blank {} above heading",
603 required_above_count,
604 if required_above_count == 1 { "line" } else { "lines" }
605 ),
606 heading_line, )
608 }
609 "below" => {
610 let Some(required_below_count) =
611 self.config.lines_below.get_for_level(heading_level).required_count()
612 else {
613 continue;
614 };
615 let insert_after = if line_info.heading.as_ref().is_some_and(|h| {
617 matches!(
618 h.style,
619 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
620 )
621 }) {
622 heading_line + 2
623 } else {
624 heading_line + 1
625 };
626
627 (
628 format!(
629 "Expected {} blank {} below heading",
630 required_below_count,
631 if required_below_count == 1 { "line" } else { "lines" }
632 ),
633 insert_after,
634 )
635 }
636 _ => continue,
637 };
638
639 let byte_range = if insertion_point == 0 && position == "above" {
641 0..0
643 } else if position == "above" && insertion_point > 0 {
644 ctx.lines[insertion_point].byte_offset..ctx.lines[insertion_point].byte_offset
646 } else if position == "below" && insertion_point - 1 < ctx.lines.len() {
647 let line_idx = insertion_point - 1;
649 let line_end_offset = if line_idx + 1 < ctx.lines.len() {
650 ctx.lines[line_idx + 1].byte_offset
651 } else {
652 ctx.content.len()
653 };
654 line_end_offset..line_end_offset
655 } else {
656 let content_len = ctx.content.len();
658 content_len..content_len
659 };
660
661 result.push(LintWarning {
662 rule_name: Some(self.name().to_string()),
663 message,
664 line: start_line,
665 column: start_col,
666 end_line,
667 end_column: end_col,
668 severity: Severity::Warning,
669 fix: Some(Fix::new(byte_range, line_ending.repeat(needed_blanks))),
670 });
671 }
672
673 Ok(result)
674 }
675
676 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
677 if ctx.content.is_empty() {
678 return Ok(ctx.content.to_string());
679 }
680
681 let fixed = self.fix_content(ctx);
683
684 Ok(fixed)
685 }
686
687 fn category(&self) -> RuleCategory {
689 RuleCategory::Heading
690 }
691
692 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
694 if ctx.content.is_empty() || !ctx.likely_has_headings() {
696 return true;
697 }
698 ctx.lines.iter().all(|line| line.heading.is_none())
700 }
701
702 fn as_any(&self) -> &dyn std::any::Any {
703 self
704 }
705
706 crate::impl_rule_config_methods!(MD022Config);
707}
708
709#[cfg(test)]
710mod tests {
711 use super::*;
712 use crate::lint_context::LintContext;
713
714 #[test]
715 fn test_valid_headings() {
716 let rule = MD022BlanksAroundHeadings::default();
717 let content = "\n# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.\n";
718 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
719 let result = rule.check(&ctx).unwrap();
720 assert!(result.is_empty());
721 }
722
723 #[test]
724 fn test_missing_blank_above() {
725 let rule = MD022BlanksAroundHeadings::default();
726 let content = "# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.\n";
727 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
728 let result = rule.check(&ctx).unwrap();
729 assert_eq!(result.len(), 0); let fixed = rule.fix(&ctx).unwrap();
732
733 assert!(fixed.contains("# Heading 1"));
736 assert!(fixed.contains("Some content."));
737 assert!(fixed.contains("## Heading 2"));
738 assert!(fixed.contains("More content."));
739 }
740
741 #[test]
742 fn test_missing_blank_below() {
743 let rule = MD022BlanksAroundHeadings::default();
744 let content = "\n# Heading 1\nSome content.\n\n## Heading 2\n\nMore content.\n";
745 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
746 let result = rule.check(&ctx).unwrap();
747 assert_eq!(result.len(), 1);
748 assert_eq!(result[0].line, 2);
749
750 let fixed = rule.fix(&ctx).unwrap();
752 assert!(fixed.contains("# Heading 1\n\nSome content"));
753 }
754
755 #[test]
756 fn test_missing_blank_above_and_below() {
757 let rule = MD022BlanksAroundHeadings::default();
758 let content = "# Heading 1\nSome content.\n## Heading 2\nMore content.\n";
759 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
760 let result = rule.check(&ctx).unwrap();
761 assert_eq!(result.len(), 3); let fixed = rule.fix(&ctx).unwrap();
765 assert!(fixed.contains("# Heading 1\n\nSome content"));
766 assert!(fixed.contains("Some content.\n\n## Heading 2"));
767 assert!(fixed.contains("## Heading 2\n\nMore content"));
768 }
769
770 #[test]
771 fn test_fix_headings() {
772 let rule = MD022BlanksAroundHeadings::default();
773 let content = "# Heading 1\nSome content.\n## Heading 2\nMore content.";
774 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
775 let result = rule.fix(&ctx).unwrap();
776
777 let expected = "# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.";
778 assert_eq!(result, expected);
779 }
780
781 #[test]
782 fn test_consecutive_headings_pattern() {
783 let rule = MD022BlanksAroundHeadings::default();
784 let content = "# Heading 1\n## Heading 2\n### Heading 3";
785 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
786 let result = rule.fix(&ctx).unwrap();
787
788 let lines: Vec<&str> = result.lines().collect();
790 assert!(!lines.is_empty());
791
792 let h1_pos = lines.iter().position(|&l| l == "# Heading 1").unwrap();
794 let h2_pos = lines.iter().position(|&l| l == "## Heading 2").unwrap();
795 let h3_pos = lines.iter().position(|&l| l == "### Heading 3").unwrap();
796
797 assert!(
799 h2_pos > h1_pos + 1,
800 "Should have at least one blank line after first heading"
801 );
802 assert!(
803 h3_pos > h2_pos + 1,
804 "Should have at least one blank line after second heading"
805 );
806
807 assert!(lines[h1_pos + 1].trim().is_empty(), "Line after h1 should be blank");
809
810 assert!(lines[h2_pos + 1].trim().is_empty(), "Line after h2 should be blank");
812 }
813
814 #[test]
815 fn test_blanks_around_setext_headings() {
816 let rule = MD022BlanksAroundHeadings::default();
817 let content = "Heading 1\n=========\nSome content.\nHeading 2\n---------\nMore content.";
818 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
819 let result = rule.fix(&ctx).unwrap();
820
821 let lines: Vec<&str> = result.lines().collect();
823
824 assert!(result.contains("Heading 1"));
826 assert!(result.contains("========="));
827 assert!(result.contains("Some content."));
828 assert!(result.contains("Heading 2"));
829 assert!(result.contains("---------"));
830 assert!(result.contains("More content."));
831
832 let heading1_marker_idx = lines.iter().position(|&l| l == "=========").unwrap();
834 let some_content_idx = lines.iter().position(|&l| l == "Some content.").unwrap();
835 assert!(
836 some_content_idx > heading1_marker_idx + 1,
837 "Should have a blank line after the first heading"
838 );
839
840 let heading2_marker_idx = lines.iter().position(|&l| l == "---------").unwrap();
841 let more_content_idx = lines.iter().position(|&l| l == "More content.").unwrap();
842 assert!(
843 more_content_idx > heading2_marker_idx + 1,
844 "Should have a blank line after the second heading"
845 );
846
847 let fixed_ctx = LintContext::new(&result, crate::config::MarkdownFlavor::Standard, None);
849 let fixed_warnings = rule.check(&fixed_ctx).unwrap();
850 assert!(fixed_warnings.is_empty(), "Fixed content should have no warnings");
851 }
852
853 #[test]
854 fn test_fix_specific_blank_line_cases() {
855 let rule = MD022BlanksAroundHeadings::default();
856
857 let content1 = "# Heading 1\n## Heading 2\n### Heading 3";
859 let ctx1 = LintContext::new(content1, crate::config::MarkdownFlavor::Standard, None);
860 let result1 = rule.fix(&ctx1).unwrap();
861 assert!(result1.contains("# Heading 1"));
863 assert!(result1.contains("## Heading 2"));
864 assert!(result1.contains("### Heading 3"));
865 let lines: Vec<&str> = result1.lines().collect();
867 let h1_pos = lines.iter().position(|&l| l == "# Heading 1").unwrap();
868 let h2_pos = lines.iter().position(|&l| l == "## Heading 2").unwrap();
869 assert!(lines[h1_pos + 1].trim().is_empty(), "Should have a blank line after h1");
870 assert!(lines[h2_pos + 1].trim().is_empty(), "Should have a blank line after h2");
871
872 let content2 = "# Heading 1\nContent under heading 1\n## Heading 2";
874 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
875 let result2 = rule.fix(&ctx2).unwrap();
876 assert!(result2.contains("# Heading 1"));
878 assert!(result2.contains("Content under heading 1"));
879 assert!(result2.contains("## Heading 2"));
880 let lines2: Vec<&str> = result2.lines().collect();
882 let h1_pos2 = lines2.iter().position(|&l| l == "# Heading 1").unwrap();
883 let _content_pos = lines2.iter().position(|&l| l == "Content under heading 1").unwrap();
884 assert!(
885 lines2[h1_pos2 + 1].trim().is_empty(),
886 "Should have a blank line after heading 1"
887 );
888
889 let content3 = "# Heading 1\n\n\n## Heading 2\n\n\n### Heading 3\n\nContent";
891 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
892 let result3 = rule.fix(&ctx3).unwrap();
893 assert!(result3.contains("# Heading 1"));
895 assert!(result3.contains("## Heading 2"));
896 assert!(result3.contains("### Heading 3"));
897 assert!(result3.contains("Content"));
898 }
899
900 #[test]
901 fn test_fix_preserves_existing_blank_lines() {
902 let rule = MD022BlanksAroundHeadings::new();
903 let content = "# Title
904
905## Section 1
906
907Content here.
908
909## Section 2
910
911More content.
912### Missing Blank Above
913
914Even more content.
915
916## Section 3
917
918Final content.";
919
920 let expected = "# Title
921
922## Section 1
923
924Content here.
925
926## Section 2
927
928More content.
929
930### Missing Blank Above
931
932Even more content.
933
934## Section 3
935
936Final content.";
937
938 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
939 let result = rule.fix_content(&ctx);
940 assert_eq!(
941 result, expected,
942 "Fix should only add missing blank lines, never remove existing ones"
943 );
944 }
945
946 #[test]
947 fn test_fix_preserves_trailing_newline() {
948 let rule = MD022BlanksAroundHeadings::new();
949
950 let content_with_newline = "# Title\nContent here.\n";
952 let ctx = LintContext::new(content_with_newline, crate::config::MarkdownFlavor::Standard, None);
953 let result = rule.fix(&ctx).unwrap();
954 assert!(result.ends_with('\n'), "Should preserve trailing newline");
955
956 let content_without_newline = "# Title\nContent here.";
958 let ctx = LintContext::new(content_without_newline, crate::config::MarkdownFlavor::Standard, None);
959 let result = rule.fix(&ctx).unwrap();
960 assert!(
961 !result.ends_with('\n'),
962 "Should not add trailing newline if original didn't have one"
963 );
964 }
965
966 #[test]
967 fn test_fix_does_not_add_blank_lines_before_lists() {
968 let rule = MD022BlanksAroundHeadings::new();
969 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.";
970
971 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.";
972
973 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
974 let result = rule.fix_content(&ctx);
975 assert_eq!(result, expected, "Fix should not add blank lines before lists");
976 }
977
978 #[test]
979 fn test_fix_idempotent_when_heading_follows_a_list_and_precedes_a_marker() {
980 let rule = MD022BlanksAroundHeadings::default();
986 let content = "- a\n# H\n2. ";
987 for flavor in [
988 crate::config::MarkdownFlavor::Standard,
989 crate::config::MarkdownFlavor::MkDocs,
990 crate::config::MarkdownFlavor::MDX,
991 ] {
992 let ctx1 = LintContext::new(content, flavor, None);
993 let fixed1 = rule.fix(&ctx1).unwrap();
994 let ctx2 = LintContext::new(&fixed1, flavor, None);
995 let fixed2 = rule.fix(&ctx2).unwrap();
996 assert_eq!(fixed1, fixed2, "MD022 fix must be idempotent (flavor={flavor:?})");
997 }
998 }
999
1000 #[test]
1001 fn test_thematic_break_below_heading_is_not_a_list_item() {
1002 let rule = MD022BlanksAroundHeadings::default();
1009 for marker in [
1010 "* * *",
1011 "- - -",
1012 "_ _ _",
1013 "***",
1014 "---",
1015 "___",
1016 "- --",
1017 "* ** *",
1018 "---- ----",
1019 ] {
1020 let content = format!("text\n\n# Heading\n{marker}\nafter\n");
1021 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1022 let result = rule.check(&ctx).unwrap();
1023 assert_eq!(
1024 result.len(),
1025 1,
1026 "a heading above `{marker}` needs a blank line below it, got {result:?}"
1027 );
1028 assert_eq!(
1029 rule.fix(&ctx).unwrap(),
1030 format!("text\n\n# Heading\n\n{marker}\nafter\n"),
1031 "fix must insert the blank line below the heading for `{marker}`"
1032 );
1033 }
1034 }
1035
1036 #[test]
1037 fn test_list_item_below_heading_is_still_exempt() {
1038 let rule = MD022BlanksAroundHeadings::default();
1041 for item in ["- item", "* item", "+ item", "1. item", "+ + +"] {
1042 let content = format!("text\n\n# Heading\n{item}\n");
1043 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1044 assert!(
1045 rule.check(&ctx).unwrap().is_empty(),
1046 "a list below a heading stays exempt, but `{item}` was reported"
1047 );
1048 assert_eq!(rule.fix(&ctx).unwrap(), content, "`{item}` must not be rewritten");
1049 }
1050 }
1051
1052 #[test]
1053 fn test_per_level_configuration_no_blank_above_h1() {
1054 use md022_config::HeadingLevelConfig;
1055
1056 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1058 lines_above: HeadingLevelConfig::per_level([0, 1, 1, 1, 1, 1]),
1059 lines_below: HeadingLevelConfig::scalar(1),
1060 allowed_at_start: false, });
1062
1063 let content = "Some text\n# Heading 1\n\nMore text";
1065 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1066 let warnings = rule.check(&ctx).unwrap();
1067 assert_eq!(warnings.len(), 0, "H1 without blank above should not trigger warning");
1068
1069 let content = "Some text\n## Heading 2\n\nMore text";
1071 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1072 let warnings = rule.check(&ctx).unwrap();
1073 assert_eq!(warnings.len(), 1, "H2 without blank above should trigger warning");
1074 assert!(warnings[0].message.contains("above"));
1075 }
1076
1077 #[test]
1078 fn test_unlimited_above_with_limited_below_does_not_panic() {
1079 use md022_config::{HeadingBlankRequirement, HeadingLevelConfig};
1080
1081 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1085 lines_above: HeadingLevelConfig::scalar_requirement(HeadingBlankRequirement::Unlimited),
1086 lines_below: HeadingLevelConfig::scalar(1),
1087 allowed_at_start: false,
1088 });
1089
1090 let content = "# Title\n\nText\n## Banana\nText\n";
1092 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1093
1094 let warnings = rule.check(&ctx).expect("check must not fail");
1095
1096 assert!(
1097 warnings.iter().any(|w| w.message.contains("below")),
1098 "expected a 'below' violation, got: {:?}",
1099 warnings.iter().map(|w| &w.message).collect::<Vec<_>>()
1100 );
1101 assert!(
1102 !warnings.iter().any(|w| w.message.contains("above")),
1103 "an unlimited 'above' requirement must never report: {:?}",
1104 warnings.iter().map(|w| &w.message).collect::<Vec<_>>()
1105 );
1106 }
1107
1108 #[test]
1109 fn test_unlimited_below_with_limited_above_does_not_panic() {
1110 use md022_config::{HeadingBlankRequirement, HeadingLevelConfig};
1111
1112 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1113 lines_above: HeadingLevelConfig::scalar(1),
1114 lines_below: HeadingLevelConfig::scalar_requirement(HeadingBlankRequirement::Unlimited),
1115 allowed_at_start: false,
1116 });
1117
1118 let content = "# Title\n\nText\n## Banana\n\nText\n";
1120 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1121
1122 let warnings = rule.check(&ctx).expect("check must not fail");
1123
1124 assert!(
1125 warnings.iter().any(|w| w.message.contains("above")),
1126 "expected an 'above' violation, got: {:?}",
1127 warnings.iter().map(|w| &w.message).collect::<Vec<_>>()
1128 );
1129 assert!(
1130 !warnings.iter().any(|w| w.message.contains("below")),
1131 "an unlimited 'below' requirement must never report: {:?}",
1132 warnings.iter().map(|w| &w.message).collect::<Vec<_>>()
1133 );
1134 }
1135
1136 #[test]
1137 fn test_per_level_configuration_different_requirements() {
1138 use md022_config::HeadingLevelConfig;
1139
1140 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1142 lines_above: HeadingLevelConfig::per_level([0, 1, 1, 2, 2, 2]),
1143 lines_below: HeadingLevelConfig::scalar(1),
1144 allowed_at_start: false,
1145 });
1146
1147 let content = "Text\n# H1\n\nText\n\n## H2\n\nText\n\n### H3\n\nText\n\n\n#### H4\n\nText";
1148 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1149 let warnings = rule.check(&ctx).unwrap();
1150
1151 assert_eq!(
1153 warnings.len(),
1154 0,
1155 "All headings should satisfy level-specific requirements"
1156 );
1157 }
1158
1159 #[test]
1160 fn test_per_level_configuration_violations() {
1161 use md022_config::HeadingLevelConfig;
1162
1163 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1165 lines_above: HeadingLevelConfig::per_level([1, 1, 1, 2, 1, 1]),
1166 lines_below: HeadingLevelConfig::scalar(1),
1167 allowed_at_start: false,
1168 });
1169
1170 let content = "Text\n\n#### Heading 4\n\nMore text";
1172 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1173 let warnings = rule.check(&ctx).unwrap();
1174
1175 assert_eq!(warnings.len(), 1, "H4 with insufficient blanks should trigger warning");
1176 assert!(warnings[0].message.contains("2 blank lines above"));
1177 }
1178
1179 #[test]
1180 fn test_per_level_fix_different_levels() {
1181 use md022_config::HeadingLevelConfig;
1182
1183 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1185 lines_above: HeadingLevelConfig::per_level([0, 1, 2, 2, 2, 2]),
1186 lines_below: HeadingLevelConfig::scalar(1),
1187 allowed_at_start: false,
1188 });
1189
1190 let content = "Text\n# H1\nContent\n## H2\nContent\n### H3\nContent";
1191 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1192 let fixed = rule.fix(&ctx).unwrap();
1193
1194 assert!(fixed.contains("Text\n# H1\n\nContent"));
1196 assert!(fixed.contains("Content\n\n## H2\n\nContent"));
1197 assert!(fixed.contains("Content\n\n\n### H3\n\nContent"));
1198 }
1199
1200 #[test]
1201 fn test_per_level_below_configuration() {
1202 use md022_config::HeadingLevelConfig;
1203
1204 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1206 lines_above: HeadingLevelConfig::scalar(1),
1207 lines_below: HeadingLevelConfig::per_level([2, 1, 1, 1, 1, 1]), allowed_at_start: true,
1209 });
1210
1211 let content = "# Heading 1\n\nSome text";
1213 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1214 let warnings = rule.check(&ctx).unwrap();
1215
1216 assert_eq!(
1217 warnings.len(),
1218 1,
1219 "H1 with insufficient blanks below should trigger warning"
1220 );
1221 assert!(warnings[0].message.contains("2 blank lines below"));
1222 }
1223
1224 #[test]
1225 fn test_scalar_configuration_still_works() {
1226 use md022_config::HeadingLevelConfig;
1227
1228 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1230 lines_above: HeadingLevelConfig::scalar(2),
1231 lines_below: HeadingLevelConfig::scalar(2),
1232 allowed_at_start: false,
1233 });
1234
1235 let content = "Text\n# H1\nContent\n## H2\nContent";
1236 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1237 let warnings = rule.check(&ctx).unwrap();
1238
1239 assert!(!warnings.is_empty(), "Should have violations for insufficient blanks");
1241 }
1242
1243 #[test]
1244 fn test_unlimited_configuration_skips_requirements() {
1245 use md022_config::{HeadingBlankRequirement, HeadingLevelConfig};
1246
1247 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1249 lines_above: HeadingLevelConfig::per_level_requirements([
1250 HeadingBlankRequirement::unlimited(),
1251 HeadingBlankRequirement::limited(1),
1252 HeadingBlankRequirement::limited(1),
1253 HeadingBlankRequirement::limited(1),
1254 HeadingBlankRequirement::limited(1),
1255 HeadingBlankRequirement::limited(1),
1256 ]),
1257 lines_below: HeadingLevelConfig::per_level_requirements([
1258 HeadingBlankRequirement::unlimited(),
1259 HeadingBlankRequirement::limited(1),
1260 HeadingBlankRequirement::limited(1),
1261 HeadingBlankRequirement::limited(1),
1262 HeadingBlankRequirement::limited(1),
1263 HeadingBlankRequirement::limited(1),
1264 ]),
1265 allowed_at_start: false,
1266 });
1267
1268 let content = "# H1\nParagraph\n## H2\nParagraph";
1269 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1270 let warnings = rule.check(&ctx).unwrap();
1271
1272 assert_eq!(warnings.len(), 2, "Only non-unlimited headings should warn");
1274 assert!(
1275 warnings.iter().all(|w| w.line >= 3),
1276 "Warnings should target later headings"
1277 );
1278
1279 let fixed = rule.fix(&ctx).unwrap();
1281 assert!(
1282 fixed.starts_with("# H1\nParagraph\n\n## H2"),
1283 "H1 should remain unchanged"
1284 );
1285 }
1286
1287 #[test]
1288 fn test_html_comment_transparency() {
1289 let rule = MD022BlanksAroundHeadings::default();
1293
1294 let content = "Some content\n\n<!-- markdownlint-disable-next-line MD001 -->\n#### Heading";
1297 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1298 let warnings = rule.check(&ctx).unwrap();
1299 assert!(
1300 warnings.is_empty(),
1301 "HTML comment is transparent - blank line above it counts for heading"
1302 );
1303
1304 let content_multiline = "Some content\n\n<!-- This is a\nmulti-line comment -->\n#### Heading";
1306 let ctx_multiline = LintContext::new(content_multiline, crate::config::MarkdownFlavor::Standard, None);
1307 let warnings_multiline = rule.check(&ctx_multiline).unwrap();
1308 assert!(
1309 warnings_multiline.is_empty(),
1310 "Multi-line HTML comment is also transparent"
1311 );
1312 }
1313
1314 #[test]
1315 fn test_frontmatter_transparency() {
1316 let rule = MD022BlanksAroundHeadings::default();
1319
1320 let content = "---\ntitle: Test\n---\n# First heading";
1322 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1323 let warnings = rule.check(&ctx).unwrap();
1324 assert!(
1325 warnings.is_empty(),
1326 "Frontmatter is transparent - heading can appear immediately after"
1327 );
1328
1329 let content_with_blank = "---\ntitle: Test\n---\n\n# First heading";
1331 let ctx_with_blank = LintContext::new(content_with_blank, crate::config::MarkdownFlavor::Standard, None);
1332 let warnings_with_blank = rule.check(&ctx_with_blank).unwrap();
1333 assert!(
1334 warnings_with_blank.is_empty(),
1335 "Heading with blank line after frontmatter should also be valid"
1336 );
1337
1338 let content_toml = "+++\ntitle = \"Test\"\n+++\n# First heading";
1340 let ctx_toml = LintContext::new(content_toml, crate::config::MarkdownFlavor::Standard, None);
1341 let warnings_toml = rule.check(&ctx_toml).unwrap();
1342 assert!(
1343 warnings_toml.is_empty(),
1344 "TOML frontmatter is also transparent for MD022"
1345 );
1346 }
1347
1348 #[test]
1349 fn test_horizontal_rule_not_treated_as_frontmatter() {
1350 let rule = MD022BlanksAroundHeadings::default();
1353
1354 let content = "Some content\n\n---\n# Heading after HR";
1356 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1357 let warnings = rule.check(&ctx).unwrap();
1358 assert!(
1359 !warnings.is_empty(),
1360 "Heading after horizontal rule without blank line SHOULD trigger MD022"
1361 );
1362 assert!(
1363 warnings.iter().any(|w| w.line == 4),
1364 "Warning should be on line 4 (the heading line)"
1365 );
1366
1367 let content_with_blank = "Some content\n\n---\n\n# Heading after HR";
1369 let ctx_with_blank = LintContext::new(content_with_blank, crate::config::MarkdownFlavor::Standard, None);
1370 let warnings_with_blank = rule.check(&ctx_with_blank).unwrap();
1371 assert!(
1372 warnings_with_blank.is_empty(),
1373 "Heading with blank line after HR should not trigger MD022"
1374 );
1375
1376 let content_hr_start = "---\n# Heading";
1378 let ctx_hr_start = LintContext::new(content_hr_start, crate::config::MarkdownFlavor::Standard, None);
1379 let warnings_hr_start = rule.check(&ctx_hr_start).unwrap();
1380 assert!(
1381 !warnings_hr_start.is_empty(),
1382 "Heading after HR at document start SHOULD trigger MD022"
1383 );
1384
1385 let content_multi_hr = "Content\n\n---\n\n---\n# Heading";
1387 let ctx_multi_hr = LintContext::new(content_multi_hr, crate::config::MarkdownFlavor::Standard, None);
1388 let warnings_multi_hr = rule.check(&ctx_multi_hr).unwrap();
1389 assert!(
1390 !warnings_multi_hr.is_empty(),
1391 "Heading after multiple HRs without blank line SHOULD trigger MD022"
1392 );
1393 }
1394
1395 #[test]
1396 fn test_all_hr_styles_require_blank_before_heading() {
1397 let rule = MD022BlanksAroundHeadings::default();
1399
1400 let hr_styles = [
1402 "---", "***", "___", "- - -", "* * *", "_ _ _", "----", "****", "____", "- - - -",
1403 "- - -", " ---", " ---", ];
1407
1408 for hr in hr_styles {
1409 let content = format!("Content\n\n{hr}\n# Heading");
1410 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1411 let warnings = rule.check(&ctx).unwrap();
1412 assert!(
1413 !warnings.is_empty(),
1414 "HR style '{hr}' followed by heading should trigger MD022"
1415 );
1416 }
1417 }
1418
1419 #[test]
1420 fn test_setext_heading_after_hr() {
1421 let rule = MD022BlanksAroundHeadings::default();
1423
1424 let content = "Content\n\n---\nHeading\n======";
1426 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1427 let warnings = rule.check(&ctx).unwrap();
1428 assert!(
1429 !warnings.is_empty(),
1430 "Setext heading after HR without blank should trigger MD022"
1431 );
1432
1433 let content_h2 = "Content\n\n---\nHeading\n------";
1435 let ctx_h2 = LintContext::new(content_h2, crate::config::MarkdownFlavor::Standard, None);
1436 let warnings_h2 = rule.check(&ctx_h2).unwrap();
1437 assert!(
1438 !warnings_h2.is_empty(),
1439 "Setext h2 after HR without blank should trigger MD022"
1440 );
1441
1442 let content_ok = "Content\n\n---\n\nHeading\n======";
1444 let ctx_ok = LintContext::new(content_ok, crate::config::MarkdownFlavor::Standard, None);
1445 let warnings_ok = rule.check(&ctx_ok).unwrap();
1446 assert!(
1447 warnings_ok.is_empty(),
1448 "Setext heading with blank after HR should not warn"
1449 );
1450 }
1451
1452 #[test]
1453 fn test_hr_in_code_block_not_treated_as_hr() {
1454 let rule = MD022BlanksAroundHeadings::default();
1456
1457 let content = "```\n---\n```\n# Heading";
1460 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1461 let warnings = rule.check(&ctx).unwrap();
1462 assert!(!warnings.is_empty(), "Heading after code block still needs blank line");
1465
1466 let content_ok = "```\n---\n```\n\n# Heading";
1468 let ctx_ok = LintContext::new(content_ok, crate::config::MarkdownFlavor::Standard, None);
1469 let warnings_ok = rule.check(&ctx_ok).unwrap();
1470 assert!(
1471 warnings_ok.is_empty(),
1472 "Heading with blank after code block should not warn"
1473 );
1474 }
1475
1476 #[test]
1477 fn test_hr_in_html_comment_not_treated_as_hr() {
1478 let rule = MD022BlanksAroundHeadings::default();
1480
1481 let content = "<!-- \n---\n -->\n# Heading";
1483 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1484 let warnings = rule.check(&ctx).unwrap();
1485 assert!(
1487 warnings.is_empty(),
1488 "HR inside HTML comment should be ignored - heading after comment is OK"
1489 );
1490 }
1491
1492 #[test]
1493 fn test_invalid_hr_not_triggering() {
1494 let rule = MD022BlanksAroundHeadings::default();
1496
1497 let invalid_hrs = [
1498 " ---", "\t---", "--", "**", "__", "-*-", "---a", "a---", ];
1507
1508 for invalid in invalid_hrs {
1509 let content = format!("Content\n\n{invalid}\n# Heading");
1512 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1513 let _ = rule.check(&ctx);
1516 }
1517 }
1518
1519 #[test]
1520 fn test_frontmatter_vs_horizontal_rule_distinction() {
1521 let rule = MD022BlanksAroundHeadings::default();
1523
1524 let content = "---\ntitle: Test\n---\n\nSome content\n\n---\n# Heading after HR";
1527 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1528 let warnings = rule.check(&ctx).unwrap();
1529 assert!(
1530 !warnings.is_empty(),
1531 "HR after frontmatter content should still require blank line before heading"
1532 );
1533
1534 let content_ok = "---\ntitle: Test\n---\n\nSome content\n\n---\n\n# Heading after HR";
1536 let ctx_ok = LintContext::new(content_ok, crate::config::MarkdownFlavor::Standard, None);
1537 let warnings_ok = rule.check(&ctx_ok).unwrap();
1538 assert!(
1539 warnings_ok.is_empty(),
1540 "HR with blank line before heading should not warn"
1541 );
1542 }
1543
1544 #[test]
1547 fn test_kramdown_ial_after_heading_no_warning() {
1548 let rule = MD022BlanksAroundHeadings::default();
1550 let content = "## Table of Contents\n{: .hhc-toc-heading}\n\nSome content here.";
1551 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1552 let warnings = rule.check(&ctx).unwrap();
1553
1554 assert!(
1555 warnings.is_empty(),
1556 "IAL after heading should not require blank line between them: {warnings:?}"
1557 );
1558 }
1559
1560 #[test]
1561 fn test_kramdown_ial_with_class() {
1562 let rule = MD022BlanksAroundHeadings::default();
1563 let content = "# Heading\n{:.highlight}\n\nContent.";
1564 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1565 let warnings = rule.check(&ctx).unwrap();
1566
1567 assert!(warnings.is_empty(), "IAL with class should be part of heading");
1568 }
1569
1570 #[test]
1571 fn test_kramdown_ial_with_id() {
1572 let rule = MD022BlanksAroundHeadings::default();
1573 let content = "# Heading\n{:#custom-id}\n\nContent.";
1574 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1575 let warnings = rule.check(&ctx).unwrap();
1576
1577 assert!(warnings.is_empty(), "IAL with id should be part of heading");
1578 }
1579
1580 #[test]
1581 fn test_kramdown_ial_with_multiple_attributes() {
1582 let rule = MD022BlanksAroundHeadings::default();
1583 let content = "# Heading\n{: .class #id style=\"color: red\"}\n\nContent.";
1584 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1585 let warnings = rule.check(&ctx).unwrap();
1586
1587 assert!(
1588 warnings.is_empty(),
1589 "IAL with multiple attributes should be part of heading"
1590 );
1591 }
1592
1593 #[test]
1594 fn test_kramdown_ial_missing_blank_after() {
1595 let rule = MD022BlanksAroundHeadings::default();
1597 let content = "# Heading\n{:.class}\nContent without blank.";
1598 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1599 let warnings = rule.check(&ctx).unwrap();
1600
1601 assert_eq!(
1602 warnings.len(),
1603 1,
1604 "Should warn about missing blank after IAL (part of heading)"
1605 );
1606 assert!(warnings[0].message.contains("below"));
1607 }
1608
1609 #[test]
1610 fn test_kramdown_ial_before_heading_transparent() {
1611 let rule = MD022BlanksAroundHeadings::default();
1613 let content = "Content.\n\n{:.preclass}\n## Heading\n\nMore content.";
1614 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1615 let warnings = rule.check(&ctx).unwrap();
1616
1617 assert!(
1618 warnings.is_empty(),
1619 "IAL before heading should be transparent for blank line count"
1620 );
1621 }
1622
1623 #[test]
1624 fn test_kramdown_ial_setext_heading() {
1625 let rule = MD022BlanksAroundHeadings::default();
1626 let content = "Heading\n=======\n{:.setext-class}\n\nContent.";
1627 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1628 let warnings = rule.check(&ctx).unwrap();
1629
1630 assert!(
1631 warnings.is_empty(),
1632 "IAL after Setext heading should be part of heading"
1633 );
1634 }
1635
1636 #[test]
1637 fn test_kramdown_ial_fix_preserves_ial() {
1638 let rule = MD022BlanksAroundHeadings::default();
1639 let content = "Content.\n# Heading\n{:.class}\nMore content.";
1640 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1641 let fixed = rule.fix(&ctx).unwrap();
1642
1643 assert!(
1645 fixed.contains("# Heading\n{:.class}"),
1646 "IAL should stay attached to heading"
1647 );
1648 assert!(fixed.contains("{:.class}\n\nMore"), "Should add blank after IAL");
1649 }
1650
1651 #[test]
1652 fn test_kramdown_ial_fix_does_not_separate() {
1653 let rule = MD022BlanksAroundHeadings::default();
1654 let content = "# Heading\n{:.class}\nContent.";
1655 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1656 let fixed = rule.fix(&ctx).unwrap();
1657
1658 assert!(
1660 !fixed.contains("# Heading\n\n{:.class}"),
1661 "Should not add blank between heading and IAL"
1662 );
1663 assert!(fixed.contains("# Heading\n{:.class}"), "IAL should remain attached");
1664 }
1665
1666 #[test]
1667 fn test_kramdown_multiple_ial_lines() {
1668 let rule = MD022BlanksAroundHeadings::default();
1670 let content = "# Heading\n{:.class1}\n{:#id}\n\nContent.";
1671 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1672 let warnings = rule.check(&ctx).unwrap();
1673
1674 assert!(
1677 warnings.is_empty(),
1678 "Multiple consecutive IALs should be part of heading"
1679 );
1680 }
1681
1682 #[test]
1683 fn test_kramdown_ial_with_blank_line_not_attached() {
1684 let rule = MD022BlanksAroundHeadings::default();
1686 let content = "# Heading\n\n{:.class}\nContent.";
1687 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1688 let warnings = rule.check(&ctx).unwrap();
1689
1690 assert!(warnings.is_empty(), "Blank line separates heading from IAL");
1694 }
1695
1696 #[test]
1697 fn test_not_kramdown_ial_regular_braces() {
1698 let rule = MD022BlanksAroundHeadings::default();
1700 let content = "# Heading\n{not an ial}\n\nContent.";
1701 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1702 let warnings = rule.check(&ctx).unwrap();
1703
1704 assert_eq!(
1706 warnings.len(),
1707 1,
1708 "Non-IAL braces should be regular content requiring blank"
1709 );
1710 }
1711
1712 #[test]
1713 fn test_kramdown_ial_at_document_end() {
1714 let rule = MD022BlanksAroundHeadings::default();
1715 let content = "# Heading\n{:.class}";
1716 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1717 let warnings = rule.check(&ctx).unwrap();
1718
1719 assert!(warnings.is_empty(), "IAL at document end needs no blank after");
1721 }
1722
1723 #[test]
1724 fn test_kramdown_ial_followed_by_code_fence() {
1725 let rule = MD022BlanksAroundHeadings::default();
1726 let content = "# Heading\n{:.class}\n```\ncode\n```";
1727 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1728 let warnings = rule.check(&ctx).unwrap();
1729
1730 assert!(warnings.is_empty(), "No blank needed between IAL and code fence");
1732 }
1733
1734 #[test]
1735 fn test_kramdown_ial_followed_by_list() {
1736 let rule = MD022BlanksAroundHeadings::default();
1737 let content = "# Heading\n{:.class}\n- List item";
1738 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1739 let warnings = rule.check(&ctx).unwrap();
1740
1741 assert!(warnings.is_empty(), "No blank needed between IAL and list");
1743 }
1744
1745 #[test]
1746 fn test_kramdown_ial_fix_idempotent() {
1747 let rule = MD022BlanksAroundHeadings::default();
1748 let content = "# Heading\n{:.class}\nContent.";
1749 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1750
1751 let fixed_once = rule.fix(&ctx).unwrap();
1752 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
1753 let fixed_twice = rule.fix(&ctx2).unwrap();
1754
1755 assert_eq!(fixed_once, fixed_twice, "Fix should be idempotent");
1756 }
1757
1758 #[test]
1759 fn test_kramdown_ial_whitespace_line_between_not_attached() {
1760 let rule = MD022BlanksAroundHeadings::default();
1763 let content = "# Heading\n \n{:.class}\n\nContent.";
1764 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1765 let warnings = rule.check(&ctx).unwrap();
1766
1767 assert!(
1771 warnings.is_empty(),
1772 "Whitespace between heading and IAL means IAL is not attached"
1773 );
1774 }
1775
1776 #[test]
1777 fn test_kramdown_ial_html_comment_between() {
1778 let rule = MD022BlanksAroundHeadings::default();
1781 let content = "# Heading\n<!-- comment -->\n{:.class}\n\nContent.";
1782 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1783 let warnings = rule.check(&ctx).unwrap();
1784
1785 assert_eq!(
1789 warnings.len(),
1790 1,
1791 "IAL not attached when comment is between: {warnings:?}"
1792 );
1793 }
1794
1795 #[test]
1796 fn test_kramdown_ial_generic_attribute() {
1797 let rule = MD022BlanksAroundHeadings::default();
1798 let content = "# Heading\n{:data-toc=\"true\" style=\"color: red\"}\n\nContent.";
1799 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1800 let warnings = rule.check(&ctx).unwrap();
1801
1802 assert!(warnings.is_empty(), "Generic attributes should be recognized as IAL");
1803 }
1804
1805 #[test]
1806 fn test_kramdown_ial_fix_multiple_lines_preserves_all() {
1807 let rule = MD022BlanksAroundHeadings::default();
1808 let content = "# Heading\n{:.class1}\n{:#id}\n{:data-x=\"y\"}\nContent.";
1809 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1810
1811 let fixed = rule.fix(&ctx).unwrap();
1812
1813 assert!(fixed.contains("{:.class1}"), "First IAL should be preserved");
1815 assert!(fixed.contains("{:#id}"), "Second IAL should be preserved");
1816 assert!(fixed.contains("{:data-x=\"y\"}"), "Third IAL should be preserved");
1817 assert!(
1819 fixed.contains("{:data-x=\"y\"}\n\nContent"),
1820 "Blank line should be after all IALs"
1821 );
1822 }
1823
1824 #[test]
1825 fn test_kramdown_ial_crlf_line_endings() {
1826 let rule = MD022BlanksAroundHeadings::default();
1827 let content = "# Heading\r\n{:.class}\r\n\r\nContent.";
1828 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1829 let warnings = rule.check(&ctx).unwrap();
1830
1831 assert!(warnings.is_empty(), "CRLF should work correctly with IAL");
1832 }
1833
1834 #[test]
1835 fn test_kramdown_ial_invalid_patterns_not_recognized() {
1836 let rule = MD022BlanksAroundHeadings::default();
1837
1838 let content = "# Heading\n{ :.class}\n\nContent.";
1840 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1841 let warnings = rule.check(&ctx).unwrap();
1842 assert_eq!(warnings.len(), 1, "Invalid IAL syntax should trigger warning");
1843
1844 let content2 = "# Heading\n{.class}\n\nContent.";
1846 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1847 let warnings2 = rule.check(&ctx2).unwrap();
1848 assert!(warnings2.is_empty(), "{{.class}} is valid kramdown block attribute");
1850
1851 let content3 = "# Heading\n{just text}\n\nContent.";
1853 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
1854 let warnings3 = rule.check(&ctx3).unwrap();
1855 assert_eq!(
1856 warnings3.len(),
1857 1,
1858 "Text in braces is not IAL and should trigger warning"
1859 );
1860 }
1861
1862 #[test]
1863 fn test_kramdown_ial_toc_marker() {
1864 let rule = MD022BlanksAroundHeadings::default();
1866 let content = "# Heading\n{:toc}\n\nContent.";
1867 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1868 let warnings = rule.check(&ctx).unwrap();
1869
1870 assert!(warnings.is_empty(), "{{:toc}} should be recognized as IAL");
1872 }
1873
1874 #[test]
1875 fn test_kramdown_ial_mixed_headings_in_document() {
1876 let rule = MD022BlanksAroundHeadings::default();
1877 let content = r#"# ATX Heading
1878{:.atx-class}
1879
1880Content after ATX.
1881
1882Setext Heading
1883--------------
1884{:#setext-id}
1885
1886Content after Setext.
1887
1888## Another ATX
1889{:.another}
1890
1891More content."#;
1892 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1893 let warnings = rule.check(&ctx).unwrap();
1894
1895 assert!(
1896 warnings.is_empty(),
1897 "Mixed headings with IAL should all work: {warnings:?}"
1898 );
1899 }
1900
1901 #[test]
1902 fn test_kramdown_extension_block_before_first_heading_is_document_start() {
1903 let rule = MD022BlanksAroundHeadings::default();
1904 let content = "{::comment}\nhidden\n{:/comment}\n# Heading\n\nBody\n";
1905 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Kramdown, None);
1906 let warnings = rule.check(&ctx).unwrap();
1907
1908 assert!(
1909 warnings.is_empty(),
1910 "Kramdown extension preamble should not require blank above first heading: {warnings:?}"
1911 );
1912 }
1913
1914 #[test]
1915 fn test_kramdown_ial_before_first_heading_is_document_start() {
1916 let rule = MD022BlanksAroundHeadings::default();
1917 let content = "{:.doc-class}\n# Heading\n\nBody\n";
1918 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Kramdown, None);
1919 let warnings = rule.check(&ctx).unwrap();
1920
1921 assert!(
1922 warnings.is_empty(),
1923 "Kramdown IAL preamble should not require blank above first heading: {warnings:?}"
1924 );
1925 }
1926
1927 #[test]
1930 fn test_quarto_div_marker_transparent_above_heading() {
1931 let rule = MD022BlanksAroundHeadings::default();
1934 let content = "Content\n\n::: {.callout-note}\n# Heading\n\nMore content\n:::\n";
1936 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1937 let warnings = rule.check(&ctx).unwrap();
1938 assert!(
1940 warnings.is_empty(),
1941 "Quarto div marker should be transparent above heading: {warnings:?}"
1942 );
1943 }
1944
1945 #[test]
1946 fn test_quarto_div_marker_transparent_below_heading() {
1947 let rule = MD022BlanksAroundHeadings::default();
1949 let content = "# Heading\n\n::: {.callout-note}\nContent\n:::\n";
1950 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1951 let warnings = rule.check(&ctx).unwrap();
1952 assert!(
1954 warnings.is_empty(),
1955 "Quarto div marker should be transparent below heading: {warnings:?}"
1956 );
1957 }
1958
1959 #[test]
1960 fn test_quarto_heading_inside_callout() {
1961 let rule = MD022BlanksAroundHeadings::default();
1963 let content = "::: {.callout-note}\n\n## Note Title\n\nNote content\n:::\n";
1964 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1965 let warnings = rule.check(&ctx).unwrap();
1966 assert!(
1967 warnings.is_empty(),
1968 "Heading inside Quarto callout should have no warnings: {warnings:?}"
1969 );
1970 }
1971
1972 #[test]
1973 fn test_quarto_heading_at_start_after_div_open() {
1974 let rule = MD022BlanksAroundHeadings::default();
1977 let content = "::: {.callout-warning}\n# Warning\n\nContent\n:::\n";
1979 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1980 let warnings = rule.check(&ctx).unwrap();
1981 assert!(
1987 warnings.is_empty(),
1988 "Heading at start after div open should pass: {warnings:?}"
1989 );
1990 }
1991
1992 #[test]
1993 fn test_quarto_heading_before_div_close() {
1994 let rule = MD022BlanksAroundHeadings::default();
1996 let content = "::: {.callout-note}\nIntro\n\n## Section\n:::\n";
1997 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1998 let warnings = rule.check(&ctx).unwrap();
1999 assert!(
2003 warnings.is_empty(),
2004 "Heading before div close should pass: {warnings:?}"
2005 );
2006 }
2007
2008 #[test]
2009 fn test_quarto_div_markers_not_transparent_in_standard_flavor() {
2010 let rule = MD022BlanksAroundHeadings::default();
2012 let content = "Content\n\n:::\n# Heading\n\n:::\n";
2013 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2014 let warnings = rule.check(&ctx).unwrap();
2015 assert!(
2017 !warnings.is_empty(),
2018 "Standard flavor should not treat ::: as transparent: {warnings:?}"
2019 );
2020 }
2021
2022 #[test]
2023 fn test_quarto_nested_divs_with_heading() {
2024 let rule = MD022BlanksAroundHeadings::default();
2026 let content = "::: {.outer}\n::: {.inner}\n\n# Heading\n\nContent\n:::\n:::\n";
2027 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
2028 let warnings = rule.check(&ctx).unwrap();
2029 assert!(
2030 warnings.is_empty(),
2031 "Nested divs with heading should work: {warnings:?}"
2032 );
2033 }
2034
2035 #[test]
2036 fn test_quarto_fix_preserves_div_markers() {
2037 let rule = MD022BlanksAroundHeadings::default();
2039 let content = "::: {.callout-note}\n\n## Note\n\nContent\n:::\n";
2040 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
2041 let fixed = rule.fix(&ctx).unwrap();
2042 assert!(fixed.contains("::: {.callout-note}"), "Should preserve div opening");
2044 assert!(fixed.contains(":::"), "Should preserve div closing");
2045 assert!(fixed.contains("## Note"), "Should preserve heading");
2046 }
2047
2048 #[test]
2049 fn test_quarto_heading_needs_blank_without_div_transparency() {
2050 let rule = MD022BlanksAroundHeadings::default();
2053 let content = "Content\n::: {.callout-note}\n# Heading\n\nMore\n:::\n";
2055 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
2056 let warnings = rule.check(&ctx).unwrap();
2057 assert!(
2060 !warnings.is_empty(),
2061 "Should still require blank line when not present: {warnings:?}"
2062 );
2063 }
2064
2065 #[test]
2066 fn test_pandoc_div_marker_transparent_above_heading() {
2067 let rule = MD022BlanksAroundHeadings::default();
2070 let content = "Content\n\n::: {.callout-note}\n# Heading\n\nMore content\n:::\n";
2071 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
2072 let warnings = rule.check(&ctx).unwrap();
2073 assert!(
2074 warnings.is_empty(),
2075 "MD022 should treat Pandoc div marker as transparent above heading: {warnings:?}"
2076 );
2077 }
2078
2079 #[test]
2080 fn test_hugo_block_attribute_after_heading_not_flagged() {
2081 let rule = MD022BlanksAroundHeadings::default();
2084 let content = "Intro text.\n\n# Heading\n{class=\"anchor\"}\n\nContent.\n";
2085
2086 for flavor in [
2087 crate::config::MarkdownFlavor::Hugo,
2088 crate::config::MarkdownFlavor::MkDocs,
2089 crate::config::MarkdownFlavor::Kramdown,
2090 ] {
2091 let ctx = LintContext::new(content, flavor, None);
2092 let warnings = rule.check(&ctx).unwrap();
2093 assert!(
2094 warnings.is_empty(),
2095 "MD022 should not flag the block attribute line under {flavor:?}: {warnings:?}"
2096 );
2097 }
2098
2099 let ctx_std = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2102 let warnings_std = rule.check(&ctx_std).unwrap();
2103 assert!(
2104 warnings_std.iter().any(|w| w.message.contains("below heading")),
2105 "MD022 must flag the missing blank below the heading under Standard: {warnings_std:?}"
2106 );
2107 }
2108}