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