Files
DarkRoom/core/dr-pipeline/build.rs
T
dtourolleandClaude Opus 5 7c57f490fe Declare a develop operation in YAML, and generate the rest
An operation was, in the overwhelming majority of cases, four facts: what
its parameters are, what uniforms they compute, what WGSL those uniforms
drive, and where it sits in the chain. Written in Rust those four facts
arrived wrapped in ninety lines of trait implementation — a match on
parameter id to a struct field, another match back, an is_active comparing
each field to its default, a Vec<Uniform> built by hand. All mechanical,
and each one a place to make a silent mistake: a param() arm returning the
wrong field reads perfectly and breaks the sidecar round-trip.

So the four facts are the file now. core/dr-pipeline/ops/<id>.yaml is a
node, build.rs compiles it into the same Operation impl as before, and the
result lands in OUT_DIR — the same reasoning as style.yaml -> theme.slint,
including why it does not land beside the sources it would look exactly
like. Nothing downstream can tell a declared node from a hand-written one:
same &'static OpDescriptor, same fused-shader composition, same sidecar.

Nine nodes moved: exposure, white_balance, contrast, highlights_shadows,
blacks_whites, brilliance, vibrance, saturation, and the shared WGSL
helper registry. Their prose came with them, and so did their tests —
set/expect/expect_active/expect_wgsl in the declaration compile to real
#[test]s, so a node file carries its own proof rather than leaving it
behind in a file that no longer exists.

Two stayed in Rust and say so with `rust:`. The tone curve's neutral is a
relationship between five interpolated points rather than a set of values;
the colour mixer generates thirty-six faceted parameters from twelve
computed hue bands. A schema stretched to cover either would be a worse
language than Rust aimed at one caller. They still declare their position
here, because the chain's *order* is the one thing a reader comes to this
directory to learn, and an order written half in YAML and half in Rust
would be worse than either alone. default_chain() is generated from it.

Uniforms are derived by a small expression language — exp2(exposure),
blacks / 100 * 0.02 — compiled to Rust rather than interpreted, so an
unknown name or a wrong arity is a build error naming the file and the key
and the arithmetic costs nothing at runtime. The build script refuses a
duplicate order, a filename disagreeing with its id, a default outside its
own range, a test value the graph would clamp before the node saw it, a
helper that does not define the function it names, and a declared node
colliding with a file in src/ops.

Verified by adding a scratch node and removing it again: one file, no
other edit, and it joined the chain at its declared order with its test
running. 237 tests pass in dr-pipeline, clippy and fmt clean.

.yaml joins the traceability tool's scanned suffixes, because a node's
Rust now lives in OUT_DIR where a tag could never be linked from the
report. Coverage 47.7% -> 48.3%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 21:08:16 +02:00

