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 let heading = line_info.heading.as_ref().unwrap();
368
369 if !heading.is_valid {
371 continue;
372 }
373
374 let heading_level = heading.level as usize;
375
376 processed_headings.insert(line_num);
380
381 let is_first_heading = Some(line_num) == heading_at_start_idx;
383
384 let required_above_count = self.config.lines_above.get_for_level(heading_level).required_count();
386 let required_below_count = self.config.lines_below.get_for_level(heading_level).required_count();
387
388 let should_check_above =
390 required_above_count.is_some() && line_num > 0 && (!is_first_heading || !self.config.allowed_at_start);
391 if should_check_above {
392 let mut blank_lines_above = 0;
393 let mut hit_frontmatter_end = false;
394 for j in (0..line_num).rev() {
395 let line_content = ctx.lines[j].content(ctx.content);
396 let trimmed = line_content.trim();
397 if ctx.lines[j].is_blank {
398 blank_lines_above += 1;
399 } else if ctx.lines[j].in_html_comment || (trimmed.starts_with("<!--") && trimmed.ends_with("-->"))
400 {
401 continue;
403 } else if is_kramdown_block_attribute(trimmed) {
404 continue;
406 } else if is_quarto && (quarto_divs::is_div_open(trimmed) || quarto_divs::is_div_close(trimmed)) {
407 continue;
409 } else if ctx.lines[j].in_front_matter {
410 hit_frontmatter_end = true;
415 break;
416 } else {
417 break;
418 }
419 }
420 let required = required_above_count.unwrap();
421 if !hit_frontmatter_end && blank_lines_above < required {
422 let needed_blanks = required - blank_lines_above;
423 heading_violations.push((line_num, "above", needed_blanks, heading_level));
424 }
425 }
426
427 let mut effective_last_line = if matches!(
429 heading.style,
430 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
431 ) {
432 line_num + 1 } else {
434 line_num
435 };
436
437 while effective_last_line + 1 < ctx.lines.len() {
440 let next_line = &ctx.lines[effective_last_line + 1];
441 let next_trimmed = next_line.content(ctx.content).trim();
442 if is_kramdown_block_attribute(next_trimmed) {
443 effective_last_line += 1;
444 } else {
445 break;
446 }
447 }
448
449 if effective_last_line < ctx.lines.len() - 1 {
451 let mut next_non_blank_idx = effective_last_line + 1;
453 while next_non_blank_idx < ctx.lines.len() {
454 let check_line = &ctx.lines[next_non_blank_idx];
455 let check_trimmed = check_line.content(ctx.content).trim();
456 if check_line.is_blank {
457 next_non_blank_idx += 1;
458 } else if check_line.in_html_comment
459 || (check_trimmed.starts_with("<!--") && check_trimmed.ends_with("-->"))
460 {
461 next_non_blank_idx += 1;
463 } else if is_quarto
464 && (quarto_divs::is_div_open(check_trimmed) || quarto_divs::is_div_close(check_trimmed))
465 {
466 next_non_blank_idx += 1;
468 } else {
469 break;
470 }
471 }
472
473 if next_non_blank_idx >= ctx.lines.len() {
475 continue;
477 }
478
479 let next_line_is_special = {
481 let next_line = &ctx.lines[next_non_blank_idx];
482 let next_trimmed = next_line.content(ctx.content).trim();
483
484 let is_code_fence = (next_trimmed.starts_with("```") || next_trimmed.starts_with("~~~"))
486 && (next_trimmed.len() == 3
487 || (next_trimmed.len() > 3
488 && next_trimmed
489 .chars()
490 .nth(3)
491 .is_some_and(|c| c.is_whitespace() || c.is_alphabetic())));
492
493 let is_list_item = next_line.list_item.is_some();
495
496 is_code_fence || is_list_item
497 };
498
499 if !next_line_is_special && let Some(required) = required_below_count {
501 let mut blank_lines_below = 0;
503 for k in (effective_last_line + 1)..next_non_blank_idx {
504 if ctx.lines[k].is_blank {
505 blank_lines_below += 1;
506 }
507 }
508
509 if blank_lines_below < required {
510 let needed_blanks = required - blank_lines_below;
511 heading_violations.push((line_num, "below", needed_blanks, heading_level));
512 }
513 }
514 }
515 }
516
517 for (heading_line, position, needed_blanks, heading_level) in heading_violations {
519 let heading_display_line = heading_line + 1; let line_info = &ctx.lines[heading_line];
521
522 let (start_line, start_col, end_line, end_col) =
524 calculate_heading_range(heading_display_line, line_info.content(ctx.content));
525
526 let required_above_count = self
527 .config
528 .lines_above
529 .get_for_level(heading_level)
530 .required_count()
531 .expect("Violations only generated for limited 'above' requirements");
532 let required_below_count = self
533 .config
534 .lines_below
535 .get_for_level(heading_level)
536 .required_count()
537 .expect("Violations only generated for limited 'below' requirements");
538
539 let (message, insertion_point) = match position {
540 "above" => (
541 format!(
542 "Expected {} blank {} above heading",
543 required_above_count,
544 if required_above_count == 1 { "line" } else { "lines" }
545 ),
546 heading_line, ),
548 "below" => {
549 let insert_after = if line_info.heading.as_ref().is_some_and(|h| {
551 matches!(
552 h.style,
553 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
554 )
555 }) {
556 heading_line + 2
557 } else {
558 heading_line + 1
559 };
560
561 (
562 format!(
563 "Expected {} blank {} below heading",
564 required_below_count,
565 if required_below_count == 1 { "line" } else { "lines" }
566 ),
567 insert_after,
568 )
569 }
570 _ => continue,
571 };
572
573 let byte_range = if insertion_point == 0 && position == "above" {
575 0..0
577 } else if position == "above" && insertion_point > 0 {
578 ctx.lines[insertion_point].byte_offset..ctx.lines[insertion_point].byte_offset
580 } else if position == "below" && insertion_point - 1 < ctx.lines.len() {
581 let line_idx = insertion_point - 1;
583 let line_end_offset = if line_idx + 1 < ctx.lines.len() {
584 ctx.lines[line_idx + 1].byte_offset
585 } else {
586 ctx.content.len()
587 };
588 line_end_offset..line_end_offset
589 } else {
590 let content_len = ctx.content.len();
592 content_len..content_len
593 };
594
595 result.push(LintWarning {
596 rule_name: Some(self.name().to_string()),
597 message,
598 line: start_line,
599 column: start_col,
600 end_line,
601 end_column: end_col,
602 severity: Severity::Warning,
603 fix: Some(Fix {
604 range: byte_range,
605 replacement: line_ending.repeat(needed_blanks),
606 }),
607 });
608 }
609
610 Ok(result)
611 }
612
613 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
614 if ctx.content.is_empty() {
615 return Ok(ctx.content.to_string());
616 }
617
618 let fixed = self._fix_content(ctx);
620
621 Ok(fixed)
622 }
623
624 fn category(&self) -> RuleCategory {
626 RuleCategory::Heading
627 }
628
629 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
631 if ctx.content.is_empty() || !ctx.likely_has_headings() {
633 return true;
634 }
635 ctx.lines.iter().all(|line| line.heading.is_none())
637 }
638
639 fn as_any(&self) -> &dyn std::any::Any {
640 self
641 }
642
643 fn default_config_section(&self) -> Option<(String, toml::Value)> {
644 let default_config = MD022Config::default();
645 let json_value = serde_json::to_value(&default_config).ok()?;
646 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
647
648 if let toml::Value::Table(table) = toml_value {
649 if !table.is_empty() {
650 Some((MD022Config::RULE_NAME.to_string(), toml::Value::Table(table)))
651 } else {
652 None
653 }
654 } else {
655 None
656 }
657 }
658
659 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
660 where
661 Self: Sized,
662 {
663 let rule_config = crate::rule_config_serde::load_rule_config::<MD022Config>(config);
664 Box::new(Self::from_config_struct(rule_config))
665 }
666}
667
668#[cfg(test)]
669mod tests {
670 use super::*;
671 use crate::lint_context::LintContext;
672
673 #[test]
674 fn test_valid_headings() {
675 let rule = MD022BlanksAroundHeadings::default();
676 let content = "\n# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.\n";
677 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
678 let result = rule.check(&ctx).unwrap();
679 assert!(result.is_empty());
680 }
681
682 #[test]
683 fn test_missing_blank_above() {
684 let rule = MD022BlanksAroundHeadings::default();
685 let content = "# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.\n";
686 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
687 let result = rule.check(&ctx).unwrap();
688 assert_eq!(result.len(), 0); let fixed = rule.fix(&ctx).unwrap();
691
692 assert!(fixed.contains("# Heading 1"));
695 assert!(fixed.contains("Some content."));
696 assert!(fixed.contains("## Heading 2"));
697 assert!(fixed.contains("More content."));
698 }
699
700 #[test]
701 fn test_missing_blank_below() {
702 let rule = MD022BlanksAroundHeadings::default();
703 let content = "\n# Heading 1\nSome content.\n\n## Heading 2\n\nMore content.\n";
704 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
705 let result = rule.check(&ctx).unwrap();
706 assert_eq!(result.len(), 1);
707 assert_eq!(result[0].line, 2);
708
709 let fixed = rule.fix(&ctx).unwrap();
711 assert!(fixed.contains("# Heading 1\n\nSome content"));
712 }
713
714 #[test]
715 fn test_missing_blank_above_and_below() {
716 let rule = MD022BlanksAroundHeadings::default();
717 let content = "# Heading 1\nSome content.\n## Heading 2\nMore content.\n";
718 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
719 let result = rule.check(&ctx).unwrap();
720 assert_eq!(result.len(), 3); let fixed = rule.fix(&ctx).unwrap();
724 assert!(fixed.contains("# Heading 1\n\nSome content"));
725 assert!(fixed.contains("Some content.\n\n## Heading 2"));
726 assert!(fixed.contains("## Heading 2\n\nMore content"));
727 }
728
729 #[test]
730 fn test_fix_headings() {
731 let rule = MD022BlanksAroundHeadings::default();
732 let content = "# Heading 1\nSome content.\n## Heading 2\nMore content.";
733 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
734 let result = rule.fix(&ctx).unwrap();
735
736 let expected = "# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.";
737 assert_eq!(result, expected);
738 }
739
740 #[test]
741 fn test_consecutive_headings_pattern() {
742 let rule = MD022BlanksAroundHeadings::default();
743 let content = "# Heading 1\n## Heading 2\n### Heading 3";
744 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
745 let result = rule.fix(&ctx).unwrap();
746
747 let lines: Vec<&str> = result.lines().collect();
749 assert!(!lines.is_empty());
750
751 let h1_pos = lines.iter().position(|&l| l == "# Heading 1").unwrap();
753 let h2_pos = lines.iter().position(|&l| l == "## Heading 2").unwrap();
754 let h3_pos = lines.iter().position(|&l| l == "### Heading 3").unwrap();
755
756 assert!(
758 h2_pos > h1_pos + 1,
759 "Should have at least one blank line after first heading"
760 );
761 assert!(
762 h3_pos > h2_pos + 1,
763 "Should have at least one blank line after second heading"
764 );
765
766 assert!(lines[h1_pos + 1].trim().is_empty(), "Line after h1 should be blank");
768
769 assert!(lines[h2_pos + 1].trim().is_empty(), "Line after h2 should be blank");
771 }
772
773 #[test]
774 fn test_blanks_around_setext_headings() {
775 let rule = MD022BlanksAroundHeadings::default();
776 let content = "Heading 1\n=========\nSome content.\nHeading 2\n---------\nMore content.";
777 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
778 let result = rule.fix(&ctx).unwrap();
779
780 let lines: Vec<&str> = result.lines().collect();
782
783 assert!(result.contains("Heading 1"));
785 assert!(result.contains("========="));
786 assert!(result.contains("Some content."));
787 assert!(result.contains("Heading 2"));
788 assert!(result.contains("---------"));
789 assert!(result.contains("More content."));
790
791 let heading1_marker_idx = lines.iter().position(|&l| l == "=========").unwrap();
793 let some_content_idx = lines.iter().position(|&l| l == "Some content.").unwrap();
794 assert!(
795 some_content_idx > heading1_marker_idx + 1,
796 "Should have a blank line after the first heading"
797 );
798
799 let heading2_marker_idx = lines.iter().position(|&l| l == "---------").unwrap();
800 let more_content_idx = lines.iter().position(|&l| l == "More content.").unwrap();
801 assert!(
802 more_content_idx > heading2_marker_idx + 1,
803 "Should have a blank line after the second heading"
804 );
805
806 let fixed_ctx = LintContext::new(&result, crate::config::MarkdownFlavor::Standard, None);
808 let fixed_warnings = rule.check(&fixed_ctx).unwrap();
809 assert!(fixed_warnings.is_empty(), "Fixed content should have no warnings");
810 }
811
812 #[test]
813 fn test_fix_specific_blank_line_cases() {
814 let rule = MD022BlanksAroundHeadings::default();
815
816 let content1 = "# Heading 1\n## Heading 2\n### Heading 3";
818 let ctx1 = LintContext::new(content1, crate::config::MarkdownFlavor::Standard, None);
819 let result1 = rule.fix(&ctx1).unwrap();
820 assert!(result1.contains("# Heading 1"));
822 assert!(result1.contains("## Heading 2"));
823 assert!(result1.contains("### Heading 3"));
824 let lines: Vec<&str> = result1.lines().collect();
826 let h1_pos = lines.iter().position(|&l| l == "# Heading 1").unwrap();
827 let h2_pos = lines.iter().position(|&l| l == "## Heading 2").unwrap();
828 assert!(lines[h1_pos + 1].trim().is_empty(), "Should have a blank line after h1");
829 assert!(lines[h2_pos + 1].trim().is_empty(), "Should have a blank line after h2");
830
831 let content2 = "# Heading 1\nContent under heading 1\n## Heading 2";
833 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
834 let result2 = rule.fix(&ctx2).unwrap();
835 assert!(result2.contains("# Heading 1"));
837 assert!(result2.contains("Content under heading 1"));
838 assert!(result2.contains("## Heading 2"));
839 let lines2: Vec<&str> = result2.lines().collect();
841 let h1_pos2 = lines2.iter().position(|&l| l == "# Heading 1").unwrap();
842 let _content_pos = lines2.iter().position(|&l| l == "Content under heading 1").unwrap();
843 assert!(
844 lines2[h1_pos2 + 1].trim().is_empty(),
845 "Should have a blank line after heading 1"
846 );
847
848 let content3 = "# Heading 1\n\n\n## Heading 2\n\n\n### Heading 3\n\nContent";
850 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
851 let result3 = rule.fix(&ctx3).unwrap();
852 assert!(result3.contains("# Heading 1"));
854 assert!(result3.contains("## Heading 2"));
855 assert!(result3.contains("### Heading 3"));
856 assert!(result3.contains("Content"));
857 }
858
859 #[test]
860 fn test_fix_preserves_existing_blank_lines() {
861 let rule = MD022BlanksAroundHeadings::new();
862 let content = "# Title
863
864## Section 1
865
866Content here.
867
868## Section 2
869
870More content.
871### Missing Blank Above
872
873Even more content.
874
875## Section 3
876
877Final content.";
878
879 let expected = "# Title
880
881## Section 1
882
883Content here.
884
885## Section 2
886
887More content.
888
889### Missing Blank Above
890
891Even more content.
892
893## Section 3
894
895Final content.";
896
897 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
898 let result = rule._fix_content(&ctx);
899 assert_eq!(
900 result, expected,
901 "Fix should only add missing blank lines, never remove existing ones"
902 );
903 }
904
905 #[test]
906 fn test_fix_preserves_trailing_newline() {
907 let rule = MD022BlanksAroundHeadings::new();
908
909 let content_with_newline = "# Title\nContent here.\n";
911 let ctx = LintContext::new(content_with_newline, crate::config::MarkdownFlavor::Standard, None);
912 let result = rule.fix(&ctx).unwrap();
913 assert!(result.ends_with('\n'), "Should preserve trailing newline");
914
915 let content_without_newline = "# Title\nContent here.";
917 let ctx = LintContext::new(content_without_newline, crate::config::MarkdownFlavor::Standard, None);
918 let result = rule.fix(&ctx).unwrap();
919 assert!(
920 !result.ends_with('\n'),
921 "Should not add trailing newline if original didn't have one"
922 );
923 }
924
925 #[test]
926 fn test_fix_does_not_add_blank_lines_before_lists() {
927 let rule = MD022BlanksAroundHeadings::new();
928 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.";
929
930 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.";
931
932 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
933 let result = rule._fix_content(&ctx);
934 assert_eq!(result, expected, "Fix should not add blank lines before lists");
935 }
936
937 #[test]
938 fn test_per_level_configuration_no_blank_above_h1() {
939 use md022_config::HeadingLevelConfig;
940
941 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
943 lines_above: HeadingLevelConfig::per_level([0, 1, 1, 1, 1, 1]),
944 lines_below: HeadingLevelConfig::scalar(1),
945 allowed_at_start: false, });
947
948 let content = "Some text\n# Heading 1\n\nMore text";
950 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
951 let warnings = rule.check(&ctx).unwrap();
952 assert_eq!(warnings.len(), 0, "H1 without blank above should not trigger warning");
953
954 let content = "Some text\n## Heading 2\n\nMore text";
956 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
957 let warnings = rule.check(&ctx).unwrap();
958 assert_eq!(warnings.len(), 1, "H2 without blank above should trigger warning");
959 assert!(warnings[0].message.contains("above"));
960 }
961
962 #[test]
963 fn test_per_level_configuration_different_requirements() {
964 use md022_config::HeadingLevelConfig;
965
966 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
968 lines_above: HeadingLevelConfig::per_level([0, 1, 1, 2, 2, 2]),
969 lines_below: HeadingLevelConfig::scalar(1),
970 allowed_at_start: false,
971 });
972
973 let content = "Text\n# H1\n\nText\n\n## H2\n\nText\n\n### H3\n\nText\n\n\n#### H4\n\nText";
974 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
975 let warnings = rule.check(&ctx).unwrap();
976
977 assert_eq!(
979 warnings.len(),
980 0,
981 "All headings should satisfy level-specific requirements"
982 );
983 }
984
985 #[test]
986 fn test_per_level_configuration_violations() {
987 use md022_config::HeadingLevelConfig;
988
989 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
991 lines_above: HeadingLevelConfig::per_level([1, 1, 1, 2, 1, 1]),
992 lines_below: HeadingLevelConfig::scalar(1),
993 allowed_at_start: false,
994 });
995
996 let content = "Text\n\n#### Heading 4\n\nMore text";
998 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
999 let warnings = rule.check(&ctx).unwrap();
1000
1001 assert_eq!(warnings.len(), 1, "H4 with insufficient blanks should trigger warning");
1002 assert!(warnings[0].message.contains("2 blank lines above"));
1003 }
1004
1005 #[test]
1006 fn test_per_level_fix_different_levels() {
1007 use md022_config::HeadingLevelConfig;
1008
1009 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1011 lines_above: HeadingLevelConfig::per_level([0, 1, 2, 2, 2, 2]),
1012 lines_below: HeadingLevelConfig::scalar(1),
1013 allowed_at_start: false,
1014 });
1015
1016 let content = "Text\n# H1\nContent\n## H2\nContent\n### H3\nContent";
1017 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1018 let fixed = rule.fix(&ctx).unwrap();
1019
1020 assert!(fixed.contains("Text\n# H1\n\nContent"));
1022 assert!(fixed.contains("Content\n\n## H2\n\nContent"));
1023 assert!(fixed.contains("Content\n\n\n### H3\n\nContent"));
1024 }
1025
1026 #[test]
1027 fn test_per_level_below_configuration() {
1028 use md022_config::HeadingLevelConfig;
1029
1030 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1032 lines_above: HeadingLevelConfig::scalar(1),
1033 lines_below: HeadingLevelConfig::per_level([2, 1, 1, 1, 1, 1]), allowed_at_start: true,
1035 });
1036
1037 let content = "# Heading 1\n\nSome text";
1039 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1040 let warnings = rule.check(&ctx).unwrap();
1041
1042 assert_eq!(
1043 warnings.len(),
1044 1,
1045 "H1 with insufficient blanks below should trigger warning"
1046 );
1047 assert!(warnings[0].message.contains("2 blank lines below"));
1048 }
1049
1050 #[test]
1051 fn test_scalar_configuration_still_works() {
1052 use md022_config::HeadingLevelConfig;
1053
1054 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1056 lines_above: HeadingLevelConfig::scalar(2),
1057 lines_below: HeadingLevelConfig::scalar(2),
1058 allowed_at_start: false,
1059 });
1060
1061 let content = "Text\n# H1\nContent\n## H2\nContent";
1062 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1063 let warnings = rule.check(&ctx).unwrap();
1064
1065 assert!(!warnings.is_empty(), "Should have violations for insufficient blanks");
1067 }
1068
1069 #[test]
1070 fn test_unlimited_configuration_skips_requirements() {
1071 use md022_config::{HeadingBlankRequirement, HeadingLevelConfig};
1072
1073 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1075 lines_above: HeadingLevelConfig::per_level_requirements([
1076 HeadingBlankRequirement::unlimited(),
1077 HeadingBlankRequirement::limited(1),
1078 HeadingBlankRequirement::limited(1),
1079 HeadingBlankRequirement::limited(1),
1080 HeadingBlankRequirement::limited(1),
1081 HeadingBlankRequirement::limited(1),
1082 ]),
1083 lines_below: HeadingLevelConfig::per_level_requirements([
1084 HeadingBlankRequirement::unlimited(),
1085 HeadingBlankRequirement::limited(1),
1086 HeadingBlankRequirement::limited(1),
1087 HeadingBlankRequirement::limited(1),
1088 HeadingBlankRequirement::limited(1),
1089 HeadingBlankRequirement::limited(1),
1090 ]),
1091 allowed_at_start: false,
1092 });
1093
1094 let content = "# H1\nParagraph\n## H2\nParagraph";
1095 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1096 let warnings = rule.check(&ctx).unwrap();
1097
1098 assert_eq!(warnings.len(), 2, "Only non-unlimited headings should warn");
1100 assert!(
1101 warnings.iter().all(|w| w.line >= 3),
1102 "Warnings should target later headings"
1103 );
1104
1105 let fixed = rule.fix(&ctx).unwrap();
1107 assert!(
1108 fixed.starts_with("# H1\nParagraph\n\n## H2"),
1109 "H1 should remain unchanged"
1110 );
1111 }
1112
1113 #[test]
1114 fn test_html_comment_transparency() {
1115 let rule = MD022BlanksAroundHeadings::default();
1119
1120 let content = "Some content\n\n<!-- markdownlint-disable-next-line MD001 -->\n#### Heading";
1123 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1124 let warnings = rule.check(&ctx).unwrap();
1125 assert!(
1126 warnings.is_empty(),
1127 "HTML comment is transparent - blank line above it counts for heading"
1128 );
1129
1130 let content_multiline = "Some content\n\n<!-- This is a\nmulti-line comment -->\n#### Heading";
1132 let ctx_multiline = LintContext::new(content_multiline, crate::config::MarkdownFlavor::Standard, None);
1133 let warnings_multiline = rule.check(&ctx_multiline).unwrap();
1134 assert!(
1135 warnings_multiline.is_empty(),
1136 "Multi-line HTML comment is also transparent"
1137 );
1138 }
1139
1140 #[test]
1141 fn test_frontmatter_transparency() {
1142 let rule = MD022BlanksAroundHeadings::default();
1145
1146 let content = "---\ntitle: Test\n---\n# First heading";
1148 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1149 let warnings = rule.check(&ctx).unwrap();
1150 assert!(
1151 warnings.is_empty(),
1152 "Frontmatter is transparent - heading can appear immediately after"
1153 );
1154
1155 let content_with_blank = "---\ntitle: Test\n---\n\n# First heading";
1157 let ctx_with_blank = LintContext::new(content_with_blank, crate::config::MarkdownFlavor::Standard, None);
1158 let warnings_with_blank = rule.check(&ctx_with_blank).unwrap();
1159 assert!(
1160 warnings_with_blank.is_empty(),
1161 "Heading with blank line after frontmatter should also be valid"
1162 );
1163
1164 let content_toml = "+++\ntitle = \"Test\"\n+++\n# First heading";
1166 let ctx_toml = LintContext::new(content_toml, crate::config::MarkdownFlavor::Standard, None);
1167 let warnings_toml = rule.check(&ctx_toml).unwrap();
1168 assert!(
1169 warnings_toml.is_empty(),
1170 "TOML frontmatter is also transparent for MD022"
1171 );
1172 }
1173
1174 #[test]
1175 fn test_horizontal_rule_not_treated_as_frontmatter() {
1176 let rule = MD022BlanksAroundHeadings::default();
1179
1180 let content = "Some content\n\n---\n# Heading after HR";
1182 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1183 let warnings = rule.check(&ctx).unwrap();
1184 assert!(
1185 !warnings.is_empty(),
1186 "Heading after horizontal rule without blank line SHOULD trigger MD022"
1187 );
1188 assert!(
1189 warnings.iter().any(|w| w.line == 4),
1190 "Warning should be on line 4 (the heading line)"
1191 );
1192
1193 let content_with_blank = "Some content\n\n---\n\n# Heading after HR";
1195 let ctx_with_blank = LintContext::new(content_with_blank, crate::config::MarkdownFlavor::Standard, None);
1196 let warnings_with_blank = rule.check(&ctx_with_blank).unwrap();
1197 assert!(
1198 warnings_with_blank.is_empty(),
1199 "Heading with blank line after HR should not trigger MD022"
1200 );
1201
1202 let content_hr_start = "---\n# Heading";
1204 let ctx_hr_start = LintContext::new(content_hr_start, crate::config::MarkdownFlavor::Standard, None);
1205 let warnings_hr_start = rule.check(&ctx_hr_start).unwrap();
1206 assert!(
1207 !warnings_hr_start.is_empty(),
1208 "Heading after HR at document start SHOULD trigger MD022"
1209 );
1210
1211 let content_multi_hr = "Content\n\n---\n\n---\n# Heading";
1213 let ctx_multi_hr = LintContext::new(content_multi_hr, crate::config::MarkdownFlavor::Standard, None);
1214 let warnings_multi_hr = rule.check(&ctx_multi_hr).unwrap();
1215 assert!(
1216 !warnings_multi_hr.is_empty(),
1217 "Heading after multiple HRs without blank line SHOULD trigger MD022"
1218 );
1219 }
1220
1221 #[test]
1222 fn test_all_hr_styles_require_blank_before_heading() {
1223 let rule = MD022BlanksAroundHeadings::default();
1225
1226 let hr_styles = [
1228 "---", "***", "___", "- - -", "* * *", "_ _ _", "----", "****", "____", "- - - -",
1229 "- - -", " ---", " ---", ];
1233
1234 for hr in hr_styles {
1235 let content = format!("Content\n\n{hr}\n# Heading");
1236 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1237 let warnings = rule.check(&ctx).unwrap();
1238 assert!(
1239 !warnings.is_empty(),
1240 "HR style '{hr}' followed by heading should trigger MD022"
1241 );
1242 }
1243 }
1244
1245 #[test]
1246 fn test_setext_heading_after_hr() {
1247 let rule = MD022BlanksAroundHeadings::default();
1249
1250 let content = "Content\n\n---\nHeading\n======";
1252 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1253 let warnings = rule.check(&ctx).unwrap();
1254 assert!(
1255 !warnings.is_empty(),
1256 "Setext heading after HR without blank should trigger MD022"
1257 );
1258
1259 let content_h2 = "Content\n\n---\nHeading\n------";
1261 let ctx_h2 = LintContext::new(content_h2, crate::config::MarkdownFlavor::Standard, None);
1262 let warnings_h2 = rule.check(&ctx_h2).unwrap();
1263 assert!(
1264 !warnings_h2.is_empty(),
1265 "Setext h2 after HR without blank should trigger MD022"
1266 );
1267
1268 let content_ok = "Content\n\n---\n\nHeading\n======";
1270 let ctx_ok = LintContext::new(content_ok, crate::config::MarkdownFlavor::Standard, None);
1271 let warnings_ok = rule.check(&ctx_ok).unwrap();
1272 assert!(
1273 warnings_ok.is_empty(),
1274 "Setext heading with blank after HR should not warn"
1275 );
1276 }
1277
1278 #[test]
1279 fn test_hr_in_code_block_not_treated_as_hr() {
1280 let rule = MD022BlanksAroundHeadings::default();
1282
1283 let content = "```\n---\n```\n# Heading";
1286 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1287 let warnings = rule.check(&ctx).unwrap();
1288 assert!(!warnings.is_empty(), "Heading after code block still needs blank line");
1291
1292 let content_ok = "```\n---\n```\n\n# Heading";
1294 let ctx_ok = LintContext::new(content_ok, crate::config::MarkdownFlavor::Standard, None);
1295 let warnings_ok = rule.check(&ctx_ok).unwrap();
1296 assert!(
1297 warnings_ok.is_empty(),
1298 "Heading with blank after code block should not warn"
1299 );
1300 }
1301
1302 #[test]
1303 fn test_hr_in_html_comment_not_treated_as_hr() {
1304 let rule = MD022BlanksAroundHeadings::default();
1306
1307 let content = "<!-- \n---\n -->\n# Heading";
1309 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1310 let warnings = rule.check(&ctx).unwrap();
1311 assert!(
1313 warnings.is_empty(),
1314 "HR inside HTML comment should be ignored - heading after comment is OK"
1315 );
1316 }
1317
1318 #[test]
1319 fn test_invalid_hr_not_triggering() {
1320 let rule = MD022BlanksAroundHeadings::default();
1322
1323 let invalid_hrs = [
1324 " ---", "\t---", "--", "**", "__", "-*-", "---a", "a---", ];
1333
1334 for invalid in invalid_hrs {
1335 let content = format!("Content\n\n{invalid}\n# Heading");
1338 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1339 let _ = rule.check(&ctx);
1342 }
1343 }
1344
1345 #[test]
1346 fn test_frontmatter_vs_horizontal_rule_distinction() {
1347 let rule = MD022BlanksAroundHeadings::default();
1349
1350 let content = "---\ntitle: Test\n---\n\nSome content\n\n---\n# Heading after HR";
1353 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1354 let warnings = rule.check(&ctx).unwrap();
1355 assert!(
1356 !warnings.is_empty(),
1357 "HR after frontmatter content should still require blank line before heading"
1358 );
1359
1360 let content_ok = "---\ntitle: Test\n---\n\nSome content\n\n---\n\n# Heading after HR";
1362 let ctx_ok = LintContext::new(content_ok, crate::config::MarkdownFlavor::Standard, None);
1363 let warnings_ok = rule.check(&ctx_ok).unwrap();
1364 assert!(
1365 warnings_ok.is_empty(),
1366 "HR with blank line before heading should not warn"
1367 );
1368 }
1369
1370 #[test]
1373 fn test_kramdown_ial_after_heading_no_warning() {
1374 let rule = MD022BlanksAroundHeadings::default();
1376 let content = "## Table of Contents\n{: .hhc-toc-heading}\n\nSome content here.";
1377 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1378 let warnings = rule.check(&ctx).unwrap();
1379
1380 assert!(
1381 warnings.is_empty(),
1382 "IAL after heading should not require blank line between them: {warnings:?}"
1383 );
1384 }
1385
1386 #[test]
1387 fn test_kramdown_ial_with_class() {
1388 let rule = MD022BlanksAroundHeadings::default();
1389 let content = "# Heading\n{:.highlight}\n\nContent.";
1390 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1391 let warnings = rule.check(&ctx).unwrap();
1392
1393 assert!(warnings.is_empty(), "IAL with class should be part of heading");
1394 }
1395
1396 #[test]
1397 fn test_kramdown_ial_with_id() {
1398 let rule = MD022BlanksAroundHeadings::default();
1399 let content = "# Heading\n{:#custom-id}\n\nContent.";
1400 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1401 let warnings = rule.check(&ctx).unwrap();
1402
1403 assert!(warnings.is_empty(), "IAL with id should be part of heading");
1404 }
1405
1406 #[test]
1407 fn test_kramdown_ial_with_multiple_attributes() {
1408 let rule = MD022BlanksAroundHeadings::default();
1409 let content = "# Heading\n{: .class #id style=\"color: red\"}\n\nContent.";
1410 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1411 let warnings = rule.check(&ctx).unwrap();
1412
1413 assert!(
1414 warnings.is_empty(),
1415 "IAL with multiple attributes should be part of heading"
1416 );
1417 }
1418
1419 #[test]
1420 fn test_kramdown_ial_missing_blank_after() {
1421 let rule = MD022BlanksAroundHeadings::default();
1423 let content = "# Heading\n{:.class}\nContent without blank.";
1424 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1425 let warnings = rule.check(&ctx).unwrap();
1426
1427 assert_eq!(
1428 warnings.len(),
1429 1,
1430 "Should warn about missing blank after IAL (part of heading)"
1431 );
1432 assert!(warnings[0].message.contains("below"));
1433 }
1434
1435 #[test]
1436 fn test_kramdown_ial_before_heading_transparent() {
1437 let rule = MD022BlanksAroundHeadings::default();
1439 let content = "Content.\n\n{:.preclass}\n## Heading\n\nMore content.";
1440 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1441 let warnings = rule.check(&ctx).unwrap();
1442
1443 assert!(
1444 warnings.is_empty(),
1445 "IAL before heading should be transparent for blank line count"
1446 );
1447 }
1448
1449 #[test]
1450 fn test_kramdown_ial_setext_heading() {
1451 let rule = MD022BlanksAroundHeadings::default();
1452 let content = "Heading\n=======\n{:.setext-class}\n\nContent.";
1453 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1454 let warnings = rule.check(&ctx).unwrap();
1455
1456 assert!(
1457 warnings.is_empty(),
1458 "IAL after Setext heading should be part of heading"
1459 );
1460 }
1461
1462 #[test]
1463 fn test_kramdown_ial_fix_preserves_ial() {
1464 let rule = MD022BlanksAroundHeadings::default();
1465 let content = "Content.\n# Heading\n{:.class}\nMore content.";
1466 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1467 let fixed = rule.fix(&ctx).unwrap();
1468
1469 assert!(
1471 fixed.contains("# Heading\n{:.class}"),
1472 "IAL should stay attached to heading"
1473 );
1474 assert!(fixed.contains("{:.class}\n\nMore"), "Should add blank after IAL");
1475 }
1476
1477 #[test]
1478 fn test_kramdown_ial_fix_does_not_separate() {
1479 let rule = MD022BlanksAroundHeadings::default();
1480 let content = "# Heading\n{:.class}\nContent.";
1481 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1482 let fixed = rule.fix(&ctx).unwrap();
1483
1484 assert!(
1486 !fixed.contains("# Heading\n\n{:.class}"),
1487 "Should not add blank between heading and IAL"
1488 );
1489 assert!(fixed.contains("# Heading\n{:.class}"), "IAL should remain attached");
1490 }
1491
1492 #[test]
1493 fn test_kramdown_multiple_ial_lines() {
1494 let rule = MD022BlanksAroundHeadings::default();
1496 let content = "# Heading\n{:.class1}\n{:#id}\n\nContent.";
1497 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1498 let warnings = rule.check(&ctx).unwrap();
1499
1500 assert!(
1503 warnings.is_empty(),
1504 "Multiple consecutive IALs should be part of heading"
1505 );
1506 }
1507
1508 #[test]
1509 fn test_kramdown_ial_with_blank_line_not_attached() {
1510 let rule = MD022BlanksAroundHeadings::default();
1512 let content = "# Heading\n\n{:.class}\nContent.";
1513 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1514 let warnings = rule.check(&ctx).unwrap();
1515
1516 assert!(warnings.is_empty(), "Blank line separates heading from IAL");
1520 }
1521
1522 #[test]
1523 fn test_not_kramdown_ial_regular_braces() {
1524 let rule = MD022BlanksAroundHeadings::default();
1526 let content = "# Heading\n{not an ial}\n\nContent.";
1527 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1528 let warnings = rule.check(&ctx).unwrap();
1529
1530 assert_eq!(
1532 warnings.len(),
1533 1,
1534 "Non-IAL braces should be regular content requiring blank"
1535 );
1536 }
1537
1538 #[test]
1539 fn test_kramdown_ial_at_document_end() {
1540 let rule = MD022BlanksAroundHeadings::default();
1541 let content = "# Heading\n{:.class}";
1542 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1543 let warnings = rule.check(&ctx).unwrap();
1544
1545 assert!(warnings.is_empty(), "IAL at document end needs no blank after");
1547 }
1548
1549 #[test]
1550 fn test_kramdown_ial_followed_by_code_fence() {
1551 let rule = MD022BlanksAroundHeadings::default();
1552 let content = "# Heading\n{:.class}\n```\ncode\n```";
1553 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1554 let warnings = rule.check(&ctx).unwrap();
1555
1556 assert!(warnings.is_empty(), "No blank needed between IAL and code fence");
1558 }
1559
1560 #[test]
1561 fn test_kramdown_ial_followed_by_list() {
1562 let rule = MD022BlanksAroundHeadings::default();
1563 let content = "# Heading\n{:.class}\n- List item";
1564 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1565 let warnings = rule.check(&ctx).unwrap();
1566
1567 assert!(warnings.is_empty(), "No blank needed between IAL and list");
1569 }
1570
1571 #[test]
1572 fn test_kramdown_ial_fix_idempotent() {
1573 let rule = MD022BlanksAroundHeadings::default();
1574 let content = "# Heading\n{:.class}\nContent.";
1575 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1576
1577 let fixed_once = rule.fix(&ctx).unwrap();
1578 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
1579 let fixed_twice = rule.fix(&ctx2).unwrap();
1580
1581 assert_eq!(fixed_once, fixed_twice, "Fix should be idempotent");
1582 }
1583
1584 #[test]
1585 fn test_kramdown_ial_whitespace_line_between_not_attached() {
1586 let rule = MD022BlanksAroundHeadings::default();
1589 let content = "# Heading\n \n{:.class}\n\nContent.";
1590 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1591 let warnings = rule.check(&ctx).unwrap();
1592
1593 assert!(
1597 warnings.is_empty(),
1598 "Whitespace between heading and IAL means IAL is not attached"
1599 );
1600 }
1601
1602 #[test]
1603 fn test_kramdown_ial_html_comment_between() {
1604 let rule = MD022BlanksAroundHeadings::default();
1607 let content = "# Heading\n<!-- comment -->\n{:.class}\n\nContent.";
1608 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1609 let warnings = rule.check(&ctx).unwrap();
1610
1611 assert_eq!(
1615 warnings.len(),
1616 1,
1617 "IAL not attached when comment is between: {warnings:?}"
1618 );
1619 }
1620
1621 #[test]
1622 fn test_kramdown_ial_generic_attribute() {
1623 let rule = MD022BlanksAroundHeadings::default();
1624 let content = "# Heading\n{:data-toc=\"true\" style=\"color: red\"}\n\nContent.";
1625 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1626 let warnings = rule.check(&ctx).unwrap();
1627
1628 assert!(warnings.is_empty(), "Generic attributes should be recognized as IAL");
1629 }
1630
1631 #[test]
1632 fn test_kramdown_ial_fix_multiple_lines_preserves_all() {
1633 let rule = MD022BlanksAroundHeadings::default();
1634 let content = "# Heading\n{:.class1}\n{:#id}\n{:data-x=\"y\"}\nContent.";
1635 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1636
1637 let fixed = rule.fix(&ctx).unwrap();
1638
1639 assert!(fixed.contains("{:.class1}"), "First IAL should be preserved");
1641 assert!(fixed.contains("{:#id}"), "Second IAL should be preserved");
1642 assert!(fixed.contains("{:data-x=\"y\"}"), "Third IAL should be preserved");
1643 assert!(
1645 fixed.contains("{:data-x=\"y\"}\n\nContent"),
1646 "Blank line should be after all IALs"
1647 );
1648 }
1649
1650 #[test]
1651 fn test_kramdown_ial_crlf_line_endings() {
1652 let rule = MD022BlanksAroundHeadings::default();
1653 let content = "# Heading\r\n{:.class}\r\n\r\nContent.";
1654 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1655 let warnings = rule.check(&ctx).unwrap();
1656
1657 assert!(warnings.is_empty(), "CRLF should work correctly with IAL");
1658 }
1659
1660 #[test]
1661 fn test_kramdown_ial_invalid_patterns_not_recognized() {
1662 let rule = MD022BlanksAroundHeadings::default();
1663
1664 let content = "# Heading\n{ :.class}\n\nContent.";
1666 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1667 let warnings = rule.check(&ctx).unwrap();
1668 assert_eq!(warnings.len(), 1, "Invalid IAL syntax should trigger warning");
1669
1670 let content2 = "# Heading\n{.class}\n\nContent.";
1672 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1673 let warnings2 = rule.check(&ctx2).unwrap();
1674 assert!(warnings2.is_empty(), "{{.class}} is valid kramdown block attribute");
1676
1677 let content3 = "# Heading\n{just text}\n\nContent.";
1679 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
1680 let warnings3 = rule.check(&ctx3).unwrap();
1681 assert_eq!(
1682 warnings3.len(),
1683 1,
1684 "Text in braces is not IAL and should trigger warning"
1685 );
1686 }
1687
1688 #[test]
1689 fn test_kramdown_ial_toc_marker() {
1690 let rule = MD022BlanksAroundHeadings::default();
1692 let content = "# Heading\n{:toc}\n\nContent.";
1693 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1694 let warnings = rule.check(&ctx).unwrap();
1695
1696 assert!(warnings.is_empty(), "{{:toc}} should be recognized as IAL");
1698 }
1699
1700 #[test]
1701 fn test_kramdown_ial_mixed_headings_in_document() {
1702 let rule = MD022BlanksAroundHeadings::default();
1703 let content = r#"# ATX Heading
1704{:.atx-class}
1705
1706Content after ATX.
1707
1708Setext Heading
1709--------------
1710{:#setext-id}
1711
1712Content after Setext.
1713
1714## Another ATX
1715{:.another}
1716
1717More content."#;
1718 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1719 let warnings = rule.check(&ctx).unwrap();
1720
1721 assert!(
1722 warnings.is_empty(),
1723 "Mixed headings with IAL should all work: {warnings:?}"
1724 );
1725 }
1726
1727 #[test]
1730 fn test_quarto_div_marker_transparent_above_heading() {
1731 let rule = MD022BlanksAroundHeadings::default();
1734 let content = "Content\n\n::: {.callout-note}\n# Heading\n\nMore content\n:::\n";
1736 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1737 let warnings = rule.check(&ctx).unwrap();
1738 assert!(
1740 warnings.is_empty(),
1741 "Quarto div marker should be transparent above heading: {warnings:?}"
1742 );
1743 }
1744
1745 #[test]
1746 fn test_quarto_div_marker_transparent_below_heading() {
1747 let rule = MD022BlanksAroundHeadings::default();
1749 let content = "# Heading\n\n::: {.callout-note}\nContent\n:::\n";
1750 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1751 let warnings = rule.check(&ctx).unwrap();
1752 assert!(
1754 warnings.is_empty(),
1755 "Quarto div marker should be transparent below heading: {warnings:?}"
1756 );
1757 }
1758
1759 #[test]
1760 fn test_quarto_heading_inside_callout() {
1761 let rule = MD022BlanksAroundHeadings::default();
1763 let content = "::: {.callout-note}\n\n## Note Title\n\nNote content\n:::\n";
1764 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1765 let warnings = rule.check(&ctx).unwrap();
1766 assert!(
1767 warnings.is_empty(),
1768 "Heading inside Quarto callout should have no warnings: {warnings:?}"
1769 );
1770 }
1771
1772 #[test]
1773 fn test_quarto_heading_at_start_after_div_open() {
1774 let rule = MD022BlanksAroundHeadings::default();
1777 let content = "::: {.callout-warning}\n# Warning\n\nContent\n:::\n";
1779 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1780 let warnings = rule.check(&ctx).unwrap();
1781 assert!(
1787 warnings.is_empty(),
1788 "Heading at start after div open should pass: {warnings:?}"
1789 );
1790 }
1791
1792 #[test]
1793 fn test_quarto_heading_before_div_close() {
1794 let rule = MD022BlanksAroundHeadings::default();
1796 let content = "::: {.callout-note}\nIntro\n\n## Section\n:::\n";
1797 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1798 let warnings = rule.check(&ctx).unwrap();
1799 assert!(
1803 warnings.is_empty(),
1804 "Heading before div close should pass: {warnings:?}"
1805 );
1806 }
1807
1808 #[test]
1809 fn test_quarto_div_markers_not_transparent_in_standard_flavor() {
1810 let rule = MD022BlanksAroundHeadings::default();
1812 let content = "Content\n\n:::\n# Heading\n\n:::\n";
1813 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1814 let warnings = rule.check(&ctx).unwrap();
1815 assert!(
1817 !warnings.is_empty(),
1818 "Standard flavor should not treat ::: as transparent: {warnings:?}"
1819 );
1820 }
1821
1822 #[test]
1823 fn test_quarto_nested_divs_with_heading() {
1824 let rule = MD022BlanksAroundHeadings::default();
1826 let content = "::: {.outer}\n::: {.inner}\n\n# Heading\n\nContent\n:::\n:::\n";
1827 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1828 let warnings = rule.check(&ctx).unwrap();
1829 assert!(
1830 warnings.is_empty(),
1831 "Nested divs with heading should work: {warnings:?}"
1832 );
1833 }
1834
1835 #[test]
1836 fn test_quarto_fix_preserves_div_markers() {
1837 let rule = MD022BlanksAroundHeadings::default();
1839 let content = "::: {.callout-note}\n\n## Note\n\nContent\n:::\n";
1840 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1841 let fixed = rule.fix(&ctx).unwrap();
1842 assert!(fixed.contains("::: {.callout-note}"), "Should preserve div opening");
1844 assert!(fixed.contains(":::"), "Should preserve div closing");
1845 assert!(fixed.contains("## Note"), "Should preserve heading");
1846 }
1847
1848 #[test]
1849 fn test_quarto_heading_needs_blank_without_div_transparency() {
1850 let rule = MD022BlanksAroundHeadings::default();
1853 let content = "Content\n::: {.callout-note}\n# Heading\n\nMore\n:::\n";
1855 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1856 let warnings = rule.check(&ctx).unwrap();
1857 assert!(
1860 !warnings.is_empty(),
1861 "Should still require blank line when not present: {warnings:?}"
1862 );
1863 }
1864}