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 {
632 range: byte_range,
633 replacement: line_ending.repeat(needed_blanks),
634 }),
635 });
636 }
637
638 Ok(result)
639 }
640
641 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
642 if ctx.content.is_empty() {
643 return Ok(ctx.content.to_string());
644 }
645
646 let fixed = self.fix_content(ctx);
648
649 Ok(fixed)
650 }
651
652 fn category(&self) -> RuleCategory {
654 RuleCategory::Heading
655 }
656
657 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
659 if ctx.content.is_empty() || !ctx.likely_has_headings() {
661 return true;
662 }
663 ctx.lines.iter().all(|line| line.heading.is_none())
665 }
666
667 fn as_any(&self) -> &dyn std::any::Any {
668 self
669 }
670
671 fn default_config_section(&self) -> Option<(String, toml::Value)> {
672 let default_config = MD022Config::default();
673 let json_value = serde_json::to_value(&default_config).ok()?;
674 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
675
676 if let toml::Value::Table(table) = toml_value {
677 if !table.is_empty() {
678 Some((MD022Config::RULE_NAME.to_string(), toml::Value::Table(table)))
679 } else {
680 None
681 }
682 } else {
683 None
684 }
685 }
686
687 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
688 where
689 Self: Sized,
690 {
691 let rule_config = crate::rule_config_serde::load_rule_config::<MD022Config>(config);
692 Box::new(Self::from_config_struct(rule_config))
693 }
694}
695
696#[cfg(test)]
697mod tests {
698 use super::*;
699 use crate::lint_context::LintContext;
700
701 #[test]
702 fn test_valid_headings() {
703 let rule = MD022BlanksAroundHeadings::default();
704 let content = "\n# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.\n";
705 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
706 let result = rule.check(&ctx).unwrap();
707 assert!(result.is_empty());
708 }
709
710 #[test]
711 fn test_missing_blank_above() {
712 let rule = MD022BlanksAroundHeadings::default();
713 let content = "# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.\n";
714 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
715 let result = rule.check(&ctx).unwrap();
716 assert_eq!(result.len(), 0); let fixed = rule.fix(&ctx).unwrap();
719
720 assert!(fixed.contains("# Heading 1"));
723 assert!(fixed.contains("Some content."));
724 assert!(fixed.contains("## Heading 2"));
725 assert!(fixed.contains("More content."));
726 }
727
728 #[test]
729 fn test_missing_blank_below() {
730 let rule = MD022BlanksAroundHeadings::default();
731 let content = "\n# Heading 1\nSome content.\n\n## Heading 2\n\nMore content.\n";
732 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
733 let result = rule.check(&ctx).unwrap();
734 assert_eq!(result.len(), 1);
735 assert_eq!(result[0].line, 2);
736
737 let fixed = rule.fix(&ctx).unwrap();
739 assert!(fixed.contains("# Heading 1\n\nSome content"));
740 }
741
742 #[test]
743 fn test_missing_blank_above_and_below() {
744 let rule = MD022BlanksAroundHeadings::default();
745 let content = "# Heading 1\nSome content.\n## Heading 2\nMore content.\n";
746 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
747 let result = rule.check(&ctx).unwrap();
748 assert_eq!(result.len(), 3); let fixed = rule.fix(&ctx).unwrap();
752 assert!(fixed.contains("# Heading 1\n\nSome content"));
753 assert!(fixed.contains("Some content.\n\n## Heading 2"));
754 assert!(fixed.contains("## Heading 2\n\nMore content"));
755 }
756
757 #[test]
758 fn test_fix_headings() {
759 let rule = MD022BlanksAroundHeadings::default();
760 let content = "# Heading 1\nSome content.\n## Heading 2\nMore content.";
761 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
762 let result = rule.fix(&ctx).unwrap();
763
764 let expected = "# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.";
765 assert_eq!(result, expected);
766 }
767
768 #[test]
769 fn test_consecutive_headings_pattern() {
770 let rule = MD022BlanksAroundHeadings::default();
771 let content = "# Heading 1\n## Heading 2\n### Heading 3";
772 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
773 let result = rule.fix(&ctx).unwrap();
774
775 let lines: Vec<&str> = result.lines().collect();
777 assert!(!lines.is_empty());
778
779 let h1_pos = lines.iter().position(|&l| l == "# Heading 1").unwrap();
781 let h2_pos = lines.iter().position(|&l| l == "## Heading 2").unwrap();
782 let h3_pos = lines.iter().position(|&l| l == "### Heading 3").unwrap();
783
784 assert!(
786 h2_pos > h1_pos + 1,
787 "Should have at least one blank line after first heading"
788 );
789 assert!(
790 h3_pos > h2_pos + 1,
791 "Should have at least one blank line after second heading"
792 );
793
794 assert!(lines[h1_pos + 1].trim().is_empty(), "Line after h1 should be blank");
796
797 assert!(lines[h2_pos + 1].trim().is_empty(), "Line after h2 should be blank");
799 }
800
801 #[test]
802 fn test_blanks_around_setext_headings() {
803 let rule = MD022BlanksAroundHeadings::default();
804 let content = "Heading 1\n=========\nSome content.\nHeading 2\n---------\nMore content.";
805 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
806 let result = rule.fix(&ctx).unwrap();
807
808 let lines: Vec<&str> = result.lines().collect();
810
811 assert!(result.contains("Heading 1"));
813 assert!(result.contains("========="));
814 assert!(result.contains("Some content."));
815 assert!(result.contains("Heading 2"));
816 assert!(result.contains("---------"));
817 assert!(result.contains("More content."));
818
819 let heading1_marker_idx = lines.iter().position(|&l| l == "=========").unwrap();
821 let some_content_idx = lines.iter().position(|&l| l == "Some content.").unwrap();
822 assert!(
823 some_content_idx > heading1_marker_idx + 1,
824 "Should have a blank line after the first heading"
825 );
826
827 let heading2_marker_idx = lines.iter().position(|&l| l == "---------").unwrap();
828 let more_content_idx = lines.iter().position(|&l| l == "More content.").unwrap();
829 assert!(
830 more_content_idx > heading2_marker_idx + 1,
831 "Should have a blank line after the second heading"
832 );
833
834 let fixed_ctx = LintContext::new(&result, crate::config::MarkdownFlavor::Standard, None);
836 let fixed_warnings = rule.check(&fixed_ctx).unwrap();
837 assert!(fixed_warnings.is_empty(), "Fixed content should have no warnings");
838 }
839
840 #[test]
841 fn test_fix_specific_blank_line_cases() {
842 let rule = MD022BlanksAroundHeadings::default();
843
844 let content1 = "# Heading 1\n## Heading 2\n### Heading 3";
846 let ctx1 = LintContext::new(content1, crate::config::MarkdownFlavor::Standard, None);
847 let result1 = rule.fix(&ctx1).unwrap();
848 assert!(result1.contains("# Heading 1"));
850 assert!(result1.contains("## Heading 2"));
851 assert!(result1.contains("### Heading 3"));
852 let lines: Vec<&str> = result1.lines().collect();
854 let h1_pos = lines.iter().position(|&l| l == "# Heading 1").unwrap();
855 let h2_pos = lines.iter().position(|&l| l == "## Heading 2").unwrap();
856 assert!(lines[h1_pos + 1].trim().is_empty(), "Should have a blank line after h1");
857 assert!(lines[h2_pos + 1].trim().is_empty(), "Should have a blank line after h2");
858
859 let content2 = "# Heading 1\nContent under heading 1\n## Heading 2";
861 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
862 let result2 = rule.fix(&ctx2).unwrap();
863 assert!(result2.contains("# Heading 1"));
865 assert!(result2.contains("Content under heading 1"));
866 assert!(result2.contains("## Heading 2"));
867 let lines2: Vec<&str> = result2.lines().collect();
869 let h1_pos2 = lines2.iter().position(|&l| l == "# Heading 1").unwrap();
870 let _content_pos = lines2.iter().position(|&l| l == "Content under heading 1").unwrap();
871 assert!(
872 lines2[h1_pos2 + 1].trim().is_empty(),
873 "Should have a blank line after heading 1"
874 );
875
876 let content3 = "# Heading 1\n\n\n## Heading 2\n\n\n### Heading 3\n\nContent";
878 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
879 let result3 = rule.fix(&ctx3).unwrap();
880 assert!(result3.contains("# Heading 1"));
882 assert!(result3.contains("## Heading 2"));
883 assert!(result3.contains("### Heading 3"));
884 assert!(result3.contains("Content"));
885 }
886
887 #[test]
888 fn test_fix_preserves_existing_blank_lines() {
889 let rule = MD022BlanksAroundHeadings::new();
890 let content = "# Title
891
892## Section 1
893
894Content here.
895
896## Section 2
897
898More content.
899### Missing Blank Above
900
901Even more content.
902
903## Section 3
904
905Final content.";
906
907 let expected = "# Title
908
909## Section 1
910
911Content here.
912
913## Section 2
914
915More content.
916
917### Missing Blank Above
918
919Even more content.
920
921## Section 3
922
923Final content.";
924
925 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
926 let result = rule.fix_content(&ctx);
927 assert_eq!(
928 result, expected,
929 "Fix should only add missing blank lines, never remove existing ones"
930 );
931 }
932
933 #[test]
934 fn test_fix_preserves_trailing_newline() {
935 let rule = MD022BlanksAroundHeadings::new();
936
937 let content_with_newline = "# Title\nContent here.\n";
939 let ctx = LintContext::new(content_with_newline, crate::config::MarkdownFlavor::Standard, None);
940 let result = rule.fix(&ctx).unwrap();
941 assert!(result.ends_with('\n'), "Should preserve trailing newline");
942
943 let content_without_newline = "# Title\nContent here.";
945 let ctx = LintContext::new(content_without_newline, crate::config::MarkdownFlavor::Standard, None);
946 let result = rule.fix(&ctx).unwrap();
947 assert!(
948 !result.ends_with('\n'),
949 "Should not add trailing newline if original didn't have one"
950 );
951 }
952
953 #[test]
954 fn test_fix_does_not_add_blank_lines_before_lists() {
955 let rule = MD022BlanksAroundHeadings::new();
956 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.";
957
958 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.";
959
960 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
961 let result = rule.fix_content(&ctx);
962 assert_eq!(result, expected, "Fix should not add blank lines before lists");
963 }
964
965 #[test]
966 fn test_per_level_configuration_no_blank_above_h1() {
967 use md022_config::HeadingLevelConfig;
968
969 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
971 lines_above: HeadingLevelConfig::per_level([0, 1, 1, 1, 1, 1]),
972 lines_below: HeadingLevelConfig::scalar(1),
973 allowed_at_start: false, });
975
976 let content = "Some text\n# Heading 1\n\nMore text";
978 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
979 let warnings = rule.check(&ctx).unwrap();
980 assert_eq!(warnings.len(), 0, "H1 without blank above should not trigger warning");
981
982 let content = "Some text\n## Heading 2\n\nMore text";
984 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
985 let warnings = rule.check(&ctx).unwrap();
986 assert_eq!(warnings.len(), 1, "H2 without blank above should trigger warning");
987 assert!(warnings[0].message.contains("above"));
988 }
989
990 #[test]
991 fn test_per_level_configuration_different_requirements() {
992 use md022_config::HeadingLevelConfig;
993
994 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
996 lines_above: HeadingLevelConfig::per_level([0, 1, 1, 2, 2, 2]),
997 lines_below: HeadingLevelConfig::scalar(1),
998 allowed_at_start: false,
999 });
1000
1001 let content = "Text\n# H1\n\nText\n\n## H2\n\nText\n\n### H3\n\nText\n\n\n#### H4\n\nText";
1002 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1003 let warnings = rule.check(&ctx).unwrap();
1004
1005 assert_eq!(
1007 warnings.len(),
1008 0,
1009 "All headings should satisfy level-specific requirements"
1010 );
1011 }
1012
1013 #[test]
1014 fn test_per_level_configuration_violations() {
1015 use md022_config::HeadingLevelConfig;
1016
1017 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1019 lines_above: HeadingLevelConfig::per_level([1, 1, 1, 2, 1, 1]),
1020 lines_below: HeadingLevelConfig::scalar(1),
1021 allowed_at_start: false,
1022 });
1023
1024 let content = "Text\n\n#### Heading 4\n\nMore text";
1026 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1027 let warnings = rule.check(&ctx).unwrap();
1028
1029 assert_eq!(warnings.len(), 1, "H4 with insufficient blanks should trigger warning");
1030 assert!(warnings[0].message.contains("2 blank lines above"));
1031 }
1032
1033 #[test]
1034 fn test_per_level_fix_different_levels() {
1035 use md022_config::HeadingLevelConfig;
1036
1037 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1039 lines_above: HeadingLevelConfig::per_level([0, 1, 2, 2, 2, 2]),
1040 lines_below: HeadingLevelConfig::scalar(1),
1041 allowed_at_start: false,
1042 });
1043
1044 let content = "Text\n# H1\nContent\n## H2\nContent\n### H3\nContent";
1045 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1046 let fixed = rule.fix(&ctx).unwrap();
1047
1048 assert!(fixed.contains("Text\n# H1\n\nContent"));
1050 assert!(fixed.contains("Content\n\n## H2\n\nContent"));
1051 assert!(fixed.contains("Content\n\n\n### H3\n\nContent"));
1052 }
1053
1054 #[test]
1055 fn test_per_level_below_configuration() {
1056 use md022_config::HeadingLevelConfig;
1057
1058 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1060 lines_above: HeadingLevelConfig::scalar(1),
1061 lines_below: HeadingLevelConfig::per_level([2, 1, 1, 1, 1, 1]), allowed_at_start: true,
1063 });
1064
1065 let content = "# Heading 1\n\nSome text";
1067 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1068 let warnings = rule.check(&ctx).unwrap();
1069
1070 assert_eq!(
1071 warnings.len(),
1072 1,
1073 "H1 with insufficient blanks below should trigger warning"
1074 );
1075 assert!(warnings[0].message.contains("2 blank lines below"));
1076 }
1077
1078 #[test]
1079 fn test_scalar_configuration_still_works() {
1080 use md022_config::HeadingLevelConfig;
1081
1082 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1084 lines_above: HeadingLevelConfig::scalar(2),
1085 lines_below: HeadingLevelConfig::scalar(2),
1086 allowed_at_start: false,
1087 });
1088
1089 let content = "Text\n# H1\nContent\n## H2\nContent";
1090 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1091 let warnings = rule.check(&ctx).unwrap();
1092
1093 assert!(!warnings.is_empty(), "Should have violations for insufficient blanks");
1095 }
1096
1097 #[test]
1098 fn test_unlimited_configuration_skips_requirements() {
1099 use md022_config::{HeadingBlankRequirement, HeadingLevelConfig};
1100
1101 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1103 lines_above: HeadingLevelConfig::per_level_requirements([
1104 HeadingBlankRequirement::unlimited(),
1105 HeadingBlankRequirement::limited(1),
1106 HeadingBlankRequirement::limited(1),
1107 HeadingBlankRequirement::limited(1),
1108 HeadingBlankRequirement::limited(1),
1109 HeadingBlankRequirement::limited(1),
1110 ]),
1111 lines_below: HeadingLevelConfig::per_level_requirements([
1112 HeadingBlankRequirement::unlimited(),
1113 HeadingBlankRequirement::limited(1),
1114 HeadingBlankRequirement::limited(1),
1115 HeadingBlankRequirement::limited(1),
1116 HeadingBlankRequirement::limited(1),
1117 HeadingBlankRequirement::limited(1),
1118 ]),
1119 allowed_at_start: false,
1120 });
1121
1122 let content = "# H1\nParagraph\n## H2\nParagraph";
1123 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1124 let warnings = rule.check(&ctx).unwrap();
1125
1126 assert_eq!(warnings.len(), 2, "Only non-unlimited headings should warn");
1128 assert!(
1129 warnings.iter().all(|w| w.line >= 3),
1130 "Warnings should target later headings"
1131 );
1132
1133 let fixed = rule.fix(&ctx).unwrap();
1135 assert!(
1136 fixed.starts_with("# H1\nParagraph\n\n## H2"),
1137 "H1 should remain unchanged"
1138 );
1139 }
1140
1141 #[test]
1142 fn test_html_comment_transparency() {
1143 let rule = MD022BlanksAroundHeadings::default();
1147
1148 let content = "Some content\n\n<!-- markdownlint-disable-next-line MD001 -->\n#### Heading";
1151 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1152 let warnings = rule.check(&ctx).unwrap();
1153 assert!(
1154 warnings.is_empty(),
1155 "HTML comment is transparent - blank line above it counts for heading"
1156 );
1157
1158 let content_multiline = "Some content\n\n<!-- This is a\nmulti-line comment -->\n#### Heading";
1160 let ctx_multiline = LintContext::new(content_multiline, crate::config::MarkdownFlavor::Standard, None);
1161 let warnings_multiline = rule.check(&ctx_multiline).unwrap();
1162 assert!(
1163 warnings_multiline.is_empty(),
1164 "Multi-line HTML comment is also transparent"
1165 );
1166 }
1167
1168 #[test]
1169 fn test_frontmatter_transparency() {
1170 let rule = MD022BlanksAroundHeadings::default();
1173
1174 let content = "---\ntitle: Test\n---\n# First heading";
1176 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1177 let warnings = rule.check(&ctx).unwrap();
1178 assert!(
1179 warnings.is_empty(),
1180 "Frontmatter is transparent - heading can appear immediately after"
1181 );
1182
1183 let content_with_blank = "---\ntitle: Test\n---\n\n# First heading";
1185 let ctx_with_blank = LintContext::new(content_with_blank, crate::config::MarkdownFlavor::Standard, None);
1186 let warnings_with_blank = rule.check(&ctx_with_blank).unwrap();
1187 assert!(
1188 warnings_with_blank.is_empty(),
1189 "Heading with blank line after frontmatter should also be valid"
1190 );
1191
1192 let content_toml = "+++\ntitle = \"Test\"\n+++\n# First heading";
1194 let ctx_toml = LintContext::new(content_toml, crate::config::MarkdownFlavor::Standard, None);
1195 let warnings_toml = rule.check(&ctx_toml).unwrap();
1196 assert!(
1197 warnings_toml.is_empty(),
1198 "TOML frontmatter is also transparent for MD022"
1199 );
1200 }
1201
1202 #[test]
1203 fn test_horizontal_rule_not_treated_as_frontmatter() {
1204 let rule = MD022BlanksAroundHeadings::default();
1207
1208 let content = "Some content\n\n---\n# Heading after HR";
1210 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1211 let warnings = rule.check(&ctx).unwrap();
1212 assert!(
1213 !warnings.is_empty(),
1214 "Heading after horizontal rule without blank line SHOULD trigger MD022"
1215 );
1216 assert!(
1217 warnings.iter().any(|w| w.line == 4),
1218 "Warning should be on line 4 (the heading line)"
1219 );
1220
1221 let content_with_blank = "Some content\n\n---\n\n# Heading after HR";
1223 let ctx_with_blank = LintContext::new(content_with_blank, crate::config::MarkdownFlavor::Standard, None);
1224 let warnings_with_blank = rule.check(&ctx_with_blank).unwrap();
1225 assert!(
1226 warnings_with_blank.is_empty(),
1227 "Heading with blank line after HR should not trigger MD022"
1228 );
1229
1230 let content_hr_start = "---\n# Heading";
1232 let ctx_hr_start = LintContext::new(content_hr_start, crate::config::MarkdownFlavor::Standard, None);
1233 let warnings_hr_start = rule.check(&ctx_hr_start).unwrap();
1234 assert!(
1235 !warnings_hr_start.is_empty(),
1236 "Heading after HR at document start SHOULD trigger MD022"
1237 );
1238
1239 let content_multi_hr = "Content\n\n---\n\n---\n# Heading";
1241 let ctx_multi_hr = LintContext::new(content_multi_hr, crate::config::MarkdownFlavor::Standard, None);
1242 let warnings_multi_hr = rule.check(&ctx_multi_hr).unwrap();
1243 assert!(
1244 !warnings_multi_hr.is_empty(),
1245 "Heading after multiple HRs without blank line SHOULD trigger MD022"
1246 );
1247 }
1248
1249 #[test]
1250 fn test_all_hr_styles_require_blank_before_heading() {
1251 let rule = MD022BlanksAroundHeadings::default();
1253
1254 let hr_styles = [
1256 "---", "***", "___", "- - -", "* * *", "_ _ _", "----", "****", "____", "- - - -",
1257 "- - -", " ---", " ---", ];
1261
1262 for hr in hr_styles {
1263 let content = format!("Content\n\n{hr}\n# Heading");
1264 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1265 let warnings = rule.check(&ctx).unwrap();
1266 assert!(
1267 !warnings.is_empty(),
1268 "HR style '{hr}' followed by heading should trigger MD022"
1269 );
1270 }
1271 }
1272
1273 #[test]
1274 fn test_setext_heading_after_hr() {
1275 let rule = MD022BlanksAroundHeadings::default();
1277
1278 let content = "Content\n\n---\nHeading\n======";
1280 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1281 let warnings = rule.check(&ctx).unwrap();
1282 assert!(
1283 !warnings.is_empty(),
1284 "Setext heading after HR without blank should trigger MD022"
1285 );
1286
1287 let content_h2 = "Content\n\n---\nHeading\n------";
1289 let ctx_h2 = LintContext::new(content_h2, crate::config::MarkdownFlavor::Standard, None);
1290 let warnings_h2 = rule.check(&ctx_h2).unwrap();
1291 assert!(
1292 !warnings_h2.is_empty(),
1293 "Setext h2 after HR without blank should trigger MD022"
1294 );
1295
1296 let content_ok = "Content\n\n---\n\nHeading\n======";
1298 let ctx_ok = LintContext::new(content_ok, crate::config::MarkdownFlavor::Standard, None);
1299 let warnings_ok = rule.check(&ctx_ok).unwrap();
1300 assert!(
1301 warnings_ok.is_empty(),
1302 "Setext heading with blank after HR should not warn"
1303 );
1304 }
1305
1306 #[test]
1307 fn test_hr_in_code_block_not_treated_as_hr() {
1308 let rule = MD022BlanksAroundHeadings::default();
1310
1311 let content = "```\n---\n```\n# Heading";
1314 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1315 let warnings = rule.check(&ctx).unwrap();
1316 assert!(!warnings.is_empty(), "Heading after code block still needs blank line");
1319
1320 let content_ok = "```\n---\n```\n\n# Heading";
1322 let ctx_ok = LintContext::new(content_ok, crate::config::MarkdownFlavor::Standard, None);
1323 let warnings_ok = rule.check(&ctx_ok).unwrap();
1324 assert!(
1325 warnings_ok.is_empty(),
1326 "Heading with blank after code block should not warn"
1327 );
1328 }
1329
1330 #[test]
1331 fn test_hr_in_html_comment_not_treated_as_hr() {
1332 let rule = MD022BlanksAroundHeadings::default();
1334
1335 let content = "<!-- \n---\n -->\n# Heading";
1337 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1338 let warnings = rule.check(&ctx).unwrap();
1339 assert!(
1341 warnings.is_empty(),
1342 "HR inside HTML comment should be ignored - heading after comment is OK"
1343 );
1344 }
1345
1346 #[test]
1347 fn test_invalid_hr_not_triggering() {
1348 let rule = MD022BlanksAroundHeadings::default();
1350
1351 let invalid_hrs = [
1352 " ---", "\t---", "--", "**", "__", "-*-", "---a", "a---", ];
1361
1362 for invalid in invalid_hrs {
1363 let content = format!("Content\n\n{invalid}\n# Heading");
1366 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1367 let _ = rule.check(&ctx);
1370 }
1371 }
1372
1373 #[test]
1374 fn test_frontmatter_vs_horizontal_rule_distinction() {
1375 let rule = MD022BlanksAroundHeadings::default();
1377
1378 let content = "---\ntitle: Test\n---\n\nSome content\n\n---\n# Heading after HR";
1381 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1382 let warnings = rule.check(&ctx).unwrap();
1383 assert!(
1384 !warnings.is_empty(),
1385 "HR after frontmatter content should still require blank line before heading"
1386 );
1387
1388 let content_ok = "---\ntitle: Test\n---\n\nSome content\n\n---\n\n# Heading after HR";
1390 let ctx_ok = LintContext::new(content_ok, crate::config::MarkdownFlavor::Standard, None);
1391 let warnings_ok = rule.check(&ctx_ok).unwrap();
1392 assert!(
1393 warnings_ok.is_empty(),
1394 "HR with blank line before heading should not warn"
1395 );
1396 }
1397
1398 #[test]
1401 fn test_kramdown_ial_after_heading_no_warning() {
1402 let rule = MD022BlanksAroundHeadings::default();
1404 let content = "## Table of Contents\n{: .hhc-toc-heading}\n\nSome content here.";
1405 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1406 let warnings = rule.check(&ctx).unwrap();
1407
1408 assert!(
1409 warnings.is_empty(),
1410 "IAL after heading should not require blank line between them: {warnings:?}"
1411 );
1412 }
1413
1414 #[test]
1415 fn test_kramdown_ial_with_class() {
1416 let rule = MD022BlanksAroundHeadings::default();
1417 let content = "# Heading\n{:.highlight}\n\nContent.";
1418 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1419 let warnings = rule.check(&ctx).unwrap();
1420
1421 assert!(warnings.is_empty(), "IAL with class should be part of heading");
1422 }
1423
1424 #[test]
1425 fn test_kramdown_ial_with_id() {
1426 let rule = MD022BlanksAroundHeadings::default();
1427 let content = "# Heading\n{:#custom-id}\n\nContent.";
1428 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1429 let warnings = rule.check(&ctx).unwrap();
1430
1431 assert!(warnings.is_empty(), "IAL with id should be part of heading");
1432 }
1433
1434 #[test]
1435 fn test_kramdown_ial_with_multiple_attributes() {
1436 let rule = MD022BlanksAroundHeadings::default();
1437 let content = "# Heading\n{: .class #id style=\"color: red\"}\n\nContent.";
1438 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1439 let warnings = rule.check(&ctx).unwrap();
1440
1441 assert!(
1442 warnings.is_empty(),
1443 "IAL with multiple attributes should be part of heading"
1444 );
1445 }
1446
1447 #[test]
1448 fn test_kramdown_ial_missing_blank_after() {
1449 let rule = MD022BlanksAroundHeadings::default();
1451 let content = "# Heading\n{:.class}\nContent without blank.";
1452 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1453 let warnings = rule.check(&ctx).unwrap();
1454
1455 assert_eq!(
1456 warnings.len(),
1457 1,
1458 "Should warn about missing blank after IAL (part of heading)"
1459 );
1460 assert!(warnings[0].message.contains("below"));
1461 }
1462
1463 #[test]
1464 fn test_kramdown_ial_before_heading_transparent() {
1465 let rule = MD022BlanksAroundHeadings::default();
1467 let content = "Content.\n\n{:.preclass}\n## Heading\n\nMore content.";
1468 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1469 let warnings = rule.check(&ctx).unwrap();
1470
1471 assert!(
1472 warnings.is_empty(),
1473 "IAL before heading should be transparent for blank line count"
1474 );
1475 }
1476
1477 #[test]
1478 fn test_kramdown_ial_setext_heading() {
1479 let rule = MD022BlanksAroundHeadings::default();
1480 let content = "Heading\n=======\n{:.setext-class}\n\nContent.";
1481 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1482 let warnings = rule.check(&ctx).unwrap();
1483
1484 assert!(
1485 warnings.is_empty(),
1486 "IAL after Setext heading should be part of heading"
1487 );
1488 }
1489
1490 #[test]
1491 fn test_kramdown_ial_fix_preserves_ial() {
1492 let rule = MD022BlanksAroundHeadings::default();
1493 let content = "Content.\n# Heading\n{:.class}\nMore content.";
1494 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1495 let fixed = rule.fix(&ctx).unwrap();
1496
1497 assert!(
1499 fixed.contains("# Heading\n{:.class}"),
1500 "IAL should stay attached to heading"
1501 );
1502 assert!(fixed.contains("{:.class}\n\nMore"), "Should add blank after IAL");
1503 }
1504
1505 #[test]
1506 fn test_kramdown_ial_fix_does_not_separate() {
1507 let rule = MD022BlanksAroundHeadings::default();
1508 let content = "# Heading\n{:.class}\nContent.";
1509 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1510 let fixed = rule.fix(&ctx).unwrap();
1511
1512 assert!(
1514 !fixed.contains("# Heading\n\n{:.class}"),
1515 "Should not add blank between heading and IAL"
1516 );
1517 assert!(fixed.contains("# Heading\n{:.class}"), "IAL should remain attached");
1518 }
1519
1520 #[test]
1521 fn test_kramdown_multiple_ial_lines() {
1522 let rule = MD022BlanksAroundHeadings::default();
1524 let content = "# Heading\n{:.class1}\n{:#id}\n\nContent.";
1525 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1526 let warnings = rule.check(&ctx).unwrap();
1527
1528 assert!(
1531 warnings.is_empty(),
1532 "Multiple consecutive IALs should be part of heading"
1533 );
1534 }
1535
1536 #[test]
1537 fn test_kramdown_ial_with_blank_line_not_attached() {
1538 let rule = MD022BlanksAroundHeadings::default();
1540 let content = "# Heading\n\n{:.class}\nContent.";
1541 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1542 let warnings = rule.check(&ctx).unwrap();
1543
1544 assert!(warnings.is_empty(), "Blank line separates heading from IAL");
1548 }
1549
1550 #[test]
1551 fn test_not_kramdown_ial_regular_braces() {
1552 let rule = MD022BlanksAroundHeadings::default();
1554 let content = "# Heading\n{not an ial}\n\nContent.";
1555 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1556 let warnings = rule.check(&ctx).unwrap();
1557
1558 assert_eq!(
1560 warnings.len(),
1561 1,
1562 "Non-IAL braces should be regular content requiring blank"
1563 );
1564 }
1565
1566 #[test]
1567 fn test_kramdown_ial_at_document_end() {
1568 let rule = MD022BlanksAroundHeadings::default();
1569 let content = "# Heading\n{:.class}";
1570 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1571 let warnings = rule.check(&ctx).unwrap();
1572
1573 assert!(warnings.is_empty(), "IAL at document end needs no blank after");
1575 }
1576
1577 #[test]
1578 fn test_kramdown_ial_followed_by_code_fence() {
1579 let rule = MD022BlanksAroundHeadings::default();
1580 let content = "# Heading\n{:.class}\n```\ncode\n```";
1581 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1582 let warnings = rule.check(&ctx).unwrap();
1583
1584 assert!(warnings.is_empty(), "No blank needed between IAL and code fence");
1586 }
1587
1588 #[test]
1589 fn test_kramdown_ial_followed_by_list() {
1590 let rule = MD022BlanksAroundHeadings::default();
1591 let content = "# Heading\n{:.class}\n- List item";
1592 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1593 let warnings = rule.check(&ctx).unwrap();
1594
1595 assert!(warnings.is_empty(), "No blank needed between IAL and list");
1597 }
1598
1599 #[test]
1600 fn test_kramdown_ial_fix_idempotent() {
1601 let rule = MD022BlanksAroundHeadings::default();
1602 let content = "# Heading\n{:.class}\nContent.";
1603 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1604
1605 let fixed_once = rule.fix(&ctx).unwrap();
1606 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
1607 let fixed_twice = rule.fix(&ctx2).unwrap();
1608
1609 assert_eq!(fixed_once, fixed_twice, "Fix should be idempotent");
1610 }
1611
1612 #[test]
1613 fn test_kramdown_ial_whitespace_line_between_not_attached() {
1614 let rule = MD022BlanksAroundHeadings::default();
1617 let content = "# Heading\n \n{:.class}\n\nContent.";
1618 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1619 let warnings = rule.check(&ctx).unwrap();
1620
1621 assert!(
1625 warnings.is_empty(),
1626 "Whitespace between heading and IAL means IAL is not attached"
1627 );
1628 }
1629
1630 #[test]
1631 fn test_kramdown_ial_html_comment_between() {
1632 let rule = MD022BlanksAroundHeadings::default();
1635 let content = "# Heading\n<!-- comment -->\n{:.class}\n\nContent.";
1636 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1637 let warnings = rule.check(&ctx).unwrap();
1638
1639 assert_eq!(
1643 warnings.len(),
1644 1,
1645 "IAL not attached when comment is between: {warnings:?}"
1646 );
1647 }
1648
1649 #[test]
1650 fn test_kramdown_ial_generic_attribute() {
1651 let rule = MD022BlanksAroundHeadings::default();
1652 let content = "# Heading\n{:data-toc=\"true\" style=\"color: red\"}\n\nContent.";
1653 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1654 let warnings = rule.check(&ctx).unwrap();
1655
1656 assert!(warnings.is_empty(), "Generic attributes should be recognized as IAL");
1657 }
1658
1659 #[test]
1660 fn test_kramdown_ial_fix_multiple_lines_preserves_all() {
1661 let rule = MD022BlanksAroundHeadings::default();
1662 let content = "# Heading\n{:.class1}\n{:#id}\n{:data-x=\"y\"}\nContent.";
1663 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1664
1665 let fixed = rule.fix(&ctx).unwrap();
1666
1667 assert!(fixed.contains("{:.class1}"), "First IAL should be preserved");
1669 assert!(fixed.contains("{:#id}"), "Second IAL should be preserved");
1670 assert!(fixed.contains("{:data-x=\"y\"}"), "Third IAL should be preserved");
1671 assert!(
1673 fixed.contains("{:data-x=\"y\"}\n\nContent"),
1674 "Blank line should be after all IALs"
1675 );
1676 }
1677
1678 #[test]
1679 fn test_kramdown_ial_crlf_line_endings() {
1680 let rule = MD022BlanksAroundHeadings::default();
1681 let content = "# Heading\r\n{:.class}\r\n\r\nContent.";
1682 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1683 let warnings = rule.check(&ctx).unwrap();
1684
1685 assert!(warnings.is_empty(), "CRLF should work correctly with IAL");
1686 }
1687
1688 #[test]
1689 fn test_kramdown_ial_invalid_patterns_not_recognized() {
1690 let rule = MD022BlanksAroundHeadings::default();
1691
1692 let content = "# Heading\n{ :.class}\n\nContent.";
1694 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1695 let warnings = rule.check(&ctx).unwrap();
1696 assert_eq!(warnings.len(), 1, "Invalid IAL syntax should trigger warning");
1697
1698 let content2 = "# Heading\n{.class}\n\nContent.";
1700 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1701 let warnings2 = rule.check(&ctx2).unwrap();
1702 assert!(warnings2.is_empty(), "{{.class}} is valid kramdown block attribute");
1704
1705 let content3 = "# Heading\n{just text}\n\nContent.";
1707 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
1708 let warnings3 = rule.check(&ctx3).unwrap();
1709 assert_eq!(
1710 warnings3.len(),
1711 1,
1712 "Text in braces is not IAL and should trigger warning"
1713 );
1714 }
1715
1716 #[test]
1717 fn test_kramdown_ial_toc_marker() {
1718 let rule = MD022BlanksAroundHeadings::default();
1720 let content = "# Heading\n{:toc}\n\nContent.";
1721 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1722 let warnings = rule.check(&ctx).unwrap();
1723
1724 assert!(warnings.is_empty(), "{{:toc}} should be recognized as IAL");
1726 }
1727
1728 #[test]
1729 fn test_kramdown_ial_mixed_headings_in_document() {
1730 let rule = MD022BlanksAroundHeadings::default();
1731 let content = r#"# ATX Heading
1732{:.atx-class}
1733
1734Content after ATX.
1735
1736Setext Heading
1737--------------
1738{:#setext-id}
1739
1740Content after Setext.
1741
1742## Another ATX
1743{:.another}
1744
1745More content."#;
1746 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1747 let warnings = rule.check(&ctx).unwrap();
1748
1749 assert!(
1750 warnings.is_empty(),
1751 "Mixed headings with IAL should all work: {warnings:?}"
1752 );
1753 }
1754
1755 #[test]
1756 fn test_kramdown_extension_block_before_first_heading_is_document_start() {
1757 let rule = MD022BlanksAroundHeadings::default();
1758 let content = "{::comment}\nhidden\n{:/comment}\n# Heading\n\nBody\n";
1759 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Kramdown, None);
1760 let warnings = rule.check(&ctx).unwrap();
1761
1762 assert!(
1763 warnings.is_empty(),
1764 "Kramdown extension preamble should not require blank above first heading: {warnings:?}"
1765 );
1766 }
1767
1768 #[test]
1769 fn test_kramdown_ial_before_first_heading_is_document_start() {
1770 let rule = MD022BlanksAroundHeadings::default();
1771 let content = "{:.doc-class}\n# Heading\n\nBody\n";
1772 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Kramdown, None);
1773 let warnings = rule.check(&ctx).unwrap();
1774
1775 assert!(
1776 warnings.is_empty(),
1777 "Kramdown IAL preamble should not require blank above first heading: {warnings:?}"
1778 );
1779 }
1780
1781 #[test]
1784 fn test_quarto_div_marker_transparent_above_heading() {
1785 let rule = MD022BlanksAroundHeadings::default();
1788 let content = "Content\n\n::: {.callout-note}\n# Heading\n\nMore content\n:::\n";
1790 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1791 let warnings = rule.check(&ctx).unwrap();
1792 assert!(
1794 warnings.is_empty(),
1795 "Quarto div marker should be transparent above heading: {warnings:?}"
1796 );
1797 }
1798
1799 #[test]
1800 fn test_quarto_div_marker_transparent_below_heading() {
1801 let rule = MD022BlanksAroundHeadings::default();
1803 let content = "# Heading\n\n::: {.callout-note}\nContent\n:::\n";
1804 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1805 let warnings = rule.check(&ctx).unwrap();
1806 assert!(
1808 warnings.is_empty(),
1809 "Quarto div marker should be transparent below heading: {warnings:?}"
1810 );
1811 }
1812
1813 #[test]
1814 fn test_quarto_heading_inside_callout() {
1815 let rule = MD022BlanksAroundHeadings::default();
1817 let content = "::: {.callout-note}\n\n## Note Title\n\nNote content\n:::\n";
1818 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1819 let warnings = rule.check(&ctx).unwrap();
1820 assert!(
1821 warnings.is_empty(),
1822 "Heading inside Quarto callout should have no warnings: {warnings:?}"
1823 );
1824 }
1825
1826 #[test]
1827 fn test_quarto_heading_at_start_after_div_open() {
1828 let rule = MD022BlanksAroundHeadings::default();
1831 let content = "::: {.callout-warning}\n# Warning\n\nContent\n:::\n";
1833 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1834 let warnings = rule.check(&ctx).unwrap();
1835 assert!(
1841 warnings.is_empty(),
1842 "Heading at start after div open should pass: {warnings:?}"
1843 );
1844 }
1845
1846 #[test]
1847 fn test_quarto_heading_before_div_close() {
1848 let rule = MD022BlanksAroundHeadings::default();
1850 let content = "::: {.callout-note}\nIntro\n\n## Section\n:::\n";
1851 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1852 let warnings = rule.check(&ctx).unwrap();
1853 assert!(
1857 warnings.is_empty(),
1858 "Heading before div close should pass: {warnings:?}"
1859 );
1860 }
1861
1862 #[test]
1863 fn test_quarto_div_markers_not_transparent_in_standard_flavor() {
1864 let rule = MD022BlanksAroundHeadings::default();
1866 let content = "Content\n\n:::\n# Heading\n\n:::\n";
1867 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1868 let warnings = rule.check(&ctx).unwrap();
1869 assert!(
1871 !warnings.is_empty(),
1872 "Standard flavor should not treat ::: as transparent: {warnings:?}"
1873 );
1874 }
1875
1876 #[test]
1877 fn test_quarto_nested_divs_with_heading() {
1878 let rule = MD022BlanksAroundHeadings::default();
1880 let content = "::: {.outer}\n::: {.inner}\n\n# Heading\n\nContent\n:::\n:::\n";
1881 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1882 let warnings = rule.check(&ctx).unwrap();
1883 assert!(
1884 warnings.is_empty(),
1885 "Nested divs with heading should work: {warnings:?}"
1886 );
1887 }
1888
1889 #[test]
1890 fn test_quarto_fix_preserves_div_markers() {
1891 let rule = MD022BlanksAroundHeadings::default();
1893 let content = "::: {.callout-note}\n\n## Note\n\nContent\n:::\n";
1894 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1895 let fixed = rule.fix(&ctx).unwrap();
1896 assert!(fixed.contains("::: {.callout-note}"), "Should preserve div opening");
1898 assert!(fixed.contains(":::"), "Should preserve div closing");
1899 assert!(fixed.contains("## Note"), "Should preserve heading");
1900 }
1901
1902 #[test]
1903 fn test_quarto_heading_needs_blank_without_div_transparency() {
1904 let rule = MD022BlanksAroundHeadings::default();
1907 let content = "Content\n::: {.callout-note}\n# Heading\n\nMore\n:::\n";
1909 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1910 let warnings = rule.check(&ctx).unwrap();
1911 assert!(
1914 !warnings.is_empty(),
1915 "Should still require blank line when not present: {warnings:?}"
1916 );
1917 }
1918}