use mdbook_lint_core::error::Result;
use mdbook_lint_core::rule::{Rule, RuleCategory, RuleMetadata};
use mdbook_lint_core::{
Document,
violation::{Fix, Severity, Violation},
};
pub struct MD021;
impl Rule for MD021 {
fn id(&self) -> &'static str {
"MD021"
}
fn name(&self) -> &'static str {
"no-multiple-space-closed-atx"
}
fn description(&self) -> &'static str {
"Multiple spaces inside hashes on closed atx style heading"
}
fn metadata(&self) -> RuleMetadata {
RuleMetadata::stable(RuleCategory::Formatting).introduced_in("mdbook-lint 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();
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("#!") {
if trimmed.ends_with('#') {
let opening_hash_count = trimmed.chars().take_while(|&c| c == '#').count();
let closing_hash_count =
trimmed.chars().rev().take_while(|&c| c == '#').count();
if trimmed.len() > opening_hash_count + closing_hash_count {
let content_with_spaces =
&trimmed[opening_hash_count..trimmed.len() - closing_hash_count];
let leading_whitespace_count = content_with_spaces
.chars()
.take_while(|c| c.is_whitespace())
.count();
let trailing_whitespace_count = content_with_spaces
.chars()
.rev()
.take_while(|c| c.is_whitespace())
.count();
if leading_whitespace_count > 1 || trailing_whitespace_count > 1 {
let indent = &line[..line.len() - trimmed.len()];
let opening_hashes = &trimmed[..opening_hash_count];
let closing_hashes = &trimmed[trimmed.len() - closing_hash_count..];
let content = content_with_spaces.trim();
let fixed_line = format!(
"{}{} {} {}",
indent, opening_hashes, content, closing_hashes
);
let fix = Fix::line_replacement(
"Replace multiple spaces with single spaces inside hashes",
fixed_line,
line_num,
line,
document.line_ending(line_num),
);
if leading_whitespace_count > 1 {
violations.push(self.create_violation_with_fix(
format!("Multiple spaces after opening hashes in closed ATX heading: found {leading_whitespace_count} whitespace characters, expected 1"),
line_num,
opening_hash_count + 1,
Severity::Warning,
fix.clone(),
));
}
if trailing_whitespace_count > 1 {
violations.push(self.create_violation_with_fix(
format!("Multiple spaces before closing hashes in closed ATX heading: found {trailing_whitespace_count} whitespace characters, expected 1"),
line_num,
trimmed.len() - closing_hash_count - trailing_whitespace_count + 1,
Severity::Warning,
fix,
));
}
}
}
}
}
}
Ok(violations)
}
}
#[cfg(test)]
mod tests {
use super::*;
use mdbook_lint_core::Document;
use mdbook_lint_core::rule::Rule;
use std::path::PathBuf;
#[test]
fn test_md021_no_violations() {
let content = r#"# Open ATX heading (not checked)
## Another open heading
# Single space inside #
## Single space here ##
### Valid closed heading ###
#### Multiple words single space ####
##### Another valid closed heading #####
###### Level 6 valid ######
Regular paragraph text.
Not a heading: # this has text before it #
Also not a heading:
# this is indented #
Shebang line should be ignored:
#!/bin/bash
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD021;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 0);
}
#[test]
fn test_md021_multiple_spaces_at_beginning() {
let content = r#"# Open heading is fine
## Two spaces after opening ##
### Three spaces after opening ###
#### Four spaces after opening ####
Regular text.
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD021;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 3);
assert!(
violations[0]
.message
.contains("found 2 whitespace characters, expected 1")
);
assert!(
violations[1]
.message
.contains("found 3 whitespace characters, expected 1")
);
assert!(
violations[2]
.message
.contains("found 4 whitespace characters, expected 1")
);
assert_eq!(violations[0].line, 3);
assert_eq!(violations[1].line, 5);
assert_eq!(violations[2].line, 7);
}
#[test]
fn test_md021_multiple_spaces_at_end() {
let content = r#"# Open heading is fine
## Content with two spaces ##
### Content with three spaces ###
#### Content with four spaces ####
Regular text.
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD021;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 3);
assert!(
violations[0]
.message
.contains("found 2 whitespace characters, expected 1")
);
assert!(
violations[1]
.message
.contains("found 3 whitespace characters, expected 1")
);
assert!(
violations[2]
.message
.contains("found 4 whitespace characters, expected 1")
);
assert_eq!(violations[0].line, 3);
assert_eq!(violations[1].line, 5);
assert_eq!(violations[2].line, 7);
}
#[test]
fn test_md021_multiple_spaces_both_sides() {
let content = r#"# Open heading is fine
## Two spaces both sides ##
### Three spaces both sides ###
#### Four spaces both sides ####
Regular text.
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD021;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 6);
assert_eq!(violations[0].line, 3); assert_eq!(violations[1].line, 3); assert_eq!(violations[2].line, 5); assert_eq!(violations[3].line, 5); assert_eq!(violations[4].line, 7); assert_eq!(violations[5].line, 7); }
#[test]
fn test_md021_mixed_valid_invalid() {
let content = r#"# Valid closed heading #
## Invalid: two spaces after ##
### Valid closed heading ###
#### Invalid: two spaces both sides ####
##### Valid closed heading #####
###### Invalid: three spaces after ######
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD021;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 4);
assert_eq!(violations[0].line, 3); assert_eq!(violations[1].line, 7); assert_eq!(violations[2].line, 7); assert_eq!(violations[3].line, 11); }
#[test]
fn test_md021_tabs_and_mixed_whitespace() {
let content = "#\t\tTwo tabs after opening##\n\n##Content with tab at end\t\t##\n\n###\t Content tab space mix \t###\n";
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD021;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 4);
}
#[test]
fn test_md021_empty_closed_heading() {
let content = r#"# Valid open heading
## ##
### ###
#### ####
Regular text.
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD021;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 0);
}
#[test]
fn test_md021_no_space_inside() {
let content = r#"# Valid open heading
##No space inside##
###Content###
####Text####
Regular text.
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD021;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 0);
}
#[test]
fn test_md021_indented_headings() {
let content = r#"# Valid open heading
## Indented with multiple spaces ##
Regular text.
### Another indented with multiple spaces ###
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD021;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 4);
assert_eq!(violations[0].line, 3); assert_eq!(violations[1].line, 3); assert_eq!(violations[2].line, 7); assert_eq!(violations[3].line, 7); }
#[test]
fn test_md021_asymmetric_hashes() {
let content = r#"# Open heading with one hash
## Content with multiple spaces ####
### More content #####
#### Even more ######
Regular text.
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD021;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 6);
}
#[test]
fn test_md021_all_heading_levels() {
let content = r#"# Content with multiple spaces #
## Content with multiple spaces ##
### Content with multiple spaces ###
#### Content with multiple spaces ####
##### Content with multiple spaces #####
###### Content with multiple spaces ######
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD021;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 12);
for (i, violation) in violations.iter().enumerate() {
let line_num = (i / 2) + 1; assert_eq!(violation.line, line_num);
assert!(
violation
.message
.contains("found 2 whitespace characters, expected 1")
);
}
}
#[test]
fn test_md021_single_space_valid() {
let content = r#"# Content with single space #
## Content with single space ##
### Content with single space ###
#### Content with single space ####
##### Content with single space #####
###### Content with single space ######
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD021;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 0);
}
#[test]
fn test_md021_fix_multiple_spaces_at_beginning() {
let content = "## Multiple spaces at beginning ##\n";
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD021;
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.description,
"Replace multiple spaces with single spaces inside hashes"
);
assert_eq!(
fix.replacement,
Some("## Multiple spaces at beginning ##\n".to_string())
);
assert_eq!(fix.start.line, 1);
assert_eq!(fix.start.column, 1);
}
#[test]
fn test_md021_fix_multiple_spaces_at_end() {
let content = "## Content with multiple spaces ##\n";
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD021;
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,
Some("## Content with multiple spaces ##\n".to_string())
);
}
#[test]
fn test_md021_fix_multiple_spaces_both_sides() {
let content = "## Multiple spaces on both sides ##\n";
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD021;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 2);
assert!(violations[0].fix.is_some());
assert!(violations[1].fix.is_some());
let fix = violations[0].fix.as_ref().unwrap();
assert_eq!(
fix.replacement,
Some("## Multiple spaces on both sides ##\n".to_string())
);
}
#[test]
fn test_md021_fix_many_spaces() {
let content = "# Five spaces at beginning #\n";
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD021;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 2);
assert!(violations[0].fix.is_some());
let fix = violations[0].fix.as_ref().unwrap();
assert_eq!(
fix.replacement,
Some("# Five spaces at beginning #\n".to_string())
);
}
#[test]
fn test_md021_fix_tabs() {
let content = "##\t\tTabs at beginning\t\t##\n";
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD021;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 2);
assert!(violations[0].fix.is_some());
let fix = violations[0].fix.as_ref().unwrap();
assert_eq!(
fix.replacement,
Some("## Tabs at beginning ##\n".to_string())
);
}
#[test]
fn test_md021_fix_mixed_whitespace() {
let content = "### \t Mixed spaces and tabs \t ###\n";
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD021;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 2);
assert!(violations[0].fix.is_some());
let fix = violations[0].fix.as_ref().unwrap();
assert_eq!(
fix.replacement,
Some("### Mixed spaces and tabs ###\n".to_string())
);
}
#[test]
fn test_md021_fix_preserves_indentation() {
let content = " ## Indented with multiple spaces ##\n";
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD021;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 2);
assert!(violations[0].fix.is_some());
let fix = violations[0].fix.as_ref().unwrap();
assert_eq!(
fix.replacement,
Some(" ## Indented with multiple spaces ##\n".to_string())
);
}
#[test]
fn test_md021_fix_all_levels() {
let content = r#"# Level 1 #
## Level 2 ##
### Level 3 ###
#### Level 4 ####
##### Level 5 #####
###### Level 6 ######"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD021;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 12);
let fix = violations[0].fix.as_ref().unwrap();
assert_eq!(fix.replacement, Some("# Level 1 #\n".to_string()));
}
#[test]
fn test_md021_fix_asymmetric_hashes() {
let content = "### Content ####\n";
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD021;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 2);
assert!(violations[0].fix.is_some());
let fix = violations[0].fix.as_ref().unwrap();
assert_eq!(fix.replacement, Some("### Content ####\n".to_string()));
}
}