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::pandoc;
8use crate::utils::range_utils::calculate_heading_range;
9use toml;
10
11pub(crate) mod md022_config;
12use md022_config::MD022Config;
13
14fn starts_with_list_marker(trimmed: &str) -> bool {
22 let bytes = trimmed.as_bytes();
23 match bytes.first() {
24 Some(b'-' | b'*' | b'+') => matches!(bytes.get(1), None | Some(b' ')),
25 Some(b'0'..=b'9') => {
26 let mut i = 0;
27 while bytes.get(i).is_some_and(u8::is_ascii_digit) {
28 i += 1;
29 }
30 matches!(bytes.get(i), Some(b'.' | b')')) && matches!(bytes.get(i + 1), None | Some(b' '))
31 }
32 _ => false,
33 }
34}
35
36#[derive(Clone, Default)]
108pub struct MD022BlanksAroundHeadings {
109 config: MD022Config,
110}
111
112impl MD022BlanksAroundHeadings {
113 pub fn new() -> Self {
116 Self {
117 config: MD022Config::default(),
118 }
119 }
120
121 pub fn with_values(lines_above: usize, lines_below: usize) -> Self {
123 use md022_config::HeadingLevelConfig;
124 Self {
125 config: MD022Config {
126 lines_above: HeadingLevelConfig::scalar(lines_above),
127 lines_below: HeadingLevelConfig::scalar(lines_below),
128 allowed_at_start: true,
129 },
130 }
131 }
132
133 pub fn from_config_struct(config: MD022Config) -> Self {
134 Self { config }
135 }
136
137 fn fix_content(&self, ctx: &crate::lint_context::LintContext) -> String {
139 let line_ending = "\n";
141 let had_trailing_newline = ctx.content.ends_with('\n');
142 let is_pandoc = ctx.flavor.is_pandoc_compatible();
143 let mut result = Vec::new();
144 let mut skip_count: usize = 0;
145
146 let heading_at_start_idx = {
147 let mut found_non_transparent = false;
148 ctx.lines.iter().enumerate().find_map(|(i, line)| {
149 if line.heading.as_ref().is_some_and(|h| h.is_valid) && !found_non_transparent {
151 Some(i)
152 } else {
153 if !line.is_blank && !line.in_html_comment && !line.in_mdx_comment {
156 let trimmed = line.content(ctx.content).trim();
157 if trimmed.starts_with("<!--") && trimmed.ends_with("-->") {
159 } else if line.in_kramdown_extension_block || line.is_kramdown_block_ial {
161 } else if is_pandoc && (pandoc::is_div_open(trimmed) || pandoc::is_div_close(trimmed)) {
163 } else {
165 found_non_transparent = true;
166 }
167 }
168 None
169 }
170 })
171 };
172
173 for (i, line_info) in ctx.lines.iter().enumerate() {
174 if skip_count > 0 {
175 skip_count -= 1;
176 continue;
177 }
178 let line = line_info.content(ctx.content);
179
180 if line_info.in_code_block {
181 result.push(line.to_string());
182 continue;
183 }
184
185 if let Some(heading) = &line_info.heading {
187 if !heading.is_valid {
189 result.push(line.to_string());
190 continue;
191 }
192
193 let line_num = i + 1;
195 if ctx.inline_config().is_rule_disabled("MD022", line_num) {
196 result.push(line.to_string());
197 if matches!(
199 heading.style,
200 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
201 ) && i + 1 < ctx.lines.len()
202 {
203 result.push(ctx.lines[i + 1].content(ctx.content).to_string());
204 skip_count += 1;
205 }
206 continue;
207 }
208
209 let is_first_heading = Some(i) == heading_at_start_idx;
211 let heading_level = heading.level as usize;
212
213 let mut blank_lines_above = 0;
215 let mut check_idx = result.len();
216 while check_idx > 0 {
217 let prev_line = &result[check_idx - 1];
218 let trimmed = prev_line.trim();
219 if trimmed.is_empty() {
220 blank_lines_above += 1;
221 check_idx -= 1;
222 } else if trimmed.starts_with("<!--") && trimmed.ends_with("-->") {
223 check_idx -= 1;
225 } else if is_kramdown_block_attribute(trimmed) {
226 check_idx -= 1;
228 } else if is_pandoc && (pandoc::is_div_open(trimmed) || pandoc::is_div_close(trimmed)) {
229 check_idx -= 1;
231 } else {
232 break;
233 }
234 }
235
236 let requirement_above = self.config.lines_above.get_for_level(heading_level);
238 let needed_blanks_above = if is_first_heading && self.config.allowed_at_start {
239 0
240 } else {
241 requirement_above.required_count().unwrap_or(0)
242 };
243
244 while blank_lines_above < needed_blanks_above {
246 result.push(String::new());
247 blank_lines_above += 1;
248 }
249
250 result.push(line.to_string());
252
253 let mut effective_end_idx = i;
255
256 if matches!(
258 heading.style,
259 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
260 ) {
261 if i + 1 < ctx.lines.len() {
263 result.push(ctx.lines[i + 1].content(ctx.content).to_string());
264 skip_count += 1; effective_end_idx = i + 1;
266 }
267 }
268
269 let mut ial_count = 0;
272 while effective_end_idx + 1 < ctx.lines.len() {
273 let next_line = &ctx.lines[effective_end_idx + 1];
274 let next_trimmed = next_line.content(ctx.content).trim();
275 if is_kramdown_block_attribute(next_trimmed) {
276 result.push(next_trimmed.to_string());
277 effective_end_idx += 1;
278 ial_count += 1;
279 } else {
280 break;
281 }
282 }
283
284 let mut blank_lines_below = 0;
286 let mut next_content_line_idx = None;
287 for j in (effective_end_idx + 1)..ctx.lines.len() {
288 if ctx.lines[j].is_blank {
289 blank_lines_below += 1;
290 } else {
291 next_content_line_idx = Some(j);
292 break;
293 }
294 }
295
296 let next_is_special = if let Some(idx) = next_content_line_idx {
298 let next_line = &ctx.lines[idx];
299 next_line.list_item.is_some() || {
300 let trimmed = next_line.content(ctx.content).trim();
301 (trimmed.starts_with("```") || trimmed.starts_with("~~~"))
302 && (trimmed.len() == 3
303 || (trimmed.len() > 3
304 && trimmed
305 .chars()
306 .nth(3)
307 .is_some_and(|c| c.is_whitespace() || c.is_alphabetic())))
308 }
309 } else {
310 false
311 };
312
313 let requirement_below = self.config.lines_below.get_for_level(heading_level);
315 let needed_blanks_below = if next_is_special {
316 0
317 } else {
318 requirement_below.required_count().unwrap_or(0)
319 };
320 if blank_lines_below < needed_blanks_below {
321 for _ in 0..(needed_blanks_below - blank_lines_below) {
322 result.push(String::new());
323 }
324 }
325
326 skip_count += ial_count;
328 } else {
329 result.push(line.to_string());
331 }
332 }
333
334 let joined = result.join(line_ending);
335
336 if had_trailing_newline && !joined.ends_with('\n') {
339 format!("{joined}{line_ending}")
340 } else if !had_trailing_newline && joined.ends_with('\n') {
341 joined[..joined.len() - 1].to_string()
343 } else {
344 joined
345 }
346 }
347}
348
349impl Rule for MD022BlanksAroundHeadings {
350 fn name(&self) -> &'static str {
351 "MD022"
352 }
353
354 fn description(&self) -> &'static str {
355 "Headings should be surrounded by blank lines"
356 }
357
358 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
359 let mut result = Vec::new();
360
361 if ctx.lines.is_empty() {
363 return Ok(result);
364 }
365
366 let line_ending = "\n";
368 let is_pandoc = ctx.flavor.is_pandoc_compatible();
369
370 let heading_at_start_idx = {
371 let mut found_non_transparent = false;
372 ctx.lines.iter().enumerate().find_map(|(i, line)| {
373 if line.heading.as_ref().is_some_and(|h| h.is_valid) && !found_non_transparent {
375 Some(i)
376 } else {
377 if !line.is_blank && !line.in_html_comment && !line.in_mdx_comment {
380 let trimmed = line.content(ctx.content).trim();
381 if trimmed.starts_with("<!--") && trimmed.ends_with("-->") {
383 } else if line.in_kramdown_extension_block || line.is_kramdown_block_ial {
385 } else if is_pandoc && (pandoc::is_div_open(trimmed) || pandoc::is_div_close(trimmed)) {
387 } else {
389 found_non_transparent = true;
390 }
391 }
392 None
393 }
394 })
395 };
396
397 let mut heading_violations = Vec::new();
399 let mut processed_headings = std::collections::HashSet::new();
400
401 for (line_num, line_info) in ctx.lines.iter().enumerate() {
402 if processed_headings.contains(&line_num) || line_info.heading.is_none() {
404 continue;
405 }
406
407 if line_info.in_pymdown_block {
409 continue;
410 }
411
412 let heading = line_info.heading.as_ref().unwrap();
413
414 if !heading.is_valid {
416 continue;
417 }
418
419 let heading_level = heading.level as usize;
420
421 processed_headings.insert(line_num);
425
426 let is_first_heading = Some(line_num) == heading_at_start_idx;
428
429 let required_above_count = self.config.lines_above.get_for_level(heading_level).required_count();
431 let required_below_count = self.config.lines_below.get_for_level(heading_level).required_count();
432
433 let should_check_above =
435 required_above_count.is_some() && line_num > 0 && (!is_first_heading || !self.config.allowed_at_start);
436 if should_check_above {
437 let mut blank_lines_above = 0;
438 let mut hit_frontmatter_end = false;
439 for j in (0..line_num).rev() {
440 let line_content = ctx.lines[j].content(ctx.content);
441 let trimmed = line_content.trim();
442 if ctx.lines[j].is_blank {
443 blank_lines_above += 1;
444 } else if ctx.lines[j].in_html_comment
445 || ctx.lines[j].in_mdx_comment
446 || (trimmed.starts_with("<!--") && trimmed.ends_with("-->"))
447 {
448 continue;
450 } else if is_kramdown_block_attribute(trimmed) {
451 continue;
453 } else if is_pandoc && (pandoc::is_div_open(trimmed) || pandoc::is_div_close(trimmed)) {
454 continue;
456 } else if ctx.lines[j].in_front_matter {
457 hit_frontmatter_end = true;
462 break;
463 } else {
464 break;
465 }
466 }
467 let required = required_above_count.unwrap();
468 if !hit_frontmatter_end && blank_lines_above < required {
469 let needed_blanks = required - blank_lines_above;
470 heading_violations.push((line_num, "above", needed_blanks, heading_level));
471 }
472 }
473
474 let mut effective_last_line = if matches!(
476 heading.style,
477 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
478 ) {
479 line_num + 1 } else {
481 line_num
482 };
483
484 while effective_last_line + 1 < ctx.lines.len() {
487 let next_line = &ctx.lines[effective_last_line + 1];
488 let next_trimmed = next_line.content(ctx.content).trim();
489 if is_kramdown_block_attribute(next_trimmed) {
490 effective_last_line += 1;
491 } else {
492 break;
493 }
494 }
495
496 if effective_last_line < ctx.lines.len() - 1 {
498 let mut next_non_blank_idx = effective_last_line + 1;
500 while next_non_blank_idx < ctx.lines.len() {
501 let check_line = &ctx.lines[next_non_blank_idx];
502 let check_trimmed = check_line.content(ctx.content).trim();
503 if check_line.is_blank {
504 next_non_blank_idx += 1;
505 } else if check_line.in_html_comment
506 || check_line.in_mdx_comment
507 || (check_trimmed.starts_with("<!--") && check_trimmed.ends_with("-->"))
508 {
509 next_non_blank_idx += 1;
511 } else if is_pandoc && (pandoc::is_div_open(check_trimmed) || pandoc::is_div_close(check_trimmed)) {
512 next_non_blank_idx += 1;
514 } else {
515 break;
516 }
517 }
518
519 if next_non_blank_idx >= ctx.lines.len() {
521 continue;
523 }
524
525 let next_line_is_special = {
527 let next_line = &ctx.lines[next_non_blank_idx];
528 let next_trimmed = next_line.content(ctx.content).trim();
529
530 let is_code_fence = (next_trimmed.starts_with("```") || next_trimmed.starts_with("~~~"))
532 && (next_trimmed.len() == 3
533 || (next_trimmed.len() > 3
534 && next_trimmed
535 .chars()
536 .nth(3)
537 .is_some_and(|c| c.is_whitespace() || c.is_alphabetic())));
538
539 let is_list_item = next_line.list_item.is_some() || starts_with_list_marker(next_trimmed);
546
547 is_code_fence || is_list_item
548 };
549
550 if !next_line_is_special && let Some(required) = required_below_count {
552 let mut blank_lines_below = 0;
554 for k in (effective_last_line + 1)..next_non_blank_idx {
555 if ctx.lines[k].is_blank {
556 blank_lines_below += 1;
557 }
558 }
559
560 if blank_lines_below < required {
561 let needed_blanks = required - blank_lines_below;
562 heading_violations.push((line_num, "below", needed_blanks, heading_level));
563 }
564 }
565 }
566 }
567
568 for (heading_line, position, needed_blanks, heading_level) in heading_violations {
570 let heading_display_line = heading_line + 1; let line_info = &ctx.lines[heading_line];
572
573 let (start_line, start_col, end_line, end_col) =
575 calculate_heading_range(heading_display_line, line_info.content(ctx.content));
576
577 let required_above_count = self
578 .config
579 .lines_above
580 .get_for_level(heading_level)
581 .required_count()
582 .expect("Violations only generated for limited 'above' requirements");
583 let required_below_count = self
584 .config
585 .lines_below
586 .get_for_level(heading_level)
587 .required_count()
588 .expect("Violations only generated for limited 'below' requirements");
589
590 let (message, insertion_point) = match position {
591 "above" => (
592 format!(
593 "Expected {} blank {} above heading",
594 required_above_count,
595 if required_above_count == 1 { "line" } else { "lines" }
596 ),
597 heading_line, ),
599 "below" => {
600 let insert_after = if line_info.heading.as_ref().is_some_and(|h| {
602 matches!(
603 h.style,
604 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
605 )
606 }) {
607 heading_line + 2
608 } else {
609 heading_line + 1
610 };
611
612 (
613 format!(
614 "Expected {} blank {} below heading",
615 required_below_count,
616 if required_below_count == 1 { "line" } else { "lines" }
617 ),
618 insert_after,
619 )
620 }
621 _ => continue,
622 };
623
624 let byte_range = if insertion_point == 0 && position == "above" {
626 0..0
628 } else if position == "above" && insertion_point > 0 {
629 ctx.lines[insertion_point].byte_offset..ctx.lines[insertion_point].byte_offset
631 } else if position == "below" && insertion_point - 1 < ctx.lines.len() {
632 let line_idx = insertion_point - 1;
634 let line_end_offset = if line_idx + 1 < ctx.lines.len() {
635 ctx.lines[line_idx + 1].byte_offset
636 } else {
637 ctx.content.len()
638 };
639 line_end_offset..line_end_offset
640 } else {
641 let content_len = ctx.content.len();
643 content_len..content_len
644 };
645
646 result.push(LintWarning {
647 rule_name: Some(self.name().to_string()),
648 message,
649 line: start_line,
650 column: start_col,
651 end_line,
652 end_column: end_col,
653 severity: Severity::Warning,
654 fix: Some(Fix::new(byte_range, line_ending.repeat(needed_blanks))),
655 });
656 }
657
658 Ok(result)
659 }
660
661 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
662 if ctx.content.is_empty() {
663 return Ok(ctx.content.to_string());
664 }
665
666 let fixed = self.fix_content(ctx);
668
669 Ok(fixed)
670 }
671
672 fn category(&self) -> RuleCategory {
674 RuleCategory::Heading
675 }
676
677 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
679 if ctx.content.is_empty() || !ctx.likely_has_headings() {
681 return true;
682 }
683 ctx.lines.iter().all(|line| line.heading.is_none())
685 }
686
687 fn as_any(&self) -> &dyn std::any::Any {
688 self
689 }
690
691 fn default_config_section(&self) -> Option<(String, toml::Value)> {
692 let default_config = MD022Config::default();
693 let json_value = serde_json::to_value(&default_config).ok()?;
694 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
695
696 if let toml::Value::Table(table) = toml_value {
697 if !table.is_empty() {
698 Some((MD022Config::RULE_NAME.to_string(), toml::Value::Table(table)))
699 } else {
700 None
701 }
702 } else {
703 None
704 }
705 }
706
707 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
708 where
709 Self: Sized,
710 {
711 let rule_config = crate::rule_config_serde::load_rule_config::<MD022Config>(config);
712 Box::new(Self::from_config_struct(rule_config))
713 }
714}
715
716#[cfg(test)]
717mod tests {
718 use super::*;
719 use crate::lint_context::LintContext;
720
721 #[test]
722 fn test_valid_headings() {
723 let rule = MD022BlanksAroundHeadings::default();
724 let content = "\n# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.\n";
725 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
726 let result = rule.check(&ctx).unwrap();
727 assert!(result.is_empty());
728 }
729
730 #[test]
731 fn test_missing_blank_above() {
732 let rule = MD022BlanksAroundHeadings::default();
733 let content = "# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.\n";
734 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
735 let result = rule.check(&ctx).unwrap();
736 assert_eq!(result.len(), 0); let fixed = rule.fix(&ctx).unwrap();
739
740 assert!(fixed.contains("# Heading 1"));
743 assert!(fixed.contains("Some content."));
744 assert!(fixed.contains("## Heading 2"));
745 assert!(fixed.contains("More content."));
746 }
747
748 #[test]
749 fn test_missing_blank_below() {
750 let rule = MD022BlanksAroundHeadings::default();
751 let content = "\n# Heading 1\nSome content.\n\n## Heading 2\n\nMore content.\n";
752 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
753 let result = rule.check(&ctx).unwrap();
754 assert_eq!(result.len(), 1);
755 assert_eq!(result[0].line, 2);
756
757 let fixed = rule.fix(&ctx).unwrap();
759 assert!(fixed.contains("# Heading 1\n\nSome content"));
760 }
761
762 #[test]
763 fn test_missing_blank_above_and_below() {
764 let rule = MD022BlanksAroundHeadings::default();
765 let content = "# Heading 1\nSome content.\n## Heading 2\nMore content.\n";
766 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
767 let result = rule.check(&ctx).unwrap();
768 assert_eq!(result.len(), 3); let fixed = rule.fix(&ctx).unwrap();
772 assert!(fixed.contains("# Heading 1\n\nSome content"));
773 assert!(fixed.contains("Some content.\n\n## Heading 2"));
774 assert!(fixed.contains("## Heading 2\n\nMore content"));
775 }
776
777 #[test]
778 fn test_fix_headings() {
779 let rule = MD022BlanksAroundHeadings::default();
780 let content = "# Heading 1\nSome content.\n## Heading 2\nMore content.";
781 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
782 let result = rule.fix(&ctx).unwrap();
783
784 let expected = "# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.";
785 assert_eq!(result, expected);
786 }
787
788 #[test]
789 fn test_consecutive_headings_pattern() {
790 let rule = MD022BlanksAroundHeadings::default();
791 let content = "# Heading 1\n## Heading 2\n### Heading 3";
792 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
793 let result = rule.fix(&ctx).unwrap();
794
795 let lines: Vec<&str> = result.lines().collect();
797 assert!(!lines.is_empty());
798
799 let h1_pos = lines.iter().position(|&l| l == "# Heading 1").unwrap();
801 let h2_pos = lines.iter().position(|&l| l == "## Heading 2").unwrap();
802 let h3_pos = lines.iter().position(|&l| l == "### Heading 3").unwrap();
803
804 assert!(
806 h2_pos > h1_pos + 1,
807 "Should have at least one blank line after first heading"
808 );
809 assert!(
810 h3_pos > h2_pos + 1,
811 "Should have at least one blank line after second heading"
812 );
813
814 assert!(lines[h1_pos + 1].trim().is_empty(), "Line after h1 should be blank");
816
817 assert!(lines[h2_pos + 1].trim().is_empty(), "Line after h2 should be blank");
819 }
820
821 #[test]
822 fn test_blanks_around_setext_headings() {
823 let rule = MD022BlanksAroundHeadings::default();
824 let content = "Heading 1\n=========\nSome content.\nHeading 2\n---------\nMore content.";
825 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
826 let result = rule.fix(&ctx).unwrap();
827
828 let lines: Vec<&str> = result.lines().collect();
830
831 assert!(result.contains("Heading 1"));
833 assert!(result.contains("========="));
834 assert!(result.contains("Some content."));
835 assert!(result.contains("Heading 2"));
836 assert!(result.contains("---------"));
837 assert!(result.contains("More content."));
838
839 let heading1_marker_idx = lines.iter().position(|&l| l == "=========").unwrap();
841 let some_content_idx = lines.iter().position(|&l| l == "Some content.").unwrap();
842 assert!(
843 some_content_idx > heading1_marker_idx + 1,
844 "Should have a blank line after the first heading"
845 );
846
847 let heading2_marker_idx = lines.iter().position(|&l| l == "---------").unwrap();
848 let more_content_idx = lines.iter().position(|&l| l == "More content.").unwrap();
849 assert!(
850 more_content_idx > heading2_marker_idx + 1,
851 "Should have a blank line after the second heading"
852 );
853
854 let fixed_ctx = LintContext::new(&result, crate::config::MarkdownFlavor::Standard, None);
856 let fixed_warnings = rule.check(&fixed_ctx).unwrap();
857 assert!(fixed_warnings.is_empty(), "Fixed content should have no warnings");
858 }
859
860 #[test]
861 fn test_fix_specific_blank_line_cases() {
862 let rule = MD022BlanksAroundHeadings::default();
863
864 let content1 = "# Heading 1\n## Heading 2\n### Heading 3";
866 let ctx1 = LintContext::new(content1, crate::config::MarkdownFlavor::Standard, None);
867 let result1 = rule.fix(&ctx1).unwrap();
868 assert!(result1.contains("# Heading 1"));
870 assert!(result1.contains("## Heading 2"));
871 assert!(result1.contains("### Heading 3"));
872 let lines: Vec<&str> = result1.lines().collect();
874 let h1_pos = lines.iter().position(|&l| l == "# Heading 1").unwrap();
875 let h2_pos = lines.iter().position(|&l| l == "## Heading 2").unwrap();
876 assert!(lines[h1_pos + 1].trim().is_empty(), "Should have a blank line after h1");
877 assert!(lines[h2_pos + 1].trim().is_empty(), "Should have a blank line after h2");
878
879 let content2 = "# Heading 1\nContent under heading 1\n## Heading 2";
881 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
882 let result2 = rule.fix(&ctx2).unwrap();
883 assert!(result2.contains("# Heading 1"));
885 assert!(result2.contains("Content under heading 1"));
886 assert!(result2.contains("## Heading 2"));
887 let lines2: Vec<&str> = result2.lines().collect();
889 let h1_pos2 = lines2.iter().position(|&l| l == "# Heading 1").unwrap();
890 let _content_pos = lines2.iter().position(|&l| l == "Content under heading 1").unwrap();
891 assert!(
892 lines2[h1_pos2 + 1].trim().is_empty(),
893 "Should have a blank line after heading 1"
894 );
895
896 let content3 = "# Heading 1\n\n\n## Heading 2\n\n\n### Heading 3\n\nContent";
898 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
899 let result3 = rule.fix(&ctx3).unwrap();
900 assert!(result3.contains("# Heading 1"));
902 assert!(result3.contains("## Heading 2"));
903 assert!(result3.contains("### Heading 3"));
904 assert!(result3.contains("Content"));
905 }
906
907 #[test]
908 fn test_fix_preserves_existing_blank_lines() {
909 let rule = MD022BlanksAroundHeadings::new();
910 let content = "# Title
911
912## Section 1
913
914Content here.
915
916## Section 2
917
918More content.
919### Missing Blank Above
920
921Even more content.
922
923## Section 3
924
925Final content.";
926
927 let expected = "# Title
928
929## Section 1
930
931Content here.
932
933## Section 2
934
935More content.
936
937### Missing Blank Above
938
939Even more content.
940
941## Section 3
942
943Final content.";
944
945 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
946 let result = rule.fix_content(&ctx);
947 assert_eq!(
948 result, expected,
949 "Fix should only add missing blank lines, never remove existing ones"
950 );
951 }
952
953 #[test]
954 fn test_fix_preserves_trailing_newline() {
955 let rule = MD022BlanksAroundHeadings::new();
956
957 let content_with_newline = "# Title\nContent here.\n";
959 let ctx = LintContext::new(content_with_newline, crate::config::MarkdownFlavor::Standard, None);
960 let result = rule.fix(&ctx).unwrap();
961 assert!(result.ends_with('\n'), "Should preserve trailing newline");
962
963 let content_without_newline = "# Title\nContent here.";
965 let ctx = LintContext::new(content_without_newline, crate::config::MarkdownFlavor::Standard, None);
966 let result = rule.fix(&ctx).unwrap();
967 assert!(
968 !result.ends_with('\n'),
969 "Should not add trailing newline if original didn't have one"
970 );
971 }
972
973 #[test]
974 fn test_fix_does_not_add_blank_lines_before_lists() {
975 let rule = MD022BlanksAroundHeadings::new();
976 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.";
977
978 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.";
979
980 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
981 let result = rule.fix_content(&ctx);
982 assert_eq!(result, expected, "Fix should not add blank lines before lists");
983 }
984
985 #[test]
986 fn test_fix_idempotent_when_heading_follows_a_list_and_precedes_a_marker() {
987 let rule = MD022BlanksAroundHeadings::default();
993 let content = "- a\n# H\n2. ";
994 for flavor in [
995 crate::config::MarkdownFlavor::Standard,
996 crate::config::MarkdownFlavor::MkDocs,
997 crate::config::MarkdownFlavor::MDX,
998 ] {
999 let ctx1 = LintContext::new(content, flavor, None);
1000 let fixed1 = rule.fix(&ctx1).unwrap();
1001 let ctx2 = LintContext::new(&fixed1, flavor, None);
1002 let fixed2 = rule.fix(&ctx2).unwrap();
1003 assert_eq!(fixed1, fixed2, "MD022 fix must be idempotent (flavor={flavor:?})");
1004 }
1005 }
1006
1007 #[test]
1008 fn test_per_level_configuration_no_blank_above_h1() {
1009 use md022_config::HeadingLevelConfig;
1010
1011 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1013 lines_above: HeadingLevelConfig::per_level([0, 1, 1, 1, 1, 1]),
1014 lines_below: HeadingLevelConfig::scalar(1),
1015 allowed_at_start: false, });
1017
1018 let content = "Some text\n# Heading 1\n\nMore text";
1020 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1021 let warnings = rule.check(&ctx).unwrap();
1022 assert_eq!(warnings.len(), 0, "H1 without blank above should not trigger warning");
1023
1024 let content = "Some text\n## Heading 2\n\nMore text";
1026 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1027 let warnings = rule.check(&ctx).unwrap();
1028 assert_eq!(warnings.len(), 1, "H2 without blank above should trigger warning");
1029 assert!(warnings[0].message.contains("above"));
1030 }
1031
1032 #[test]
1033 fn test_per_level_configuration_different_requirements() {
1034 use md022_config::HeadingLevelConfig;
1035
1036 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1038 lines_above: HeadingLevelConfig::per_level([0, 1, 1, 2, 2, 2]),
1039 lines_below: HeadingLevelConfig::scalar(1),
1040 allowed_at_start: false,
1041 });
1042
1043 let content = "Text\n# H1\n\nText\n\n## H2\n\nText\n\n### H3\n\nText\n\n\n#### H4\n\nText";
1044 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1045 let warnings = rule.check(&ctx).unwrap();
1046
1047 assert_eq!(
1049 warnings.len(),
1050 0,
1051 "All headings should satisfy level-specific requirements"
1052 );
1053 }
1054
1055 #[test]
1056 fn test_per_level_configuration_violations() {
1057 use md022_config::HeadingLevelConfig;
1058
1059 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1061 lines_above: HeadingLevelConfig::per_level([1, 1, 1, 2, 1, 1]),
1062 lines_below: HeadingLevelConfig::scalar(1),
1063 allowed_at_start: false,
1064 });
1065
1066 let content = "Text\n\n#### Heading 4\n\nMore text";
1068 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1069 let warnings = rule.check(&ctx).unwrap();
1070
1071 assert_eq!(warnings.len(), 1, "H4 with insufficient blanks should trigger warning");
1072 assert!(warnings[0].message.contains("2 blank lines above"));
1073 }
1074
1075 #[test]
1076 fn test_per_level_fix_different_levels() {
1077 use md022_config::HeadingLevelConfig;
1078
1079 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1081 lines_above: HeadingLevelConfig::per_level([0, 1, 2, 2, 2, 2]),
1082 lines_below: HeadingLevelConfig::scalar(1),
1083 allowed_at_start: false,
1084 });
1085
1086 let content = "Text\n# H1\nContent\n## H2\nContent\n### H3\nContent";
1087 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1088 let fixed = rule.fix(&ctx).unwrap();
1089
1090 assert!(fixed.contains("Text\n# H1\n\nContent"));
1092 assert!(fixed.contains("Content\n\n## H2\n\nContent"));
1093 assert!(fixed.contains("Content\n\n\n### H3\n\nContent"));
1094 }
1095
1096 #[test]
1097 fn test_per_level_below_configuration() {
1098 use md022_config::HeadingLevelConfig;
1099
1100 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1102 lines_above: HeadingLevelConfig::scalar(1),
1103 lines_below: HeadingLevelConfig::per_level([2, 1, 1, 1, 1, 1]), allowed_at_start: true,
1105 });
1106
1107 let content = "# Heading 1\n\nSome text";
1109 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1110 let warnings = rule.check(&ctx).unwrap();
1111
1112 assert_eq!(
1113 warnings.len(),
1114 1,
1115 "H1 with insufficient blanks below should trigger warning"
1116 );
1117 assert!(warnings[0].message.contains("2 blank lines below"));
1118 }
1119
1120 #[test]
1121 fn test_scalar_configuration_still_works() {
1122 use md022_config::HeadingLevelConfig;
1123
1124 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1126 lines_above: HeadingLevelConfig::scalar(2),
1127 lines_below: HeadingLevelConfig::scalar(2),
1128 allowed_at_start: false,
1129 });
1130
1131 let content = "Text\n# H1\nContent\n## H2\nContent";
1132 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1133 let warnings = rule.check(&ctx).unwrap();
1134
1135 assert!(!warnings.is_empty(), "Should have violations for insufficient blanks");
1137 }
1138
1139 #[test]
1140 fn test_unlimited_configuration_skips_requirements() {
1141 use md022_config::{HeadingBlankRequirement, HeadingLevelConfig};
1142
1143 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1145 lines_above: HeadingLevelConfig::per_level_requirements([
1146 HeadingBlankRequirement::unlimited(),
1147 HeadingBlankRequirement::limited(1),
1148 HeadingBlankRequirement::limited(1),
1149 HeadingBlankRequirement::limited(1),
1150 HeadingBlankRequirement::limited(1),
1151 HeadingBlankRequirement::limited(1),
1152 ]),
1153 lines_below: HeadingLevelConfig::per_level_requirements([
1154 HeadingBlankRequirement::unlimited(),
1155 HeadingBlankRequirement::limited(1),
1156 HeadingBlankRequirement::limited(1),
1157 HeadingBlankRequirement::limited(1),
1158 HeadingBlankRequirement::limited(1),
1159 HeadingBlankRequirement::limited(1),
1160 ]),
1161 allowed_at_start: false,
1162 });
1163
1164 let content = "# H1\nParagraph\n## H2\nParagraph";
1165 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1166 let warnings = rule.check(&ctx).unwrap();
1167
1168 assert_eq!(warnings.len(), 2, "Only non-unlimited headings should warn");
1170 assert!(
1171 warnings.iter().all(|w| w.line >= 3),
1172 "Warnings should target later headings"
1173 );
1174
1175 let fixed = rule.fix(&ctx).unwrap();
1177 assert!(
1178 fixed.starts_with("# H1\nParagraph\n\n## H2"),
1179 "H1 should remain unchanged"
1180 );
1181 }
1182
1183 #[test]
1184 fn test_html_comment_transparency() {
1185 let rule = MD022BlanksAroundHeadings::default();
1189
1190 let content = "Some content\n\n<!-- markdownlint-disable-next-line MD001 -->\n#### Heading";
1193 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1194 let warnings = rule.check(&ctx).unwrap();
1195 assert!(
1196 warnings.is_empty(),
1197 "HTML comment is transparent - blank line above it counts for heading"
1198 );
1199
1200 let content_multiline = "Some content\n\n<!-- This is a\nmulti-line comment -->\n#### Heading";
1202 let ctx_multiline = LintContext::new(content_multiline, crate::config::MarkdownFlavor::Standard, None);
1203 let warnings_multiline = rule.check(&ctx_multiline).unwrap();
1204 assert!(
1205 warnings_multiline.is_empty(),
1206 "Multi-line HTML comment is also transparent"
1207 );
1208 }
1209
1210 #[test]
1211 fn test_frontmatter_transparency() {
1212 let rule = MD022BlanksAroundHeadings::default();
1215
1216 let content = "---\ntitle: Test\n---\n# First heading";
1218 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1219 let warnings = rule.check(&ctx).unwrap();
1220 assert!(
1221 warnings.is_empty(),
1222 "Frontmatter is transparent - heading can appear immediately after"
1223 );
1224
1225 let content_with_blank = "---\ntitle: Test\n---\n\n# First heading";
1227 let ctx_with_blank = LintContext::new(content_with_blank, crate::config::MarkdownFlavor::Standard, None);
1228 let warnings_with_blank = rule.check(&ctx_with_blank).unwrap();
1229 assert!(
1230 warnings_with_blank.is_empty(),
1231 "Heading with blank line after frontmatter should also be valid"
1232 );
1233
1234 let content_toml = "+++\ntitle = \"Test\"\n+++\n# First heading";
1236 let ctx_toml = LintContext::new(content_toml, crate::config::MarkdownFlavor::Standard, None);
1237 let warnings_toml = rule.check(&ctx_toml).unwrap();
1238 assert!(
1239 warnings_toml.is_empty(),
1240 "TOML frontmatter is also transparent for MD022"
1241 );
1242 }
1243
1244 #[test]
1245 fn test_horizontal_rule_not_treated_as_frontmatter() {
1246 let rule = MD022BlanksAroundHeadings::default();
1249
1250 let content = "Some content\n\n---\n# Heading after HR";
1252 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1253 let warnings = rule.check(&ctx).unwrap();
1254 assert!(
1255 !warnings.is_empty(),
1256 "Heading after horizontal rule without blank line SHOULD trigger MD022"
1257 );
1258 assert!(
1259 warnings.iter().any(|w| w.line == 4),
1260 "Warning should be on line 4 (the heading line)"
1261 );
1262
1263 let content_with_blank = "Some content\n\n---\n\n# Heading after HR";
1265 let ctx_with_blank = LintContext::new(content_with_blank, crate::config::MarkdownFlavor::Standard, None);
1266 let warnings_with_blank = rule.check(&ctx_with_blank).unwrap();
1267 assert!(
1268 warnings_with_blank.is_empty(),
1269 "Heading with blank line after HR should not trigger MD022"
1270 );
1271
1272 let content_hr_start = "---\n# Heading";
1274 let ctx_hr_start = LintContext::new(content_hr_start, crate::config::MarkdownFlavor::Standard, None);
1275 let warnings_hr_start = rule.check(&ctx_hr_start).unwrap();
1276 assert!(
1277 !warnings_hr_start.is_empty(),
1278 "Heading after HR at document start SHOULD trigger MD022"
1279 );
1280
1281 let content_multi_hr = "Content\n\n---\n\n---\n# Heading";
1283 let ctx_multi_hr = LintContext::new(content_multi_hr, crate::config::MarkdownFlavor::Standard, None);
1284 let warnings_multi_hr = rule.check(&ctx_multi_hr).unwrap();
1285 assert!(
1286 !warnings_multi_hr.is_empty(),
1287 "Heading after multiple HRs without blank line SHOULD trigger MD022"
1288 );
1289 }
1290
1291 #[test]
1292 fn test_all_hr_styles_require_blank_before_heading() {
1293 let rule = MD022BlanksAroundHeadings::default();
1295
1296 let hr_styles = [
1298 "---", "***", "___", "- - -", "* * *", "_ _ _", "----", "****", "____", "- - - -",
1299 "- - -", " ---", " ---", ];
1303
1304 for hr in hr_styles {
1305 let content = format!("Content\n\n{hr}\n# Heading");
1306 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1307 let warnings = rule.check(&ctx).unwrap();
1308 assert!(
1309 !warnings.is_empty(),
1310 "HR style '{hr}' followed by heading should trigger MD022"
1311 );
1312 }
1313 }
1314
1315 #[test]
1316 fn test_setext_heading_after_hr() {
1317 let rule = MD022BlanksAroundHeadings::default();
1319
1320 let content = "Content\n\n---\nHeading\n======";
1322 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1323 let warnings = rule.check(&ctx).unwrap();
1324 assert!(
1325 !warnings.is_empty(),
1326 "Setext heading after HR without blank should trigger MD022"
1327 );
1328
1329 let content_h2 = "Content\n\n---\nHeading\n------";
1331 let ctx_h2 = LintContext::new(content_h2, crate::config::MarkdownFlavor::Standard, None);
1332 let warnings_h2 = rule.check(&ctx_h2).unwrap();
1333 assert!(
1334 !warnings_h2.is_empty(),
1335 "Setext h2 after HR without blank should trigger MD022"
1336 );
1337
1338 let content_ok = "Content\n\n---\n\nHeading\n======";
1340 let ctx_ok = LintContext::new(content_ok, crate::config::MarkdownFlavor::Standard, None);
1341 let warnings_ok = rule.check(&ctx_ok).unwrap();
1342 assert!(
1343 warnings_ok.is_empty(),
1344 "Setext heading with blank after HR should not warn"
1345 );
1346 }
1347
1348 #[test]
1349 fn test_hr_in_code_block_not_treated_as_hr() {
1350 let rule = MD022BlanksAroundHeadings::default();
1352
1353 let content = "```\n---\n```\n# Heading";
1356 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1357 let warnings = rule.check(&ctx).unwrap();
1358 assert!(!warnings.is_empty(), "Heading after code block still needs blank line");
1361
1362 let content_ok = "```\n---\n```\n\n# Heading";
1364 let ctx_ok = LintContext::new(content_ok, crate::config::MarkdownFlavor::Standard, None);
1365 let warnings_ok = rule.check(&ctx_ok).unwrap();
1366 assert!(
1367 warnings_ok.is_empty(),
1368 "Heading with blank after code block should not warn"
1369 );
1370 }
1371
1372 #[test]
1373 fn test_hr_in_html_comment_not_treated_as_hr() {
1374 let rule = MD022BlanksAroundHeadings::default();
1376
1377 let content = "<!-- \n---\n -->\n# Heading";
1379 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1380 let warnings = rule.check(&ctx).unwrap();
1381 assert!(
1383 warnings.is_empty(),
1384 "HR inside HTML comment should be ignored - heading after comment is OK"
1385 );
1386 }
1387
1388 #[test]
1389 fn test_invalid_hr_not_triggering() {
1390 let rule = MD022BlanksAroundHeadings::default();
1392
1393 let invalid_hrs = [
1394 " ---", "\t---", "--", "**", "__", "-*-", "---a", "a---", ];
1403
1404 for invalid in invalid_hrs {
1405 let content = format!("Content\n\n{invalid}\n# Heading");
1408 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1409 let _ = rule.check(&ctx);
1412 }
1413 }
1414
1415 #[test]
1416 fn test_frontmatter_vs_horizontal_rule_distinction() {
1417 let rule = MD022BlanksAroundHeadings::default();
1419
1420 let content = "---\ntitle: Test\n---\n\nSome content\n\n---\n# Heading after HR";
1423 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1424 let warnings = rule.check(&ctx).unwrap();
1425 assert!(
1426 !warnings.is_empty(),
1427 "HR after frontmatter content should still require blank line before heading"
1428 );
1429
1430 let content_ok = "---\ntitle: Test\n---\n\nSome content\n\n---\n\n# Heading after HR";
1432 let ctx_ok = LintContext::new(content_ok, crate::config::MarkdownFlavor::Standard, None);
1433 let warnings_ok = rule.check(&ctx_ok).unwrap();
1434 assert!(
1435 warnings_ok.is_empty(),
1436 "HR with blank line before heading should not warn"
1437 );
1438 }
1439
1440 #[test]
1443 fn test_kramdown_ial_after_heading_no_warning() {
1444 let rule = MD022BlanksAroundHeadings::default();
1446 let content = "## Table of Contents\n{: .hhc-toc-heading}\n\nSome content here.";
1447 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1448 let warnings = rule.check(&ctx).unwrap();
1449
1450 assert!(
1451 warnings.is_empty(),
1452 "IAL after heading should not require blank line between them: {warnings:?}"
1453 );
1454 }
1455
1456 #[test]
1457 fn test_kramdown_ial_with_class() {
1458 let rule = MD022BlanksAroundHeadings::default();
1459 let content = "# Heading\n{:.highlight}\n\nContent.";
1460 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1461 let warnings = rule.check(&ctx).unwrap();
1462
1463 assert!(warnings.is_empty(), "IAL with class should be part of heading");
1464 }
1465
1466 #[test]
1467 fn test_kramdown_ial_with_id() {
1468 let rule = MD022BlanksAroundHeadings::default();
1469 let content = "# Heading\n{:#custom-id}\n\nContent.";
1470 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1471 let warnings = rule.check(&ctx).unwrap();
1472
1473 assert!(warnings.is_empty(), "IAL with id should be part of heading");
1474 }
1475
1476 #[test]
1477 fn test_kramdown_ial_with_multiple_attributes() {
1478 let rule = MD022BlanksAroundHeadings::default();
1479 let content = "# Heading\n{: .class #id style=\"color: red\"}\n\nContent.";
1480 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1481 let warnings = rule.check(&ctx).unwrap();
1482
1483 assert!(
1484 warnings.is_empty(),
1485 "IAL with multiple attributes should be part of heading"
1486 );
1487 }
1488
1489 #[test]
1490 fn test_kramdown_ial_missing_blank_after() {
1491 let rule = MD022BlanksAroundHeadings::default();
1493 let content = "# Heading\n{:.class}\nContent without blank.";
1494 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1495 let warnings = rule.check(&ctx).unwrap();
1496
1497 assert_eq!(
1498 warnings.len(),
1499 1,
1500 "Should warn about missing blank after IAL (part of heading)"
1501 );
1502 assert!(warnings[0].message.contains("below"));
1503 }
1504
1505 #[test]
1506 fn test_kramdown_ial_before_heading_transparent() {
1507 let rule = MD022BlanksAroundHeadings::default();
1509 let content = "Content.\n\n{:.preclass}\n## Heading\n\nMore content.";
1510 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1511 let warnings = rule.check(&ctx).unwrap();
1512
1513 assert!(
1514 warnings.is_empty(),
1515 "IAL before heading should be transparent for blank line count"
1516 );
1517 }
1518
1519 #[test]
1520 fn test_kramdown_ial_setext_heading() {
1521 let rule = MD022BlanksAroundHeadings::default();
1522 let content = "Heading\n=======\n{:.setext-class}\n\nContent.";
1523 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1524 let warnings = rule.check(&ctx).unwrap();
1525
1526 assert!(
1527 warnings.is_empty(),
1528 "IAL after Setext heading should be part of heading"
1529 );
1530 }
1531
1532 #[test]
1533 fn test_kramdown_ial_fix_preserves_ial() {
1534 let rule = MD022BlanksAroundHeadings::default();
1535 let content = "Content.\n# Heading\n{:.class}\nMore content.";
1536 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1537 let fixed = rule.fix(&ctx).unwrap();
1538
1539 assert!(
1541 fixed.contains("# Heading\n{:.class}"),
1542 "IAL should stay attached to heading"
1543 );
1544 assert!(fixed.contains("{:.class}\n\nMore"), "Should add blank after IAL");
1545 }
1546
1547 #[test]
1548 fn test_kramdown_ial_fix_does_not_separate() {
1549 let rule = MD022BlanksAroundHeadings::default();
1550 let content = "# Heading\n{:.class}\nContent.";
1551 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1552 let fixed = rule.fix(&ctx).unwrap();
1553
1554 assert!(
1556 !fixed.contains("# Heading\n\n{:.class}"),
1557 "Should not add blank between heading and IAL"
1558 );
1559 assert!(fixed.contains("# Heading\n{:.class}"), "IAL should remain attached");
1560 }
1561
1562 #[test]
1563 fn test_kramdown_multiple_ial_lines() {
1564 let rule = MD022BlanksAroundHeadings::default();
1566 let content = "# Heading\n{:.class1}\n{:#id}\n\nContent.";
1567 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1568 let warnings = rule.check(&ctx).unwrap();
1569
1570 assert!(
1573 warnings.is_empty(),
1574 "Multiple consecutive IALs should be part of heading"
1575 );
1576 }
1577
1578 #[test]
1579 fn test_kramdown_ial_with_blank_line_not_attached() {
1580 let rule = MD022BlanksAroundHeadings::default();
1582 let content = "# Heading\n\n{:.class}\nContent.";
1583 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1584 let warnings = rule.check(&ctx).unwrap();
1585
1586 assert!(warnings.is_empty(), "Blank line separates heading from IAL");
1590 }
1591
1592 #[test]
1593 fn test_not_kramdown_ial_regular_braces() {
1594 let rule = MD022BlanksAroundHeadings::default();
1596 let content = "# Heading\n{not an ial}\n\nContent.";
1597 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1598 let warnings = rule.check(&ctx).unwrap();
1599
1600 assert_eq!(
1602 warnings.len(),
1603 1,
1604 "Non-IAL braces should be regular content requiring blank"
1605 );
1606 }
1607
1608 #[test]
1609 fn test_kramdown_ial_at_document_end() {
1610 let rule = MD022BlanksAroundHeadings::default();
1611 let content = "# Heading\n{:.class}";
1612 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1613 let warnings = rule.check(&ctx).unwrap();
1614
1615 assert!(warnings.is_empty(), "IAL at document end needs no blank after");
1617 }
1618
1619 #[test]
1620 fn test_kramdown_ial_followed_by_code_fence() {
1621 let rule = MD022BlanksAroundHeadings::default();
1622 let content = "# Heading\n{:.class}\n```\ncode\n```";
1623 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1624 let warnings = rule.check(&ctx).unwrap();
1625
1626 assert!(warnings.is_empty(), "No blank needed between IAL and code fence");
1628 }
1629
1630 #[test]
1631 fn test_kramdown_ial_followed_by_list() {
1632 let rule = MD022BlanksAroundHeadings::default();
1633 let content = "# Heading\n{:.class}\n- List item";
1634 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1635 let warnings = rule.check(&ctx).unwrap();
1636
1637 assert!(warnings.is_empty(), "No blank needed between IAL and list");
1639 }
1640
1641 #[test]
1642 fn test_kramdown_ial_fix_idempotent() {
1643 let rule = MD022BlanksAroundHeadings::default();
1644 let content = "# Heading\n{:.class}\nContent.";
1645 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1646
1647 let fixed_once = rule.fix(&ctx).unwrap();
1648 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
1649 let fixed_twice = rule.fix(&ctx2).unwrap();
1650
1651 assert_eq!(fixed_once, fixed_twice, "Fix should be idempotent");
1652 }
1653
1654 #[test]
1655 fn test_kramdown_ial_whitespace_line_between_not_attached() {
1656 let rule = MD022BlanksAroundHeadings::default();
1659 let content = "# Heading\n \n{:.class}\n\nContent.";
1660 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1661 let warnings = rule.check(&ctx).unwrap();
1662
1663 assert!(
1667 warnings.is_empty(),
1668 "Whitespace between heading and IAL means IAL is not attached"
1669 );
1670 }
1671
1672 #[test]
1673 fn test_kramdown_ial_html_comment_between() {
1674 let rule = MD022BlanksAroundHeadings::default();
1677 let content = "# Heading\n<!-- comment -->\n{:.class}\n\nContent.";
1678 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1679 let warnings = rule.check(&ctx).unwrap();
1680
1681 assert_eq!(
1685 warnings.len(),
1686 1,
1687 "IAL not attached when comment is between: {warnings:?}"
1688 );
1689 }
1690
1691 #[test]
1692 fn test_kramdown_ial_generic_attribute() {
1693 let rule = MD022BlanksAroundHeadings::default();
1694 let content = "# Heading\n{:data-toc=\"true\" style=\"color: red\"}\n\nContent.";
1695 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1696 let warnings = rule.check(&ctx).unwrap();
1697
1698 assert!(warnings.is_empty(), "Generic attributes should be recognized as IAL");
1699 }
1700
1701 #[test]
1702 fn test_kramdown_ial_fix_multiple_lines_preserves_all() {
1703 let rule = MD022BlanksAroundHeadings::default();
1704 let content = "# Heading\n{:.class1}\n{:#id}\n{:data-x=\"y\"}\nContent.";
1705 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1706
1707 let fixed = rule.fix(&ctx).unwrap();
1708
1709 assert!(fixed.contains("{:.class1}"), "First IAL should be preserved");
1711 assert!(fixed.contains("{:#id}"), "Second IAL should be preserved");
1712 assert!(fixed.contains("{:data-x=\"y\"}"), "Third IAL should be preserved");
1713 assert!(
1715 fixed.contains("{:data-x=\"y\"}\n\nContent"),
1716 "Blank line should be after all IALs"
1717 );
1718 }
1719
1720 #[test]
1721 fn test_kramdown_ial_crlf_line_endings() {
1722 let rule = MD022BlanksAroundHeadings::default();
1723 let content = "# Heading\r\n{:.class}\r\n\r\nContent.";
1724 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1725 let warnings = rule.check(&ctx).unwrap();
1726
1727 assert!(warnings.is_empty(), "CRLF should work correctly with IAL");
1728 }
1729
1730 #[test]
1731 fn test_kramdown_ial_invalid_patterns_not_recognized() {
1732 let rule = MD022BlanksAroundHeadings::default();
1733
1734 let content = "# Heading\n{ :.class}\n\nContent.";
1736 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1737 let warnings = rule.check(&ctx).unwrap();
1738 assert_eq!(warnings.len(), 1, "Invalid IAL syntax should trigger warning");
1739
1740 let content2 = "# Heading\n{.class}\n\nContent.";
1742 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1743 let warnings2 = rule.check(&ctx2).unwrap();
1744 assert!(warnings2.is_empty(), "{{.class}} is valid kramdown block attribute");
1746
1747 let content3 = "# Heading\n{just text}\n\nContent.";
1749 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
1750 let warnings3 = rule.check(&ctx3).unwrap();
1751 assert_eq!(
1752 warnings3.len(),
1753 1,
1754 "Text in braces is not IAL and should trigger warning"
1755 );
1756 }
1757
1758 #[test]
1759 fn test_kramdown_ial_toc_marker() {
1760 let rule = MD022BlanksAroundHeadings::default();
1762 let content = "# Heading\n{:toc}\n\nContent.";
1763 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1764 let warnings = rule.check(&ctx).unwrap();
1765
1766 assert!(warnings.is_empty(), "{{:toc}} should be recognized as IAL");
1768 }
1769
1770 #[test]
1771 fn test_kramdown_ial_mixed_headings_in_document() {
1772 let rule = MD022BlanksAroundHeadings::default();
1773 let content = r#"# ATX Heading
1774{:.atx-class}
1775
1776Content after ATX.
1777
1778Setext Heading
1779--------------
1780{:#setext-id}
1781
1782Content after Setext.
1783
1784## Another ATX
1785{:.another}
1786
1787More content."#;
1788 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1789 let warnings = rule.check(&ctx).unwrap();
1790
1791 assert!(
1792 warnings.is_empty(),
1793 "Mixed headings with IAL should all work: {warnings:?}"
1794 );
1795 }
1796
1797 #[test]
1798 fn test_kramdown_extension_block_before_first_heading_is_document_start() {
1799 let rule = MD022BlanksAroundHeadings::default();
1800 let content = "{::comment}\nhidden\n{:/comment}\n# Heading\n\nBody\n";
1801 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Kramdown, None);
1802 let warnings = rule.check(&ctx).unwrap();
1803
1804 assert!(
1805 warnings.is_empty(),
1806 "Kramdown extension preamble should not require blank above first heading: {warnings:?}"
1807 );
1808 }
1809
1810 #[test]
1811 fn test_kramdown_ial_before_first_heading_is_document_start() {
1812 let rule = MD022BlanksAroundHeadings::default();
1813 let content = "{:.doc-class}\n# Heading\n\nBody\n";
1814 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Kramdown, None);
1815 let warnings = rule.check(&ctx).unwrap();
1816
1817 assert!(
1818 warnings.is_empty(),
1819 "Kramdown IAL preamble should not require blank above first heading: {warnings:?}"
1820 );
1821 }
1822
1823 #[test]
1826 fn test_quarto_div_marker_transparent_above_heading() {
1827 let rule = MD022BlanksAroundHeadings::default();
1830 let content = "Content\n\n::: {.callout-note}\n# Heading\n\nMore content\n:::\n";
1832 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1833 let warnings = rule.check(&ctx).unwrap();
1834 assert!(
1836 warnings.is_empty(),
1837 "Quarto div marker should be transparent above heading: {warnings:?}"
1838 );
1839 }
1840
1841 #[test]
1842 fn test_quarto_div_marker_transparent_below_heading() {
1843 let rule = MD022BlanksAroundHeadings::default();
1845 let content = "# Heading\n\n::: {.callout-note}\nContent\n:::\n";
1846 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1847 let warnings = rule.check(&ctx).unwrap();
1848 assert!(
1850 warnings.is_empty(),
1851 "Quarto div marker should be transparent below heading: {warnings:?}"
1852 );
1853 }
1854
1855 #[test]
1856 fn test_quarto_heading_inside_callout() {
1857 let rule = MD022BlanksAroundHeadings::default();
1859 let content = "::: {.callout-note}\n\n## Note Title\n\nNote content\n:::\n";
1860 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1861 let warnings = rule.check(&ctx).unwrap();
1862 assert!(
1863 warnings.is_empty(),
1864 "Heading inside Quarto callout should have no warnings: {warnings:?}"
1865 );
1866 }
1867
1868 #[test]
1869 fn test_quarto_heading_at_start_after_div_open() {
1870 let rule = MD022BlanksAroundHeadings::default();
1873 let content = "::: {.callout-warning}\n# Warning\n\nContent\n:::\n";
1875 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1876 let warnings = rule.check(&ctx).unwrap();
1877 assert!(
1883 warnings.is_empty(),
1884 "Heading at start after div open should pass: {warnings:?}"
1885 );
1886 }
1887
1888 #[test]
1889 fn test_quarto_heading_before_div_close() {
1890 let rule = MD022BlanksAroundHeadings::default();
1892 let content = "::: {.callout-note}\nIntro\n\n## Section\n:::\n";
1893 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1894 let warnings = rule.check(&ctx).unwrap();
1895 assert!(
1899 warnings.is_empty(),
1900 "Heading before div close should pass: {warnings:?}"
1901 );
1902 }
1903
1904 #[test]
1905 fn test_quarto_div_markers_not_transparent_in_standard_flavor() {
1906 let rule = MD022BlanksAroundHeadings::default();
1908 let content = "Content\n\n:::\n# Heading\n\n:::\n";
1909 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1910 let warnings = rule.check(&ctx).unwrap();
1911 assert!(
1913 !warnings.is_empty(),
1914 "Standard flavor should not treat ::: as transparent: {warnings:?}"
1915 );
1916 }
1917
1918 #[test]
1919 fn test_quarto_nested_divs_with_heading() {
1920 let rule = MD022BlanksAroundHeadings::default();
1922 let content = "::: {.outer}\n::: {.inner}\n\n# Heading\n\nContent\n:::\n:::\n";
1923 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1924 let warnings = rule.check(&ctx).unwrap();
1925 assert!(
1926 warnings.is_empty(),
1927 "Nested divs with heading should work: {warnings:?}"
1928 );
1929 }
1930
1931 #[test]
1932 fn test_quarto_fix_preserves_div_markers() {
1933 let rule = MD022BlanksAroundHeadings::default();
1935 let content = "::: {.callout-note}\n\n## Note\n\nContent\n:::\n";
1936 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1937 let fixed = rule.fix(&ctx).unwrap();
1938 assert!(fixed.contains("::: {.callout-note}"), "Should preserve div opening");
1940 assert!(fixed.contains(":::"), "Should preserve div closing");
1941 assert!(fixed.contains("## Note"), "Should preserve heading");
1942 }
1943
1944 #[test]
1945 fn test_quarto_heading_needs_blank_without_div_transparency() {
1946 let rule = MD022BlanksAroundHeadings::default();
1949 let content = "Content\n::: {.callout-note}\n# Heading\n\nMore\n:::\n";
1951 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1952 let warnings = rule.check(&ctx).unwrap();
1953 assert!(
1956 !warnings.is_empty(),
1957 "Should still require blank line when not present: {warnings:?}"
1958 );
1959 }
1960
1961 #[test]
1962 fn test_pandoc_div_marker_transparent_above_heading() {
1963 let rule = MD022BlanksAroundHeadings::default();
1966 let content = "Content\n\n::: {.callout-note}\n# Heading\n\nMore content\n:::\n";
1967 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1968 let warnings = rule.check(&ctx).unwrap();
1969 assert!(
1970 warnings.is_empty(),
1971 "MD022 should treat Pandoc div marker as transparent above heading: {warnings:?}"
1972 );
1973 }
1974}