1758 lines
60 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Compiles the nodes in `ops/*.yaml` into Rust implementing `Operation`.
//!
//! # Why a node is a YAML file
//!
//! A develop operation is, in the overwhelming majority of cases, four facts:
//! what its parameters are, what uniforms they compute, what WGSL those
//! uniforms drive, and where it sits in the chain. Written in Rust those four
//! facts arrive wrapped in ninety lines of trait implementation — a `match` on
//! parameter id to a struct field, another `match` back, an `is_active` that
//! compares each field to its default, a `Vec<Uniform>` built by hand. Every
//! one of those is mechanical, and every one is a place to make a silent
//! mistake: a `param()` arm returning the wrong field reads perfectly and
//! breaks the sidecar round-trip.
//!
//! So the four facts are the file, and this generates the rest.
//!
//! # What it does not do
//!
//! It does not change the runtime model. The generated code implements the
//! same [`Operation`](../src/operation.rs) trait, publishes the same
//! `&'static OpDescriptor`, and composes through the same fused-shader path.
//! Nothing downstream — not `dr-gpu`, not the develop panel — can tell a
//! declared node from a hand-written one, which is what allows the two to sit
//! side by side in one chain.
//!
//! A node that needs more than the four facts stays in Rust and declares
//! itself with `rust:` instead (see `ops/tone_curve.yaml`). The escape hatch
//! is deliberate: a schema stretched to cover the tone curve's interpolator
//! would be a worse language than Rust, aimed at one caller.
//!
//! # Output
//!
//! `OUT_DIR/nodes.rs`, included by `src/ops/mod.rs`. In `OUT_DIR` rather than
//! beside the hand-written sources for the reason `ui/dr-ui/build.rs` records:
//! a generated file sitting in `src/ops/` looks exactly like the files around
//! it that *are* meant to be edited, and an edit to it survives until the next
//! `touch`.
use std::collections::{BTreeMap, BTreeSet};
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use serde_norway::{Mapping, Value};
/// The directory holding node declarations, relative to the manifest.
const OPS_DIR: &str = "ops";
/// Declarations whose name begins with this are not nodes.
const NON_NODE_PREFIX: &str = "_";
const HELPERS_FILE: &str = "_helpers.yaml";
const GENERATED: &str = "nodes.rs";
fn main() {
println!("cargo:rerun-if-changed={OPS_DIR}");
println!("cargo:rerun-if-changed=build.rs");
let manifest_dir = PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").expect("manifest dir"));
let out_dir = PathBuf::from(std::env::var_os("OUT_DIR").expect("OUT_DIR"));
let ops_dir = manifest_dir.join(OPS_DIR);
let src_ops = manifest_dir.join("src").join(OPS_DIR);
match generate(&ops_dir, &src_ops) {
Ok(rust) => {
let path = out_dir.join(GENERATED);
std::fs::write(&path, rust)
.unwrap_or_else(|e| fail(format!("writing {}: {e}", path.display())));
}
Err(e) => fail(e),
}
}
/// Build scripts report failure through stderr and a non-zero exit; a panic
/// buries the message under a backtrace and the "process didn't exit
/// successfully" boilerplate, which is exactly the wrong thing when the
/// message is the name of the key the author got wrong.
fn fail(message: String) -> ! {
eprintln!("\nerror: {message}\n");
std::process::exit(1);
}
// ---------------------------------------------------------------------------
// The declaration model
// ---------------------------------------------------------------------------
/// A WGSL helper function, either shared or declared by one node.
struct HelperDef {
name: String,
doc: Option<String>,
wgsl: String,
}
/// One parameter of a declared node.
struct ParamDef {
id: String,
doc: Option<String>,
/// The `ParamDescriptor` constructor call, already rendered.
ctor: String,
/// Needed to generate `is_active` and to range-check test values.
default: f64,
min: f64,
max: f64,
}
/// A uniform the node's fragment reads, and the expression computing it.
struct UniformDef {
name: String,
doc: Option<String>,
/// The Rust expression, compiled from the declared one.
rust: String,
}
/// One generated `#[test]`.
struct TestDef {
name: String,
why: Option<String>,
set: Vec<(String, f64)>,
expect: Vec<(String, f64)>,
expect_range: Vec<(String, f64, f64)>,
expect_active: Option<bool>,
expect_wgsl: Vec<String>,
expect_helper_wgsl: Vec<(String, Vec<String>)>,
}
/// A node: either declared in full, or a pointer to a hand-written type.
enum Node {
Declared {
id: String,
label: String,
order: i64,
doc: Option<String>,
placement: Option<String>,
params: Vec<ParamDef>,
uniforms: Vec<UniformDef>,
/// Names of shared helpers, in declaration order.
shared_helpers: Vec<String>,
/// Helpers this node defines for itself.
local_helpers: Vec<HelperDef>,
wgsl: String,
/// `None` means the default rule: active when any parameter has moved.
active: Option<String>,
tests: Vec<TestDef>,
},
Rust {
id: String,
order: i64,
/// The type in `crate::ops` implementing `Operation`.
ty: String,
why_rust: Option<String>,
placement: Option<String>,
},
}
impl Node {
fn id(&self) -> &str {
match self {
Node::Declared { id, .. } | Node::Rust { id, .. } => id,
}
}
fn order(&self) -> i64 {
match self {
Node::Declared { order, .. } | Node::Rust { order, .. } => *order,
}
}
fn placement(&self) -> Option<&str> {
match self {
Node::Declared { placement, .. } | Node::Rust { placement, .. } => placement.as_deref(),
}
}
}
// ---------------------------------------------------------------------------
// Driver
// ---------------------------------------------------------------------------
fn generate(ops_dir: &Path, src_ops: &Path) -> Result<String, String> {
let shared = read_helpers(&ops_dir.join(HELPERS_FILE))?;
let shared_names: BTreeSet<&str> = shared.helpers.iter().map(|h| h.name.as_str()).collect();
let mut nodes = Vec::new();
for path in node_files(ops_dir)? {
let name = file_stem(&path);
let node = read_node(&path, &shared_names)
.map_err(|e| format!("{}/{}.yaml: {e}", OPS_DIR, name))?;
// The filename and the id must agree. They are two names for one
// thing, and a node found by one and referred to by the other is a
// node nobody can grep for.
if node.id() != name {
return Err(format!(
"{}/{}.yaml declares `id: {}`; the file name and the id must match",
OPS_DIR,
name,
node.id()
));
}
nodes.push(node);
}
if nodes.is_empty() {
return Err(format!(
"{OPS_DIR}/ declares no nodes; expected at least one `<id>.yaml`"
));
}
check_unique(&nodes)?;
check_no_shadowing(&nodes, src_ops)?;
nodes.sort_by_key(|n| n.order());
emit(&shared, &nodes)
}
/// The node declarations, sorted so generation is deterministic.
///
/// Determinism is not cosmetic here: the generated file is an input to
/// `rustc`'s incremental cache, and a set of nodes that reorders between runs
/// would rebuild the crate every time.
fn node_files(dir: &Path) -> Result<Vec<PathBuf>, String> {
let entries = std::fs::read_dir(dir)
.map_err(|e| format!("cannot read {}: {e}", dir.display()))?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("cannot read {}: {e}", dir.display()))?;
let mut files: Vec<PathBuf> = entries
.into_iter()
.map(|e| e.path())
.filter(|p| p.extension().and_then(|e| e.to_str()) == Some("yaml"))
.filter(|p| !file_stem(p).starts_with(NON_NODE_PREFIX))
.collect();
files.sort();
Ok(files)
}
fn file_stem(path: &Path) -> String {
path.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default()
}
fn check_unique(nodes: &[Node]) -> Result<(), String> {
let mut ids = BTreeSet::new();
let mut orders: BTreeMap<i64, &str> = BTreeMap::new();
for node in nodes {
if !ids.insert(node.id()) {
return Err(format!("`{}` is declared by two files", node.id()));
}
// Two nodes at one order is an ambiguous pipeline, and the pipeline's
// order changes what the image looks like. Sorting would silently pick
// one, so refuse instead.
if let Some(other) = orders.insert(node.order(), node.id()) {
return Err(format!(
"`{}` and `{}` both declare `order: {}`; the chain order would \
be ambiguous, and operation order changes the result",
other,
node.id(),
node.order()
));
}
}
Ok(())
}
/// A declared node must not share a name with a hand-written module.
///
/// The same guard `ui/dr-ui/build.rs` applies to `theme.slint`: the generated
/// `pub mod exposure` and a `src/ops/exposure.rs` would both want that name,
/// and the error a reader gets from the collision says nothing about which
/// file to delete.
fn check_no_shadowing(nodes: &[Node], src_ops: &Path) -> Result<(), String> {
for node in nodes {
let Node::Declared { id, .. } = node else {
continue;
};
let shadowed = src_ops.join(format!("{id}.rs"));
if shadowed.exists() {
return Err(format!(
"{} exists and would collide with the module generated from \
{OPS_DIR}/{id}.yaml.\nThe node is now declared in YAML; delete \
the stale Rust file.",
shadowed.display()
));
}
}
Ok(())
}
// ---------------------------------------------------------------------------
// Reading: shared helpers
// ---------------------------------------------------------------------------
struct SharedHelpers {
doc: Option<String>,
helpers: Vec<HelperDef>,
}
fn read_helpers(path: &Path) -> Result<SharedHelpers, String> {
let doc = read_yaml(path)?;
let root = as_mapping(&doc, HELPERS_FILE)?;
let module_doc = opt_prose(root, "doc", HELPERS_FILE)?;
let helpers = root
.get("helpers")
.ok_or_else(|| format!("{HELPERS_FILE}: missing `helpers:` map"))?;
let helpers = as_mapping(helpers, "helpers")?;
let mut out = Vec::new();
for (name, spec) in helpers {
let name = as_str(name, "a helper name")?.to_string();
let ctx = format!("helpers.{name}");
let spec = as_mapping(spec, &ctx)?;
let wgsl = spec
.get("wgsl")
.ok_or_else(|| format!("{HELPERS_FILE}: `{ctx}` has no `wgsl:`"))?;
let wgsl = as_str(wgsl, &format!("{ctx}.wgsl"))?.trim_end().to_string();
// The composer deduplicates by name, so a helper whose declared name
// is not the function it defines would be emitted under one name and
// called under another.
if !wgsl.contains(&format!("fn {name}(")) {
return Err(format!(
"{HELPERS_FILE}: `{ctx}` does not define `fn {name}(`. The key \
is the name the composer deduplicates on, so it has to be the \
function actually declared."
));
}
out.push(HelperDef {
doc: opt_prose(spec, "doc", &ctx)?,
name,
wgsl,
});
}
if out.is_empty() {
return Err(format!("{HELPERS_FILE}: `helpers:` is empty"));
}
Ok(SharedHelpers {
doc: module_doc,
helpers: out,
})
}
// ---------------------------------------------------------------------------
// Reading: a node
// ---------------------------------------------------------------------------
fn read_node(path: &Path, shared: &BTreeSet<&str>) -> Result<Node, String> {
let doc = read_yaml(path)?;
let root = as_mapping(&doc, "the document")?;
let id = as_str(root.get("id").ok_or("missing `id:`")?, "id")?.to_string();
check_ident(&id, "id")?;
let order = root
.get("order")
.ok_or("missing `order:`; it is what places this node in the chain")?
.as_i64()
.ok_or("`order` must be a whole number")?;
let placement = opt_prose(root, "placement", "placement")?;
// A `rust:` node describes where a hand-written type sits, and nothing
// else — its descriptor comes from the type. Mixing the two forms would
// mean two sources for one node's parameters.
if let Some(ty) = root.get("rust") {
let ty = as_str(ty, "rust")?.to_string();
check_type_name(&ty)?;
for key in ["params", "uniforms", "wgsl", "helpers", "define", "label"] {
if root.contains_key(key) {
return Err(format!(
"`{key}` is meaningless on a `rust:` node — {ty} publishes \
its own descriptor. Remove one or the other."
));
}
}
return Ok(Node::Rust {
id,
order,
ty,
why_rust: opt_prose(root, "why_rust", "why_rust")?,
placement,
});
}
let label = as_str(root.get("label").ok_or("missing `label:`")?, "label")?.to_string();
let params = read_params(root)?;
let param_names: BTreeSet<&str> = params.iter().map(|p| p.id.as_str()).collect();
let local_helpers = read_local_helpers(root)?;
let shared_helpers = read_shared_refs(root, shared, &local_helpers)?;
let uniforms = read_uniforms(root, &param_names)?;
let wgsl = as_str(root.get("wgsl").ok_or("missing `wgsl:`")?, "wgsl")?
.trim_end()
.to_string();
if wgsl.trim().is_empty() {
return Err("`wgsl:` is empty; a node that changes nothing is not a node".into());
}
let active = match root.get("active") {
None => None,
Some(v) => {
let expr = as_str(v, "active")?;
Some(compile_expr(expr, &param_names).map_err(|e| format!("`active`: {e}"))?)
}
};
let uniform_names: BTreeSet<&str> = uniforms.iter().map(|u| u.name.as_str()).collect();
let helper_names: BTreeSet<&str> = shared_helpers
.iter()
.map(String::as_str)
.chain(local_helpers.iter().map(|h| h.name.as_str()))
.collect();
let tests = read_tests(root, &params, &uniform_names, &helper_names)?;
Ok(Node::Declared {
id,
label,
order,
doc: opt_prose(root, "doc", "doc")?,
placement,
params,
uniforms,
shared_helpers,
local_helpers,
wgsl,
active,
tests,
})
}
fn read_params(root: &Mapping) -> Result<Vec<ParamDef>, String> {
let params = root
.get("params")
.ok_or("missing `params:`; an operation with no parameters has nothing to control")?;
let params = as_mapping(params, "params")?;
let mut out = Vec::new();
for (id, spec) in params {
let id = as_str(id, "a parameter name")?.to_string();
check_ident(&id, &format!("params.{id}"))?;
let ctx = format!("params.{id}");
let spec = as_mapping(spec, &ctx)?;
let label = as_str(
spec.get("label")
.ok_or_else(|| format!("`{ctx}` has no `label:`"))?,
&format!("{ctx}.label"),
)?
.to_string();
let kind = as_str(
spec.get("kind")
.ok_or_else(|| format!("`{ctx}` has no `kind:`"))?,
&format!("{ctx}.kind"),
)?;
let (ctor, default, min, max) = param_ctor(&id, &label, kind, spec, &ctx)?;
out.push(ParamDef {
id,
doc: opt_prose(spec, "doc", &ctx)?,
ctor,
default,
min,
max,
});
}
if out.is_empty() {
return Err("`params:` is empty".into());
}
Ok(out)
}
/// Render the `ParamDescriptor` constructor for a declared parameter kind.
///
/// The kinds are the constructors `descriptor.rs` already offers, named rather
/// than spelled out: `amount` is the −100…+100 shape nearly every photographic
/// control takes, and writing its range in every node would invite one of them
/// to drift.
fn param_ctor(
id: &str,
label: &str,
kind: &str,
spec: &Mapping,
ctx: &str,
) -> Result<(String, f64, f64, f64), String> {
let need = |key: &str| -> Result<f64, String> {
spec.get(key)
.and_then(Value::as_f64)
.ok_or_else(|| format!("`{ctx}` is `kind: {kind}` and needs a numeric `{key}:`"))
};
let opt = |key: &str, fallback: f64| -> f64 {
spec.get(key).and_then(Value::as_f64).unwrap_or(fallback)
};
Ok(match kind {
"stops" => {
let (min, max) = (need("min")?, need("max")?);
(
format!(
"ParamDescriptor::stops({:?}, {:?}, {}, {})",
id,
label,
rust_f32(min),
rust_f32(max)
),
0.0,
min,
max,
)
}
"amount" => (
format!("ParamDescriptor::amount({id:?}, {label:?})"),
0.0,
-100.0,
100.0,
),
"switch" => (
format!("ParamDescriptor::switch({id:?}, {label:?})"),
0.0,
0.0,
1.0,
),
"fraction" => {
let default = opt("default", 0.0);
(
format!(
"ParamDescriptor::fraction({:?}, {:?}, {})",
id,
label,
rust_f32(default)
),
default,
0.0,
1.0,
)
}
"scalar" => {
let (min, max) = (need("min")?, need("max")?);
let default = opt("default", 0.0);
let unit = match spec.get("unit").and_then(Value::as_str).unwrap_or("none") {
"none" => "Unit::None",
"stops" => "Unit::Stops",
"kelvin" => "Unit::Kelvin",
"percent" => "Unit::Percent",
other => {
return Err(format!(
"`{ctx}.unit` is `{other}`; expected none, stops, kelvin or percent"
))
}
};
let scale = match spec
.get("scale")
.and_then(Value::as_str)
.unwrap_or("linear")
{
"linear" => "Scale::Linear",
"perceptual" => "Scale::Perceptual",
other => {
return Err(format!(
"`{ctx}.scale` is `{other}`; expected linear or perceptual"
))
}
};
let precision = spec
.get("precision")
.and_then(Value::as_u64)
.ok_or_else(|| format!("`{ctx}` is `kind: scalar` and needs `precision:`"))?;
(
format!(
"ParamDescriptor::scalar({:?}, {:?}, {}, {}, {}, {}, {}, {})",
id,
label,
rust_f32(min),
rust_f32(max),
rust_f32(default),
unit,
scale,
precision
),
default,
min,
max,
)
}
other => {
return Err(format!(
"`{ctx}.kind` is `{other}`; expected stops, amount, switch, fraction or scalar"
))
}
})
.and_then(|(ctor, default, min, max): (String, f64, f64, f64)| {
// Caught at build time rather than surfacing as a control that opens
// where it cannot be dragged back to.
if min >= max {
return Err(format!(
"`{ctx}` has an empty range: min {min} >= max {max}"
));
}
if default < min || default > max {
return Err(format!(
"`{ctx}` has default {default} outside its range {min}..{max}"
));
}
Ok((ctor, default, min, max))
})
}
fn read_local_helpers(root: &Mapping) -> Result<Vec<HelperDef>, String> {
let Some(define) = root.get("define") else {
return Ok(Vec::new());
};
let define = as_mapping(define, "define")?;
let mut out = Vec::new();
for (name, spec) in define {
let name = as_str(name, "a helper name under `define`")?.to_string();
let ctx = format!("define.{name}");
// Shorthand: the value may be the WGSL directly, or a mapping with
// prose beside it. Most node-local helpers carry their explanation in
// the WGSL itself, so the shorthand is the common case.
let (doc, wgsl) = match spec {
Value::String(s) => (None, s.trim_end().to_string()),
other => {
let m = as_mapping(other, &ctx)?;
let wgsl = as_str(
m.get("wgsl")
.ok_or_else(|| format!("`{ctx}` has no `wgsl:`"))?,
&format!("{ctx}.wgsl"),
)?
.trim_end()
.to_string();
(opt_prose(m, "doc", &ctx)?, wgsl)
}
};
if !wgsl.contains(&format!("fn {name}(")) {
return Err(format!("`{ctx}` does not define `fn {name}(`"));
}
out.push(HelperDef { name, doc, wgsl });
}
Ok(out)
}
fn read_shared_refs(
root: &Mapping,
shared: &BTreeSet<&str>,
local: &[HelperDef],
) -> Result<Vec<String>, String> {
let Some(list) = root.get("helpers") else {
return Ok(Vec::new());
};
let list = list
.as_sequence()
.ok_or("`helpers` must be a list of helper names")?;
let mut out = Vec::new();
let mut seen = BTreeSet::new();
for item in list {
let name = as_str(item, "a helper name")?.to_string();
if !shared.contains(name.as_str()) {
let known: Vec<&str> = shared.iter().copied().collect();
return Err(format!(
"`helpers` names `{name}`, which {HELPERS_FILE} does not \
define. Known helpers: {}",
known.join(", ")
));
}
if local.iter().any(|h| h.name == name) {
return Err(format!(
"`{name}` is both requested from {HELPERS_FILE} and redefined \
under `define`. The composer deduplicates by name, so one of \
the two definitions would silently win."
));
}
if !seen.insert(name.clone()) {
return Err(format!("`helpers` lists `{name}` twice"));
}
out.push(name);
}
Ok(out)
}
fn read_uniforms(root: &Mapping, params: &BTreeSet<&str>) -> Result<Vec<UniformDef>, String> {
let uniforms = root
.get("uniforms")
.ok_or("missing `uniforms:`; the fragment has nothing to read otherwise")?;
let uniforms = as_mapping(uniforms, "uniforms")?;
let mut out = Vec::new();
for (name, spec) in uniforms {
let name = as_str(name, "a uniform name")?.to_string();
check_ident(&name, &format!("uniforms.{name}"))?;
let ctx = format!("uniforms.{name}");
// Shorthand: `gain: exp2(exposure)`, or a mapping carrying prose.
let (doc, expr) = match spec {
Value::String(s) => (None, s.clone()),
other => {
let m = as_mapping(other, &ctx)?;
let value = m
.get("value")
.ok_or_else(|| format!("`{ctx}` has neither a bare expression nor `value:`"))?;
(
opt_prose(m, "doc", &ctx)?,
as_str(value, &format!("{ctx}.value"))?.to_string(),
)
}
};
let rust = compile_expr(&expr, params).map_err(|e| format!("`{ctx}`: {e}"))?;
out.push(UniformDef { name, doc, rust });
}
if out.is_empty() {
return Err("`uniforms:` is empty".into());
}
Ok(out)
}
fn read_tests(
root: &Mapping,
params: &[ParamDef],
uniforms: &BTreeSet<&str>,
helpers: &BTreeSet<&str>,
) -> Result<Vec<TestDef>, String> {
let Some(list) = root.get("tests") else {
return Ok(Vec::new());
};
let list = list.as_sequence().ok_or("`tests` must be a list")?;
let mut out = Vec::new();
let mut seen = BTreeSet::new();
for item in list {
let m = as_mapping(item, "a test")?;
let name = as_str(
m.get("name").ok_or("a test has no `name:`")?,
"tests[].name",
)?
.to_string();
check_ident(&name, &format!("tests.{name}.name"))?;
if !seen.insert(name.clone()) {
return Err(format!("two tests are both called `{name}`"));
}
let ctx = format!("tests.{name}");
let mut set = Vec::new();
if let Some(v) = m.get("set") {
for (k, val) in as_mapping(v, &format!("{ctx}.set"))? {
let key = as_str(k, &format!("{ctx}.set key"))?.to_string();
let Some(param) = params.iter().find(|p| p.id == key) else {
return Err(format!(
"`{ctx}.set` names `{key}`, which is not a parameter"
));
};
let value = val
.as_f64()
.ok_or_else(|| format!("`{ctx}.set.{key}` must be a number"))?;
// Values reach `set_param` already clamped by the graph, so a
// test setting an out-of-range value would be asserting
// against something that cannot happen.
if value < param.min || value > param.max {
return Err(format!(
"`{ctx}.set.{key}` is {value}, outside the parameter's \
range {}..{}. The graph clamps before an operation \
sees a value, so this test could never run as written.",
param.min, param.max
));
}
set.push((key, value));
}
}
let mut expect = Vec::new();
if let Some(v) = m.get("expect") {
for (k, val) in as_mapping(v, &format!("{ctx}.expect"))? {
let key = as_str(k, &format!("{ctx}.expect key"))?.to_string();
if !uniforms.contains(key.as_str()) {
return Err(format!(
"`{ctx}.expect` names `{key}`, which is not a uniform of this node"
));
}
let value = val
.as_f64()
.ok_or_else(|| format!("`{ctx}.expect.{key}` must be a number"))?;
expect.push((key, value));
}
}
let mut expect_range = Vec::new();
if let Some(v) = m.get("expect_range") {
for (k, val) in as_mapping(v, &format!("{ctx}.expect_range"))? {
let key = as_str(k, &format!("{ctx}.expect_range key"))?.to_string();
if !uniforms.contains(key.as_str()) {
return Err(format!(
"`{ctx}.expect_range` names `{key}`, which is not a uniform"
));
}
let pair = val
.as_sequence()
.filter(|s| s.len() == 2)
.ok_or_else(|| format!("`{ctx}.expect_range.{key}` must be [low, high]"))?;
let lo = pair[0]
.as_f64()
.ok_or_else(|| format!("`{ctx}.expect_range.{key}` low must be a number"))?;
let hi = pair[1]
.as_f64()
.ok_or_else(|| format!("`{ctx}.expect_range.{key}` high must be a number"))?;
expect_range.push((key, lo, hi));
}
}
let mut expect_wgsl = Vec::new();
if let Some(v) = m.get("expect_wgsl") {
for item in v
.as_sequence()
.ok_or_else(|| format!("`{ctx}.expect_wgsl` must be a list of strings"))?
{
expect_wgsl.push(as_str(item, &format!("{ctx}.expect_wgsl[]"))?.to_string());
}
}
let mut expect_helper_wgsl = Vec::new();
if let Some(v) = m.get("expect_helper_wgsl") {
for (k, val) in as_mapping(v, &format!("{ctx}.expect_helper_wgsl"))? {
let key = as_str(k, &format!("{ctx}.expect_helper_wgsl key"))?.to_string();
if !helpers.contains(key.as_str()) {
return Err(format!(
"`{ctx}.expect_helper_wgsl` names `{key}`, which this node does not use"
));
}
let mut needles = Vec::new();
for item in val
.as_sequence()
.ok_or_else(|| format!("`{ctx}.expect_helper_wgsl.{key}` must be a list"))?
{
needles.push(
as_str(item, &format!("{ctx}.expect_helper_wgsl.{key}[]"))?.to_string(),
);
}
expect_helper_wgsl.push((key, needles));
}
}
let expect_active = match m.get("expect_active") {
None => None,
Some(v) => Some(
v.as_bool()
.ok_or_else(|| format!("`{ctx}.expect_active` must be true or false"))?,
),
};
if expect.is_empty()
&& expect_range.is_empty()
&& expect_wgsl.is_empty()
&& expect_helper_wgsl.is_empty()
&& expect_active.is_none()
{
return Err(format!("`{ctx}` asserts nothing"));
}
out.push(TestDef {
name,
why: opt_prose(m, "why", &ctx)?,
set,
expect,
expect_range,
expect_active,
expect_wgsl,
expect_helper_wgsl,
});
}
Ok(out)
}
// ---------------------------------------------------------------------------
// The expression language
// ---------------------------------------------------------------------------
//
// Uniforms are derived from parameters — `exp2(exposure)`, `blacks / 100 *
// 0.02` — and that derivation is the one piece of a node that is genuinely
// computation rather than description. It is kept to arithmetic over the
// node's own parameters and a fixed set of maths functions: enough for every
// operation in the chain, and small enough that a reader of the YAML can see
// exactly what will happen.
//
// Compiled to Rust rather than interpreted, so an unknown name or a wrong
// arity is a build error naming the file, and the arithmetic itself costs
// nothing at runtime.
#[derive(Debug)]
enum Expr {
Num(f64),
Param(String),
Neg(Box<Expr>),
Bin(char, Box<Expr>, Box<Expr>),
Call(String, Vec<Expr>),
}
/// Compile a declared expression to a Rust `f32` expression.
fn compile_expr(src: &str, params: &BTreeSet<&str>) -> Result<String, String> {
let tokens = tokenise(src)?;
let mut parser = Parser { tokens, at: 0 };
let expr = parser.expr()?;
if parser.at < parser.tokens.len() {
return Err(format!(
"unexpected `{}` after the end of the expression",
parser.tokens[parser.at]
));
}
render(&expr, params).map(|s| unwrap_parens(&s))
}
/// Drop one redundant pair of enclosing parentheses.
///
/// [`render`] parenthesises every binary expression, which is what keeps
/// precedence correct under composition. At the outermost position — a
/// function argument, or the whole expression — that pair is redundant, and
/// `rustc` warns about it. Generated code is the one place a warning cannot be
/// fixed where it appears, so it is fixed here.
fn unwrap_parens(s: &str) -> String {
let inner = match s.strip_prefix('(').and_then(|s| s.strip_suffix(')')) {
Some(inner) => inner,
None => return s.to_string(),
};
// Only when the two are actually a pair: `(a) * (b)` also starts with `(`
// and ends with `)`, and stripping those would change what it means.
let mut depth = 0i32;
for c in inner.chars() {
match c {
'(' => depth += 1,
')' => {
depth -= 1;
if depth < 0 {
return s.to_string();
}
}
_ => {}
}
}
if depth == 0 {
inner.to_string()
} else {
s.to_string()
}
}
#[derive(Debug, Clone, PartialEq)]
enum Tok {
Num(f64),
Ident(String),
Sym(char),
}
impl std::fmt::Display for Tok {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Tok::Num(n) => write!(f, "{n}"),
Tok::Ident(s) => write!(f, "{s}"),
Tok::Sym(c) => write!(f, "{c}"),
}
}
}
fn tokenise(src: &str) -> Result<Vec<Tok>, String> {
let bytes: Vec<char> = src.chars().collect();
let mut out = Vec::new();
let mut i = 0;
while i < bytes.len() {
let c = bytes[i];
if c.is_whitespace() {
i += 1;
} else if c.is_ascii_digit()
|| (c == '.' && bytes.get(i + 1).is_some_and(char::is_ascii_digit))
{
let start = i;
while i < bytes.len() && (bytes[i].is_ascii_digit() || bytes[i] == '.') {
i += 1;
}
let text: String = bytes[start..i].iter().collect();
let n = text
.parse::<f64>()
.map_err(|_| format!("`{text}` is not a number"))?;
out.push(Tok::Num(n));
} else if c.is_ascii_alphabetic() || c == '_' {
let start = i;
while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == '_') {
i += 1;
}
out.push(Tok::Ident(bytes[start..i].iter().collect()));
} else if "+-*/(),".contains(c) {
out.push(Tok::Sym(c));
i += 1;
} else {
return Err(format!(
"`{c}` is not valid in an expression; the language is \
arithmetic (+ - * /), parentheses, numbers, this node's \
parameters, and the maths functions"
));
}
}
if out.is_empty() {
return Err("the expression is empty".into());
}
Ok(out)
}
struct Parser {
tokens: Vec<Tok>,
at: usize,
}
impl Parser {
fn peek(&self) -> Option<&Tok> {
self.tokens.get(self.at)
}
fn eat(&mut self, sym: char) -> bool {
if self.peek() == Some(&Tok::Sym(sym)) {
self.at += 1;
return true;
}
false
}
fn expr(&mut self) -> Result<Expr, String> {
let mut left = self.term()?;
loop {
if self.eat('+') {
left = Expr::Bin('+', Box::new(left), Box::new(self.term()?));
} else if self.eat('-') {
left = Expr::Bin('-', Box::new(left), Box::new(self.term()?));
} else {
return Ok(left);
}
}
}
fn term(&mut self) -> Result<Expr, String> {
let mut left = self.unary()?;
loop {
if self.eat('*') {
left = Expr::Bin('*', Box::new(left), Box::new(self.unary()?));
} else if self.eat('/') {
left = Expr::Bin('/', Box::new(left), Box::new(self.unary()?));
} else {
return Ok(left);
}
}
}
fn unary(&mut self) -> Result<Expr, String> {
if self.eat('-') {
return Ok(Expr::Neg(Box::new(self.unary()?)));
}
self.primary()
}
fn primary(&mut self) -> Result<Expr, String> {
match self.peek().cloned() {
Some(Tok::Num(n)) => {
self.at += 1;
Ok(Expr::Num(n))
}
Some(Tok::Ident(name)) => {
self.at += 1;
if !self.eat('(') {
return Ok(Expr::Param(name));
}
let mut args = Vec::new();
if !self.eat(')') {
loop {
args.push(self.expr()?);
if self.eat(',') {
continue;
}
if self.eat(')') {
break;
}
return Err(format!("expected `,` or `)` in the call to `{name}`"));
}
}
Ok(Expr::Call(name, args))
}
Some(Tok::Sym('(')) => {
self.at += 1;
let inner = self.expr()?;
if !self.eat(')') {
return Err("unclosed `(`".into());
}
Ok(inner)
}
Some(t) => Err(format!("unexpected `{t}`")),
None => Err("the expression ends early".into()),
}
}
}
/// The maths functions a node may call, and the Rust each becomes.
///
/// A closed list rather than a passthrough to `f32`: a node is a description,
/// and letting it name arbitrary Rust would make the YAML a second, worse
/// place to write code.
fn render(expr: &Expr, params: &BTreeSet<&str>) -> Result<String, String> {
Ok(match expr {
Expr::Num(n) => rust_f32(*n),
Expr::Param(name) => {
if !params.contains(name.as_str()) {
let known: Vec<&str> = params.iter().copied().collect();
return Err(format!(
"`{name}` is not a parameter of this node. Its parameters \
are: {}",
known.join(", ")
));
}
format!("self.{name}")
}
// One pair of parentheses, never two: the inner expression brings its
// own, and `-((a * b))` is a clippy warning in code nobody can edit.
// The pair that remains is load-bearing — `-(a + b)` and `-a + b` are
// different numbers.
Expr::Neg(inner) => format!("-({})", unwrap_parens(&render(inner, params)?)),
Expr::Bin(op, l, r) => format!("({} {op} {})", render(l, params)?, render(r, params)?),
Expr::Call(name, args) => {
let rendered: Vec<String> = args
.iter()
.map(|a| render(a, params).map(|s| unwrap_parens(&s)))
.collect::<Result<_, _>>()?;
let arity = |n: usize| -> Result<(), String> {
if rendered.len() != n {
return Err(format!(
"`{name}` takes {n} argument(s), given {}",
rendered.len()
));
}
Ok(())
};
match name.as_str() {
"exp2" | "log2" | "exp" | "sqrt" | "abs" | "floor" | "ceil" | "round" => {
arity(1)?;
format!("f32::{name}({})", rendered[0])
}
"pow" => {
arity(2)?;
format!("f32::powf({}, {})", rendered[0], rendered[1])
}
"min" | "max" => {
arity(2)?;
format!("f32::{name}({}, {})", rendered[0], rendered[1])
}
"clamp" => {
arity(3)?;
format!(
"f32::clamp({}, {}, {})",
rendered[0], rendered[1], rendered[2]
)
}
"mix" => {
arity(3)?;
// Spelled out rather than called: Rust has no `mix`, and
// the linear form is what WGSL's `mix` means.
format!(
"({a} + ({b} - {a}) * {t})",
a = rendered[0],
b = rendered[1],
t = rendered[2]
)
}
other => {
return Err(format!(
"`{other}` is not one of the maths functions a node may \
call. Available: exp2, log2, exp, sqrt, abs, floor, \
ceil, round, pow, min, max, clamp, mix"
))
}
}
}
})
}
// ---------------------------------------------------------------------------
// Emission
// ---------------------------------------------------------------------------
fn emit(shared: &SharedHelpers, nodes: &[Node]) -> Result<String, String> {
let mut out = String::new();
out.push_str(
"// GENERATED FILE — DO NOT EDIT.\n\
//\n\
// Written by core/dr-pipeline/build.rs from core/dr-pipeline/ops/*.yaml.\n\
// Edits here are discarded the next time a declaration changes.\n\
// Change the node, not this.\n\n",
);
emit_helpers(&mut out, shared);
for node in nodes {
if let Node::Declared { .. } = node {
emit_node(&mut out, node)?;
}
}
emit_chain(&mut out, nodes);
Ok(out)
}
fn emit_helpers(out: &mut String, shared: &SharedHelpers) {
out.push_str("pub mod helpers {\n");
if let Some(doc) = &shared.doc {
out.push_str(&comment(doc, "//!", 4));
out.push_str(" //!\n");
}
out.push_str(" //! Generated from `ops/_helpers.yaml`.\n\n");
out.push_str(" use crate::operation::Helper;\n");
for h in &shared.helpers {
out.push('\n');
if let Some(doc) = &h.doc {
out.push_str(&comment(doc, "///", 4));
}
let _ = writeln!(
out,
" pub const {}: Helper = Helper {{\n name: {:?},\n source: {},\n }};",
h.name.to_uppercase(),
h.name,
wgsl_literal(&h.wgsl, h.doc.as_deref())
);
}
// A registry, so a test can assert over every helper rather than a list
// someone has to remember to extend.
let names: Vec<String> = shared
.helpers
.iter()
.map(|h| h.name.to_uppercase())
.collect();
let _ = writeln!(
out,
"\n /// Every helper `_helpers.yaml` declares, for registry-wide tests.\n\
\x20 pub static ALL: &[Helper] = &[{}];\n}}\n",
names.join(", ")
);
}
fn emit_node(out: &mut String, node: &Node) -> Result<(), String> {
let Node::Declared {
id,
label,
doc,
params,
uniforms,
shared_helpers,
local_helpers,
wgsl,
active,
tests,
..
} = node
else {
return Ok(());
};
let ty = pascal_case(id);
let _ = writeln!(out, "pub mod {id} {{");
if let Some(doc) = doc {
out.push_str(&comment(doc, "//!", 4));
out.push_str(" //!\n");
}
let _ = writeln!(out, " //! Generated from `ops/{id}.yaml`.\n");
// `Scale`, `Unit` and `Helper` are used only by some nodes — a node with
// no `kind: scalar` parameter and no helpers needs neither — so the group
// is allowed to go unused rather than being assembled per node.
out.push_str(
" #[allow(unused_imports)]\n\
\x20 use crate::descriptor::{\n\
\x20 LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Scale, Unit,\n\
\x20 };\n\
\x20 #[allow(unused_imports)]\n\
\x20 use crate::operation::{Helper, Operation, Uniform};\n\n",
);
// Ids as consts, so a caller names a parameter through the type system
// rather than by retyping a string.
let _ = writeln!(out, " pub const ID: OpId = OpId({id:?});");
for p in params {
let _ = writeln!(
out,
" pub const {}: ParamId = ParamId({:?});",
p.id.to_uppercase(),
p.id
);
}
// Descriptor.
let _ = writeln!(
out,
"\n static DESCRIPTOR: OpDescriptor = OpDescriptor {{\n\
\x20 id: ID,\n\
\x20 label: LocalizedKey({label:?}),\n\
\x20 params: &["
);
for p in params {
if let Some(doc) = &p.doc {
out.push_str(&comment(doc, "//", 12));
}
let _ = writeln!(out, " {},", p.ctor);
}
out.push_str(" ],\n };\n");
// Helpers: node-local definitions first, then the assembled list.
for h in local_helpers {
let _ = writeln!(
out,
"\n const {}: Helper = Helper {{\n name: {:?},\n source: {},\n }};",
local_helper_const(&h.name),
h.name,
wgsl_literal(&h.wgsl, h.doc.as_deref())
);
}
let mut helper_refs: Vec<String> = shared_helpers
.iter()
.map(|h| format!("super::helpers::{}", h.to_uppercase()))
.collect();
helper_refs.extend(local_helpers.iter().map(|h| local_helper_const(&h.name)));
if !helper_refs.is_empty() {
let _ = writeln!(
out,
"\n static HELPERS: &[Helper] = &[\n {},\n ];",
helper_refs.join(",\n ")
);
}
// State.
let _ = writeln!(
out,
"\n #[derive(Debug, Default, Clone)]\n pub struct {ty} {{"
);
for p in params {
let _ = writeln!(out, " {}: f32,", p.id);
}
out.push_str(" }\n");
let _ = writeln!(
out,
"\n impl {ty} {{\n pub fn new() -> Self {{\n Self::default()\n }}\n }}"
);
// The trait.
let _ = writeln!(out, "\n impl Operation for {ty} {{");
out.push_str(
" fn descriptor(&self) -> &'static OpDescriptor {\n &DESCRIPTOR\n }\n\n",
);
out.push_str(
" fn set_param(&mut self, id: ParamId, value: f32) {\n match id {\n",
);
for p in params {
let _ = writeln!(
out,
" {} => self.{} = value,",
p.id.to_uppercase(),
p.id
);
}
let _ = writeln!(
out,
" _ => log::warn!(\"{id}: unknown parameter {{id}}\"),\n }}\n }}\n"
);
out.push_str(" fn param(&self, id: ParamId) -> f32 {\n match id {\n");
for p in params {
let _ = writeln!(
out,
" {} => self.{},",
p.id.to_uppercase(),
p.id
);
}
out.push_str(" _ => 0.0,\n }\n }\n\n");
// `is_active` decides whether this node reaches the shader at all, so the
// default rule is the honest one: the operation is doing something exactly
// when a parameter has moved off its default.
let active_expr = match active {
Some(expr) => format!("({expr}) != 0.0"),
None => params
.iter()
.map(|p| format!("self.{} != {}", p.id, rust_f32(p.default)))
.collect::<Vec<_>>()
.join(" || "),
};
let _ = writeln!(
out,
" fn is_active(&self) -> bool {{\n {active_expr}\n }}\n"
);
let _ = writeln!(
out,
" fn wgsl_body(&self) -> String {{\n {}.into()\n }}\n",
wgsl_literal(wgsl, None)
);
out.push_str(" fn uniforms(&self) -> Vec<Uniform> {\n vec![\n");
for u in uniforms {
if let Some(doc) = &u.doc {
out.push_str(&comment(doc, "//", 16));
}
let _ = writeln!(
out,
" Uniform {{ name: {:?}, value: {} }},",
u.name, u.rust
);
}
out.push_str(" ]\n }\n");
if !helper_refs.is_empty() {
out.push_str(
"\n fn helpers(&self) -> &'static [Helper] {\n HELPERS\n }\n",
);
}
out.push_str(" }\n");
emit_tests(out, &ty, params, tests);
out.push_str("}\n\n");
Ok(())
}
fn emit_tests(out: &mut String, ty: &str, params: &[ParamDef], tests: &[TestDef]) {
if tests.is_empty() {
return;
}
// `approx_constant`: an expected uniform value is a physical quantity, and
// one occasionally coincides with a maths constant — half a stop of white
// balance is exp2(0.5), which is √2. Writing `SQRT_2` there would name the
// arithmetic instead of the photography, and the declaration says `half a
// stop` in prose beside it.
out.push_str(
"\n #[cfg(test)]\n\
\x20 #[allow(clippy::approx_constant)]\n\
\x20 mod tests {\n\
\x20 use super::*;\n\n",
);
out.push_str(
" /// One uniform's value, by the name the node declared it under.\n\
\x20 fn uniform(op: &impl Operation, name: &str) -> f32 {\n\
\x20 op.uniforms()\n\
\x20 .into_iter()\n\
\x20 .find(|u| u.name == name)\n\
\x20 .unwrap_or_else(|| panic!(\"no uniform called {name}\"))\n\
\x20 .value\n\
\x20 }\n",
);
for t in tests {
out.push('\n');
let _ = writeln!(out, " #[test]");
let _ = writeln!(out, " fn {}() {{", t.name);
if let Some(why) = &t.why {
out.push_str(&comment(why, "//", 12));
}
// `mut` only where a value is actually set: an unused-mut warning in
// generated code is noise nobody can fix at the source.
let binding = if t.set.is_empty() { "op" } else { "mut op" };
let _ = writeln!(out, " let {binding} = {ty}::new();");
for (param, value) in &t.set {
let id = params
.iter()
.find(|p| &p.id == param)
.map(|p| p.id.to_uppercase())
.unwrap_or_default();
let _ = writeln!(out, " op.set_param({id}, {});", rust_f32(*value));
}
for (name, expected) in &t.expect {
let _ = writeln!(
out,
" let got = uniform(&op, {name:?});\n\
\x20 assert!(\n\
\x20 (got - {e}).abs() <= 1e-5,\n\
\x20 \"{name}: expected {{}}, got {{got}}\",\n\
\x20 {e}\n\
\x20 );",
e = rust_f32(*expected)
);
}
for (name, lo, hi) in &t.expect_range {
let _ = writeln!(
out,
" let got = uniform(&op, {name:?});\n\
\x20 assert!(\n\
\x20 ({lo}..={hi}).contains(&got),\n\
\x20 \"{name}: {{got}} is outside {lo}..={hi}\"\n\
\x20 );",
lo = rust_f32(*lo),
hi = rust_f32(*hi)
);
}
if let Some(active) = t.expect_active {
let (negation, expected) = if active {
("", "active")
} else {
("!", "inactive")
};
let _ = writeln!(
out,
" assert!(\n\
\x20 {negation}op.is_active(),\n\
\x20 \"the operation should be {expected} at these settings\"\n\
\x20 );"
);
}
for needle in &t.expect_wgsl {
let _ = writeln!(
out,
" assert!(\n\
\x20 op.wgsl_body().contains({needle:?}),\n\
\x20 \"the fragment no longer contains {{:?}}\",\n\
\x20 {needle:?}\n\
\x20 );"
);
}
for (helper, needles) in &t.expect_helper_wgsl {
let _ = writeln!(
out,
" let helper = op\n\
\x20 .helpers()\n\
\x20 .iter()\n\
\x20 .find(|h| h.name == {helper:?})\n\
\x20 .expect(\"declares {helper}\");"
);
for needle in needles {
let _ = writeln!(
out,
" assert!(\n\
\x20 helper.source.contains({needle:?}),\n\
\x20 \"{helper} no longer contains {{:?}}\",\n\
\x20 {needle:?}\n\
\x20 );"
);
}
}
out.push_str(" }\n");
}
out.push_str(" }\n");
}
fn emit_chain(out: &mut String, nodes: &[Node]) {
out.push_str(
"/// TRACES: FR-DEV-3a | FR-DEV-3c\n\
/// The default develop chain, in the order `ops/*.yaml` declares.\n\
///\n\
/// Order is data, not code (ARCH §3.4): each node carries an `order:`\n\
/// and this is the sorted result, so reordering the pipeline is an edit\n\
/// to one number in one declaration.\n\
pub fn chain() -> Vec<Box<dyn crate::operation::Operation>> {\n\
\x20 vec![\n",
);
for node in nodes {
let _ = writeln!(
out,
" // ---- {} (order {})",
node.id(),
node.order()
);
if let Some(placement) = node.placement() {
out.push_str(&comment(placement, "//", 8));
}
match node {
Node::Declared { id, .. } => {
let _ = writeln!(out, " Box::new({id}::{}::new()),", pascal_case(id));
}
Node::Rust { ty, why_rust, .. } => {
if let Some(why) = why_rust {
out.push_str(" // Hand-written:\n");
out.push_str(&comment(why, "//", 8));
}
let _ = writeln!(out, " Box::new({ty}::new()),");
}
}
}
out.push_str(" ]\n}\n\n");
// The ids, in order, as data — so a test can assert that what the chain
// actually builds matches what the declarations say. This is the only
// check available on a `rust:` node, whose descriptor comes from a type
// this build script cannot read.
let ids: Vec<String> = nodes.iter().map(|n| format!("{:?}", n.id())).collect();
let _ = writeln!(
out,
"/// The node ids `ops/*.yaml` declares, in chain order.\n\
pub static DECLARED_IDS: &[&str] = &[{}];\n",
ids.join(", ")
);
}
// ---------------------------------------------------------------------------
// Rendering helpers
// ---------------------------------------------------------------------------
/// A WGSL block as a Rust raw string literal.
///
/// Raw so the WGSL reads as itself in the generated file — escaped quotes and
/// `\n` would make the one thing a reader comes to generated source to check
/// unreadable. The hash count grows if the source contains a `"` sequence.
///
/// **Emitted at column zero, deliberately.** The composer indents each line of
/// a fragment as it places it into the generated shader, so a literal indented
/// here to look tidy in `nodes.rs` would arrive in the WGSL indented twice.
/// The hand-written operations had the same shape for the same reason.
fn wgsl_literal(wgsl: &str, doc: Option<&str>) -> String {
let mut body = String::new();
// Prose from the declaration becomes a WGSL comment above the function,
// which is where it is useful — the generated shader is what gets read
// when a compile fails.
if let Some(doc) = doc {
for line in doc.trim_end().lines() {
if line.trim().is_empty() {
body.push_str("//\n");
} else {
let _ = writeln!(body, "// {line}");
}
}
}
body.push_str(wgsl.trim_matches('\n').trim_end());
let mut hashes = String::new();
while body.contains(&format!("\"{hashes}")) {
hashes.push('#');
}
format!("r{hashes}\"{body}\"{hashes}")
}
/// A f64 from YAML as a Rust `f32` literal.
///
/// Always with a decimal point: `100f32` parses, but `100` in a position
/// expecting `f32` does not, and the generated arithmetic mixes the two
/// freely.
fn rust_f32(n: f64) -> String {
let s = format!("{n:?}");
if s.contains('.') || s.contains('e') || s.contains("inf") || s.contains("NaN") {
format!("{s}f32")
} else {
format!("{s}.0f32")
}
}
fn local_helper_const(name: &str) -> String {
format!("{}_HELPER", name.to_uppercase())
}
fn pascal_case(id: &str) -> String {
id.split('_')
.filter(|s| !s.is_empty())
.map(|word| {
let mut chars = word.chars();
match chars.next() {
Some(first) => first.to_ascii_uppercase().to_string() + chars.as_str(),
None => String::new(),
}
})
.collect()
}
/// Wrap prose as comments at a given indent, keeping the author's line breaks.
fn comment(text: &str, marker: &str, indent: usize) -> String {
let pad = " ".repeat(indent);
let mut out = String::new();
for line in text.trim_end().lines() {
if line.trim().is_empty() {
let _ = writeln!(out, "{pad}{marker}");
} else {
let _ = writeln!(out, "{pad}{marker} {line}");
}
}
out
}
// ---------------------------------------------------------------------------
// YAML access
// ---------------------------------------------------------------------------
fn read_yaml(path: &Path) -> Result<Value, String> {
let text = std::fs::read_to_string(path)
.map_err(|e| format!("cannot read {}: {e}", path.display()))?;
serde_norway::from_str(&text).map_err(|e| format!("{}: not valid YAML: {e}", path.display()))
}
fn as_mapping<'a>(value: &'a Value, ctx: &str) -> Result<&'a Mapping, String> {
value
.as_mapping()
.ok_or_else(|| format!("`{ctx}` must be a mapping"))
}
fn as_str<'a>(value: &'a Value, ctx: &str) -> Result<&'a str, String> {
value
.as_str()
.ok_or_else(|| format!("`{ctx}` must be a string"))
}
fn opt_prose(map: &Mapping, key: &str, ctx: &str) -> Result<Option<String>, String> {
match map.get(key) {
None => Ok(None),
Some(v) => v
.as_str()
.map(|s| Some(s.trim_end().to_string()))
.ok_or_else(|| format!("`{ctx}.{key}` must be a string")),
}
}
/// Names reaching generated Rust have to be identifiers, and must not be
/// keywords — `ParamId("type")` would generate a struct field called `type`.
fn check_ident(name: &str, ctx: &str) -> Result<(), String> {
if name.is_empty() {
return Err(format!("`{ctx}` is empty"));
}
let head_ok = name
.chars()
.next()
.is_some_and(|c| c.is_ascii_lowercase() || c == '_');
let rest_ok = name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_');
if !head_ok || !rest_ok {
return Err(format!(
"`{ctx}` is `{name}`; names must be lower_snake_case so they can \
become Rust identifiers"
));
}
const KEYWORDS: &[&str] = &[
"as", "break", "const", "continue", "crate", "dyn", "else", "enum", "extern", "false",
"fn", "for", "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub",
"ref", "return", "self", "static", "struct", "super", "trait", "true", "type", "unsafe",
"use", "where", "while", "async", "await", "box", "final", "macro", "override", "priv",
"try", "typeof", "unsized", "virtual", "yield",
];
if KEYWORDS.contains(&name) {
return Err(format!("`{ctx}` is `{name}`, which is a Rust keyword"));
}
Ok(())
}
/// `rust:` names a type in `crate::ops`, so it is PascalCase rather than an
/// id. Checked only for shape — whether the type exists, and whether it
/// implements `Operation`, is for the compiler to say.
fn check_type_name(name: &str) -> Result<(), String> {
let ok = name.chars().next().is_some_and(|c| c.is_ascii_uppercase())
&& name.chars().all(|c| c.is_ascii_alphanumeric());
if !ok {
return Err(format!(
"`rust: {name}` must be the PascalCase name of a type in \
`crate::ops`, such as `ToneCurve`"
));
}
Ok(())
}