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 crate::types::PositiveUsize;
100 Self {
101 config: MD022Config {
102 lines_above: PositiveUsize::new(lines_above).unwrap_or(PositiveUsize::from_const(1)),
103 lines_below: PositiveUsize::new(lines_below).unwrap_or(PositiveUsize::from_const(1)),
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
152 let mut blank_lines_above = 0;
154 let mut check_idx = result.len();
155 while check_idx > 0 && result[check_idx - 1].trim().is_empty() {
156 blank_lines_above += 1;
157 check_idx -= 1;
158 }
159
160 let needed_blanks_above = if is_first_heading && self.config.allowed_at_start {
162 0
163 } else {
164 self.config.lines_above.get()
165 };
166
167 while blank_lines_above < needed_blanks_above {
169 result.push(String::new());
170 blank_lines_above += 1;
171 }
172
173 result.push(line.to_string());
175
176 if matches!(
178 heading.style,
179 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
180 ) {
181 if i + 1 < ctx.lines.len() {
183 result.push(ctx.lines[i + 1].content(ctx.content).to_string());
184 skip_next = true; }
186
187 let mut blank_lines_below = 0;
189 let mut next_content_line_idx = None;
190 for j in (i + 2)..ctx.lines.len() {
191 if ctx.lines[j].is_blank {
192 blank_lines_below += 1;
193 } else {
194 next_content_line_idx = Some(j);
195 break;
196 }
197 }
198
199 let next_is_special = if let Some(idx) = next_content_line_idx {
201 let next_line = &ctx.lines[idx];
202 next_line.list_item.is_some() || {
203 let trimmed = next_line.content(ctx.content).trim();
204 (trimmed.starts_with("```") || trimmed.starts_with("~~~"))
205 && (trimmed.len() == 3
206 || (trimmed.len() > 3
207 && trimmed
208 .chars()
209 .nth(3)
210 .is_some_and(|c| c.is_whitespace() || c.is_alphabetic())))
211 }
212 } else {
213 false
214 };
215
216 let needed_blanks_below = if next_is_special {
218 0
219 } else {
220 self.config.lines_below.get()
221 };
222 if blank_lines_below < needed_blanks_below {
223 for _ in 0..(needed_blanks_below - blank_lines_below) {
224 result.push(String::new());
225 }
226 }
227 } else {
228 let mut blank_lines_below = 0;
230 let mut next_content_line_idx = None;
231 for j in (i + 1)..ctx.lines.len() {
232 if ctx.lines[j].is_blank {
233 blank_lines_below += 1;
234 } else {
235 next_content_line_idx = Some(j);
236 break;
237 }
238 }
239
240 let next_is_special = if let Some(idx) = next_content_line_idx {
242 let next_line = &ctx.lines[idx];
243 next_line.list_item.is_some() || {
244 let trimmed = next_line.content(ctx.content).trim();
245 (trimmed.starts_with("```") || trimmed.starts_with("~~~"))
246 && (trimmed.len() == 3
247 || (trimmed.len() > 3
248 && trimmed
249 .chars()
250 .nth(3)
251 .is_some_and(|c| c.is_whitespace() || c.is_alphabetic())))
252 }
253 } else {
254 false
255 };
256
257 let needed_blanks_below = if next_is_special {
259 0
260 } else {
261 self.config.lines_below.get()
262 };
263 if blank_lines_below < needed_blanks_below {
264 for _ in 0..(needed_blanks_below - blank_lines_below) {
265 result.push(String::new());
266 }
267 }
268 }
269 } else {
270 result.push(line.to_string());
272 }
273 }
274
275 let joined = result.join(line_ending);
276
277 if had_trailing_newline && !joined.ends_with('\n') {
280 format!("{joined}{line_ending}")
281 } else if !had_trailing_newline && joined.ends_with('\n') {
282 joined[..joined.len() - 1].to_string()
284 } else {
285 joined
286 }
287 }
288}
289
290impl Rule for MD022BlanksAroundHeadings {
291 fn name(&self) -> &'static str {
292 "MD022"
293 }
294
295 fn description(&self) -> &'static str {
296 "Headings should be surrounded by blank lines"
297 }
298
299 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
300 let mut result = Vec::new();
301
302 if ctx.lines.is_empty() {
304 return Ok(result);
305 }
306
307 let line_ending = "\n";
309
310 let heading_at_start_idx = {
311 let mut found_non_blank = false;
312 ctx.lines.iter().enumerate().find_map(|(i, line)| {
313 if line.heading.is_some() && !found_non_blank {
314 Some(i)
315 } else {
316 if !line.is_blank {
317 found_non_blank = true;
318 }
319 None
320 }
321 })
322 };
323
324 let mut heading_violations = Vec::new();
326 let mut processed_headings = std::collections::HashSet::new();
327
328 for (line_num, line_info) in ctx.lines.iter().enumerate() {
329 if processed_headings.contains(&line_num) || line_info.heading.is_none() {
331 continue;
332 }
333
334 let heading = line_info.heading.as_ref().unwrap();
335
336 if matches!(
338 heading.style,
339 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
340 ) {
341 if line_num > 0 && ctx.lines[line_num - 1].heading.is_none() {
343 continue; }
345 }
346
347 processed_headings.insert(line_num);
348
349 let is_first_heading = Some(line_num) == heading_at_start_idx;
351
352 let blank_lines_above = if line_num > 0 && (!is_first_heading || !self.config.allowed_at_start) {
354 let mut count = 0;
355 for j in (0..line_num).rev() {
356 if ctx.lines[j].is_blank {
357 count += 1;
358 } else {
359 break;
360 }
361 }
362 count
363 } else {
364 self.config.lines_above.get() };
366
367 if line_num > 0
369 && blank_lines_above < self.config.lines_above.get()
370 && (!is_first_heading || !self.config.allowed_at_start)
371 {
372 let needed_blanks = self.config.lines_above.get() - blank_lines_above;
373 heading_violations.push((line_num, "above", needed_blanks));
374 }
375
376 let effective_last_line = if matches!(
378 heading.style,
379 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
380 ) {
381 line_num + 1 } else {
383 line_num
384 };
385
386 if effective_last_line < ctx.lines.len() - 1 {
388 let mut next_non_blank_idx = effective_last_line + 1;
390 while next_non_blank_idx < ctx.lines.len() && ctx.lines[next_non_blank_idx].is_blank {
391 next_non_blank_idx += 1;
392 }
393
394 let next_line_is_special = next_non_blank_idx < ctx.lines.len() && {
396 let next_line = &ctx.lines[next_non_blank_idx];
397 let next_trimmed = next_line.content(ctx.content).trim();
398
399 let is_code_fence = (next_trimmed.starts_with("```") || next_trimmed.starts_with("~~~"))
401 && (next_trimmed.len() == 3
402 || (next_trimmed.len() > 3
403 && next_trimmed
404 .chars()
405 .nth(3)
406 .is_some_and(|c| c.is_whitespace() || c.is_alphabetic())));
407
408 let is_list_item = next_line.list_item.is_some();
410
411 is_code_fence || is_list_item
412 };
413
414 if !next_line_is_special {
416 let blank_lines_below = next_non_blank_idx - effective_last_line - 1;
418
419 if blank_lines_below < self.config.lines_below.get() {
420 let needed_blanks = self.config.lines_below.get() - blank_lines_below;
421 heading_violations.push((line_num, "below", needed_blanks));
422 }
423 }
424 }
425 }
426
427 for (heading_line, position, needed_blanks) in heading_violations {
429 let heading_display_line = heading_line + 1; let line_info = &ctx.lines[heading_line];
431
432 let (start_line, start_col, end_line, end_col) =
434 calculate_heading_range(heading_display_line, line_info.content(ctx.content));
435
436 let (message, insertion_point) = match position {
437 "above" => (
438 format!(
439 "Expected {} blank {} above heading",
440 self.config.lines_above.get(),
441 if self.config.lines_above.get() == 1 {
442 "line"
443 } else {
444 "lines"
445 }
446 ),
447 heading_line, ),
449 "below" => {
450 let insert_after = if line_info.heading.as_ref().is_some_and(|h| {
452 matches!(
453 h.style,
454 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
455 )
456 }) {
457 heading_line + 2
458 } else {
459 heading_line + 1
460 };
461
462 (
463 format!(
464 "Expected {} blank {} below heading",
465 self.config.lines_below.get(),
466 if self.config.lines_below.get() == 1 {
467 "line"
468 } else {
469 "lines"
470 }
471 ),
472 insert_after,
473 )
474 }
475 _ => continue,
476 };
477
478 let byte_range = if insertion_point == 0 && position == "above" {
480 0..0
482 } else if position == "above" && insertion_point > 0 {
483 ctx.lines[insertion_point].byte_offset..ctx.lines[insertion_point].byte_offset
485 } else if position == "below" && insertion_point - 1 < ctx.lines.len() {
486 let line_idx = insertion_point - 1;
488 let line_end_offset = if line_idx + 1 < ctx.lines.len() {
489 ctx.lines[line_idx + 1].byte_offset
490 } else {
491 ctx.content.len()
492 };
493 line_end_offset..line_end_offset
494 } else {
495 let content_len = ctx.content.len();
497 content_len..content_len
498 };
499
500 result.push(LintWarning {
501 rule_name: Some(self.name().to_string()),
502 message,
503 line: start_line,
504 column: start_col,
505 end_line,
506 end_column: end_col,
507 severity: Severity::Warning,
508 fix: Some(Fix {
509 range: byte_range,
510 replacement: line_ending.repeat(needed_blanks),
511 }),
512 });
513 }
514
515 Ok(result)
516 }
517
518 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
519 if ctx.content.is_empty() {
520 return Ok(ctx.content.to_string());
521 }
522
523 let fixed = self._fix_content(ctx);
525
526 Ok(fixed)
527 }
528
529 fn category(&self) -> RuleCategory {
531 RuleCategory::Heading
532 }
533
534 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
536 if ctx.content.is_empty() || !ctx.likely_has_headings() {
538 return true;
539 }
540 ctx.lines.iter().all(|line| line.heading.is_none())
542 }
543
544 fn as_any(&self) -> &dyn std::any::Any {
545 self
546 }
547
548 fn default_config_section(&self) -> Option<(String, toml::Value)> {
549 let default_config = MD022Config::default();
550 let json_value = serde_json::to_value(&default_config).ok()?;
551 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
552
553 if let toml::Value::Table(table) = toml_value {
554 if !table.is_empty() {
555 Some((MD022Config::RULE_NAME.to_string(), toml::Value::Table(table)))
556 } else {
557 None
558 }
559 } else {
560 None
561 }
562 }
563
564 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
565 where
566 Self: Sized,
567 {
568 let rule_config = crate::rule_config_serde::load_rule_config::<MD022Config>(config);
569 Box::new(Self::from_config_struct(rule_config))
570 }
571}
572
573#[cfg(test)]
574mod tests {
575 use super::*;
576 use crate::lint_context::LintContext;
577
578 #[test]
579 fn test_valid_headings() {
580 let rule = MD022BlanksAroundHeadings::default();
581 let content = "\n# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.\n";
582 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
583 let result = rule.check(&ctx).unwrap();
584 assert!(result.is_empty());
585 }
586
587 #[test]
588 fn test_missing_blank_above() {
589 let rule = MD022BlanksAroundHeadings::default();
590 let content = "# 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_eq!(result.len(), 0); let fixed = rule.fix(&ctx).unwrap();
596
597 assert!(fixed.contains("# Heading 1"));
600 assert!(fixed.contains("Some content."));
601 assert!(fixed.contains("## Heading 2"));
602 assert!(fixed.contains("More content."));
603 }
604
605 #[test]
606 fn test_missing_blank_below() {
607 let rule = MD022BlanksAroundHeadings::default();
608 let content = "\n# Heading 1\nSome content.\n\n## Heading 2\n\nMore content.\n";
609 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
610 let result = rule.check(&ctx).unwrap();
611 assert_eq!(result.len(), 1);
612 assert_eq!(result[0].line, 2);
613
614 let fixed = rule.fix(&ctx).unwrap();
616 assert!(fixed.contains("# Heading 1\n\nSome content"));
617 }
618
619 #[test]
620 fn test_missing_blank_above_and_below() {
621 let rule = MD022BlanksAroundHeadings::default();
622 let content = "# Heading 1\nSome content.\n## Heading 2\nMore content.\n";
623 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
624 let result = rule.check(&ctx).unwrap();
625 assert_eq!(result.len(), 3); let fixed = rule.fix(&ctx).unwrap();
629 assert!(fixed.contains("# Heading 1\n\nSome content"));
630 assert!(fixed.contains("Some content.\n\n## Heading 2"));
631 assert!(fixed.contains("## Heading 2\n\nMore content"));
632 }
633
634 #[test]
635 fn test_fix_headings() {
636 let rule = MD022BlanksAroundHeadings::default();
637 let content = "# Heading 1\nSome content.\n## Heading 2\nMore content.";
638 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
639 let result = rule.fix(&ctx).unwrap();
640
641 let expected = "# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.";
642 assert_eq!(result, expected);
643 }
644
645 #[test]
646 fn test_consecutive_headings_pattern() {
647 let rule = MD022BlanksAroundHeadings::default();
648 let content = "# Heading 1\n## Heading 2\n### Heading 3";
649 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
650 let result = rule.fix(&ctx).unwrap();
651
652 let lines: Vec<&str> = result.lines().collect();
654 assert!(!lines.is_empty());
655
656 let h1_pos = lines.iter().position(|&l| l == "# Heading 1").unwrap();
658 let h2_pos = lines.iter().position(|&l| l == "## Heading 2").unwrap();
659 let h3_pos = lines.iter().position(|&l| l == "### Heading 3").unwrap();
660
661 assert!(
663 h2_pos > h1_pos + 1,
664 "Should have at least one blank line after first heading"
665 );
666 assert!(
667 h3_pos > h2_pos + 1,
668 "Should have at least one blank line after second heading"
669 );
670
671 assert!(lines[h1_pos + 1].trim().is_empty(), "Line after h1 should be blank");
673
674 assert!(lines[h2_pos + 1].trim().is_empty(), "Line after h2 should be blank");
676 }
677
678 #[test]
679 fn test_blanks_around_setext_headings() {
680 let rule = MD022BlanksAroundHeadings::default();
681 let content = "Heading 1\n=========\nSome content.\nHeading 2\n---------\nMore content.";
682 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
683 let result = rule.fix(&ctx).unwrap();
684
685 let lines: Vec<&str> = result.lines().collect();
687
688 assert!(result.contains("Heading 1"));
690 assert!(result.contains("========="));
691 assert!(result.contains("Some content."));
692 assert!(result.contains("Heading 2"));
693 assert!(result.contains("---------"));
694 assert!(result.contains("More content."));
695
696 let heading1_marker_idx = lines.iter().position(|&l| l == "=========").unwrap();
698 let some_content_idx = lines.iter().position(|&l| l == "Some content.").unwrap();
699 assert!(
700 some_content_idx > heading1_marker_idx + 1,
701 "Should have a blank line after the first heading"
702 );
703
704 let heading2_marker_idx = lines.iter().position(|&l| l == "---------").unwrap();
705 let more_content_idx = lines.iter().position(|&l| l == "More content.").unwrap();
706 assert!(
707 more_content_idx > heading2_marker_idx + 1,
708 "Should have a blank line after the second heading"
709 );
710
711 let fixed_ctx = LintContext::new(&result, crate::config::MarkdownFlavor::Standard);
713 let fixed_warnings = rule.check(&fixed_ctx).unwrap();
714 assert!(fixed_warnings.is_empty(), "Fixed content should have no warnings");
715 }
716
717 #[test]
718 fn test_fix_specific_blank_line_cases() {
719 let rule = MD022BlanksAroundHeadings::default();
720
721 let content1 = "# Heading 1\n## Heading 2\n### Heading 3";
723 let ctx1 = LintContext::new(content1, crate::config::MarkdownFlavor::Standard);
724 let result1 = rule.fix(&ctx1).unwrap();
725 assert!(result1.contains("# Heading 1"));
727 assert!(result1.contains("## Heading 2"));
728 assert!(result1.contains("### Heading 3"));
729 let lines: Vec<&str> = result1.lines().collect();
731 let h1_pos = lines.iter().position(|&l| l == "# Heading 1").unwrap();
732 let h2_pos = lines.iter().position(|&l| l == "## Heading 2").unwrap();
733 assert!(lines[h1_pos + 1].trim().is_empty(), "Should have a blank line after h1");
734 assert!(lines[h2_pos + 1].trim().is_empty(), "Should have a blank line after h2");
735
736 let content2 = "# Heading 1\nContent under heading 1\n## Heading 2";
738 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard);
739 let result2 = rule.fix(&ctx2).unwrap();
740 assert!(result2.contains("# Heading 1"));
742 assert!(result2.contains("Content under heading 1"));
743 assert!(result2.contains("## Heading 2"));
744 let lines2: Vec<&str> = result2.lines().collect();
746 let h1_pos2 = lines2.iter().position(|&l| l == "# Heading 1").unwrap();
747 let _content_pos = lines2.iter().position(|&l| l == "Content under heading 1").unwrap();
748 assert!(
749 lines2[h1_pos2 + 1].trim().is_empty(),
750 "Should have a blank line after heading 1"
751 );
752
753 let content3 = "# Heading 1\n\n\n## Heading 2\n\n\n### Heading 3\n\nContent";
755 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard);
756 let result3 = rule.fix(&ctx3).unwrap();
757 assert!(result3.contains("# Heading 1"));
759 assert!(result3.contains("## Heading 2"));
760 assert!(result3.contains("### Heading 3"));
761 assert!(result3.contains("Content"));
762 }
763
764 #[test]
765 fn test_fix_preserves_existing_blank_lines() {
766 let rule = MD022BlanksAroundHeadings::new();
767 let content = "# Title
768
769## Section 1
770
771Content here.
772
773## Section 2
774
775More content.
776### Missing Blank Above
777
778Even more content.
779
780## Section 3
781
782Final content.";
783
784 let expected = "# Title
785
786## Section 1
787
788Content here.
789
790## Section 2
791
792More content.
793
794### Missing Blank Above
795
796Even more content.
797
798## Section 3
799
800Final content.";
801
802 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
803 let result = rule._fix_content(&ctx);
804 assert_eq!(
805 result, expected,
806 "Fix should only add missing blank lines, never remove existing ones"
807 );
808 }
809
810 #[test]
811 fn test_fix_preserves_trailing_newline() {
812 let rule = MD022BlanksAroundHeadings::new();
813
814 let content_with_newline = "# Title\nContent here.\n";
816 let ctx = LintContext::new(content_with_newline, crate::config::MarkdownFlavor::Standard);
817 let result = rule.fix(&ctx).unwrap();
818 assert!(result.ends_with('\n'), "Should preserve trailing newline");
819
820 let content_without_newline = "# Title\nContent here.";
822 let ctx = LintContext::new(content_without_newline, crate::config::MarkdownFlavor::Standard);
823 let result = rule.fix(&ctx).unwrap();
824 assert!(
825 !result.ends_with('\n'),
826 "Should not add trailing newline if original didn't have one"
827 );
828 }
829
830 #[test]
831 fn test_fix_does_not_add_blank_lines_before_lists() {
832 let rule = MD022BlanksAroundHeadings::new();
833 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.";
834
835 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.";
836
837 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
838 let result = rule._fix_content(&ctx);
839 assert_eq!(result, expected, "Fix should not add blank lines before lists");
840 }
841}