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_blank = false;
123 ctx.lines.iter().enumerate().find_map(|(i, line)| {
124 if line.heading.is_some() && !found_non_blank {
125 Some(i)
126 } else {
127 if !line.is_blank {
128 found_non_blank = true;
129 }
130 None
131 }
132 })
133 };
134
135 for (i, line_info) in ctx.lines.iter().enumerate() {
136 if skip_next {
137 skip_next = false;
138 continue;
139 }
140 let line = line_info.content(ctx.content);
141
142 if line_info.in_code_block {
143 result.push(line.to_string());
144 continue;
145 }
146
147 if let Some(heading) = &line_info.heading {
149 let is_first_heading = Some(i) == heading_at_start_idx;
151 let heading_level = heading.level as usize;
152
153 let mut blank_lines_above = 0;
155 let mut check_idx = result.len();
156 while check_idx > 0 {
157 let prev_line = &result[check_idx - 1];
158 let trimmed = prev_line.trim();
159 if trimmed.is_empty() {
160 blank_lines_above += 1;
161 check_idx -= 1;
162 } else if trimmed.starts_with("<!--") && trimmed.ends_with("-->") {
163 check_idx -= 1;
165 } else {
166 break;
167 }
168 }
169
170 let requirement_above = self.config.lines_above.get_for_level(heading_level);
172 let needed_blanks_above = if is_first_heading && self.config.allowed_at_start {
173 0
174 } else {
175 requirement_above.required_count().unwrap_or(0)
176 };
177
178 while blank_lines_above < needed_blanks_above {
180 result.push(String::new());
181 blank_lines_above += 1;
182 }
183
184 result.push(line.to_string());
186
187 if matches!(
189 heading.style,
190 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
191 ) {
192 if i + 1 < ctx.lines.len() {
194 result.push(ctx.lines[i + 1].content(ctx.content).to_string());
195 skip_next = true; }
197
198 let mut blank_lines_below = 0;
200 let mut next_content_line_idx = None;
201 for j in (i + 2)..ctx.lines.len() {
202 if ctx.lines[j].is_blank {
203 blank_lines_below += 1;
204 } else {
205 next_content_line_idx = Some(j);
206 break;
207 }
208 }
209
210 let next_is_special = if let Some(idx) = next_content_line_idx {
212 let next_line = &ctx.lines[idx];
213 next_line.list_item.is_some() || {
214 let trimmed = next_line.content(ctx.content).trim();
215 (trimmed.starts_with("```") || trimmed.starts_with("~~~"))
216 && (trimmed.len() == 3
217 || (trimmed.len() > 3
218 && trimmed
219 .chars()
220 .nth(3)
221 .is_some_and(|c| c.is_whitespace() || c.is_alphabetic())))
222 }
223 } else {
224 false
225 };
226
227 let requirement_below = self.config.lines_below.get_for_level(heading_level);
229 let needed_blanks_below = if next_is_special {
230 0
231 } else {
232 requirement_below.required_count().unwrap_or(0)
233 };
234 if blank_lines_below < needed_blanks_below {
235 for _ in 0..(needed_blanks_below - blank_lines_below) {
236 result.push(String::new());
237 }
238 }
239 } else {
240 let mut blank_lines_below = 0;
242 let mut next_content_line_idx = None;
243 for j in (i + 1)..ctx.lines.len() {
244 if ctx.lines[j].is_blank {
245 blank_lines_below += 1;
246 } else {
247 next_content_line_idx = Some(j);
248 break;
249 }
250 }
251
252 let next_is_special = if let Some(idx) = next_content_line_idx {
254 let next_line = &ctx.lines[idx];
255 next_line.list_item.is_some() || {
256 let trimmed = next_line.content(ctx.content).trim();
257 (trimmed.starts_with("```") || trimmed.starts_with("~~~"))
258 && (trimmed.len() == 3
259 || (trimmed.len() > 3
260 && trimmed
261 .chars()
262 .nth(3)
263 .is_some_and(|c| c.is_whitespace() || c.is_alphabetic())))
264 }
265 } else {
266 false
267 };
268
269 let requirement_below = self.config.lines_below.get_for_level(heading_level);
271 let needed_blanks_below = if next_is_special {
272 0
273 } else {
274 requirement_below.required_count().unwrap_or(0)
275 };
276 if blank_lines_below < needed_blanks_below {
277 for _ in 0..(needed_blanks_below - blank_lines_below) {
278 result.push(String::new());
279 }
280 }
281 }
282 } else {
283 result.push(line.to_string());
285 }
286 }
287
288 let joined = result.join(line_ending);
289
290 if had_trailing_newline && !joined.ends_with('\n') {
293 format!("{joined}{line_ending}")
294 } else if !had_trailing_newline && joined.ends_with('\n') {
295 joined[..joined.len() - 1].to_string()
297 } else {
298 joined
299 }
300 }
301}
302
303impl Rule for MD022BlanksAroundHeadings {
304 fn name(&self) -> &'static str {
305 "MD022"
306 }
307
308 fn description(&self) -> &'static str {
309 "Headings should be surrounded by blank lines"
310 }
311
312 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
313 let mut result = Vec::new();
314
315 if ctx.lines.is_empty() {
317 return Ok(result);
318 }
319
320 let line_ending = "\n";
322
323 let heading_at_start_idx = {
324 let mut found_non_blank = false;
325 ctx.lines.iter().enumerate().find_map(|(i, line)| {
326 if line.heading.is_some() && !found_non_blank {
327 Some(i)
328 } else {
329 if !line.is_blank {
330 found_non_blank = true;
331 }
332 None
333 }
334 })
335 };
336
337 let mut heading_violations = Vec::new();
339 let mut processed_headings = std::collections::HashSet::new();
340
341 for (line_num, line_info) in ctx.lines.iter().enumerate() {
342 if processed_headings.contains(&line_num) || line_info.heading.is_none() {
344 continue;
345 }
346
347 let heading = line_info.heading.as_ref().unwrap();
348 let heading_level = heading.level as usize;
349
350 if matches!(
352 heading.style,
353 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
354 ) {
355 if line_num > 0 && ctx.lines[line_num - 1].heading.is_none() {
357 continue; }
359 }
360
361 processed_headings.insert(line_num);
362
363 let is_first_heading = Some(line_num) == heading_at_start_idx;
365
366 let required_above_count = self.config.lines_above.get_for_level(heading_level).required_count();
368 let required_below_count = self.config.lines_below.get_for_level(heading_level).required_count();
369
370 let should_check_above =
372 required_above_count.is_some() && line_num > 0 && (!is_first_heading || !self.config.allowed_at_start);
373 if should_check_above {
374 let mut blank_lines_above = 0;
375 let mut hit_frontmatter_end = false;
376 for j in (0..line_num).rev() {
377 let line_content = ctx.lines[j].content(ctx.content);
378 let trimmed = line_content.trim();
379 if ctx.lines[j].is_blank {
380 blank_lines_above += 1;
381 } else if ctx.lines[j].in_html_comment || (trimmed.starts_with("<!--") && trimmed.ends_with("-->"))
382 {
383 continue;
385 } else if ctx.lines[j].in_front_matter || trimmed == "---" {
386 hit_frontmatter_end = true;
388 break;
389 } else {
390 break;
391 }
392 }
393 let required = required_above_count.unwrap();
394 if !hit_frontmatter_end && blank_lines_above < required {
395 let needed_blanks = required - blank_lines_above;
396 heading_violations.push((line_num, "above", needed_blanks, heading_level));
397 }
398 }
399
400 let effective_last_line = if matches!(
402 heading.style,
403 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
404 ) {
405 line_num + 1 } else {
407 line_num
408 };
409
410 if effective_last_line < ctx.lines.len() - 1 {
412 let mut next_non_blank_idx = effective_last_line + 1;
414 while next_non_blank_idx < ctx.lines.len() && ctx.lines[next_non_blank_idx].is_blank {
415 next_non_blank_idx += 1;
416 }
417
418 let next_line_is_special = next_non_blank_idx < ctx.lines.len() && {
420 let next_line = &ctx.lines[next_non_blank_idx];
421 let next_trimmed = next_line.content(ctx.content).trim();
422
423 let is_code_fence = (next_trimmed.starts_with("```") || next_trimmed.starts_with("~~~"))
425 && (next_trimmed.len() == 3
426 || (next_trimmed.len() > 3
427 && next_trimmed
428 .chars()
429 .nth(3)
430 .is_some_and(|c| c.is_whitespace() || c.is_alphabetic())));
431
432 let is_list_item = next_line.list_item.is_some();
434
435 is_code_fence || is_list_item
436 };
437
438 if !next_line_is_special && let Some(required) = required_below_count {
440 let blank_lines_below = next_non_blank_idx - effective_last_line - 1;
442
443 if blank_lines_below < required {
444 let needed_blanks = required - blank_lines_below;
445 heading_violations.push((line_num, "below", needed_blanks, heading_level));
446 }
447 }
448 }
449 }
450
451 for (heading_line, position, needed_blanks, heading_level) in heading_violations {
453 let heading_display_line = heading_line + 1; let line_info = &ctx.lines[heading_line];
455
456 let (start_line, start_col, end_line, end_col) =
458 calculate_heading_range(heading_display_line, line_info.content(ctx.content));
459
460 let required_above_count = self
461 .config
462 .lines_above
463 .get_for_level(heading_level)
464 .required_count()
465 .expect("Violations only generated for limited 'above' requirements");
466 let required_below_count = self
467 .config
468 .lines_below
469 .get_for_level(heading_level)
470 .required_count()
471 .expect("Violations only generated for limited 'below' requirements");
472
473 let (message, insertion_point) = match position {
474 "above" => (
475 format!(
476 "Expected {} blank {} above heading",
477 required_above_count,
478 if required_above_count == 1 { "line" } else { "lines" }
479 ),
480 heading_line, ),
482 "below" => {
483 let insert_after = if line_info.heading.as_ref().is_some_and(|h| {
485 matches!(
486 h.style,
487 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
488 )
489 }) {
490 heading_line + 2
491 } else {
492 heading_line + 1
493 };
494
495 (
496 format!(
497 "Expected {} blank {} below heading",
498 required_below_count,
499 if required_below_count == 1 { "line" } else { "lines" }
500 ),
501 insert_after,
502 )
503 }
504 _ => continue,
505 };
506
507 let byte_range = if insertion_point == 0 && position == "above" {
509 0..0
511 } else if position == "above" && insertion_point > 0 {
512 ctx.lines[insertion_point].byte_offset..ctx.lines[insertion_point].byte_offset
514 } else if position == "below" && insertion_point - 1 < ctx.lines.len() {
515 let line_idx = insertion_point - 1;
517 let line_end_offset = if line_idx + 1 < ctx.lines.len() {
518 ctx.lines[line_idx + 1].byte_offset
519 } else {
520 ctx.content.len()
521 };
522 line_end_offset..line_end_offset
523 } else {
524 let content_len = ctx.content.len();
526 content_len..content_len
527 };
528
529 result.push(LintWarning {
530 rule_name: Some(self.name().to_string()),
531 message,
532 line: start_line,
533 column: start_col,
534 end_line,
535 end_column: end_col,
536 severity: Severity::Warning,
537 fix: Some(Fix {
538 range: byte_range,
539 replacement: line_ending.repeat(needed_blanks),
540 }),
541 });
542 }
543
544 Ok(result)
545 }
546
547 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
548 if ctx.content.is_empty() {
549 return Ok(ctx.content.to_string());
550 }
551
552 let fixed = self._fix_content(ctx);
554
555 Ok(fixed)
556 }
557
558 fn category(&self) -> RuleCategory {
560 RuleCategory::Heading
561 }
562
563 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
565 if ctx.content.is_empty() || !ctx.likely_has_headings() {
567 return true;
568 }
569 ctx.lines.iter().all(|line| line.heading.is_none())
571 }
572
573 fn as_any(&self) -> &dyn std::any::Any {
574 self
575 }
576
577 fn default_config_section(&self) -> Option<(String, toml::Value)> {
578 let default_config = MD022Config::default();
579 let json_value = serde_json::to_value(&default_config).ok()?;
580 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
581
582 if let toml::Value::Table(table) = toml_value {
583 if !table.is_empty() {
584 Some((MD022Config::RULE_NAME.to_string(), toml::Value::Table(table)))
585 } else {
586 None
587 }
588 } else {
589 None
590 }
591 }
592
593 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
594 where
595 Self: Sized,
596 {
597 let rule_config = crate::rule_config_serde::load_rule_config::<MD022Config>(config);
598 Box::new(Self::from_config_struct(rule_config))
599 }
600}
601
602#[cfg(test)]
603mod tests {
604 use super::*;
605 use crate::lint_context::LintContext;
606
607 #[test]
608 fn test_valid_headings() {
609 let rule = MD022BlanksAroundHeadings::default();
610 let content = "\n# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.\n";
611 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
612 let result = rule.check(&ctx).unwrap();
613 assert!(result.is_empty());
614 }
615
616 #[test]
617 fn test_missing_blank_above() {
618 let rule = MD022BlanksAroundHeadings::default();
619 let content = "# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.\n";
620 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
621 let result = rule.check(&ctx).unwrap();
622 assert_eq!(result.len(), 0); let fixed = rule.fix(&ctx).unwrap();
625
626 assert!(fixed.contains("# Heading 1"));
629 assert!(fixed.contains("Some content."));
630 assert!(fixed.contains("## Heading 2"));
631 assert!(fixed.contains("More content."));
632 }
633
634 #[test]
635 fn test_missing_blank_below() {
636 let rule = MD022BlanksAroundHeadings::default();
637 let content = "\n# Heading 1\nSome content.\n\n## Heading 2\n\nMore content.\n";
638 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
639 let result = rule.check(&ctx).unwrap();
640 assert_eq!(result.len(), 1);
641 assert_eq!(result[0].line, 2);
642
643 let fixed = rule.fix(&ctx).unwrap();
645 assert!(fixed.contains("# Heading 1\n\nSome content"));
646 }
647
648 #[test]
649 fn test_missing_blank_above_and_below() {
650 let rule = MD022BlanksAroundHeadings::default();
651 let content = "# Heading 1\nSome content.\n## Heading 2\nMore content.\n";
652 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
653 let result = rule.check(&ctx).unwrap();
654 assert_eq!(result.len(), 3); let fixed = rule.fix(&ctx).unwrap();
658 assert!(fixed.contains("# Heading 1\n\nSome content"));
659 assert!(fixed.contains("Some content.\n\n## Heading 2"));
660 assert!(fixed.contains("## Heading 2\n\nMore content"));
661 }
662
663 #[test]
664 fn test_fix_headings() {
665 let rule = MD022BlanksAroundHeadings::default();
666 let content = "# Heading 1\nSome content.\n## Heading 2\nMore content.";
667 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
668 let result = rule.fix(&ctx).unwrap();
669
670 let expected = "# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.";
671 assert_eq!(result, expected);
672 }
673
674 #[test]
675 fn test_consecutive_headings_pattern() {
676 let rule = MD022BlanksAroundHeadings::default();
677 let content = "# Heading 1\n## Heading 2\n### Heading 3";
678 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
679 let result = rule.fix(&ctx).unwrap();
680
681 let lines: Vec<&str> = result.lines().collect();
683 assert!(!lines.is_empty());
684
685 let h1_pos = lines.iter().position(|&l| l == "# Heading 1").unwrap();
687 let h2_pos = lines.iter().position(|&l| l == "## Heading 2").unwrap();
688 let h3_pos = lines.iter().position(|&l| l == "### Heading 3").unwrap();
689
690 assert!(
692 h2_pos > h1_pos + 1,
693 "Should have at least one blank line after first heading"
694 );
695 assert!(
696 h3_pos > h2_pos + 1,
697 "Should have at least one blank line after second heading"
698 );
699
700 assert!(lines[h1_pos + 1].trim().is_empty(), "Line after h1 should be blank");
702
703 assert!(lines[h2_pos + 1].trim().is_empty(), "Line after h2 should be blank");
705 }
706
707 #[test]
708 fn test_blanks_around_setext_headings() {
709 let rule = MD022BlanksAroundHeadings::default();
710 let content = "Heading 1\n=========\nSome content.\nHeading 2\n---------\nMore content.";
711 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
712 let result = rule.fix(&ctx).unwrap();
713
714 let lines: Vec<&str> = result.lines().collect();
716
717 assert!(result.contains("Heading 1"));
719 assert!(result.contains("========="));
720 assert!(result.contains("Some content."));
721 assert!(result.contains("Heading 2"));
722 assert!(result.contains("---------"));
723 assert!(result.contains("More content."));
724
725 let heading1_marker_idx = lines.iter().position(|&l| l == "=========").unwrap();
727 let some_content_idx = lines.iter().position(|&l| l == "Some content.").unwrap();
728 assert!(
729 some_content_idx > heading1_marker_idx + 1,
730 "Should have a blank line after the first heading"
731 );
732
733 let heading2_marker_idx = lines.iter().position(|&l| l == "---------").unwrap();
734 let more_content_idx = lines.iter().position(|&l| l == "More content.").unwrap();
735 assert!(
736 more_content_idx > heading2_marker_idx + 1,
737 "Should have a blank line after the second heading"
738 );
739
740 let fixed_ctx = LintContext::new(&result, crate::config::MarkdownFlavor::Standard, None);
742 let fixed_warnings = rule.check(&fixed_ctx).unwrap();
743 assert!(fixed_warnings.is_empty(), "Fixed content should have no warnings");
744 }
745
746 #[test]
747 fn test_fix_specific_blank_line_cases() {
748 let rule = MD022BlanksAroundHeadings::default();
749
750 let content1 = "# Heading 1\n## Heading 2\n### Heading 3";
752 let ctx1 = LintContext::new(content1, crate::config::MarkdownFlavor::Standard, None);
753 let result1 = rule.fix(&ctx1).unwrap();
754 assert!(result1.contains("# Heading 1"));
756 assert!(result1.contains("## Heading 2"));
757 assert!(result1.contains("### Heading 3"));
758 let lines: Vec<&str> = result1.lines().collect();
760 let h1_pos = lines.iter().position(|&l| l == "# Heading 1").unwrap();
761 let h2_pos = lines.iter().position(|&l| l == "## Heading 2").unwrap();
762 assert!(lines[h1_pos + 1].trim().is_empty(), "Should have a blank line after h1");
763 assert!(lines[h2_pos + 1].trim().is_empty(), "Should have a blank line after h2");
764
765 let content2 = "# Heading 1\nContent under heading 1\n## Heading 2";
767 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
768 let result2 = rule.fix(&ctx2).unwrap();
769 assert!(result2.contains("# Heading 1"));
771 assert!(result2.contains("Content under heading 1"));
772 assert!(result2.contains("## Heading 2"));
773 let lines2: Vec<&str> = result2.lines().collect();
775 let h1_pos2 = lines2.iter().position(|&l| l == "# Heading 1").unwrap();
776 let _content_pos = lines2.iter().position(|&l| l == "Content under heading 1").unwrap();
777 assert!(
778 lines2[h1_pos2 + 1].trim().is_empty(),
779 "Should have a blank line after heading 1"
780 );
781
782 let content3 = "# Heading 1\n\n\n## Heading 2\n\n\n### Heading 3\n\nContent";
784 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
785 let result3 = rule.fix(&ctx3).unwrap();
786 assert!(result3.contains("# Heading 1"));
788 assert!(result3.contains("## Heading 2"));
789 assert!(result3.contains("### Heading 3"));
790 assert!(result3.contains("Content"));
791 }
792
793 #[test]
794 fn test_fix_preserves_existing_blank_lines() {
795 let rule = MD022BlanksAroundHeadings::new();
796 let content = "# Title
797
798## Section 1
799
800Content here.
801
802## Section 2
803
804More content.
805### Missing Blank Above
806
807Even more content.
808
809## Section 3
810
811Final content.";
812
813 let expected = "# Title
814
815## Section 1
816
817Content here.
818
819## Section 2
820
821More content.
822
823### Missing Blank Above
824
825Even more content.
826
827## Section 3
828
829Final content.";
830
831 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
832 let result = rule._fix_content(&ctx);
833 assert_eq!(
834 result, expected,
835 "Fix should only add missing blank lines, never remove existing ones"
836 );
837 }
838
839 #[test]
840 fn test_fix_preserves_trailing_newline() {
841 let rule = MD022BlanksAroundHeadings::new();
842
843 let content_with_newline = "# Title\nContent here.\n";
845 let ctx = LintContext::new(content_with_newline, crate::config::MarkdownFlavor::Standard, None);
846 let result = rule.fix(&ctx).unwrap();
847 assert!(result.ends_with('\n'), "Should preserve trailing newline");
848
849 let content_without_newline = "# Title\nContent here.";
851 let ctx = LintContext::new(content_without_newline, crate::config::MarkdownFlavor::Standard, None);
852 let result = rule.fix(&ctx).unwrap();
853 assert!(
854 !result.ends_with('\n'),
855 "Should not add trailing newline if original didn't have one"
856 );
857 }
858
859 #[test]
860 fn test_fix_does_not_add_blank_lines_before_lists() {
861 let rule = MD022BlanksAroundHeadings::new();
862 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.";
863
864 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.";
865
866 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
867 let result = rule._fix_content(&ctx);
868 assert_eq!(result, expected, "Fix should not add blank lines before lists");
869 }
870
871 #[test]
872 fn test_per_level_configuration_no_blank_above_h1() {
873 use md022_config::HeadingLevelConfig;
874
875 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
877 lines_above: HeadingLevelConfig::per_level([0, 1, 1, 1, 1, 1]),
878 lines_below: HeadingLevelConfig::scalar(1),
879 allowed_at_start: false, });
881
882 let content = "Some text\n# Heading 1\n\nMore text";
884 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
885 let warnings = rule.check(&ctx).unwrap();
886 assert_eq!(warnings.len(), 0, "H1 without blank above should not trigger warning");
887
888 let content = "Some text\n## Heading 2\n\nMore text";
890 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
891 let warnings = rule.check(&ctx).unwrap();
892 assert_eq!(warnings.len(), 1, "H2 without blank above should trigger warning");
893 assert!(warnings[0].message.contains("above"));
894 }
895
896 #[test]
897 fn test_per_level_configuration_different_requirements() {
898 use md022_config::HeadingLevelConfig;
899
900 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
902 lines_above: HeadingLevelConfig::per_level([0, 1, 1, 2, 2, 2]),
903 lines_below: HeadingLevelConfig::scalar(1),
904 allowed_at_start: false,
905 });
906
907 let content = "Text\n# H1\n\nText\n\n## H2\n\nText\n\n### H3\n\nText\n\n\n#### H4\n\nText";
908 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
909 let warnings = rule.check(&ctx).unwrap();
910
911 assert_eq!(
913 warnings.len(),
914 0,
915 "All headings should satisfy level-specific requirements"
916 );
917 }
918
919 #[test]
920 fn test_per_level_configuration_violations() {
921 use md022_config::HeadingLevelConfig;
922
923 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
925 lines_above: HeadingLevelConfig::per_level([1, 1, 1, 2, 1, 1]),
926 lines_below: HeadingLevelConfig::scalar(1),
927 allowed_at_start: false,
928 });
929
930 let content = "Text\n\n#### Heading 4\n\nMore text";
932 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
933 let warnings = rule.check(&ctx).unwrap();
934
935 assert_eq!(warnings.len(), 1, "H4 with insufficient blanks should trigger warning");
936 assert!(warnings[0].message.contains("2 blank lines above"));
937 }
938
939 #[test]
940 fn test_per_level_fix_different_levels() {
941 use md022_config::HeadingLevelConfig;
942
943 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
945 lines_above: HeadingLevelConfig::per_level([0, 1, 2, 2, 2, 2]),
946 lines_below: HeadingLevelConfig::scalar(1),
947 allowed_at_start: false,
948 });
949
950 let content = "Text\n# H1\nContent\n## H2\nContent\n### H3\nContent";
951 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
952 let fixed = rule.fix(&ctx).unwrap();
953
954 assert!(fixed.contains("Text\n# H1\n\nContent"));
956 assert!(fixed.contains("Content\n\n## H2\n\nContent"));
957 assert!(fixed.contains("Content\n\n\n### H3\n\nContent"));
958 }
959
960 #[test]
961 fn test_per_level_below_configuration() {
962 use md022_config::HeadingLevelConfig;
963
964 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
966 lines_above: HeadingLevelConfig::scalar(1),
967 lines_below: HeadingLevelConfig::per_level([2, 1, 1, 1, 1, 1]), allowed_at_start: true,
969 });
970
971 let content = "# Heading 1\n\nSome text";
973 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
974 let warnings = rule.check(&ctx).unwrap();
975
976 assert_eq!(
977 warnings.len(),
978 1,
979 "H1 with insufficient blanks below should trigger warning"
980 );
981 assert!(warnings[0].message.contains("2 blank lines below"));
982 }
983
984 #[test]
985 fn test_scalar_configuration_still_works() {
986 use md022_config::HeadingLevelConfig;
987
988 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
990 lines_above: HeadingLevelConfig::scalar(2),
991 lines_below: HeadingLevelConfig::scalar(2),
992 allowed_at_start: false,
993 });
994
995 let content = "Text\n# H1\nContent\n## H2\nContent";
996 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
997 let warnings = rule.check(&ctx).unwrap();
998
999 assert!(!warnings.is_empty(), "Should have violations for insufficient blanks");
1001 }
1002
1003 #[test]
1004 fn test_unlimited_configuration_skips_requirements() {
1005 use md022_config::{HeadingBlankRequirement, HeadingLevelConfig};
1006
1007 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
1009 lines_above: HeadingLevelConfig::per_level_requirements([
1010 HeadingBlankRequirement::unlimited(),
1011 HeadingBlankRequirement::limited(1),
1012 HeadingBlankRequirement::limited(1),
1013 HeadingBlankRequirement::limited(1),
1014 HeadingBlankRequirement::limited(1),
1015 HeadingBlankRequirement::limited(1),
1016 ]),
1017 lines_below: HeadingLevelConfig::per_level_requirements([
1018 HeadingBlankRequirement::unlimited(),
1019 HeadingBlankRequirement::limited(1),
1020 HeadingBlankRequirement::limited(1),
1021 HeadingBlankRequirement::limited(1),
1022 HeadingBlankRequirement::limited(1),
1023 HeadingBlankRequirement::limited(1),
1024 ]),
1025 allowed_at_start: false,
1026 });
1027
1028 let content = "# H1\nParagraph\n## H2\nParagraph";
1029 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1030 let warnings = rule.check(&ctx).unwrap();
1031
1032 assert_eq!(warnings.len(), 2, "Only non-unlimited headings should warn");
1034 assert!(
1035 warnings.iter().all(|w| w.line >= 3),
1036 "Warnings should target later headings"
1037 );
1038
1039 let fixed = rule.fix(&ctx).unwrap();
1041 assert!(
1042 fixed.starts_with("# H1\nParagraph\n\n## H2"),
1043 "H1 should remain unchanged"
1044 );
1045 }
1046
1047 #[test]
1048 fn test_html_comment_transparency() {
1049 let rule = MD022BlanksAroundHeadings::default();
1053
1054 let content = "Some content\n\n<!-- markdownlint-disable-next-line MD001 -->\n#### Heading";
1057 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1058 let warnings = rule.check(&ctx).unwrap();
1059 assert!(
1060 warnings.is_empty(),
1061 "HTML comment is transparent - blank line above it counts for heading"
1062 );
1063
1064 let content_multiline = "Some content\n\n<!-- This is a\nmulti-line comment -->\n#### Heading";
1066 let ctx_multiline = LintContext::new(content_multiline, crate::config::MarkdownFlavor::Standard, None);
1067 let warnings_multiline = rule.check(&ctx_multiline).unwrap();
1068 assert!(
1069 warnings_multiline.is_empty(),
1070 "Multi-line HTML comment is also transparent"
1071 );
1072 }
1073
1074 #[test]
1075 fn test_frontmatter_transparency() {
1076 let rule = MD022BlanksAroundHeadings::default();
1079
1080 let content = "---\ntitle: Test\n---\n# First heading";
1082 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1083 let warnings = rule.check(&ctx).unwrap();
1084 assert!(
1085 warnings.is_empty(),
1086 "Frontmatter is transparent - heading can appear immediately after"
1087 );
1088
1089 let content_with_blank = "---\ntitle: Test\n---\n\n# First heading";
1091 let ctx_with_blank = LintContext::new(content_with_blank, crate::config::MarkdownFlavor::Standard, None);
1092 let warnings_with_blank = rule.check(&ctx_with_blank).unwrap();
1093 assert!(
1094 warnings_with_blank.is_empty(),
1095 "Heading with blank line after frontmatter should also be valid"
1096 );
1097
1098 let content_toml = "+++\ntitle = \"Test\"\n+++\n# First heading";
1100 let ctx_toml = LintContext::new(content_toml, crate::config::MarkdownFlavor::Standard, None);
1101 let warnings_toml = rule.check(&ctx_toml).unwrap();
1102 assert!(
1103 warnings_toml.is_empty(),
1104 "TOML frontmatter is also transparent for MD022"
1105 );
1106 }
1107}