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 Self {
100 config: MD022Config {
101 lines_above,
102 lines_below,
103 allowed_at_start: true,
104 },
105 }
106 }
107
108 pub fn from_config_struct(config: MD022Config) -> Self {
109 Self { config }
110 }
111
112 fn _fix_content(&self, ctx: &crate::lint_context::LintContext) -> String {
114 let line_ending = crate::utils::detect_line_ending(ctx.content);
115 let had_trailing_newline = ctx.content.ends_with('\n') || ctx.content.ends_with("\r\n");
116 let mut result = Vec::new();
117 let mut in_front_matter = false;
118 let mut front_matter_delimiter_count = 0;
119 let mut skip_next = false;
120
121 for (i, line_info) in ctx.lines.iter().enumerate() {
122 if skip_next {
123 skip_next = false;
124 continue;
125 }
126 let line = &line_info.content;
127
128 if line.trim() == "---" {
130 if i == 0 || (i > 0 && ctx.lines[..i].iter().all(|l| l.is_blank)) {
131 if front_matter_delimiter_count == 0 {
132 in_front_matter = true;
133 front_matter_delimiter_count = 1;
134 }
135 } else if in_front_matter && front_matter_delimiter_count == 1 {
136 in_front_matter = false;
137 front_matter_delimiter_count = 2;
138 }
139 result.push(line.to_string());
140 continue;
141 }
142
143 if in_front_matter || line_info.in_code_block {
145 result.push(line.to_string());
146 continue;
147 }
148
149 if let Some(heading) = &line_info.heading {
151 let is_first_heading = (0..i).all(|j| {
153 ctx.lines[j].is_blank
154 || (j == 0 && ctx.lines[j].content.trim() == "---")
155 || (in_front_matter && ctx.lines[j].content.trim() == "---")
156 });
157
158 let mut blank_lines_above = 0;
160 let mut check_idx = result.len();
161 while check_idx > 0 && result[check_idx - 1].trim().is_empty() {
162 blank_lines_above += 1;
163 check_idx -= 1;
164 }
165
166 let needed_blanks_above = if is_first_heading && self.config.allowed_at_start {
168 0
169 } else {
170 self.config.lines_above
171 };
172
173 while blank_lines_above < needed_blanks_above {
175 result.push(String::new());
176 blank_lines_above += 1;
177 }
178
179 result.push(line.to_string());
181
182 if matches!(
184 heading.style,
185 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
186 ) {
187 if i + 1 < ctx.lines.len() {
189 result.push(ctx.lines[i + 1].content.clone());
190 skip_next = true; }
192
193 let mut blank_lines_below = 0;
195 let mut next_content_line_idx = None;
196 for j in (i + 2)..ctx.lines.len() {
197 if ctx.lines[j].is_blank {
198 blank_lines_below += 1;
199 } else {
200 next_content_line_idx = Some(j);
201 break;
202 }
203 }
204
205 let next_is_special = if let Some(idx) = next_content_line_idx {
207 let next_line = &ctx.lines[idx];
208 next_line.list_item.is_some() || {
209 let trimmed = next_line.content.trim();
210 (trimmed.starts_with("```") || trimmed.starts_with("~~~"))
211 && (trimmed.len() == 3
212 || (trimmed.len() > 3
213 && trimmed
214 .chars()
215 .nth(3)
216 .is_some_and(|c| c.is_whitespace() || c.is_alphabetic())))
217 }
218 } else {
219 false
220 };
221
222 let needed_blanks_below = if next_is_special { 0 } else { self.config.lines_below };
224 if blank_lines_below < needed_blanks_below {
225 for _ in 0..(needed_blanks_below - blank_lines_below) {
226 result.push(String::new());
227 }
228 }
229 } else {
230 let mut blank_lines_below = 0;
232 let mut next_content_line_idx = None;
233 for j in (i + 1)..ctx.lines.len() {
234 if ctx.lines[j].is_blank {
235 blank_lines_below += 1;
236 } else {
237 next_content_line_idx = Some(j);
238 break;
239 }
240 }
241
242 let next_is_special = if let Some(idx) = next_content_line_idx {
244 let next_line = &ctx.lines[idx];
245 next_line.list_item.is_some() || {
246 let trimmed = next_line.content.trim();
247 (trimmed.starts_with("```") || trimmed.starts_with("~~~"))
248 && (trimmed.len() == 3
249 || (trimmed.len() > 3
250 && trimmed
251 .chars()
252 .nth(3)
253 .is_some_and(|c| c.is_whitespace() || c.is_alphabetic())))
254 }
255 } else {
256 false
257 };
258
259 let needed_blanks_below = if next_is_special { 0 } else { self.config.lines_below };
261 if blank_lines_below < needed_blanks_below {
262 for _ in 0..(needed_blanks_below - blank_lines_below) {
263 result.push(String::new());
264 }
265 }
266 }
267 } else {
268 result.push(line.to_string());
270 }
271 }
272
273 let joined = result.join(line_ending);
274
275 if had_trailing_newline && !joined.ends_with('\n') && !joined.ends_with("\r\n") {
277 format!("{joined}{line_ending}")
278 } else if !had_trailing_newline && (joined.ends_with('\n') || joined.ends_with("\r\n")) {
279 if joined.ends_with("\r\n") {
281 joined[..joined.len() - 2].to_string()
282 } else {
283 joined[..joined.len() - 1].to_string()
284 }
285 } else {
286 joined
287 }
288 }
289}
290
291impl Rule for MD022BlanksAroundHeadings {
292 fn name(&self) -> &'static str {
293 "MD022"
294 }
295
296 fn description(&self) -> &'static str {
297 "Headings should be surrounded by blank lines"
298 }
299
300 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
301 let mut result = Vec::new();
302
303 if ctx.lines.is_empty() {
305 return Ok(result);
306 }
307
308 let line_ending = crate::utils::detect_line_ending(ctx.content);
309
310 let mut heading_violations = Vec::new();
312 let mut processed_headings = std::collections::HashSet::new();
313
314 for (line_num, line_info) in ctx.lines.iter().enumerate() {
315 if processed_headings.contains(&line_num) || line_info.heading.is_none() {
317 continue;
318 }
319
320 let heading = line_info.heading.as_ref().unwrap();
321
322 if matches!(
324 heading.style,
325 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
326 ) {
327 if line_num > 0 && ctx.lines[line_num - 1].heading.is_none() {
329 continue; }
331 }
332
333 processed_headings.insert(line_num);
334
335 let is_first_heading = (0..line_num).all(|j| {
337 ctx.lines[j].is_blank ||
338 (j == 0 && ctx.lines[j].content.trim() == "---") ||
340 (j > 0 && ctx.lines[0].content.trim() == "---" && ctx.lines[j].content.trim() == "---")
341 });
342
343 let blank_lines_above = if line_num > 0 && (!is_first_heading || !self.config.allowed_at_start) {
345 let mut count = 0;
346 for j in (0..line_num).rev() {
347 if ctx.lines[j].is_blank {
348 count += 1;
349 } else {
350 break;
351 }
352 }
353 count
354 } else {
355 self.config.lines_above };
357
358 if line_num > 0
360 && blank_lines_above < self.config.lines_above
361 && (!is_first_heading || !self.config.allowed_at_start)
362 {
363 let needed_blanks = self.config.lines_above - blank_lines_above;
364 heading_violations.push((line_num, "above", needed_blanks));
365 }
366
367 let effective_last_line = if matches!(
369 heading.style,
370 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
371 ) {
372 line_num + 1 } else {
374 line_num
375 };
376
377 if effective_last_line < ctx.lines.len() - 1 {
379 let mut next_non_blank_idx = effective_last_line + 1;
381 while next_non_blank_idx < ctx.lines.len() && ctx.lines[next_non_blank_idx].is_blank {
382 next_non_blank_idx += 1;
383 }
384
385 let next_line_is_special = next_non_blank_idx < ctx.lines.len() && {
387 let next_line = &ctx.lines[next_non_blank_idx];
388 let next_trimmed = next_line.content.trim();
389
390 let is_code_fence = (next_trimmed.starts_with("```") || next_trimmed.starts_with("~~~"))
392 && (next_trimmed.len() == 3
393 || (next_trimmed.len() > 3
394 && next_trimmed
395 .chars()
396 .nth(3)
397 .is_some_and(|c| c.is_whitespace() || c.is_alphabetic())));
398
399 let is_list_item = next_line.list_item.is_some();
401
402 is_code_fence || is_list_item
403 };
404
405 if !next_line_is_special {
407 let blank_lines_below = next_non_blank_idx - effective_last_line - 1;
409
410 if blank_lines_below < self.config.lines_below {
411 let needed_blanks = self.config.lines_below - blank_lines_below;
412 heading_violations.push((line_num, "below", needed_blanks));
413 }
414 }
415 }
416 }
417
418 for (heading_line, position, needed_blanks) in heading_violations {
420 let heading_display_line = heading_line + 1; let line_info = &ctx.lines[heading_line];
422
423 let (start_line, start_col, end_line, end_col) =
425 calculate_heading_range(heading_display_line, &line_info.content);
426
427 let (message, insertion_point) = match position {
428 "above" => (
429 format!(
430 "Expected {} blank {} above heading",
431 self.config.lines_above,
432 if self.config.lines_above == 1 { "line" } else { "lines" }
433 ),
434 heading_line, ),
436 "below" => {
437 let insert_after = if line_info.heading.as_ref().is_some_and(|h| {
439 matches!(
440 h.style,
441 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
442 )
443 }) {
444 heading_line + 2
445 } else {
446 heading_line + 1
447 };
448
449 (
450 format!(
451 "Expected {} blank {} below heading",
452 self.config.lines_below,
453 if self.config.lines_below == 1 { "line" } else { "lines" }
454 ),
455 insert_after,
456 )
457 }
458 _ => continue,
459 };
460
461 let byte_range = if insertion_point == 0 && position == "above" {
463 0..0
465 } else if position == "above" && insertion_point > 0 {
466 ctx.lines[insertion_point].byte_offset..ctx.lines[insertion_point].byte_offset
468 } else if position == "below" && insertion_point - 1 < ctx.lines.len() {
469 let line_idx = insertion_point - 1;
471 let line_end_offset = if line_idx + 1 < ctx.lines.len() {
472 ctx.lines[line_idx + 1].byte_offset
473 } else {
474 ctx.content.len()
475 };
476 line_end_offset..line_end_offset
477 } else {
478 let content_len = ctx.content.len();
480 content_len..content_len
481 };
482
483 result.push(LintWarning {
484 rule_name: Some(self.name()),
485 message,
486 line: start_line,
487 column: start_col,
488 end_line,
489 end_column: end_col,
490 severity: Severity::Warning,
491 fix: Some(Fix {
492 range: byte_range,
493 replacement: line_ending.repeat(needed_blanks),
494 }),
495 });
496 }
497
498 Ok(result)
499 }
500
501 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
502 if ctx.content.is_empty() {
503 return Ok(ctx.content.to_string());
504 }
505
506 let fixed = self._fix_content(ctx);
508
509 Ok(fixed)
510 }
511
512 fn category(&self) -> RuleCategory {
514 RuleCategory::Heading
515 }
516
517 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
519 if ctx.content.is_empty() || !ctx.likely_has_headings() {
521 return true;
522 }
523 ctx.lines.iter().all(|line| line.heading.is_none())
525 }
526
527 fn as_any(&self) -> &dyn std::any::Any {
528 self
529 }
530
531 fn default_config_section(&self) -> Option<(String, toml::Value)> {
532 let default_config = MD022Config::default();
533 let json_value = serde_json::to_value(&default_config).ok()?;
534 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
535
536 if let toml::Value::Table(table) = toml_value {
537 if !table.is_empty() {
538 Some((MD022Config::RULE_NAME.to_string(), toml::Value::Table(table)))
539 } else {
540 None
541 }
542 } else {
543 None
544 }
545 }
546
547 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
548 where
549 Self: Sized,
550 {
551 let rule_config = crate::rule_config_serde::load_rule_config::<MD022Config>(config);
552 Box::new(Self::from_config_struct(rule_config))
553 }
554}
555
556#[cfg(test)]
557mod tests {
558 use super::*;
559 use crate::lint_context::LintContext;
560
561 #[test]
562 fn test_valid_headings() {
563 let rule = MD022BlanksAroundHeadings::default();
564 let content = "\n# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.\n";
565 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
566 let result = rule.check(&ctx).unwrap();
567 assert!(result.is_empty());
568 }
569
570 #[test]
571 fn test_missing_blank_above() {
572 let rule = MD022BlanksAroundHeadings::default();
573 let content = "# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.\n";
574 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
575 let result = rule.check(&ctx).unwrap();
576 assert_eq!(result.len(), 0); let fixed = rule.fix(&ctx).unwrap();
579
580 assert!(fixed.contains("# Heading 1"));
583 assert!(fixed.contains("Some content."));
584 assert!(fixed.contains("## Heading 2"));
585 assert!(fixed.contains("More content."));
586 }
587
588 #[test]
589 fn test_missing_blank_below() {
590 let rule = MD022BlanksAroundHeadings::default();
591 let content = "\n# Heading 1\nSome content.\n\n## Heading 2\n\nMore content.\n";
592 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
593 let result = rule.check(&ctx).unwrap();
594 assert_eq!(result.len(), 1);
595 assert_eq!(result[0].line, 2);
596
597 let fixed = rule.fix(&ctx).unwrap();
599 assert!(fixed.contains("# Heading 1\n\nSome content"));
600 }
601
602 #[test]
603 fn test_missing_blank_above_and_below() {
604 let rule = MD022BlanksAroundHeadings::default();
605 let content = "# Heading 1\nSome content.\n## Heading 2\nMore content.\n";
606 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
607 let result = rule.check(&ctx).unwrap();
608 assert_eq!(result.len(), 3); let fixed = rule.fix(&ctx).unwrap();
612 assert!(fixed.contains("# Heading 1\n\nSome content"));
613 assert!(fixed.contains("Some content.\n\n## Heading 2"));
614 assert!(fixed.contains("## Heading 2\n\nMore content"));
615 }
616
617 #[test]
618 fn test_fix_headings() {
619 let rule = MD022BlanksAroundHeadings::default();
620 let content = "# Heading 1\nSome content.\n## Heading 2\nMore content.";
621 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
622 let result = rule.fix(&ctx).unwrap();
623
624 let expected = "# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.";
625 assert_eq!(result, expected);
626 }
627
628 #[test]
629 fn test_consecutive_headings_pattern() {
630 let rule = MD022BlanksAroundHeadings::default();
631 let content = "# Heading 1\n## Heading 2\n### Heading 3";
632 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
633 let result = rule.fix(&ctx).unwrap();
634
635 let lines: Vec<&str> = result.lines().collect();
637 assert!(!lines.is_empty());
638
639 let h1_pos = lines.iter().position(|&l| l == "# Heading 1").unwrap();
641 let h2_pos = lines.iter().position(|&l| l == "## Heading 2").unwrap();
642 let h3_pos = lines.iter().position(|&l| l == "### Heading 3").unwrap();
643
644 assert!(
646 h2_pos > h1_pos + 1,
647 "Should have at least one blank line after first heading"
648 );
649 assert!(
650 h3_pos > h2_pos + 1,
651 "Should have at least one blank line after second heading"
652 );
653
654 assert!(lines[h1_pos + 1].trim().is_empty(), "Line after h1 should be blank");
656
657 assert!(lines[h2_pos + 1].trim().is_empty(), "Line after h2 should be blank");
659 }
660
661 #[test]
662 fn test_blanks_around_setext_headings() {
663 let rule = MD022BlanksAroundHeadings::default();
664 let content = "Heading 1\n=========\nSome content.\nHeading 2\n---------\nMore content.";
665 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
666 let result = rule.fix(&ctx).unwrap();
667
668 let lines: Vec<&str> = result.lines().collect();
670
671 assert!(result.contains("Heading 1"));
673 assert!(result.contains("========="));
674 assert!(result.contains("Some content."));
675 assert!(result.contains("Heading 2"));
676 assert!(result.contains("---------"));
677 assert!(result.contains("More content."));
678
679 let heading1_marker_idx = lines.iter().position(|&l| l == "=========").unwrap();
681 let some_content_idx = lines.iter().position(|&l| l == "Some content.").unwrap();
682 assert!(
683 some_content_idx > heading1_marker_idx + 1,
684 "Should have a blank line after the first heading"
685 );
686
687 let heading2_marker_idx = lines.iter().position(|&l| l == "---------").unwrap();
688 let more_content_idx = lines.iter().position(|&l| l == "More content.").unwrap();
689 assert!(
690 more_content_idx > heading2_marker_idx + 1,
691 "Should have a blank line after the second heading"
692 );
693
694 let fixed_ctx = LintContext::new(&result, crate::config::MarkdownFlavor::Standard);
696 let fixed_warnings = rule.check(&fixed_ctx).unwrap();
697 assert!(fixed_warnings.is_empty(), "Fixed content should have no warnings");
698 }
699
700 #[test]
701 fn test_fix_specific_blank_line_cases() {
702 let rule = MD022BlanksAroundHeadings::default();
703
704 let content1 = "# Heading 1\n## Heading 2\n### Heading 3";
706 let ctx1 = LintContext::new(content1, crate::config::MarkdownFlavor::Standard);
707 let result1 = rule.fix(&ctx1).unwrap();
708 assert!(result1.contains("# Heading 1"));
710 assert!(result1.contains("## Heading 2"));
711 assert!(result1.contains("### Heading 3"));
712 let lines: Vec<&str> = result1.lines().collect();
714 let h1_pos = lines.iter().position(|&l| l == "# Heading 1").unwrap();
715 let h2_pos = lines.iter().position(|&l| l == "## Heading 2").unwrap();
716 assert!(lines[h1_pos + 1].trim().is_empty(), "Should have a blank line after h1");
717 assert!(lines[h2_pos + 1].trim().is_empty(), "Should have a blank line after h2");
718
719 let content2 = "# Heading 1\nContent under heading 1\n## Heading 2";
721 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard);
722 let result2 = rule.fix(&ctx2).unwrap();
723 assert!(result2.contains("# Heading 1"));
725 assert!(result2.contains("Content under heading 1"));
726 assert!(result2.contains("## Heading 2"));
727 let lines2: Vec<&str> = result2.lines().collect();
729 let h1_pos2 = lines2.iter().position(|&l| l == "# Heading 1").unwrap();
730 let _content_pos = lines2.iter().position(|&l| l == "Content under heading 1").unwrap();
731 assert!(
732 lines2[h1_pos2 + 1].trim().is_empty(),
733 "Should have a blank line after heading 1"
734 );
735
736 let content3 = "# Heading 1\n\n\n## Heading 2\n\n\n### Heading 3\n\nContent";
738 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard);
739 let result3 = rule.fix(&ctx3).unwrap();
740 assert!(result3.contains("# Heading 1"));
742 assert!(result3.contains("## Heading 2"));
743 assert!(result3.contains("### Heading 3"));
744 assert!(result3.contains("Content"));
745 }
746
747 #[test]
748 fn test_fix_preserves_existing_blank_lines() {
749 let rule = MD022BlanksAroundHeadings::new();
750 let content = "# Title
751
752## Section 1
753
754Content here.
755
756## Section 2
757
758More content.
759### Missing Blank Above
760
761Even more content.
762
763## Section 3
764
765Final content.";
766
767 let expected = "# Title
768
769## Section 1
770
771Content here.
772
773## Section 2
774
775More content.
776
777### Missing Blank Above
778
779Even more content.
780
781## Section 3
782
783Final content.";
784
785 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
786 let result = rule._fix_content(&ctx);
787 assert_eq!(
788 result, expected,
789 "Fix should only add missing blank lines, never remove existing ones"
790 );
791 }
792
793 #[test]
794 fn test_fix_preserves_trailing_newline() {
795 let rule = MD022BlanksAroundHeadings::new();
796
797 let content_with_newline = "# Title\nContent here.\n";
799 let ctx = LintContext::new(content_with_newline, crate::config::MarkdownFlavor::Standard);
800 let result = rule.fix(&ctx).unwrap();
801 assert!(result.ends_with('\n'), "Should preserve trailing newline");
802
803 let content_without_newline = "# Title\nContent here.";
805 let ctx = LintContext::new(content_without_newline, crate::config::MarkdownFlavor::Standard);
806 let result = rule.fix(&ctx).unwrap();
807 assert!(
808 !result.ends_with('\n'),
809 "Should not add trailing newline if original didn't have one"
810 );
811 }
812
813 #[test]
814 fn test_fix_does_not_add_blank_lines_before_lists() {
815 let rule = MD022BlanksAroundHeadings::new();
816 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.";
817
818 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.";
819
820 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
821 let result = rule._fix_content(&ctx);
822 assert_eq!(result, expected, "Fix should not add blank lines before lists");
823 }
824}