//! TRACES: FR-PLG-2 | FR-PLG-2d //! Reading a node declaration — the one reader, used at build time and at load //! time. //! //! # Why this is not in `build.rs` //! //! It was, and everything below is that code. FR-PLG-2 requires the schema //! documented in `ops/README.md` to be "readable at **load time** as well as //! build time … with no change to what a declaration means", and the sharpest //! way to guarantee "no change" is for there to be nothing that could change: //! one grammar, one set of validations, one set of error messages. //! //! So this module produces a **neutral** [`Declaration`] — owned data that //! names no Rust type and no crate item — and the two backends work from it: //! //! - `build.rs` `#[path]`-includes this file and renders a `Declaration` as //! Rust source, so the operations that ship with the application stay //! compiled and inspectable. //! - [`super::DeclaredOp`] converts a `Declaration` into descriptors and //! evaluates it, so an operation found in a file at startup is the same kind //! of thing as one found at compile time. //! //! `tests/declared_parity.rs` then asserts the two agree byte for byte on //! every node in `ops/`. //! //! **Nothing here may refer to the rest of the crate.** `build.rs` compiles it //! as a standalone module of a different crate, so `crate::` paths would not //! resolve. The mapping from these neutral types onto `crate::descriptor`'s is //! in `declared/mod.rs`, which is compiled only as part of the library. use std::collections::BTreeSet; use serde_norway::{Mapping, Value}; use super::expr::{self, Expr}; /// The file shared WGSL helpers are declared in. pub const HELPERS_FILE: &str = "_helpers.yaml"; // --------------------------------------------------------------------------- // The declaration model // --------------------------------------------------------------------------- /// A WGSL helper function, either shared or declared by one node. pub struct HelperDef { pub name: String, pub doc: Option, pub wgsl: String, } impl HelperDef { /// The helper's source exactly as it reaches the composed shader. /// /// 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. /// /// Shared between the backends because it is *content*, not rendering: /// `build.rs` wraps this in a Rust raw-string literal and a declared /// operation interns it, and the two have to produce the same characters /// or the composed shader differs. pub fn source(&self) -> String { literal_body(&self.wgsl, self.doc.as_deref()) } } /// TRACES: FR-PLG-2d /// What a parameter *is*, from the closed list of kinds a declaration may name. /// /// Closed on purpose, and the reason `ops/README.md` gives strengthens here: a /// typo that invented a kind would produce a control nobody asked for, and a /// control that silently does not exist is worse than a build that stops. pub enum Kind { Stops { min: f64, max: f64, }, Amount, Switch, Fraction { default: f64, }, Scalar { min: f64, max: f64, default: f64, unit: Unit, scale: Scale, precision: u64, }, Enum { variants: Vec, }, } /// The unit a value carries, mirroring `crate::descriptor::Unit`. #[derive(Clone, Copy)] pub enum Unit { None, Stops, Kelvin, Percent, } /// What a slider's travel means, mirroring `crate::descriptor::Scale`. #[derive(Clone, Copy)] pub enum Scale { Linear, Perceptual, } /// TRACES: FR-PLG-2d /// What an operation is about, mirroring `crate::descriptor::Attribute`. /// /// A closed vocabulary. `declared/mod.rs` asserts that this list and the /// crate's own agree, so the two spellings of one idea cannot drift. #[derive(Clone, Copy, PartialEq)] pub enum Attr { Tone, Colour, Detail, Optics, Geometry, Effect, } impl Attr { /// The name a declaration spells this with. pub const fn name(self) -> &'static str { match self { Attr::Tone => "tone", Attr::Colour => "colour", Attr::Detail => "detail", Attr::Optics => "optics", Attr::Geometry => "geometry", Attr::Effect => "effect", } } /// Every attribute, in the order `crate::descriptor::Attribute` declares /// them. pub const ALL: [Attr; 6] = [ Attr::Tone, Attr::Colour, Attr::Detail, Attr::Optics, Attr::Geometry, Attr::Effect, ]; } /// TRACES: FR-PLG-2d /// A widget a node may ask for, mirroring `crate::descriptor::WidgetKind`. /// /// Spelled in the YAML the way the enum spells it, so a node declaration and /// `descriptor.rs` cannot drift into two vocabularies for one idea. #[derive(Clone, Copy, PartialEq)] pub enum Widget { ToneCurve, ColourWheel, CropOverlay, GradientHandle, BrushMask, WhitePoint, } impl Widget { pub const fn name(self) -> &'static str { match self { Widget::ToneCurve => "tone_curve", Widget::ColourWheel => "colour_wheel", Widget::CropOverlay => "crop_overlay", Widget::GradientHandle => "gradient_handle", Widget::BrushMask => "brush_mask", Widget::WhitePoint => "white_point", } } /// The `WidgetKind` variant this is, as Rust source. For `build.rs`. pub const fn rust(self) -> &'static str { match self { Widget::ToneCurve => "WidgetKind::ToneCurve", Widget::ColourWheel => "WidgetKind::ColourWheel", Widget::CropOverlay => "WidgetKind::CropOverlay", Widget::GradientHandle => "WidgetKind::GradientHandle", Widget::BrushMask => "WidgetKind::BrushMask", Widget::WhitePoint => "WidgetKind::WhitePoint", } } pub const ALL: [Widget; 6] = [ Widget::ToneCurve, Widget::ColourWheel, Widget::CropOverlay, Widget::GradientHandle, Widget::BrushMask, Widget::WhitePoint, ]; } /// One parameter of a declared node. pub struct ParamDef { pub id: String, pub label: String, pub doc: Option, pub kind: Kind, /// Needed to generate `is_active` and to range-check test values. pub default: f64, pub min: f64, pub max: f64, } /// A uniform the node's fragment reads, and the expression computing it. pub struct UniformDef { pub name: String, pub doc: Option, pub expr: Expr, } /// A node's `presentation:` block. pub struct PresentationDef { /// Most preferred first. pub widgets: Vec, /// The owned parameters' ids, in widget order. pub params: Vec, pub two_dimensional: bool, pub precise_pointing: bool, } /// One declared test. /// /// Read and validated here so that a nonsense assertion is rejected the same /// way whoever wrote it, though only `build.rs` emits anything from it: a /// declared node's tests run under `cargo test` against the *generated* /// implementation, which is where FR-PLG-2 says they belong. pub struct TestDef { pub name: String, pub why: Option, pub set: Vec<(String, f64)>, pub expect: Vec<(String, f64)>, pub expect_range: Vec<(String, f64, f64)>, pub expect_active: Option, pub expect_wgsl: Vec, pub expect_helper_wgsl: Vec<(String, Vec)>, } /// A node declared in full. pub struct Declaration { pub id: String, pub label: String, pub order: i64, /// What the operation is about (ARCH §4.3a). Never empty — see /// [`read_attributes`]. pub attributes: Vec, pub doc: Option, pub placement: Option, pub params: Vec, pub uniforms: Vec, /// Names of shared helpers, in declaration order. pub shared_helpers: Vec, /// Helpers this node defines for itself. pub local_helpers: Vec, pub wgsl: String, /// `None` means the default rule: active when any parameter has moved. pub active: Option, pub tests: Vec, /// `None` — the usual case — means one control per parameter. /// /// Boxed so the rare node that declares one does not widen every /// declaration by the size of a presentation it does not have. pub presentation: Option>, } impl Declaration { /// The fragment body exactly as it reaches [`crate::Operation::wgsl_body`]. pub fn wgsl_body(&self) -> String { literal_body(&self.wgsl, None) } } /// A node: either declared in full, or a pointer to a hand-written type. /// /// The declared arm is boxed because it is an order of magnitude larger than /// the other: a `Declaration` carries every parameter, uniform, helper and /// test a node has, while a `rust:` node is four strings. Both readers move /// these by value out of the parser, and an unboxed enum would copy the larger /// shape every time it moved either. pub enum Node { Declared(Box), Rust { id: String, order: i64, /// The type in `crate::ops` implementing `Operation`. ty: String, why_rust: Option, placement: Option, }, } impl Node { pub fn id(&self) -> &str { match self { Node::Declared(d) => &d.id, Node::Rust { id, .. } => id, } } pub fn order(&self) -> i64 { match self { Node::Declared(d) => d.order, Node::Rust { order, .. } => *order, } } pub fn placement(&self) -> Option<&str> { match self { Node::Declared(d) => d.placement.as_deref(), Node::Rust { placement, .. } => placement.as_deref(), } } } /// The WGSL a declaration contributes, with its prose folded in as comments. /// /// The single definition of "what text does this declaration produce", so that /// the generated raw-string literal and the interpreted `String` cannot come /// to hold different characters. /// /// The trailing `trim` pair is load-bearing rather than tidiness: it is what /// `build.rs` used to do inline when emitting the literal, and the composed /// shader would differ by a newline without it. fn literal_body(wgsl: &str, doc: Option<&str>) -> String { let mut body = String::new(); if let Some(doc) = doc { for line in doc.trim_end().lines() { if line.trim().is_empty() { body.push_str("//\n"); } else { body.push_str("// "); body.push_str(line); body.push('\n'); } } } body.push_str(wgsl.trim_matches('\n').trim_end()); body } // --------------------------------------------------------------------------- // Reading: shared helpers // --------------------------------------------------------------------------- pub struct SharedHelpers { pub doc: Option, pub helpers: Vec, } impl SharedHelpers { /// The names, for validating a node's `helpers:` list against. pub fn names(&self) -> BTreeSet<&str> { self.helpers.iter().map(|h| h.name.as_str()).collect() } pub fn get(&self, name: &str) -> Option<&HelperDef> { self.helpers.iter().find(|h| h.name == name) } } /// Parse `_helpers.yaml`. pub fn read_helpers(text: &str) -> Result { let doc = parse_yaml(text, HELPERS_FILE)?; 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 // --------------------------------------------------------------------------- /// TRACES: FR-PLG-2d /// The attributes an operation declares, validated against the vocabulary. /// /// **Required, and non-empty.** An operation with no attribute is invisible to /// a frontend that filters by them, and a control that silently does not exist /// is a far worse failure than a build that stops — especially when the cause /// is one missing line in a YAML file nobody had reason to open. Failing here /// costs whoever adds an operation ten seconds; failing at runtime costs a /// photographer a control they cannot find and cannot know is missing. /// /// The vocabulary is closed on purpose. A typo would otherwise invent a /// category containing exactly one operation, which is indistinguishable from /// a deliberate new one until somebody notices the tab with a single control /// in it. fn read_attributes(root: &Mapping) -> Result, String> { let known: Vec<&str> = Attr::ALL.iter().map(|a| a.name()).collect(); let value = root.get("attributes").ok_or_else(|| { format!( "missing `attributes:`; every operation must say what it is about, \ one or more of {known:?}. It is what lets the panel group \ operations without naming any of them (ARCH §4.3a)." ) })?; let list = value .as_sequence() .ok_or("`attributes:` must be a list, even with one entry")?; let mut out: Vec = Vec::new(); for entry in list { let name = as_str(entry, "attributes")?; let Some(attr) = Attr::ALL.iter().copied().find(|a| a.name() == name) else { return Err(format!( "unknown attribute {name:?}; expected one of {known:?}" )); }; if out.contains(&attr) { return Err(format!("attribute {name:?} is listed twice")); } out.push(attr); } if out.is_empty() { return Err("`attributes:` is empty; an operation with no attribute \ would not appear in a panel that groups by them" .into()); } Ok(out) } /// Parse one `ops/.yaml`. /// /// `shared` is the set of helper names `_helpers.yaml` defines, so that a node /// naming one that does not exist is rejected where it is written rather than /// producing a shader that fails to compile. pub fn read_node(text: &str, ctx: &str, shared: &BTreeSet<&str>) -> Result { let doc = parse_yaml(text, ctx)?; 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 attributes = read_attributes(root)?; 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 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 uniforms = read_uniforms(root, ¶m_names)?; let active = match root.get("active") { None => None, Some(v) => { let src = as_str(v, "active")?; Some(expr::parse(src, ¶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)?; let presentation = read_presentation(root, ¶m_names)?; Ok(Node::Declared(Box::new(Declaration { id, label, order, doc: opt_prose(root, "doc", "doc")?, attributes, placement, params, uniforms, shared_helpers, local_helpers, wgsl, active, tests, presentation, }))) } /// The widgets a node would like, in descending order of preference. /// /// Optional, and absent on nearly every node — one control per parameter is /// the right answer for a list of unrelated sliders, which is what most /// operations are. Declaring this says that several parameters form *one* /// conceptual control. /// /// It is a hint and nothing more (ARCH §4.3a). A frontend implementing none of /// the named widgets renders the parameters as ordinary sliders and the edit /// still works, which is why the list can safely name widgets that do not /// exist yet. fn read_presentation( root: &Mapping, params: &BTreeSet<&str>, ) -> Result>, String> { let Some(value) = root.get("presentation") else { return Ok(None); }; let m = as_mapping(value, "presentation")?; let widgets = m .get("widgets") .and_then(Value::as_sequence) .ok_or("`presentation` needs a `widgets:` list, most preferred first")?; if widgets.is_empty() { return Err("`presentation.widgets` is empty; omit `presentation:` instead".into()); } let widgets = widgets .iter() .enumerate() .map(|(i, w)| { let name = as_str(w, &format!("presentation.widgets[{i}]"))?; Widget::ALL .iter() .copied() .find(|k| k.name() == name) .ok_or_else(|| { format!( "`presentation.widgets[{i}]` is `{name}`; expected tone_curve, \ colour_wheel, crop_overlay, gradient_handle, brush_mask or white_point" ) }) }) .collect::, _>>()?; // The parameters the widget owns, in the order it expects them. Checked // against the node's own list, because a typo here would silently leave a // parameter out of the widget *and* out of the panel — the widget claims // it, and the generic path skips what the widget claimed. let owned = m .get("params") .and_then(Value::as_sequence) .ok_or("`presentation` needs a `params:` list naming what the widget owns")?; let owned = owned .iter() .enumerate() .map(|(i, p)| { let name = as_str(p, &format!("presentation.params[{i}]"))?; if !params.contains(name) { return Err(format!( "`presentation.params[{i}]` is `{name}`, which this node does not declare" )); } Ok(name.to_string()) }) .collect::, _>>()?; let demand = match m.get("demand") { None => (false, false), Some(d) => { let d = as_mapping(d, "presentation.demand")?; let flag = |key: &str| -> Result { match d.get(key) { None => Ok(false), Some(v) => v.as_bool().ok_or_else(|| { format!("`presentation.demand.{key}` must be true or false") }), } }; // Deliberately only these two. ARCH §4.3a forbids a demand // carrying pixels, breakpoints or a platform name — those are the // frontend's to decide — so there is no key here to write one in. (flag("two_dimensional")?, flag("precise_pointing")?) } }; Ok(Some(Box::new(PresentationDef { widgets, params: owned, two_dimensional: demand.0, precise_pointing: demand.1, }))) } 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_name = as_str( spec.get("kind") .ok_or_else(|| format!("`{ctx}` has no `kind:`"))?, &format!("{ctx}.kind"), )?; let (kind, default, min, max) = read_kind(kind_name, spec, &ctx)?; out.push(ParamDef { id, label, doc: opt_prose(spec, "doc", &ctx)?, kind, default, min, max, }); } if out.is_empty() { return Err("`params:` is empty".into()); } Ok(out) } /// Read a declared parameter kind, with its default and its bounds. /// /// 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 read_kind(kind: &str, spec: &Mapping, ctx: &str) -> Result<(Kind, 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) }; let (kind, default, min, max) = match kind { "stops" => { let (min, max) = (need("min")?, need("max")?); (Kind::Stops { min, max }, 0.0, min, max) } "amount" => (Kind::Amount, 0.0, -100.0, 100.0), "switch" => (Kind::Switch, 0.0, 0.0, 1.0), "fraction" => { let default = opt("default", 0.0); (Kind::Fraction { 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:`"))?; ( Kind::Scalar { min, max, default, unit, scale, precision, }, default, min, max, ) } // A fixed list of named alternatives. The value is the chosen index, // so the range is the list's own bounds and a node declaring one needs // no `min:`/`max:` of its own. // // Variants are localisation keys, like every other label in a node — // the core never holds a display string (NFR-A11Y-1). The default is // always the first, so `reset` means the same thing here as everywhere // else; a node whose neutral choice is not first has listed them in // the wrong order. "enum" => { let variants = spec .get("variants") .and_then(Value::as_sequence) .ok_or_else(|| format!("`{ctx}` is `kind: enum` and needs a `variants:` list"))?; if variants.len() < 2 { return Err(format!( "`{ctx}.variants` lists {} choice(s); a control the user \ cannot change is not a control", variants.len() )); } let keys = variants .iter() .enumerate() .map(|(i, v)| as_str(v, &format!("{ctx}.variants[{i}]")).map(str::to_string)) .collect::, _>>()?; let last = (keys.len() - 1) as f64; (Kind::Enum { variants: keys }, 0.0, 0.0, last) } other => { return Err(format!( "`{ctx}.kind` is `{other}`; expected stops, amount, switch, \ fraction, scalar or enum" )) } }; // 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((kind, 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, src) = 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 expr = expr::parse(&src, params).map_err(|e| format!("`{ctx}`: {e}"))?; out.push(UniformDef { name, doc, expr }); } 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) } // --------------------------------------------------------------------------- // YAML access // --------------------------------------------------------------------------- fn parse_yaml(text: &str, ctx: &str) -> Result { serde_norway::from_str(text).map_err(|e| format!("{ctx}: not valid YAML: {e}")) } 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`. /// /// Enforced at load time as well as build time even though a declaration read /// at run time never becomes Rust: the two paths must agree on what a valid /// declaration *is*, or a plugin could be accepted by one and rejected by the /// other, and the built-ins would stop being a fair test of the format. pub 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(()) }