use crate::error::Result;
use crate::rule::{AstRule, RuleCategory, RuleMetadata};
use crate::{
Document,
violation::{Severity, Violation},
};
use comrak::nodes::{AstNode, NodeValue};
pub struct MD043 {
headings: Vec<String>,
}
impl MD043 {
pub fn new() -> Self {
Self {
headings: Vec::new(), }
}
#[allow(dead_code)]
pub fn with_headings(headings: Vec<String>) -> Self {
Self { headings }
}
fn get_position<'a>(&self, node: &'a AstNode<'a>) -> (usize, usize) {
let data = node.data.borrow();
let pos = data.sourcepos;
(pos.start.line, pos.start.column)
}
fn extract_heading_text<'a>(&self, node: &'a AstNode<'a>) -> String {
let mut text = String::new();
Self::collect_text_content(node, &mut text);
text
}
fn collect_text_content<'a>(node: &'a AstNode<'a>, text: &mut String) {
match &node.data.borrow().value {
NodeValue::Text(t) => text.push_str(t),
NodeValue::Code(code) => text.push_str(&code.literal),
_ => {}
}
for child in node.children() {
Self::collect_text_content(child, text);
}
}
fn matches_pattern(&self, heading_text: &str, pattern: &str) -> bool {
heading_text.trim().to_lowercase() == pattern.trim().to_lowercase()
}
fn check_node<'a>(&self, node: &'a AstNode<'a>, headings: &mut Vec<(usize, String, usize)>) {
if let NodeValue::Heading(heading_data) = &node.data.borrow().value {
let (line, _) = self.get_position(node);
let text = self.extract_heading_text(node);
headings.push((line, text, heading_data.level as usize));
}
for child in node.children() {
self.check_node(child, headings);
}
}
}
impl Default for MD043 {
fn default() -> Self {
Self::new()
}
}
impl AstRule for MD043 {
fn id(&self) -> &'static str {
"MD043"
}
fn name(&self) -> &'static str {
"required-headings"
}
fn description(&self) -> &'static str {
"Required heading structure"
}
fn metadata(&self) -> RuleMetadata {
RuleMetadata::stable(RuleCategory::Structure).introduced_in("mdbook-lint v0.1.0")
}
fn check_ast<'a>(&self, _document: &Document, ast: &'a AstNode<'a>) -> Result<Vec<Violation>> {
let mut violations = Vec::new();
if self.headings.is_empty() {
return Ok(violations);
}
let mut document_headings = Vec::new();
self.check_node(ast, &mut document_headings);
if document_headings.len() < self.headings.len() {
violations.push(self.create_violation(
format!(
"Document should have at least {} headings but found {}",
self.headings.len(),
document_headings.len()
),
1,
1,
Severity::Warning,
));
return Ok(violations);
}
for (i, required_heading) in self.headings.iter().enumerate() {
if i < document_headings.len() {
let (line, actual_text, _level) = &document_headings[i];
if !self.matches_pattern(actual_text, required_heading) {
violations.push(self.create_violation(
format!("Expected heading '{required_heading}' but found '{actual_text}'"),
*line,
1,
Severity::Warning,
));
}
}
}
Ok(violations)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::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_md043_no_required_structure() {
let content = r#"# Any Heading
## Any Subheading
### Any Sub-subheading
"#;
let document = create_test_document(content);
let rule = MD043::new();
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 0); }
#[test]
fn test_md043_correct_structure() {
let content = r#"# Introduction
## Getting Started
## Configuration
"#;
let required_headings = vec![
"Introduction".to_string(),
"Getting Started".to_string(),
"Configuration".to_string(),
];
let document = create_test_document(content);
let rule = MD043::with_headings(required_headings);
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 0);
}
#[test]
fn test_md043_incorrect_heading_text() {
let content = r#"# Introduction
## Getting Started
## Setup
"#;
let required_headings = vec![
"Introduction".to_string(),
"Getting Started".to_string(),
"Configuration".to_string(),
];
let document = create_test_document(content);
let rule = MD043::with_headings(required_headings);
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 1);
assert_eq!(violations[0].rule_id, "MD043");
assert!(
violations[0]
.message
.contains("Expected heading 'Configuration' but found 'Setup'")
);
assert_eq!(violations[0].line, 5);
}
#[test]
fn test_md043_missing_headings() {
let content = r#"# Introduction
## Getting Started
"#;
let required_headings = vec![
"Introduction".to_string(),
"Getting Started".to_string(),
"Configuration".to_string(),
];
let document = create_test_document(content);
let rule = MD043::with_headings(required_headings);
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 1);
assert!(
violations[0]
.message
.contains("should have at least 3 headings but found 2")
);
}
#[test]
fn test_md043_case_insensitive_matching() {
let content = r#"# INTRODUCTION
## getting started
## Configuration
"#;
let required_headings = vec![
"Introduction".to_string(),
"Getting Started".to_string(),
"Configuration".to_string(),
];
let document = create_test_document(content);
let rule = MD043::with_headings(required_headings);
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 0); }
#[test]
fn test_md043_extra_headings_allowed() {
let content = r#"# Introduction
## Getting Started
## Configuration
## Advanced Topics
### Customization
"#;
let required_headings = vec![
"Introduction".to_string(),
"Getting Started".to_string(),
"Configuration".to_string(),
];
let document = create_test_document(content);
let rule = MD043::with_headings(required_headings);
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 0); }
#[test]
fn test_md043_first_heading_wrong() {
let content = r#"# Overview
## Getting Started
## Configuration
"#;
let required_headings = vec![
"Introduction".to_string(),
"Getting Started".to_string(),
"Configuration".to_string(),
];
let document = create_test_document(content);
let rule = MD043::with_headings(required_headings);
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 1);
assert!(
violations[0]
.message
.contains("Expected heading 'Introduction' but found 'Overview'")
);
assert_eq!(violations[0].line, 1);
}
#[test]
fn test_md043_multiple_violations() {
let content = r#"# Overview
## Setup
## Deployment
"#;
let required_headings = vec![
"Introduction".to_string(),
"Getting Started".to_string(),
"Configuration".to_string(),
];
let document = create_test_document(content);
let rule = MD043::with_headings(required_headings);
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 3); assert!(
violations[0]
.message
.contains("Expected heading 'Introduction' but found 'Overview'")
);
assert!(
violations[1]
.message
.contains("Expected heading 'Getting Started' but found 'Setup'")
);
assert!(
violations[2]
.message
.contains("Expected heading 'Configuration' but found 'Deployment'")
);
}
#[test]
fn test_md043_headings_with_formatting() {
let content = r#"# **Introduction**
## *Getting Started*
## Configuration
"#;
let required_headings = vec![
"Introduction".to_string(),
"Getting Started".to_string(),
"Configuration".to_string(),
];
let document = create_test_document(content);
let rule = MD043::with_headings(required_headings);
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 0); }
#[test]
fn test_md043_headings_with_code() {
let content = r#"# Introduction
## Getting Started with `npm`
## Configuration
"#;
let required_headings = vec![
"Introduction".to_string(),
"Getting Started with npm".to_string(),
"Configuration".to_string(),
];
let document = create_test_document(content);
let rule = MD043::with_headings(required_headings);
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 0);
}
#[test]
fn test_md043_whitespace_handling() {
let content = r#"# Introduction
## Getting Started
## Configuration
"#;
let required_headings = vec![
"Introduction".to_string(),
"Getting Started".to_string(),
"Configuration".to_string(),
];
let document = create_test_document(content);
let rule = MD043::with_headings(required_headings);
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 0); }
}