1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
5use crate::rule_config_serde::RuleConfig;
6use crate::utils::range_utils::calculate_heading_range;
7use toml;
8
9mod md022_config;
10use md022_config::MD022Config;
11
12#[derive(Clone, Default)]
84pub struct MD022BlanksAroundHeadings {
85 config: MD022Config,
86}
87
88impl MD022BlanksAroundHeadings {
89 pub fn new() -> Self {
92 Self {
93 config: MD022Config::default(),
94 }
95 }
96
97 pub fn with_values(lines_above: usize, lines_below: usize) -> Self {
99 use md022_config::HeadingLevelConfig;
100 Self {
101 config: MD022Config {
102 lines_above: HeadingLevelConfig::scalar(lines_above),
103 lines_below: HeadingLevelConfig::scalar(lines_below),
104 allowed_at_start: true,
105 },
106 }
107 }
108
109 pub fn from_config_struct(config: MD022Config) -> Self {
110 Self { config }
111 }
112
113 fn _fix_content(&self, ctx: &crate::lint_context::LintContext) -> String {
115 let line_ending = "\n";
117 let had_trailing_newline = ctx.content.ends_with('\n');
118 let mut result = Vec::new();
119 let mut skip_next = false;
120
121 let heading_at_start_idx = {
122 let mut found_non_transparent = false;
123 ctx.lines.iter().enumerate().find_map(|(i, line)| {
124 if line.heading.as_ref().is_some_and(|h| h.is_valid) && !found_non_transparent {
126 Some(i)
127 } else {
128 if !line.is_blank && !line.in_html_comment {
131 let trimmed = line.content(ctx.content).trim();
132 if !(trimmed.starts_with("<!--") && trimmed.ends_with("-->")) {
134 found_non_transparent = true;
135 }
136 }
137 None
138 }
139 })
140 };
141
142 for (i, line_info) in ctx.lines.iter().enumerate() {
143 if skip_next {
144 skip_next = false;
145 continue;
146 }
147 let line = line_info.content(ctx.content);
148
149 if line_info.in_code_block {
150 result.push(line.to_string());
151 continue;
152 }
153
154 if let Some(heading) = &line_info.heading {
156 if !heading.is_valid {
158 result.push(line.to_string());
159 continue;
160 }
161
162 let is_first_heading = Some(i) == heading_at_start_idx;
164 let heading_level = heading.level as usize;
165
166 let mut blank_lines_above = 0;
168 let mut check_idx = result.len();
169 while check_idx > 0 {
170 let prev_line = &result[check_idx - 1];
171 let trimmed = prev_line.trim();
172 if trimmed.is_empty() {
173 blank_lines_above += 1;
174 check_idx -= 1;
175 } else if trimmed.starts_with("<!--") && trimmed.ends_with("-->") {
176 check_idx -= 1;
178 } else {
179 break;
180 }
181 }
182
183 let requirement_above = self.config.lines_above.get_for_level(heading_level);
185 let needed_blanks_above = if is_first_heading && self.config.allowed_at_start {
186 0
187 } else {
188 requirement_above.required_count().unwrap_or(0)
189 };
190
191 while blank_lines_above < needed_blanks_above {
193 result.push(String::new());
194 blank_lines_above += 1;
195 }
196
197 result.push(line.to_string());
199
200 if matches!(
202 heading.style,
203 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
204 ) {
205 if i + 1 < ctx.lines.len() {
207 result.push(ctx.lines[i + 1].content(ctx.content).to_string());
208 skip_next = true; }
210
211 let mut blank_lines_below = 0;
213 let mut next_content_line_idx = None;
214 for j in (i + 2)..ctx.lines.len() {
215 if ctx.lines[j].is_blank {
216 blank_lines_below += 1;
217 } else {
218 next_content_line_idx = Some(j);
219 break;
220 }
221 }
222
223 let next_is_special = if let Some(idx) = next_content_line_idx {
225 let next_line = &ctx.lines[idx];
226 next_line.list_item.is_some() || {
227 let trimmed = next_line.content(ctx.content).trim();
228 (trimmed.starts_with("```") || trimmed.starts_with("~~~"))
229 && (trimmed.len() == 3
230 || (trimmed.len() > 3
231 && trimmed
232 .chars()
233 .nth(3)
234 .is_some_and(|c| c.is_whitespace() || c.is_alphabetic())))
235 }
236 } else {
237 false
238 };
239
240 let requirement_below = self.config.lines_below.get_for_level(heading_level);
242 let needed_blanks_below = if next_is_special {
243 0
244 } else {
245 requirement_below.required_count().unwrap_or(0)
246 };
247 if blank_lines_below < needed_blanks_below {
248 for _ in 0..(needed_blanks_below - blank_lines_below) {
249 result.push(String::new());
250 }
251 }
252 } else {
253 let mut blank_lines_below = 0;
255 let mut next_content_line_idx = None;
256 for j in (i + 1)..ctx.lines.len() {
257 if ctx.lines[j].is_blank {
258 blank_lines_below += 1;
259 } else {
260 next_content_line_idx = Some(j);
261 break;
262 }
263 }
264
265 let next_is_special = if let Some(idx) = next_content_line_idx {
267 let next_line = &ctx.lines[idx];
268 next_line.list_item.is_some() || {
269 let trimmed = next_line.content(ctx.content).trim();
270 (trimmed.starts_with("```") || trimmed.starts_with("~~~"))
271 && (trimmed.len() == 3
272 || (trimmed.len() > 3
273 && trimmed
274 .chars()
275 .nth(3)
276 .is_some_and(|c| c.is_whitespace() || c.is_alphabetic())))
277 }
278 } else {
279 false
280 };
281
282 let requirement_below = self.config.lines_below.get_for_level(heading_level);
284 let needed_blanks_below = if next_is_special {
285 0
286 } else {
287 requirement_below.required_count().unwrap_or(0)
288 };
289 if blank_lines_below < needed_blanks_below {
290 for _ in 0..(needed_blanks_below - blank_lines_below) {
291 result.push(String::new());
292 }
293 }
294 }
295 } else {
296 result.push(line.to_string());
298 }
299 }
300
301 let joined = result.join(line_ending);
302
303 if had_trailing_newline && !joined.ends_with('\n') {
306 format!("{joined}{line_ending}")
307 } else if !had_trailing_newline && joined.ends_with('\n') {
308 joined[..joined.len() - 1].to_string()
310 } else {
311 joined
312 }
313 }
314}
315
316impl Rule for MD022BlanksAroundHeadings {
317 fn name(&self) -> &'static str {
318 "MD022"
319 }
320
321 fn description(&self) -> &'static str {
322 "Headings should be surrounded by blank lines"
323 }
324
325 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
326 let mut result = Vec::new();
327
328 if ctx.lines.is_empty() {
330 return Ok(result);
331 }
332
333 let line_ending = "\n";
335
336 let heading_at_start_idx = {
337 let mut found_non_transparent = false;
338 ctx.lines.iter().enumerate().find_map(|(i, line)| {
339 if line.heading.as_ref().is_some_and(|h| h.is_valid) && !found_non_transparent {
341 Some(i)
342 } else {
343 if !line.is_blank && !line.in_html_comment {
346 let trimmed = line.content(ctx.content).trim();
347 if !(trimmed.starts_with("<!--") && trimmed.ends_with("-->")) {
349 found_non_transparent = true;
350 }
351 }
352 None
353 }
354 })
355 };
356
357 let mut heading_violations = Vec::new();
359 let mut processed_headings = std::collections::HashSet::new();
360
361 for (line_num, line_info) in ctx.lines.iter().enumerate() {
362 if processed_headings.contains(&line_num) || line_info.heading.is_none() {
364 continue;
365 }
366
367 let heading = line_info.heading.as_ref().unwrap();
368
369 if !heading.is_valid {
371 continue;
372 }
373
374 let heading_level = heading.level as usize;
375
376 processed_headings.insert(line_num);
380
381 let is_first_heading = Some(line_num) == heading_at_start_idx;
383
384 let required_above_count = self.config.lines_above.get_for_level(heading_level).required_count();
386 let required_below_count = self.config.lines_below.get_for_level(heading_level).required_count();
387
388 let should_check_above =
390 required_above_count.is_some() && line_num > 0 && (!is_first_heading || !self.config.allowed_at_start);
391 if should_check_above {
392 let mut blank_lines_above = 0;
393 let mut hit_frontmatter_end = false;
394 for j in (0..line_num).rev() {
395 let line_content = ctx.lines[j].content(ctx.content);
396 let trimmed = line_content.trim();
397 if ctx.lines[j].is_blank {
398 blank_lines_above += 1;
399 } else if ctx.lines[j].in_html_comment || (trimmed.starts_with("<!--") && trimmed.ends_with("-->"))
400 {
401 continue;
403 } else if ctx.lines[j].in_front_matter {
404 hit_frontmatter_end = true;
409 break;
410 } else {
411 break;
412 }
413 }
414 let required = required_above_count.unwrap();
415 if !hit_frontmatter_end && blank_lines_above < required {
416 let needed_blanks = required - blank_lines_above;
417 heading_violations.push((line_num, "above", needed_blanks, heading_level));
418 }
419 }
420
421 let effective_last_line = if matches!(
423 heading.style,
424 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
425 ) {
426 line_num + 1 } else {
428 line_num
429 };
430
431 if effective_last_line < ctx.lines.len() - 1 {
433 let mut next_non_blank_idx = effective_last_line + 1;
435 while next_non_blank_idx < ctx.lines.len() && ctx.lines[next_non_blank_idx].is_blank {
436 next_non_blank_idx += 1;
437 }
438
439 let next_line_is_special = next_non_blank_idx < ctx.lines.len() && {
441 let next_line = &ctx.lines[next_non_blank_idx];
442 let next_trimmed = next_line.content(ctx.content).trim();
443
444 let is_code_fence = (next_trimmed.starts_with("```") || next_trimmed.starts_with("~~~"))
446 && (next_trimmed.len() == 3
447 || (next_trimmed.len() > 3
448 && next_trimmed
449 .chars()
450 .nth(3)
451 .is_some_and(|c| c.is_whitespace() || c.is_alphabetic())));
452
453 let is_list_item = next_line.list_item.is_some();
455
456 is_code_fence || is_list_item
457 };
458
459 if !next_line_is_special && let Some(required) = required_below_count {
461 let blank_lines_below = next_non_blank_idx - effective_last_line - 1;
463
464 if blank_lines_below < required {
465 let needed_blanks = required - blank_lines_below;
466 heading_violations.push((line_num, "below", needed_blanks, heading_level));
467 }
468 }
469 }
470 }
471
472 for (heading_line, position, needed_blanks, heading_level) in heading_violations {
474 let heading_display_line = heading_line + 1; let line_info = &ctx.lines[heading_line];
476
477 let (start_line, start_col, end_line, end_col) =
479 calculate_heading_range(heading_display_line, line_info.content(ctx.content));
480
481 let required_above_count = self
482 .config
483 .lines_above
484 .get_for_level(heading_level)
485 .required_count()
486 .expect("Violations only generated for limited 'above' requirements");
487 let required_below_count = self
488 .config
489 .lines_below
490 .get_for_level(heading_level)
491 .required_count()
492 .expect("Violations only generated for limited 'below' requirements");
493
494 let (message, insertion_point) = match position {
495 "above" => (
496 format!(
497 "Expected {} blank {} above heading",
498 required_above_count,
499 if required_above_count == 1 { "line" } else { "lines" }
500 ),
501 heading_line, ),
503 "below" => {
504 let insert_after = if line_info.heading.as_ref().is_some_and(|h| {
506 matches!(
507 h.style,
508 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
509 )
510 }) {
511 heading_line + 2
512 } else {
513 heading_line + 1
514 };
515
516 (
517 format!(
518 "Expected {} blank {} below heading",
519 required_below_count,
520 if required_below_count == 1 { "line" } else { "lines" }
521 ),
522 insert_after,
523 )
524 }
525 _ => continue,
526 };
527
528 let byte_range = if insertion_point == 0 && position == "above" {
530 0..0
532 } else if position == "above" && insertion_point > 0 {
533 ctx.lines[insertion_point].byte_offset..ctx.lines[insertion_point].byte_offset
535 } else if position == "below" && insertion_point - 1 < ctx.lines.len() {
536 let line_idx = insertion_point - 1;
538 let line_end_offset = if line_idx + 1 < ctx.lines.len() {
539 ctx.lines[line_idx + 1].byte_offset
540 } else {
541 ctx.content.len()
542 };
543 line_end_offset..line_end_offset
544 } else {
545 let content_len = ctx.content.len();
547 content_len..content_len
548 };
549
550 result.push(LintWarning {
551 rule_name: Some(self.name().to_string()),
552 message,
553 line: start_line,
554 column: start_col,
555 end_line,
556 end_column: end_col,
557 severity: Severity::Warning,
558 fix: Some(Fix {
559 range: byte_range,
560 replacement: line_ending.repeat(needed_blanks),
561 }),
562 });
563 }
564
565 Ok(result)
566 }
567
568 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
569 if ctx.content.is_empty() {
570 return Ok(ctx.content.to_string());
571 }
572
573 let fixed = self._fix_content(ctx);
575
576 Ok(fixed)
577 }
578
579 fn category(&self) -> RuleCategory {
581 RuleCategory::Heading
582 }
583
584 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
586 if ctx.content.is_empty() || !ctx.likely_has_headings() {
588 return true;
589 }
590 ctx.lines.iter().all(|line| line.heading.is_none())
592 }
593
594 fn as_any(&self) -> &dyn std::any::Any {
595 self
596 }
597
598 fn default_config_section(&self) -> Option<(String, toml::Value)> {
599 let default_config = MD022Config::default();
600 let json_value = serde_json::to_value(&default_config).ok()?;
601 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
602
603 if let toml::Value::Table(table) = toml_value {
604 if !table.is_empty() {
605 Some((MD022Config::RULE_NAME.to_string(), toml::Value::Table(table)))
606 } else {
607 None
608 }
609 } else {
610 None
611 }
612 }
613
614 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
615 where
616 Self: Sized,
617 {
618 let rule_config = crate::rule_config_serde::load_rule_config::<MD022Config>(config);
619 Box::new(Self::from_config_struct(rule_config))
620 }
621}
622
623#[cfg(test)]
624mod tests {
625 use super::*;
626 use crate::lint_context::LintContext;
627
628 #[test]
629 fn test_valid_headings() {
630 let rule = MD022BlanksAroundHeadings::default();
631 let content = "\n# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.\n";
632 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
633 let result = rule.check(&ctx).unwrap();
634 assert!(result.is_empty());
635 }
636
637 #[test]
638 fn test_missing_blank_above() {
639 let rule = MD022BlanksAroundHeadings::default();
640 let content = "# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.\n";
641 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
642 let result = rule.check(&ctx).unwrap();
643 assert_eq!(result.len(), 0); let fixed = rule.fix(&ctx).unwrap();
646
647 assert!(fixed.contains("# Heading 1"));
650 assert!(fixed.contains("Some content."));
651 assert!(fixed.contains("## Heading 2"));
652 assert!(fixed.contains("More content."));
653 }
654
655 #[test]
656 fn test_missing_blank_below() {
657 let rule = MD022BlanksAroundHeadings::default();
658 let content = "\n# Heading 1\nSome content.\n\n## Heading 2\n\nMore content.\n";
659 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
660 let result = rule.check(&ctx).unwrap();
661 assert_eq!(result.len(), 1);
662 assert_eq!(result[0].line, 2);
663
664 let fixed = rule.fix(&ctx).unwrap();
666 assert!(fixed.contains("# Heading 1\n\nSome content"));
667 }
668
669 #[test]
670 fn test_missing_blank_above_and_below() {
671 let rule = MD022BlanksAroundHeadings::default();
672 let content = "# Heading 1\nSome content.\n## Heading 2\nMore content.\n";
673 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
674 let result = rule.check(&ctx).unwrap();
675 assert_eq!(result.len(), 3); let fixed = rule.fix(&ctx).unwrap();
679 assert!(fixed.contains("# Heading 1\n\nSome content"));
680 assert!(fixed.contains("Some content.\n\n## Heading 2"));
681 assert!(fixed.contains("## Heading 2\n\nMore content"));
682 }
683
684 #[test]
685 fn test_fix_headings() {
686 let rule = MD022BlanksAroundHeadings::default();
687 let content = "# Heading 1\nSome content.\n## Heading 2\nMore content.";
688 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
689 let result = rule.fix(&ctx).unwrap();
690
691 let expected = "# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.";
692 assert_eq!(result, expected);
693 }
694
695 #[test]
696 fn test_consecutive_headings_pattern() {
697 let rule = MD022BlanksAroundHeadings::default();
698 let content = "# Heading 1\n## Heading 2\n### Heading 3";
699 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
700 let result = rule.fix(&ctx).unwrap();
701
702 let lines: Vec<&str> = result.lines().collect();
704 assert!(!lines.is_empty());
705
706 let h1_pos = lines.iter().position(|&l| l == "# Heading 1").unwrap();
708 let h2_pos = lines.iter().position(|&l| l == "## Heading 2").unwrap();
709 let h3_pos = lines.iter().position(|&l| l == "### Heading 3").unwrap();
710
711 assert!(
713 h2_pos > h1_pos + 1,
714 "Should have at least one blank line after first heading"
715 );
716 assert!(
717 h3_pos > h2_pos + 1,
718 "Should have at least one blank line after second heading"
719 );
720
721 assert!(lines[h1_pos + 1].trim().is_empty(), "Line after h1 should be blank");
723
724 assert!(lines[h2_pos + 1].trim().is_empty(), "Line after h2 should be blank");
726 }
727
728 #[test]
729 fn test_blanks_around_setext_headings() {
730 let rule = MD022BlanksAroundHeadings::default();
731 let content = "Heading 1\n=========\nSome content.\nHeading 2\n---------\nMore content.";
732 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
733 let result = rule.fix(&ctx).unwrap();
734
735 let lines: Vec<&str> = result.lines().collect();
737
738 assert!(result.contains("Heading 1"));
740 assert!(result.contains("========="));
741 assert!(result.contains("Some content."));
742 assert!(result.contains("Heading 2"));
743 assert!(result.contains("---------"));
744 assert!(result.contains("More content."));
745
746 let heading1_marker_idx = lines.iter().position(|&l| l == "=========").unwrap();
748 let some_content_idx = lines.iter().position(|&l| l == "Some content.").unwrap();
749 assert!(
750 some_content_idx > heading1_marker_idx + 1,
751 "Should have a blank line after the first heading"
752 );
753
754 let heading2_marker_idx = lines.iter().position(|&l| l == "---------").unwrap();
755 let more_content_idx = lines.iter().position(|&l| l == "More content.").unwrap();
756 assert!(
757 more_content_idx > heading2_marker_idx + 1,
758 "Should have a blank line after the second heading"
759 );
760
761 let fixed_ctx = LintContext::new(&result, crate::config::MarkdownFlavor::Standard, None);
763 let fixed_warnings = rule.check(&fixed_ctx).unwrap();
764 assert!(fixed_warnings.is_empty(), "Fixed content should have no warnings");
765 }
766
767 #[test]
768 fn test_fix_specific_blank_line_cases() {
769 let rule = MD022BlanksAroundHeadings::default();
770
771 let content1 = "# Heading 1\n## Heading 2\n### Heading 3";
773 let ctx1 = LintContext::new(content1, crate::config::MarkdownFlavor::Standard, None);
774 let result1 = rule.fix(&ctx1).unwrap();
775 assert!(result1.contains("# Heading 1"));
777 assert!(result1.contains("## Heading 2"));
778 assert!(result1.contains("### Heading 3"));
779 let lines: Vec<&str> = result1.lines().collect();
781 let h1_pos = lines.iter().position(|&l| l == "# Heading 1").unwrap();
782 let h2_pos = lines.iter().position(|&l| l == "## Heading 2").unwrap();
783 assert!(lines[h1_pos + 1].trim().is_empty(), "Should have a blank line after h1");
784 assert!(lines[h2_pos + 1].trim().is_empty(), "Should have a blank line after h2");
785
786 let content2 = "# Heading 1\nContent under heading 1\n## Heading 2";
788 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
789 let result2 = rule.fix(&ctx2).unwrap();
790 assert!(result2.contains("# Heading 1"));
792 assert!(result2.contains("Content under heading 1"));
793 assert!(result2.contains("## Heading 2"));
794 let lines2: Vec<&str> = result2.lines().collect();
796 let h1_pos2 = lines2.iter().position(|&l| l == "# Heading 1").unwrap();
797 let _content_pos = lines2.iter().position(|&l| l == "Content under heading 1").unwrap();
798 assert!(
799 lines2[h1_pos2 + 1].trim().is_empty(),
800 "Should have a blank line after heading 1"
801 );
802
803 let content3 = "# Heading 1\n\n\n## Heading 2\n\n\n### Heading 3\n\nContent";
805 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
806 let result3 = rule.fix(&ctx3).unwrap();
807 assert!(result3.contains("# Heading 1"));
809 assert!(result3.contains("## Heading 2"));
810 assert!(result3.contains("### Heading 3"));
811 assert!(result3.contains("Content"));
812 }
813
814 #[test]
815 fn test_fix_preserves_existing_blank_lines() {
816 let rule = MD022BlanksAroundHeadings::new();
817 let content = "# Title
818
819## Section 1
820
821Content here.
822
823## Section 2
824
825More content.
826### Missing Blank Above
827
828Even more content.
829
830## Section 3
831
832Final content.";
833
834 let expected = "# Title
835
836## Section 1
837
838Content here.
839
840## Section 2
841
842More content.
843
844### Missing Blank Above
845
846Even more content.
847
848## Section 3
849
850Final content.";
851
852 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
853 let result = rule._fix_content(&ctx);
854 assert_eq!(
855 result, expected,
856 "Fix should only add missing blank lines, never remove existing ones"
857 );
858 }
859
860 #[test]
861 fn test_fix_preserves_trailing_newline() {
862 let rule = MD022BlanksAroundHeadings::new();
863
864 let content_with_newline = "# Title\nContent here.\n";
866 let ctx = LintContext::new(content_with_newline, crate::config::MarkdownFlavor::Standard, None);
867 let result = rule.fix(&ctx).unwrap();
868 assert!(result.ends_with('\n'), "Should preserve trailing newline");
869
870 let content_without_newline = "# Title\nContent here.";
872 let ctx = LintContext::new(content_without_newline, crate::config::MarkdownFlavor::Standard, None);
873 let result = rule.fix(&ctx).unwrap();
874 assert!(
875 !result.ends_with('\n'),
876 "Should not add trailing newline if original didn't have one"
877 );
878 }
879
880 #[test]
881 fn test_fix_does_not_add_blank_lines_before_lists() {
882 let rule = MD022BlanksAroundHeadings::new();
883 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.";
884
885 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.";
886
887 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
888 let result = rule._fix_content(&ctx);
889 assert_eq!(result, expected, "Fix should not add blank lines before lists");
890 }
891
892 #[test]
893 fn test_per_level_configuration_no_blank_above_h1() {
894 use md022_config::HeadingLevelConfig;
895
896 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
898 lines_above: HeadingLevelConfig::per_level([0, 1, 1, 1, 1, 1]),
899 lines_below: HeadingLevelConfig::scalar(1),
900 allowed_at_start: false, });
902
903 let content = "Some text\n# Heading 1\n\nMore text";
905 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
906 let warnings = rule.check(&ctx).unwrap();
907 assert_eq!(warnings.len(), 0, "H1 without blank above should not trigger warning");
908
909 let content = "Some text\n## Heading 2\n\nMore text";
911 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
912 let warnings = rule.check(&ctx).unwrap();
913 assert_eq!(warnings.len(), 1, "H2 without blank above should trigger warning");
914 assert!(warnings[0].message.contains("above"));
915 }
916
917 #[test]
918 fn test_per_level_configuration_different_requirements() {
919 use md022_config::HeadingLevelConfig;
920
921 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
923 lines_above: HeadingLevelConfig::per_level([0, 1, 1, 2, 2, 2]),
924 lines_below: HeadingLevelConfig::scalar(1),
925 allowed_at_start: false,
926 });
927
928 let content = "Text\n# H1\n\nText\n\n## H2\n\nText\n\n### H3\n\nText\n\n\n#### H4\n\nText";
929 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
930 let warnings = rule.check(&ctx).unwrap();
931
932 assert_eq!(
934 warnings.len(),
935 0,
936 "All headings should satisfy level-specific requirements"
937 );
938 }
939
940 #[test]
941 fn test_per_level_configuration_violations() {
942 use md022_config::HeadingLevelConfig;
943
944 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
946 lines_above: HeadingLevelConfig::per_level([1, 1, 1, 2, 1, 1]),
947 lines_below: HeadingLevelConfig::scalar(1),
948 allowed_at_start: false,
949 });
950
951 let content = "Text\n\n#### Heading 4\n\nMore text";
953 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
954 let warnings = rule.check(&ctx).unwrap();
955
956 assert_eq!(warnings.len(), 1, "H4 with insufficient blanks should trigger warning");
957 assert!(warnings[0].message.contains("2 blank lines above"));
958 }
959
960 #[test]
961 fn test_per_level_fix_different_levels() {
962 use md022_config::HeadingLevelConfig;
963
964 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
966 lines_above: HeadingLevelConfig::per_level([0, 1, 2, 2, 2, 2]),
967 lines_below: HeadingLevelConfig::scalar(1),
968 allowed_at_start: false,
969 });
970
971 let content = "Text\n# H1\nContent\n## H2\nContent\n### H3\nContent";
972 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
973 let fixed = rule.fix(&ctx).unwrap();
974
975 assert!(fixed.contains("Text\n# H1\n\nContent"));
977 assert!(fixed.contains("Content\n\n## H2\n\nContent"));
978 assert!(fixed.contains("Content\n\n\n### H3\n\nContent"));
979 }
980
981 #[test]
982 fn test_per_level_below_configuration() {
983 use md022_config::HeadingLevelConfig;
984
985 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
987 lines_above: HeadingLevelConfig::scalar(1),
988 lines_below: HeadingLevelConfig::per_level([2, 1, 1, 1, 1, 1]), allowed_at_start: true,
990 });
991
992 let content = "# Heading 1\n\nSome text";
994 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
995 let warnings = rule.check(&ctx).unwrap();
996
997 assert_eq!(
998 warnings.len(),
999 1,
1000 "H1 with insufficient blanks below should trigger warning"
1001 );
1002 assert!(warnings[0].message.contains("2 blank lines below"));
1003 }
1004
1005 #[test]
1006 fn test_scalar_configuration_still_works() {
1007 use md022_config::HeadingLevelConfig;
1008
1009 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1011 lines_above: HeadingLevelConfig::scalar(2),
1012 lines_below: HeadingLevelConfig::scalar(2),
1013 allowed_at_start: false,
1014 });
1015
1016 let content = "Text\n# H1\nContent\n## H2\nContent";
1017 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1018 let warnings = rule.check(&ctx).unwrap();
1019
1020 assert!(!warnings.is_empty(), "Should have violations for insufficient blanks");
1022 }
1023
1024 #[test]
1025 fn test_unlimited_configuration_skips_requirements() {
1026 use md022_config::{HeadingBlankRequirement, HeadingLevelConfig};
1027
1028 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1030 lines_above: HeadingLevelConfig::per_level_requirements([
1031 HeadingBlankRequirement::unlimited(),
1032 HeadingBlankRequirement::limited(1),
1033 HeadingBlankRequirement::limited(1),
1034 HeadingBlankRequirement::limited(1),
1035 HeadingBlankRequirement::limited(1),
1036 HeadingBlankRequirement::limited(1),
1037 ]),
1038 lines_below: HeadingLevelConfig::per_level_requirements([
1039 HeadingBlankRequirement::unlimited(),
1040 HeadingBlankRequirement::limited(1),
1041 HeadingBlankRequirement::limited(1),
1042 HeadingBlankRequirement::limited(1),
1043 HeadingBlankRequirement::limited(1),
1044 HeadingBlankRequirement::limited(1),
1045 ]),
1046 allowed_at_start: false,
1047 });
1048
1049 let content = "# H1\nParagraph\n## H2\nParagraph";
1050 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1051 let warnings = rule.check(&ctx).unwrap();
1052
1053 assert_eq!(warnings.len(), 2, "Only non-unlimited headings should warn");
1055 assert!(
1056 warnings.iter().all(|w| w.line >= 3),
1057 "Warnings should target later headings"
1058 );
1059
1060 let fixed = rule.fix(&ctx).unwrap();
1062 assert!(
1063 fixed.starts_with("# H1\nParagraph\n\n## H2"),
1064 "H1 should remain unchanged"
1065 );
1066 }
1067
1068 #[test]
1069 fn test_html_comment_transparency() {
1070 let rule = MD022BlanksAroundHeadings::default();
1074
1075 let content = "Some content\n\n<!-- markdownlint-disable-next-line MD001 -->\n#### Heading";
1078 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1079 let warnings = rule.check(&ctx).unwrap();
1080 assert!(
1081 warnings.is_empty(),
1082 "HTML comment is transparent - blank line above it counts for heading"
1083 );
1084
1085 let content_multiline = "Some content\n\n<!-- This is a\nmulti-line comment -->\n#### Heading";
1087 let ctx_multiline = LintContext::new(content_multiline, crate::config::MarkdownFlavor::Standard, None);
1088 let warnings_multiline = rule.check(&ctx_multiline).unwrap();
1089 assert!(
1090 warnings_multiline.is_empty(),
1091 "Multi-line HTML comment is also transparent"
1092 );
1093 }
1094
1095 #[test]
1096 fn test_frontmatter_transparency() {
1097 let rule = MD022BlanksAroundHeadings::default();
1100
1101 let content = "---\ntitle: Test\n---\n# First heading";
1103 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1104 let warnings = rule.check(&ctx).unwrap();
1105 assert!(
1106 warnings.is_empty(),
1107 "Frontmatter is transparent - heading can appear immediately after"
1108 );
1109
1110 let content_with_blank = "---\ntitle: Test\n---\n\n# First heading";
1112 let ctx_with_blank = LintContext::new(content_with_blank, crate::config::MarkdownFlavor::Standard, None);
1113 let warnings_with_blank = rule.check(&ctx_with_blank).unwrap();
1114 assert!(
1115 warnings_with_blank.is_empty(),
1116 "Heading with blank line after frontmatter should also be valid"
1117 );
1118
1119 let content_toml = "+++\ntitle = \"Test\"\n+++\n# First heading";
1121 let ctx_toml = LintContext::new(content_toml, crate::config::MarkdownFlavor::Standard, None);
1122 let warnings_toml = rule.check(&ctx_toml).unwrap();
1123 assert!(
1124 warnings_toml.is_empty(),
1125 "TOML frontmatter is also transparent for MD022"
1126 );
1127 }
1128
1129 #[test]
1130 fn test_horizontal_rule_not_treated_as_frontmatter() {
1131 let rule = MD022BlanksAroundHeadings::default();
1134
1135 let content = "Some content\n\n---\n# Heading after HR";
1137 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1138 let warnings = rule.check(&ctx).unwrap();
1139 assert!(
1140 !warnings.is_empty(),
1141 "Heading after horizontal rule without blank line SHOULD trigger MD022"
1142 );
1143 assert!(
1144 warnings.iter().any(|w| w.line == 4),
1145 "Warning should be on line 4 (the heading line)"
1146 );
1147
1148 let content_with_blank = "Some content\n\n---\n\n# Heading after HR";
1150 let ctx_with_blank = LintContext::new(content_with_blank, crate::config::MarkdownFlavor::Standard, None);
1151 let warnings_with_blank = rule.check(&ctx_with_blank).unwrap();
1152 assert!(
1153 warnings_with_blank.is_empty(),
1154 "Heading with blank line after HR should not trigger MD022"
1155 );
1156
1157 let content_hr_start = "---\n# Heading";
1159 let ctx_hr_start = LintContext::new(content_hr_start, crate::config::MarkdownFlavor::Standard, None);
1160 let warnings_hr_start = rule.check(&ctx_hr_start).unwrap();
1161 assert!(
1162 !warnings_hr_start.is_empty(),
1163 "Heading after HR at document start SHOULD trigger MD022"
1164 );
1165
1166 let content_multi_hr = "Content\n\n---\n\n---\n# Heading";
1168 let ctx_multi_hr = LintContext::new(content_multi_hr, crate::config::MarkdownFlavor::Standard, None);
1169 let warnings_multi_hr = rule.check(&ctx_multi_hr).unwrap();
1170 assert!(
1171 !warnings_multi_hr.is_empty(),
1172 "Heading after multiple HRs without blank line SHOULD trigger MD022"
1173 );
1174 }
1175
1176 #[test]
1177 fn test_all_hr_styles_require_blank_before_heading() {
1178 let rule = MD022BlanksAroundHeadings::default();
1180
1181 let hr_styles = [
1183 "---", "***", "___", "- - -", "* * *", "_ _ _", "----", "****", "____", "- - - -",
1184 "- - -", " ---", " ---", ];
1188
1189 for hr in hr_styles {
1190 let content = format!("Content\n\n{hr}\n# Heading");
1191 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1192 let warnings = rule.check(&ctx).unwrap();
1193 assert!(
1194 !warnings.is_empty(),
1195 "HR style '{hr}' followed by heading should trigger MD022"
1196 );
1197 }
1198 }
1199
1200 #[test]
1201 fn test_setext_heading_after_hr() {
1202 let rule = MD022BlanksAroundHeadings::default();
1204
1205 let content = "Content\n\n---\nHeading\n======";
1207 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1208 let warnings = rule.check(&ctx).unwrap();
1209 assert!(
1210 !warnings.is_empty(),
1211 "Setext heading after HR without blank should trigger MD022"
1212 );
1213
1214 let content_h2 = "Content\n\n---\nHeading\n------";
1216 let ctx_h2 = LintContext::new(content_h2, crate::config::MarkdownFlavor::Standard, None);
1217 let warnings_h2 = rule.check(&ctx_h2).unwrap();
1218 assert!(
1219 !warnings_h2.is_empty(),
1220 "Setext h2 after HR without blank should trigger MD022"
1221 );
1222
1223 let content_ok = "Content\n\n---\n\nHeading\n======";
1225 let ctx_ok = LintContext::new(content_ok, crate::config::MarkdownFlavor::Standard, None);
1226 let warnings_ok = rule.check(&ctx_ok).unwrap();
1227 assert!(
1228 warnings_ok.is_empty(),
1229 "Setext heading with blank after HR should not warn"
1230 );
1231 }
1232
1233 #[test]
1234 fn test_hr_in_code_block_not_treated_as_hr() {
1235 let rule = MD022BlanksAroundHeadings::default();
1237
1238 let content = "```\n---\n```\n# Heading";
1241 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1242 let warnings = rule.check(&ctx).unwrap();
1243 assert!(!warnings.is_empty(), "Heading after code block still needs blank line");
1246
1247 let content_ok = "```\n---\n```\n\n# Heading";
1249 let ctx_ok = LintContext::new(content_ok, crate::config::MarkdownFlavor::Standard, None);
1250 let warnings_ok = rule.check(&ctx_ok).unwrap();
1251 assert!(
1252 warnings_ok.is_empty(),
1253 "Heading with blank after code block should not warn"
1254 );
1255 }
1256
1257 #[test]
1258 fn test_hr_in_html_comment_not_treated_as_hr() {
1259 let rule = MD022BlanksAroundHeadings::default();
1261
1262 let content = "<!-- \n---\n -->\n# Heading";
1264 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1265 let warnings = rule.check(&ctx).unwrap();
1266 assert!(
1268 warnings.is_empty(),
1269 "HR inside HTML comment should be ignored - heading after comment is OK"
1270 );
1271 }
1272
1273 #[test]
1274 fn test_invalid_hr_not_triggering() {
1275 let rule = MD022BlanksAroundHeadings::default();
1277
1278 let invalid_hrs = [
1279 " ---", "\t---", "--", "**", "__", "-*-", "---a", "a---", ];
1288
1289 for invalid in invalid_hrs {
1290 let content = format!("Content\n\n{invalid}\n# Heading");
1293 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1294 let _ = rule.check(&ctx);
1297 }
1298 }
1299
1300 #[test]
1301 fn test_frontmatter_vs_horizontal_rule_distinction() {
1302 let rule = MD022BlanksAroundHeadings::default();
1304
1305 let content = "---\ntitle: Test\n---\n\nSome content\n\n---\n# Heading after HR";
1308 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1309 let warnings = rule.check(&ctx).unwrap();
1310 assert!(
1311 !warnings.is_empty(),
1312 "HR after frontmatter content should still require blank line before heading"
1313 );
1314
1315 let content_ok = "---\ntitle: Test\n---\n\nSome content\n\n---\n\n# Heading after HR";
1317 let ctx_ok = LintContext::new(content_ok, crate::config::MarkdownFlavor::Standard, None);
1318 let warnings_ok = rule.check(&ctx_ok).unwrap();
1319 assert!(
1320 warnings_ok.is_empty(),
1321 "HR with blank line before heading should not warn"
1322 );
1323 }
1324}