use comrak::nodes::{AstNode, NodeValue};
use mdbook_lint_core::Document;
use mdbook_lint_core::error::Result;
use mdbook_lint_core::rule::{RuleCategory, RuleMetadata};
use mdbook_lint_core::violation::{Fix, Position, Severity, Violation};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Md003Config {
pub style: String,
}
impl Default for Md003Config {
fn default() -> Self {
Self {
style: "consistent".to_string(),
}
}
}
pub struct MD003 {
config: Md003Config,
}
impl MD003 {
pub fn new() -> Self {
Self {
config: Md003Config::default(),
}
}
#[allow(dead_code)]
pub fn with_config(config: Md003Config) -> Self {
Self { config }
}
pub fn from_config(config: &toml::Value) -> Self {
let mut rule_config = Md003Config::default();
if let Some(style) = config.get("style").and_then(|v| v.as_str()) {
rule_config.style = style.to_string();
}
Self {
config: rule_config,
}
}
}
impl Default for MD003 {
fn default() -> Self {
Self::new()
}
}
impl mdbook_lint_core::rule::AstRule for MD003 {
fn id(&self) -> &'static str {
"MD003"
}
fn name(&self) -> &'static str {
"heading-style"
}
fn description(&self) -> &'static str {
"Heading style should be consistent throughout the document"
}
fn metadata(&self) -> RuleMetadata {
RuleMetadata::stable(RuleCategory::Structure).introduced_in("markdownlint v0.1.0")
}
fn can_fix(&self) -> bool {
true
}
fn check_ast<'a>(&self, document: &Document, ast: &'a AstNode<'a>) -> Result<Vec<Violation>> {
let mut violations = Vec::new();
let mut headings = Vec::new();
self.collect_headings(ast, document, &mut headings);
if headings.is_empty() {
return Ok(violations);
}
let expected_style = self.determine_expected_style(&headings);
for heading in &headings {
if !self.is_valid_style(&heading.style, &expected_style, heading.level) {
let fix = self.create_heading_fix(document, heading, &expected_style);
violations.push(self.create_violation_with_fix(
format!(
"Expected '{}' style heading but found '{}' style",
expected_style, heading.style
),
heading.line,
heading.column,
Severity::Error,
fix,
));
}
}
Ok(violations)
}
}
impl MD003 {
fn collect_headings<'a>(
&self,
node: &'a AstNode<'a>,
document: &Document,
headings: &mut Vec<HeadingInfo>,
) {
if let NodeValue::Heading(heading_data) = &node.data.borrow().value {
let position = node.data.borrow().sourcepos;
let style = self.determine_heading_style(node, document, position.start.line);
headings.push(HeadingInfo {
level: heading_data.level,
style,
line: position.start.line,
column: position.start.column,
});
}
for child in node.children() {
self.collect_headings(child, document, headings);
}
}
fn determine_heading_style(
&self,
_node: &AstNode,
document: &Document,
line_number: usize,
) -> HeadingStyle {
let line_index = line_number.saturating_sub(1);
if line_index >= document.lines.len() {
return HeadingStyle::Atx;
}
let line = &document.lines[line_index];
let trimmed = line.trim();
if trimmed.starts_with('#') {
if trimmed.ends_with('#') && trimmed.len() > 1 {
let content = trimmed.trim_start_matches('#').trim_end_matches('#').trim();
if !content.is_empty() {
return HeadingStyle::AtxClosed;
}
}
return HeadingStyle::Atx;
}
if line_index + 1 < document.lines.len() {
let next_line = &document.lines[line_index + 1];
let next_trimmed = next_line.trim();
if !next_trimmed.is_empty() {
let first_char = next_trimmed.chars().next().unwrap();
if (first_char == '=' || first_char == '-')
&& next_trimmed.chars().all(|c| c == first_char)
{
return HeadingStyle::Setext;
}
}
}
HeadingStyle::Atx
}
fn determine_expected_style(&self, headings: &[HeadingInfo]) -> HeadingStyle {
match self.config.style.as_str() {
"atx" => HeadingStyle::Atx,
"atx_closed" => HeadingStyle::AtxClosed,
"setext" => HeadingStyle::Setext,
"setext_with_atx" => HeadingStyle::SetextWithAtx,
"consistent" => {
headings
.first()
.map(|h| h.style.clone())
.unwrap_or(HeadingStyle::Atx)
}
_ => {
headings
.first()
.map(|h| h.style.clone())
.unwrap_or(HeadingStyle::Atx)
}
}
}
fn is_valid_style(&self, actual: &HeadingStyle, expected: &HeadingStyle, level: u8) -> bool {
match expected {
HeadingStyle::SetextWithAtx => {
if level <= 2 {
matches!(actual, HeadingStyle::Setext)
} else {
matches!(actual, HeadingStyle::Atx)
}
}
_ => actual == expected,
}
}
fn create_heading_fix(
&self,
document: &Document,
heading: &HeadingInfo,
expected_style: &HeadingStyle,
) -> Fix {
let line_idx = heading.line.saturating_sub(1);
let heading_text = self.extract_heading_text(document, heading);
let replacement = match expected_style {
HeadingStyle::Atx => {
format!("{} {}\n", "#".repeat(heading.level as usize), heading_text)
}
HeadingStyle::AtxClosed => {
format!(
"{} {} {}\n",
"#".repeat(heading.level as usize),
heading_text,
"#".repeat(heading.level as usize)
)
}
HeadingStyle::Setext => {
if heading.level <= 2 {
let underline = if heading.level == 1 { "=" } else { "-" };
format!(
"{}\n{}\n",
heading_text,
underline.repeat(heading_text.len())
)
} else {
format!("{} {}\n", "#".repeat(heading.level as usize), heading_text)
}
}
HeadingStyle::SetextWithAtx => {
if heading.level <= 2 {
let underline = if heading.level == 1 { "=" } else { "-" };
format!(
"{}\n{}\n",
heading_text,
underline.repeat(heading_text.len())
)
} else {
format!("{} {}\n", "#".repeat(heading.level as usize), heading_text)
}
}
};
let (start_line, end_line) =
if heading.style == HeadingStyle::Setext && line_idx + 1 < document.lines.len() {
(heading.line, heading.line + 1)
} else {
(heading.line, heading.line)
};
Fix {
description: format!("Convert to {} style", expected_style),
replacement: Some(replacement),
start: Position {
line: start_line,
column: 1,
},
end: Position {
line: end_line,
column: if end_line > start_line && end_line <= document.lines.len() {
document.lines[end_line - 1].len() + 1
} else {
document.lines[line_idx].len() + 1
},
},
}
}
fn extract_heading_text(&self, document: &Document, heading: &HeadingInfo) -> String {
let line_idx = heading.line.saturating_sub(1);
if line_idx >= document.lines.len() {
return String::new();
}
let line = &document.lines[line_idx];
let trimmed = line.trim();
match heading.style {
HeadingStyle::Atx => {
trimmed.trim_start_matches('#').trim().to_string()
}
HeadingStyle::AtxClosed => {
trimmed
.trim_start_matches('#')
.trim_end_matches('#')
.trim()
.to_string()
}
HeadingStyle::Setext => {
trimmed.to_string()
}
HeadingStyle::SetextWithAtx => {
trimmed.to_string()
}
}
}
}
#[derive(Debug, Clone)]
struct HeadingInfo {
level: u8,
style: HeadingStyle,
line: usize,
column: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum HeadingStyle {
Atx,
AtxClosed,
Setext,
SetextWithAtx,
}
impl std::fmt::Display for HeadingStyle {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
HeadingStyle::Atx => write!(f, "atx"),
HeadingStyle::AtxClosed => write!(f, "atx_closed"),
HeadingStyle::Setext => write!(f, "setext"),
HeadingStyle::SetextWithAtx => write!(f, "setext_with_atx"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use mdbook_lint_core::Document;
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_md003_consistent_atx_style() {
let content = r#"# Main Title
## Section A
### Subsection 1
## Section B
### Subsection 2
"#;
let doc = create_test_document(content);
let rule = MD003::new();
let violations = rule.check(&doc).unwrap();
assert_eq!(
violations.len(),
0,
"Consistent ATX style should not trigger violations"
);
}
#[test]
fn test_md003_consistent_atx_closed_style() {
let content = r#"# Main Title #
## Section A ##
### Subsection 1 ###
## Section B ##
"#;
let doc = create_test_document(content);
let rule = MD003::new();
let violations = rule.check(&doc).unwrap();
assert_eq!(
violations.len(),
0,
"Consistent ATX closed style should not trigger violations"
);
}
#[test]
fn test_md003_consistent_setext_style() {
let content = r#"Main Title
==========
Section A
---------
Section B
---------
"#;
let doc = create_test_document(content);
let rule = MD003::new();
let violations = rule.check(&doc).unwrap();
assert_eq!(
violations.len(),
0,
"Consistent Setext style should not trigger violations"
);
}
#[test]
fn test_md003_mixed_styles_violation() {
let content = r#"# Main Title
Section A
---------
## Section B
"#;
let doc = create_test_document(content);
let rule = MD003::new();
let violations = rule.check(&doc).unwrap();
assert!(
!violations.is_empty(),
"Mixed heading styles should trigger violations"
);
let violation_messages: Vec<&str> = violations.iter().map(|v| v.message.as_str()).collect();
assert!(
violation_messages
.iter()
.any(|msg| msg.contains("Expected 'atx' style"))
);
}
#[test]
fn test_md003_atx_and_atx_closed_mixed() {
let content = r#"# Main Title
## Section A ##
### Subsection 1
## Section B ##
"#;
let doc = create_test_document(content);
let rule = MD003::new();
let violations = rule.check(&doc).unwrap();
assert!(
!violations.is_empty(),
"Mixed ATX and ATX closed styles should trigger violations"
);
}
#[test]
fn test_md003_configured_atx_style() {
let content = r#"Main Title
==========
Section A
---------
"#;
let doc = create_test_document(content);
let config = Md003Config {
style: "atx".to_string(),
};
let rule = MD003::with_config(config);
let violations = rule.check(&doc).unwrap();
assert!(
!violations.is_empty(),
"Setext headings should violate when ATX is required"
);
}
#[test]
fn test_md003_configured_setext_style() {
let content = r#"# Main Title
## Section A
"#;
let doc = create_test_document(content);
let config = Md003Config {
style: "setext".to_string(),
};
let rule = MD003::with_config(config);
let violations = rule.check(&doc).unwrap();
assert!(
!violations.is_empty(),
"ATX headings should violate when Setext is required"
);
}
#[test]
fn test_md003_setext_with_atx_valid() {
let content = r#"Main Title
==========
Section A
---------
### Subsection 1
#### Deep Section
"#;
let doc = create_test_document(content);
let config = Md003Config {
style: "setext_with_atx".to_string(),
};
let rule = MD003::with_config(config);
let violations = rule.check(&doc).unwrap();
assert_eq!(
violations.len(),
0,
"Setext for levels 1-2 and ATX for 3+ should be valid"
);
}
#[test]
fn test_md003_setext_with_atx_violation() {
let content = r#"# Main Title
Section A
---------
### Subsection 1
"#;
let doc = create_test_document(content);
let config = Md003Config {
style: "setext_with_atx".to_string(),
};
let rule = MD003::with_config(config);
let violations = rule.check(&doc).unwrap();
assert!(
!violations.is_empty(),
"ATX level 1 should violate setext_with_atx style"
);
}
#[test]
fn test_md003_no_headings() {
let content = r#"This is a document with no headings.
Just some regular text content.
"#;
let doc = create_test_document(content);
let rule = MD003::new();
let violations = rule.check(&doc).unwrap();
assert_eq!(
violations.len(),
0,
"Documents with no headings should not trigger violations"
);
}
#[test]
fn test_md003_single_heading() {
let content = r#"# Only One Heading
Some content here.
"#;
let doc = create_test_document(content);
let rule = MD003::new();
let violations = rule.check(&doc).unwrap();
assert_eq!(
violations.len(),
0,
"Documents with single heading should not trigger violations"
);
}
#[test]
fn test_md003_fix_atx_to_setext() {
let content = r#"# Main Title
## Section A
### Subsection
"#;
let doc = create_test_document(content);
let config = Md003Config {
style: "setext".to_string(),
};
let rule = MD003::with_config(config);
let violations = rule.check(&doc).unwrap();
assert_eq!(violations.len(), 3);
assert!(violations[0].fix.is_some());
let fix1 = violations[0].fix.as_ref().unwrap();
assert_eq!(fix1.description, "Convert to setext style");
assert_eq!(
fix1.replacement,
Some("Main Title\n==========\n".to_string())
);
assert!(violations[1].fix.is_some());
let fix2 = violations[1].fix.as_ref().unwrap();
assert_eq!(fix2.replacement, Some("Section A\n---------\n".to_string()));
assert!(violations[2].fix.is_some());
let fix3 = violations[2].fix.as_ref().unwrap();
assert_eq!(fix3.replacement, Some("### Subsection\n".to_string()));
}
#[test]
fn test_md003_fix_setext_to_atx() {
let content = r#"Main Title
==========
Section A
---------
"#;
let doc = create_test_document(content);
let config = Md003Config {
style: "atx".to_string(),
};
let rule = MD003::with_config(config);
let violations = rule.check(&doc).unwrap();
assert_eq!(violations.len(), 2);
assert!(violations[0].fix.is_some());
let fix1 = violations[0].fix.as_ref().unwrap();
assert_eq!(fix1.description, "Convert to atx style");
assert_eq!(fix1.replacement, Some("# Main Title\n".to_string()));
assert_eq!(fix1.start.line, 1);
assert_eq!(fix1.end.line, 2);
assert!(violations[1].fix.is_some());
let fix2 = violations[1].fix.as_ref().unwrap();
assert_eq!(fix2.replacement, Some("## Section A\n".to_string()));
assert_eq!(fix2.start.line, 4);
assert_eq!(fix2.end.line, 5);
}
#[test]
fn test_md003_fix_atx_to_atx_closed() {
let content = r#"# Main Title
## Section A
"#;
let doc = create_test_document(content);
let config = Md003Config {
style: "atx_closed".to_string(),
};
let rule = MD003::with_config(config);
let violations = rule.check(&doc).unwrap();
assert_eq!(violations.len(), 2);
assert!(violations[0].fix.is_some());
let fix1 = violations[0].fix.as_ref().unwrap();
assert_eq!(fix1.replacement, Some("# Main Title #\n".to_string()));
assert!(violations[1].fix.is_some());
let fix2 = violations[1].fix.as_ref().unwrap();
assert_eq!(fix2.replacement, Some("## Section A ##\n".to_string()));
}
#[test]
fn test_md003_fix_mixed_to_consistent() {
let content = r#"# ATX Title
Setext Section
--------------
### Another ATX
"#;
let doc = create_test_document(content);
let rule = MD003::new(); let violations = rule.check(&doc).unwrap();
assert!(!violations.is_empty());
let setext_violation = violations.iter().find(|v| v.line == 3).unwrap();
assert!(setext_violation.fix.is_some());
let fix = setext_violation.fix.as_ref().unwrap();
assert_eq!(fix.replacement, Some("## Setext Section\n".to_string()));
}
#[test]
fn test_md003_fix_setext_with_atx() {
let content = r#"# Level 1 ATX
## Level 2 ATX
### Level 3 ATX
"#;
let doc = create_test_document(content);
let config = Md003Config {
style: "setext_with_atx".to_string(),
};
let rule = MD003::with_config(config);
let violations = rule.check(&doc).unwrap();
assert_eq!(violations.len(), 2);
assert!(violations[0].fix.is_some());
let fix1 = violations[0].fix.as_ref().unwrap();
assert!(fix1.replacement.as_ref().unwrap().contains("="));
assert!(violations[1].fix.is_some());
let fix2 = violations[1].fix.as_ref().unwrap();
assert!(fix2.replacement.as_ref().unwrap().contains("-"));
}
}