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 = "\n";
116 let had_trailing_newline = ctx.content.ends_with('\n');
117 let mut result = Vec::new();
118 let mut in_front_matter = false;
119 let mut front_matter_delimiter_count = 0;
120 let mut skip_next = false;
121
122 for (i, line_info) in ctx.lines.iter().enumerate() {
123 if skip_next {
124 skip_next = false;
125 continue;
126 }
127 let line = &line_info.content;
128
129 if line.trim() == "---" {
131 if i == 0 || (i > 0 && ctx.lines[..i].iter().all(|l| l.is_blank)) {
132 if front_matter_delimiter_count == 0 {
133 in_front_matter = true;
134 front_matter_delimiter_count = 1;
135 }
136 } else if in_front_matter && front_matter_delimiter_count == 1 {
137 in_front_matter = false;
138 front_matter_delimiter_count = 2;
139 }
140 result.push(line.to_string());
141 continue;
142 }
143
144 if in_front_matter || line_info.in_code_block {
146 result.push(line.to_string());
147 continue;
148 }
149
150 if let Some(heading) = &line_info.heading {
152 let is_first_heading = (0..i).all(|j| {
154 ctx.lines[j].is_blank
155 || (j == 0 && ctx.lines[j].content.trim() == "---")
156 || (in_front_matter && ctx.lines[j].content.trim() == "---")
157 });
158
159 let mut blank_lines_above = 0;
161 let mut check_idx = result.len();
162 while check_idx > 0 && result[check_idx - 1].trim().is_empty() {
163 blank_lines_above += 1;
164 check_idx -= 1;
165 }
166
167 let needed_blanks_above = if is_first_heading && self.config.allowed_at_start {
169 0
170 } else {
171 self.config.lines_above
172 };
173
174 while blank_lines_above < needed_blanks_above {
176 result.push(String::new());
177 blank_lines_above += 1;
178 }
179
180 result.push(line.to_string());
182
183 if matches!(
185 heading.style,
186 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
187 ) {
188 if i + 1 < ctx.lines.len() {
190 result.push(ctx.lines[i + 1].content.clone());
191 skip_next = true; }
193
194 let mut blank_lines_below = 0;
196 let mut next_content_line_idx = None;
197 for j in (i + 2)..ctx.lines.len() {
198 if ctx.lines[j].is_blank {
199 blank_lines_below += 1;
200 } else {
201 next_content_line_idx = Some(j);
202 break;
203 }
204 }
205
206 let next_is_special = if let Some(idx) = next_content_line_idx {
208 let next_line = &ctx.lines[idx];
209 next_line.list_item.is_some() || {
210 let trimmed = next_line.content.trim();
211 (trimmed.starts_with("```") || trimmed.starts_with("~~~"))
212 && (trimmed.len() == 3
213 || (trimmed.len() > 3
214 && trimmed
215 .chars()
216 .nth(3)
217 .is_some_and(|c| c.is_whitespace() || c.is_alphabetic())))
218 }
219 } else {
220 false
221 };
222
223 let needed_blanks_below = if next_is_special { 0 } else { self.config.lines_below };
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.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 needed_blanks_below = if next_is_special { 0 } else { self.config.lines_below };
262 if blank_lines_below < needed_blanks_below {
263 for _ in 0..(needed_blanks_below - blank_lines_below) {
264 result.push(String::new());
265 }
266 }
267 }
268 } else {
269 result.push(line.to_string());
271 }
272 }
273
274 let joined = result.join(line_ending);
275
276 if had_trailing_newline && !joined.ends_with('\n') {
279 format!("{joined}{line_ending}")
280 } else if !had_trailing_newline && joined.ends_with('\n') {
281 joined[..joined.len() - 1].to_string()
283 } else {
284 joined
285 }
286 }
287}
288
289impl Rule for MD022BlanksAroundHeadings {
290 fn name(&self) -> &'static str {
291 "MD022"
292 }
293
294 fn description(&self) -> &'static str {
295 "Headings should be surrounded by blank lines"
296 }
297
298 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
299 let mut result = Vec::new();
300
301 if ctx.lines.is_empty() {
303 return Ok(result);
304 }
305
306 let line_ending = "\n";
308
309 let mut heading_violations = Vec::new();
311 let mut processed_headings = std::collections::HashSet::new();
312
313 for (line_num, line_info) in ctx.lines.iter().enumerate() {
314 if processed_headings.contains(&line_num) || line_info.heading.is_none() {
316 continue;
317 }
318
319 let heading = line_info.heading.as_ref().unwrap();
320
321 if matches!(
323 heading.style,
324 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
325 ) {
326 if line_num > 0 && ctx.lines[line_num - 1].heading.is_none() {
328 continue; }
330 }
331
332 processed_headings.insert(line_num);
333
334 let is_first_heading = (0..line_num).all(|j| {
336 ctx.lines[j].is_blank ||
337 (j == 0 && ctx.lines[j].content.trim() == "---") ||
339 (j > 0 && ctx.lines[0].content.trim() == "---" && ctx.lines[j].content.trim() == "---")
340 });
341
342 let blank_lines_above = if line_num > 0 && (!is_first_heading || !self.config.allowed_at_start) {
344 let mut count = 0;
345 for j in (0..line_num).rev() {
346 if ctx.lines[j].is_blank {
347 count += 1;
348 } else {
349 break;
350 }
351 }
352 count
353 } else {
354 self.config.lines_above };
356
357 if line_num > 0
359 && blank_lines_above < self.config.lines_above
360 && (!is_first_heading || !self.config.allowed_at_start)
361 {
362 let needed_blanks = self.config.lines_above - blank_lines_above;
363 heading_violations.push((line_num, "above", needed_blanks));
364 }
365
366 let effective_last_line = if matches!(
368 heading.style,
369 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
370 ) {
371 line_num + 1 } else {
373 line_num
374 };
375
376 if effective_last_line < ctx.lines.len() - 1 {
378 let mut next_non_blank_idx = effective_last_line + 1;
380 while next_non_blank_idx < ctx.lines.len() && ctx.lines[next_non_blank_idx].is_blank {
381 next_non_blank_idx += 1;
382 }
383
384 let next_line_is_special = next_non_blank_idx < ctx.lines.len() && {
386 let next_line = &ctx.lines[next_non_blank_idx];
387 let next_trimmed = next_line.content.trim();
388
389 let is_code_fence = (next_trimmed.starts_with("```") || next_trimmed.starts_with("~~~"))
391 && (next_trimmed.len() == 3
392 || (next_trimmed.len() > 3
393 && next_trimmed
394 .chars()
395 .nth(3)
396 .is_some_and(|c| c.is_whitespace() || c.is_alphabetic())));
397
398 let is_list_item = next_line.list_item.is_some();
400
401 is_code_fence || is_list_item
402 };
403
404 if !next_line_is_special {
406 let blank_lines_below = next_non_blank_idx - effective_last_line - 1;
408
409 if blank_lines_below < self.config.lines_below {
410 let needed_blanks = self.config.lines_below - blank_lines_below;
411 heading_violations.push((line_num, "below", needed_blanks));
412 }
413 }
414 }
415 }
416
417 for (heading_line, position, needed_blanks) in heading_violations {
419 let heading_display_line = heading_line + 1; let line_info = &ctx.lines[heading_line];
421
422 let (start_line, start_col, end_line, end_col) =
424 calculate_heading_range(heading_display_line, &line_info.content);
425
426 let (message, insertion_point) = match position {
427 "above" => (
428 format!(
429 "Expected {} blank {} above heading",
430 self.config.lines_above,
431 if self.config.lines_above == 1 { "line" } else { "lines" }
432 ),
433 heading_line, ),
435 "below" => {
436 let insert_after = if line_info.heading.as_ref().is_some_and(|h| {
438 matches!(
439 h.style,
440 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
441 )
442 }) {
443 heading_line + 2
444 } else {
445 heading_line + 1
446 };
447
448 (
449 format!(
450 "Expected {} blank {} below heading",
451 self.config.lines_below,
452 if self.config.lines_below == 1 { "line" } else { "lines" }
453 ),
454 insert_after,
455 )
456 }
457 _ => continue,
458 };
459
460 let byte_range = if insertion_point == 0 && position == "above" {
462 0..0
464 } else if position == "above" && insertion_point > 0 {
465 ctx.lines[insertion_point].byte_offset..ctx.lines[insertion_point].byte_offset
467 } else if position == "below" && insertion_point - 1 < ctx.lines.len() {
468 let line_idx = insertion_point - 1;
470 let line_end_offset = if line_idx + 1 < ctx.lines.len() {
471 ctx.lines[line_idx + 1].byte_offset
472 } else {
473 ctx.content.len()
474 };
475 line_end_offset..line_end_offset
476 } else {
477 let content_len = ctx.content.len();
479 content_len..content_len
480 };
481
482 result.push(LintWarning {
483 rule_name: Some(self.name()),
484 message,
485 line: start_line,
486 column: start_col,
487 end_line,
488 end_column: end_col,
489 severity: Severity::Warning,
490 fix: Some(Fix {
491 range: byte_range,
492 replacement: line_ending.repeat(needed_blanks),
493 }),
494 });
495 }
496
497 Ok(result)
498 }
499
500 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
501 if ctx.content.is_empty() {
502 return Ok(ctx.content.to_string());
503 }
504
505 let fixed = self._fix_content(ctx);
507
508 Ok(fixed)
509 }
510
511 fn category(&self) -> RuleCategory {
513 RuleCategory::Heading
514 }
515
516 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
518 if ctx.content.is_empty() || !ctx.likely_has_headings() {
520 return true;
521 }
522 ctx.lines.iter().all(|line| line.heading.is_none())
524 }
525
526 fn as_any(&self) -> &dyn std::any::Any {
527 self
528 }
529
530 fn default_config_section(&self) -> Option<(String, toml::Value)> {
531 let default_config = MD022Config::default();
532 let json_value = serde_json::to_value(&default_config).ok()?;
533 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
534
535 if let toml::Value::Table(table) = toml_value {
536 if !table.is_empty() {
537 Some((MD022Config::RULE_NAME.to_string(), toml::Value::Table(table)))
538 } else {
539 None
540 }
541 } else {
542 None
543 }
544 }
545
546 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
547 where
548 Self: Sized,
549 {
550 let rule_config = crate::rule_config_serde::load_rule_config::<MD022Config>(config);
551 Box::new(Self::from_config_struct(rule_config))
552 }
553}
554
555#[cfg(test)]
556mod tests {
557 use super::*;
558 use crate::lint_context::LintContext;
559
560 #[test]
561 fn test_valid_headings() {
562 let rule = MD022BlanksAroundHeadings::default();
563 let content = "\n# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.\n";
564 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
565 let result = rule.check(&ctx).unwrap();
566 assert!(result.is_empty());
567 }
568
569 #[test]
570 fn test_missing_blank_above() {
571 let rule = MD022BlanksAroundHeadings::default();
572 let content = "# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.\n";
573 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
574 let result = rule.check(&ctx).unwrap();
575 assert_eq!(result.len(), 0); let fixed = rule.fix(&ctx).unwrap();
578
579 assert!(fixed.contains("# Heading 1"));
582 assert!(fixed.contains("Some content."));
583 assert!(fixed.contains("## Heading 2"));
584 assert!(fixed.contains("More content."));
585 }
586
587 #[test]
588 fn test_missing_blank_below() {
589 let rule = MD022BlanksAroundHeadings::default();
590 let content = "\n# Heading 1\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(), 1);
594 assert_eq!(result[0].line, 2);
595
596 let fixed = rule.fix(&ctx).unwrap();
598 assert!(fixed.contains("# Heading 1\n\nSome content"));
599 }
600
601 #[test]
602 fn test_missing_blank_above_and_below() {
603 let rule = MD022BlanksAroundHeadings::default();
604 let content = "# Heading 1\nSome content.\n## Heading 2\nMore content.\n";
605 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
606 let result = rule.check(&ctx).unwrap();
607 assert_eq!(result.len(), 3); let fixed = rule.fix(&ctx).unwrap();
611 assert!(fixed.contains("# Heading 1\n\nSome content"));
612 assert!(fixed.contains("Some content.\n\n## Heading 2"));
613 assert!(fixed.contains("## Heading 2\n\nMore content"));
614 }
615
616 #[test]
617 fn test_fix_headings() {
618 let rule = MD022BlanksAroundHeadings::default();
619 let content = "# Heading 1\nSome content.\n## Heading 2\nMore content.";
620 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
621 let result = rule.fix(&ctx).unwrap();
622
623 let expected = "# Heading 1\n\nSome content.\n\n## Heading 2\n\nMore content.";
624 assert_eq!(result, expected);
625 }
626
627 #[test]
628 fn test_consecutive_headings_pattern() {
629 let rule = MD022BlanksAroundHeadings::default();
630 let content = "# Heading 1\n## Heading 2\n### Heading 3";
631 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
632 let result = rule.fix(&ctx).unwrap();
633
634 let lines: Vec<&str> = result.lines().collect();
636 assert!(!lines.is_empty());
637
638 let h1_pos = lines.iter().position(|&l| l == "# Heading 1").unwrap();
640 let h2_pos = lines.iter().position(|&l| l == "## Heading 2").unwrap();
641 let h3_pos = lines.iter().position(|&l| l == "### Heading 3").unwrap();
642
643 assert!(
645 h2_pos > h1_pos + 1,
646 "Should have at least one blank line after first heading"
647 );
648 assert!(
649 h3_pos > h2_pos + 1,
650 "Should have at least one blank line after second heading"
651 );
652
653 assert!(lines[h1_pos + 1].trim().is_empty(), "Line after h1 should be blank");
655
656 assert!(lines[h2_pos + 1].trim().is_empty(), "Line after h2 should be blank");
658 }
659
660 #[test]
661 fn test_blanks_around_setext_headings() {
662 let rule = MD022BlanksAroundHeadings::default();
663 let content = "Heading 1\n=========\nSome content.\nHeading 2\n---------\nMore content.";
664 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
665 let result = rule.fix(&ctx).unwrap();
666
667 let lines: Vec<&str> = result.lines().collect();
669
670 assert!(result.contains("Heading 1"));
672 assert!(result.contains("========="));
673 assert!(result.contains("Some content."));
674 assert!(result.contains("Heading 2"));
675 assert!(result.contains("---------"));
676 assert!(result.contains("More content."));
677
678 let heading1_marker_idx = lines.iter().position(|&l| l == "=========").unwrap();
680 let some_content_idx = lines.iter().position(|&l| l == "Some content.").unwrap();
681 assert!(
682 some_content_idx > heading1_marker_idx + 1,
683 "Should have a blank line after the first heading"
684 );
685
686 let heading2_marker_idx = lines.iter().position(|&l| l == "---------").unwrap();
687 let more_content_idx = lines.iter().position(|&l| l == "More content.").unwrap();
688 assert!(
689 more_content_idx > heading2_marker_idx + 1,
690 "Should have a blank line after the second heading"
691 );
692
693 let fixed_ctx = LintContext::new(&result, crate::config::MarkdownFlavor::Standard);
695 let fixed_warnings = rule.check(&fixed_ctx).unwrap();
696 assert!(fixed_warnings.is_empty(), "Fixed content should have no warnings");
697 }
698
699 #[test]
700 fn test_fix_specific_blank_line_cases() {
701 let rule = MD022BlanksAroundHeadings::default();
702
703 let content1 = "# Heading 1\n## Heading 2\n### Heading 3";
705 let ctx1 = LintContext::new(content1, crate::config::MarkdownFlavor::Standard);
706 let result1 = rule.fix(&ctx1).unwrap();
707 assert!(result1.contains("# Heading 1"));
709 assert!(result1.contains("## Heading 2"));
710 assert!(result1.contains("### Heading 3"));
711 let lines: Vec<&str> = result1.lines().collect();
713 let h1_pos = lines.iter().position(|&l| l == "# Heading 1").unwrap();
714 let h2_pos = lines.iter().position(|&l| l == "## Heading 2").unwrap();
715 assert!(lines[h1_pos + 1].trim().is_empty(), "Should have a blank line after h1");
716 assert!(lines[h2_pos + 1].trim().is_empty(), "Should have a blank line after h2");
717
718 let content2 = "# Heading 1\nContent under heading 1\n## Heading 2";
720 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard);
721 let result2 = rule.fix(&ctx2).unwrap();
722 assert!(result2.contains("# Heading 1"));
724 assert!(result2.contains("Content under heading 1"));
725 assert!(result2.contains("## Heading 2"));
726 let lines2: Vec<&str> = result2.lines().collect();
728 let h1_pos2 = lines2.iter().position(|&l| l == "# Heading 1").unwrap();
729 let _content_pos = lines2.iter().position(|&l| l == "Content under heading 1").unwrap();
730 assert!(
731 lines2[h1_pos2 + 1].trim().is_empty(),
732 "Should have a blank line after heading 1"
733 );
734
735 let content3 = "# Heading 1\n\n\n## Heading 2\n\n\n### Heading 3\n\nContent";
737 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard);
738 let result3 = rule.fix(&ctx3).unwrap();
739 assert!(result3.contains("# Heading 1"));
741 assert!(result3.contains("## Heading 2"));
742 assert!(result3.contains("### Heading 3"));
743 assert!(result3.contains("Content"));
744 }
745
746 #[test]
747 fn test_fix_preserves_existing_blank_lines() {
748 let rule = MD022BlanksAroundHeadings::new();
749 let content = "# Title
750
751## Section 1
752
753Content here.
754
755## Section 2
756
757More content.
758### Missing Blank Above
759
760Even more content.
761
762## Section 3
763
764Final content.";
765
766 let expected = "# Title
767
768## Section 1
769
770Content here.
771
772## Section 2
773
774More content.
775
776### Missing Blank Above
777
778Even more content.
779
780## Section 3
781
782Final content.";
783
784 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
785 let result = rule._fix_content(&ctx);
786 assert_eq!(
787 result, expected,
788 "Fix should only add missing blank lines, never remove existing ones"
789 );
790 }
791
792 #[test]
793 fn test_fix_preserves_trailing_newline() {
794 let rule = MD022BlanksAroundHeadings::new();
795
796 let content_with_newline = "# Title\nContent here.\n";
798 let ctx = LintContext::new(content_with_newline, crate::config::MarkdownFlavor::Standard);
799 let result = rule.fix(&ctx).unwrap();
800 assert!(result.ends_with('\n'), "Should preserve trailing newline");
801
802 let content_without_newline = "# Title\nContent here.";
804 let ctx = LintContext::new(content_without_newline, crate::config::MarkdownFlavor::Standard);
805 let result = rule.fix(&ctx).unwrap();
806 assert!(
807 !result.ends_with('\n'),
808 "Should not add trailing newline if original didn't have one"
809 );
810 }
811
812 #[test]
813 fn test_fix_does_not_add_blank_lines_before_lists() {
814 let rule = MD022BlanksAroundHeadings::new();
815 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.";
816
817 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.";
818
819 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
820 let result = rule._fix_content(&ctx);
821 assert_eq!(result, expected, "Fix should not add blank lines before lists");
822 }
823}