The recipe for adding a node/op to the catalog (ops.rs / stats.rs), and the
table of what you actually have to touch. /new-op
(.claude/commands/new-op.md) is the step-by-step; this page is the reference
it defers to for why the boilerplate is shaped the way it is.
Extracted from
planning/port-plan.md§ Phase 2. It lived there while the port was running, but the port is finished and that file is an archive — this is live reference material with callers across the skills,crates/README.md,wingfoil-derive/README.mdandops.rs, so it gets its own page. The design argument behind the no-table mechanism isdecisions/macro-extensibility-decision.md.
Per node, in this order, no exceptions:
- identify
Cfg/State/In<'a>/Out/ACTIVATION; - write the
cyclebody — inputs are passed in, not read from upstream; - wire it up:
#[op(build = name)]on the impl generates the interpretedBuildermethod from the op'sInshape and thenitro!/compiled forwarders the emission dispatches through; add the fluent method (the one piece still hand-written); - write the tests as parity tests — values and tick times. If a legacy node is the twin, its unit tests are the oracle.
Two mechanisms single-source most of the boilerplate; the residual per-op cost is small and explained by two hard constraints on proc macros:
- A proc macro sees tokens, not resolved types — so
nitro!cannot introspect anOpimpl to learn its arity/cfg/input shape. Any per-op knowledge the macro needs must be written in the macro crate. - A trait cannot be extended from scattered sites — so
#[op]cannot add a method toStreamOpsdirectly. It gets there anyway, one indirection later:#[op(.., fluent)]emits amacro_rules!writing the method, which the trait'simplblock invokes. The declaration is still hand-written. (The same constraint is why the generatedBuildermethod arrives on a new per-op trait rather than on one shared one — and why it can be generated at all outside this crate, where an inherent impl would be illegal.)
- Interpreted engine —
#[op(build = name)]onimpl Op for XgeneratesBuilder::namefrom the op's declared shape: oneHandleparameter per edge ofIn<'a>, in order, then theCfg(omitted when it is()). This covers every shape the macro parses — sources (In = ()), single- and multi-input ops, edges read with their tick flag ((&'a T, bool)—delay,merge),passive = [..]non-activating edges,start/stop/teardownlifecycle hooks, andinit_argseeded accumulators — so no op keeps a hand-writtenBuildermethod for want of tooling. Node labels come fromtype_name::<X>()(shortened), not hand-written strings.no_builderis left for the case where the interpreted signature deliberately differs from the shape:with_time(seeds its value slot from the input's current value, so it never requiresOut: Default) is the catalog's only one. Thebimap/trimapfamily are additional hand-written methods over theJoin/Join3ops — their active/passive split is a runtime argument rather than the compile-timepassivemask — alongside the generatedjoin/join_passive/join3. The generated body is the samenext_node_index→slot/new_slot→push_node→set_*sequence a hand-written builder contains, against a#[doc(hidden)] pubcodegen seam onBuilder.register_op1…register_op4remain the curated, documented primitives for wiring a shape by hand. - Compiled /
nitro!— zero-touch, because there is no per-op table.#[op]emits forwarder functions by naming convention (__wf_op_<name>_*) and per-op facts (ACTIVATION, passive-edge masks) as consts the emission folds on; rustc's inference resolves the op type the macro never names. TheOpKind/OpInfotable this bullet used to describe has been deleted — built-in ops and user ops now take the identical path.
#[op] is not in-crate tooling. Its expansion names ::wingfoil::…
throughout and hangs the generated Builder method on a per-op extension trait
(__WfBuild<CamelName>) implemented for wingfoil::interp::Builder, so the
same attribute expands the same way in a downstream crate
(#782). A user op is:
use wingfoil::op; // the attribute
use wingfoil::op::{Activation, Ctx, Op, Tick}; // the trait
use wingfoil::prelude::*;
pub struct Gain;
#[op(build = gain)]
impl Op for Gain { /* Cfg / State / In / Out / ACTIVATION / cycle */ }
trait UserOps { fn gain(&self, factor: f64) -> Stream<f64>; }
impl UserOps for Stream<f64> {
fn gain(&self, factor: f64) -> Stream<f64> {
self.wire(move |b, h| b.gain(h, factor))
}
}and that reaches interpreted(), compiled() and nested() alike. Two
things to know, neither specific to the catalog:
- The generated method needs its trait in scope. In the op's own module
that is automatic; from another module it takes
use path::to::__WfBuild<CamelName>;. In-crate, that is whyfluent.rs,signal.rsandadapters::statisticsglob-importcrate::ops— naming ~70 traits one by one is churn with no reader value. - The dependency must be named
wingfoil. The expansion is::wingfoil::-qualified, so a crate that renames it (wf = { package = "wingfoil", … }) cannot use#[op]— ornitro!, which has always emitted the same paths. The workaround isextern crate wf as wingfoil;at the dependent's crate root. There is no$crate-style fix available: a proc macro cannot learn what the dependent calls its dependencies.
Worked examples: crates/wingfoil/tests/custom_op.rs (an integration test is a
separate crate, so every #[op] in it is an out-of-crate expansion) and
crates/wingfoil/tests/trybuild/pass/out_of_crate_op.rs (compiled and run as
its own crate in a throwaway Cargo project, outside the workspace).
Where to touch when adding an op — the compiled path is zero-touch:
| Op shape | Interpreted | nitro!/compiled |
|---|---|---|
| Single-input | ops.rs (impl + attr) + fluent method |
nothing — #[op]'s forwarders cover it |
Multi-input, values-only, all-active (the join shape) |
same — ops.rs (impl + attr) + fluent method |
nothing — &stream args classify as edges |
Source (In = ()), lifecycle hooks, tick-flag edges |
same — ops.rs (impl + attr) + fluent method |
nothing |
Passive edges (passive = [..]) / seeded accumulators (init_arg) |
same — ops.rs (impl + attr, with the flag) + fluent method |
nothing — attribute flags on #[op] |
Interpreted signature ≠ the op's shape (with_time) |
no_builder + a hand-written Builder method + fluent method |
nothing |
Constraint #1 still holds (a proc macro sees tokens, not types), but it is
routed around rather than paid per-op. Delay's engine-level special cases became
Tick::Silent in the Op contract. Measured at parity with the deleted table
emission and covered by wingfoil/tests/custom_op.rs; full analysis in
decisions/macro-extensibility-decision.md.
The fluent method remains hand-written (constraint #2, unchanged).
There is no central op list to diff, so the guard is realized at compile
time in tests/op_completeness.rs: a combinator used inside a nitro!
block only compiles if it has both a fluent method (the wiring fn is fluent
code) and a forwarder (#[op]), so exercising every dual-mode combinator
there is exactly the two-sided one-sided-registration guard. Each block
additionally asserts interpreted() == compiled().
The by-design fluent-only surface — feedback, the IO sources
(external/channel/poll), the for_each sink — is documented in that file
as an explicit allowlist. Three further ops are interpreted-only for want of a
forwarder rather than by design — join_passive / try_join_passive,
delay_with_reset, and with_time (all hand-written builder methods, not
#[op]). Those are candidate follow-ups, not by-design gaps.
Two shapes genuinely cannot be promoted: split returns two output streams
against an Op's single Out, and never has no Op witness for #[op] to
hang forwarders on (a source that never ticks). A runtime-width fan-in
(combine, merge_n) defeats #[op]'s generation but not the emission —
both reach all three engines through a witness op with hand-written forwarders
declaring In<'a> = &'a [(&'a T, bool)].