1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
5use crate::rule_config_serde::RuleConfig;
6use crate::utils::kramdown_utils::is_kramdown_block_attribute;
7use crate::utils::quarto_divs;
8use crate::utils::range_utils::calculate_heading_range;
9use toml;
10
11mod md022_config;
12use md022_config::MD022Config;
13
14#[derive(Clone, Default)]
86pub struct MD022BlanksAroundHeadings {
87 config: MD022Config,
88}
89
90impl MD022BlanksAroundHeadings {
91 pub fn new() -> Self {
94 Self {
95 config: MD022Config::default(),
96 }
97 }
98
99 pub fn with_values(lines_above: usize, lines_below: usize) -> Self {
101 use md022_config::HeadingLevelConfig;
102 Self {
103 config: MD022Config {
104 lines_above: HeadingLevelConfig::scalar(lines_above),
105 lines_below: HeadingLevelConfig::scalar(lines_below),
106 allowed_at_start: true,
107 },
108 }
109 }
110
111 pub fn from_config_struct(config: MD022Config) -> Self {
112 Self { config }
113 }
114
115 fn _fix_content(&self, ctx: &crate::lint_context::LintContext) -> String {
117 let line_ending = "\n";
119 let had_trailing_newline = ctx.content.ends_with('\n');
120 let is_quarto = ctx.flavor == crate::config::MarkdownFlavor::Quarto;
121 let mut result = Vec::new();
122 let mut skip_count: usize = 0;
123
124 let heading_at_start_idx = {
125 let mut found_non_transparent = false;
126 ctx.lines.iter().enumerate().find_map(|(i, line)| {
127 if line.heading.as_ref().is_some_and(|h| h.is_valid) && !found_non_transparent {
129 Some(i)
130 } else {
131 if !line.is_blank && !line.in_html_comment {
134 let trimmed = line.content(ctx.content).trim();
135 if trimmed.starts_with("<!--") && trimmed.ends_with("-->") {
137 } else if is_quarto && (quarto_divs::is_div_open(trimmed) || quarto_divs::is_div_close(trimmed))
139 {
140 } else {
142 found_non_transparent = true;
143 }
144 }
145 None
146 }
147 })
148 };
149
150 for (i, line_info) in ctx.lines.iter().enumerate() {
151 if skip_count > 0 {
152 skip_count -= 1;
153 continue;
154 }
155 let line = line_info.content(ctx.content);
156
157 if line_info.in_code_block {
158 result.push(line.to_string());
159 continue;
160 }
161
162 if let Some(heading) = &line_info.heading {
164 if !heading.is_valid {
166 result.push(line.to_string());
167 continue;
168 }
169
170 let is_first_heading = Some(i) == heading_at_start_idx;
172 let heading_level = heading.level as usize;
173
174 let mut blank_lines_above = 0;
176 let mut check_idx = result.len();
177 while check_idx > 0 {
178 let prev_line = &result[check_idx - 1];
179 let trimmed = prev_line.trim();
180 if trimmed.is_empty() {
181 blank_lines_above += 1;
182 check_idx -= 1;
183 } else if trimmed.starts_with("<!--") && trimmed.ends_with("-->") {
184 check_idx -= 1;
186 } else if is_kramdown_block_attribute(trimmed) {
187 check_idx -= 1;
189 } else if is_quarto && (quarto_divs::is_div_open(trimmed) || quarto_divs::is_div_close(trimmed)) {
190 check_idx -= 1;
192 } else {
193 break;
194 }
195 }
196
197 let requirement_above = self.config.lines_above.get_for_level(heading_level);
199 let needed_blanks_above = if is_first_heading && self.config.allowed_at_start {
200 0
201 } else {
202 requirement_above.required_count().unwrap_or(0)
203 };
204
205 while blank_lines_above < needed_blanks_above {
207 result.push(String::new());
208 blank_lines_above += 1;
209 }
210
211 result.push(line.to_string());
213
214 let mut effective_end_idx = i;
216
217 if matches!(
219 heading.style,
220 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
221 ) {
222 if i + 1 < ctx.lines.len() {
224 result.push(ctx.lines[i + 1].content(ctx.content).to_string());
225 skip_count += 1; effective_end_idx = i + 1;
227 }
228 }
229
230 let mut ial_count = 0;
233 while effective_end_idx + 1 < ctx.lines.len() {
234 let next_line = &ctx.lines[effective_end_idx + 1];
235 let next_trimmed = next_line.content(ctx.content).trim();
236 if is_kramdown_block_attribute(next_trimmed) {
237 result.push(next_trimmed.to_string());
238 effective_end_idx += 1;
239 ial_count += 1;
240 } else {
241 break;
242 }
243 }
244
245 let mut blank_lines_below = 0;
247 let mut next_content_line_idx = None;
248 for j in (effective_end_idx + 1)..ctx.lines.len() {
249 if ctx.lines[j].is_blank {
250 blank_lines_below += 1;
251 } else {
252 next_content_line_idx = Some(j);
253 break;
254 }
255 }
256
257 let next_is_special = if let Some(idx) = next_content_line_idx {
259 let next_line = &ctx.lines[idx];
260 next_line.list_item.is_some() || {
261 let trimmed = next_line.content(ctx.content).trim();
262 (trimmed.starts_with("```") || trimmed.starts_with("~~~"))
263 && (trimmed.len() == 3
264 || (trimmed.len() > 3
265 && trimmed
266 .chars()
267 .nth(3)
268 .is_some_and(|c| c.is_whitespace() || c.is_alphabetic())))
269 }
270 } else {
271 false
272 };
273
274 let requirement_below = self.config.lines_below.get_for_level(heading_level);
276 let needed_blanks_below = if next_is_special {
277 0
278 } else {
279 requirement_below.required_count().unwrap_or(0)
280 };
281 if blank_lines_below < needed_blanks_below {
282 for _ in 0..(needed_blanks_below - blank_lines_below) {
283 result.push(String::new());
284 }
285 }
286
287 skip_count += ial_count;
289 } else {
290 result.push(line.to_string());
292 }
293 }
294
295 let joined = result.join(line_ending);
296
297 if had_trailing_newline && !joined.ends_with('\n') {
300 format!("{joined}{line_ending}")
301 } else if !had_trailing_newline && joined.ends_with('\n') {
302 joined[..joined.len() - 1].to_string()
304 } else {
305 joined
306 }
307 }
308}
309
310impl Rule for MD022BlanksAroundHeadings {
311 fn name(&self) -> &'static str {
312 "MD022"
313 }
314
315 fn description(&self) -> &'static str {
316 "Headings should be surrounded by blank lines"
317 }
318
319 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
320 let mut result = Vec::new();
321
322 if ctx.lines.is_empty() {
324 return Ok(result);
325 }
326
327 let line_ending = "\n";
329 let is_quarto = ctx.flavor == crate::config::MarkdownFlavor::Quarto;
330
331 let heading_at_start_idx = {
332 let mut found_non_transparent = false;
333 ctx.lines.iter().enumerate().find_map(|(i, line)| {
334 if line.heading.as_ref().is_some_and(|h| h.is_valid) && !found_non_transparent {
336 Some(i)
337 } else {
338 if !line.is_blank && !line.in_html_comment {
341 let trimmed = line.content(ctx.content).trim();
342 if trimmed.starts_with("<!--") && trimmed.ends_with("-->") {
344 } else if is_quarto && (quarto_divs::is_div_open(trimmed) || quarto_divs::is_div_close(trimmed))
346 {
347 } else {
349 found_non_transparent = true;
350 }
351 }
352 None
353 }
354 })
355 };
356
357 let mut heading_violations = Vec::new();
359 let mut processed_headings = std::collections::HashSet::new();
360
361 for (line_num, line_info) in ctx.lines.iter().enumerate() {
362 if processed_headings.contains(&line_num) || line_info.heading.is_none() {
364 continue;
365 }
366
367 if line_info.in_pymdown_block {
369 continue;
370 }
371
372 let heading = line_info.heading.as_ref().unwrap();
373
374 if !heading.is_valid {
376 continue;
377 }
378
379 let heading_level = heading.level as usize;
380
381 processed_headings.insert(line_num);
385
386 let is_first_heading = Some(line_num) == heading_at_start_idx;
388
389 let required_above_count = self.config.lines_above.get_for_level(heading_level).required_count();
391 let required_below_count = self.config.lines_below.get_for_level(heading_level).required_count();
392
393 let should_check_above =
395 required_above_count.is_some() && line_num > 0 && (!is_first_heading || !self.config.allowed_at_start);
396 if should_check_above {
397 let mut blank_lines_above = 0;
398 let mut hit_frontmatter_end = false;
399 for j in (0..line_num).rev() {
400 let line_content = ctx.lines[j].content(ctx.content);
401 let trimmed = line_content.trim();
402 if ctx.lines[j].is_blank {
403 blank_lines_above += 1;
404 } else if ctx.lines[j].in_html_comment || (trimmed.starts_with("<!--") && trimmed.ends_with("-->"))
405 {
406 continue;
408 } else if is_kramdown_block_attribute(trimmed) {
409 continue;
411 } else if is_quarto && (quarto_divs::is_div_open(trimmed) || quarto_divs::is_div_close(trimmed)) {
412 continue;
414 } else if ctx.lines[j].in_front_matter {
415 hit_frontmatter_end = true;
420 break;
421 } else {
422 break;
423 }
424 }
425 let required = required_above_count.unwrap();
426 if !hit_frontmatter_end && blank_lines_above < required {
427 let needed_blanks = required - blank_lines_above;
428 heading_violations.push((line_num, "above", needed_blanks, heading_level));
429 }
430 }
431
432 let mut effective_last_line = if matches!(
434 heading.style,
435 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
436 ) {
437 line_num + 1 } else {
439 line_num
440 };
441
442 while effective_last_line + 1 < ctx.lines.len() {
445 let next_line = &ctx.lines[effective_last_line + 1];
446 let next_trimmed = next_line.content(ctx.content).trim();
447 if is_kramdown_block_attribute(next_trimmed) {
448 effective_last_line += 1;
449 } else {
450 break;
451 }
452 }
453
454 if effective_last_line < ctx.lines.len() - 1 {
456 let mut next_non_blank_idx = effective_last_line + 1;
458 while next_non_blank_idx < ctx.lines.len() {
459 let check_line = &ctx.lines[next_non_blank_idx];
460 let check_trimmed = check_line.content(ctx.content).trim();
461 if check_line.is_blank {
462 next_non_blank_idx += 1;
463 } else if check_line.in_html_comment
464 || (check_trimmed.starts_with("<!--") && check_trimmed.ends_with("-->"))
465 {
466 next_non_blank_idx += 1;
468 } else if is_quarto
469 && (quarto_divs::is_div_open(check_trimmed) || quarto_divs::is_div_close(check_trimmed))
470 {
471 next_non_blank_idx += 1;
473 } else {
474 break;
475 }
476 }
477
478 if next_non_blank_idx >= ctx.lines.len() {
480 continue;
482 }
483
484 let next_line_is_special = {
486 let next_line = &ctx.lines[next_non_blank_idx];
487 let next_trimmed = next_line.content(ctx.content).trim();
488
489 let is_code_fence = (next_trimmed.starts_with("```") || next_trimmed.starts_with("~~~"))
491 && (next_trimmed.len() == 3
492 || (next_trimmed.len() > 3
493 && next_trimmed
494 .chars()
495 .nth(3)
496 .is_some_and(|c| c.is_whitespace() || c.is_alphabetic())));
497
498 let is_list_item = next_line.list_item.is_some();
500
501 is_code_fence || is_list_item
502 };
503
504 if !next_line_is_special && let Some(required) = required_below_count {
506 let mut blank_lines_below = 0;
508 for k in (effective_last_line + 1)..next_non_blank_idx {
509 if ctx.lines[k].is_blank {
510 blank_lines_below += 1;
511 }
512 }
513
514 if blank_lines_below < required {
515 let needed_blanks = required - blank_lines_below;
516 heading_violations.push((line_num, "below", needed_blanks, heading_level));
517 }
518 }
519 }
520 }
521
522 for (heading_line, position, needed_blanks, heading_level) in heading_violations {
524 let heading_display_line = heading_line + 1; let line_info = &ctx.lines[heading_line];
526
527 let (start_line, start_col, end_line, end_col) =
529 calculate_heading_range(heading_display_line, line_info.content(ctx.content));
530
531 let required_above_count = self
532 .config
533 .lines_above
534 .get_for_level(heading_level)
535 .required_count()
536 .expect("Violations only generated for limited 'above' requirements");
537 let required_below_count = self
538 .config
539 .lines_below
540 .get_for_level(heading_level)
541 .required_count()
542 .expect("Violations only generated for limited 'below' requirements");
543
544 let (message, insertion_point) = match position {
545 "above" => (
546 format!(
547 "Expected {} blank {} above heading",
548 required_above_count,
549 if required_above_count == 1 { "line" } else { "lines" }
550 ),
551 heading_line, ),
553 "below" => {
554 let insert_after = if line_info.heading.as_ref().is_some_and(|h| {
556 matches!(
557 h.style,
558 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
559 )
560 }) {
561 heading_line + 2
562 } else {
563 heading_line + 1
564 };
565
566 (
567 format!(
568 "Expected {} blank {} below heading",
569 required_below_count,
570 if required_below_count == 1 { "line" } else { "lines" }
571 ),
572 insert_after,
573 )
574 }
575 _ => continue,
576 };
577
578 let byte_range = if insertion_point == 0 && position == "above" {
580 0..0
582 } else if position == "above" && insertion_point > 0 {
583 ctx.lines[insertion_point].byte_offset..ctx.lines[insertion_point].byte_offset
585 } else if position == "below" && insertion_point - 1 < ctx.lines.len() {
586 let line_idx = insertion_point - 1;
588 let line_end_offset = if line_idx + 1 < ctx.lines.len() {
589 ctx.lines[line_idx + 1].byte_offset
590 } else {
591 ctx.content.len()
592 };
593 line_end_offset..line_end_offset
594 } else {
595 let content_len = ctx.content.len();
597 content_len..content_len
598 };
599
600 result.push(LintWarning {
601 rule_name: Some(self.name().to_string()),
602 message,
603 line: start_line,
604 column: start_col,
605 end_line,
606 end_column: end_col,
607 severity: Severity::Warning,
608 fix: Some(Fix {
609 range: byte_range,
610 replacement: line_ending.repeat(needed_blanks),
611 }),
612 });
613 }
614
615 Ok(result)
616 }
617
618 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
619 if ctx.content.is_empty() {
620 return Ok(ctx.content.to_string());
621 }
622
623 let fixed = self._fix_content(ctx);
625
626 Ok(fixed)
627 }
628
629 fn category(&self) -> RuleCategory {
631 RuleCategory::Heading
632 }
633
634 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
636 if ctx.content.is_empty() || !ctx.likely_has_headings() {
638 return true;
639 }
640 ctx.lines.iter().all(|line| line.heading.is_none())
642 }
643
644 fn as_any(&self) -> &dyn std::any::Any {
645 self
646 }
647
648 fn default_config_section(&self) -> Option<(String, toml::Value)> {
649 let default_config = MD022Config::default();
650 let json_value = serde_json::to_value(&default_config).ok()?;
651 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
652
653 if let toml::Value::Table(table) = toml_value {
654 if !table.is_empty() {
655 Some((MD022Config::RULE_NAME.to_string(), toml::Value::Table(table)))
656 } else {
657 None
658 }
659 } else {
660 None
661 }
662 }
663
664 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
665 where
666 Self: Sized,
667 {
668 let rule_config = crate::rule_config_serde::load_rule_config::<MD022Config>(config);
669 Box::new(Self::from_config_struct(rule_config))
670 }
671}
672
673#[cfg(test)]
674mod tests {
675 use super::*;
676 use crate::lint_context::LintContext;
677
678 #[test]
679 fn test_valid_headings() {
680 let rule = MD022BlanksAroundHeadings::default();
681 let content = "\n# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.\n";
682 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
683 let result = rule.check(&ctx).unwrap();
684 assert!(result.is_empty());
685 }
686
687 #[test]
688 fn test_missing_blank_above() {
689 let rule = MD022BlanksAroundHeadings::default();
690 let content = "# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.\n";
691 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
692 let result = rule.check(&ctx).unwrap();
693 assert_eq!(result.len(), 0); let fixed = rule.fix(&ctx).unwrap();
696
697 assert!(fixed.contains("# Heading 1"));
700 assert!(fixed.contains("Some content."));
701 assert!(fixed.contains("## Heading 2"));
702 assert!(fixed.contains("More content."));
703 }
704
705 #[test]
706 fn test_missing_blank_below() {
707 let rule = MD022BlanksAroundHeadings::default();
708 let content = "\n# Heading 1\nSome content.\n\n## Heading 2\n\nMore content.\n";
709 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
710 let result = rule.check(&ctx).unwrap();
711 assert_eq!(result.len(), 1);
712 assert_eq!(result[0].line, 2);
713
714 let fixed = rule.fix(&ctx).unwrap();
716 assert!(fixed.contains("# Heading 1\n\nSome content"));
717 }
718
719 #[test]
720 fn test_missing_blank_above_and_below() {
721 let rule = MD022BlanksAroundHeadings::default();
722 let content = "# Heading 1\nSome content.\n## Heading 2\nMore content.\n";
723 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
724 let result = rule.check(&ctx).unwrap();
725 assert_eq!(result.len(), 3); let fixed = rule.fix(&ctx).unwrap();
729 assert!(fixed.contains("# Heading 1\n\nSome content"));
730 assert!(fixed.contains("Some content.\n\n## Heading 2"));
731 assert!(fixed.contains("## Heading 2\n\nMore content"));
732 }
733
734 #[test]
735 fn test_fix_headings() {
736 let rule = MD022BlanksAroundHeadings::default();
737 let content = "# Heading 1\nSome content.\n## Heading 2\nMore content.";
738 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
739 let result = rule.fix(&ctx).unwrap();
740
741 let expected = "# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.";
742 assert_eq!(result, expected);
743 }
744
745 #[test]
746 fn test_consecutive_headings_pattern() {
747 let rule = MD022BlanksAroundHeadings::default();
748 let content = "# Heading 1\n## Heading 2\n### Heading 3";
749 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
750 let result = rule.fix(&ctx).unwrap();
751
752 let lines: Vec<&str> = result.lines().collect();
754 assert!(!lines.is_empty());
755
756 let h1_pos = lines.iter().position(|&l| l == "# Heading 1").unwrap();
758 let h2_pos = lines.iter().position(|&l| l == "## Heading 2").unwrap();
759 let h3_pos = lines.iter().position(|&l| l == "### Heading 3").unwrap();
760
761 assert!(
763 h2_pos > h1_pos + 1,
764 "Should have at least one blank line after first heading"
765 );
766 assert!(
767 h3_pos > h2_pos + 1,
768 "Should have at least one blank line after second heading"
769 );
770
771 assert!(lines[h1_pos + 1].trim().is_empty(), "Line after h1 should be blank");
773
774 assert!(lines[h2_pos + 1].trim().is_empty(), "Line after h2 should be blank");
776 }
777
778 #[test]
779 fn test_blanks_around_setext_headings() {
780 let rule = MD022BlanksAroundHeadings::default();
781 let content = "Heading 1\n=========\nSome content.\nHeading 2\n---------\nMore content.";
782 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
783 let result = rule.fix(&ctx).unwrap();
784
785 let lines: Vec<&str> = result.lines().collect();
787
788 assert!(result.contains("Heading 1"));
790 assert!(result.contains("========="));
791 assert!(result.contains("Some content."));
792 assert!(result.contains("Heading 2"));
793 assert!(result.contains("---------"));
794 assert!(result.contains("More content."));
795
796 let heading1_marker_idx = lines.iter().position(|&l| l == "=========").unwrap();
798 let some_content_idx = lines.iter().position(|&l| l == "Some content.").unwrap();
799 assert!(
800 some_content_idx > heading1_marker_idx + 1,
801 "Should have a blank line after the first heading"
802 );
803
804 let heading2_marker_idx = lines.iter().position(|&l| l == "---------").unwrap();
805 let more_content_idx = lines.iter().position(|&l| l == "More content.").unwrap();
806 assert!(
807 more_content_idx > heading2_marker_idx + 1,
808 "Should have a blank line after the second heading"
809 );
810
811 let fixed_ctx = LintContext::new(&result, crate::config::MarkdownFlavor::Standard, None);
813 let fixed_warnings = rule.check(&fixed_ctx).unwrap();
814 assert!(fixed_warnings.is_empty(), "Fixed content should have no warnings");
815 }
816
817 #[test]
818 fn test_fix_specific_blank_line_cases() {
819 let rule = MD022BlanksAroundHeadings::default();
820
821 let content1 = "# Heading 1\n## Heading 2\n### Heading 3";
823 let ctx1 = LintContext::new(content1, crate::config::MarkdownFlavor::Standard, None);
824 let result1 = rule.fix(&ctx1).unwrap();
825 assert!(result1.contains("# Heading 1"));
827 assert!(result1.contains("## Heading 2"));
828 assert!(result1.contains("### Heading 3"));
829 let lines: Vec<&str> = result1.lines().collect();
831 let h1_pos = lines.iter().position(|&l| l == "# Heading 1").unwrap();
832 let h2_pos = lines.iter().position(|&l| l == "## Heading 2").unwrap();
833 assert!(lines[h1_pos + 1].trim().is_empty(), "Should have a blank line after h1");
834 assert!(lines[h2_pos + 1].trim().is_empty(), "Should have a blank line after h2");
835
836 let content2 = "# Heading 1\nContent under heading 1\n## Heading 2";
838 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
839 let result2 = rule.fix(&ctx2).unwrap();
840 assert!(result2.contains("# Heading 1"));
842 assert!(result2.contains("Content under heading 1"));
843 assert!(result2.contains("## Heading 2"));
844 let lines2: Vec<&str> = result2.lines().collect();
846 let h1_pos2 = lines2.iter().position(|&l| l == "# Heading 1").unwrap();
847 let _content_pos = lines2.iter().position(|&l| l == "Content under heading 1").unwrap();
848 assert!(
849 lines2[h1_pos2 + 1].trim().is_empty(),
850 "Should have a blank line after heading 1"
851 );
852
853 let content3 = "# Heading 1\n\n\n## Heading 2\n\n\n### Heading 3\n\nContent";
855 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
856 let result3 = rule.fix(&ctx3).unwrap();
857 assert!(result3.contains("# Heading 1"));
859 assert!(result3.contains("## Heading 2"));
860 assert!(result3.contains("### Heading 3"));
861 assert!(result3.contains("Content"));
862 }
863
864 #[test]
865 fn test_fix_preserves_existing_blank_lines() {
866 let rule = MD022BlanksAroundHeadings::new();
867 let content = "# Title
868
869## Section 1
870
871Content here.
872
873## Section 2
874
875More content.
876### Missing Blank Above
877
878Even more content.
879
880## Section 3
881
882Final content.";
883
884 let expected = "# Title
885
886## Section 1
887
888Content here.
889
890## Section 2
891
892More content.
893
894### Missing Blank Above
895
896Even more content.
897
898## Section 3
899
900Final content.";
901
902 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
903 let result = rule._fix_content(&ctx);
904 assert_eq!(
905 result, expected,
906 "Fix should only add missing blank lines, never remove existing ones"
907 );
908 }
909
910 #[test]
911 fn test_fix_preserves_trailing_newline() {
912 let rule = MD022BlanksAroundHeadings::new();
913
914 let content_with_newline = "# Title\nContent here.\n";
916 let ctx = LintContext::new(content_with_newline, crate::config::MarkdownFlavor::Standard, None);
917 let result = rule.fix(&ctx).unwrap();
918 assert!(result.ends_with('\n'), "Should preserve trailing newline");
919
920 let content_without_newline = "# Title\nContent here.";
922 let ctx = LintContext::new(content_without_newline, crate::config::MarkdownFlavor::Standard, None);
923 let result = rule.fix(&ctx).unwrap();
924 assert!(
925 !result.ends_with('\n'),
926 "Should not add trailing newline if original didn't have one"
927 );
928 }
929
930 #[test]
931 fn test_fix_does_not_add_blank_lines_before_lists() {
932 let rule = MD022BlanksAroundHeadings::new();
933 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.";
934
935 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.";
936
937 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
938 let result = rule._fix_content(&ctx);
939 assert_eq!(result, expected, "Fix should not add blank lines before lists");
940 }
941
942 #[test]
943 fn test_per_level_configuration_no_blank_above_h1() {
944 use md022_config::HeadingLevelConfig;
945
946 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
948 lines_above: HeadingLevelConfig::per_level([0, 1, 1, 1, 1, 1]),
949 lines_below: HeadingLevelConfig::scalar(1),
950 allowed_at_start: false, });
952
953 let content = "Some text\n# Heading 1\n\nMore text";
955 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
956 let warnings = rule.check(&ctx).unwrap();
957 assert_eq!(warnings.len(), 0, "H1 without blank above should not trigger warning");
958
959 let content = "Some text\n## Heading 2\n\nMore text";
961 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
962 let warnings = rule.check(&ctx).unwrap();
963 assert_eq!(warnings.len(), 1, "H2 without blank above should trigger warning");
964 assert!(warnings[0].message.contains("above"));
965 }
966
967 #[test]
968 fn test_per_level_configuration_different_requirements() {
969 use md022_config::HeadingLevelConfig;
970
971 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
973 lines_above: HeadingLevelConfig::per_level([0, 1, 1, 2, 2, 2]),
974 lines_below: HeadingLevelConfig::scalar(1),
975 allowed_at_start: false,
976 });
977
978 let content = "Text\n# H1\n\nText\n\n## H2\n\nText\n\n### H3\n\nText\n\n\n#### H4\n\nText";
979 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
980 let warnings = rule.check(&ctx).unwrap();
981
982 assert_eq!(
984 warnings.len(),
985 0,
986 "All headings should satisfy level-specific requirements"
987 );
988 }
989
990 #[test]
991 fn test_per_level_configuration_violations() {
992 use md022_config::HeadingLevelConfig;
993
994 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
996 lines_above: HeadingLevelConfig::per_level([1, 1, 1, 2, 1, 1]),
997 lines_below: HeadingLevelConfig::scalar(1),
998 allowed_at_start: false,
999 });
1000
1001 let content = "Text\n\n#### Heading 4\n\nMore text";
1003 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1004 let warnings = rule.check(&ctx).unwrap();
1005
1006 assert_eq!(warnings.len(), 1, "H4 with insufficient blanks should trigger warning");
1007 assert!(warnings[0].message.contains("2 blank lines above"));
1008 }
1009
1010 #[test]
1011 fn test_per_level_fix_different_levels() {
1012 use md022_config::HeadingLevelConfig;
1013
1014 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1016 lines_above: HeadingLevelConfig::per_level([0, 1, 2, 2, 2, 2]),
1017 lines_below: HeadingLevelConfig::scalar(1),
1018 allowed_at_start: false,
1019 });
1020
1021 let content = "Text\n# H1\nContent\n## H2\nContent\n### H3\nContent";
1022 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1023 let fixed = rule.fix(&ctx).unwrap();
1024
1025 assert!(fixed.contains("Text\n# H1\n\nContent"));
1027 assert!(fixed.contains("Content\n\n## H2\n\nContent"));
1028 assert!(fixed.contains("Content\n\n\n### H3\n\nContent"));
1029 }
1030
1031 #[test]
1032 fn test_per_level_below_configuration() {
1033 use md022_config::HeadingLevelConfig;
1034
1035 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1037 lines_above: HeadingLevelConfig::scalar(1),
1038 lines_below: HeadingLevelConfig::per_level([2, 1, 1, 1, 1, 1]), allowed_at_start: true,
1040 });
1041
1042 let content = "# Heading 1\n\nSome text";
1044 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1045 let warnings = rule.check(&ctx).unwrap();
1046
1047 assert_eq!(
1048 warnings.len(),
1049 1,
1050 "H1 with insufficient blanks below should trigger warning"
1051 );
1052 assert!(warnings[0].message.contains("2 blank lines below"));
1053 }
1054
1055 #[test]
1056 fn test_scalar_configuration_still_works() {
1057 use md022_config::HeadingLevelConfig;
1058
1059 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1061 lines_above: HeadingLevelConfig::scalar(2),
1062 lines_below: HeadingLevelConfig::scalar(2),
1063 allowed_at_start: false,
1064 });
1065
1066 let content = "Text\n# H1\nContent\n## H2\nContent";
1067 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1068 let warnings = rule.check(&ctx).unwrap();
1069
1070 assert!(!warnings.is_empty(), "Should have violations for insufficient blanks");
1072 }
1073
1074 #[test]
1075 fn test_unlimited_configuration_skips_requirements() {
1076 use md022_config::{HeadingBlankRequirement, HeadingLevelConfig};
1077
1078 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1080 lines_above: HeadingLevelConfig::per_level_requirements([
1081 HeadingBlankRequirement::unlimited(),
1082 HeadingBlankRequirement::limited(1),
1083 HeadingBlankRequirement::limited(1),
1084 HeadingBlankRequirement::limited(1),
1085 HeadingBlankRequirement::limited(1),
1086 HeadingBlankRequirement::limited(1),
1087 ]),
1088 lines_below: HeadingLevelConfig::per_level_requirements([
1089 HeadingBlankRequirement::unlimited(),
1090 HeadingBlankRequirement::limited(1),
1091 HeadingBlankRequirement::limited(1),
1092 HeadingBlankRequirement::limited(1),
1093 HeadingBlankRequirement::limited(1),
1094 HeadingBlankRequirement::limited(1),
1095 ]),
1096 allowed_at_start: false,
1097 });
1098
1099 let content = "# H1\nParagraph\n## H2\nParagraph";
1100 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1101 let warnings = rule.check(&ctx).unwrap();
1102
1103 assert_eq!(warnings.len(), 2, "Only non-unlimited headings should warn");
1105 assert!(
1106 warnings.iter().all(|w| w.line >= 3),
1107 "Warnings should target later headings"
1108 );
1109
1110 let fixed = rule.fix(&ctx).unwrap();
1112 assert!(
1113 fixed.starts_with("# H1\nParagraph\n\n## H2"),
1114 "H1 should remain unchanged"
1115 );
1116 }
1117
1118 #[test]
1119 fn test_html_comment_transparency() {
1120 let rule = MD022BlanksAroundHeadings::default();
1124
1125 let content = "Some content\n\n<!-- markdownlint-disable-next-line MD001 -->\n#### Heading";
1128 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1129 let warnings = rule.check(&ctx).unwrap();
1130 assert!(
1131 warnings.is_empty(),
1132 "HTML comment is transparent - blank line above it counts for heading"
1133 );
1134
1135 let content_multiline = "Some content\n\n<!-- This is a\nmulti-line comment -->\n#### Heading";
1137 let ctx_multiline = LintContext::new(content_multiline, crate::config::MarkdownFlavor::Standard, None);
1138 let warnings_multiline = rule.check(&ctx_multiline).unwrap();
1139 assert!(
1140 warnings_multiline.is_empty(),
1141 "Multi-line HTML comment is also transparent"
1142 );
1143 }
1144
1145 #[test]
1146 fn test_frontmatter_transparency() {
1147 let rule = MD022BlanksAroundHeadings::default();
1150
1151 let content = "---\ntitle: Test\n---\n# First heading";
1153 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1154 let warnings = rule.check(&ctx).unwrap();
1155 assert!(
1156 warnings.is_empty(),
1157 "Frontmatter is transparent - heading can appear immediately after"
1158 );
1159
1160 let content_with_blank = "---\ntitle: Test\n---\n\n# First heading";
1162 let ctx_with_blank = LintContext::new(content_with_blank, crate::config::MarkdownFlavor::Standard, None);
1163 let warnings_with_blank = rule.check(&ctx_with_blank).unwrap();
1164 assert!(
1165 warnings_with_blank.is_empty(),
1166 "Heading with blank line after frontmatter should also be valid"
1167 );
1168
1169 let content_toml = "+++\ntitle = \"Test\"\n+++\n# First heading";
1171 let ctx_toml = LintContext::new(content_toml, crate::config::MarkdownFlavor::Standard, None);
1172 let warnings_toml = rule.check(&ctx_toml).unwrap();
1173 assert!(
1174 warnings_toml.is_empty(),
1175 "TOML frontmatter is also transparent for MD022"
1176 );
1177 }
1178
1179 #[test]
1180 fn test_horizontal_rule_not_treated_as_frontmatter() {
1181 let rule = MD022BlanksAroundHeadings::default();
1184
1185 let content = "Some content\n\n---\n# Heading after HR";
1187 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1188 let warnings = rule.check(&ctx).unwrap();
1189 assert!(
1190 !warnings.is_empty(),
1191 "Heading after horizontal rule without blank line SHOULD trigger MD022"
1192 );
1193 assert!(
1194 warnings.iter().any(|w| w.line == 4),
1195 "Warning should be on line 4 (the heading line)"
1196 );
1197
1198 let content_with_blank = "Some content\n\n---\n\n# Heading after HR";
1200 let ctx_with_blank = LintContext::new(content_with_blank, crate::config::MarkdownFlavor::Standard, None);
1201 let warnings_with_blank = rule.check(&ctx_with_blank).unwrap();
1202 assert!(
1203 warnings_with_blank.is_empty(),
1204 "Heading with blank line after HR should not trigger MD022"
1205 );
1206
1207 let content_hr_start = "---\n# Heading";
1209 let ctx_hr_start = LintContext::new(content_hr_start, crate::config::MarkdownFlavor::Standard, None);
1210 let warnings_hr_start = rule.check(&ctx_hr_start).unwrap();
1211 assert!(
1212 !warnings_hr_start.is_empty(),
1213 "Heading after HR at document start SHOULD trigger MD022"
1214 );
1215
1216 let content_multi_hr = "Content\n\n---\n\n---\n# Heading";
1218 let ctx_multi_hr = LintContext::new(content_multi_hr, crate::config::MarkdownFlavor::Standard, None);
1219 let warnings_multi_hr = rule.check(&ctx_multi_hr).unwrap();
1220 assert!(
1221 !warnings_multi_hr.is_empty(),
1222 "Heading after multiple HRs without blank line SHOULD trigger MD022"
1223 );
1224 }
1225
1226 #[test]
1227 fn test_all_hr_styles_require_blank_before_heading() {
1228 let rule = MD022BlanksAroundHeadings::default();
1230
1231 let hr_styles = [
1233 "---", "***", "___", "- - -", "* * *", "_ _ _", "----", "****", "____", "- - - -",
1234 "- - -", " ---", " ---", ];
1238
1239 for hr in hr_styles {
1240 let content = format!("Content\n\n{hr}\n# Heading");
1241 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1242 let warnings = rule.check(&ctx).unwrap();
1243 assert!(
1244 !warnings.is_empty(),
1245 "HR style '{hr}' followed by heading should trigger MD022"
1246 );
1247 }
1248 }
1249
1250 #[test]
1251 fn test_setext_heading_after_hr() {
1252 let rule = MD022BlanksAroundHeadings::default();
1254
1255 let content = "Content\n\n---\nHeading\n======";
1257 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1258 let warnings = rule.check(&ctx).unwrap();
1259 assert!(
1260 !warnings.is_empty(),
1261 "Setext heading after HR without blank should trigger MD022"
1262 );
1263
1264 let content_h2 = "Content\n\n---\nHeading\n------";
1266 let ctx_h2 = LintContext::new(content_h2, crate::config::MarkdownFlavor::Standard, None);
1267 let warnings_h2 = rule.check(&ctx_h2).unwrap();
1268 assert!(
1269 !warnings_h2.is_empty(),
1270 "Setext h2 after HR without blank should trigger MD022"
1271 );
1272
1273 let content_ok = "Content\n\n---\n\nHeading\n======";
1275 let ctx_ok = LintContext::new(content_ok, crate::config::MarkdownFlavor::Standard, None);
1276 let warnings_ok = rule.check(&ctx_ok).unwrap();
1277 assert!(
1278 warnings_ok.is_empty(),
1279 "Setext heading with blank after HR should not warn"
1280 );
1281 }
1282
1283 #[test]
1284 fn test_hr_in_code_block_not_treated_as_hr() {
1285 let rule = MD022BlanksAroundHeadings::default();
1287
1288 let content = "```\n---\n```\n# Heading";
1291 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1292 let warnings = rule.check(&ctx).unwrap();
1293 assert!(!warnings.is_empty(), "Heading after code block still needs blank line");
1296
1297 let content_ok = "```\n---\n```\n\n# Heading";
1299 let ctx_ok = LintContext::new(content_ok, crate::config::MarkdownFlavor::Standard, None);
1300 let warnings_ok = rule.check(&ctx_ok).unwrap();
1301 assert!(
1302 warnings_ok.is_empty(),
1303 "Heading with blank after code block should not warn"
1304 );
1305 }
1306
1307 #[test]
1308 fn test_hr_in_html_comment_not_treated_as_hr() {
1309 let rule = MD022BlanksAroundHeadings::default();
1311
1312 let content = "<!-- \n---\n -->\n# Heading";
1314 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1315 let warnings = rule.check(&ctx).unwrap();
1316 assert!(
1318 warnings.is_empty(),
1319 "HR inside HTML comment should be ignored - heading after comment is OK"
1320 );
1321 }
1322
1323 #[test]
1324 fn test_invalid_hr_not_triggering() {
1325 let rule = MD022BlanksAroundHeadings::default();
1327
1328 let invalid_hrs = [
1329 " ---", "\t---", "--", "**", "__", "-*-", "---a", "a---", ];
1338
1339 for invalid in invalid_hrs {
1340 let content = format!("Content\n\n{invalid}\n# Heading");
1343 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1344 let _ = rule.check(&ctx);
1347 }
1348 }
1349
1350 #[test]
1351 fn test_frontmatter_vs_horizontal_rule_distinction() {
1352 let rule = MD022BlanksAroundHeadings::default();
1354
1355 let content = "---\ntitle: Test\n---\n\nSome content\n\n---\n# Heading after HR";
1358 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1359 let warnings = rule.check(&ctx).unwrap();
1360 assert!(
1361 !warnings.is_empty(),
1362 "HR after frontmatter content should still require blank line before heading"
1363 );
1364
1365 let content_ok = "---\ntitle: Test\n---\n\nSome content\n\n---\n\n# Heading after HR";
1367 let ctx_ok = LintContext::new(content_ok, crate::config::MarkdownFlavor::Standard, None);
1368 let warnings_ok = rule.check(&ctx_ok).unwrap();
1369 assert!(
1370 warnings_ok.is_empty(),
1371 "HR with blank line before heading should not warn"
1372 );
1373 }
1374
1375 #[test]
1378 fn test_kramdown_ial_after_heading_no_warning() {
1379 let rule = MD022BlanksAroundHeadings::default();
1381 let content = "## Table of Contents\n{: .hhc-toc-heading}\n\nSome content here.";
1382 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1383 let warnings = rule.check(&ctx).unwrap();
1384
1385 assert!(
1386 warnings.is_empty(),
1387 "IAL after heading should not require blank line between them: {warnings:?}"
1388 );
1389 }
1390
1391 #[test]
1392 fn test_kramdown_ial_with_class() {
1393 let rule = MD022BlanksAroundHeadings::default();
1394 let content = "# Heading\n{:.highlight}\n\nContent.";
1395 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1396 let warnings = rule.check(&ctx).unwrap();
1397
1398 assert!(warnings.is_empty(), "IAL with class should be part of heading");
1399 }
1400
1401 #[test]
1402 fn test_kramdown_ial_with_id() {
1403 let rule = MD022BlanksAroundHeadings::default();
1404 let content = "# Heading\n{:#custom-id}\n\nContent.";
1405 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1406 let warnings = rule.check(&ctx).unwrap();
1407
1408 assert!(warnings.is_empty(), "IAL with id should be part of heading");
1409 }
1410
1411 #[test]
1412 fn test_kramdown_ial_with_multiple_attributes() {
1413 let rule = MD022BlanksAroundHeadings::default();
1414 let content = "# Heading\n{: .class #id style=\"color: red\"}\n\nContent.";
1415 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1416 let warnings = rule.check(&ctx).unwrap();
1417
1418 assert!(
1419 warnings.is_empty(),
1420 "IAL with multiple attributes should be part of heading"
1421 );
1422 }
1423
1424 #[test]
1425 fn test_kramdown_ial_missing_blank_after() {
1426 let rule = MD022BlanksAroundHeadings::default();
1428 let content = "# Heading\n{:.class}\nContent without blank.";
1429 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1430 let warnings = rule.check(&ctx).unwrap();
1431
1432 assert_eq!(
1433 warnings.len(),
1434 1,
1435 "Should warn about missing blank after IAL (part of heading)"
1436 );
1437 assert!(warnings[0].message.contains("below"));
1438 }
1439
1440 #[test]
1441 fn test_kramdown_ial_before_heading_transparent() {
1442 let rule = MD022BlanksAroundHeadings::default();
1444 let content = "Content.\n\n{:.preclass}\n## Heading\n\nMore content.";
1445 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1446 let warnings = rule.check(&ctx).unwrap();
1447
1448 assert!(
1449 warnings.is_empty(),
1450 "IAL before heading should be transparent for blank line count"
1451 );
1452 }
1453
1454 #[test]
1455 fn test_kramdown_ial_setext_heading() {
1456 let rule = MD022BlanksAroundHeadings::default();
1457 let content = "Heading\n=======\n{:.setext-class}\n\nContent.";
1458 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1459 let warnings = rule.check(&ctx).unwrap();
1460
1461 assert!(
1462 warnings.is_empty(),
1463 "IAL after Setext heading should be part of heading"
1464 );
1465 }
1466
1467 #[test]
1468 fn test_kramdown_ial_fix_preserves_ial() {
1469 let rule = MD022BlanksAroundHeadings::default();
1470 let content = "Content.\n# Heading\n{:.class}\nMore content.";
1471 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1472 let fixed = rule.fix(&ctx).unwrap();
1473
1474 assert!(
1476 fixed.contains("# Heading\n{:.class}"),
1477 "IAL should stay attached to heading"
1478 );
1479 assert!(fixed.contains("{:.class}\n\nMore"), "Should add blank after IAL");
1480 }
1481
1482 #[test]
1483 fn test_kramdown_ial_fix_does_not_separate() {
1484 let rule = MD022BlanksAroundHeadings::default();
1485 let content = "# Heading\n{:.class}\nContent.";
1486 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1487 let fixed = rule.fix(&ctx).unwrap();
1488
1489 assert!(
1491 !fixed.contains("# Heading\n\n{:.class}"),
1492 "Should not add blank between heading and IAL"
1493 );
1494 assert!(fixed.contains("# Heading\n{:.class}"), "IAL should remain attached");
1495 }
1496
1497 #[test]
1498 fn test_kramdown_multiple_ial_lines() {
1499 let rule = MD022BlanksAroundHeadings::default();
1501 let content = "# Heading\n{:.class1}\n{:#id}\n\nContent.";
1502 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1503 let warnings = rule.check(&ctx).unwrap();
1504
1505 assert!(
1508 warnings.is_empty(),
1509 "Multiple consecutive IALs should be part of heading"
1510 );
1511 }
1512
1513 #[test]
1514 fn test_kramdown_ial_with_blank_line_not_attached() {
1515 let rule = MD022BlanksAroundHeadings::default();
1517 let content = "# Heading\n\n{:.class}\nContent.";
1518 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1519 let warnings = rule.check(&ctx).unwrap();
1520
1521 assert!(warnings.is_empty(), "Blank line separates heading from IAL");
1525 }
1526
1527 #[test]
1528 fn test_not_kramdown_ial_regular_braces() {
1529 let rule = MD022BlanksAroundHeadings::default();
1531 let content = "# Heading\n{not an ial}\n\nContent.";
1532 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1533 let warnings = rule.check(&ctx).unwrap();
1534
1535 assert_eq!(
1537 warnings.len(),
1538 1,
1539 "Non-IAL braces should be regular content requiring blank"
1540 );
1541 }
1542
1543 #[test]
1544 fn test_kramdown_ial_at_document_end() {
1545 let rule = MD022BlanksAroundHeadings::default();
1546 let content = "# Heading\n{:.class}";
1547 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1548 let warnings = rule.check(&ctx).unwrap();
1549
1550 assert!(warnings.is_empty(), "IAL at document end needs no blank after");
1552 }
1553
1554 #[test]
1555 fn test_kramdown_ial_followed_by_code_fence() {
1556 let rule = MD022BlanksAroundHeadings::default();
1557 let content = "# Heading\n{:.class}\n```\ncode\n```";
1558 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1559 let warnings = rule.check(&ctx).unwrap();
1560
1561 assert!(warnings.is_empty(), "No blank needed between IAL and code fence");
1563 }
1564
1565 #[test]
1566 fn test_kramdown_ial_followed_by_list() {
1567 let rule = MD022BlanksAroundHeadings::default();
1568 let content = "# Heading\n{:.class}\n- List item";
1569 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1570 let warnings = rule.check(&ctx).unwrap();
1571
1572 assert!(warnings.is_empty(), "No blank needed between IAL and list");
1574 }
1575
1576 #[test]
1577 fn test_kramdown_ial_fix_idempotent() {
1578 let rule = MD022BlanksAroundHeadings::default();
1579 let content = "# Heading\n{:.class}\nContent.";
1580 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1581
1582 let fixed_once = rule.fix(&ctx).unwrap();
1583 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
1584 let fixed_twice = rule.fix(&ctx2).unwrap();
1585
1586 assert_eq!(fixed_once, fixed_twice, "Fix should be idempotent");
1587 }
1588
1589 #[test]
1590 fn test_kramdown_ial_whitespace_line_between_not_attached() {
1591 let rule = MD022BlanksAroundHeadings::default();
1594 let content = "# Heading\n \n{:.class}\n\nContent.";
1595 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1596 let warnings = rule.check(&ctx).unwrap();
1597
1598 assert!(
1602 warnings.is_empty(),
1603 "Whitespace between heading and IAL means IAL is not attached"
1604 );
1605 }
1606
1607 #[test]
1608 fn test_kramdown_ial_html_comment_between() {
1609 let rule = MD022BlanksAroundHeadings::default();
1612 let content = "# Heading\n<!-- comment -->\n{:.class}\n\nContent.";
1613 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1614 let warnings = rule.check(&ctx).unwrap();
1615
1616 assert_eq!(
1620 warnings.len(),
1621 1,
1622 "IAL not attached when comment is between: {warnings:?}"
1623 );
1624 }
1625
1626 #[test]
1627 fn test_kramdown_ial_generic_attribute() {
1628 let rule = MD022BlanksAroundHeadings::default();
1629 let content = "# Heading\n{:data-toc=\"true\" style=\"color: red\"}\n\nContent.";
1630 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1631 let warnings = rule.check(&ctx).unwrap();
1632
1633 assert!(warnings.is_empty(), "Generic attributes should be recognized as IAL");
1634 }
1635
1636 #[test]
1637 fn test_kramdown_ial_fix_multiple_lines_preserves_all() {
1638 let rule = MD022BlanksAroundHeadings::default();
1639 let content = "# Heading\n{:.class1}\n{:#id}\n{:data-x=\"y\"}\nContent.";
1640 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1641
1642 let fixed = rule.fix(&ctx).unwrap();
1643
1644 assert!(fixed.contains("{:.class1}"), "First IAL should be preserved");
1646 assert!(fixed.contains("{:#id}"), "Second IAL should be preserved");
1647 assert!(fixed.contains("{:data-x=\"y\"}"), "Third IAL should be preserved");
1648 assert!(
1650 fixed.contains("{:data-x=\"y\"}\n\nContent"),
1651 "Blank line should be after all IALs"
1652 );
1653 }
1654
1655 #[test]
1656 fn test_kramdown_ial_crlf_line_endings() {
1657 let rule = MD022BlanksAroundHeadings::default();
1658 let content = "# Heading\r\n{:.class}\r\n\r\nContent.";
1659 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1660 let warnings = rule.check(&ctx).unwrap();
1661
1662 assert!(warnings.is_empty(), "CRLF should work correctly with IAL");
1663 }
1664
1665 #[test]
1666 fn test_kramdown_ial_invalid_patterns_not_recognized() {
1667 let rule = MD022BlanksAroundHeadings::default();
1668
1669 let content = "# Heading\n{ :.class}\n\nContent.";
1671 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1672 let warnings = rule.check(&ctx).unwrap();
1673 assert_eq!(warnings.len(), 1, "Invalid IAL syntax should trigger warning");
1674
1675 let content2 = "# Heading\n{.class}\n\nContent.";
1677 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1678 let warnings2 = rule.check(&ctx2).unwrap();
1679 assert!(warnings2.is_empty(), "{{.class}} is valid kramdown block attribute");
1681
1682 let content3 = "# Heading\n{just text}\n\nContent.";
1684 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
1685 let warnings3 = rule.check(&ctx3).unwrap();
1686 assert_eq!(
1687 warnings3.len(),
1688 1,
1689 "Text in braces is not IAL and should trigger warning"
1690 );
1691 }
1692
1693 #[test]
1694 fn test_kramdown_ial_toc_marker() {
1695 let rule = MD022BlanksAroundHeadings::default();
1697 let content = "# Heading\n{:toc}\n\nContent.";
1698 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1699 let warnings = rule.check(&ctx).unwrap();
1700
1701 assert!(warnings.is_empty(), "{{:toc}} should be recognized as IAL");
1703 }
1704
1705 #[test]
1706 fn test_kramdown_ial_mixed_headings_in_document() {
1707 let rule = MD022BlanksAroundHeadings::default();
1708 let content = r#"# ATX Heading
1709{:.atx-class}
1710
1711Content after ATX.
1712
1713Setext Heading
1714--------------
1715{:#setext-id}
1716
1717Content after Setext.
1718
1719## Another ATX
1720{:.another}
1721
1722More content."#;
1723 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1724 let warnings = rule.check(&ctx).unwrap();
1725
1726 assert!(
1727 warnings.is_empty(),
1728 "Mixed headings with IAL should all work: {warnings:?}"
1729 );
1730 }
1731
1732 #[test]
1735 fn test_quarto_div_marker_transparent_above_heading() {
1736 let rule = MD022BlanksAroundHeadings::default();
1739 let content = "Content\n\n::: {.callout-note}\n# Heading\n\nMore content\n:::\n";
1741 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1742 let warnings = rule.check(&ctx).unwrap();
1743 assert!(
1745 warnings.is_empty(),
1746 "Quarto div marker should be transparent above heading: {warnings:?}"
1747 );
1748 }
1749
1750 #[test]
1751 fn test_quarto_div_marker_transparent_below_heading() {
1752 let rule = MD022BlanksAroundHeadings::default();
1754 let content = "# Heading\n\n::: {.callout-note}\nContent\n:::\n";
1755 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1756 let warnings = rule.check(&ctx).unwrap();
1757 assert!(
1759 warnings.is_empty(),
1760 "Quarto div marker should be transparent below heading: {warnings:?}"
1761 );
1762 }
1763
1764 #[test]
1765 fn test_quarto_heading_inside_callout() {
1766 let rule = MD022BlanksAroundHeadings::default();
1768 let content = "::: {.callout-note}\n\n## Note Title\n\nNote content\n:::\n";
1769 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1770 let warnings = rule.check(&ctx).unwrap();
1771 assert!(
1772 warnings.is_empty(),
1773 "Heading inside Quarto callout should have no warnings: {warnings:?}"
1774 );
1775 }
1776
1777 #[test]
1778 fn test_quarto_heading_at_start_after_div_open() {
1779 let rule = MD022BlanksAroundHeadings::default();
1782 let content = "::: {.callout-warning}\n# Warning\n\nContent\n:::\n";
1784 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1785 let warnings = rule.check(&ctx).unwrap();
1786 assert!(
1792 warnings.is_empty(),
1793 "Heading at start after div open should pass: {warnings:?}"
1794 );
1795 }
1796
1797 #[test]
1798 fn test_quarto_heading_before_div_close() {
1799 let rule = MD022BlanksAroundHeadings::default();
1801 let content = "::: {.callout-note}\nIntro\n\n## Section\n:::\n";
1802 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1803 let warnings = rule.check(&ctx).unwrap();
1804 assert!(
1808 warnings.is_empty(),
1809 "Heading before div close should pass: {warnings:?}"
1810 );
1811 }
1812
1813 #[test]
1814 fn test_quarto_div_markers_not_transparent_in_standard_flavor() {
1815 let rule = MD022BlanksAroundHeadings::default();
1817 let content = "Content\n\n:::\n# Heading\n\n:::\n";
1818 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1819 let warnings = rule.check(&ctx).unwrap();
1820 assert!(
1822 !warnings.is_empty(),
1823 "Standard flavor should not treat ::: as transparent: {warnings:?}"
1824 );
1825 }
1826
1827 #[test]
1828 fn test_quarto_nested_divs_with_heading() {
1829 let rule = MD022BlanksAroundHeadings::default();
1831 let content = "::: {.outer}\n::: {.inner}\n\n# Heading\n\nContent\n:::\n:::\n";
1832 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1833 let warnings = rule.check(&ctx).unwrap();
1834 assert!(
1835 warnings.is_empty(),
1836 "Nested divs with heading should work: {warnings:?}"
1837 );
1838 }
1839
1840 #[test]
1841 fn test_quarto_fix_preserves_div_markers() {
1842 let rule = MD022BlanksAroundHeadings::default();
1844 let content = "::: {.callout-note}\n\n## Note\n\nContent\n:::\n";
1845 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1846 let fixed = rule.fix(&ctx).unwrap();
1847 assert!(fixed.contains("::: {.callout-note}"), "Should preserve div opening");
1849 assert!(fixed.contains(":::"), "Should preserve div closing");
1850 assert!(fixed.contains("## Note"), "Should preserve heading");
1851 }
1852
1853 #[test]
1854 fn test_quarto_heading_needs_blank_without_div_transparency() {
1855 let rule = MD022BlanksAroundHeadings::default();
1858 let content = "Content\n::: {.callout-note}\n# Heading\n\nMore\n:::\n";
1860 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1861 let warnings = rule.check(&ctx).unwrap();
1862 assert!(
1865 !warnings.is_empty(),
1866 "Should still require blank line when not present: {warnings:?}"
1867 );
1868 }
1869}