use mdbook_lint_core::Document;
use mdbook_lint_core::rule::{Rule, RuleCategory, RuleMetadata};
use mdbook_lint_core::violation::{Severity, Violation};
use regex::Regex;
use std::sync::LazyLock;
static HEADING_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^(#{1,6})\s+(.+?)(?:\s*#*)?$").unwrap());
const TITLE_CASE_EXCEPTIONS: &[&str] = &[
"a", "an", "the", "and", "but", "or", "nor", "for", "yet", "so", "at", "by", "in", "of", "on",
"to", "up", "as", "if", "is", "it", "vs", "via", "with",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum CapitalizationStyle {
TitleCase,
SentenceCase,
#[default]
Consistent,
}
#[derive(Clone, Default)]
pub struct CONTENT004 {
style: CapitalizationStyle,
}
impl CONTENT004 {
#[allow(dead_code)]
pub fn with_style(style: CapitalizationStyle) -> Self {
Self { style }
}
pub fn from_config(config: &toml::Value) -> Self {
let mut rule = Self::default();
if let Some(style) = config.get("style").and_then(|v| v.as_str()) {
rule.style = match style.to_lowercase().replace(['-', ' '], "_").as_str() {
"title" | "title_case" => CapitalizationStyle::TitleCase,
"sentence" | "sentence_case" => CapitalizationStyle::SentenceCase,
"consistent" => CapitalizationStyle::Consistent,
_ => rule.style,
};
}
rule
}
fn extract_heading(&self, line: &str) -> Option<(usize, String)> {
HEADING_REGEX.captures(line).map(|caps| {
let level = caps.get(1).unwrap().as_str().len();
let text = caps.get(2).unwrap().as_str().trim().to_string();
(level, text)
})
}
fn is_acronym(&self, word: &str) -> bool {
word.len() > 1
&& word.chars().all(|c| c.is_uppercase() || !c.is_alphabetic())
&& word.chars().any(|c| c.is_alphabetic())
}
fn is_exception(&self, word: &str) -> bool {
TITLE_CASE_EXCEPTIONS.contains(&word.to_lowercase().as_str())
}
fn get_significant_words<'a>(&self, text: &'a str) -> Vec<(usize, &'a str)> {
text.split_whitespace()
.enumerate()
.filter(|(i, word)| {
if *i == 0 {
return true;
}
if self.is_acronym(word) {
return false;
}
if self.is_exception(word) {
return false;
}
if !word.chars().next().is_some_and(|c| c.is_alphabetic()) {
return false;
}
true
})
.collect()
}
fn is_title_case(&self, text: &str) -> bool {
let words = self.get_significant_words(text);
if words.is_empty() {
return true;
}
let capitalized = words
.iter()
.filter(|(_, word)| word.chars().next().is_some_and(|c| c.is_uppercase()))
.count();
capitalized as f64 / words.len() as f64 >= 0.6
}
fn is_sentence_case(&self, text: &str) -> bool {
let words: Vec<&str> = text.split_whitespace().collect();
if words.is_empty() {
return true;
}
if !words[0].chars().next().is_some_and(|c| c.is_uppercase()) {
return false;
}
let mut uppercase_non_first = 0;
let mut checkable_non_first = 0;
for word in words.iter().skip(1) {
if self.is_acronym(word) {
continue;
}
if !word.chars().next().is_some_and(|c| c.is_alphabetic()) {
continue;
}
checkable_non_first += 1;
if word.chars().next().is_some_and(|c| c.is_uppercase()) {
uppercase_non_first += 1;
}
}
checkable_non_first == 0 || uppercase_non_first as f64 / checkable_non_first as f64 <= 0.35
}
fn detect_style(&self, text: &str) -> HeadingStyle {
let is_title = self.is_title_case(text);
let is_sentence = self.is_sentence_case(text);
match (is_title, is_sentence) {
(true, true) => HeadingStyle::Ambiguous,
(true, false) => HeadingStyle::Title,
(false, _) => HeadingStyle::Sentence,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum HeadingStyle {
Title,
Sentence,
Ambiguous,
}
impl Rule for CONTENT004 {
fn id(&self) -> &'static str {
"CONTENT004"
}
fn name(&self) -> &'static str {
"heading-capitalization"
}
fn description(&self) -> &'static str {
"Headings should use consistent capitalization (Title Case or sentence case)"
}
fn metadata(&self) -> RuleMetadata {
RuleMetadata::stable(RuleCategory::Content).introduced_in("mdbook-lint v0.12.0")
}
fn check_with_ast<'a>(
&self,
document: &Document,
_ast: Option<&'a comrak::nodes::AstNode<'a>>,
) -> mdbook_lint_core::error::Result<Vec<Violation>> {
let mut violations = Vec::new();
let mut detected_style: Option<HeadingStyle> = None;
let mut in_code_block = false;
for (line_idx, line) in document.lines.iter().enumerate() {
let line_num = line_idx + 1;
let trimmed = line.trim();
if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
in_code_block = !in_code_block;
continue;
}
if in_code_block {
continue;
}
if let Some((_level, text)) = self.extract_heading(trimmed) {
if text.split_whitespace().count() < 2 {
continue;
}
match self.style {
CapitalizationStyle::Consistent => {
let heading_style = self.detect_style(&text);
if heading_style == HeadingStyle::Ambiguous {
continue;
}
if let Some(expected) = detected_style {
if heading_style != expected {
let expected_name = match expected {
HeadingStyle::Title => "Title Case",
HeadingStyle::Sentence => "sentence case",
HeadingStyle::Ambiguous => {
unreachable!("ambiguous headings never become the baseline")
}
};
violations.push(self.create_violation(
format!(
"Heading '{}' uses inconsistent capitalization (expected {})",
text, expected_name
),
line_num,
1,
Severity::Warning,
));
}
} else {
detected_style = Some(heading_style);
}
}
CapitalizationStyle::TitleCase => {
if !self.is_title_case(&text) {
violations.push(self.create_violation(
format!("Heading '{}' should use Title Case", text),
line_num,
1,
Severity::Warning,
));
}
}
CapitalizationStyle::SentenceCase => {
if !self.is_sentence_case(&text) {
violations.push(self.create_violation(
format!("Heading '{}' should use sentence case", text),
line_num,
1,
Severity::Warning,
));
}
}
}
}
}
Ok(violations)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn create_test_document(content: &str) -> Document {
Document::new(content.to_string(), PathBuf::from("test.md")).unwrap()
}
#[test]
fn test_from_config_style() {
let mk = |s: &str| {
let cfg: toml::Value = toml::from_str(&format!("style = \"{s}\"")).unwrap();
CONTENT004::from_config(&cfg).style
};
assert_eq!(mk("title"), CapitalizationStyle::TitleCase);
assert_eq!(mk("title_case"), CapitalizationStyle::TitleCase);
assert_eq!(mk("sentence"), CapitalizationStyle::SentenceCase);
assert_eq!(mk("consistent"), CapitalizationStyle::Consistent);
assert_eq!(mk("bogus"), CapitalizationStyle::Consistent);
}
#[test]
fn test_consistent_title_case() {
let content = "# Getting Started Guide
## Installation Steps
### Configuration Options";
let doc = create_test_document(content);
let rule = CONTENT004::default();
let violations = rule.check(&doc).unwrap();
assert_eq!(violations.len(), 0);
}
#[test]
fn test_ambiguous_first_heading_does_not_force_sentence_case() {
let content = "# Agentic SDLC
## Installation Steps
## Configuration Options";
let doc = create_test_document(content);
let violations = CONTENT004::default().check(&doc).unwrap();
assert_eq!(
violations.len(),
0,
"ambiguous first heading should not override a Title Case document, got: {:?}",
violations.iter().map(|v| &v.message).collect::<Vec<_>>()
);
}
#[test]
fn test_ambiguous_first_heading_allows_sentence_case_document() {
let content = "# Agentic SDLC
## Installation steps
## Configuration options";
let doc = create_test_document(content);
let violations = CONTENT004::default().check(&doc).unwrap();
assert_eq!(
violations.len(),
0,
"ambiguous first heading should not conflict with a sentence case document, got: {:?}",
violations.iter().map(|v| &v.message).collect::<Vec<_>>()
);
}
#[test]
fn test_ambiguous_heading_does_not_suppress_later_inconsistency() {
let content = "# Agentic SDLC
## Installation Steps
## Configuration options";
let doc = create_test_document(content);
let violations = CONTENT004::default().check(&doc).unwrap();
assert_eq!(violations.len(), 1);
assert!(violations[0].message.contains("Configuration options"));
assert!(violations[0].message.contains("Title Case"));
}
#[test]
fn test_ambiguous_heading_in_middle_is_not_flagged() {
for content in [
"# Getting Started Guide\n\n## Agentic SDLC\n\n## Configuration Options",
"# Getting started guide\n\n## Agentic SDLC\n\n## Configuration options",
] {
let doc = create_test_document(content);
let violations = CONTENT004::default().check(&doc).unwrap();
assert_eq!(
violations.len(),
0,
"ambiguous heading should not be flagged under either baseline, got: {:?}",
violations.iter().map(|v| &v.message).collect::<Vec<_>>()
);
}
}
#[test]
fn test_explicit_style_unaffected_by_ambiguity() {
let content = "# Agentic SDLC";
let doc = create_test_document(content);
for style in [
CapitalizationStyle::TitleCase,
CapitalizationStyle::SentenceCase,
] {
let violations = CONTENT004::with_style(style).check(&doc).unwrap();
assert_eq!(violations.len(), 0, "unexpected violation for {style:?}");
}
}
#[test]
fn test_consistent_sentence_case() {
let content = "# Getting started guide
## Installation steps
### Configuration options";
let doc = create_test_document(content);
let rule = CONTENT004::default();
let violations = rule.check(&doc).unwrap();
assert_eq!(violations.len(), 0);
}
#[test]
fn test_inconsistent_capitalization() {
let content = "# Getting Started Guide
## installation steps
### More Configuration Options";
let doc = create_test_document(content);
let rule = CONTENT004::default();
let violations = rule.check(&doc).unwrap();
assert_eq!(violations.len(), 1);
assert!(violations[0].message.contains("installation steps"));
}
#[test]
fn test_enforced_title_case() {
let content = "# Getting started guide
## Installation Steps";
let doc = create_test_document(content);
let rule = CONTENT004::with_style(CapitalizationStyle::TitleCase);
let violations = rule.check(&doc).unwrap();
assert_eq!(violations.len(), 1);
assert!(violations[0].message.contains("Getting started guide"));
}
#[test]
fn test_enforced_sentence_case() {
let content = "# Getting Started Guide
## Installation steps";
let doc = create_test_document(content);
let rule = CONTENT004::with_style(CapitalizationStyle::SentenceCase);
let violations = rule.check(&doc).unwrap();
assert_eq!(violations.len(), 1);
assert!(violations[0].message.contains("Getting Started Guide"));
}
#[test]
fn test_single_word_headings_ignored() {
let content = "# Introduction
## Overview
### Details";
let doc = create_test_document(content);
let rule = CONTENT004::default();
let violations = rule.check(&doc).unwrap();
assert_eq!(violations.len(), 0);
}
#[test]
fn test_headings_in_code_blocks_ignored() {
let content = "# Main Title Here
```markdown
# This Is Not a Real Heading
## neither is this
```
## Second Section Here";
let doc = create_test_document(content);
let rule = CONTENT004::default();
let violations = rule.check(&doc).unwrap();
assert_eq!(violations.len(), 0);
}
#[test]
fn test_mixed_styles_detected() {
let content = "# User Guide Introduction
## getting started quickly
### Advanced Configuration";
let doc = create_test_document(content);
let rule = CONTENT004::default();
let violations = rule.check(&doc).unwrap();
assert!(!violations.is_empty());
}
#[test]
fn test_is_title_case() {
let rule = CONTENT004::default();
assert!(rule.is_title_case("Getting Started Guide"));
assert!(rule.is_title_case("The Quick Brown Fox"));
assert!(!rule.is_title_case("getting started guide"));
}
#[test]
fn test_is_sentence_case() {
let rule = CONTENT004::default();
assert!(rule.is_sentence_case("Getting started guide"));
assert!(rule.is_sentence_case("The quick brown fox"));
assert!(!rule.is_sentence_case("Getting Started Guide"));
}
#[test]
fn test_is_acronym() {
let rule = CONTENT004::default();
assert!(rule.is_acronym("API"));
assert!(rule.is_acronym("HTTP"));
assert!(rule.is_acronym("REST"));
assert!(!rule.is_acronym("Api"));
assert!(!rule.is_acronym("A")); }
}