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 skip_next = false;
119
120 let heading_at_start_idx = {
121 let mut found_non_blank = false;
122 ctx.lines.iter().enumerate().find_map(|(i, line)| {
123 if line.heading.is_some() && !found_non_blank {
124 Some(i)
125 } else {
126 if !line.is_blank {
127 found_non_blank = true;
128 }
129 None
130 }
131 })
132 };
133
134 for (i, line_info) in ctx.lines.iter().enumerate() {
135 if skip_next {
136 skip_next = false;
137 continue;
138 }
139 let line = &line_info.content;
140
141 if line_info.in_code_block {
142 result.push(line.to_string());
143 continue;
144 }
145
146 if let Some(heading) = &line_info.heading {
148 let is_first_heading = Some(i) == heading_at_start_idx;
150
151 let mut blank_lines_above = 0;
153 let mut check_idx = result.len();
154 while check_idx > 0 && result[check_idx - 1].trim().is_empty() {
155 blank_lines_above += 1;
156 check_idx -= 1;
157 }
158
159 let needed_blanks_above = if is_first_heading && self.config.allowed_at_start {
161 0
162 } else {
163 self.config.lines_above
164 };
165
166 while blank_lines_above < needed_blanks_above {
168 result.push(String::new());
169 blank_lines_above += 1;
170 }
171
172 result.push(line.to_string());
174
175 if matches!(
177 heading.style,
178 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
179 ) {
180 if i + 1 < ctx.lines.len() {
182 result.push(ctx.lines[i + 1].content.clone());
183 skip_next = true; }
185
186 let mut blank_lines_below = 0;
188 let mut next_content_line_idx = None;
189 for j in (i + 2)..ctx.lines.len() {
190 if ctx.lines[j].is_blank {
191 blank_lines_below += 1;
192 } else {
193 next_content_line_idx = Some(j);
194 break;
195 }
196 }
197
198 let next_is_special = if let Some(idx) = next_content_line_idx {
200 let next_line = &ctx.lines[idx];
201 next_line.list_item.is_some() || {
202 let trimmed = next_line.content.trim();
203 (trimmed.starts_with("```") || trimmed.starts_with("~~~"))
204 && (trimmed.len() == 3
205 || (trimmed.len() > 3
206 && trimmed
207 .chars()
208 .nth(3)
209 .is_some_and(|c| c.is_whitespace() || c.is_alphabetic())))
210 }
211 } else {
212 false
213 };
214
215 let needed_blanks_below = if next_is_special { 0 } else { self.config.lines_below };
217 if blank_lines_below < needed_blanks_below {
218 for _ in 0..(needed_blanks_below - blank_lines_below) {
219 result.push(String::new());
220 }
221 }
222 } else {
223 let mut blank_lines_below = 0;
225 let mut next_content_line_idx = None;
226 for j in (i + 1)..ctx.lines.len() {
227 if ctx.lines[j].is_blank {
228 blank_lines_below += 1;
229 } else {
230 next_content_line_idx = Some(j);
231 break;
232 }
233 }
234
235 let next_is_special = if let Some(idx) = next_content_line_idx {
237 let next_line = &ctx.lines[idx];
238 next_line.list_item.is_some() || {
239 let trimmed = next_line.content.trim();
240 (trimmed.starts_with("```") || trimmed.starts_with("~~~"))
241 && (trimmed.len() == 3
242 || (trimmed.len() > 3
243 && trimmed
244 .chars()
245 .nth(3)
246 .is_some_and(|c| c.is_whitespace() || c.is_alphabetic())))
247 }
248 } else {
249 false
250 };
251
252 let needed_blanks_below = if next_is_special { 0 } else { self.config.lines_below };
254 if blank_lines_below < needed_blanks_below {
255 for _ in 0..(needed_blanks_below - blank_lines_below) {
256 result.push(String::new());
257 }
258 }
259 }
260 } else {
261 result.push(line.to_string());
263 }
264 }
265
266 let joined = result.join(line_ending);
267
268 if had_trailing_newline && !joined.ends_with('\n') {
271 format!("{joined}{line_ending}")
272 } else if !had_trailing_newline && joined.ends_with('\n') {
273 joined[..joined.len() - 1].to_string()
275 } else {
276 joined
277 }
278 }
279}
280
281impl Rule for MD022BlanksAroundHeadings {
282 fn name(&self) -> &'static str {
283 "MD022"
284 }
285
286 fn description(&self) -> &'static str {
287 "Headings should be surrounded by blank lines"
288 }
289
290 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
291 let mut result = Vec::new();
292
293 if ctx.lines.is_empty() {
295 return Ok(result);
296 }
297
298 let line_ending = "\n";
300
301 let heading_at_start_idx = {
302 let mut found_non_blank = false;
303 ctx.lines.iter().enumerate().find_map(|(i, line)| {
304 if line.heading.is_some() && !found_non_blank {
305 Some(i)
306 } else {
307 if !line.is_blank {
308 found_non_blank = true;
309 }
310 None
311 }
312 })
313 };
314
315 let mut heading_violations = Vec::new();
317 let mut processed_headings = std::collections::HashSet::new();
318
319 for (line_num, line_info) in ctx.lines.iter().enumerate() {
320 if processed_headings.contains(&line_num) || line_info.heading.is_none() {
322 continue;
323 }
324
325 let heading = line_info.heading.as_ref().unwrap();
326
327 if matches!(
329 heading.style,
330 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
331 ) {
332 if line_num > 0 && ctx.lines[line_num - 1].heading.is_none() {
334 continue; }
336 }
337
338 processed_headings.insert(line_num);
339
340 let is_first_heading = Some(line_num) == heading_at_start_idx;
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().to_string()),
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}