Skip to main content

batpak_macros/
lib.rs

1//! Proc macros for the batpak event-sourcing runtime.
2//!
3//! This crate is pulled in transitively via `batpak`. Users never add it
4//! to their own `Cargo.toml` — the derives are already in scope via
5//! `use batpak::EventPayload;` or `use batpak::EventSourced;`.
6
7mod all_variants;
8mod error;
9mod event_payload;
10mod operation;
11
12use proc_macro::TokenStream;
13use quote::quote;
14use std::collections::HashSet;
15use syn::{
16    parse_macro_input, spanned::Spanned, Attribute, Data, DeriveInput, Fields, Ident, LitInt, Path,
17};
18
19/// Derives `batpak::event::EventPayload` for a named-field struct.
20///
21/// Requires `#[batpak(category = N, type_id = N)]` on the struct. See
22/// `batpak::event::EventPayload` and ADR-0010 for the full contract.
23#[proc_macro_derive(EventPayload, attributes(batpak))]
24pub fn derive_event_payload(input: TokenStream) -> TokenStream {
25    let input = parse_macro_input!(input as DeriveInput);
26    match event_payload::expand(&input) {
27        Ok(ts) => ts.into(),
28        Err(e) => e.to_compile_error().into(),
29    }
30}
31
32/// Derives `core::fmt::Display` + `std::error::Error` for an error enum from a
33/// per-variant `#[error("...")]` format literal — a thiserror-lite.
34///
35/// Every variant needs exactly one `#[error("literal {field}")]`. A field
36/// annotated `#[source]` becomes `Error::source()`; `#[from]` additionally
37/// generates `impl From`. Enums only; see `crates/macros/src/error.rs` for the
38/// full contract (byte-identical rendering, explicit source wiring).
39#[proc_macro_derive(Error, attributes(error, source, from))]
40pub fn derive_error(input: TokenStream) -> TokenStream {
41    let input = parse_macro_input!(input as DeriveInput);
42    match error::expand(&input) {
43        Ok(ts) => ts.into(),
44        Err(e) => e.to_compile_error().into(),
45    }
46}
47
48/// Derives a `pub const ALL: [Self; N]` slice listing every variant of a
49/// fieldless enum, in declaration order. See `crates/macros/src/all_variants.rs`.
50#[proc_macro_derive(AllVariants)]
51pub fn derive_all_variants(input: TokenStream) -> TokenStream {
52    let input = parse_macro_input!(input as DeriveInput);
53    match all_variants::expand(&input) {
54        Ok(ts) => ts.into(),
55        Err(e) => e.to_compile_error().into(),
56    }
57}
58
59/// `#[operation(...)]` — generate a syncbat operation descriptor + optional
60/// registration fns. Re-exported as `syncbat::operation`; users never name this
61/// crate. (Moved here from the former `syncbat-macros` crate — the family has one
62/// proc-macro crate.)
63#[proc_macro_attribute]
64pub fn operation(attr: TokenStream, item: TokenStream) -> TokenStream {
65    let args = parse_macro_input!(attr as operation::OperationArgs);
66    let function = parse_macro_input!(item as syn::ItemFn);
67    match operation::expand_operation(args, &function) {
68        Ok(tokens) => tokens.into(),
69        Err(error) => error.to_compile_error().into(),
70    }
71}
72
73/// Derives `batpak::event::MultiReactive<Input>` for a named-field struct,
74/// for use with `Store::react_loop_multi` (JSON) or
75/// `Store::react_loop_multi_raw` (msgpack).
76///
77/// Syntax mirrors `#[derive(EventSourced)]`:
78///   * `#[batpak(input = <Lane>)]` — required, once. `Lane` is either
79///     `JsonValueInput` or `RawMsgpackInput`.
80///   * `#[batpak(event = <Payload>, handler = <fn>)]` — one per bound
81///     payload type. At least one is required. `event = T` requires `T` to
82///     be a single-segment path (bring the type into scope with `use` if
83///     needed). This ensures the derive can dedupe event bindings without
84///     running full path resolution.
85///
86/// Generates a `MultiReactive<Input>` impl whose `dispatch` body matches
87/// on `event.header.event_kind`, uses `DecodeTyped::route_typed` per arm,
88/// calls the matching handler, and returns `MultiDispatchError::Decode` on
89/// matched-kind decode failure (unified contract with `TypedReactive<T>`).
90/// Unbound kinds fall through as `Ok(())` — silent filter.
91#[proc_macro_derive(MultiEventReactor, attributes(batpak))]
92pub fn derive_multi_event_reactor(input: TokenStream) -> TokenStream {
93    let input = parse_macro_input!(input as DeriveInput);
94    match expand_multi_event_reactor(&input) {
95        Ok(ts) => ts.into(),
96        Err(e) => e.to_compile_error().into(),
97    }
98}
99
100/// Derives `batpak::event::EventSourced` for a named-field struct.
101///
102/// Requires a config attr `#[batpak(input = <Lane>, cache_version = N)]`
103/// (the `cache_version` key is optional and defaults to 0) plus at least
104/// one event-binding attr `#[batpak(event = <Payload>, handler = <fn>)]`.
105/// `event = T` requires `T` to be a single-segment path (bring the type into
106/// scope with `use` if needed). This ensures the derive can dedupe event
107/// bindings without running full path resolution.
108///
109/// Generates:
110///   - `type Input = <Lane>`
111///   - `from_events` — default fold over `Default::default()`
112///   - `apply_event` — dispatch by `P::KIND` via `DecodeTyped::route_typed`,
113///     with the two failure modes kept rigorously distinct:
114///       * wrong-kind event → silent skip (fall-through to next arm)
115///       * matched-kind + decode failure → `panic!` (see "Panics" below)
116///   - `relevant_event_kinds` — `&[T1::KIND, T2::KIND, ...]` generated from
117///     the `event =` list (single source of truth; sync-drift is impossible)
118///   - `schema_version` — from `cache_version` (projection-cache invalidation
119///     only; unrelated to payload wire `type_id`)
120///
121/// # Panics
122///
123/// The generated `apply_event` **panics** when an event's `event_kind` matches
124/// a bound payload's `KIND` but the payload bytes fail to deserialize into
125/// that payload type. This is a deliberate contract:
126///
127/// 1. The raw `EventSourced` trait's `apply_event` returns `()`, not `Result`.
128///    A hand-written implementation must either panic, log-and-skip, or
129///    log-and-ignore on decode failure. The canonical pattern demonstrated
130///    in the pre-derive `examples/event_sourced_counter.rs` used
131///    `.expect(...)`, which is equivalent.
132///
133/// 2. Matched-kind decode failure is a **hard correctness signal** — the
134///    event was written as this kind but the bytes are malformed (schema
135///    drift, `type_id` reuse, corruption). Silently skipping would produce
136///    incorrect projected state.
137///
138/// If you need fallible replay (log-and-skip, fail-the-projection, custom
139/// recovery), implement `EventSourced` manually. The derive does not offer a
140/// fallible mode because the trait signature does not support one.
141///
142/// See `05_TERMINALS.md`, `08_CIRCUITS.md`, and the Dispatch Chapter plan
143/// for the full contract.
144#[proc_macro_derive(EventSourced, attributes(batpak))]
145pub fn derive_event_sourced(input: TokenStream) -> TokenStream {
146    let input = parse_macro_input!(input as DeriveInput);
147    match expand_event_sourced(&input) {
148        Ok(ts) => ts.into(),
149        Err(e) => e.to_compile_error().into(),
150    }
151}
152
153// ─── EventSourced derive expansion ────────────────────────────────────────────
154
155/// One `#[batpak(event = X, handler = fn)]` entry parsed from the derive
156/// attrs.
157struct EventBinding {
158    event: Path,
159    handler: Ident,
160}
161
162/// Parsed state for a single `#[batpak(...)]` attribute on an `EventSourced`
163/// or `MultiEventReactor` derive. Each attribute is either a `Config` attr
164/// (containing `input`, `cache_version`, `error`, or projection state
165/// contract fields) or an `EventBinding` attr (containing `event` and
166/// `handler`). Mixing keys is a compile-time error.
167enum BatpakAttrKind {
168    Config {
169        input: Option<Path>,
170        cache_version: Option<LitInt>,
171        state_max_cardinality: Option<LitInt>,
172        error: Option<Path>,
173    },
174    Event(EventBinding),
175}
176
177#[derive(Default)]
178struct BatpakAttrParts {
179    input: Option<Path>,
180    cache_version: Option<LitInt>,
181    state_max_cardinality: Option<LitInt>,
182    error_ty: Option<Path>,
183    event: Option<Path>,
184    handler: Option<Ident>,
185}
186
187impl BatpakAttrParts {
188    fn set_nested(&mut self, meta: &syn::meta::ParseNestedMeta<'_>) -> syn::Result<()> {
189        let key = meta.path.get_ident().ok_or_else(|| {
190            meta.error("expected `input`, `cache_version`, `state_max_cardinality`, `error`, `event`, or `handler`")
191        })?;
192        let key_name = key.to_string();
193        if self.set_config_nested(key_name.as_str(), meta)? {
194            return Ok(());
195        }
196        if self.set_event_nested(key_name.as_str(), meta)? {
197            return Ok(());
198        }
199        Err(meta.error(format!(
200            "unknown key `{key_name}`, expected `input`, `cache_version`, `state_max_cardinality`, `error`, `event`, or `handler`"
201        )))
202    }
203
204    fn set_config_nested(
205        &mut self,
206        key: &str,
207        meta: &syn::meta::ParseNestedMeta<'_>,
208    ) -> syn::Result<bool> {
209        match key {
210            "input" => {
211                if self.input.is_some() {
212                    return Err(meta.error("duplicate `input` key within attribute"));
213                }
214                self.input = Some(meta.value()?.parse::<Path>()?);
215            }
216            "cache_version" => {
217                if self.cache_version.is_some() {
218                    return Err(meta.error("duplicate `cache_version` key within attribute"));
219                }
220                self.cache_version = Some(meta.value()?.parse::<LitInt>()?);
221            }
222            "state_max_cardinality" => {
223                if self.state_max_cardinality.is_some() {
224                    return Err(
225                        meta.error("duplicate `state_max_cardinality` key within attribute")
226                    );
227                }
228                self.state_max_cardinality = Some(meta.value()?.parse::<LitInt>()?);
229            }
230            "error" => {
231                if self.error_ty.is_some() {
232                    return Err(meta.error("duplicate `error` key within attribute"));
233                }
234                self.error_ty = Some(meta.value()?.parse::<Path>()?);
235            }
236            _ => return Ok(false),
237        }
238        Ok(true)
239    }
240
241    fn set_event_nested(
242        &mut self,
243        key: &str,
244        meta: &syn::meta::ParseNestedMeta<'_>,
245    ) -> syn::Result<bool> {
246        match key {
247            "event" => {
248                if self.event.is_some() {
249                    return Err(meta.error("duplicate `event` key within attribute"));
250                }
251                self.event = Some(meta.value()?.parse::<Path>()?);
252            }
253            "handler" => {
254                if self.handler.is_some() {
255                    return Err(meta.error("duplicate `handler` key within attribute"));
256                }
257                self.handler = Some(meta.value()?.parse::<Ident>()?);
258            }
259            _ => return Ok(false),
260        }
261        Ok(true)
262    }
263
264    fn finish(self, attr: &Attribute) -> syn::Result<BatpakAttrKind> {
265        let has_config = self.input.is_some()
266            || self.cache_version.is_some()
267            || self.state_max_cardinality.is_some()
268            || self.error_ty.is_some();
269        let has_event = self.event.is_some() || self.handler.is_some();
270
271        if has_config && has_event {
272            return Err(syn::Error::new(
273                attr.span(),
274                "`#[batpak(...)]` attribute must contain either config keys \
275                 (`input`, `cache_version`, `state_max_cardinality`, `error`) or an event-binding pair (`event`, `handler`), not both",
276            ));
277        }
278
279        if has_event {
280            let event = self.event.ok_or_else(|| {
281                syn::Error::new(
282                    attr.span(),
283                    "event-binding attribute is missing `event = <PayloadType>`",
284                )
285            })?;
286            let handler = self.handler.ok_or_else(|| {
287                syn::Error::new(
288                    attr.span(),
289                    "event-binding attribute is missing `handler = <fn_name>`",
290                )
291            })?;
292            return Ok(BatpakAttrKind::Event(EventBinding { event, handler }));
293        }
294
295        if !has_config {
296            return Err(syn::Error::new(
297                attr.span(),
298                "`#[batpak(...)]` must contain at least one key: `input`, `cache_version`, `state_max_cardinality`, `error`, or the `event`/`handler` pair",
299            ));
300        }
301        Ok(BatpakAttrKind::Config {
302            input: self.input,
303            cache_version: self.cache_version,
304            state_max_cardinality: self.state_max_cardinality,
305            error: self.error_ty,
306        })
307    }
308}
309
310fn classify_batpak_attr(attr: &Attribute) -> syn::Result<BatpakAttrKind> {
311    let mut parts = BatpakAttrParts::default();
312    attr.parse_nested_meta(|meta| parts.set_nested(&meta))?;
313    parts.finish(attr)
314}
315
316fn ensure_named_field_struct(input: &DeriveInput, derive_name: &str) -> syn::Result<()> {
317    match &input.data {
318        Data::Struct(s) => match &s.fields {
319            Fields::Named(_) => Ok(()),
320            Fields::Unnamed(f) => Err(syn::Error::new(
321                f.span(),
322                format!(
323                    "#[derive({derive_name})] requires a named-field struct; tuple structs are not supported"
324                ),
325            )),
326            Fields::Unit => Err(syn::Error::new(
327                input.ident.span(),
328                format!(
329                    "#[derive({derive_name})] requires a named-field struct; unit structs are not supported"
330                ),
331            )),
332        },
333        Data::Enum(e) => Err(syn::Error::new(
334            e.enum_token.span,
335            format!("#[derive({derive_name})] requires a named-field struct; enums are not supported"),
336        )),
337        Data::Union(u) => Err(syn::Error::new(
338            u.union_token.span,
339            format!(
340                "#[derive({derive_name})] requires a named-field struct; unions are not supported"
341            ),
342        )),
343    }
344}
345
346struct EventSourcedDeriveAttrs {
347    input_path: Path,
348    cache_version_lit: Option<LitInt>,
349    state_max_cardinality_lit: Option<LitInt>,
350    bindings: Vec<EventBinding>,
351}
352
353fn collect_event_sourced_attrs(input: &DeriveInput) -> syn::Result<EventSourcedDeriveAttrs> {
354    let batpak_attrs: Vec<&Attribute> = input
355        .attrs
356        .iter()
357        .filter(|a| a.path().is_ident("batpak"))
358        .collect();
359
360    if batpak_attrs.is_empty() {
361        return Err(syn::Error::new(
362            input.ident.span(),
363            "#[derive(EventSourced)] requires at least one `#[batpak(input = <Lane>)]` attribute",
364        ));
365    }
366
367    let mut input_path: Option<Path> = None;
368    let mut cache_version_lit: Option<LitInt> = None;
369    let mut state_max_cardinality_lit: Option<LitInt> = None;
370    let mut bindings: Vec<EventBinding> = Vec::new();
371    let mut seen_events: HashSet<String> = HashSet::new();
372
373    for attr in &batpak_attrs {
374        match classify_batpak_attr(attr)? {
375            BatpakAttrKind::Config {
376                input: attr_input,
377                cache_version: attr_cache,
378                state_max_cardinality: attr_state_max,
379                error: attr_error,
380            } => {
381                collect_event_sourced_config(
382                    &mut input_path,
383                    &mut cache_version_lit,
384                    &mut state_max_cardinality_lit,
385                    attr_input,
386                    attr_cache,
387                    attr_state_max,
388                    attr_error,
389                )?;
390            }
391            BatpakAttrKind::Event(binding) => {
392                collect_unique_event_binding(
393                    &mut bindings,
394                    &mut seen_events,
395                    binding,
396                    "projection",
397                )?;
398            }
399        }
400    }
401
402    let input_path = input_path.ok_or_else(|| {
403        syn::Error::new(
404            input.ident.span(),
405            "#[derive(EventSourced)] requires `#[batpak(input = <Lane>)]` — e.g. `input = JsonValueInput` or `input = RawMsgpackInput`",
406        )
407    })?;
408
409    if bindings.is_empty() {
410        return Err(syn::Error::new(
411            input.ident.span(),
412            "`#[derive(EventSourced)]` requires at least one `#[batpak(event = T, handler = h)]` binding",
413        ));
414    }
415
416    Ok(EventSourcedDeriveAttrs {
417        input_path,
418        cache_version_lit,
419        state_max_cardinality_lit,
420        bindings,
421    })
422}
423
424fn collect_event_sourced_config(
425    input_path: &mut Option<Path>,
426    cache_version_lit: &mut Option<LitInt>,
427    state_max_cardinality_lit: &mut Option<LitInt>,
428    attr_input: Option<Path>,
429    attr_cache: Option<LitInt>,
430    attr_state_max: Option<LitInt>,
431    attr_error: Option<Path>,
432) -> syn::Result<()> {
433    if let Some(path) = attr_error {
434        return Err(syn::Error::new(
435            path.span(),
436            "`error` is not valid on `#[derive(EventSourced)]` — projections do not have an associated error type",
437        ));
438    }
439    if let Some(path) = attr_input {
440        if input_path.is_some() {
441            return Err(syn::Error::new(
442                path.span(),
443                "duplicate `input =` across `#[batpak(...)]` config attributes — `input` must appear exactly once",
444            ));
445        }
446        *input_path = Some(path);
447    }
448    if let Some(lit) = attr_cache {
449        if cache_version_lit.is_some() {
450            return Err(syn::Error::new(
451                lit.span(),
452                "duplicate `cache_version =` across `#[batpak(...)]` config attributes",
453            ));
454        }
455        *cache_version_lit = Some(lit);
456    }
457    if let Some(lit) = attr_state_max {
458        if state_max_cardinality_lit.is_some() {
459            return Err(syn::Error::new(
460                lit.span(),
461                "duplicate `state_max_cardinality =` across `#[batpak(...)]` config attributes",
462            ));
463        }
464        *state_max_cardinality_lit = Some(lit);
465    }
466    Ok(())
467}
468
469fn collect_unique_event_binding(
470    bindings: &mut Vec<EventBinding>,
471    seen_events: &mut HashSet<String>,
472    binding: EventBinding,
473    owner: &str,
474) -> syn::Result<()> {
475    require_single_segment_event_path(&binding.event)?;
476    let key = binding.event.to_token_stream_string();
477    if !seen_events.insert(key) {
478        return Err(syn::Error::new(
479            binding.event.span(),
480            format!(
481                "duplicate `event = X` — each payload type may be bound to exactly one handler per {owner}"
482            ),
483        ));
484    }
485    bindings.push(binding);
486    Ok(())
487}
488
489fn expand_event_sourced(input: &DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
490    ensure_named_field_struct(input, "EventSourced")?;
491
492    let attrs = collect_event_sourced_attrs(input)?;
493    let input_path = attrs.input_path;
494    let cache_version_lit = attrs.cache_version_lit;
495    let state_max_cardinality_lit = attrs.state_max_cardinality_lit;
496    let bindings = attrs.bindings;
497
498    // ─── Validate cache_version fits u64 ────────────────────────────────────
499    let cache_version_value: u64 = match &cache_version_lit {
500        Some(lit) => lit.base10_parse::<u64>()?,
501        None => 0u64,
502    };
503
504    // ─── Codegen ─────────────────────────────────────────────────────────────
505    let ident = &input.ident;
506    let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
507
508    let state_contract_impl = match &state_max_cardinality_lit {
509        Some(lit) => {
510            let state_max_cardinality_value = lit.base10_parse::<u64>()?;
511            // The derived `state_extent` reports a fixed cardinality of 1, so a
512            // declared bound above 1 would be vacuous (always 1 <= n). Reject it:
513            // multi-key projections must hand-implement `EventSourced` with a real
514            // `state_extent()` so the P2 bound check stays meaningful.
515            if state_max_cardinality_value != 1 {
516                return Err(syn::Error::new_spanned(
517                    lit,
518                    "#[derive(EventSourced)] supports only single-aggregate state (n = 1); \
519                     implement EventSourced by hand with a real `state_extent()` for multi-key state",
520                ));
521            }
522            quote! {
523                const STATE_CONTRACT: ::batpak::event::ProjectionStateContract =
524                    ::batpak::event::ProjectionStateContract::Bounded {
525                        key_space: ::core::concat!(
526                            ::core::module_path!(),
527                            "::",
528                            ::core::stringify!(#ident)
529                        ),
530                        max_cardinality: #state_max_cardinality_value,
531                        retention_policy: "derive-event-sourced-state-object",
532                        compaction_policy: "projection-cache-overwrite",
533                        checkpoint_policy: "projection-cache",
534                    };
535
536                fn state_extent(&self) -> ::batpak::event::StateExtent {
537                    let _ = self;
538                    ::batpak::event::StateExtent::cardinality(
539                        1,
540                        ::batpak::event::StateExtentCost::ConstantTime,
541                    )
542                }
543            }
544        }
545        None => quote! {},
546    };
547
548    // Build apply_event dispatch arms — one per event-binding. Handlers
549    // take `&T` so users can read fields without being forced to consume
550    // the payload (the clippy::needless_pass_by_value would otherwise fire
551    // on every handler that does not move-use its argument).
552    let arms: Vec<proc_macro2::TokenStream> = bindings
553        .iter()
554        .map(|b| {
555            let event_ty = &b.event;
556            let handler_fn = &b.handler;
557            quote! {
558                // route_typed → Ok(Some(p)): matched kind, decode ok → call handler
559                // route_typed → Ok(None):    wrong kind (normal filter) → fall through
560                // route_typed → Err(e):      matched kind but decode failed → hard
561                //                             correctness signal, panic with source
562                match ::batpak::event::DecodeTyped::route_typed::<#event_ty>(event) {
563                    ::core::result::Result::Ok(::core::option::Option::Some(__p)) => {
564                        self.#handler_fn(&__p);
565                        return;
566                    }
567                    ::core::result::Result::Ok(::core::option::Option::None) => {}
568                    ::core::result::Result::Err(__e) => {
569                        ::core::panic!(
570                            "EventSourced: decode failed for matched kind {}: {}",
571                            ::core::stringify!(#event_ty),
572                            __e
573                        );
574                    }
575                }
576            }
577        })
578        .collect();
579
580    // relevant_event_kinds: compile-time const array from the event= list.
581    let kind_exprs: Vec<proc_macro2::TokenStream> = bindings
582        .iter()
583        .map(|b| {
584            let event_ty = &b.event;
585            quote! {
586                <#event_ty as ::batpak::event::EventPayload>::KIND
587            }
588        })
589        .collect();
590    let kind_count = bindings.len();
591
592    // Handler-signature pins live inside a generic impl so they can reference
593    // `Self`-with-type-params. Module-scope `const _: fn(...)` items can't
594    // reintroduce generics; this pattern does. When the user's
595    // `fn on_x(&mut self, &T)` has the wrong parameter types, rustc spans the
596    // error at the generated fn-pointer coercion rather than inside an opaque
597    // dispatch arm.
598    let handler_checks: Vec<proc_macro2::TokenStream> = bindings
599        .iter()
600        .map(|b| {
601            let event_ty = &b.event;
602            let handler_fn = &b.handler;
603            quote! {
604                let _: fn(&mut Self, &#event_ty) = Self::#handler_fn;
605            }
606        })
607        .collect();
608
609    // C5: pin the `input = T` attribute's type to `ProjectionInput` at
610    // derive-expansion site. A non-`ProjectionInput` `input` errors here with
611    // the attribute's path visible in the trace, rather than bubbling up from
612    // inside generated trait-impl machinery.
613    let input_assertion = {
614        quote! {
615            const _: fn() = || {
616                fn __batpak_assert_projection_input<T: ::batpak::event::ProjectionInput>() {}
617                __batpak_assert_projection_input::<#input_path>();
618            };
619        }
620    };
621
622    Ok(quote! {
623        #input_assertion
624
625        impl #impl_generics ::batpak::event::EventSourced for #ident #ty_generics #where_clause {
626            type Input = #input_path;
627
628            #state_contract_impl
629
630            fn from_events(
631                events: &[::batpak::event::ProjectionEvent<Self>],
632            ) -> ::core::option::Option<Self> {
633                if events.is_empty() {
634                    return ::core::option::Option::None;
635                }
636                let mut state: Self = ::core::default::Default::default();
637                for __ev in events {
638                    state.apply_event(__ev);
639                }
640                ::core::option::Option::Some(state)
641            }
642
643            fn apply_event(&mut self, event: &::batpak::event::ProjectionEvent<Self>) {
644                #(#handler_checks)*
645                // Each arm keeps wrong-kind filtering (Ok(None)) separate from
646                // matched-kind decode failure (Err). A fall-through past all
647                // arms means "kind outside relevant_event_kinds()" — normal
648                // skip, not an error.
649                #(#arms)*
650                // Fall-through: unrelated kind. No-op.
651                let _ = event;
652            }
653
654            fn relevant_event_kinds() -> &'static [::batpak::event::EventKind] {
655                static KINDS: [::batpak::event::EventKind; #kind_count] = [
656                    #(#kind_exprs),*
657                ];
658                &KINDS
659            }
660
661            fn schema_version() -> u64 {
662                // `cache_version` is the projection-cache invalidation key.
663                // Unrelated to payload wire `type_id` — they live in different
664                // layers (ADR-0010 vs this derive).
665                #cache_version_value
666            }
667        }
668    })
669}
670
671trait ToTokenStreamString {
672    fn to_token_stream_string(&self) -> String;
673}
674
675impl ToTokenStreamString for Path {
676    fn to_token_stream_string(&self) -> String {
677        quote!(#self).to_string()
678    }
679}
680
681/// Enforce that an `event = <Path>` attribute value is a single-segment,
682/// unqualified type name (no `crate::`, no `my_mod::`, no leading `::`).
683///
684/// The derive deduplicates event bindings by stringifying the path — if
685/// multi-segment paths were allowed, `Foo` and `crate::Foo` could alias the
686/// same type but compare unequal, producing undetected duplicates. Requiring a
687/// single-segment name lets stringified comparison act as a semantic compare
688/// without running full path resolution. Users who need a type from another
689/// module bring it into scope with `use`.
690fn require_single_segment_event_path(path: &Path) -> syn::Result<()> {
691    if path.leading_colon.is_some() || path.segments.len() != 1 {
692        return Err(syn::Error::new_spanned(
693            path,
694            "event type must be named by its in-scope single-segment name — use a `use` import if the type is in another module",
695        ));
696    }
697    Ok(())
698}
699
700// ─── MultiEventReactor derive expansion ──────────────────────────────────────
701
702fn expand_multi_event_reactor(input: &DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
703    ensure_named_field_struct(input, "MultiEventReactor")?;
704
705    let batpak_attrs: Vec<&Attribute> = input
706        .attrs
707        .iter()
708        .filter(|a| a.path().is_ident("batpak"))
709        .collect();
710
711    if batpak_attrs.is_empty() {
712        return Err(syn::Error::new(
713            input.ident.span(),
714            "#[derive(MultiEventReactor)] requires `#[batpak(input = <Lane>)]` plus at least one `#[batpak(event = <Payload>, handler = <fn>)]` attribute",
715        ));
716    }
717
718    let mut input_path: Option<Path> = None;
719    let mut error_path: Option<Path> = None;
720    let mut bindings: Vec<EventBinding> = Vec::new();
721    let mut seen_events: HashSet<String> = HashSet::new();
722
723    for attr in &batpak_attrs {
724        match classify_batpak_attr(attr)? {
725            BatpakAttrKind::Config {
726                input: attr_input,
727                cache_version,
728                state_max_cardinality,
729                error: attr_error,
730            } => {
731                if let Some(lit) = cache_version {
732                    return Err(syn::Error::new(
733                        lit.span(),
734                        "`cache_version` is not valid on `#[derive(MultiEventReactor)]` — \
735                         `cache_version` is a projection-cache key, not a reactor setting",
736                    ));
737                }
738                if let Some(lit) = state_max_cardinality {
739                    return Err(syn::Error::new(
740                        lit.span(),
741                        "`state_max_cardinality` is not valid on `#[derive(MultiEventReactor)]` — \
742                         state cardinality is a projection contract, not a reactor setting",
743                    ));
744                }
745                if let Some(path) = attr_input {
746                    if input_path.is_some() {
747                        return Err(syn::Error::new(
748                            path.span(),
749                            "duplicate `input =` across `#[batpak(...)]` config attributes — `input` must appear exactly once",
750                        ));
751                    }
752                    input_path = Some(path);
753                }
754                if let Some(path) = attr_error {
755                    if error_path.is_some() {
756                        return Err(syn::Error::new(
757                            path.span(),
758                            "duplicate `error =` across `#[batpak(...)]` config attributes — `error` must appear exactly once",
759                        ));
760                    }
761                    error_path = Some(path);
762                }
763            }
764            BatpakAttrKind::Event(binding) => {
765                collect_unique_event_binding(&mut bindings, &mut seen_events, binding, "reactor")?;
766            }
767        }
768    }
769
770    let input_path = input_path.ok_or_else(|| {
771        syn::Error::new(
772            input.ident.span(),
773            "#[derive(MultiEventReactor)] requires `#[batpak(input = <Lane>)]` — e.g. `input = JsonValueInput` or `input = RawMsgpackInput`",
774        )
775    })?;
776    let error_path = error_path.ok_or_else(|| {
777        syn::Error::new(
778            input.ident.span(),
779            "#[derive(MultiEventReactor)] requires `#[batpak(error = <ErrorType>)]` — the shared error type all handlers return",
780        )
781    })?;
782
783    if bindings.is_empty() {
784        return Err(syn::Error::new(
785            input.ident.span(),
786            "#[derive(MultiEventReactor)] requires at least one `#[batpak(event = <Payload>, handler = <fn>)]`",
787        ));
788    }
789
790    let ident = &input.ident;
791    let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
792
793    let kind_exprs: Vec<proc_macro2::TokenStream> = bindings
794        .iter()
795        .map(|b| {
796            let event_ty = &b.event;
797            quote! {
798                <#event_ty as ::batpak::event::EventPayload>::KIND
799            }
800        })
801        .collect();
802    let kind_count = bindings.len();
803
804    // Generate dispatch arms. Each arm uses DecodeTyped::route_typed on
805    // the inner Event to decode matched kinds to the bound type, then
806    // builds &StoredEvent<T> (carrying the source coordinate) for the
807    // handler. Wrong-kind events fall through and return Ok(());
808    // matched-kind decode failure returns MultiDispatchError::Decode.
809    let arms: Vec<proc_macro2::TokenStream> = bindings
810        .iter()
811        .map(|b| {
812            let event_ty = &b.event;
813            let handler_fn = &b.handler;
814            quote! {
815                match ::batpak::event::DecodeTyped::route_typed::<#event_ty>(&event.event) {
816                    ::core::result::Result::Ok(::core::option::Option::Some(__p)) => {
817                        let __typed_event = ::batpak::event::StoredEvent {
818                            coordinate: event.coordinate.clone(),
819                            event: ::batpak::event::Event {
820                                header: event.event.header.clone(),
821                                payload: __p,
822                                hash_chain: event.event.hash_chain.clone(),
823                            },
824                        };
825                        return self
826                            .#handler_fn(&__typed_event, out, at_least_once)
827                            .map_err(::batpak::event::MultiDispatchError::User);
828                    }
829                    ::core::result::Result::Ok(::core::option::Option::None) => {}
830                    ::core::result::Result::Err(__e) => {
831                        return ::core::result::Result::Err(
832                            ::batpak::event::MultiDispatchError::Decode(__e)
833                        );
834                    }
835                }
836            }
837        })
838        .collect();
839
840    // Handler-signature pins live inside a generic impl so they can reference
841    // `Self`-with-type-params. Module-scope `const _: fn(...)` items can't
842    // reintroduce generics; this pattern does. Mismatched handler signatures
843    // surface as span-pointed errors at the user's handler, not inside the
844    // dispatch body.
845    let handler_checks: Vec<proc_macro2::TokenStream> = bindings
846        .iter()
847        .map(|b| {
848            let event_ty = &b.event;
849            let handler_fn = &b.handler;
850            quote! {
851                let _: fn(
852                    &mut Self,
853                    &::batpak::event::StoredEvent<#event_ty>,
854                    &mut ::batpak::store::ReactionBatch,
855                    ::core::option::Option<&::batpak::store::AtLeastOnce>,
856                ) -> ::core::result::Result<(), #error_path> = Self::#handler_fn;
857            }
858        })
859        .collect();
860
861    // C5: pin attribute types at expansion site. A non-`ProjectionInput`
862    // `input` or a non-`std::error::Error + Send + Sync + 'static` `error`
863    // errors here with the attribute's path visible in the trace, rather than
864    // bubbling up from inside generated trait-impl machinery.
865    let attr_assertions = {
866        quote! {
867            const _: fn() = || {
868                fn __batpak_assert_projection_input<T: ::batpak::event::ProjectionInput>() {}
869                __batpak_assert_projection_input::<#input_path>();
870            };
871            const _: fn() = || {
872                fn __batpak_assert_error<
873                    T: ::core::marker::Send
874                        + ::core::marker::Sync
875                        + 'static
876                        + ::std::error::Error,
877                >() {}
878                __batpak_assert_error::<#error_path>();
879            };
880        }
881    };
882
883    Ok(quote! {
884        #attr_assertions
885
886        impl #impl_generics ::batpak::event::MultiReactive<#input_path>
887        for #ident #ty_generics #where_clause
888        {
889            type Error = #error_path;
890
891            fn relevant_event_kinds() -> &'static [::batpak::event::EventKind] {
892                static KINDS: [::batpak::event::EventKind; #kind_count] = [
893                    #(#kind_exprs),*
894                ];
895                &KINDS
896            }
897
898            fn dispatch(
899                &mut self,
900                event: &::batpak::event::StoredEvent<
901                    <#input_path as ::batpak::event::ProjectionInput>::Payload,
902                >,
903                out: &mut ::batpak::store::ReactionBatch,
904                at_least_once: ::core::option::Option<&::batpak::store::AtLeastOnce>,
905            ) -> ::core::result::Result<(), ::batpak::event::MultiDispatchError<Self::Error>> {
906                #(#handler_checks)*
907                #(#arms)*
908                // Wrong kind / no binding matched — silent filter.
909                ::core::result::Result::Ok(())
910            }
911        }
912    })
913}