Every point operation ran in camera RGB and the camera matrix came after them all, against the order ARCH §5.2 draws. So `luminance()` applied Rec.709 weights to a body's own primaries, a band in the colour mixer was a different hue on every make of sensor, and the vibrance skin guard tested channel order in a space where skin does not have one. Operations now declare a `Stage`. White balance is the only camera-stage node — its multipliers scale the sensor's channels, and after a matrix that mixes them the same numbers are a different correction — and says so with `stage: camera` in its YAML, a key both the build-time generator and the load-time declared op read. The composer emits the camera nodes, then the matrix, then the rest, each group in graph order; an empty chain still gets the matrix. Film simulation stops converting out of camera space itself, since it is now handed working-space colour like every other scene node. The base curve stays where it was, after the operations, and so now acts on working-space colour; the next commit replaces it (D19).
1224 lines
43 KiB
Rust
1224 lines
43 KiB
Rust
//! 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<String>,
|
||
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<String>,
|
||
},
|
||
}
|
||
|
||
/// 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,
|
||
Compose,
|
||
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::Compose => "compose",
|
||
Attr::Effect => "effect",
|
||
}
|
||
}
|
||
|
||
/// Every attribute, in the order `crate::descriptor::Attribute::ALL`
|
||
/// lists them — the reasoning for the sequence lives there, and the
|
||
/// agreement test in `declared/mod.rs` zips the two, so a reorder there
|
||
/// that is not mirrored here is a failure rather than a drift.
|
||
pub const ALL: [Attr; 6] = [
|
||
Attr::Optics,
|
||
Attr::Compose,
|
||
Attr::Tone,
|
||
Attr::Colour,
|
||
Attr::Effect,
|
||
Attr::Detail,
|
||
];
|
||
}
|
||
|
||
/// 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<String>,
|
||
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<String>,
|
||
pub expr: Expr,
|
||
}
|
||
|
||
/// A node's `presentation:` block.
|
||
pub struct PresentationDef {
|
||
/// Most preferred first.
|
||
pub widgets: Vec<Widget>,
|
||
/// The owned parameters' ids, in widget order.
|
||
pub params: Vec<String>,
|
||
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<String>,
|
||
pub set: Vec<(String, f64)>,
|
||
pub expect: Vec<(String, f64)>,
|
||
pub expect_range: Vec<(String, f64, f64)>,
|
||
pub expect_active: Option<bool>,
|
||
pub expect_wgsl: Vec<String>,
|
||
pub expect_helper_wgsl: Vec<(String, Vec<String>)>,
|
||
}
|
||
|
||
/// 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<Attr>,
|
||
pub doc: Option<String>,
|
||
pub placement: Option<String>,
|
||
pub params: Vec<ParamDef>,
|
||
pub uniforms: Vec<UniformDef>,
|
||
/// Names of shared helpers, in declaration order.
|
||
pub shared_helpers: Vec<String>,
|
||
/// Helpers this node defines for itself.
|
||
pub local_helpers: Vec<HelperDef>,
|
||
pub wgsl: String,
|
||
/// `None` means the default rule: active when any parameter has moved.
|
||
pub active: Option<Expr>,
|
||
pub tests: Vec<TestDef>,
|
||
/// `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<Box<PresentationDef>>,
|
||
/// Whether the node runs in camera RGB, ahead of the camera matrix —
|
||
/// `stage: camera`. See [`read_stage`].
|
||
pub camera_stage: bool,
|
||
}
|
||
|
||
/// TRACES: FR-DEV-3e | FR-DEV-2
|
||
/// Where in the chain a declared node's colour comes from: `stage: camera` or
|
||
/// `stage: scene`, the default.
|
||
///
|
||
/// Camera RGB is where white balance's multipliers are defined, and it is the
|
||
/// only thing that belongs there (D19): every other operation is handed
|
||
/// working-space colour, so that a hue or a luminance weight means the same
|
||
/// thing whichever body took the frame. `view` is not offered. The view
|
||
/// transform is hand-written, and a declared node that clipped into a display
|
||
/// range would be exactly what ARCH §6.14 forbids of every node before it.
|
||
fn read_stage(root: &Mapping) -> Result<bool, String> {
|
||
match root.get("stage") {
|
||
None => Ok(false),
|
||
Some(v) => match as_str(v, "stage")? {
|
||
"camera" => Ok(true),
|
||
"scene" => Ok(false),
|
||
other => Err(format!(
|
||
"unknown stage {other:?}; expected \"camera\" or \"scene\""
|
||
)),
|
||
},
|
||
}
|
||
}
|
||
|
||
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<Declaration>),
|
||
Rust {
|
||
id: String,
|
||
order: i64,
|
||
/// The type in `crate::ops` implementing `Operation`.
|
||
ty: String,
|
||
why_rust: Option<String>,
|
||
placement: Option<String>,
|
||
},
|
||
}
|
||
|
||
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<String>,
|
||
pub helpers: Vec<HelperDef>,
|
||
}
|
||
|
||
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<SharedHelpers, String> {
|
||
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<Vec<Attr>, 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<Attr> = 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/<id>.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<Node, String> {
|
||
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)?;
|
||
// `attributes` is in this list, and was not until a declaration that
|
||
// said `[effect]` sat above a type that said `[tone, colour]` for a
|
||
// whole commit without anything noticing. The line parsed, validated
|
||
// against the vocabulary, and was then dropped on the floor — so the
|
||
// file read as though it had moved the operation and the panel went on
|
||
// filing it under two groups it did not belong to. A key that is
|
||
// *ignored* is worse than one that is rejected, because it looks like
|
||
// it worked.
|
||
for key in [
|
||
"params",
|
||
"uniforms",
|
||
"wgsl",
|
||
"helpers",
|
||
"define",
|
||
"label",
|
||
"attributes",
|
||
"stage",
|
||
] {
|
||
if root.contains_key(key) {
|
||
return Err(format!(
|
||
"`{key}` is meaningless on a `rust:` node — {ty} publishes \
|
||
its own descriptor, and this file would be ignored. Say it \
|
||
in the type instead."
|
||
));
|
||
}
|
||
}
|
||
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)?;
|
||
let camera_stage = read_stage(root)?;
|
||
|
||
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,
|
||
camera_stage,
|
||
})))
|
||
}
|
||
|
||
/// 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}]"))?;
|
||
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::<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_string())
|
||
})
|
||
.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,
|
||
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_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<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)
|
||
};
|
||
|
||
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::<Result<Vec<_>, _>>()?;
|
||
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<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, 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<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)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// YAML access
|
||
// ---------------------------------------------------------------------------
|
||
|
||
fn parse_yaml(text: &str, ctx: &str) -> Result<Value, String> {
|
||
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<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`.
|
||
///
|
||
/// 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(())
|
||
}
|