use comrak::nodes::AstNode;
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 MDBOOK007 {
file_cache: Arc<RwLock<HashMap<PathBuf, Option<String>>>>,
processing_stack: Arc<RwLock<Vec<PathBuf>>>,
}
impl AstRule for MDBOOK007 {
fn id(&self) -> &'static str {
"MDBOOK007"
}
fn name(&self) -> &'static str {
"include-validation"
}
fn description(&self) -> &'static str {
"Include directives must point to existing files with valid syntax"
}
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();
{
if let Ok(mut stack) = self.processing_stack.write() {
stack.clear();
stack.push(document.path.clone());
}
}
let include_directives = self.find_include_directives(&document.content);
for directive in include_directives {
if let Some(violation) = self.validate_include_directive(document, &directive)? {
violations.push(violation);
}
}
Ok(violations)
}
}
#[derive(Debug, Clone)]
struct IncludeDirective {
#[allow(dead_code)]
full_match: String,
#[allow(dead_code)]
directive_type: String,
file_path: String,
range_or_anchor: Option<String>,
line_number: usize,
column: usize,
}
impl MDBOOK007 {
fn find_include_directives(&self, content: &str) -> Vec<IncludeDirective> {
let mut directives = Vec::new();
for (line_number, line) in content.lines().enumerate() {
if let Some(directive) = self.parse_include_directive(line, line_number + 1) {
directives.push(directive);
}
}
directives
}
fn parse_include_directive(&self, line: &str, line_number: usize) -> Option<IncludeDirective> {
let trimmed = line.trim();
if let Some(start) = trimmed.find("{{#")
&& let Some(end) = trimmed[start..].find("}}")
{
let directive_content = &trimmed[start + 3..start + end];
let parts: Vec<&str> = directive_content.split_whitespace().collect();
if parts.len() >= 2 {
let directive_type = parts[0];
if directive_type == "include" || directive_type == "rustdoc_include" {
let file_spec = parts[1];
let (file_path, range_or_anchor) = self.parse_file_spec(file_spec);
return Some(IncludeDirective {
full_match: trimmed[start..start + end + 2].to_string(),
directive_type: directive_type.to_string(),
file_path: file_path.to_string(),
range_or_anchor,
line_number,
column: start + 1,
});
}
}
}
None
}
fn parse_file_spec<'a>(&self, file_spec: &'a str) -> (&'a str, Option<String>) {
if let Some(colon_pos) = file_spec.find(':') {
let file_path = &file_spec[..colon_pos];
let range_spec = &file_spec[colon_pos + 1..];
(file_path, Some(range_spec.to_string()))
} else {
(file_spec, None)
}
}
fn validate_include_directive(
&self,
document: &Document,
directive: &IncludeDirective,
) -> mdbook_lint_core::error::Result<Option<Violation>> {
let target_path = self.resolve_include_path(&document.path, &directive.file_path);
match self.get_file_content(&target_path)? {
Some(content) => {
if let Some(range_or_anchor) = &directive.range_or_anchor
&& let Some(violation) = self.validate_range_or_anchor(
directive,
&target_path,
&content,
range_or_anchor,
)?
{
return Ok(Some(violation));
}
if let Some(violation) = self.check_circular_dependency(&target_path, directive)? {
return Ok(Some(violation));
}
Ok(None)
}
None => {
let message = format!(
"Include file '{}' not found. Resolved path: {}",
directive.file_path,
target_path.display()
);
Ok(Some(self.create_violation(
message,
directive.line_number,
directive.column,
Severity::Error,
)))
}
}
}
fn resolve_include_path(&self, current_doc_path: &Path, include_path: &str) -> PathBuf {
let current_dir = current_doc_path.parent().unwrap_or(Path::new("."));
if let Some(stripped) = include_path.strip_prefix('/') {
PathBuf::from(stripped)
} else {
current_dir.join(include_path)
}
}
fn get_file_content(&self, file_path: &Path) -> io::Result<Option<String>> {
let canonical_path = match file_path.canonicalize() {
Ok(path) => path,
Err(_) => file_path.to_path_buf(),
};
{
if let Ok(cache) = self.file_cache.read()
&& let Some(cached_content) = cache.get(&canonical_path)
{
return Ok(cached_content.clone());
}
}
let content = fs::read_to_string(file_path).ok();
{
if let Ok(mut cache) = self.file_cache.write() {
cache.insert(canonical_path, content.clone());
}
}
Ok(content)
}
fn validate_range_or_anchor(
&self,
directive: &IncludeDirective,
target_path: &Path,
content: &str,
range_or_anchor: &str,
) -> mdbook_lint_core::error::Result<Option<Violation>> {
if self.is_line_range(range_or_anchor) {
return self.validate_line_range(directive, target_path, content, range_or_anchor);
}
if self.looks_like_malformed_line_range(range_or_anchor) {
return Ok(Some(self.create_violation(
format!("Invalid line number format '{range_or_anchor}'. Expected number or number:number format."),
directive.line_number,
directive.column,
Severity::Error,
)));
}
self.validate_anchor(directive, target_path, content, range_or_anchor)
}
fn is_line_range(&self, spec: &str) -> bool {
spec.chars().all(|c| c.is_ascii_digit() || c == ':') && !spec.is_empty()
}
fn looks_like_malformed_line_range(&self, spec: &str) -> bool {
if spec.is_empty() {
return false;
}
let has_digits = spec.chars().any(|c| c.is_ascii_digit());
let has_colon = spec.contains(':');
if has_digits {
let has_letters = spec.chars().any(|c| c.is_ascii_alphabetic());
if has_letters {
return true;
}
}
if has_colon && (spec.starts_with(':') || spec.ends_with(':')) {
return true;
}
if spec.len() <= 3
&& spec.chars().all(|c| c.is_ascii_alphabetic())
&& !spec.contains('_')
&& !spec.contains('-')
{
return true;
}
false
}
fn validate_line_range(
&self,
directive: &IncludeDirective,
_target_path: &Path,
content: &str,
range_spec: &str,
) -> mdbook_lint_core::error::Result<Option<Violation>> {
let line_count = content.lines().count();
let (start_line, end_line) = if let Some(colon_pos) = range_spec.find(':') {
let start_str = &range_spec[..colon_pos];
let end_str = &range_spec[colon_pos + 1..];
let start = match start_str.parse::<usize>() {
Ok(n) if n > 0 => n,
_ => {
return Ok(Some(self.create_violation(
format!("Invalid start line number '{start_str}' in range specification"),
directive.line_number,
directive.column,
Severity::Error,
)));
}
};
let end = match end_str.parse::<usize>() {
Ok(n) if n > 0 => n,
_ => {
return Ok(Some(self.create_violation(
format!("Invalid end line number '{end_str}' in range specification"),
directive.line_number,
directive.column,
Severity::Error,
)));
}
};
if start > end {
return Ok(Some(self.create_violation(
format!("Start line {start} cannot be greater than end line {end}"),
directive.line_number,
directive.column,
Severity::Error,
)));
}
(start, end)
} else {
let line_num = match range_spec.parse::<usize>() {
Ok(n) if n > 0 => n,
_ => {
return Ok(Some(self.create_violation(
format!("Invalid line number '{range_spec}'"),
directive.line_number,
directive.column,
Severity::Error,
)));
}
};
(line_num, line_num)
};
if start_line > line_count || end_line > line_count {
let message = if start_line == end_line {
format!("Line {start_line} does not exist in file (file has {line_count} lines)")
} else {
format!(
"Line range {start_line}:{end_line} exceeds file length (file has {line_count} lines)"
)
};
return Ok(Some(self.create_violation(
message,
directive.line_number,
directive.column,
Severity::Error,
)));
}
Ok(None)
}
fn validate_anchor(
&self,
directive: &IncludeDirective,
_target_path: &Path,
content: &str,
anchor: &str,
) -> mdbook_lint_core::error::Result<Option<Violation>> {
let anchor_patterns = [
format!("// ANCHOR: {anchor}"),
format!("# ANCHOR: {anchor}"),
format!("<!-- ANCHOR: {anchor} -->"),
format!("<!-- anchor: {anchor} -->"),
];
let mut found = false;
for line in content.lines() {
for pattern in &anchor_patterns {
if line.contains(pattern) {
found = true;
break;
}
}
if found {
break;
}
}
if !found {
return Ok(Some(self.create_violation(
format!(
"Anchor '{}' not found in included file. Expected patterns: {}",
anchor,
anchor_patterns.join(", ")
),
directive.line_number,
directive.column,
Severity::Error,
)));
}
Ok(None)
}
fn check_circular_dependency(
&self,
target_path: &Path,
directive: &IncludeDirective,
) -> mdbook_lint_core::error::Result<Option<Violation>> {
{
if let Ok(stack) = self.processing_stack.read()
&& stack.contains(&target_path.to_path_buf())
{
return Ok(Some(self.create_violation(
format!(
"Circular include dependency detected: {} -> {}",
stack.last().unwrap().display(),
target_path.display()
),
directive.line_number,
directive.column,
Severity::Error,
)));
}
}
Ok(None)
}
}
#[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_mdbook007_valid_basic_include() -> mdbook_lint_core::error::Result<()> {
let temp_dir = TempDir::new()?;
let root = temp_dir.path();
create_test_document("Hello, included content!", &root.join("included.txt"))?;
let source_content = r#"# Chapter 1
{{#include included.txt}}
More content here."#;
let source_path = root.join("chapter.md");
let doc = create_test_document(source_content, &source_path)?;
let rule = MDBOOK007::default();
let violations = rule.check(&doc)?;
assert_eq!(
violations.len(),
0,
"Valid include should have no violations"
);
Ok(())
}
#[test]
fn test_mdbook007_missing_file() -> mdbook_lint_core::error::Result<()> {
let temp_dir = TempDir::new()?;
let root = temp_dir.path();
let source_content = r#"# Chapter 1
{{#include nonexistent.txt}}
More content here."#;
let source_path = root.join("chapter.md");
let doc = create_test_document(source_content, &source_path)?;
let rule = MDBOOK007::default();
let violations = rule.check(&doc)?;
assert_eq!(violations.len(), 1);
assert_eq!(violations[0].rule_id, "MDBOOK007");
assert!(violations[0].message.contains("not found"));
assert!(violations[0].message.contains("nonexistent.txt"));
Ok(())
}
#[test]
fn test_mdbook007_valid_line_range() -> mdbook_lint_core::error::Result<()> {
let temp_dir = TempDir::new()?;
let root = temp_dir.path();
let target_content = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5\n";
create_test_document(target_content, &root.join("lines.txt"))?;
let source_content = r#"# Chapter 1
{{#include lines.txt:2:4}}
More content here."#;
let source_path = root.join("chapter.md");
let doc = create_test_document(source_content, &source_path)?;
let rule = MDBOOK007::default();
let violations = rule.check(&doc)?;
assert_eq!(
violations.len(),
0,
"Valid line range should have no violations"
);
Ok(())
}
#[test]
fn test_mdbook007_invalid_line_range() -> mdbook_lint_core::error::Result<()> {
let temp_dir = TempDir::new()?;
let root = temp_dir.path();
let target_content = "Line 1\nLine 2\nLine 3\n";
create_test_document(target_content, &root.join("lines.txt"))?;
let source_content = r#"# Chapter 1
{{#include lines.txt:2:10}}
More content here."#;
let source_path = root.join("chapter.md");
let doc = create_test_document(source_content, &source_path)?;
let rule = MDBOOK007::default();
let violations = rule.check(&doc)?;
assert_eq!(violations.len(), 1);
assert_eq!(violations[0].rule_id, "MDBOOK007");
assert!(violations[0].message.contains("exceeds file length"));
Ok(())
}
#[test]
fn test_mdbook007_single_line_include() -> mdbook_lint_core::error::Result<()> {
let temp_dir = TempDir::new()?;
let root = temp_dir.path();
let target_content = "Line 1\nLine 2\nLine 3\n";
create_test_document(target_content, &root.join("lines.txt"))?;
let source_content = r#"# Chapter 1
{{#include lines.txt:2}}
More content here."#;
let source_path = root.join("chapter.md");
let doc = create_test_document(source_content, &source_path)?;
let rule = MDBOOK007::default();
let violations = rule.check(&doc)?;
assert_eq!(
violations.len(),
0,
"Valid single line include should have no violations"
);
Ok(())
}
#[test]
fn test_mdbook007_valid_anchor() -> mdbook_lint_core::error::Result<()> {
let temp_dir = TempDir::new()?;
let root = temp_dir.path();
let target_content = r#"fn main() {
// ANCHOR: example
println!("Hello, world!");
// ANCHOR_END: example
}"#;
create_test_document(target_content, &root.join("example.rs"))?;
let source_content = r#"# Chapter 1
{{#include example.rs:example}}
More content here."#;
let source_path = root.join("chapter.md");
let doc = create_test_document(source_content, &source_path)?;
let rule = MDBOOK007::default();
let violations = rule.check(&doc)?;
assert_eq!(
violations.len(),
0,
"Valid anchor include should have no violations"
);
Ok(())
}
#[test]
fn test_mdbook007_missing_anchor() -> mdbook_lint_core::error::Result<()> {
let temp_dir = TempDir::new()?;
let root = temp_dir.path();
let target_content = r#"fn main() {
println!("Hello, world!");
}"#;
create_test_document(target_content, &root.join("example.rs"))?;
let source_content = r#"# Chapter 1
{{#include example.rs:missing_anchor}}
More content here."#;
let source_path = root.join("chapter.md");
let doc = create_test_document(source_content, &source_path)?;
let rule = MDBOOK007::default();
let violations = rule.check(&doc)?;
assert_eq!(violations.len(), 1);
assert_eq!(violations[0].rule_id, "MDBOOK007");
assert!(
violations[0]
.message
.contains("Anchor 'missing_anchor' not found")
);
Ok(())
}
#[test]
fn test_mdbook007_rustdoc_include() -> mdbook_lint_core::error::Result<()> {
let temp_dir = TempDir::new()?;
let root = temp_dir.path();
create_test_document("fn example() {}", &root.join("lib.rs"))?;
let source_content = r#"# Chapter 1
{{#rustdoc_include lib.rs}}
More content here."#;
let source_path = root.join("chapter.md");
let doc = create_test_document(source_content, &source_path)?;
let rule = MDBOOK007::default();
let violations = rule.check(&doc)?;
assert_eq!(
violations.len(),
0,
"Valid rustdoc_include should have no violations"
);
Ok(())
}
#[test]
fn test_mdbook007_invalid_line_number_format() -> mdbook_lint_core::error::Result<()> {
let temp_dir = TempDir::new()?;
let root = temp_dir.path();
create_test_document("Line 1\nLine 2\n", &root.join("lines.txt"))?;
let source_content = r#"# Chapter 1
{{#include lines.txt:abc}}
More content here."#;
let source_path = root.join("chapter.md");
let doc = create_test_document(source_content, &source_path)?;
let rule = MDBOOK007::default();
let violations = rule.check(&doc)?;
assert_eq!(violations.len(), 1);
assert_eq!(violations[0].rule_id, "MDBOOK007");
assert!(violations[0].message.contains("Invalid line number format"));
Ok(())
}
#[test]
fn test_mdbook007_nested_includes() -> mdbook_lint_core::error::Result<()> {
let temp_dir = TempDir::new()?;
let root = temp_dir.path();
fs::create_dir_all(root.join("nested"))?;
create_test_document("Nested content", &root.join("nested/file.txt"))?;
let source_content = r#"# Chapter 1
{{#include nested/file.txt}}
More content here."#;
let source_path = root.join("chapter.md");
let doc = create_test_document(source_content, &source_path)?;
let rule = MDBOOK007::default();
let violations = rule.check(&doc)?;
assert_eq!(
violations.len(),
0,
"Nested include should have no violations"
);
Ok(())
}
#[test]
fn test_parse_file_spec() {
let rule = MDBOOK007::default();
assert_eq!(rule.parse_file_spec("file.txt"), ("file.txt", None));
assert_eq!(
rule.parse_file_spec("file.rs:10:20"),
("file.rs", Some("10:20".to_string()))
);
assert_eq!(
rule.parse_file_spec("file.rs:anchor"),
("file.rs", Some("anchor".to_string()))
);
assert_eq!(
rule.parse_file_spec("path/to/file.txt:5"),
("path/to/file.txt", Some("5".to_string()))
);
}
#[test]
fn test_is_line_range() {
let rule = MDBOOK007::default();
assert!(rule.is_line_range("10"));
assert!(rule.is_line_range("10:20"));
assert!(rule.is_line_range("1:1"));
assert!(!rule.is_line_range("anchor_name"));
assert!(!rule.is_line_range("10:anchor"));
assert!(!rule.is_line_range("abc:123"));
}
#[test]
fn test_looks_like_malformed_line_range() {
let rule = MDBOOK007::default();
assert!(rule.looks_like_malformed_line_range("10abc"));
assert!(rule.looks_like_malformed_line_range("abc10"));
assert!(rule.looks_like_malformed_line_range(":10"));
assert!(rule.looks_like_malformed_line_range("10:"));
assert!(rule.looks_like_malformed_line_range("10:abc"));
assert!(rule.looks_like_malformed_line_range("abc:123"));
assert!(!rule.looks_like_malformed_line_range("anchor_name"));
assert!(!rule.looks_like_malformed_line_range("valid-anchor"));
assert!(!rule.looks_like_malformed_line_range(""));
assert!(rule.looks_like_malformed_line_range("abc"));
assert!(!rule.looks_like_malformed_line_range("anchor_name"));
assert!(!rule.looks_like_malformed_line_range("valid-anchor"));
}
}