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 ctx.content.is_empty() || ctx.lines.iter().all(|line| line.heading.is_none())
520 }
521
522 fn as_any(&self) -> &dyn std::any::Any {
523 self
524 }
525
526 fn as_maybe_document_structure(&self) -> Option<&dyn crate::rule::MaybeDocumentStructure> {
527 None
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}