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