use crate::error::Result;
use crate::rule::{AstRule, RuleCategory, RuleMetadata};
use crate::{
Document,
violation::{Severity, Violation},
};
use comrak::nodes::{AstNode, NodeValue};
pub struct MD031;
impl AstRule for MD031 {
fn id(&self) -> &'static str {
"MD031"
}
fn name(&self) -> &'static str {
"blanks-around-fences"
}
fn description(&self) -> &'static str {
"Fenced code blocks should be surrounded by blank lines"
}
fn metadata(&self) -> RuleMetadata {
RuleMetadata::stable(RuleCategory::Formatting).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();
let code_blocks = document.code_blocks(ast);
for code_block in code_blocks {
if let NodeValue::CodeBlock(code_block_data) = &code_block.data.borrow().value {
if code_block_data.fenced {
if let Some((line, column)) = document.node_position(code_block) {
if !self.has_blank_line_before(document, line) {
violations.push(self.create_violation(
"Fenced code block should be preceded by a blank line".to_string(),
line,
column,
Severity::Warning,
));
}
let end_line = self.find_code_block_end_line(document, line);
if !self.has_blank_line_after(document, end_line) {
violations.push(self.create_violation(
"Fenced code block should be followed by a blank line".to_string(),
end_line,
1,
Severity::Warning,
));
}
}
}
}
}
Ok(violations)
}
}
impl MD031 {
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_code_block_end_line(&self, document: &Document, start_line: usize) -> usize {
let start_idx = start_line - 1;
if let Some(start_line_content) = document.lines.get(start_idx) {
let trimmed = start_line_content.trim_start();
if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
let fence_chars = if trimmed.starts_with("```") {
"```"
} else {
"~~~"
};
let fence_length = trimmed
.chars()
.take_while(|&c| c == fence_chars.chars().next().unwrap())
.count();
for (idx, line) in document.lines.iter().enumerate().skip(start_idx + 1) {
let line_trimmed = line.trim();
if line_trimmed.starts_with(fence_chars) {
let closing_fence_length = line_trimmed
.chars()
.take_while(|&c| c == fence_chars.chars().next().unwrap())
.count();
if closing_fence_length >= fence_length
&& line_trimmed.len() == closing_fence_length
{
return idx + 1; }
}
}
}
}
start_line
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::rule::Rule;
use std::path::PathBuf;
#[test]
fn test_md031_valid_fenced_blocks() {
let content = r#"# Title
```rust
fn main() {
println!("Hello, world!");
}
```
Some text after.
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD031;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 0);
}
#[test]
fn test_md031_missing_blank_before() {
let content = r#"# Title
```rust
fn main() {
println!("Hello, world!");
}
```
Some text after.
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD031;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 1);
assert_eq!(violations[0].rule_id, "MD031");
assert!(violations[0].message.contains("preceded by a blank line"));
assert_eq!(violations[0].line, 2);
assert_eq!(violations[0].severity, Severity::Warning);
}
#[test]
fn test_md031_missing_blank_after() {
let content = r#"# Title
```rust
fn main() {
println!("Hello, world!");
}
```
Some text after.
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD031;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 1);
assert_eq!(violations[0].rule_id, "MD031");
assert!(violations[0].message.contains("followed by a blank line"));
assert_eq!(violations[0].severity, Severity::Warning);
}
#[test]
fn test_md031_missing_both_blanks() {
let content = r#"# Title
```rust
fn main() {
println!("Hello, world!");
}
```
Some text after.
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD031;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 2);
assert!(violations[0].message.contains("preceded by a blank line"));
assert!(violations[1].message.contains("followed by a blank line"));
}
#[test]
fn test_md031_start_of_document() {
let content = r#"```rust
fn main() {
println!("Hello, world!");
}
```
Some text after.
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD031;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 0);
}
#[test]
fn test_md031_end_of_document() {
let content = r#"# Title
```rust
fn main() {
println!("Hello, world!");
}
```"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD031;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 0);
}
#[test]
fn test_md031_multiple_code_blocks() {
let content = r#"# Title
```rust
fn main() {
println!("Hello, world!");
}
```
Some text.
```bash
echo "test"
```
End.
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD031;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 2);
assert!(violations[0].message.contains("followed by a blank line"));
assert!(violations[1].message.contains("preceded by a blank line"));
}
#[test]
fn test_md031_tildes_fenced_blocks() {
let content = r#"# Title
~~~rust
fn main() {
println!("Hello, world!");
}
~~~
Some text after.
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD031;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 0);
}
#[test]
fn test_md031_indented_code_blocks_ignored() {
let content = r#"# Title
Here is some code:
def hello():
print("Hello, world!")
Some text after.
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD031;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 0);
}
#[test]
fn test_md031_different_fence_lengths() {
let content = r#"# Title
````rust
fn main() {
println!("```");
}
````
Some text after.
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD031;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 0);
}
}