use mdbook_lint_core::error::Result;
use mdbook_lint_core::rule::{Rule, RuleCategory, RuleMetadata};
use mdbook_lint_core::{
Document,
violation::{Fix, Position, Severity, Violation},
};
pub struct MD018;
impl Rule for MD018 {
fn id(&self) -> &'static str {
"MD018"
}
fn name(&self) -> &'static str {
"no-missing-space-atx"
}
fn description(&self) -> &'static str {
"No space after hash on atx style heading"
}
fn metadata(&self) -> RuleMetadata {
RuleMetadata::stable(RuleCategory::Structure).introduced_in("markdownlint v0.1.0")
}
fn can_fix(&self) -> bool {
true
}
fn check_with_ast<'a>(
&self,
document: &Document,
_ast: Option<&'a comrak::nodes::AstNode<'a>>,
) -> Result<Vec<Violation>> {
let mut violations = Vec::new();
let mut in_fenced_code_block = false;
for (line_number, line) in document.lines.iter().enumerate() {
let line_num = line_number + 1; let trimmed = line.trim_start();
if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
in_fenced_code_block = !in_fenced_code_block;
continue;
}
if in_fenced_code_block {
continue;
}
if trimmed.starts_with('#') && !trimmed.starts_with("#!") {
let hash_count = trimmed.chars().take_while(|&c| c == '#').count();
if trimmed.len() > hash_count {
let after_hashes = &trimmed[hash_count..];
if !after_hashes.is_empty() && !after_hashes.starts_with(' ') {
let column = line.len() - line.trim_start().len() + hash_count + 1;
let indent = &line[..line.len() - trimmed.len()];
let hashes = &trimmed[..hash_count];
let fixed_line = format!("{}{} {}\n", indent, hashes, after_hashes.trim());
let fix = Fix {
description: "Add space after hash on atx style heading".to_string(),
replacement: Some(fixed_line),
start: Position {
line: line_num,
column: 1,
},
end: Position {
line: line_num,
column: line.len() + 1,
},
};
violations.push(self.create_violation_with_fix(
"No space after hash on atx style heading".to_string(),
line_num,
column,
Severity::Warning,
fix,
));
}
}
}
}
Ok(violations)
}
}
#[cfg(test)]
mod tests {
use super::*;
use mdbook_lint_core::rule::Rule;
use std::path::PathBuf;
fn create_test_document(content: &str) -> Document {
Document::new(content.to_string(), PathBuf::from("test.md")).unwrap()
}
#[test]
fn test_md018_valid_headings() {
let content = "# Heading 1\n## Heading 2\n### Heading 3";
let document = create_test_document(content);
let rule = MD018;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 0);
}
#[test]
fn test_md018_no_space_after_hash() {
let content = "#Heading without space";
let document = create_test_document(content);
let rule = MD018;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 1);
assert_eq!(violations[0].rule_id, "MD018");
assert_eq!(violations[0].line, 1);
assert_eq!(violations[0].column, 2);
assert!(violations[0].message.contains("No space after hash"));
}
#[test]
fn test_md018_multiple_violations() {
let content = "#Heading 1\n##Heading 2\n### Valid heading\n####Another violation";
let document = create_test_document(content);
let rule = MD018;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 3);
assert_eq!(violations[0].line, 1);
assert_eq!(violations[1].line, 2);
assert_eq!(violations[2].line, 4);
}
#[test]
fn test_md018_indented_heading() {
let content = " #Indented heading without space";
let document = create_test_document(content);
let rule = MD018;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 1);
assert_eq!(violations[0].column, 4); }
#[test]
fn test_md018_empty_heading() {
let content = "#\n##\n###";
let document = create_test_document(content);
let rule = MD018;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 0);
}
#[test]
fn test_md018_closed_atx_style() {
let content = "#Heading#\n##Another#Heading##";
let document = create_test_document(content);
let rule = MD018;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 2);
assert_eq!(violations[0].line, 1);
assert_eq!(violations[1].line, 2);
}
#[test]
fn test_md018_setext_headings_ignored() {
let content = "Setext Heading\n==============\n\nAnother Setext\n--------------";
let document = create_test_document(content);
let rule = MD018;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 0);
}
#[test]
fn test_md018_mixed_valid_invalid() {
let content = "# Valid heading\n#Invalid heading\n## Another valid\n###Invalid again";
let document = create_test_document(content);
let rule = MD018;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 2);
assert_eq!(violations[0].line, 2);
assert_eq!(violations[1].line, 4);
}
#[test]
fn test_md018_code_blocks_ignored() {
let content = r#"# Valid Heading
```rust
#[no_mangle]
pub extern "C" fn call_from_c() {
println!("Just called a Rust function from C!");
}
#[unsafe(no_mangle)]
fn another_function() {}
```
## Another Valid Heading
~~~python
# This is a comment in Python
def foo():
pass
~~~
#This should be flagged
"#;
let document = create_test_document(content);
let rule = MD018;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 1);
assert!(violations[0].message.contains("No space after hash"));
assert_eq!(violations[0].line, 21);
}
#[test]
fn test_md018_shebang_lines_ignored() {
let content = "#!/bin/bash\n#This should trigger\n#!/usr/bin/env python3\n# This is valid";
let document = create_test_document(content);
let rule = MD018;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 1);
assert_eq!(violations[0].line, 2);
assert!(violations[0].message.contains("No space after hash"));
}
#[test]
fn test_md018_fix_single_hash() {
let content = "#Heading";
let document = create_test_document(content);
let rule = MD018;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 1);
assert!(violations[0].fix.is_some());
let fix = violations[0].fix.as_ref().unwrap();
assert_eq!(fix.replacement.as_ref().unwrap(), "# Heading\n");
assert_eq!(fix.description, "Add space after hash on atx style heading");
}
#[test]
fn test_md018_fix_multiple_hashes() {
let content = "##Heading Two\n###Heading Three";
let document = create_test_document(content);
let rule = MD018;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 2);
let fix1 = violations[0].fix.as_ref().unwrap();
assert_eq!(fix1.replacement.as_ref().unwrap(), "## Heading Two\n");
let fix2 = violations[1].fix.as_ref().unwrap();
assert_eq!(fix2.replacement.as_ref().unwrap(), "### Heading Three\n");
}
#[test]
fn test_md018_fix_preserves_indentation() {
let content = " ##Indented Heading";
let document = create_test_document(content);
let rule = MD018;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 1);
let fix = violations[0].fix.as_ref().unwrap();
assert_eq!(fix.replacement.as_ref().unwrap(), " ## Indented Heading\n");
}
#[test]
fn test_md018_fix_closed_atx() {
let content = "##Closed Heading##";
let document = create_test_document(content);
let rule = MD018;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 1);
let fix = violations[0].fix.as_ref().unwrap();
assert_eq!(fix.replacement.as_ref().unwrap(), "## Closed Heading##\n");
}
#[test]
fn test_md018_fix_position_accuracy() {
let content = "###No Space";
let document = create_test_document(content);
let rule = MD018;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 1);
let fix = violations[0].fix.as_ref().unwrap();
assert_eq!(fix.start.line, 1);
assert_eq!(fix.start.column, 1);
assert_eq!(fix.end.line, 1);
assert_eq!(fix.end.column, content.len() + 1);
}
#[test]
fn test_md018_fix_with_trailing_whitespace() {
let content = "#Heading with spaces ";
let document = create_test_document(content);
let rule = MD018;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 1);
let fix = violations[0].fix.as_ref().unwrap();
assert_eq!(fix.replacement.as_ref().unwrap(), "# Heading with spaces\n");
}
#[test]
fn test_md018_fix_all_heading_levels() {
let content = "#H1\n##H2\n###H3\n####H4\n#####H5\n######H6";
let document = create_test_document(content);
let rule = MD018;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 6);
assert_eq!(
violations[0]
.fix
.as_ref()
.unwrap()
.replacement
.as_ref()
.unwrap(),
"# H1\n"
);
assert_eq!(
violations[1]
.fix
.as_ref()
.unwrap()
.replacement
.as_ref()
.unwrap(),
"## H2\n"
);
assert_eq!(
violations[2]
.fix
.as_ref()
.unwrap()
.replacement
.as_ref()
.unwrap(),
"### H3\n"
);
assert_eq!(
violations[3]
.fix
.as_ref()
.unwrap()
.replacement
.as_ref()
.unwrap(),
"#### H4\n"
);
assert_eq!(
violations[4]
.fix
.as_ref()
.unwrap()
.replacement
.as_ref()
.unwrap(),
"##### H5\n"
);
assert_eq!(
violations[5]
.fix
.as_ref()
.unwrap()
.replacement
.as_ref()
.unwrap(),
"###### H6\n"
);
}
}