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