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 && result[check_idx - 1].trim().is_empty() {
157 blank_lines_above += 1;
158 check_idx -= 1;
159 }
160
161 let requirement_above = self.config.lines_above.get_for_level(heading_level);
163 let needed_blanks_above = if is_first_heading && self.config.allowed_at_start {
164 0
165 } else {
166 requirement_above.required_count().unwrap_or(0)
167 };
168
169 while blank_lines_above < needed_blanks_above {
171 result.push(String::new());
172 blank_lines_above += 1;
173 }
174
175 result.push(line.to_string());
177
178 if matches!(
180 heading.style,
181 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
182 ) {
183 if i + 1 < ctx.lines.len() {
185 result.push(ctx.lines[i + 1].content(ctx.content).to_string());
186 skip_next = true; }
188
189 let mut blank_lines_below = 0;
191 let mut next_content_line_idx = None;
192 for j in (i + 2)..ctx.lines.len() {
193 if ctx.lines[j].is_blank {
194 blank_lines_below += 1;
195 } else {
196 next_content_line_idx = Some(j);
197 break;
198 }
199 }
200
201 let next_is_special = if let Some(idx) = next_content_line_idx {
203 let next_line = &ctx.lines[idx];
204 next_line.list_item.is_some() || {
205 let trimmed = next_line.content(ctx.content).trim();
206 (trimmed.starts_with("```") || trimmed.starts_with("~~~"))
207 && (trimmed.len() == 3
208 || (trimmed.len() > 3
209 && trimmed
210 .chars()
211 .nth(3)
212 .is_some_and(|c| c.is_whitespace() || c.is_alphabetic())))
213 }
214 } else {
215 false
216 };
217
218 let requirement_below = self.config.lines_below.get_for_level(heading_level);
220 let needed_blanks_below = if next_is_special {
221 0
222 } else {
223 requirement_below.required_count().unwrap_or(0)
224 };
225 if blank_lines_below < needed_blanks_below {
226 for _ in 0..(needed_blanks_below - blank_lines_below) {
227 result.push(String::new());
228 }
229 }
230 } else {
231 let mut blank_lines_below = 0;
233 let mut next_content_line_idx = None;
234 for j in (i + 1)..ctx.lines.len() {
235 if ctx.lines[j].is_blank {
236 blank_lines_below += 1;
237 } else {
238 next_content_line_idx = Some(j);
239 break;
240 }
241 }
242
243 let next_is_special = if let Some(idx) = next_content_line_idx {
245 let next_line = &ctx.lines[idx];
246 next_line.list_item.is_some() || {
247 let trimmed = next_line.content(ctx.content).trim();
248 (trimmed.starts_with("```") || trimmed.starts_with("~~~"))
249 && (trimmed.len() == 3
250 || (trimmed.len() > 3
251 && trimmed
252 .chars()
253 .nth(3)
254 .is_some_and(|c| c.is_whitespace() || c.is_alphabetic())))
255 }
256 } else {
257 false
258 };
259
260 let requirement_below = self.config.lines_below.get_for_level(heading_level);
262 let needed_blanks_below = if next_is_special {
263 0
264 } else {
265 requirement_below.required_count().unwrap_or(0)
266 };
267 if blank_lines_below < needed_blanks_below {
268 for _ in 0..(needed_blanks_below - blank_lines_below) {
269 result.push(String::new());
270 }
271 }
272 }
273 } else {
274 result.push(line.to_string());
276 }
277 }
278
279 let joined = result.join(line_ending);
280
281 if had_trailing_newline && !joined.ends_with('\n') {
284 format!("{joined}{line_ending}")
285 } else if !had_trailing_newline && joined.ends_with('\n') {
286 joined[..joined.len() - 1].to_string()
288 } else {
289 joined
290 }
291 }
292}
293
294impl Rule for MD022BlanksAroundHeadings {
295 fn name(&self) -> &'static str {
296 "MD022"
297 }
298
299 fn description(&self) -> &'static str {
300 "Headings should be surrounded by blank lines"
301 }
302
303 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
304 let mut result = Vec::new();
305
306 if ctx.lines.is_empty() {
308 return Ok(result);
309 }
310
311 let line_ending = "\n";
313
314 let heading_at_start_idx = {
315 let mut found_non_blank = false;
316 ctx.lines.iter().enumerate().find_map(|(i, line)| {
317 if line.heading.is_some() && !found_non_blank {
318 Some(i)
319 } else {
320 if !line.is_blank {
321 found_non_blank = true;
322 }
323 None
324 }
325 })
326 };
327
328 let mut heading_violations = Vec::new();
330 let mut processed_headings = std::collections::HashSet::new();
331
332 for (line_num, line_info) in ctx.lines.iter().enumerate() {
333 if processed_headings.contains(&line_num) || line_info.heading.is_none() {
335 continue;
336 }
337
338 let heading = line_info.heading.as_ref().unwrap();
339 let heading_level = heading.level as usize;
340
341 if matches!(
343 heading.style,
344 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
345 ) {
346 if line_num > 0 && ctx.lines[line_num - 1].heading.is_none() {
348 continue; }
350 }
351
352 processed_headings.insert(line_num);
353
354 let is_first_heading = Some(line_num) == heading_at_start_idx;
356
357 let required_above_count = self.config.lines_above.get_for_level(heading_level).required_count();
359 let required_below_count = self.config.lines_below.get_for_level(heading_level).required_count();
360
361 let should_check_above =
363 required_above_count.is_some() && line_num > 0 && (!is_first_heading || !self.config.allowed_at_start);
364 if should_check_above {
365 let mut blank_lines_above = 0;
366 for j in (0..line_num).rev() {
367 if ctx.lines[j].is_blank {
368 blank_lines_above += 1;
369 } else {
370 break;
371 }
372 }
373 let required = required_above_count.unwrap();
374 if blank_lines_above < required {
375 let needed_blanks = required - blank_lines_above;
376 heading_violations.push((line_num, "above", needed_blanks, heading_level));
377 }
378 }
379
380 let effective_last_line = if matches!(
382 heading.style,
383 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
384 ) {
385 line_num + 1 } else {
387 line_num
388 };
389
390 if effective_last_line < ctx.lines.len() - 1 {
392 let mut next_non_blank_idx = effective_last_line + 1;
394 while next_non_blank_idx < ctx.lines.len() && ctx.lines[next_non_blank_idx].is_blank {
395 next_non_blank_idx += 1;
396 }
397
398 let next_line_is_special = next_non_blank_idx < ctx.lines.len() && {
400 let next_line = &ctx.lines[next_non_blank_idx];
401 let next_trimmed = next_line.content(ctx.content).trim();
402
403 let is_code_fence = (next_trimmed.starts_with("```") || next_trimmed.starts_with("~~~"))
405 && (next_trimmed.len() == 3
406 || (next_trimmed.len() > 3
407 && next_trimmed
408 .chars()
409 .nth(3)
410 .is_some_and(|c| c.is_whitespace() || c.is_alphabetic())));
411
412 let is_list_item = next_line.list_item.is_some();
414
415 is_code_fence || is_list_item
416 };
417
418 if !next_line_is_special && let Some(required) = required_below_count {
420 let blank_lines_below = next_non_blank_idx - effective_last_line - 1;
422
423 if blank_lines_below < required {
424 let needed_blanks = required - blank_lines_below;
425 heading_violations.push((line_num, "below", needed_blanks, heading_level));
426 }
427 }
428 }
429 }
430
431 for (heading_line, position, needed_blanks, heading_level) in heading_violations {
433 let heading_display_line = heading_line + 1; let line_info = &ctx.lines[heading_line];
435
436 let (start_line, start_col, end_line, end_col) =
438 calculate_heading_range(heading_display_line, line_info.content(ctx.content));
439
440 let required_above_count = self
441 .config
442 .lines_above
443 .get_for_level(heading_level)
444 .required_count()
445 .expect("Violations only generated for limited 'above' requirements");
446 let required_below_count = self
447 .config
448 .lines_below
449 .get_for_level(heading_level)
450 .required_count()
451 .expect("Violations only generated for limited 'below' requirements");
452
453 let (message, insertion_point) = match position {
454 "above" => (
455 format!(
456 "Expected {} blank {} above heading",
457 required_above_count,
458 if required_above_count == 1 { "line" } else { "lines" }
459 ),
460 heading_line, ),
462 "below" => {
463 let insert_after = if line_info.heading.as_ref().is_some_and(|h| {
465 matches!(
466 h.style,
467 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
468 )
469 }) {
470 heading_line + 2
471 } else {
472 heading_line + 1
473 };
474
475 (
476 format!(
477 "Expected {} blank {} below heading",
478 required_below_count,
479 if required_below_count == 1 { "line" } else { "lines" }
480 ),
481 insert_after,
482 )
483 }
484 _ => continue,
485 };
486
487 let byte_range = if insertion_point == 0 && position == "above" {
489 0..0
491 } else if position == "above" && insertion_point > 0 {
492 ctx.lines[insertion_point].byte_offset..ctx.lines[insertion_point].byte_offset
494 } else if position == "below" && insertion_point - 1 < ctx.lines.len() {
495 let line_idx = insertion_point - 1;
497 let line_end_offset = if line_idx + 1 < ctx.lines.len() {
498 ctx.lines[line_idx + 1].byte_offset
499 } else {
500 ctx.content.len()
501 };
502 line_end_offset..line_end_offset
503 } else {
504 let content_len = ctx.content.len();
506 content_len..content_len
507 };
508
509 result.push(LintWarning {
510 rule_name: Some(self.name().to_string()),
511 message,
512 line: start_line,
513 column: start_col,
514 end_line,
515 end_column: end_col,
516 severity: Severity::Warning,
517 fix: Some(Fix {
518 range: byte_range,
519 replacement: line_ending.repeat(needed_blanks),
520 }),
521 });
522 }
523
524 Ok(result)
525 }
526
527 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
528 if ctx.content.is_empty() {
529 return Ok(ctx.content.to_string());
530 }
531
532 let fixed = self._fix_content(ctx);
534
535 Ok(fixed)
536 }
537
538 fn category(&self) -> RuleCategory {
540 RuleCategory::Heading
541 }
542
543 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
545 if ctx.content.is_empty() || !ctx.likely_has_headings() {
547 return true;
548 }
549 ctx.lines.iter().all(|line| line.heading.is_none())
551 }
552
553 fn as_any(&self) -> &dyn std::any::Any {
554 self
555 }
556
557 fn default_config_section(&self) -> Option<(String, toml::Value)> {
558 let default_config = MD022Config::default();
559 let json_value = serde_json::to_value(&default_config).ok()?;
560 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
561
562 if let toml::Value::Table(table) = toml_value {
563 if !table.is_empty() {
564 Some((MD022Config::RULE_NAME.to_string(), toml::Value::Table(table)))
565 } else {
566 None
567 }
568 } else {
569 None
570 }
571 }
572
573 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
574 where
575 Self: Sized,
576 {
577 let rule_config = crate::rule_config_serde::load_rule_config::<MD022Config>(config);
578 Box::new(Self::from_config_struct(rule_config))
579 }
580}
581
582#[cfg(test)]
583mod tests {
584 use super::*;
585 use crate::lint_context::LintContext;
586
587 #[test]
588 fn test_valid_headings() {
589 let rule = MD022BlanksAroundHeadings::default();
590 let content = "\n# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.\n";
591 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
592 let result = rule.check(&ctx).unwrap();
593 assert!(result.is_empty());
594 }
595
596 #[test]
597 fn test_missing_blank_above() {
598 let rule = MD022BlanksAroundHeadings::default();
599 let content = "# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.\n";
600 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
601 let result = rule.check(&ctx).unwrap();
602 assert_eq!(result.len(), 0); let fixed = rule.fix(&ctx).unwrap();
605
606 assert!(fixed.contains("# Heading 1"));
609 assert!(fixed.contains("Some content."));
610 assert!(fixed.contains("## Heading 2"));
611 assert!(fixed.contains("More content."));
612 }
613
614 #[test]
615 fn test_missing_blank_below() {
616 let rule = MD022BlanksAroundHeadings::default();
617 let content = "\n# Heading 1\nSome content.\n\n## Heading 2\n\nMore content.\n";
618 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
619 let result = rule.check(&ctx).unwrap();
620 assert_eq!(result.len(), 1);
621 assert_eq!(result[0].line, 2);
622
623 let fixed = rule.fix(&ctx).unwrap();
625 assert!(fixed.contains("# Heading 1\n\nSome content"));
626 }
627
628 #[test]
629 fn test_missing_blank_above_and_below() {
630 let rule = MD022BlanksAroundHeadings::default();
631 let content = "# Heading 1\nSome content.\n## Heading 2\nMore content.\n";
632 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
633 let result = rule.check(&ctx).unwrap();
634 assert_eq!(result.len(), 3); let fixed = rule.fix(&ctx).unwrap();
638 assert!(fixed.contains("# Heading 1\n\nSome content"));
639 assert!(fixed.contains("Some content.\n\n## Heading 2"));
640 assert!(fixed.contains("## Heading 2\n\nMore content"));
641 }
642
643 #[test]
644 fn test_fix_headings() {
645 let rule = MD022BlanksAroundHeadings::default();
646 let content = "# Heading 1\nSome content.\n## Heading 2\nMore content.";
647 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
648 let result = rule.fix(&ctx).unwrap();
649
650 let expected = "# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.";
651 assert_eq!(result, expected);
652 }
653
654 #[test]
655 fn test_consecutive_headings_pattern() {
656 let rule = MD022BlanksAroundHeadings::default();
657 let content = "# Heading 1\n## Heading 2\n### Heading 3";
658 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
659 let result = rule.fix(&ctx).unwrap();
660
661 let lines: Vec<&str> = result.lines().collect();
663 assert!(!lines.is_empty());
664
665 let h1_pos = lines.iter().position(|&l| l == "# Heading 1").unwrap();
667 let h2_pos = lines.iter().position(|&l| l == "## Heading 2").unwrap();
668 let h3_pos = lines.iter().position(|&l| l == "### Heading 3").unwrap();
669
670 assert!(
672 h2_pos > h1_pos + 1,
673 "Should have at least one blank line after first heading"
674 );
675 assert!(
676 h3_pos > h2_pos + 1,
677 "Should have at least one blank line after second heading"
678 );
679
680 assert!(lines[h1_pos + 1].trim().is_empty(), "Line after h1 should be blank");
682
683 assert!(lines[h2_pos + 1].trim().is_empty(), "Line after h2 should be blank");
685 }
686
687 #[test]
688 fn test_blanks_around_setext_headings() {
689 let rule = MD022BlanksAroundHeadings::default();
690 let content = "Heading 1\n=========\nSome content.\nHeading 2\n---------\nMore content.";
691 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
692 let result = rule.fix(&ctx).unwrap();
693
694 let lines: Vec<&str> = result.lines().collect();
696
697 assert!(result.contains("Heading 1"));
699 assert!(result.contains("========="));
700 assert!(result.contains("Some content."));
701 assert!(result.contains("Heading 2"));
702 assert!(result.contains("---------"));
703 assert!(result.contains("More content."));
704
705 let heading1_marker_idx = lines.iter().position(|&l| l == "=========").unwrap();
707 let some_content_idx = lines.iter().position(|&l| l == "Some content.").unwrap();
708 assert!(
709 some_content_idx > heading1_marker_idx + 1,
710 "Should have a blank line after the first heading"
711 );
712
713 let heading2_marker_idx = lines.iter().position(|&l| l == "---------").unwrap();
714 let more_content_idx = lines.iter().position(|&l| l == "More content.").unwrap();
715 assert!(
716 more_content_idx > heading2_marker_idx + 1,
717 "Should have a blank line after the second heading"
718 );
719
720 let fixed_ctx = LintContext::new(&result, crate::config::MarkdownFlavor::Standard);
722 let fixed_warnings = rule.check(&fixed_ctx).unwrap();
723 assert!(fixed_warnings.is_empty(), "Fixed content should have no warnings");
724 }
725
726 #[test]
727 fn test_fix_specific_blank_line_cases() {
728 let rule = MD022BlanksAroundHeadings::default();
729
730 let content1 = "# Heading 1\n## Heading 2\n### Heading 3";
732 let ctx1 = LintContext::new(content1, crate::config::MarkdownFlavor::Standard);
733 let result1 = rule.fix(&ctx1).unwrap();
734 assert!(result1.contains("# Heading 1"));
736 assert!(result1.contains("## Heading 2"));
737 assert!(result1.contains("### Heading 3"));
738 let lines: Vec<&str> = result1.lines().collect();
740 let h1_pos = lines.iter().position(|&l| l == "# Heading 1").unwrap();
741 let h2_pos = lines.iter().position(|&l| l == "## Heading 2").unwrap();
742 assert!(lines[h1_pos + 1].trim().is_empty(), "Should have a blank line after h1");
743 assert!(lines[h2_pos + 1].trim().is_empty(), "Should have a blank line after h2");
744
745 let content2 = "# Heading 1\nContent under heading 1\n## Heading 2";
747 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard);
748 let result2 = rule.fix(&ctx2).unwrap();
749 assert!(result2.contains("# Heading 1"));
751 assert!(result2.contains("Content under heading 1"));
752 assert!(result2.contains("## Heading 2"));
753 let lines2: Vec<&str> = result2.lines().collect();
755 let h1_pos2 = lines2.iter().position(|&l| l == "# Heading 1").unwrap();
756 let _content_pos = lines2.iter().position(|&l| l == "Content under heading 1").unwrap();
757 assert!(
758 lines2[h1_pos2 + 1].trim().is_empty(),
759 "Should have a blank line after heading 1"
760 );
761
762 let content3 = "# Heading 1\n\n\n## Heading 2\n\n\n### Heading 3\n\nContent";
764 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard);
765 let result3 = rule.fix(&ctx3).unwrap();
766 assert!(result3.contains("# Heading 1"));
768 assert!(result3.contains("## Heading 2"));
769 assert!(result3.contains("### Heading 3"));
770 assert!(result3.contains("Content"));
771 }
772
773 #[test]
774 fn test_fix_preserves_existing_blank_lines() {
775 let rule = MD022BlanksAroundHeadings::new();
776 let content = "# Title
777
778## Section 1
779
780Content here.
781
782## Section 2
783
784More content.
785### Missing Blank Above
786
787Even more content.
788
789## Section 3
790
791Final content.";
792
793 let expected = "# Title
794
795## Section 1
796
797Content here.
798
799## Section 2
800
801More content.
802
803### Missing Blank Above
804
805Even more content.
806
807## Section 3
808
809Final content.";
810
811 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
812 let result = rule._fix_content(&ctx);
813 assert_eq!(
814 result, expected,
815 "Fix should only add missing blank lines, never remove existing ones"
816 );
817 }
818
819 #[test]
820 fn test_fix_preserves_trailing_newline() {
821 let rule = MD022BlanksAroundHeadings::new();
822
823 let content_with_newline = "# Title\nContent here.\n";
825 let ctx = LintContext::new(content_with_newline, crate::config::MarkdownFlavor::Standard);
826 let result = rule.fix(&ctx).unwrap();
827 assert!(result.ends_with('\n'), "Should preserve trailing newline");
828
829 let content_without_newline = "# Title\nContent here.";
831 let ctx = LintContext::new(content_without_newline, crate::config::MarkdownFlavor::Standard);
832 let result = rule.fix(&ctx).unwrap();
833 assert!(
834 !result.ends_with('\n'),
835 "Should not add trailing newline if original didn't have one"
836 );
837 }
838
839 #[test]
840 fn test_fix_does_not_add_blank_lines_before_lists() {
841 let rule = MD022BlanksAroundHeadings::new();
842 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.";
843
844 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.";
845
846 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
847 let result = rule._fix_content(&ctx);
848 assert_eq!(result, expected, "Fix should not add blank lines before lists");
849 }
850
851 #[test]
852 fn test_per_level_configuration_no_blank_above_h1() {
853 use md022_config::HeadingLevelConfig;
854
855 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
857 lines_above: HeadingLevelConfig::per_level([0, 1, 1, 1, 1, 1]),
858 lines_below: HeadingLevelConfig::scalar(1),
859 allowed_at_start: false, });
861
862 let content = "Some text\n# Heading 1\n\nMore text";
864 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
865 let warnings = rule.check(&ctx).unwrap();
866 assert_eq!(warnings.len(), 0, "H1 without blank above should not trigger warning");
867
868 let content = "Some text\n## Heading 2\n\nMore text";
870 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
871 let warnings = rule.check(&ctx).unwrap();
872 assert_eq!(warnings.len(), 1, "H2 without blank above should trigger warning");
873 assert!(warnings[0].message.contains("above"));
874 }
875
876 #[test]
877 fn test_per_level_configuration_different_requirements() {
878 use md022_config::HeadingLevelConfig;
879
880 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
882 lines_above: HeadingLevelConfig::per_level([0, 1, 1, 2, 2, 2]),
883 lines_below: HeadingLevelConfig::scalar(1),
884 allowed_at_start: false,
885 });
886
887 let content = "Text\n# H1\n\nText\n\n## H2\n\nText\n\n### H3\n\nText\n\n\n#### H4\n\nText";
888 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
889 let warnings = rule.check(&ctx).unwrap();
890
891 assert_eq!(
893 warnings.len(),
894 0,
895 "All headings should satisfy level-specific requirements"
896 );
897 }
898
899 #[test]
900 fn test_per_level_configuration_violations() {
901 use md022_config::HeadingLevelConfig;
902
903 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
905 lines_above: HeadingLevelConfig::per_level([1, 1, 1, 2, 1, 1]),
906 lines_below: HeadingLevelConfig::scalar(1),
907 allowed_at_start: false,
908 });
909
910 let content = "Text\n\n#### Heading 4\n\nMore text";
912 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
913 let warnings = rule.check(&ctx).unwrap();
914
915 assert_eq!(warnings.len(), 1, "H4 with insufficient blanks should trigger warning");
916 assert!(warnings[0].message.contains("2 blank lines above"));
917 }
918
919 #[test]
920 fn test_per_level_fix_different_levels() {
921 use md022_config::HeadingLevelConfig;
922
923 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
925 lines_above: HeadingLevelConfig::per_level([0, 1, 2, 2, 2, 2]),
926 lines_below: HeadingLevelConfig::scalar(1),
927 allowed_at_start: false,
928 });
929
930 let content = "Text\n# H1\nContent\n## H2\nContent\n### H3\nContent";
931 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
932 let fixed = rule.fix(&ctx).unwrap();
933
934 assert!(fixed.contains("Text\n# H1\n\nContent"));
936 assert!(fixed.contains("Content\n\n## H2\n\nContent"));
937 assert!(fixed.contains("Content\n\n\n### H3\n\nContent"));
938 }
939
940 #[test]
941 fn test_per_level_below_configuration() {
942 use md022_config::HeadingLevelConfig;
943
944 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
946 lines_above: HeadingLevelConfig::scalar(1),
947 lines_below: HeadingLevelConfig::per_level([2, 1, 1, 1, 1, 1]), allowed_at_start: true,
949 });
950
951 let content = "# Heading 1\n\nSome text";
953 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
954 let warnings = rule.check(&ctx).unwrap();
955
956 assert_eq!(
957 warnings.len(),
958 1,
959 "H1 with insufficient blanks below should trigger warning"
960 );
961 assert!(warnings[0].message.contains("2 blank lines below"));
962 }
963
964 #[test]
965 fn test_scalar_configuration_still_works() {
966 use md022_config::HeadingLevelConfig;
967
968 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
970 lines_above: HeadingLevelConfig::scalar(2),
971 lines_below: HeadingLevelConfig::scalar(2),
972 allowed_at_start: false,
973 });
974
975 let content = "Text\n# H1\nContent\n## H2\nContent";
976 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
977 let warnings = rule.check(&ctx).unwrap();
978
979 assert!(!warnings.is_empty(), "Should have violations for insufficient blanks");
981 }
982
983 #[test]
984 fn test_unlimited_configuration_skips_requirements() {
985 use md022_config::{HeadingBlankRequirement, HeadingLevelConfig};
986
987 let rule = MD022BlanksAroundHeadings::from_config_struct(MD022Config {
989 lines_above: HeadingLevelConfig::per_level_requirements([
990 HeadingBlankRequirement::unlimited(),
991 HeadingBlankRequirement::limited(1),
992 HeadingBlankRequirement::limited(1),
993 HeadingBlankRequirement::limited(1),
994 HeadingBlankRequirement::limited(1),
995 HeadingBlankRequirement::limited(1),
996 ]),
997 lines_below: HeadingLevelConfig::per_level_requirements([
998 HeadingBlankRequirement::unlimited(),
999 HeadingBlankRequirement::limited(1),
1000 HeadingBlankRequirement::limited(1),
1001 HeadingBlankRequirement::limited(1),
1002 HeadingBlankRequirement::limited(1),
1003 HeadingBlankRequirement::limited(1),
1004 ]),
1005 allowed_at_start: false,
1006 });
1007
1008 let content = "# H1\nParagraph\n## H2\nParagraph";
1009 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1010 let warnings = rule.check(&ctx).unwrap();
1011
1012 assert_eq!(warnings.len(), 2, "Only non-unlimited headings should warn");
1014 assert!(
1015 warnings.iter().all(|w| w.line >= 3),
1016 "Warnings should target later headings"
1017 );
1018
1019 let fixed = rule.fix(&ctx).unwrap();
1021 assert!(
1022 fixed.starts_with("# H1\nParagraph\n\n## H2"),
1023 "H1 should remain unchanged"
1024 );
1025 }
1026}