use crate::error::Result;
use crate::rule::{AstRule, RuleCategory, RuleMetadata};
use crate::{
Document,
violation::{Severity, Violation},
};
use comrak::nodes::{AstNode, NodeValue};
pub struct MD022;
impl AstRule for MD022 {
fn id(&self) -> &'static str {
"MD022"
}
fn name(&self) -> &'static str {
"blanks-around-headings"
}
fn description(&self) -> &'static str {
"Headings should be surrounded by blank lines"
}
fn metadata(&self) -> RuleMetadata {
RuleMetadata::stable(RuleCategory::Structure).introduced_in("markdownlint v0.1.0")
}
fn check_ast<'a>(&self, document: &Document, ast: &'a AstNode<'a>) -> Result<Vec<Violation>> {
let mut violations = Vec::new();
for node in ast.descendants() {
if let NodeValue::Heading(_) = &node.data.borrow().value {
if let Some((line, column)) = document.node_position(node) {
if !self.has_blank_line_before(document, line) {
violations.push(self.create_violation(
"Heading should be preceded by a blank line".to_string(),
line,
column,
Severity::Warning,
));
}
if !self.has_blank_line_after(document, line) {
violations.push(self.create_violation(
"Heading should be followed by a blank line".to_string(),
line,
column,
Severity::Warning,
));
}
}
}
}
Ok(violations)
}
}
impl MD022 {
fn has_blank_line_before(&self, document: &Document, line_num: usize) -> bool {
if line_num <= 1 {
return true;
}
if let Some(prev_line) = document.lines.get(line_num - 2) {
prev_line.trim().is_empty()
} else {
true }
}
fn has_blank_line_after(&self, document: &Document, line_num: usize) -> bool {
if line_num >= document.lines.len() {
return true;
}
if let Some(next_line) = document.lines.get(line_num) {
next_line.trim().is_empty()
} else {
true }
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_helpers::*;
#[test]
fn test_md022_valid_headings() {
let content = MarkdownBuilder::new()
.heading(1, "Title")
.blank_line()
.paragraph("Some content here.")
.blank_line()
.heading(2, "Subtitle")
.blank_line()
.paragraph("More content.")
.build();
assert_no_violations(MD022, &content);
}
#[test]
fn test_md022_missing_blank_before() {
let content = MarkdownBuilder::new()
.paragraph("Some text before.")
.heading(1, "Title")
.blank_line()
.paragraph("Content after.")
.build();
let violations = assert_violation_count(MD022, &content, 1);
assert_violation_contains_message(&violations, "preceded by a blank line");
assert_violation_at_line(&violations, 2);
}
#[test]
fn test_md022_missing_blank_after() {
let content = MarkdownBuilder::new()
.heading(1, "Title")
.paragraph("Content immediately after.")
.build();
let violations = assert_violation_count(MD022, &content, 1);
assert_violation_contains_message(&violations, "followed by a blank line");
assert_violation_at_line(&violations, 1);
}
#[test]
fn test_md022_missing_both_blanks() {
let content = MarkdownBuilder::new()
.paragraph("Text before.")
.heading(1, "Title")
.paragraph("Text after.")
.build();
let violations = assert_violation_count(MD022, &content, 2);
assert_violation_contains_message(&violations, "preceded by a blank line");
assert_violation_contains_message(&violations, "followed by a blank line");
}
#[test]
fn test_md022_start_of_document() {
let content = MarkdownBuilder::new()
.heading(1, "Title")
.blank_line()
.paragraph("Content after.")
.build();
assert_no_violations(MD022, &content);
}
#[test]
fn test_md022_end_of_document() {
let content = MarkdownBuilder::new()
.paragraph("Some content.")
.blank_line()
.heading(1, "Final Heading")
.build();
assert_no_violations(MD022, &content);
}
#[test]
fn test_md022_multiple_headings() {
let content = MarkdownBuilder::new()
.heading(1, "Main Title")
.blank_line()
.paragraph("Introduction text.")
.blank_line()
.heading(2, "Section 1")
.blank_line()
.paragraph("Section content.")
.blank_line()
.heading(2, "Section 2")
.blank_line()
.paragraph("More content.")
.build();
assert_no_violations(MD022, &content);
}
#[test]
fn test_md022_consecutive_headings() {
let content = MarkdownBuilder::new()
.heading(1, "Main Title")
.blank_line()
.heading(2, "Subtitle")
.blank_line()
.paragraph("Content.")
.build();
assert_no_violations(MD022, &content);
}
#[test]
fn test_md022_mixed_heading_levels() {
let content = MarkdownBuilder::new()
.heading(1, "Level 1")
.blank_line()
.heading(3, "Level 3")
.blank_line()
.heading(2, "Level 2")
.blank_line()
.paragraph("Content.")
.build();
assert_no_violations(MD022, &content);
}
#[test]
fn test_md022_multiple_violations() {
let content = MarkdownBuilder::new()
.paragraph("Text before first heading.")
.heading(1, "Title")
.paragraph("No blank lines around this heading.")
.heading(2, "Subtitle")
.paragraph("More text.")
.build();
let violations = assert_violation_count(MD022, &content, 4);
assert_violation_contains_message(&violations, "preceded by a blank line");
assert_violation_contains_message(&violations, "followed by a blank line");
}
#[test]
fn test_md022_headings_with_other_elements() {
let content = MarkdownBuilder::new()
.heading(1, "Document Title")
.blank_line()
.blockquote("This is a quote before the next heading.")
.blank_line()
.heading(2, "Section with Quote")
.blank_line()
.unordered_list(&["Item 1", "Item 2", "Item 3"])
.blank_line()
.heading(3, "Section with List")
.blank_line()
.code_block("rust", "fn main() {}")
.build();
assert_no_violations(MD022, &content);
}
#[test]
fn test_md022_heading_immediately_after_code_block() {
let content = MarkdownBuilder::new()
.code_block("rust", "fn main() {}")
.heading(1, "Heading")
.blank_line()
.paragraph("Content.")
.build();
let violations = assert_violation_count(MD022, &content, 1);
assert_violation_contains_message(&violations, "preceded by a blank line");
}
#[test]
fn test_md022_single_heading_document() {
let content = MarkdownBuilder::new().heading(1, "Only Heading").build();
assert_no_violations(MD022, &content);
}
}