use crate::error::Result;
use crate::rule::{AstRule, RuleCategory, RuleMetadata};
use crate::{
Document,
violation::{Severity, Violation},
};
use comrak::nodes::{AstNode, NodeValue};
pub struct MD032;
impl AstRule for MD032 {
fn id(&self) -> &'static str {
"MD032"
}
fn name(&self) -> &'static str {
"blanks-around-lists"
}
fn description(&self) -> &'static str {
"Lists 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::List(_) = &node.data.borrow().value {
if !self.is_nested_list(node) {
if let Some((start_line, start_column)) = document.node_position(node) {
if !self.has_blank_line_before(document, start_line) {
violations.push(self.create_violation(
"List should be preceded by a blank line".to_string(),
start_line,
start_column,
Severity::Warning,
));
}
let end_line = self.find_list_end_line(document, node);
if !self.has_blank_line_after(document, end_line) {
violations.push(self.create_violation(
"List should be followed by a blank line".to_string(),
end_line,
1,
Severity::Warning,
));
}
}
}
}
}
Ok(violations)
}
}
impl MD032 {
fn is_nested_list(&self, list_node: &AstNode) -> bool {
let mut current = list_node.parent();
while let Some(parent) = current {
match &parent.data.borrow().value {
NodeValue::List(_) => return true,
NodeValue::Item(_) => {
if let Some(grandparent) = parent.parent() {
if let NodeValue::List(_) = &grandparent.data.borrow().value {
return true;
}
}
}
_ => {}
}
current = parent.parent();
}
false
}
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 }
}
fn find_list_end_line<'a>(&self, document: &Document, list_node: &'a AstNode<'a>) -> usize {
let mut max_line = 1;
for descendant in list_node.descendants() {
if let Some((line, _)) = document.node_position(descendant) {
max_line = max_line.max(line);
}
}
max_line
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_helpers::*;
#[test]
fn test_md032_valid_unordered_list() {
let content = MarkdownBuilder::new()
.heading(1, "Title")
.blank_line()
.unordered_list(&["Item 1", "Item 2", "Item 3"])
.blank_line()
.paragraph("Some text after.")
.build();
assert_no_violations(MD032, &content);
}
#[test]
fn test_md032_valid_ordered_list() {
let content = MarkdownBuilder::new()
.heading(1, "Title")
.blank_line()
.ordered_list(&["First item", "Second item", "Third item"])
.blank_line()
.paragraph("Some text after.")
.build();
assert_no_violations(MD032, &content);
}
#[test]
fn test_md032_missing_blank_before() {
let content = MarkdownBuilder::new()
.heading(1, "Title")
.unordered_list(&["Item 1", "Item 2", "Item 3"])
.blank_line()
.paragraph("Some text after.")
.build();
let violations = assert_violation_count(MD032, &content, 1);
assert_violation_contains_message(&violations, "preceded by a blank line");
}
#[test]
fn test_md032_missing_blank_after() {
let content = MarkdownBuilder::new()
.heading(1, "Title")
.blank_line()
.unordered_list(&["Item 1", "Item 2", "Item 3"])
.paragraph("Some text after.")
.build();
assert_no_violations(MD032, &content);
}
#[test]
fn test_md032_missing_both_blanks() {
let content = MarkdownBuilder::new()
.heading(1, "Title")
.unordered_list(&["Item 1", "Item 2", "Item 3"])
.paragraph("Some text after.")
.build();
let violations = assert_violation_count(MD032, &content, 1);
assert_violation_contains_message(&violations, "preceded by a blank line");
}
#[test]
fn test_md032_start_of_document() {
let content = MarkdownBuilder::new()
.unordered_list(&["Item 1", "Item 2", "Item 3"])
.blank_line()
.paragraph("Some text after.")
.build();
assert_no_violations(MD032, &content);
}
#[test]
fn test_md032_end_of_document() {
let content = MarkdownBuilder::new()
.heading(1, "Title")
.blank_line()
.unordered_list(&["Item 1", "Item 2", "Item 3"])
.build();
assert_no_violations(MD032, &content);
}
#[test]
fn test_md032_nested_lists_ignored() {
let content = r#"# Title
- Item 1
- Nested item 1
- Nested item 2
- Item 2
- Item 3
Some text after.
"#;
assert_no_violations(MD032, content);
}
#[test]
fn test_md032_multiple_lists() {
let content = MarkdownBuilder::new()
.heading(1, "Title")
.blank_line()
.unordered_list(&["First list item 1", "First list item 2"])
.blank_line()
.paragraph("Some text in between.")
.blank_line()
.ordered_list(&["Second list item 1", "Second list item 2"])
.blank_line()
.paragraph("End.")
.build();
assert_no_violations(MD032, &content);
}
#[test]
fn test_md032_mixed_list_types() {
let content = r#"# Title
- Unordered item
* Different marker
+ Another marker
Some text.
1. Ordered item
2. Another ordered item
End.
"#;
assert_no_violations(MD032, content);
}
#[test]
fn test_md032_list_with_multiline_items() {
let content = r#"# Title
- Item 1 with a very long line that wraps
to multiple lines
- Item 2 which also has
multiple lines of content
- Item 3
Some text after.
"#;
assert_no_violations(MD032, content);
}
#[test]
fn test_md032_numbered_list_variations() {
let content = MarkdownBuilder::new()
.heading(1, "Title")
.blank_line()
.ordered_list(&["Item one", "Item two", "Item three"])
.blank_line()
.paragraph("Text between.")
.blank_line()
.line("1) Parenthesis style")
.line("2) Another item")
.line("3) Third item")
.blank_line()
.paragraph("End.")
.build();
assert_no_violations(MD032, &content);
}
#[test]
fn test_md032_markdown_parsing_behavior() {
let content = "# Title\n\n- Item 1\n- Item 2\n- Item 3\nText immediately after.";
assert_no_violations(MD032, content);
}
}