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