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