use mdbook_lint_core::error::Result;
use mdbook_lint_core::rule::{Rule, RuleCategory, RuleMetadata};
use mdbook_lint_core::{
Document,
violation::{Severity, Violation},
};
use regex::Regex;
pub struct MDBOOK009;
impl Rule for MDBOOK009 {
fn id(&self) -> &'static str {
"MDBOOK009"
}
fn name(&self) -> &'static str {
"playground-validation"
}
fn description(&self) -> &'static str {
"Invalid {{#playground}} configuration"
}
fn metadata(&self) -> RuleMetadata {
RuleMetadata::experimental(RuleCategory::MdBook).introduced_in("mdbook-lint v0.11.0")
}
fn check_with_ast<'a>(
&self,
document: &Document,
_ast: Option<&'a comrak::nodes::AstNode<'a>>,
) -> Result<Vec<Violation>> {
let mut violations = Vec::new();
let playground_re = Regex::new(r"\{\{#playground\s*([^}]*)\}\}").unwrap();
for (line_num, line) in document.lines.iter().enumerate() {
for capture in playground_re.captures_iter(line) {
if let Some(args) = capture.get(1) {
let file_path = args.as_str().trim();
if file_path.is_empty() {
violations.push(self.create_violation(
"Empty file path in {{#playground}} directive".to_string(),
line_num + 1,
args.start() + 1,
Severity::Error,
));
continue;
}
let base_path = if file_path.contains(':') && !file_path.contains("://") {
file_path.split(':').next().unwrap_or(file_path)
} else {
file_path
};
if !base_path.ends_with(".rs") {
violations.push(self.create_violation(
format!(
"{{#playground}} should reference a Rust file (.rs), found: {}",
base_path
),
line_num + 1,
args.start() + 1,
Severity::Error,
));
}
if base_path.starts_with('/')
|| base_path.starts_with('\\')
|| (base_path.len() > 1 && base_path.chars().nth(1) == Some(':'))
{
violations.push(self.create_violation(
format!(
"{{#playground}} should use relative paths, found absolute: {}",
base_path
),
line_num + 1,
args.start() + 1,
Severity::Error,
));
}
if file_path.contains("{{") || file_path.contains("}}") {
violations.push(self.create_violation(
"Nested mdBook directives not allowed in {{#playground}}".to_string(),
line_num + 1,
args.start() + 1,
Severity::Error,
));
}
if file_path.contains(':') && !file_path.contains("://") {
violations.push(self.create_violation(
"{{#playground}} does not support line ranges, use {{#include}} instead for partial content".to_string(),
line_num + 1,
args.start() + 1,
Severity::Warning,
));
}
}
}
if (line.contains("{{#play ") || line.contains("{{#play}}"))
|| line.contains("{{#plaground")
|| line.contains("{{#payground")
{
violations.push(self.create_violation(
"Possible misspelling of {{#playground}} directive".to_string(),
line_num + 1,
1,
Severity::Warning,
));
}
}
Ok(violations)
}
}
#[cfg(test)]
mod tests {
use super::*;
use mdbook_lint_core::Document;
use std::path::PathBuf;
#[test]
fn test_valid_playground() {
let content = r#"# Playground Examples
Here's a playground example:
{{#playground ../examples/hello.rs}}
Another one:
{{#playground examples/demo.rs}}
"#;
let doc = Document::new(content.to_string(), PathBuf::from("chapter.md")).unwrap();
let rule = MDBOOK009;
let violations = rule.check(&doc).unwrap();
assert_eq!(violations.len(), 0);
}
#[test]
fn test_empty_file_path() {
let content = "{{#playground }}";
let doc = Document::new(content.to_string(), PathBuf::from("chapter.md")).unwrap();
let rule = MDBOOK009;
let violations = rule.check(&doc).unwrap();
assert_eq!(violations.len(), 1);
assert!(violations[0].message.contains("Empty file path"));
}
#[test]
fn test_non_rust_file() {
let content = "{{#playground ../docs/README.md}}";
let doc = Document::new(content.to_string(), PathBuf::from("chapter.md")).unwrap();
let rule = MDBOOK009;
let violations = rule.check(&doc).unwrap();
assert_eq!(violations.len(), 1);
assert!(
violations[0]
.message
.contains("should reference a Rust file")
);
}
#[test]
fn test_absolute_path() {
let content = "{{#playground /usr/src/main.rs}}";
let doc = Document::new(content.to_string(), PathBuf::from("chapter.md")).unwrap();
let rule = MDBOOK009;
let violations = rule.check(&doc).unwrap();
assert_eq!(violations.len(), 1);
assert!(violations[0].message.contains("should use relative paths"));
}
#[test]
fn test_line_ranges_not_supported() {
let content = "{{#playground ../src/main.rs:10:20}}";
let doc = Document::new(content.to_string(), PathBuf::from("chapter.md")).unwrap();
let rule = MDBOOK009;
let violations = rule.check(&doc).unwrap();
assert_eq!(violations.len(), 1);
assert!(
violations[0]
.message
.contains("does not support line ranges")
);
}
#[test]
fn test_nested_directives() {
let content = "{{#playground {{#include ../src/main.rs}}}}";
let doc = Document::new(content.to_string(), PathBuf::from("chapter.md")).unwrap();
let rule = MDBOOK009;
let violations = rule.check(&doc).unwrap();
assert_eq!(violations.len(), 1);
assert!(violations[0].message.contains("Nested mdBook directives"));
}
#[test]
fn test_misspellings() {
let content = r#"
{{#play ../src/main.rs}}
{{#plaground ../src/main.rs}}
{{#payground ../src/main.rs}}
"#;
let doc = Document::new(content.to_string(), PathBuf::from("chapter.md")).unwrap();
let rule = MDBOOK009;
let violations = rule.check(&doc).unwrap();
assert_eq!(violations.len(), 3);
for violation in &violations {
assert!(violation.message.contains("misspelling"));
}
}
}