use std::cell::RefCell;
use std::path::PathBuf;
use schemars::generate::{SchemaGenerator, SchemaSettings};
use schemars::{JsonSchema, Schema};
use serde_json::{json, Map, Value};
pub const SCHEMA_FILE: &str = "schemas/wasm.schema.json";
const NOT_OPERATIONS: &[&str] = &["codes", "version"];
pub struct Arg {
name: &'static str,
schema: Schema,
required: bool,
}
pub fn arg(name: &'static str, schema: Schema) -> Arg {
Arg {
name,
schema,
required: true,
}
}
#[allow(dead_code, reason = "this file is shared; not every binding has one")]
pub fn optional(name: &'static str, schema: Schema) -> Arg {
Arg {
name,
schema,
required: false,
}
}
#[allow(dead_code, reason = "this file is shared; not every binding has one")]
pub fn string_enum<'a>(names: impl IntoIterator<Item = &'a str>) -> Schema {
let names: Vec<&str> = names.into_iter().collect();
Schema::try_from(json!({ "type": "string", "enum": names })).expect("an object is a schema")
}
struct Operation {
id: &'static str,
args: Vec<Arg>,
output: Option<Schema>,
}
pub struct Schemas {
generator: RefCell<SchemaGenerator>,
operations: RefCell<Vec<Operation>>,
}
impl Schemas {
pub fn new() -> Self {
Self {
generator: RefCell::new(SchemaSettings::draft2020_12().into_generator()),
operations: RefCell::new(Vec::new()),
}
}
pub fn of<T: JsonSchema>(&self) -> Schema {
self.generator.borrow_mut().subschema_for::<T>()
}
pub fn op(&self, id: &'static str, args: Vec<Arg>, output: Schema) {
self.operations.borrow_mut().push(Operation {
id,
args,
output: Some(output),
});
}
#[allow(dead_code, reason = "this file is shared; not every binding has one")]
pub fn op_by_hand(&self, id: &'static str, args: &[&'static str]) {
let args = args
.iter()
.map(|name| arg(name, Schema::default()))
.collect();
self.operations.borrow_mut().push(Operation {
id,
args,
output: None,
});
}
pub fn document(&self, title: &str) -> Value {
let mut defs: Map<String, Value> = self.generator.borrow_mut().take_definitions(true);
for op in self.operations.borrow().iter() {
let Some(output) = &op.output else {
continue;
};
let names: Vec<&str> = op.args.iter().map(|a| a.name).collect();
let mut properties = Map::new();
for a in &op.args {
properties.insert(a.name.to_string(), a.schema.clone().to_value());
}
let required: Vec<&str> = op
.args
.iter()
.filter(|a| a.required)
.map(|a| a.name)
.collect();
defs.insert(
format!("{}.input", op.id),
json!({
"description": format!(
"The arguments of {}({}), by name; the WebAssembly function takes them in that order.",
op.id,
names.join(", ")
),
"type": "object",
"properties": properties,
"required": required,
"additionalProperties": false,
}),
);
defs.insert(format!("{}.output", op.id), output.clone().to_value());
}
json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": title,
"$defs": defs,
})
}
pub fn check(&self, title: &str) {
let path = root().join(SCHEMA_FILE);
let generated = serde_json::to_string_pretty(&self.document(title))
.expect("a JSON value serializes")
+ "\n";
if std::env::var_os("UPDATE_SCHEMAS").is_some() {
std::fs::create_dir_all(path.parent().expect("the file has a directory"))
.expect("the schema directory can be created");
std::fs::write(&path, &generated).expect("the schema file can be written");
return;
}
let committed = std::fs::read_to_string(&path)
.unwrap_or_default()
.replace("\r\n", "\n");
assert!(
committed == generated,
"{SCHEMA_FILE} is not what the binding's types generate; \
regenerate it with UPDATE_SCHEMAS=1 cargo test --features wasm --lib engine_card"
);
}
pub fn check_exports(&self, source: &str) {
let exported: Vec<(String, Vec<String>)> = exports(source)
.into_iter()
.filter(|(name, _)| !NOT_OPERATIONS.contains(&name.as_str()))
.collect();
let declared: Vec<(String, Vec<String>)> = self
.operations
.borrow()
.iter()
.map(|op| {
(
op.id.to_string(),
op.args.iter().map(|a| a.name.to_string()).collect(),
)
})
.collect();
let sorted = |mut v: Vec<(String, Vec<String>)>| {
v.sort();
v
};
assert_eq!(sorted(exported), sorted(declared));
}
pub fn check_card(&self) {
let card: Value = serde_json::from_str(
&std::fs::read_to_string(root().join("engine-card.json"))
.expect("engine-card.json is at the repository root"),
)
.expect("engine-card.json is JSON");
let listed = card["operations"]
.as_array()
.expect("the card has operations");
let mut ids: Vec<&str> = listed
.iter()
.map(|op| op["id"].as_str().expect("an operation has an id"))
.collect();
ids.sort_unstable();
let mut expected: Vec<&str> = self.operations.borrow().iter().map(|op| op.id).collect();
expected.sort_unstable();
assert_eq!(ids, expected, "the card's operations are the binding's");
let by_hand: Vec<&str> = self
.operations
.borrow()
.iter()
.filter(|op| op.output.is_none())
.map(|op| op.id)
.collect();
for op in listed {
let id = op["id"].as_str().expect("an operation has an id");
if by_hand.contains(&id) {
continue;
}
for side in ["input", "output"] {
assert_eq!(
op[side]["schema"].as_str(),
Some(format!("{SCHEMA_FILE}#/$defs/{id}.{side}").as_str()),
"{id}.{side}.schema"
);
}
}
}
}
fn root() -> PathBuf {
let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
manifest
.ancestors()
.find(|dir| dir.join("engine-card.json").is_file())
.map_or(manifest.clone(), PathBuf::from)
}
const CLOCK_AND_ENTROPY: &[&str] = &[
"Instant::now",
"SystemTime",
"Date::now",
"Math::random",
"thread_rng",
"rand::rng()",
"rand::random",
"from_entropy",
"from_os_rng",
"OsRng",
"getrandom",
];
pub fn clock_and_entropy_uses() -> Vec<(String, &'static str)> {
let mut found = Vec::new();
let mut dirs = vec![root()];
while let Some(dir) = dirs.pop() {
for entry in std::fs::read_dir(&dir).expect("a source directory is readable") {
let path = entry.expect("a directory entry").path();
let name = path.file_name().map(|n| n.to_string_lossy().into_owned());
if path.is_dir() {
if !name.is_some_and(|n| n == "target" || n.starts_with('.')) {
dirs.push(path);
}
continue;
}
let in_src = path
.strip_prefix(root())
.expect("under the repository root")
.components()
.any(|c| c.as_os_str() == "src");
if !in_src
|| path.extension().is_none_or(|e| e != "rs")
|| path.ends_with("engine_card.rs")
{
continue;
}
let text = std::fs::read_to_string(&path).expect("a source file is readable");
let mut kept = Vec::new();
let mut lines = text.lines().peekable();
while let Some(line) = lines.next() {
if line == "#[cfg(test)]" && lines.peek().is_some_and(|l| l.starts_with("mod ")) {
lines.by_ref().find(|l| *l == "}");
continue;
}
if !line.trim_start().starts_with("//") {
kept.push(line);
}
}
let code = kept.join("\n");
for needle in CLOCK_AND_ENTROPY {
if code.contains(needle) {
let rel = path
.strip_prefix(root())
.expect("under the repository root");
found.push((rel.to_string_lossy().replace('\\', "/"), *needle));
}
}
}
}
found.sort();
found
}
pub fn exports(source: &str) -> Vec<(String, Vec<String>)> {
let lines: Vec<&str> = source.lines().collect();
let mut found = Vec::new();
for (i, line) in lines.iter().enumerate() {
let Some(rest) = line.strip_prefix("pub fn ") else {
continue;
};
let exported = lines[..i]
.iter()
.rev()
.take_while(|l| {
let t = l.trim_start();
l.starts_with("#[")
|| t.starts_with("///")
|| l.starts_with(' ')
|| l.starts_with(")]")
})
.any(|l| l.starts_with("#[wasm_bindgen"));
if !exported {
continue;
}
let name: String = rest
.chars()
.take_while(|c| c.is_alphanumeric() || *c == '_')
.collect();
let start = rest.as_ptr() as usize - source.as_ptr() as usize + name.len();
found.push((name, argument_names(&source[start..])));
}
found
}
fn argument_names(text: &str) -> Vec<String> {
let mut depth = 0usize;
let mut in_string = false;
let mut attribute_depth: Option<usize> = None;
let mut current = String::new();
let mut names = Vec::new();
let mut chars = text.chars().peekable();
while let Some(c) = chars.next() {
if in_string {
if c == '\\' {
chars.next();
} else if c == '"' {
in_string = false;
}
continue;
}
match c {
'"' => in_string = true,
'#' if chars.peek() == Some(&'[') => attribute_depth = Some(depth),
'(' | '[' | '<' => depth += 1,
')' | ']' | '>' => {
depth -= 1;
if c == ']' && attribute_depth == Some(depth) {
attribute_depth = None;
continue;
}
if depth == 0 {
break;
}
}
',' if depth == 1 => {
names.extend(argument_name(¤t));
current.clear();
continue;
}
_ => {}
}
if depth >= 1 && attribute_depth.is_none() && !(depth == 1 && c == '(') {
current.push(c);
}
}
names.extend(argument_name(¤t));
names
}
fn argument_name(text: &str) -> Option<String> {
let (name, _) = text.split_once(':')?;
let name = name.trim().trim_start_matches("mut ").trim();
(!name.is_empty()).then(|| name.to_string())
}