kithara derive - zvuk/kithara GitHub Wiki
Documentation reviewed from PR #536, source revision c0b2e34b3. This records the documented contract at that revision; it is not a runtime or product validation. API and usage · All crates.
This crate owns generated configuration declarations, owner bindings, and the
shape of a configuration document. Runtime ownership stays with
kithara-config and each domain owner. A configuration document's
<X>Patch and its merge come from #[derive(Patch)]: a hand-written patch
or merge beside a configuration is a defect, not a style choice. This is review
policy rather than a lint, since derivable_patch flags only a hand-spelled
humantime attribute. The kithara-ui skin overlays and firewheel parameter
patches are other types that only share the suffix.
The crate owns no runtime state. Its code depends only on syn, quote, and
proc-macro2, plus kithara-workspace-hack for feature unification on
non-wasm targets. Expansions name crates by absolute path and re-export
nothing, so consumers carry the direct dependency their derives require:
- Patch names
::serdeand::core. It also names::humantime_serdefor ahumantimekey and::kithara_configfor a struct that declarescheck(error = ...). - Config and ConfigOwner name
::kithara_configand::core. - Ranged names
::serdeand::core. - Event and EventSet name
::kithara_eventsand::core.
From a struct with named fields, Patch takes every field not carrying
#[patch(skip)] and emits these items in the same module:
- the patch struct, named after the configuration with a
Patchsuffix, carrying the configuration's visibility on itself and its fields, derivingClone,Debug,DefaultandDeserialize, and attributed#[serde(default, deny_unknown_fields)]and#[non_exhaustive]; - an inherent impl on the patch holding the deserializer of each non-nested
key, which refuses
null; - an inherent
applyon the configuration itself, taking that patch by value and repeating the configuration's own generics and where-clause; - for a configuration that declares a refusal (see below), the patch error,
named with a
PatchErrorsuffix,#[non_exhaustive], implementingDisplayandcore::error::Error, with one variant per way the merge can be refused.
The patch carries no generic parameters of its own. That was the reason the
derive was written: struct-patch, the crate it replaced, copies a struct's
generics onto the patch it generates. A patch of a generic configuration whose
generic-carrying fields are skipped then has a type parameter no field uses
and does not compile. The configurations generic over a stream, pool or
resampler backend (QueueConfig<S>, AudioConfig<T, B>, the file and HLS
configurations) keep those parameters out of every document. The derive has
since added null refusal, wire-typed keys, nested and fallible merges, and the
validation gate.
A configuration that derives kithara_config::Config keeps every facet in one
#[config(...)] attribute, so Patch also reads the patch(...) group of the
type's and of each field's #[config(...)]. An item spells its patch options
once: a native #[patch] beside a patch(...) group is refused, because two
lists of the same options could disagree. Inside #[config] the patch options
are always the group patch(...). Patch sits behind the patch feature, which
config implies.
Config keeps snapshot roles, accessors, construction, debugging, field checks
and live changes in one field declaration. fields(...) on a struct and
config(...) on an individual field use the same grammar. The struct supplies
defaults; a field overrides only the facet it names, and an explicit option
group replaces that whole inherited group. An explicit builder or wrapper
replaces inherited construction settings. Duplicate declarations at one scope
are refused.
value clones a field into an observation snapshot; nested snapshots it
through its own Config implementation. Neither operation transfers canonical
ownership to the snapshot. get(ref) borrows the original retained field,
get(copy) returns it through Copy, and get(skip) disables an inherited
accessor. Snapshot exclusion, builder exclusion, patch exclusion, and debug
exclusion are independent policies.
The same defaults can carry get(...), builder(...), patch(...),
debug(skip), wrap(...), live or live(owner), check = path, and sdk.
fields(patch(skip)) excludes document keys by default, and an explicit field
patch group replaces that exclusion. Patch supports these patch defaults
without requiring the Config derive or its feature. An inherited
fields(live) cannot be cancelled per field. live takes no argument except
owner, so every field that inherits it must be a value or nested field.
Owner accessors delegate to the retained configuration. A live change passes
its own field's check and nothing else. A builder that declares
validate_builder passes the configuration's declared check. Accessor
generation adds no mutable getter that bypasses either.
PR #536 replaces whole-configuration runtime updates with changes of one field. The owner's control side checks a change, and the executor assigns it.
Field checks. #[config(check(error = E))] on the struct and
#[config(check = path)] on a value field (a fn(T) -> Result<T, E>) generate
kithara_config::CheckedConfig. Its validated runs the field checks in
declaration order and returns the first refusal. It validates every nested
field whole through the nested type's own validated, and the nested error
converts into E through ?. A field check is refused without the struct's
error, and on a nested, skipped or projected field. check(error = ...)
excludes patch(validate = ..., error = ...). With it, a document merge gates
the merged candidate through validated, and a validate_builder build
returns Result<Self, E>.
Live fields. live on a value or nested field makes the field one variant
of the generated <Name>Change enum. The enum is Copy and Debug, has the
struct's visibility, and names each variant after its field in UpperCamelCase.
A value field's variant carries the value. A nested live field's variant
carries the nested change, and the parent change implements From for it. The
derive implements kithara_config::LiveConfig:
-
checkruns the one field's check; -
apply_changeassigns that field alone, with no check or allocation. A real-time executor calls it.
A live configuration must meet these rules:
- it is
Copyand has no generic parameters; - it declares
check(error = ...)rather than a whole-structpatch(validate = ...), because a change of one field would skip a whole-struct check; - it names no field
settingsorconfigure; - no two fields form the same variant, and no two nested live fields share a type.
Construction inputs cannot declare live fields or checks.
live(owner) marks a value field whose owner executes the change with its own
method, as a sample-rate change restarts the output route instead of reaching
the render graph. LiveConfig::OWNER_FIELDS
reports such a field. A nested live configuration must have none, which a
compile-time assertion enforces.
Owner surface. kithara_config::Configure<Ch> is implemented by hand and
keyed by the change type, so one owner configures several configurations. It
has two methods:
-
configure(&self, change, at)hands a change to the owner for a moment of typeAt: Default; -
settings(&self)returns the configuration as last applied.
For a struct with a live field, the derive also emits two traits:
-
<Name>Control, blanket-implemented for every such owner. It has a getter per field with an accessor, which readssettings(), and aset_<field>per live value field, which configures at the default moment. Each nested live field gets aNestedgetter. It forwards the nested change to the parent's owner, so the parent's check guards it. -
<Name>Exec<Cx>, how the owner's executor runs a change. The providedexecroutes eachlive(owner)field toexec_<field>(value, at, cx)and every other live field toexec_live(change, at, cx).
Two fields that generate the same method are refused, and the error names both.
Hygiene. Generated bodies bind __kithara_-prefixed names, so a constant
in scope named value, at, cx, change or config never turns a binding
into a pattern. Several generated items give Self another meaning: the change
enum, <Name>Control, <Name>Exec, <Name>OwnerAccess and the snapshot. In
those items, a field type naming Self is respelled as the configuration type
with its generics.
Owners. #[config(owner_access)] emits <Name>OwnerAccess for any
ConfigOwner of the type. It repeats each get(ref) or get(copy) getter
through config(). It is refused without such a getter, and for a field named
config, whose getter would collide with ConfigOwner::config.
#[derive(ConfigOwner)] takes one of three forms:
-
#[config_owner(field)]; -
#[config_owner(Type, field.path)]; -
#[config_owner(delegate(field))], which reads the configuration through a field that is itself aConfigOwner. One such field iskithara_command::Live, which owns a live configuration but never hands out&mutto it.
A single #[config_owner_mut] adds ConfigOwnerMut. Its apply_config_change
checks a change and applies it only on acceptance. On refusal, the
configuration stays unchanged.
- A field of type
TbecomesOption<T>; the merge writes it when the document named it. - A field already of type
Option<T>staysOption<T>, so a document names the value bare. An absent key is the only way to leave the caller's value standing: a patch cannot clear a field back toNone. - A key a document names must carry a value. Every non-nested key refuses
nullthrough its generated deserializer, rather than reading it as "leave it alone" or as "clear it": the only way to keep a value is to leave the key out. A nested key gets no generated guard. Whether it acceptsnulldepends on how the document format deserializes the nested patch. -
#[patch(nested)]makes the patch field<T>Patchand the merge recurse. Nesting is declared, not inferred, so a document's shape is readable from the configuration alone. -
#[patch(wire = <type>, from = <path>)]gives the key a type of its own. Use it when the field holds something a document cannot spell — a live handle, or an enum one of whose variants carries one. The key parses as the wire type and the merge writes#from(value), so the choice a document may make is a type in its own right and the variants it may not name are refused by name rather than dropped in silence. Both halves are required;wireandnestedare mutually exclusive, because a replaced type has no nested patch to recurse into. -
humantimereads a key throughhumantime_serde.attribute(...)adds attributes to the patch field alone, and aserde(with = ...)orserde(deserialize_with = ...)inside it becomes the key's null-guarded deserializer. A key has one deserializer, which cannot be combined withnested. -
docandcfgattributes carry over to the patch field, and acfgalso gates the merge statement, so a feature-gated field stays gated on both sides.
Most configurations accept any combination of their own field types, and their
apply returns nothing. A configuration whose merge can refuse declares so, and
apply returns Result<(), <X>PatchError> instead. A struct declares a refusal
in one of three ways:
-
#[patch(validate = <path>, error = <type>)]names afn(Self) -> Result<Self, E>; either half without the other is refused. The merge builds a whole candidate beside the caller's value, puts it through that gate, and commits only a judged one, so a refused document leaves the caller holding exactly what it had. The refusal reaches the patch error asInvalid. The same gate guards the generated builder when the struct declaresvalidate_builder. A configuration with live fields cannot use it. -
#[config(check(error = E))]stages the candidate the same way. It puts the candidate throughCheckedConfig::validated, so a document merge passes every field check and nested validation. The refusal again arrives asInvalid. -
#[patch(fallible)]alone, for a struct whose only refusals come from its nested children.
#[patch(nested, fallible)] on a field says the nested configuration judges
itself. Its refusal reaches the parent under a variant named after the field,
and the error Displays as "<key>: <what the child said>", so a nested
refusal reads as the path a document would have to fix. Every fallible child is
judged before any key is written, so here too a refused document commits
nothing. A refusing merge stages a clone of the configuration or of each
fallible child, so those types are Clone.
The signature is the struct's declaration, never a consequence of the fields the
current features leave standing. cfg is resolved before a derive runs, so a
gated fallible field is invisible here; inferring fallibility from what is left
would move apply's signature from build to build. A field marked fallible
is therefore refused unless the struct declares a refusal, and fallible
requires nested. Consider a struct that declares only patch(fallible) and
whose features gate every fallible key out. Its patch error is still emitted,
uninhabited, so the callers do not change shape.
A generated patch is Deserialize and never Serialize. By the time a document
is typed its $ENV references are resolved, so the patch holds secrets in the
clear; serializing one would write them out. The derive emits no Serialize,
and adding one to a configuration must not add one to its patch.
#[derive(Ranged)] declares a numeric newtype whose generated doors never
produce a value outside its range. The doors are checked, Deserialize, and
the opt-in clamping From. A bound therefore crosses a crate boundary as a
type rather than as loose constants a caller must remember to clamp against.
The derive does not hide the field. The type stays sealed only while its field
is private and the declaring module does not write Self(x) itself.
A hand-written MIN/MAX pair with a contains check or a clamping
constructor is a defect, and derivable_ranged denies it. Without clamp there
is no conversion into the type. clamp is the explicit exception a knob opts
into, and it maps NaN to DEFAULT rather than propagating it through every
later comparison. A document never clamps: Deserialize refuses an
out-of-range value, NaN and infinity, even for a clamping type. Serialize
is never emitted. The generated code names ::core and ::serde only, so a
crate that declares a Ranged type must carry serde. Standard derives stay
at the site.
PR #354 adds Event and EventSet.
Event emits the marker implementation for a concrete struct or enum and refuses
unions, generics, lifetimes, and const parameters. Bounds remain the event trait's
contract, checked when the generated implementation is compiled.
EventSet accepts a nonempty concrete enum with exactly one unnamed event field
per variant. It emits the member conversions and one receiver per event type.
Do not combine it with derive_more::From, which would duplicate those impls.
Variant declaration order is receive priority. Async receive skips closed members
and returns Closed only when all are closed; nonblocking receive returns lag
immediately, keeps scanning empty/closed members, and reports Empty while any
member remains open. Publication dispatches the wrapped event with its original
metadata. Generated paths require a direct kithara-events dependency.