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::range_utils::calculate_heading_range;
8use toml;
9
10mod md022_config;
11use md022_config::MD022Config;
12
13#[derive(Clone, Default)]
85pub struct MD022BlanksAroundHeadings {
86 config: MD022Config,
87}
88
89impl MD022BlanksAroundHeadings {
90 pub fn new() -> Self {
93 Self {
94 config: MD022Config::default(),
95 }
96 }
97
98 pub fn with_values(lines_above: usize, lines_below: usize) -> Self {
100 use md022_config::HeadingLevelConfig;
101 Self {
102 config: MD022Config {
103 lines_above: HeadingLevelConfig::scalar(lines_above),
104 lines_below: HeadingLevelConfig::scalar(lines_below),
105 allowed_at_start: true,
106 },
107 }
108 }
109
110 pub fn from_config_struct(config: MD022Config) -> Self {
111 Self { config }
112 }
113
114 fn _fix_content(&self, ctx: &crate::lint_context::LintContext) -> String {
116 let line_ending = "\n";
118 let had_trailing_newline = ctx.content.ends_with('\n');
119 let mut result = Vec::new();
120 let mut skip_count: usize = 0;
121
122 let heading_at_start_idx = {
123 let mut found_non_transparent = false;
124 ctx.lines.iter().enumerate().find_map(|(i, line)| {
125 if line.heading.as_ref().is_some_and(|h| h.is_valid) && !found_non_transparent {
127 Some(i)
128 } else {
129 if !line.is_blank && !line.in_html_comment {
132 let trimmed = line.content(ctx.content).trim();
133 if !(trimmed.starts_with("<!--") && trimmed.ends_with("-->")) {
135 found_non_transparent = true;
136 }
137 }
138 None
139 }
140 })
141 };
142
143 for (i, line_info) in ctx.lines.iter().enumerate() {
144 if skip_count > 0 {
145 skip_count -= 1;
146 continue;
147 }
148 let line = line_info.content(ctx.content);
149
150 if line_info.in_code_block {
151 result.push(line.to_string());
152 continue;
153 }
154
155 if let Some(heading) = &line_info.heading {
157 if !heading.is_valid {
159 result.push(line.to_string());
160 continue;
161 }
162
163 let is_first_heading = Some(i) == heading_at_start_idx;
165 let heading_level = heading.level as usize;
166
167 let mut blank_lines_above = 0;
169 let mut check_idx = result.len();
170 while check_idx > 0 {
171 let prev_line = &result[check_idx - 1];
172 let trimmed = prev_line.trim();
173 if trimmed.is_empty() {
174 blank_lines_above += 1;
175 check_idx -= 1;
176 } else if trimmed.starts_with("<!--") && trimmed.ends_with("-->") {
177 check_idx -= 1;
179 } else if is_kramdown_block_attribute(trimmed) {
180 check_idx -= 1;
182 } else {
183 break;
184 }
185 }
186
187 let requirement_above = self.config.lines_above.get_for_level(heading_level);
189 let needed_blanks_above = if is_first_heading && self.config.allowed_at_start {
190 0
191 } else {
192 requirement_above.required_count().unwrap_or(0)
193 };
194
195 while blank_lines_above < needed_blanks_above {
197 result.push(String::new());
198 blank_lines_above += 1;
199 }
200
201 result.push(line.to_string());
203
204 let mut effective_end_idx = i;
206
207 if matches!(
209 heading.style,
210 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
211 ) {
212 if i + 1 < ctx.lines.len() {
214 result.push(ctx.lines[i + 1].content(ctx.content).to_string());
215 skip_count += 1; effective_end_idx = i + 1;
217 }
218 }
219
220 let mut ial_count = 0;
223 while effective_end_idx + 1 < ctx.lines.len() {
224 let next_line = &ctx.lines[effective_end_idx + 1];
225 let next_trimmed = next_line.content(ctx.content).trim();
226 if is_kramdown_block_attribute(next_trimmed) {
227 result.push(next_trimmed.to_string());
228 effective_end_idx += 1;
229 ial_count += 1;
230 } else {
231 break;
232 }
233 }
234
235 let mut blank_lines_below = 0;
237 let mut next_content_line_idx = None;
238 for j in (effective_end_idx + 1)..ctx.lines.len() {
239 if ctx.lines[j].is_blank {
240 blank_lines_below += 1;
241 } else {
242 next_content_line_idx = Some(j);
243 break;
244 }
245 }
246
247 let next_is_special = if let Some(idx) = next_content_line_idx {
249 let next_line = &ctx.lines[idx];
250 next_line.list_item.is_some() || {
251 let trimmed = next_line.content(ctx.content).trim();
252 (trimmed.starts_with("```") || trimmed.starts_with("~~~"))
253 && (trimmed.len() == 3
254 || (trimmed.len() > 3
255 && trimmed
256 .chars()
257 .nth(3)
258 .is_some_and(|c| c.is_whitespace() || c.is_alphabetic())))
259 }
260 } else {
261 false
262 };
263
264 let requirement_below = self.config.lines_below.get_for_level(heading_level);
266 let needed_blanks_below = if next_is_special {
267 0
268 } else {
269 requirement_below.required_count().unwrap_or(0)
270 };
271 if blank_lines_below < needed_blanks_below {
272 for _ in 0..(needed_blanks_below - blank_lines_below) {
273 result.push(String::new());
274 }
275 }
276
277 skip_count += ial_count;
279 } else {
280 result.push(line.to_string());
282 }
283 }
284
285 let joined = result.join(line_ending);
286
287 if had_trailing_newline && !joined.ends_with('\n') {
290 format!("{joined}{line_ending}")
291 } else if !had_trailing_newline && joined.ends_with('\n') {
292 joined[..joined.len() - 1].to_string()
294 } else {
295 joined
296 }
297 }
298}
299
300impl Rule for MD022BlanksAroundHeadings {
301 fn name(&self) -> &'static str {
302 "MD022"
303 }
304
305 fn description(&self) -> &'static str {
306 "Headings should be surrounded by blank lines"
307 }
308
309 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
310 let mut result = Vec::new();
311
312 if ctx.lines.is_empty() {
314 return Ok(result);
315 }
316
317 let line_ending = "\n";
319
320 let heading_at_start_idx = {
321 let mut found_non_transparent = false;
322 ctx.lines.iter().enumerate().find_map(|(i, line)| {
323 if line.heading.as_ref().is_some_and(|h| h.is_valid) && !found_non_transparent {
325 Some(i)
326 } else {
327 if !line.is_blank && !line.in_html_comment {
330 let trimmed = line.content(ctx.content).trim();
331 if !(trimmed.starts_with("<!--") && trimmed.ends_with("-->")) {
333 found_non_transparent = true;
334 }
335 }
336 None
337 }
338 })
339 };
340
341 let mut heading_violations = Vec::new();
343 let mut processed_headings = std::collections::HashSet::new();
344
345 for (line_num, line_info) in ctx.lines.iter().enumerate() {
346 if processed_headings.contains(&line_num) || line_info.heading.is_none() {
348 continue;
349 }
350
351 let heading = line_info.heading.as_ref().unwrap();
352
353 if !heading.is_valid {
355 continue;
356 }
357
358 let heading_level = heading.level as usize;
359
360 processed_headings.insert(line_num);
364
365 let is_first_heading = Some(line_num) == heading_at_start_idx;
367
368 let required_above_count = self.config.lines_above.get_for_level(heading_level).required_count();
370 let required_below_count = self.config.lines_below.get_for_level(heading_level).required_count();
371
372 let should_check_above =
374 required_above_count.is_some() && line_num > 0 && (!is_first_heading || !self.config.allowed_at_start);
375 if should_check_above {
376 let mut blank_lines_above = 0;
377 let mut hit_frontmatter_end = false;
378 for j in (0..line_num).rev() {
379 let line_content = ctx.lines[j].content(ctx.content);
380 let trimmed = line_content.trim();
381 if ctx.lines[j].is_blank {
382 blank_lines_above += 1;
383 } else if ctx.lines[j].in_html_comment || (trimmed.starts_with("<!--") && trimmed.ends_with("-->"))
384 {
385 continue;
387 } else if is_kramdown_block_attribute(trimmed) {
388 continue;
390 } else if ctx.lines[j].in_front_matter {
391 hit_frontmatter_end = true;
396 break;
397 } else {
398 break;
399 }
400 }
401 let required = required_above_count.unwrap();
402 if !hit_frontmatter_end && blank_lines_above < required {
403 let needed_blanks = required - blank_lines_above;
404 heading_violations.push((line_num, "above", needed_blanks, heading_level));
405 }
406 }
407
408 let mut effective_last_line = if matches!(
410 heading.style,
411 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
412 ) {
413 line_num + 1 } else {
415 line_num
416 };
417
418 while effective_last_line + 1 < ctx.lines.len() {
421 let next_line = &ctx.lines[effective_last_line + 1];
422 let next_trimmed = next_line.content(ctx.content).trim();
423 if is_kramdown_block_attribute(next_trimmed) {
424 effective_last_line += 1;
425 } else {
426 break;
427 }
428 }
429
430 if effective_last_line < ctx.lines.len() - 1 {
432 let mut next_non_blank_idx = effective_last_line + 1;
434 while next_non_blank_idx < ctx.lines.len() && ctx.lines[next_non_blank_idx].is_blank {
435 next_non_blank_idx += 1;
436 }
437
438 let next_line_is_special = next_non_blank_idx < ctx.lines.len() && {
440 let next_line = &ctx.lines[next_non_blank_idx];
441 let next_trimmed = next_line.content(ctx.content).trim();
442
443 let is_code_fence = (next_trimmed.starts_with("```") || next_trimmed.starts_with("~~~"))
445 && (next_trimmed.len() == 3
446 || (next_trimmed.len() > 3
447 && next_trimmed
448 .chars()
449 .nth(3)
450 .is_some_and(|c| c.is_whitespace() || c.is_alphabetic())));
451
452 let is_list_item = next_line.list_item.is_some();
454
455 is_code_fence || is_list_item
456 };
457
458 if !next_line_is_special && let Some(required) = required_below_count {
460 let blank_lines_below = next_non_blank_idx - effective_last_line - 1;
462
463 if blank_lines_below < required {
464 let needed_blanks = required - blank_lines_below;
465 heading_violations.push((line_num, "below", needed_blanks, heading_level));
466 }
467 }
468 }
469 }
470
471 for (heading_line, position, needed_blanks, heading_level) in heading_violations {
473 let heading_display_line = heading_line + 1; let line_info = &ctx.lines[heading_line];
475
476 let (start_line, start_col, end_line, end_col) =
478 calculate_heading_range(heading_display_line, line_info.content(ctx.content));
479
480 let required_above_count = self
481 .config
482 .lines_above
483 .get_for_level(heading_level)
484 .required_count()
485 .expect("Violations only generated for limited 'above' requirements");
486 let required_below_count = self
487 .config
488 .lines_below
489 .get_for_level(heading_level)
490 .required_count()
491 .expect("Violations only generated for limited 'below' requirements");
492
493 let (message, insertion_point) = match position {
494 "above" => (
495 format!(
496 "Expected {} blank {} above heading",
497 required_above_count,
498 if required_above_count == 1 { "line" } else { "lines" }
499 ),
500 heading_line, ),
502 "below" => {
503 let insert_after = if line_info.heading.as_ref().is_some_and(|h| {
505 matches!(
506 h.style,
507 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
508 )
509 }) {
510 heading_line + 2
511 } else {
512 heading_line + 1
513 };
514
515 (
516 format!(
517 "Expected {} blank {} below heading",
518 required_below_count,
519 if required_below_count == 1 { "line" } else { "lines" }
520 ),
521 insert_after,
522 )
523 }
524 _ => continue,
525 };
526
527 let byte_range = if insertion_point == 0 && position == "above" {
529 0..0
531 } else if position == "above" && insertion_point > 0 {
532 ctx.lines[insertion_point].byte_offset..ctx.lines[insertion_point].byte_offset
534 } else if position == "below" && insertion_point - 1 < ctx.lines.len() {
535 let line_idx = insertion_point - 1;
537 let line_end_offset = if line_idx + 1 < ctx.lines.len() {
538 ctx.lines[line_idx + 1].byte_offset
539 } else {
540 ctx.content.len()
541 };
542 line_end_offset..line_end_offset
543 } else {
544 let content_len = ctx.content.len();
546 content_len..content_len
547 };
548
549 result.push(LintWarning {
550 rule_name: Some(self.name().to_string()),
551 message,
552 line: start_line,
553 column: start_col,
554 end_line,
555 end_column: end_col,
556 severity: Severity::Warning,
557 fix: Some(Fix {
558 range: byte_range,
559 replacement: line_ending.repeat(needed_blanks),
560 }),
561 });
562 }
563
564 Ok(result)
565 }
566
567 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
568 if ctx.content.is_empty() {
569 return Ok(ctx.content.to_string());
570 }
571
572 let fixed = self._fix_content(ctx);
574
575 Ok(fixed)
576 }
577
578 fn category(&self) -> RuleCategory {
580 RuleCategory::Heading
581 }
582
583 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
585 if ctx.content.is_empty() || !ctx.likely_has_headings() {
587 return true;
588 }
589 ctx.lines.iter().all(|line| line.heading.is_none())
591 }
592
593 fn as_any(&self) -> &dyn std::any::Any {
594 self
595 }
596
597 fn default_config_section(&self) -> Option<(String, toml::Value)> {
598 let default_config = MD022Config::default();
599 let json_value = serde_json::to_value(&default_config).ok()?;
600 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
601
602 if let toml::Value::Table(table) = toml_value {
603 if !table.is_empty() {
604 Some((MD022Config::RULE_NAME.to_string(), toml::Value::Table(table)))
605 } else {
606 None
607 }
608 } else {
609 None
610 }
611 }
612
613 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
614 where
615 Self: Sized,
616 {
617 let rule_config = crate::rule_config_serde::load_rule_config::<MD022Config>(config);
618 Box::new(Self::from_config_struct(rule_config))
619 }
620}
621
622#[cfg(test)]
623mod tests {
624 use super::*;
625 use crate::lint_context::LintContext;
626
627 #[test]
628 fn test_valid_headings() {
629 let rule = MD022BlanksAroundHeadings::default();
630 let content = "\n# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.\n";
631 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
632 let result = rule.check(&ctx).unwrap();
633 assert!(result.is_empty());
634 }
635
636 #[test]
637 fn test_missing_blank_above() {
638 let rule = MD022BlanksAroundHeadings::default();
639 let content = "# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.\n";
640 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
641 let result = rule.check(&ctx).unwrap();
642 assert_eq!(result.len(), 0); let fixed = rule.fix(&ctx).unwrap();
645
646 assert!(fixed.contains("# Heading 1"));
649 assert!(fixed.contains("Some content."));
650 assert!(fixed.contains("## Heading 2"));
651 assert!(fixed.contains("More content."));
652 }
653
654 #[test]
655 fn test_missing_blank_below() {
656 let rule = MD022BlanksAroundHeadings::default();
657 let content = "\n# Heading 1\nSome content.\n\n## Heading 2\n\nMore content.\n";
658 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
659 let result = rule.check(&ctx).unwrap();
660 assert_eq!(result.len(), 1);
661 assert_eq!(result[0].line, 2);
662
663 let fixed = rule.fix(&ctx).unwrap();
665 assert!(fixed.contains("# Heading 1\n\nSome content"));
666 }
667
668 #[test]
669 fn test_missing_blank_above_and_below() {
670 let rule = MD022BlanksAroundHeadings::default();
671 let content = "# Heading 1\nSome content.\n## Heading 2\nMore content.\n";
672 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
673 let result = rule.check(&ctx).unwrap();
674 assert_eq!(result.len(), 3); let fixed = rule.fix(&ctx).unwrap();
678 assert!(fixed.contains("# Heading 1\n\nSome content"));
679 assert!(fixed.contains("Some content.\n\n## Heading 2"));
680 assert!(fixed.contains("## Heading 2\n\nMore content"));
681 }
682
683 #[test]
684 fn test_fix_headings() {
685 let rule = MD022BlanksAroundHeadings::default();
686 let content = "# Heading 1\nSome content.\n## Heading 2\nMore content.";
687 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
688 let result = rule.fix(&ctx).unwrap();
689
690 let expected = "# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.";
691 assert_eq!(result, expected);
692 }
693
694 #[test]
695 fn test_consecutive_headings_pattern() {
696 let rule = MD022BlanksAroundHeadings::default();
697 let content = "# Heading 1\n## Heading 2\n### Heading 3";
698 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
699 let result = rule.fix(&ctx).unwrap();
700
701 let lines: Vec<&str> = result.lines().collect();
703 assert!(!lines.is_empty());
704
705 let h1_pos = lines.iter().position(|&l| l == "# Heading 1").unwrap();
707 let h2_pos = lines.iter().position(|&l| l == "## Heading 2").unwrap();
708 let h3_pos = lines.iter().position(|&l| l == "### Heading 3").unwrap();
709
710 assert!(
712 h2_pos > h1_pos + 1,
713 "Should have at least one blank line after first heading"
714 );
715 assert!(
716 h3_pos > h2_pos + 1,
717 "Should have at least one blank line after second heading"
718 );
719
720 assert!(lines[h1_pos + 1].trim().is_empty(), "Line after h1 should be blank");
722
723 assert!(lines[h2_pos + 1].trim().is_empty(), "Line after h2 should be blank");
725 }
726
727 #[test]
728 fn test_blanks_around_setext_headings() {
729 let rule = MD022BlanksAroundHeadings::default();
730 let content = "Heading 1\n=========\nSome content.\nHeading 2\n---------\nMore content.";
731 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
732 let result = rule.fix(&ctx).unwrap();
733
734 let lines: Vec<&str> = result.lines().collect();
736
737 assert!(result.contains("Heading 1"));
739 assert!(result.contains("========="));
740 assert!(result.contains("Some content."));
741 assert!(result.contains("Heading 2"));
742 assert!(result.contains("---------"));
743 assert!(result.contains("More content."));
744
745 let heading1_marker_idx = lines.iter().position(|&l| l == "=========").unwrap();
747 let some_content_idx = lines.iter().position(|&l| l == "Some content.").unwrap();
748 assert!(
749 some_content_idx > heading1_marker_idx + 1,
750 "Should have a blank line after the first heading"
751 );
752
753 let heading2_marker_idx = lines.iter().position(|&l| l == "---------").unwrap();
754 let more_content_idx = lines.iter().position(|&l| l == "More content.").unwrap();
755 assert!(
756 more_content_idx > heading2_marker_idx + 1,
757 "Should have a blank line after the second heading"
758 );
759
760 let fixed_ctx = LintContext::new(&result, crate::config::MarkdownFlavor::Standard, None);
762 let fixed_warnings = rule.check(&fixed_ctx).unwrap();
763 assert!(fixed_warnings.is_empty(), "Fixed content should have no warnings");
764 }
765
766 #[test]
767 fn test_fix_specific_blank_line_cases() {
768 let rule = MD022BlanksAroundHeadings::default();
769
770 let content1 = "# Heading 1\n## Heading 2\n### Heading 3";
772 let ctx1 = LintContext::new(content1, crate::config::MarkdownFlavor::Standard, None);
773 let result1 = rule.fix(&ctx1).unwrap();
774 assert!(result1.contains("# Heading 1"));
776 assert!(result1.contains("## Heading 2"));
777 assert!(result1.contains("### Heading 3"));
778 let lines: Vec<&str> = result1.lines().collect();
780 let h1_pos = lines.iter().position(|&l| l == "# Heading 1").unwrap();
781 let h2_pos = lines.iter().position(|&l| l == "## Heading 2").unwrap();
782 assert!(lines[h1_pos + 1].trim().is_empty(), "Should have a blank line after h1");
783 assert!(lines[h2_pos + 1].trim().is_empty(), "Should have a blank line after h2");
784
785 let content2 = "# Heading 1\nContent under heading 1\n## Heading 2";
787 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
788 let result2 = rule.fix(&ctx2).unwrap();
789 assert!(result2.contains("# Heading 1"));
791 assert!(result2.contains("Content under heading 1"));
792 assert!(result2.contains("## Heading 2"));
793 let lines2: Vec<&str> = result2.lines().collect();
795 let h1_pos2 = lines2.iter().position(|&l| l == "# Heading 1").unwrap();
796 let _content_pos = lines2.iter().position(|&l| l == "Content under heading 1").unwrap();
797 assert!(
798 lines2[h1_pos2 + 1].trim().is_empty(),
799 "Should have a blank line after heading 1"
800 );
801
802 let content3 = "# Heading 1\n\n\n## Heading 2\n\n\n### Heading 3\n\nContent";
804 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
805 let result3 = rule.fix(&ctx3).unwrap();
806 assert!(result3.contains("# Heading 1"));
808 assert!(result3.contains("## Heading 2"));
809 assert!(result3.contains("### Heading 3"));
810 assert!(result3.contains("Content"));
811 }
812
813 #[test]
814 fn test_fix_preserves_existing_blank_lines() {
815 let rule = MD022BlanksAroundHeadings::new();
816 let content = "# Title
817
818## Section 1
819
820Content here.
821
822## Section 2
823
824More content.
825### Missing Blank Above
826
827Even more content.
828
829## Section 3
830
831Final content.";
832
833 let expected = "# Title
834
835## Section 1
836
837Content here.
838
839## Section 2
840
841More content.
842
843### Missing Blank Above
844
845Even more content.
846
847## Section 3
848
849Final content.";
850
851 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
852 let result = rule._fix_content(&ctx);
853 assert_eq!(
854 result, expected,
855 "Fix should only add missing blank lines, never remove existing ones"
856 );
857 }
858
859 #[test]
860 fn test_fix_preserves_trailing_newline() {
861 let rule = MD022BlanksAroundHeadings::new();
862
863 let content_with_newline = "# Title\nContent here.\n";
865 let ctx = LintContext::new(content_with_newline, crate::config::MarkdownFlavor::Standard, None);
866 let result = rule.fix(&ctx).unwrap();
867 assert!(result.ends_with('\n'), "Should preserve trailing newline");
868
869 let content_without_newline = "# Title\nContent here.";
871 let ctx = LintContext::new(content_without_newline, crate::config::MarkdownFlavor::Standard, None);
872 let result = rule.fix(&ctx).unwrap();
873 assert!(
874 !result.ends_with('\n'),
875 "Should not add trailing newline if original didn't have one"
876 );
877 }
878
879 #[test]
880 fn test_fix_does_not_add_blank_lines_before_lists() {
881 let rule = MD022BlanksAroundHeadings::new();
882 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.";
883
884 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.";
885
886 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
887 let result = rule._fix_content(&ctx);
888 assert_eq!(result, expected, "Fix should not add blank lines before lists");
889 }
890
891 #[test]
892 fn test_per_level_configuration_no_blank_above_h1() {
893 use md022_config::HeadingLevelConfig;
894
895 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
897 lines_above: HeadingLevelConfig::per_level([0, 1, 1, 1, 1, 1]),
898 lines_below: HeadingLevelConfig::scalar(1),
899 allowed_at_start: false, });
901
902 let content = "Some text\n# Heading 1\n\nMore text";
904 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
905 let warnings = rule.check(&ctx).unwrap();
906 assert_eq!(warnings.len(), 0, "H1 without blank above should not trigger warning");
907
908 let content = "Some text\n## Heading 2\n\nMore text";
910 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
911 let warnings = rule.check(&ctx).unwrap();
912 assert_eq!(warnings.len(), 1, "H2 without blank above should trigger warning");
913 assert!(warnings[0].message.contains("above"));
914 }
915
916 #[test]
917 fn test_per_level_configuration_different_requirements() {
918 use md022_config::HeadingLevelConfig;
919
920 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
922 lines_above: HeadingLevelConfig::per_level([0, 1, 1, 2, 2, 2]),
923 lines_below: HeadingLevelConfig::scalar(1),
924 allowed_at_start: false,
925 });
926
927 let content = "Text\n# H1\n\nText\n\n## H2\n\nText\n\n### H3\n\nText\n\n\n#### H4\n\nText";
928 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
929 let warnings = rule.check(&ctx).unwrap();
930
931 assert_eq!(
933 warnings.len(),
934 0,
935 "All headings should satisfy level-specific requirements"
936 );
937 }
938
939 #[test]
940 fn test_per_level_configuration_violations() {
941 use md022_config::HeadingLevelConfig;
942
943 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
945 lines_above: HeadingLevelConfig::per_level([1, 1, 1, 2, 1, 1]),
946 lines_below: HeadingLevelConfig::scalar(1),
947 allowed_at_start: false,
948 });
949
950 let content = "Text\n\n#### Heading 4\n\nMore text";
952 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
953 let warnings = rule.check(&ctx).unwrap();
954
955 assert_eq!(warnings.len(), 1, "H4 with insufficient blanks should trigger warning");
956 assert!(warnings[0].message.contains("2 blank lines above"));
957 }
958
959 #[test]
960 fn test_per_level_fix_different_levels() {
961 use md022_config::HeadingLevelConfig;
962
963 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
965 lines_above: HeadingLevelConfig::per_level([0, 1, 2, 2, 2, 2]),
966 lines_below: HeadingLevelConfig::scalar(1),
967 allowed_at_start: false,
968 });
969
970 let content = "Text\n# H1\nContent\n## H2\nContent\n### H3\nContent";
971 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
972 let fixed = rule.fix(&ctx).unwrap();
973
974 assert!(fixed.contains("Text\n# H1\n\nContent"));
976 assert!(fixed.contains("Content\n\n## H2\n\nContent"));
977 assert!(fixed.contains("Content\n\n\n### H3\n\nContent"));
978 }
979
980 #[test]
981 fn test_per_level_below_configuration() {
982 use md022_config::HeadingLevelConfig;
983
984 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
986 lines_above: HeadingLevelConfig::scalar(1),
987 lines_below: HeadingLevelConfig::per_level([2, 1, 1, 1, 1, 1]), allowed_at_start: true,
989 });
990
991 let content = "# Heading 1\n\nSome text";
993 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
994 let warnings = rule.check(&ctx).unwrap();
995
996 assert_eq!(
997 warnings.len(),
998 1,
999 "H1 with insufficient blanks below should trigger warning"
1000 );
1001 assert!(warnings[0].message.contains("2 blank lines below"));
1002 }
1003
1004 #[test]
1005 fn test_scalar_configuration_still_works() {
1006 use md022_config::HeadingLevelConfig;
1007
1008 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1010 lines_above: HeadingLevelConfig::scalar(2),
1011 lines_below: HeadingLevelConfig::scalar(2),
1012 allowed_at_start: false,
1013 });
1014
1015 let content = "Text\n# H1\nContent\n## H2\nContent";
1016 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1017 let warnings = rule.check(&ctx).unwrap();
1018
1019 assert!(!warnings.is_empty(), "Should have violations for insufficient blanks");
1021 }
1022
1023 #[test]
1024 fn test_unlimited_configuration_skips_requirements() {
1025 use md022_config::{HeadingBlankRequirement, HeadingLevelConfig};
1026
1027 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1029 lines_above: HeadingLevelConfig::per_level_requirements([
1030 HeadingBlankRequirement::unlimited(),
1031 HeadingBlankRequirement::limited(1),
1032 HeadingBlankRequirement::limited(1),
1033 HeadingBlankRequirement::limited(1),
1034 HeadingBlankRequirement::limited(1),
1035 HeadingBlankRequirement::limited(1),
1036 ]),
1037 lines_below: HeadingLevelConfig::per_level_requirements([
1038 HeadingBlankRequirement::unlimited(),
1039 HeadingBlankRequirement::limited(1),
1040 HeadingBlankRequirement::limited(1),
1041 HeadingBlankRequirement::limited(1),
1042 HeadingBlankRequirement::limited(1),
1043 HeadingBlankRequirement::limited(1),
1044 ]),
1045 allowed_at_start: false,
1046 });
1047
1048 let content = "# H1\nParagraph\n## H2\nParagraph";
1049 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1050 let warnings = rule.check(&ctx).unwrap();
1051
1052 assert_eq!(warnings.len(), 2, "Only non-unlimited headings should warn");
1054 assert!(
1055 warnings.iter().all(|w| w.line >= 3),
1056 "Warnings should target later headings"
1057 );
1058
1059 let fixed = rule.fix(&ctx).unwrap();
1061 assert!(
1062 fixed.starts_with("# H1\nParagraph\n\n## H2"),
1063 "H1 should remain unchanged"
1064 );
1065 }
1066
1067 #[test]
1068 fn test_html_comment_transparency() {
1069 let rule = MD022BlanksAroundHeadings::default();
1073
1074 let content = "Some content\n\n<!-- markdownlint-disable-next-line MD001 -->\n#### Heading";
1077 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1078 let warnings = rule.check(&ctx).unwrap();
1079 assert!(
1080 warnings.is_empty(),
1081 "HTML comment is transparent - blank line above it counts for heading"
1082 );
1083
1084 let content_multiline = "Some content\n\n<!-- This is a\nmulti-line comment -->\n#### Heading";
1086 let ctx_multiline = LintContext::new(content_multiline, crate::config::MarkdownFlavor::Standard, None);
1087 let warnings_multiline = rule.check(&ctx_multiline).unwrap();
1088 assert!(
1089 warnings_multiline.is_empty(),
1090 "Multi-line HTML comment is also transparent"
1091 );
1092 }
1093
1094 #[test]
1095 fn test_frontmatter_transparency() {
1096 let rule = MD022BlanksAroundHeadings::default();
1099
1100 let content = "---\ntitle: Test\n---\n# First heading";
1102 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1103 let warnings = rule.check(&ctx).unwrap();
1104 assert!(
1105 warnings.is_empty(),
1106 "Frontmatter is transparent - heading can appear immediately after"
1107 );
1108
1109 let content_with_blank = "---\ntitle: Test\n---\n\n# First heading";
1111 let ctx_with_blank = LintContext::new(content_with_blank, crate::config::MarkdownFlavor::Standard, None);
1112 let warnings_with_blank = rule.check(&ctx_with_blank).unwrap();
1113 assert!(
1114 warnings_with_blank.is_empty(),
1115 "Heading with blank line after frontmatter should also be valid"
1116 );
1117
1118 let content_toml = "+++\ntitle = \"Test\"\n+++\n# First heading";
1120 let ctx_toml = LintContext::new(content_toml, crate::config::MarkdownFlavor::Standard, None);
1121 let warnings_toml = rule.check(&ctx_toml).unwrap();
1122 assert!(
1123 warnings_toml.is_empty(),
1124 "TOML frontmatter is also transparent for MD022"
1125 );
1126 }
1127
1128 #[test]
1129 fn test_horizontal_rule_not_treated_as_frontmatter() {
1130 let rule = MD022BlanksAroundHeadings::default();
1133
1134 let content = "Some content\n\n---\n# Heading after HR";
1136 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1137 let warnings = rule.check(&ctx).unwrap();
1138 assert!(
1139 !warnings.is_empty(),
1140 "Heading after horizontal rule without blank line SHOULD trigger MD022"
1141 );
1142 assert!(
1143 warnings.iter().any(|w| w.line == 4),
1144 "Warning should be on line 4 (the heading line)"
1145 );
1146
1147 let content_with_blank = "Some content\n\n---\n\n# Heading after HR";
1149 let ctx_with_blank = LintContext::new(content_with_blank, crate::config::MarkdownFlavor::Standard, None);
1150 let warnings_with_blank = rule.check(&ctx_with_blank).unwrap();
1151 assert!(
1152 warnings_with_blank.is_empty(),
1153 "Heading with blank line after HR should not trigger MD022"
1154 );
1155
1156 let content_hr_start = "---\n# Heading";
1158 let ctx_hr_start = LintContext::new(content_hr_start, crate::config::MarkdownFlavor::Standard, None);
1159 let warnings_hr_start = rule.check(&ctx_hr_start).unwrap();
1160 assert!(
1161 !warnings_hr_start.is_empty(),
1162 "Heading after HR at document start SHOULD trigger MD022"
1163 );
1164
1165 let content_multi_hr = "Content\n\n---\n\n---\n# Heading";
1167 let ctx_multi_hr = LintContext::new(content_multi_hr, crate::config::MarkdownFlavor::Standard, None);
1168 let warnings_multi_hr = rule.check(&ctx_multi_hr).unwrap();
1169 assert!(
1170 !warnings_multi_hr.is_empty(),
1171 "Heading after multiple HRs without blank line SHOULD trigger MD022"
1172 );
1173 }
1174
1175 #[test]
1176 fn test_all_hr_styles_require_blank_before_heading() {
1177 let rule = MD022BlanksAroundHeadings::default();
1179
1180 let hr_styles = [
1182 "---", "***", "___", "- - -", "* * *", "_ _ _", "----", "****", "____", "- - - -",
1183 "- - -", " ---", " ---", ];
1187
1188 for hr in hr_styles {
1189 let content = format!("Content\n\n{hr}\n# Heading");
1190 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1191 let warnings = rule.check(&ctx).unwrap();
1192 assert!(
1193 !warnings.is_empty(),
1194 "HR style '{hr}' followed by heading should trigger MD022"
1195 );
1196 }
1197 }
1198
1199 #[test]
1200 fn test_setext_heading_after_hr() {
1201 let rule = MD022BlanksAroundHeadings::default();
1203
1204 let content = "Content\n\n---\nHeading\n======";
1206 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1207 let warnings = rule.check(&ctx).unwrap();
1208 assert!(
1209 !warnings.is_empty(),
1210 "Setext heading after HR without blank should trigger MD022"
1211 );
1212
1213 let content_h2 = "Content\n\n---\nHeading\n------";
1215 let ctx_h2 = LintContext::new(content_h2, crate::config::MarkdownFlavor::Standard, None);
1216 let warnings_h2 = rule.check(&ctx_h2).unwrap();
1217 assert!(
1218 !warnings_h2.is_empty(),
1219 "Setext h2 after HR without blank should trigger MD022"
1220 );
1221
1222 let content_ok = "Content\n\n---\n\nHeading\n======";
1224 let ctx_ok = LintContext::new(content_ok, crate::config::MarkdownFlavor::Standard, None);
1225 let warnings_ok = rule.check(&ctx_ok).unwrap();
1226 assert!(
1227 warnings_ok.is_empty(),
1228 "Setext heading with blank after HR should not warn"
1229 );
1230 }
1231
1232 #[test]
1233 fn test_hr_in_code_block_not_treated_as_hr() {
1234 let rule = MD022BlanksAroundHeadings::default();
1236
1237 let content = "```\n---\n```\n# Heading";
1240 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1241 let warnings = rule.check(&ctx).unwrap();
1242 assert!(!warnings.is_empty(), "Heading after code block still needs blank line");
1245
1246 let content_ok = "```\n---\n```\n\n# Heading";
1248 let ctx_ok = LintContext::new(content_ok, crate::config::MarkdownFlavor::Standard, None);
1249 let warnings_ok = rule.check(&ctx_ok).unwrap();
1250 assert!(
1251 warnings_ok.is_empty(),
1252 "Heading with blank after code block should not warn"
1253 );
1254 }
1255
1256 #[test]
1257 fn test_hr_in_html_comment_not_treated_as_hr() {
1258 let rule = MD022BlanksAroundHeadings::default();
1260
1261 let content = "<!-- \n---\n -->\n# Heading";
1263 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1264 let warnings = rule.check(&ctx).unwrap();
1265 assert!(
1267 warnings.is_empty(),
1268 "HR inside HTML comment should be ignored - heading after comment is OK"
1269 );
1270 }
1271
1272 #[test]
1273 fn test_invalid_hr_not_triggering() {
1274 let rule = MD022BlanksAroundHeadings::default();
1276
1277 let invalid_hrs = [
1278 " ---", "\t---", "--", "**", "__", "-*-", "---a", "a---", ];
1287
1288 for invalid in invalid_hrs {
1289 let content = format!("Content\n\n{invalid}\n# Heading");
1292 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1293 let _ = rule.check(&ctx);
1296 }
1297 }
1298
1299 #[test]
1300 fn test_frontmatter_vs_horizontal_rule_distinction() {
1301 let rule = MD022BlanksAroundHeadings::default();
1303
1304 let content = "---\ntitle: Test\n---\n\nSome content\n\n---\n# Heading after HR";
1307 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1308 let warnings = rule.check(&ctx).unwrap();
1309 assert!(
1310 !warnings.is_empty(),
1311 "HR after frontmatter content should still require blank line before heading"
1312 );
1313
1314 let content_ok = "---\ntitle: Test\n---\n\nSome content\n\n---\n\n# Heading after HR";
1316 let ctx_ok = LintContext::new(content_ok, crate::config::MarkdownFlavor::Standard, None);
1317 let warnings_ok = rule.check(&ctx_ok).unwrap();
1318 assert!(
1319 warnings_ok.is_empty(),
1320 "HR with blank line before heading should not warn"
1321 );
1322 }
1323
1324 #[test]
1327 fn test_kramdown_ial_after_heading_no_warning() {
1328 let rule = MD022BlanksAroundHeadings::default();
1330 let content = "## Table of Contents\n{: .hhc-toc-heading}\n\nSome content here.";
1331 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1332 let warnings = rule.check(&ctx).unwrap();
1333
1334 assert!(
1335 warnings.is_empty(),
1336 "IAL after heading should not require blank line between them: {warnings:?}"
1337 );
1338 }
1339
1340 #[test]
1341 fn test_kramdown_ial_with_class() {
1342 let rule = MD022BlanksAroundHeadings::default();
1343 let content = "# Heading\n{:.highlight}\n\nContent.";
1344 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1345 let warnings = rule.check(&ctx).unwrap();
1346
1347 assert!(warnings.is_empty(), "IAL with class should be part of heading");
1348 }
1349
1350 #[test]
1351 fn test_kramdown_ial_with_id() {
1352 let rule = MD022BlanksAroundHeadings::default();
1353 let content = "# Heading\n{:#custom-id}\n\nContent.";
1354 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1355 let warnings = rule.check(&ctx).unwrap();
1356
1357 assert!(warnings.is_empty(), "IAL with id should be part of heading");
1358 }
1359
1360 #[test]
1361 fn test_kramdown_ial_with_multiple_attributes() {
1362 let rule = MD022BlanksAroundHeadings::default();
1363 let content = "# Heading\n{: .class #id style=\"color: red\"}\n\nContent.";
1364 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1365 let warnings = rule.check(&ctx).unwrap();
1366
1367 assert!(
1368 warnings.is_empty(),
1369 "IAL with multiple attributes should be part of heading"
1370 );
1371 }
1372
1373 #[test]
1374 fn test_kramdown_ial_missing_blank_after() {
1375 let rule = MD022BlanksAroundHeadings::default();
1377 let content = "# Heading\n{:.class}\nContent without blank.";
1378 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1379 let warnings = rule.check(&ctx).unwrap();
1380
1381 assert_eq!(
1382 warnings.len(),
1383 1,
1384 "Should warn about missing blank after IAL (part of heading)"
1385 );
1386 assert!(warnings[0].message.contains("below"));
1387 }
1388
1389 #[test]
1390 fn test_kramdown_ial_before_heading_transparent() {
1391 let rule = MD022BlanksAroundHeadings::default();
1393 let content = "Content.\n\n{:.preclass}\n## Heading\n\nMore content.";
1394 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1395 let warnings = rule.check(&ctx).unwrap();
1396
1397 assert!(
1398 warnings.is_empty(),
1399 "IAL before heading should be transparent for blank line count"
1400 );
1401 }
1402
1403 #[test]
1404 fn test_kramdown_ial_setext_heading() {
1405 let rule = MD022BlanksAroundHeadings::default();
1406 let content = "Heading\n=======\n{:.setext-class}\n\nContent.";
1407 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1408 let warnings = rule.check(&ctx).unwrap();
1409
1410 assert!(
1411 warnings.is_empty(),
1412 "IAL after Setext heading should be part of heading"
1413 );
1414 }
1415
1416 #[test]
1417 fn test_kramdown_ial_fix_preserves_ial() {
1418 let rule = MD022BlanksAroundHeadings::default();
1419 let content = "Content.\n# Heading\n{:.class}\nMore content.";
1420 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1421 let fixed = rule.fix(&ctx).unwrap();
1422
1423 assert!(
1425 fixed.contains("# Heading\n{:.class}"),
1426 "IAL should stay attached to heading"
1427 );
1428 assert!(fixed.contains("{:.class}\n\nMore"), "Should add blank after IAL");
1429 }
1430
1431 #[test]
1432 fn test_kramdown_ial_fix_does_not_separate() {
1433 let rule = MD022BlanksAroundHeadings::default();
1434 let content = "# Heading\n{:.class}\nContent.";
1435 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1436 let fixed = rule.fix(&ctx).unwrap();
1437
1438 assert!(
1440 !fixed.contains("# Heading\n\n{:.class}"),
1441 "Should not add blank between heading and IAL"
1442 );
1443 assert!(fixed.contains("# Heading\n{:.class}"), "IAL should remain attached");
1444 }
1445
1446 #[test]
1447 fn test_kramdown_multiple_ial_lines() {
1448 let rule = MD022BlanksAroundHeadings::default();
1450 let content = "# Heading\n{:.class1}\n{:#id}\n\nContent.";
1451 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1452 let warnings = rule.check(&ctx).unwrap();
1453
1454 assert!(
1457 warnings.is_empty(),
1458 "Multiple consecutive IALs should be part of heading"
1459 );
1460 }
1461
1462 #[test]
1463 fn test_kramdown_ial_with_blank_line_not_attached() {
1464 let rule = MD022BlanksAroundHeadings::default();
1466 let content = "# Heading\n\n{:.class}\nContent.";
1467 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1468 let warnings = rule.check(&ctx).unwrap();
1469
1470 assert!(warnings.is_empty(), "Blank line separates heading from IAL");
1474 }
1475
1476 #[test]
1477 fn test_not_kramdown_ial_regular_braces() {
1478 let rule = MD022BlanksAroundHeadings::default();
1480 let content = "# Heading\n{not an ial}\n\nContent.";
1481 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1482 let warnings = rule.check(&ctx).unwrap();
1483
1484 assert_eq!(
1486 warnings.len(),
1487 1,
1488 "Non-IAL braces should be regular content requiring blank"
1489 );
1490 }
1491
1492 #[test]
1493 fn test_kramdown_ial_at_document_end() {
1494 let rule = MD022BlanksAroundHeadings::default();
1495 let content = "# Heading\n{:.class}";
1496 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1497 let warnings = rule.check(&ctx).unwrap();
1498
1499 assert!(warnings.is_empty(), "IAL at document end needs no blank after");
1501 }
1502
1503 #[test]
1504 fn test_kramdown_ial_followed_by_code_fence() {
1505 let rule = MD022BlanksAroundHeadings::default();
1506 let content = "# Heading\n{:.class}\n```\ncode\n```";
1507 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1508 let warnings = rule.check(&ctx).unwrap();
1509
1510 assert!(warnings.is_empty(), "No blank needed between IAL and code fence");
1512 }
1513
1514 #[test]
1515 fn test_kramdown_ial_followed_by_list() {
1516 let rule = MD022BlanksAroundHeadings::default();
1517 let content = "# Heading\n{:.class}\n- List item";
1518 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1519 let warnings = rule.check(&ctx).unwrap();
1520
1521 assert!(warnings.is_empty(), "No blank needed between IAL and list");
1523 }
1524
1525 #[test]
1526 fn test_kramdown_ial_fix_idempotent() {
1527 let rule = MD022BlanksAroundHeadings::default();
1528 let content = "# Heading\n{:.class}\nContent.";
1529 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1530
1531 let fixed_once = rule.fix(&ctx).unwrap();
1532 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
1533 let fixed_twice = rule.fix(&ctx2).unwrap();
1534
1535 assert_eq!(fixed_once, fixed_twice, "Fix should be idempotent");
1536 }
1537
1538 #[test]
1539 fn test_kramdown_ial_whitespace_line_between_not_attached() {
1540 let rule = MD022BlanksAroundHeadings::default();
1543 let content = "# Heading\n \n{:.class}\n\nContent.";
1544 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1545 let warnings = rule.check(&ctx).unwrap();
1546
1547 assert!(
1551 warnings.is_empty(),
1552 "Whitespace between heading and IAL means IAL is not attached"
1553 );
1554 }
1555
1556 #[test]
1557 fn test_kramdown_ial_html_comment_between() {
1558 let rule = MD022BlanksAroundHeadings::default();
1561 let content = "# Heading\n<!-- comment -->\n{:.class}\n\nContent.";
1562 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1563 let warnings = rule.check(&ctx).unwrap();
1564
1565 assert_eq!(
1569 warnings.len(),
1570 1,
1571 "IAL not attached when comment is between: {warnings:?}"
1572 );
1573 }
1574
1575 #[test]
1576 fn test_kramdown_ial_generic_attribute() {
1577 let rule = MD022BlanksAroundHeadings::default();
1578 let content = "# Heading\n{:data-toc=\"true\" style=\"color: red\"}\n\nContent.";
1579 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1580 let warnings = rule.check(&ctx).unwrap();
1581
1582 assert!(warnings.is_empty(), "Generic attributes should be recognized as IAL");
1583 }
1584
1585 #[test]
1586 fn test_kramdown_ial_fix_multiple_lines_preserves_all() {
1587 let rule = MD022BlanksAroundHeadings::default();
1588 let content = "# Heading\n{:.class1}\n{:#id}\n{:data-x=\"y\"}\nContent.";
1589 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1590
1591 let fixed = rule.fix(&ctx).unwrap();
1592
1593 assert!(fixed.contains("{:.class1}"), "First IAL should be preserved");
1595 assert!(fixed.contains("{:#id}"), "Second IAL should be preserved");
1596 assert!(fixed.contains("{:data-x=\"y\"}"), "Third IAL should be preserved");
1597 assert!(
1599 fixed.contains("{:data-x=\"y\"}\n\nContent"),
1600 "Blank line should be after all IALs"
1601 );
1602 }
1603
1604 #[test]
1605 fn test_kramdown_ial_crlf_line_endings() {
1606 let rule = MD022BlanksAroundHeadings::default();
1607 let content = "# Heading\r\n{:.class}\r\n\r\nContent.";
1608 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1609 let warnings = rule.check(&ctx).unwrap();
1610
1611 assert!(warnings.is_empty(), "CRLF should work correctly with IAL");
1612 }
1613
1614 #[test]
1615 fn test_kramdown_ial_invalid_patterns_not_recognized() {
1616 let rule = MD022BlanksAroundHeadings::default();
1617
1618 let content = "# Heading\n{ :.class}\n\nContent.";
1620 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1621 let warnings = rule.check(&ctx).unwrap();
1622 assert_eq!(warnings.len(), 1, "Invalid IAL syntax should trigger warning");
1623
1624 let content2 = "# Heading\n{.class}\n\nContent.";
1626 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1627 let warnings2 = rule.check(&ctx2).unwrap();
1628 assert!(warnings2.is_empty(), "{{.class}} is valid kramdown block attribute");
1630
1631 let content3 = "# Heading\n{just text}\n\nContent.";
1633 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
1634 let warnings3 = rule.check(&ctx3).unwrap();
1635 assert_eq!(
1636 warnings3.len(),
1637 1,
1638 "Text in braces is not IAL and should trigger warning"
1639 );
1640 }
1641
1642 #[test]
1643 fn test_kramdown_ial_toc_marker() {
1644 let rule = MD022BlanksAroundHeadings::default();
1646 let content = "# Heading\n{:toc}\n\nContent.";
1647 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1648 let warnings = rule.check(&ctx).unwrap();
1649
1650 assert!(warnings.is_empty(), "{{:toc}} should be recognized as IAL");
1652 }
1653
1654 #[test]
1655 fn test_kramdown_ial_mixed_headings_in_document() {
1656 let rule = MD022BlanksAroundHeadings::default();
1657 let content = r#"# ATX Heading
1658{:.atx-class}
1659
1660Content after ATX.
1661
1662Setext Heading
1663--------------
1664{:#setext-id}
1665
1666Content after Setext.
1667
1668## Another ATX
1669{:.another}
1670
1671More content."#;
1672 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1673 let warnings = rule.check(&ctx).unwrap();
1674
1675 assert!(
1676 warnings.is_empty(),
1677 "Mixed headings with IAL should all work: {warnings:?}"
1678 );
1679 }
1680}