//! 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 //! `Arc`, 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. //! //! # And it is no longer the only reader //! //! The parsing half of this script lives in `src/declared/`, included here by //! `#[path]` and compiled into the crate as well (FR-PLG-2). This script's own //! job is now purely *rendering*: it takes the [`decl::Declaration`] the shared //! reader produced and writes Rust from it, while at run time //! [`crate::declared::DeclaredOp`](../src/declared/mod.rs) takes the same //! declaration and interprets it. //! //! That is what makes "a plugin is the same kind of thing as a built-in" //! checkable rather than merely intended: there is one grammar, one set of //! error messages, and one place where a node's meaning is decided. //! `tests/declared_parity.rs` then asserts the two backends agree byte for //! byte on every node in `ops/`. //! //! 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}; // The shared reader, compiled into this build script as well as into the // crate. See its own module documentation, and `#[path]` rather than a copy // because a copy is precisely what FR-PLG-2 forbids: a plugin and a built-in // have to be read by the same code or they are not the same kind of thing. // // Nothing in either file may name `crate::` items — they are compiled here as // modules of a build script, where those paths do not exist. // // `dead_code` because the run-time half of the shared reader — `Expr::eval`, // the descriptor conversions' inputs — has no caller here. Silencing it at the // module rather than at each item keeps the shared files free of attributes // that only mean something in one of their two compilations. #[allow(dead_code)] #[path = "src/declared/decl.rs"] mod decl; #[allow(dead_code)] #[path = "src/declared/expr.rs"] mod expr; use decl::{Declaration, Node, ParamDef, SharedHelpers, TestDef}; use expr::Expr; /// 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 = decl::HELPERS_FILE; const GENERATED: &str = "nodes.rs"; fn main() { println!("cargo:rerun-if-changed={OPS_DIR}"); println!("cargo:rerun-if-changed=build.rs"); // The reader is an input to this build script now, so a change to it has // to regenerate `nodes.rs` — otherwise a fix to the grammar would take // effect at load time and not at build time, which is the one divergence // this whole arrangement exists to prevent. println!("cargo:rerun-if-changed=src/declared/decl.rs"); println!("cargo:rerun-if-changed=src/declared/expr.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); } // --------------------------------------------------------------------------- // Driver // --------------------------------------------------------------------------- fn generate(ops_dir: &Path, src_ops: &Path) -> Result { let helpers_path = ops_dir.join(HELPERS_FILE); let shared = decl::read_helpers(&read_file(&helpers_path)?)?; let shared_names = shared.names(); let mut nodes = Vec::new(); for path in node_files(ops_dir)? { let name = file_stem(&path); let ctx = format!("{OPS_DIR}/{name}.yaml"); let node = decl::read_node(&read_file(&path)?, &ctx, &shared_names) .map_err(|e| format!("{ctx}: {e}"))?; // 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 read_file(path: &Path) -> Result { std::fs::read_to_string(path).map_err(|e| format!("cannot read {}: {e}", path.display())) } 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(declared) = node else { continue; }; let id = &declared.id; 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(()) } // --------------------------------------------------------------------------- // The expression language, rendered as Rust // --------------------------------------------------------------------------- // // The grammar and the validation live in `src/declared/expr.rs`, shared with // the crate. What is left here is the half that is specific to generating // code: turning a validated [`Expr`] into a Rust `f32` expression, so that a // built-in node's arithmetic costs nothing at run time. // // `Expr::eval` is the other half, and the two must agree bit for bit. Every // arm below has a counterpart there, written to produce the same operations in // the same order — including `mix`, which is spelled out in both because // `a + (b - a) * t` and `a * (1 - t) + b * t` are different numbers in `f32`. /// Compile a validated expression to a Rust `f32` expression. fn compile_expr(expr: &Expr) -> String { unwrap_parens(&render(expr)) } /// Render an expression as Rust source. /// /// Infallible: `expr::parse` has already established that every name resolves /// and every call has the right arity, which is why the validation lives in /// the shared reader rather than here — an unknown function had to be rejected /// identically whether the declaration was read by this script or at load /// time. fn render(expr: &Expr) -> String { match expr { Expr::Num(n) => rust_f32(*n), Expr::Param(name) => 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))), Expr::Bin(op, l, r) => format!("({} {op} {})", render(l), render(r)), Expr::Call(name, args) => { let a: Vec = args.iter().map(|x| unwrap_parens(&render(x))).collect(); match name.as_str() { "exp2" | "log2" | "exp" | "sqrt" | "abs" | "floor" | "ceil" | "round" => { format!("f32::{name}({})", a[0]) } "pow" => format!("f32::powf({}, {})", a[0], a[1]), "min" | "max" => format!("f32::{name}({}, {})", a[0], a[1]), "clamp" => format!("f32::clamp({}, {}, {})", a[0], a[1], a[2]), // Spelled out rather than called: Rust has no `mix`, and the // linear form is what WGSL's `mix` means. "mix" => format!("({x} + ({y} - {x}) * {t})", x = a[0], y = a[1], t = a[2]), // `expr::FUNCTIONS` is the closed list and `expr::parse` // enforces it, so reaching here means the two fell out of step. other => unreachable!("`{other}` passed validation but has no Rust rendering"), } } } } /// 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() } } // --------------------------------------------------------------------------- // 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(declared) = node { emit_node(&mut out, declared); } } 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, raw_string(&h.source()) ); } // 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: &Declaration) { let Declaration { id, label, attributes, doc, params, uniforms, shared_helpers, local_helpers, active, tests, presentation, camera_stage, .. } = node; 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 Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId,\n\ \x20 Presentation,\n\ \x20 Scale, Unit, WidgetDemand, WidgetKind,\n\ \x20 };\n\ \x20 #[allow(unused_imports)]\n\ \x20 use crate::operation::{Helper, Operation, Uniform};\n\ \x20 use std::sync::{Arc, LazyLock};\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. // // Built once, behind a `LazyLock`, and handed out as an `Arc` clone. It // was a plain `static` until descriptors became owned (FR-PLG-2): an // `OpDescriptor` now holds `Vec`s, and an `Arc` is not const-constructible // in any case. The cost is one lock check the first time an operation is // asked what it is, and an atomic increment thereafter. out.push_str( "\n static DESCRIPTOR: LazyLock> = LazyLock::new(|| {\n\ \x20 Arc::new(OpDescriptor {\n", ); let _ = writeln!(out, " id: ID,"); let _ = writeln!(out, " label: LocalizedKey({label:?}),"); out.push_str(" params: vec![\n"); for p in params { if let Some(doc) = &p.doc { out.push_str(&comment(doc, "//", 16)); } let _ = writeln!(out, " {},", param_ctor(p)); } out.push_str(" ],\n"); // What the operation is about. The panel groups by these and names no // operation, which is what keeps FR-DEV-3a true as the set grows. let attrs: Vec = attributes .iter() .map(|a| { let mut c = a.name().chars(); let head = c .next() .expect("attribute names are non-empty") .to_uppercase(); format!("Attribute::{head}{}", c.as_str()) }) .collect(); let _ = writeln!(out, " attributes: vec![{}],", attrs.join(", ")); 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, raw_string(&h.source()) ); } 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) -> Arc {\n DESCRIPTOR.clone()\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!("({}) != 0.0", compile_expr(expr)), 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" ); // Only a camera-stage node says anything: the trait's default is the // scene, which is every other node (D19). if *camera_stage { out.push_str( " fn stage(&self) -> crate::operation::Stage {\n \ crate::operation::Stage::Camera\n }\n\n", ); } let _ = writeln!( out, " fn wgsl_body(&self) -> String {{\n {}.into()\n }}\n", raw_string(&node.wgsl_body()) ); 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, compile_expr(&u.expr) ); } out.push_str(" ]\n }\n"); if !helper_refs.is_empty() { out.push_str( "\n fn helpers(&self) -> &[Helper] {\n HELPERS\n }\n", ); } // A node that asked for a widget. Omitted entirely otherwise, so the // trait's default — one control per parameter — stands. if let Some(p) = presentation { let _ = writeln!( out, "\n fn presentation(&self) -> Option {{\n\ \x20 Some(Presentation {{\n\ \x20 widgets: vec![{}],\n\ \x20 demand: WidgetDemand {{\n\ \x20 two_dimensional: {},\n\ \x20 precise_pointing: {},\n\ \x20 }},\n\ \x20 params: vec![{}],\n\ \x20 }})\n\ \x20 }}", p.widgets .iter() .map(|w| w.rust()) .collect::>() .join(", "), p.two_dimensional, p.precise_pointing, // The generated `ParamId` constants, which are the ids uppercased. p.params .iter() .map(|name| name.to_uppercase()) .collect::>() .join(", ") ); } out.push_str(" }\n"); emit_tests(out, &ty, params, tests); out.push_str("}\n\n"); } 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(declared) => { let id = &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 // --------------------------------------------------------------------------- /// The `ParamDescriptor` constructor call for a declared parameter. /// /// 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. /// /// The load-time reader calls the *same* constructors from the same /// [`decl::Kind`] — see `declared/mod.rs`'s `param_descriptor` — so the two /// produce equal descriptors by construction rather than by coincidence. fn param_ctor(p: &ParamDef) -> String { let (id, label) = (&p.id, &p.label); match &p.kind { decl::Kind::Stops { min, max } => format!( "ParamDescriptor::stops({id:?}, {label:?}, {}, {})", rust_f32(*min), rust_f32(*max) ), decl::Kind::Amount => format!("ParamDescriptor::amount({id:?}, {label:?})"), decl::Kind::Switch => format!("ParamDescriptor::switch({id:?}, {label:?})"), decl::Kind::Fraction { default } => format!( "ParamDescriptor::fraction({id:?}, {label:?}, {})", rust_f32(*default) ), decl::Kind::Scalar { min, max, default, unit, scale, precision, } => format!( "ParamDescriptor::scalar({id:?}, {label:?}, {}, {}, {}, {}, {}, {precision})", rust_f32(*min), rust_f32(*max), rust_f32(*default), match unit { decl::Unit::None => "Unit::None", decl::Unit::Stops => "Unit::Stops", decl::Unit::Kelvin => "Unit::Kelvin", decl::Unit::Percent => "Unit::Percent", }, match scale { decl::Scale::Linear => "Scale::Linear", decl::Scale::Perceptual => "Scale::Perceptual", } ), decl::Kind::Enum { variants } => { let keys: Vec = variants .iter() .map(|v| format!("LocalizedKey({v:?})")) .collect(); format!( "ParamDescriptor::choice({id:?}, {label:?}, vec![{}])", keys.join(", ") ) } } } /// 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. /// /// The *content* it wraps comes from the shared reader — `Declaration:: /// wgsl_body` and `HelperDef::source` — because that content is what reaches /// the composed shader, and a run-time node has to produce the same /// characters. All this adds is the quoting. fn raw_string(body: &str) -> String { 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 }