//! 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` 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, wgsl: String, } /// One parameter of a declared node. struct ParamDef { id: String, doc: Option, /// 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, /// The Rust expression, compiled from the declared one. rust: String, } /// One generated `#[test]`. struct TestDef { name: String, why: Option, set: Vec<(String, f64)>, expect: Vec<(String, f64)>, expect_range: Vec<(String, f64, f64)>, expect_active: Option, expect_wgsl: Vec, expect_helper_wgsl: Vec<(String, Vec)>, } /// 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, placement: Option, params: Vec, uniforms: Vec, /// Names of shared helpers, in declaration order. shared_helpers: Vec, /// Helpers this node defines for itself. local_helpers: Vec, wgsl: String, /// `None` means the default rule: active when any parameter has moved. active: Option, tests: Vec, }, Rust { id: String, order: i64, /// The type in `crate::ops` implementing `Operation`. ty: String, why_rust: Option, placement: Option, }, } 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 { 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 `.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, String> { let entries = std::fs::read_dir(dir) .map_err(|e| format!("cannot read {}: {e}", dir.display()))? .collect::, _>>() .map_err(|e| format!("cannot read {}: {e}", dir.display()))?; let mut files: Vec = 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 = 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, helpers: Vec, } fn read_helpers(path: &Path) -> Result { 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 { 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, ¶m_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, ¶m_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, ¶ms, &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, 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 { 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, 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, 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, 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, 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), Bin(char, Box, Box), Call(String, Vec), } /// Compile a declared expression to a Rust `f32` expression. fn compile_expr(src: &str, params: &BTreeSet<&str>) -> Result { 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, String> { let bytes: Vec = 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::() .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, 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 { 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 { 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 { if self.eat('-') { return Ok(Expr::Neg(Box::new(self.unary()?))); } self.primary() } fn primary(&mut self) -> Result { 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 { 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 = args .iter() .map(|a| render(a, params).map(|s| unwrap_parens(&s))) .collect::>()?; 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 { 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 = 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 = 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::>() .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 {\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> {\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 = 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 { 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, 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(()) }