use comrak::nodes::{AstNode, NodeValue};
use mdbook_lint_core::rule::{AstRule, RuleCategory, RuleMetadata};
use mdbook_lint_core::{
Document,
violation::{Severity, Violation},
};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, RwLock};
use std::{fs, io};
#[derive(Default)]
pub struct MDBOOK006 {
anchor_cache: Arc<RwLock<HashMap<PathBuf, Vec<String>>>>,
}
impl AstRule for MDBOOK006 {
fn id(&self) -> &'static str {
"MDBOOK006"
}
fn name(&self) -> &'static str {
"internal-cross-references"
}
fn description(&self) -> &'static str {
"Internal cross-reference links must point to valid headings in target files"
}
fn metadata(&self) -> RuleMetadata {
RuleMetadata::stable(RuleCategory::MdBook).introduced_in("mdbook-lint v0.2.0")
}
fn check_ast<'a>(
&self,
document: &Document,
ast: &'a AstNode<'a>,
) -> mdbook_lint_core::error::Result<Vec<Violation>> {
let mut violations = Vec::new();
for node in ast.descendants() {
if let NodeValue::Link(link) = &node.data.borrow().value {
let url = &link.url;
if is_external_link(url) {
continue;
}
if !url.contains('#') {
continue;
}
if url.starts_with('#') {
continue;
}
if let Some(violation) = self.validate_cross_reference(document, node, url)? {
violations.push(violation);
}
}
}
Ok(violations)
}
}
impl MDBOOK006 {
fn validate_cross_reference<'a>(
&self,
document: &Document,
node: &'a AstNode<'a>,
url: &str,
) -> mdbook_lint_core::error::Result<Option<Violation>> {
let parts: Vec<&str> = url.splitn(2, '#').collect();
if parts.len() != 2 {
return Ok(None); }
let file_path = parts[0];
let anchor = parts[1];
if file_path.is_empty() || anchor.is_empty() {
return Ok(None);
}
let target_path = self.resolve_target_path(&document.path, file_path);
if !target_path.exists() {
return Ok(None);
}
let anchors = match self.get_file_anchors(&target_path)? {
Some(anchors) => anchors,
None => return Ok(None), };
if !anchors.contains(&anchor.to_string()) {
let (line, column) = document.node_position(node).unwrap_or((1, 1));
let suggestion = self.suggest_similar_anchor(anchor, &anchors);
let message = if let Some(suggestion) = suggestion {
format!(
"Cross-reference anchor '{anchor}' not found in '{file_path}'. Did you mean '{suggestion}'?"
)
} else {
format!(
"Cross-reference anchor '{}' not found in '{}'. Available anchors: {}",
anchor,
file_path,
if anchors.is_empty() {
"none".to_string()
} else {
anchors
.iter()
.take(5)
.map(|s| format!("'{s}'"))
.collect::<Vec<_>>()
.join(", ")
}
)
};
return Ok(Some(self.create_violation(
message,
line,
column,
Severity::Error,
)));
}
Ok(None)
}
fn resolve_target_path(&self, current_doc_path: &Path, link_path: &str) -> PathBuf {
let current_dir = current_doc_path.parent().unwrap_or(Path::new("."));
if let Some(stripped) = link_path.strip_prefix("./") {
current_dir.join(stripped)
} else if link_path.starts_with("../") {
current_dir.join(link_path)
} else if let Some(stripped) = link_path.strip_prefix('/') {
PathBuf::from(stripped)
} else {
current_dir.join(link_path)
}
}
fn get_file_anchors(&self, file_path: &Path) -> io::Result<Option<Vec<String>>> {
let canonical_path = match file_path.canonicalize() {
Ok(path) => path,
Err(_) => file_path.to_path_buf(),
};
{
if let Ok(cache) = self.anchor_cache.read()
&& let Some(anchors) = cache.get(&canonical_path)
{
return Ok(Some(anchors.clone()));
}
}
let content = match fs::read_to_string(file_path) {
Ok(content) => content,
Err(_) => return Ok(None), };
let anchors = self.extract_heading_anchors(&content);
{
if let Ok(mut cache) = self.anchor_cache.write() {
cache.insert(canonical_path, anchors.clone());
}
}
Ok(Some(anchors))
}
fn extract_heading_anchors(&self, content: &str) -> Vec<String> {
let mut anchors = Vec::new();
for line in content.lines() {
let line = line.trim();
if let Some(heading_text) = self.extract_atx_heading(line) {
let anchor = self.generate_anchor_id(&heading_text);
if !anchor.is_empty() {
anchors.push(anchor);
}
}
}
anchors
}
fn extract_atx_heading(&self, line: &str) -> Option<String> {
if !line.starts_with('#') {
return None;
}
let hash_count = line.chars().take_while(|&c| c == '#').count();
if hash_count == 0 || hash_count > 6 {
return None; }
let rest = &line[hash_count..];
let text = if let Some(stripped) = rest.strip_prefix(' ') {
stripped
} else {
rest
};
let text = text.trim_end_matches(['#', ' ']);
if text.is_empty() {
return None;
}
Some(text.to_string())
}
fn generate_anchor_id(&self, heading_text: &str) -> String {
let mut fragment = String::new();
for ch in heading_text.chars() {
if ch.is_alphanumeric() {
fragment.push(ch.to_ascii_lowercase());
} else if ch == '-' || ch == '_' {
fragment.push(ch);
} else if ch.is_whitespace() {
fragment.push('-');
}
}
fragment.trim_matches('-').to_string()
}
fn suggest_similar_anchor(&self, target: &str, available: &[String]) -> Option<String> {
if available.is_empty() {
return None;
}
for anchor in available {
if anchor.contains(target) || target.contains(anchor) {
return Some(anchor.clone());
}
}
Some(available[0].clone())
}
}
fn is_external_link(url: &str) -> bool {
url.starts_with("http://")
|| url.starts_with("https://")
|| url.starts_with("mailto:")
|| url.starts_with("ftp://")
|| url.starts_with("tel:")
}
#[cfg(test)]
mod tests {
use super::*;
use mdbook_lint_core::rule::Rule;
use std::fs;
use tempfile::TempDir;
fn create_test_document(
content: &str,
file_path: &Path,
) -> mdbook_lint_core::error::Result<Document> {
if let Some(parent) = file_path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(file_path, content)?;
Document::new(content.to_string(), file_path.to_path_buf())
}
#[test]
fn test_mdbook006_valid_cross_references() -> mdbook_lint_core::error::Result<()> {
let temp_dir = TempDir::new()?;
let root = temp_dir.path();
let target_content = r#"# Chapter 2
## Overview
Some content here.
### Implementation Details
More details.
"#;
create_test_document(target_content, &root.join("chapter2.md"))?;
let source_content = r#"# Chapter 1
See [Chapter 2](chapter2.md#chapter-2) for more info.
Check out the [overview](chapter2.md#overview) section.
The [implementation](chapter2.md#implementation-details) is complex.
"#;
let source_path = root.join("chapter1.md");
let doc = create_test_document(source_content, &source_path)?;
let rule = MDBOOK006::default();
let violations = rule.check(&doc)?;
assert_eq!(
violations.len(),
0,
"Valid cross-references should have no violations"
);
Ok(())
}
#[test]
fn test_mdbook006_invalid_anchor() -> mdbook_lint_core::error::Result<()> {
let temp_dir = TempDir::new()?;
let root = temp_dir.path();
let target_content = r#"# Chapter 2
## Overview
Some content.
"#;
create_test_document(target_content, &root.join("chapter2.md"))?;
let source_content = r#"# Chapter 1
See [nonexistent section](chapter2.md#nonexistent).
"#;
let source_path = root.join("chapter1.md");
let doc = create_test_document(source_content, &source_path)?;
let rule = MDBOOK006::default();
let violations = rule.check(&doc)?;
assert_eq!(violations.len(), 1);
assert_eq!(violations[0].rule_id, "MDBOOK006");
assert!(
violations[0]
.message
.contains("anchor 'nonexistent' not found")
);
assert!(violations[0].message.contains("chapter2.md"));
Ok(())
}
#[test]
fn test_mdbook006_missing_target_file() -> mdbook_lint_core::error::Result<()> {
let temp_dir = TempDir::new()?;
let root = temp_dir.path();
let source_content = r#"# Chapter 1
See [missing](nonexistent.md#section).
"#;
let source_path = root.join("chapter1.md");
let doc = create_test_document(source_content, &source_path)?;
let rule = MDBOOK006::default();
let violations = rule.check(&doc)?;
assert_eq!(violations.len(), 0);
Ok(())
}
#[test]
fn test_mdbook006_same_document_anchors() -> mdbook_lint_core::error::Result<()> {
let temp_dir = TempDir::new()?;
let root = temp_dir.path();
let content = r#"# Chapter 1
## Section A
See [Section B](#section-b) below.
## Section B
Content here.
"#;
let file_path = root.join("chapter1.md");
let doc = create_test_document(content, &file_path)?;
let rule = MDBOOK006::default();
let violations = rule.check(&doc)?;
assert_eq!(violations.len(), 0);
Ok(())
}
#[test]
fn test_mdbook006_external_links() -> mdbook_lint_core::error::Result<()> {
let temp_dir = TempDir::new()?;
let root = temp_dir.path();
let content = r#"# Chapter 1
See [external](https://example.com#section).
"#;
let file_path = root.join("chapter1.md");
let doc = create_test_document(content, &file_path)?;
let rule = MDBOOK006::default();
let violations = rule.check(&doc)?;
assert_eq!(violations.len(), 0);
Ok(())
}
#[test]
fn test_mdbook006_no_anchor_links() -> mdbook_lint_core::error::Result<()> {
let temp_dir = TempDir::new()?;
let root = temp_dir.path();
create_test_document("# Target", &root.join("target.md"))?;
let content = r#"# Chapter 1
See [target](target.md) for more.
"#;
let file_path = root.join("chapter1.md");
let doc = create_test_document(content, &file_path)?;
let rule = MDBOOK006::default();
let violations = rule.check(&doc)?;
assert_eq!(violations.len(), 0);
Ok(())
}
#[test]
fn test_extract_atx_heading() {
let rule = MDBOOK006::default();
assert_eq!(
rule.extract_atx_heading("# Heading"),
Some("Heading".to_string())
);
assert_eq!(
rule.extract_atx_heading("## Sub Heading"),
Some("Sub Heading".to_string())
);
assert_eq!(
rule.extract_atx_heading("### Deep Heading ###"),
Some("Deep Heading".to_string())
);
assert_eq!(
rule.extract_atx_heading("#No Space"),
Some("No Space".to_string())
);
assert_eq!(rule.extract_atx_heading("Not a heading"), None);
assert_eq!(rule.extract_atx_heading(""), None);
assert_eq!(rule.extract_atx_heading("#"), None);
assert_eq!(rule.extract_atx_heading("# "), None);
}
#[test]
fn test_generate_anchor_id() {
let rule = MDBOOK006::default();
assert_eq!(rule.generate_anchor_id("Simple Heading"), "simple-heading");
assert_eq!(
rule.generate_anchor_id("Complex: Heading with! Punctuation?"),
"complex-heading-with-punctuation"
);
assert_eq!(
rule.generate_anchor_id("Multiple Spaces"),
"multiple---spaces"
);
assert_eq!(rule.generate_anchor_id("UPPER case"), "upper-case");
assert_eq!(rule.generate_anchor_id("123 Numbers"), "123-numbers");
assert_eq!(rule.generate_anchor_id(""), "");
assert_eq!(rule.generate_anchor_id("some_variable"), "some_variable");
assert_eq!(rule.generate_anchor_id("dash-test"), "dash-test");
assert_eq!(
rule.generate_anchor_id("[2026-01-28] - V5.1.2"),
"2026-01-28---v512"
);
}
#[test]
fn test_mdbook006_nested_directories() -> mdbook_lint_core::error::Result<()> {
let temp_dir = TempDir::new()?;
let root = temp_dir.path();
let target_content = r#"# Deep Chapter
## Nested Section
Content here.
"#;
create_test_document(target_content, &root.join("guide/deep.md"))?;
let source_content = r#"# Main Chapter
See [nested section](guide/deep.md#nested-section).
"#;
let source_path = root.join("chapter.md");
let doc = create_test_document(source_content, &source_path)?;
let rule = MDBOOK006::default();
let violations = rule.check(&doc)?;
assert_eq!(
violations.len(),
0,
"Nested directory cross-references should work"
);
Ok(())
}
#[test]
fn test_mdbook006_helpful_suggestions() -> mdbook_lint_core::error::Result<()> {
let temp_dir = TempDir::new()?;
let root = temp_dir.path();
let target_content = r#"# Target
## Implementation Details
Content here.
"#;
create_test_document(target_content, &root.join("target.md"))?;
let source_content = r#"# Source
See [details](target.md#implementation).
"#;
let source_path = root.join("source.md");
let doc = create_test_document(source_content, &source_path)?;
let rule = MDBOOK006::default();
let violations = rule.check(&doc)?;
assert_eq!(violations.len(), 1);
assert!(violations[0].message.contains("Did you mean"));
assert!(violations[0].message.contains("implementation-details"));
Ok(())
}
}