use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::atomic::{AtomicUsize, Ordering};
struct TempDir(PathBuf);
impl TempDir {
fn new(label: &str) -> Self {
static COUNTER: AtomicUsize = AtomicUsize::new(0);
let mut path = std::env::temp_dir();
path.push(format!(
"ridl-book-{label}-{}-{}",
std::process::id(),
COUNTER.fetch_add(1, Ordering::SeqCst),
));
std::fs::create_dir_all(&path).expect("create the temp dir");
Self(path)
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
struct Fenced {
info: String,
body: String,
fence_line: usize,
}
const MDBOOK_OPTIONS: Options = Options::ENABLE_TABLES
.union(Options::ENABLE_FOOTNOTES)
.union(Options::ENABLE_STRIKETHROUGH)
.union(Options::ENABLE_TASKLISTS)
.union(Options::ENABLE_HEADING_ATTRIBUTES);
fn fenced_blocks(markdown: &str) -> Vec<Fenced> {
let mut blocks = Vec::new();
let mut open: Option<Fenced> = None;
let parser = Parser::new_ext(markdown, MDBOOK_OPTIONS).into_offset_iter();
for (event, range) in parser {
match event {
Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(info))) => {
open = Some(Fenced {
info: info.into_string(),
body: String::new(),
fence_line: markdown[..range.start].matches('\n').count() + 1,
});
}
Event::Text(text) => {
if let Some(block) = open.as_mut() {
block.body.push_str(&text);
}
}
Event::End(TagEnd::CodeBlock) => {
if let Some(block) = open.take() {
blocks.push(block);
}
}
_ => {}
}
}
blocks
}
fn is_example_language(info: &str) -> bool {
let word = info
.split([',', ' '])
.next()
.unwrap_or_default()
.to_ascii_lowercase();
word.starts_with("ridl") || word.starts_with("typl") || word.starts_with("rsdl")
}
#[derive(Debug)]
struct Example {
origin: String,
fence_line: usize,
language: String,
allowed: BTreeSet<String>,
body: String,
staging_body: String,
}
impl Example {
fn package(&self) -> Option<String> {
self.body.lines().find_map(|line| {
let rest = line.strip_prefix("package ")?;
let name = rest.split("//").next().unwrap_or(rest).trim();
(!name.is_empty()).then(|| name.to_owned())
})
}
fn declarations(&self) -> Vec<String> {
const KINDS: [&str; 11] = [
"type",
"const",
"struct",
"enum",
"enumset",
"union",
"interface",
"system",
"component",
"distribution",
"deployment",
];
self.body
.lines()
.filter_map(|line| {
let mut words = line.split_whitespace();
let mut word = words.next()?;
if word == "internal" {
word = words.next()?;
}
if word == "error" {
word = words.next()?;
}
if !KINDS.contains(&word) {
return None;
}
let name = words.next()?.trim_end_matches([':', '{', ',']);
(!name.is_empty()).then(|| name.to_owned())
})
.collect()
}
fn imports(&self) -> Vec<(String, String)> {
self.body
.lines()
.filter_map(|line| {
let rest = line.strip_prefix("import ")?;
let path = rest.split_whitespace().next()?;
let (package, name) = path.rsplit_once('.')?;
Some((package.to_owned(), name.to_owned()))
})
.collect()
}
fn staged_text(&self) -> String {
let mut text = "\n".repeat(self.fence_line);
text.push_str(&self.staging_body);
text
}
fn locator(&self) -> String {
format!("{}:{}", self.origin, self.fence_line)
}
}
fn align_columns(markdown: &str, fence_line: usize, body: &str) -> String {
let source: Vec<&str> = markdown.lines().collect();
body.lines()
.enumerate()
.map(|(offset, line)| {
let source_line = source.get(fence_line + offset).copied().unwrap_or_default();
match source_line.len().checked_sub(line.len()) {
Some(prefix) if source_line.ends_with(line) => {
format!("{}{line}\n", " ".repeat(prefix))
}
_ => format!("{line}\n"),
}
})
.collect()
}
fn classify(origin: &str, markdown: &str) -> (Vec<Example>, Vec<String>) {
let mut examples = Vec::new();
let mut problems = Vec::new();
for block in fenced_blocks(markdown) {
if !is_example_language(&block.info) {
continue;
}
let locator = format!("{origin}:{}", block.fence_line);
let mut words = block.info.split([',', ' ']).filter(|word| !word.is_empty());
let language = words.next().unwrap_or_default().to_owned();
let mut allowed = BTreeSet::new();
let mut skip = false;
let mut bad = Vec::new();
for marker in words {
if marker == "ignore" {
skip = true;
} else if let Some(code) = marker.strip_prefix("allow=") {
if code.is_empty() {
bad.push(marker.to_owned());
} else {
allowed.insert(code.to_owned());
}
} else {
bad.push(marker.to_owned());
}
}
if skip {
if !allowed.is_empty() {
problems.push(format!(
"{locator}: `ignore` and `allow=` together — an ignored block is never \
compiled, so it can allow nothing."
));
}
continue;
}
if !bad.is_empty() {
problems.push(format!(
"{locator}: unrecognised fence marker(s) {bad:?}. This harness knows `ignore`, \
which skips the block, and `allow=<CODE>`, which permits one diagnostic code; \
anything else is a typo that would have silently skipped verification.",
));
continue;
}
if language != "ridl" && language != "typl" && language != "rsdl" {
problems.push(format!(
"{locator}: the language word is `{language}`, not exactly `ridl`, `typl` or \
`rsdl`. \
mdBook still renders this as an example a reader will believe, but the \
convention does not recognise it, so nothing would have compiled it. Spell the \
language word exactly, or mark the fence `ignore` if it is not an example.",
));
continue;
}
let example = Example {
origin: origin.to_owned(),
fence_line: block.fence_line,
language,
allowed,
staging_body: align_columns(markdown, block.fence_line, &block.body),
body: block.body,
};
if example.package().is_none() {
problems.push(format!(
"{}: a verified `{}` block declares no `package`. Every verified block is staged \
as a whole source file, so it needs one. Give it a `package` declaration, or \
mark the fence `{},ignore` if the block is a fragment shown for illustration.",
example.locator(),
example.language,
example.language,
));
} else {
examples.push(example);
}
}
(examples, problems)
}
struct Reported {
code: Option<String>,
file: Option<PathBuf>,
position: String,
headline: String,
}
fn parse_report(report: &str) -> Vec<Reported> {
let mut found: Vec<Reported> = Vec::new();
for line in report.lines() {
let severity = ["error", "warning", "note", "info"]
.into_iter()
.find(|severity| {
line.starts_with(&format!("{severity}["))
|| line.starts_with(&format!("{severity}:"))
});
if let Some(severity) = severity {
let code = line
.strip_prefix(severity)
.and_then(|rest| rest.strip_prefix('['))
.and_then(|rest| rest.split_once(']'))
.map(|(code, _)| code.to_owned());
found.push(Reported {
code,
file: None,
position: String::new(),
headline: line.chars().take(110).collect(),
});
continue;
}
if let Some(rest) = line.trim_start().strip_prefix("┌─ ")
&& let Some(last) = found.last_mut()
&& last.file.is_none()
{
let mut parts = rest.rsplitn(3, ':');
let column = parts.next().unwrap_or_default();
let row = parts.next().unwrap_or_default();
let path = parts.next().unwrap_or(rest);
last.file = Some(PathBuf::from(path));
last.position = format!("{row}:{column}");
}
}
found
}
fn stage(examples: &[Example], root: &Path) -> Vec<PathBuf> {
let mut members: BTreeMap<String, Vec<usize>> = BTreeMap::new();
for (index, example) in examples.iter().enumerate() {
let package = example
.package()
.expect("staged examples declare a package");
members.entry(package).or_default().push(index);
}
let mut staged = vec![PathBuf::new(); examples.len()];
for (package, blocks) in &members {
let directory = root.join(package.replace('.', "/"));
std::fs::create_dir_all(&directory).expect("create the package directory");
std::fs::write(
directory.join("ridl.toml"),
format!("[package]\nname = \"{package}\"\nversion = \"1.0.0\"\n"),
)
.expect("write the package manifest");
for &index in blocks {
let block = &examples[index];
let stem = Path::new(&block.origin)
.file_stem()
.expect("a Markdown file has a stem")
.to_string_lossy()
.into_owned();
let path = directory.join(format!("{stem}-L{}.{}", block.fence_line, block.language));
std::fs::write(&path, block.staged_text()).expect("write the staged example");
staged[index] = path;
}
}
let list = members
.keys()
.map(|package| format!("\"{}\"", package.replace('.', "/")))
.collect::<Vec<_>>()
.join(", ");
std::fs::write(
root.join("ridl.toml"),
format!("[workspace]\nmembers = [{list}]\n"),
)
.expect("write the workspace manifest");
staged
}
fn check(root: &Path, staged: &[PathBuf], examples: &[Example]) -> (i32, String, String) {
let output = Command::new(env!("CARGO_BIN_EXE_ridl"))
.arg("check")
.arg(root)
.output()
.expect("the ridl binary must run");
let mut raw = String::from_utf8_lossy(&output.stdout).into_owned();
raw.push_str(&String::from_utf8_lossy(&output.stderr));
let mut shown = raw.clone();
for (path, example) in staged.iter().zip(examples) {
shown = shown.replace(&path.display().to_string(), &example.origin);
}
(
output.status.code().expect("the process exits with a code"),
raw,
shown,
)
}
fn unresolved_imports(examples: &[Example]) -> Vec<String> {
let mut declared: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
for example in examples {
let package = example
.package()
.expect("staged examples declare a package");
declared
.entry(package)
.or_default()
.extend(example.declarations());
}
let mut problems = Vec::new();
for example in examples {
let own = example
.package()
.expect("staged examples declare a package");
for (package, name) in example.imports() {
if package == own {
problems.push(format!(
"{}: imports `{package}.{name}` from inside package `{package}` itself. \
Everything in a package is already visible to the rest of it, and the \
compiler diagnoses neither the redundancy nor a misspelling hidden by it.",
example.locator(),
));
continue;
}
match declared.get(&package) {
None => problems.push(format!(
"{}: imports `{package}.{name}`, but no block in the book declares package \
`{package}`. The compiler diagnoses an unresolved *package*, so this one it \
would have caught — but only once the book grows a package of that name \
would the missing *name* below start slipping through.",
example.locator(),
)),
Some(names) if !names.contains(&name) => {
let elsewhere: Vec<&str> = declared
.iter()
.filter(|(_, names)| names.contains(&name))
.map(|(package, _)| package.as_str())
.collect();
let hint = if elsewhere.is_empty() {
"no block in the book declares that name".to_owned()
} else {
format!("it is declared in {elsewhere:?}")
};
problems.push(format!(
"{}: imports `{package}.{name}`, but package `{package}` declares no \
`{name}` — {hint}. The compiler resolves the *package* and stops; an \
unresolved *name* inside a package it found draws no diagnostic, so \
this would have shipped as a broken example.",
example.locator(),
));
}
Some(_) => {}
}
}
}
problems
}
fn allows(allowed: &BTreeSet<String>, code: Option<&str>) -> bool {
match code {
Some(code) => allowed.contains(code),
None => false,
}
}
fn is_named(allowed: Option<&BTreeSet<String>>, code: Option<&str>) -> bool {
match allowed {
Some(allowed) => allows(allowed, code),
None => false,
}
}
fn verdict(code: i32, unallowed: &[String], stale: &[String]) -> Result<(), String> {
let mut reasons = Vec::new();
if code != 0 {
reasons.push(format!("`ridl check` exited {code}"));
}
if !unallowed.is_empty() {
reasons.push(format!(
"{} diagnostic(s) no block named:\n {}",
unallowed.len(),
unallowed.join("\n ")
));
}
if !stale.is_empty() {
reasons.push(format!(
"{} stale `allow=` marker(s):\n {}",
stale.len(),
stale.join("\n ")
));
}
if reasons.is_empty() {
Ok(())
} else {
Err(reasons.join("\n\n"))
}
}
fn verify_book(book_root: &Path) -> Result<usize, String> {
let mut examples = Vec::new();
let mut problems = Vec::new();
for file in markdown_files(book_root) {
let origin = file
.strip_prefix(book_root)
.expect("the file is under the book root")
.to_string_lossy()
.into_owned();
let markdown = std::fs::read_to_string(&file)
.unwrap_or_else(|error| panic!("read {}: {error}", file.display()));
let (found, found_problems) = classify(&origin, &markdown);
examples.extend(found);
problems.extend(found_problems);
}
if !problems.is_empty() {
return Err(format!(
"the book breaks the fence convention:\n\n{}\n",
problems.join("\n\n")
));
}
if examples.is_empty() {
return Err(format!(
"no verified `ridl`, `typl` or `rsdl` blocks found under {} — the harness would \
pass \
without checking anything. Either the book moved, or every block is marked \
`ignore`.",
book_root.display()
));
}
let import_problems = unresolved_imports(&examples);
if !import_problems.is_empty() {
return Err(format!(
"the book has unresolved imports:\n\n{}\n",
import_problems.join("\n\n")
));
}
let staging = TempDir::new("staging");
let staged = stage(&examples, staging.path());
let (code, raw, report) = check(staging.path(), &staged, &examples);
let mut unallowed = Vec::new();
let mut emitted: BTreeSet<(usize, String)> = BTreeSet::new();
for diagnostic in parse_report(&raw) {
let owner = diagnostic
.file
.as_ref()
.and_then(|file| staged.iter().position(|path| path == file));
if let (Some(index), Some(code)) = (owner, &diagnostic.code) {
emitted.insert((index, code.clone()));
}
let allowed = is_named(
owner.map(|index| &examples[index].allowed),
diagnostic.code.as_deref(),
);
if !allowed {
let origin = owner.map_or("<workspace>", |index| examples[index].origin.as_str());
unallowed.push(format!(
"{origin}:{} — {}",
diagnostic.position, diagnostic.headline
));
}
}
let mut stale = Vec::new();
for (index, example) in examples.iter().enumerate() {
for code in &example.allowed {
if !emitted.contains(&(index, code.clone())) {
stale.push(format!(
"{}: fence allows `{code}`, which the block does not draw. Remove the \
marker — and the prose explaining a diagnostic that no longer happens.",
example.locator()
));
}
}
}
if let Err(reasons) = verdict(code, &unallowed, &stale) {
let inventory = examples
.iter()
.map(|example| {
format!(
" {} -> package {}",
example.locator(),
example
.package()
.expect("staged examples declare a package")
)
})
.collect::<Vec<_>>()
.join("\n");
return Err(format!(
"`ridl check` rejected the book's examples.\nPaths below are \
`<book file>:<line>:<column>` — open them directly.\nFix the example, or name the \
code on its fence with `allow=<CODE>` and explain it in the prose.\n\n{report}\n\
{reasons}\n\nverified blocks:\n{inventory}\n"
));
}
Ok(examples.len())
}
fn markdown_files(root: &Path) -> Vec<PathBuf> {
let mut found = Vec::new();
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
let mut entries: Vec<PathBuf> = std::fs::read_dir(&dir)
.unwrap_or_else(|error| panic!("read {}: {error}", dir.display()))
.map(|entry| entry.expect("a readable directory entry").path())
.collect();
entries.sort();
for entry in entries {
if entry.is_dir() {
stack.push(entry);
} else if entry.extension().is_some_and(|extension| extension == "md") {
found.push(entry);
}
}
}
found.sort();
found
}
fn book_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../docs/book")
.canonicalize()
.expect("docs/book must exist")
}
fn book_of(label: &str, markdown: &str) -> TempDir {
let book = TempDir::new(label);
std::fs::write(book.path().join("chapter.md"), markdown).expect("write the book file");
book
}
#[test]
fn book_examples_compile() {
match verify_book(&book_root()) {
Ok(count) => assert!(count > 0, "the book must carry verified examples"),
Err(report) => panic!("{report}"),
}
}
#[test]
fn a_broken_example_is_rejected() {
let book = book_of(
"broken",
"# Chapter\n\nSome prose.\n\n```ridl\npackage veh.broken\n\ntype Bad : integer [10..0]\n```\n",
);
let report = verify_book(book.path()).expect_err("a broken example must be rejected");
assert!(
report.contains("chapter.md:5"),
"the report must name the file and the fence line, got:\n{report}"
);
assert!(
report.contains("TYPL-104"),
"the report must carry the compiler's diagnostic, got:\n{report}"
);
assert!(
!report.contains("warning[") && !report.contains("note["),
"the fixture must draw an error and nothing else, so that this test discriminates \
even if the exit-code check is removed; got:\n{report}"
);
}
#[test]
fn a_warned_example_is_rejected() {
let book = book_of(
"warned",
"```ridl\npackage veh.warned\n\ntype Speed : km/h [0.0..250.0 step 0.5]\n\n\
interface Cluster {\n signal currentSpeed : Speed\n}\n```\n",
);
let report = verify_book(book.path()).expect_err("a warned example must be rejected");
assert!(
report.contains("RIDL-100"),
"the report must carry the warning, got:\n{report}"
);
}
#[test]
fn a_noted_example_is_rejected_unless_allowed() {
const BODY: &str = "package veh.noted\n\ntype FwBlock : bytes [1..65536]\n";
let book = book_of("noted", &format!("```ridl\n{BODY}```\n"));
let report = verify_book(book.path()).expect_err("an unexplained note must be rejected");
assert!(
report.contains("TYPL-115"),
"the report must carry the note, got:\n{report}"
);
let allowed = book_of("noted-ok", &format!("```ridl,allow=TYPL-115\n{BODY}```\n"));
assert_eq!(
verify_book(allowed.path()).expect("an allowed note passes"),
1
);
}
#[test]
fn an_allowance_does_not_cover_another_code() {
let book = book_of(
"allow-narrow",
"```ridl,allow=RIDL-406\npackage veh.narrow\n\ntype FwBlock : bytes [1..65536]\n```\n",
);
let report = verify_book(book.path()).expect_err("an unrelated code must still fail");
assert!(
report.contains("TYPL-115"),
"the unnamed code must be reported, got:\n{report}"
);
}
#[test]
fn an_unmarked_fragment_is_rejected() {
let book = book_of(
"fragment",
"```ridl\nsignal currentSpeed : Speed @10ms\n```\n",
);
let report = verify_book(book.path()).expect_err("an unmarked fragment must be rejected");
assert!(
report.contains("chapter.md:1") && report.contains("declares no `package`"),
"the report must name the block and say what is wrong, got:\n{report}"
);
}
#[test]
fn a_mistyped_marker_is_rejected() {
let book = book_of(
"marker",
"```ridl,ignroe\nsignal currentSpeed : Speed @10ms\n```\n",
);
let report = verify_book(book.path()).expect_err("a mistyped marker must be rejected");
assert!(
report.contains("unrecognised fence marker"),
"the report must name the bad marker, got:\n{report}"
);
}
#[test]
fn an_ignored_block_is_skipped_and_an_empty_book_fails() {
let book = book_of(
"ignored",
"```ridl,ignore\nsignal currentSpeed : Speed @10ms\n```\n",
);
let report = verify_book(book.path()).expect_err("a book with no verified block must fail");
assert!(
report.contains("no verified"),
"the report must say nothing was verified, got:\n{report}"
);
}
#[test]
fn an_indented_fence_is_verified_at_every_list_depth() {
let indents = [
("three spaces", " "),
("four spaces", " "),
("six spaces", " "),
("a tab", "\t"),
];
for (label, pad) in indents {
let book = book_of(
label,
&format!(
"```ridl\npackage zz.clean\n\ntype Ok : integer [0..1]\n```\n\n\
10. Step:\n\n{pad}```ridl\n{pad}package zz.indented\n\n\
{pad}type Bad : integer [10..0]\n{pad}```\n"
),
);
let report = match verify_book(book.path()) {
Ok(count) => panic!("indent {label}: the book passed with {count} block(s) verified"),
Err(report) => report,
};
assert!(
report.contains("TYPL-104"),
"indent {label}: the indented block must be verified, got:\n{report}"
);
}
}
#[test]
fn a_fence_in_any_container_is_verified() {
let broken = "package zz.probe\n\ntype Bad : integer [10..0]";
let quoted: String = broken.lines().map(|line| format!("> {line}\n")).collect();
let cases = [
("block quote", format!("> ```ridl\n{quoted}> ```\n")),
(
"list item, unclosed fence in the step before it",
format!(
"1. Run it:\n\n ```console\n $ ridl check\n\n2. Then:\n\n ```ridl\n {}\n ```\n",
broken.replace('\n', "\n ")
),
),
(
"list ended by a column-zero fence",
format!("- Step:\n\n ```console\n $ ridl check\n\n```ridl\n{broken}\n```\n"),
),
(
"after an indented code block holding an unclosed fence",
format!("Text:\n\n ```console\n $ ridl check\n\n```ridl\n{broken}\n```\n"),
),
(
"HTML block",
format!("<div>\n\n```ridl\n{broken}\n```\n\n</div>\n"),
),
(
"nested list at column 6",
format!(
"1. outer\n 1. inner:\n\n ```ridl\n {}\n ```\n",
broken.replace('\n', "\n ")
),
),
];
for (label, markdown) in cases {
let book = book_of(
"container",
&format!("```ridl\npackage zz.clean\n\ntype Ok : integer [0..1]\n```\n\n{markdown}"),
);
let report = match verify_book(book.path()) {
Ok(count) => panic!("{label}: the book passed with {count} block(s) verified"),
Err(report) => report,
};
assert!(
report.contains("TYPL-104"),
"{label}: the block must be compiled and its diagnostic reported, got:\n{report}"
);
}
}
#[test]
fn a_near_miss_language_word_is_refused() {
for spelling in ["RIDL", "Ridl", "ridl{.class}", "typl-ish", "RSDL"] {
let book = book_of(
"near-miss",
&format!(
"```ridl\npackage zz.clean\n\ntype Ok : integer [0..1]\n```\n\n\
```{spelling}\npackage zz.probe\n```\n"
),
);
let report = match verify_book(book.path()) {
Ok(count) => panic!("{spelling}: the book passed with {count} block(s) verified"),
Err(report) => report,
};
assert!(
report.contains("not exactly `ridl`, `typl` or `rsdl`"),
"{spelling}: the report must name the language word, got:\n{report}"
);
}
}
#[test]
fn ignore_suppresses_a_refusal() {
for spelling in ["RIDL", "ridl{.class}"] {
let book = book_of(
"ignore-refusal",
&format!(
"```ridl\npackage zz.clean\n\ntype Ok : integer [0..1]\n```\n\n\
```{spelling},ignore\nnot an example at all\n```\n"
),
);
assert_eq!(
verify_book(book.path())
.unwrap_or_else(|report| panic!("{spelling},ignore must pass, got:\n{report}")),
1,
"{spelling}: only the clean block is verified"
);
}
}
#[test]
fn tilde_and_long_fences_are_verified() {
for opener in ["~~~ridl", "````ridl", "~~~~~ridl"] {
let marker: String = opener
.chars()
.take_while(|character| *character == '`' || *character == '~')
.collect();
let book = book_of(
"long-fence",
&format!("{opener}\npackage zz.longfence\n\ntype Bad : integer [10..0]\n{marker}\n"),
);
let report = match verify_book(book.path()) {
Ok(count) => panic!("{opener}: must be verified, but the book passed ({count} block)"),
Err(report) => report,
};
assert!(
report.contains("TYPL-104"),
"{opener}: the block must be verified and its diagnostic reported, got:\n{report}"
);
}
}
#[test]
fn a_fence_quoted_inside_a_longer_fence_is_not_extracted() {
let book = book_of(
"quoted",
"````markdown\n```ridl\nnot an example — no package here\n```\n````\n\n\
```ridl\npackage zz.real\n\ntype Speed : km/h [0.0..250.0 step 0.5]\n```\n",
);
assert_eq!(
verify_book(book.path()).expect("only the real block is verified"),
1
);
}
#[test]
fn an_include_directive_is_not_expanded() {
let root = TempDir::new("include");
let book_dir = root.path().join("book");
std::fs::create_dir_all(&book_dir).expect("create the book directory");
std::fs::write(
book_dir.join("chapter.md"),
"```ridl\npackage zz.include\n\ntype Ok : integer [0..1]\n```\n\n\
{{#include ../specification.md}}\n",
)
.expect("write the book file");
std::fs::write(
root.path().join("specification.md"),
"```ridl\npackage zz.included\n\ntype Bad : integer [10..0]\n```\n",
)
.expect("write the included file, outside the book directory");
assert_eq!(
verify_book(&book_dir).expect("the included file's broken block must never surface"),
1,
"only the clean block written directly in the book is verified"
);
}
#[test]
fn an_unresolved_import_is_rejected() {
let book = book_of(
"import",
"```ridl\npackage zz.vocab\n\ntype Speed : km/h [0.0..250.0 step 0.5]\n```\n\n\
```ridl\npackage zz.use\n\nimport zz.vocab.NoSuchType\n\n\
interface I {\n signal s : Speed @10ms\n}\n```\n",
);
let report = verify_book(book.path()).expect_err("an unresolved import must be rejected");
assert!(
report.contains("NoSuchType") && report.contains("declares no"),
"the report must name the unresolved import, got:\n{report}"
);
}
#[test]
fn an_import_from_the_wrong_package_is_rejected() {
let book = book_of(
"wrong-package",
"```ridl\npackage zz.here\n\nstruct DoorPayload {\n isOpen : boolean\n}\n```\n\n\
```ridl\npackage zz.there\n\ntype Speed : km/h [0.0..250.0 step 0.5]\n```\n\n\
```ridl\npackage zz.user\n\nimport zz.there.DoorPayload\n```\n",
);
let report = verify_book(book.path()).expect_err("a wrong-package import must be rejected");
assert!(
report.contains("zz.here"),
"the report must say where the name is declared, got:\n{report}"
);
}
#[test]
fn a_self_import_is_rejected() {
let book = book_of(
"self-import",
"```ridl\npackage zz.self\n\nimport zz.self.Speed\n\n\
type Speed : km/h [0.0..250.0 step 0.5]\n```\n",
);
let report = verify_book(book.path()).expect_err("a self-import must be rejected");
assert!(
report.contains("from inside package"),
"the report must name the self-import, got:\n{report}"
);
}
#[test]
fn a_stale_allowance_is_rejected() {
let cases = [
("real-code", "allow=TYPL-104"),
("unknown-code", "allow=NOPE-999"),
("doubled", "allow=allow=X"),
];
for (label, marker) in cases {
let book = book_of(
label,
&format!(
"```ridl,{marker}\npackage zz.stale\n\ntype Speed : km/h [0.0..250.0 step 0.5]\n```\n"
),
);
let report = match verify_book(book.path()) {
Ok(count) => panic!("{label}: the book passed with {count} block(s) verified"),
Err(report) => report,
};
assert!(
report.contains("which the block does not draw"),
"{label}: the report must name the stale allowance, got:\n{report}"
);
}
}
#[test]
fn an_uncoded_diagnostic_cannot_be_allowed() {
let book = book_of(
"uncoded",
"```ridl\npackage zz.uncoded\n\ninterface I {\n signal s : NoSuchType @10ms\n}\n```\n",
);
let report = verify_book(book.path()).expect_err("an uncoded diagnostic must be rejected");
let (_, unnamed) = report
.split_once("diagnostic(s) no block named:")
.expect("the report lists the diagnostics no block named");
assert!(
unnamed.contains("unknown type name"),
"the uncoded diagnostic must reach the `no block named` list, not merely appear in the \
raw report — that list is what the allow-check reasons over; got:\n{report}"
);
}
#[test]
fn parse_report_detects_an_uncoded_diagnostic() {
let report = "error: unknown type name `Speed`\n ┌─ /staging/veh/common/a.ridl:4:20\n │\n";
let found = parse_report(report);
assert_eq!(found.len(), 1, "one diagnostic is parsed");
assert!(
found[0].code.is_none(),
"a diagnostic printed without `[CODE]` parses as uncoded"
);
assert_eq!(
found[0].file,
Some(PathBuf::from("/staging/veh/common/a.ridl")),
"its locator is attributed to the staged file"
);
assert_eq!(found[0].position, "4:20", "its position is carried through");
}
#[test]
fn allows_refuses_an_uncoded_diagnostic() {
let permissive: BTreeSet<String> = ["TYPL-104", "RIDL-406", "TYPL-115"]
.into_iter()
.map(str::to_owned)
.collect();
assert!(allows(&permissive, Some("TYPL-104")), "a named code passes");
assert!(
!allows(&permissive, Some("RIDL-100")),
"a real code the block did not name fails"
);
assert!(
!allows(&permissive, None),
"an uncoded diagnostic can never be allowed, however permissive the block"
);
}
#[test]
fn verdict_fails_on_a_bare_non_zero_exit() {
assert!(
verdict(0, &[], &[]).is_ok(),
"a clean run with a zero exit passes"
);
let failure = verdict(101, &[], &[]).expect_err("a non-zero exit must fail on its own");
assert!(
failure.contains("exited 101"),
"the reason must name the exit code, got:\n{failure}"
);
assert!(
verdict(0, &["a diagnostic".to_owned()], &[]).is_err(),
"an unnamed diagnostic fails even at exit 0 — notes and warnings do not move the code"
);
assert!(
verdict(0, &[], &["a stale marker".to_owned()]).is_err(),
"a stale allowance fails even at exit 0"
);
}
#[test]
fn an_unclosed_fence_is_still_verified() {
let book = book_of(
"unclosed",
"```ridl\npackage zz.clean\n\ntype Ok : integer [0..1]\n```\n\n\
```ridl\npackage zz.unclosed\n\ntype Bad : integer [10..0]\n",
);
let report = verify_book(book.path()).expect_err("the unclosed block must be verified");
assert!(
report.contains("TYPL-104"),
"the unclosed block must be compiled, got:\n{report}"
);
}
#[test]
fn a_backtick_run_does_not_close_a_tilde_fence() {
let book = book_of(
"mixed-markers",
"~~~ridl\npackage zz.mixed\n```\ntype Bad : integer [10..0]\n~~~\n",
);
let report = verify_book(book.path()).expect_err("the whole block must be one block");
assert!(
!report.contains("never closed"),
"the tilde block must be seen as closed, got:\n{report}"
);
}
#[test]
fn ignore_and_allow_together_are_rejected() {
let book = book_of(
"ignore-allow",
"```ridl,ignore,allow=TYPL-104\npackage zz.both\n```\n",
);
let report = verify_book(book.path()).expect_err("the combination must be rejected");
assert!(
report.contains("`ignore` and `allow=` together"),
"the report must name the contradiction, got:\n{report}"
);
}
#[test]
fn an_empty_allow_value_is_rejected() {
let book = book_of("empty-allow", "```ridl,allow=\npackage zz.empty\n```\n");
let report = verify_book(book.path()).expect_err("an empty allowance must be rejected");
assert!(
report.contains("unrecognised fence marker"),
"the report must name the bad marker, got:\n{report}"
);
}
#[test]
fn a_reported_line_is_the_markdown_line() {
let filler = "\n".repeat(40);
let body = "package zz.deep\n\ntype Bad : integer [10..0]";
let cases = [
(
"top level",
format!("# Chapter\n{filler}\n```ridl\n{body}\n```\n"),
),
(
"list item",
format!(
"# Chapter\n{filler}\n10. Step:\n\n ```ridl\n {}\n ```\n",
body.replace('\n', "\n ")
),
),
(
"block quote",
format!(
"# Chapter\n{filler}\n> ```ridl\n{}> ```\n",
body.lines()
.map(|line| if line.is_empty() {
">\n".to_owned()
} else {
format!("> {line}\n")
})
.collect::<String>()
),
),
(
"nested list at column 6",
format!(
"# Chapter\n{filler}\n1. a\n 1. b:\n\n ```ridl\n {}\n ```\n",
body.replace('\n', "\n ")
),
),
];
let mut wrong = Vec::new();
for (label, markdown) in cases {
let fault_line = markdown
.lines()
.position(|line| line.contains("type Bad"))
.expect("the fixture has a fault")
+ 1;
assert!(
fault_line > 40,
"{label}: the fixture must put the fault well below the top of the file"
);
let fault_column = markdown
.lines()
.nth(fault_line - 1)
.expect("the fault line exists")
.find('[')
.expect("the fault is a range")
+ 1;
let book = book_of("deep", &markdown);
let report = verify_book(book.path()).expect_err("the broken block must be rejected");
if !report.contains(&format!("chapter.md:{fault_line}:{fault_column}")) {
let reported = report
.lines()
.find(|line| line.contains("chapter.md:"))
.unwrap_or("<no location reported>")
.trim();
wrong.push(format!(
" {label}: expected chapter.md:{fault_line}:{fault_column}, \
report says: {reported}"
));
}
}
assert!(
wrong.is_empty(),
"a reported line and column must be the Markdown line and column, in every \
container. The body-line assumption, or the column alignment, has broken in:\n{}",
wrong.join("\n")
);
}
#[test]
fn a_typl_fence_is_verified() {
let book = book_of(
"typl",
"```typl\npackage zz.typl\n\ntype Bad : integer [10..0]\n```\n",
);
let report = verify_book(book.path()).expect_err("a broken typl block must be rejected");
assert!(
report.contains("TYPL-104"),
"the typl block must be compiled, got:\n{report}"
);
}
#[test]
fn an_rsdl_fence_is_verified() {
let contract = "```ridl\npackage zz.contract\n\ntype Level : integer [0..7]\n\n\
interface Fan {\n signal level : Level @[100ms..1s]\n}\n\n\
service zz.contract.fan : Fan\n```\n\n";
let clean = book_of(
"rsdl-clean",
&format!(
"{contract}```rsdl\npackage zz.plan\n\ncomponent Blower {{ offers zz.contract.fan }}\n\n\
system Plant {{ Blower }}\n```\n"
),
);
assert_eq!(
verify_book(clean.path()).unwrap_or_else(|report| panic!("{report}")),
2,
"the ridl and the rsdl block are both verified"
);
let broken = book_of(
"rsdl-broken",
&format!(
"{contract}```rsdl\npackage zz.plan\n\ncomponent Blower {{ offers zz.contract.fam }}\n```\n"
),
);
let report = verify_book(broken.path()).expect_err("a broken rsdl block must be rejected");
assert!(
report.contains("RSDL-310"),
"the rsdl block must be compiled, got:\n{report}"
);
}
#[test]
fn a_second_system_fence_is_rejected() {
let book = book_of(
"rsdl-two-systems",
"```rsdl\npackage zz.one\n\nsystem One {}\n```\n\n\
```rsdl\npackage zz.two\n\nsystem Two {}\n```\n",
);
let report = verify_book(book.path()).expect_err("two systems must be rejected");
assert!(
report.contains("RSDL-601"),
"the second system must draw RSDL-601, got:\n{report}"
);
}
#[test]
fn markdown_in_a_subdirectory_is_read() {
let book = TempDir::new("nested");
std::fs::create_dir_all(book.path().join("part/two")).expect("create the subdirectory");
std::fs::write(
book.path().join("part/two/deep.md"),
"```ridl\npackage zz.nested\n\ntype Bad : integer [10..0]\n```\n",
)
.expect("write the nested file");
let report = verify_book(book.path()).expect_err("the nested block must be verified");
assert!(
report.contains("deep.md") && report.contains("TYPL-104"),
"the nested file must be read and named, got:\n{report}"
);
}
#[test]
fn the_option_set_matches_mdbook() {
for (name, flag) in [
("ENABLE_TABLES", Options::ENABLE_TABLES),
("ENABLE_FOOTNOTES", Options::ENABLE_FOOTNOTES),
("ENABLE_STRIKETHROUGH", Options::ENABLE_STRIKETHROUGH),
("ENABLE_TASKLISTS", Options::ENABLE_TASKLISTS),
(
"ENABLE_HEADING_ATTRIBUTES",
Options::ENABLE_HEADING_ATTRIBUTES,
),
] {
assert!(
MDBOOK_OPTIONS.contains(flag),
"{name} is one of the five mdBook enables"
);
}
for (name, flag) in [
("ENABLE_OLD_FOOTNOTES", Options::ENABLE_OLD_FOOTNOTES),
(
"ENABLE_YAML_STYLE_METADATA_BLOCKS",
Options::ENABLE_YAML_STYLE_METADATA_BLOCKS,
),
(
"ENABLE_PLUSES_DELIMITED_METADATA_BLOCKS",
Options::ENABLE_PLUSES_DELIMITED_METADATA_BLOCKS,
),
("ENABLE_DEFINITION_LIST", Options::ENABLE_DEFINITION_LIST),
] {
assert!(
!MDBOOK_OPTIONS.contains(flag),
"{name} changes block structure and mdBook does not enable it"
);
}
assert_eq!(
MDBOOK_OPTIONS.iter().count(),
5,
"exactly five flags — a sixth means someone widened the set without \
checking it against mdBook"
);
assert_ne!(
MDBOOK_OPTIONS,
Options::all(),
"`Options::all()` is what this constant exists to stop being used"
);
}
#[test]
fn the_option_set_reads_the_blocks_mdbook_reads() {
let cases = [
(
"fence indented under a footnote definition",
"Text[^1]\n\n[^1]: Note:\n\n ```ridl\n package zz.a\n ```\n",
1,
),
(
"fence inside a leading `---` block",
"---\n```ridl\npackage zz.a\n```\n---\n",
1,
),
(
"fence inside a leading `+++` block",
"+++\n```ridl\npackage zz.a\n```\n+++\n",
1,
),
(
"fence under a definition-list definition",
"Term\n\n: Definition:\n\n ```ridl\n package zz.a\n ```\n",
0,
),
];
for (label, markdown, expected) in cases {
let found = fenced_blocks(markdown)
.into_iter()
.filter(|block| is_example_language(&block.info))
.count();
assert_eq!(
found, expected,
"{label}: the harness must see what mdBook renders"
);
}
}
#[test]
fn a_diagnostic_owned_by_no_block_is_never_allowed() {
let permissive: BTreeSet<String> = ["TYPL-104", "MANI-001"]
.into_iter()
.map(str::to_owned)
.collect();
assert!(
is_named(Some(&permissive), Some("TYPL-104")),
"a block that named the code allows it"
);
assert!(
!is_named(None, Some("MANI-001")),
"a diagnostic owned by no block is never allowed, whatever its code"
);
assert!(
!is_named(None, None),
"nor when it is uncoded as well as unowned"
);
}
#[test]
fn a_failure_report_names_the_book_not_the_staging_directory() {
let book = book_of(
"paths",
"```ridl\npackage zz.paths\n\ntype Bad : integer [10..0]\n```\n",
);
let report = verify_book(book.path()).expect_err("the broken block must be rejected");
assert!(
report.contains("chapter.md:"),
"the report must name the Markdown file, got:\n{report}"
);
assert!(
!report.contains("ridl-book-staging"),
"the report must not leak the staging directory, got:\n{report}"
);
assert!(
!report.contains(".ridl:"),
"no staged source path should survive the rewrite, got:\n{report}"
);
}
#[test]
fn a_block_without_a_usable_package_is_refused() {
let cases = [
("an empty body", ""),
("a whitespace-only body", " \n\t\n"),
("a bare `package` keyword", "package\n"),
("`package` with no name", "package \n"),
("`package` with only a comment", "package // which one?\n"),
];
for (label, body) in cases {
let (examples, problems) = classify("chapter.md", &format!("```ridl\n{body}```\n"));
assert!(
examples.is_empty(),
"{label}: nothing may reach staging without a package name"
);
assert!(
problems.iter().any(|p| p.contains("declares no `package`")),
"{label}: the author must be told what is wrong, got: {problems:?}"
);
}
let book = book_of("no-package", "```ridl\npackage \n```\n");
let report = verify_book(book.path()).expect_err("the book must be refused");
assert!(report.contains("declares no `package`"), "got:\n{report}");
}