Skip to main content

bevy_react_macros/
lib.rs

1#![cfg_attr(docsrs, feature(doc_cfg))]
2//! Proc-macro support for `bevy-react`.
3//!
4//! Provides [`react_message`], the attribute that turns a plain struct into a
5//! registrable React message payload.
6//
7// TODO(review): these macros expand to `::serde::` and `::ts_rs::` paths, forcing every
8// downstream consumer crate to add `serde` AND `ts_rs` as direct dependencies (works in-repo
9// only because examples share the package's deps). Re-export both from the lib (e.g.
10// `bevy_react::__private::{serde, ts_rs}`) and reference those paths so consumers need only
11// `bevy_react` + `bevy`.
12
13use proc_macro::TokenStream;
14use quote::quote;
15use syn::{DeriveInput, LitStr, Type, parse_macro_input};
16
17/// Turn a struct into a typed React message payload.
18///
19/// Applying `#[react_message]` derives `serde::Deserialize` and `ts_rs::TS` and
20/// implements both `bevy::ecs::event::Event` and `bevy_react::ReactPayload`, so the
21/// type can be registered with `App::add_react_handler` / `add_react_message`, routed
22/// from a React `emit(name, value)` call, and exported to TypeScript via
23/// `App::export_react_typescript`.
24///
25/// The `emit` name defaults to the struct name with its first letter lowercased
26/// (`Count` → `"count"`, `PlayerScore` → `"playerScore"`); override it with
27/// `#[react_message(name = "...")]`.
28///
29/// ```ignore
30/// #[react_message]
31/// struct Count(usize);            // name = "count"
32///
33/// #[react_message(name = "hp")]
34/// struct Health(u32);             // name = "hp"
35/// ```
36#[proc_macro_attribute]
37pub fn react_message(attr: TokenStream, item: TokenStream) -> TokenStream {
38    let name_override = match parse_name_only_attr(attr, "react_message") {
39        Ok(name) => name,
40        Err(e) => return e.to_compile_error().into(),
41    };
42
43    let input = parse_macro_input!(item as DeriveInput);
44    let PayloadParts {
45        ident,
46        impl_generics,
47        ty_generics,
48        where_clause,
49        name,
50    } = payload_parts(&input, name_override);
51
52    quote! {
53        #[derive(::serde::Deserialize, ::ts_rs::TS)]
54        #input
55
56        impl #impl_generics ::bevy::ecs::event::Event for #ident #ty_generics #where_clause {
57            type Trigger<'a> = ::bevy::ecs::event::GlobalTrigger;
58        }
59
60        impl #impl_generics ::bevy_react::ReactPayload for #ident #ty_generics #where_clause {
61            const NAME: &'static str = #name;
62        }
63    }
64    .into()
65}
66
67/// Turn a struct into a typed React **request** payload (a React → Bevy call that
68/// awaits a typed reply).
69///
70/// Derives `serde::Deserialize` + `ts_rs::TS` and implements
71/// [`bevy_react::ReactRequest`], so the type can be registered with
72/// `App::add_react_request_handler` and answered from a React `request(name, value)`
73/// call. Observe `On<Request<T>>` and reply with `req.respond(value)`.
74///
75/// The `response` type is required and points at a type you define separately and
76/// derive `serde::Serialize` + `ts_rs::TS` on. The `name` defaults to the struct
77/// ident with its first letter lowercased; use a dotted name to get a nested proxy
78/// (`#[react_request(name = "board.get", ...)]` → `bevy.board.get`).
79///
80/// ```ignore
81/// #[react_request(name = "board.get", response = Board)]
82/// struct BoardGet;                // unit payload → `bevy.board.get()` takes no args
83///
84/// #[react_request(name = "pieces.move", response = MoveStatus)]
85/// struct PiecesMove { piece: String, to: String }
86/// ```
87#[proc_macro_attribute]
88pub fn react_request(attr: TokenStream, item: TokenStream) -> TokenStream {
89    let mut name_override: Option<String> = None;
90    let mut response: Option<Type> = None;
91    let arg_parser = syn::meta::parser(|meta| {
92        if try_parse_name_arg(&meta, &mut name_override)? {
93            Ok(())
94        } else if meta.path.is_ident("response") {
95            response = Some(meta.value()?.parse::<Type>()?);
96            Ok(())
97        } else {
98            Err(meta.error(
99                "unsupported `react_request` argument; expected `name = \"...\"` or `response = Type`",
100            ))
101        }
102    });
103    parse_macro_input!(attr with arg_parser);
104
105    let response = match response {
106        Some(ty) => ty,
107        None => {
108            return syn::Error::new(
109                proc_macro2::Span::call_site(),
110                "`react_request` requires a `response = Type` argument",
111            )
112            .to_compile_error()
113            .into();
114        }
115    };
116
117    let input = parse_macro_input!(item as DeriveInput);
118    let PayloadParts {
119        ident,
120        impl_generics,
121        ty_generics,
122        where_clause,
123        name,
124    } = payload_parts(&input, name_override);
125
126    quote! {
127        #[derive(::serde::Deserialize, ::ts_rs::TS)]
128        #input
129
130        impl #impl_generics ::bevy_react::ReactRequest for #ident #ty_generics #where_clause {
131            const NAME: &'static str = #name;
132            type Response = #response;
133        }
134    }
135    .into()
136}
137
138/// Turn a struct into a typed React **event** payload (a Bevy → React broadcast).
139///
140/// Derives `serde::Serialize` + `ts_rs::TS` and implements
141/// [`bevy_react::ReactEvent`]. Send it from a system with the `ReactEvents` param;
142/// React listens with `bevy.on(name, cb)`. Register the type with
143/// `App::add_react_event::<E>()` so it appears in the generated typings.
144///
145/// The `name` defaults to the struct ident with its first letter lowercased.
146///
147/// ```ignore
148/// #[react_event(name = "user.disconnected")]
149/// struct UserDisconnected { user_id: String }
150/// ```
151#[proc_macro_attribute]
152pub fn react_event(attr: TokenStream, item: TokenStream) -> TokenStream {
153    let name_override = match parse_name_only_attr(attr, "react_event") {
154        Ok(name) => name,
155        Err(e) => return e.to_compile_error().into(),
156    };
157
158    let input = parse_macro_input!(item as DeriveInput);
159    let PayloadParts {
160        ident,
161        impl_generics,
162        ty_generics,
163        where_clause,
164        name,
165    } = payload_parts(&input, name_override);
166
167    quote! {
168        #[derive(::serde::Serialize, ::ts_rs::TS)]
169        #input
170
171        impl #impl_generics ::bevy_react::ReactEvent for #ident #ty_generics #where_clause {
172            const NAME: &'static str = #name;
173        }
174    }
175    .into()
176}
177
178/// Turn a named-field struct into a typed custom **regular filter** for the
179/// layer-based `filter`/`backdropFilter` style chains. (Two-input morph
180/// filters for the `morphFilter` style are a separate family — see
181/// [`macro@react_morph_filter`].)
182///
183/// Derives `serde::Deserialize` — adding `#[serde(deny_unknown_fields)]`, so
184/// unknown param keys reject like the built-ins; per-field `#[serde(default)]`
185/// attributes you write are preserved — plus `ts_rs::TS`, and implements
186/// `bevy_react::filters::ReactFilter`. Built-in filters stay hand-written
187/// (they share canonical shader layouts); this macro is for custom filters,
188/// which pack against their own shader.
189///
190/// Give every field a `#[serde(default…)]`: the JS side types all params as
191/// individually optional, so an omitted param must decode — a field without
192/// a default rejects the whole filter use with a devtools warning whenever
193/// it is omitted.
194///
195/// Arguments:
196///
197/// - `name = "..."` (optional) — the wire name; defaults to the struct ident
198///   with its first letter lowercased (`Glow` → `"glow"`).
199/// - `shader = "path/to.wgsl"` (required) — loaded with `AssetServer::load`,
200///   so a plain asset path relative to your assets dir; `embedded://` paths
201///   also work verbatim.
202/// - `outset = <number>` (optional, default `0.0`) — extra *logical* px the
203///   effect bleeds outside the node's rect.
204/// - `time = <bool>` (optional, default `false`) — a time-driven effect
205///   re-renders its layer every frame.
206///
207/// The generated `pack()` fills the shader's `params` vec4 array
208/// contiguously **in field declaration order**, mapped by field type. Params
209/// pack into at most `MAX_FILTER_PARAM_VECS` (8) vec4s, enforced at resolve
210/// time — an over-cap filter is rejected at runtime with a devtools warning,
211/// not a compile error. Shader-side, the packed array is `uniforms.params`
212/// via `#import bevy_react::filter` (see
213/// `crates/core/src/layer/filter_prelude.wgsl` for the full binding
214/// contract).
215///
216/// | field type           | packs as                          | components |
217/// |----------------------|-----------------------------------|------------|
218/// | `f32`                | scalar                            | 1          |
219/// | `Vec2`/`Vec3`/`Vec4` | scalars                           | 2/3/4      |
220/// | `[f32; 2..=4]`       | scalars                           | 2–4        |
221/// | `Angle`              | radians (a bare wire number is degrees) | 1    |
222/// | `Length`             | logical px (px-only; other units reject the filter use via `outset`/`resolve`) | 1 |
223/// | `FilterColor`        | linear straight-alpha RGBA        | 4          |
224///
225/// A multi-component param that would straddle a vec4 boundary pads to the
226/// next vec4 (the skipped components stay zero) — a `ParamSlot` never crosses
227/// a vec4. Any other field type fails compilation with an error naming the
228/// field and listing the supported types.
229///
230/// ```ignore
231/// #[react_filter(name = "glow", shader = "shaders/glow.wgsl", outset = 4.0)]
232/// struct GlowParams {
233///     intensity: f32,          // params[0].x
234///     direction: Vec2,         // params[0].yz
235///     tint: FilterColor,       // params[1] (padded past params[0].w)
236/// }
237/// ```
238#[proc_macro_attribute]
239pub fn react_filter(attr: TokenStream, item: TokenStream) -> TokenStream {
240    let mut name_override: Option<String> = None;
241    let mut shader: Option<LitStr> = None;
242    let mut outset: f32 = 0.0;
243    let mut time = false;
244    let arg_parser = syn::meta::parser(|meta| {
245        if try_parse_name_arg(&meta, &mut name_override)? {
246            Ok(())
247        } else if meta.path.is_ident("shader") {
248            shader = Some(meta.value()?.parse::<LitStr>()?);
249            Ok(())
250        } else if meta.path.is_ident("outset") {
251            outset = match meta.value()?.parse::<syn::Lit>()? {
252                syn::Lit::Float(f) => f.base10_parse()?,
253                syn::Lit::Int(i) => i.base10_parse()?,
254                other => {
255                    return Err(syn::Error::new_spanned(
256                        other,
257                        "`outset` must be a number literal (logical px)",
258                    ));
259                }
260            };
261            Ok(())
262        } else if meta.path.is_ident("time") {
263            time = meta.value()?.parse::<syn::LitBool>()?.value;
264            Ok(())
265        } else {
266            Err(meta.error(
267                "unsupported `react_filter` argument; expected `name = \"...\"`, \
268                 `shader = \"...\"`, `outset = <number>`, or `time = <bool>`",
269            ))
270        }
271    });
272    parse_macro_input!(attr with arg_parser);
273    expand_react_filter(
274        "react_filter",
275        name_override,
276        shader,
277        outset,
278        time,
279        false,
280        item,
281    )
282}
283
284/// Turn a named-field struct into a typed custom **morph filter** — a
285/// two-input transition for the `morphFilter` style (blending the frozen old
286/// appearance into the live content on a `key` change).
287///
288/// Same expansion as [`macro@react_filter`] (strict `serde::Deserialize` +
289/// `ts_rs::TS`, contiguous field-declaration-order packing, the same param
290/// type table, the same give-every-field-a-`#[serde(default…)]` guidance —
291/// JS types all params as optional), plus `IS_MORPH = true` and the
292/// `ReactMorphFilter` marker —
293/// register with `add_react_morph_filter`, and the name lands in the
294/// generated `BevyMorphFilters` interface. The two families are separate: a
295/// morph name inside `filter`/`backdropFilter` chains (and a regular filter
296/// name in `morphFilter`) warns and is skipped at resolve time.
297///
298/// Arguments (only these two — a morph re-renders while its blend is in
299/// flight and never inflates the capture rect, so `outset`/`time` do not
300/// apply):
301///
302/// - `name = "..."` (optional) — the wire name; defaults to the struct ident
303///   with its first letter lowercased.
304/// - `shader = "path/to.wgsl"` (required) — the single blend pass. It must
305///   resolve to exactly ONE pass with user params within the morph cap of 6
306///   vec4s (`params[6..8]` are engine-reserved), and follow the MORPH
307///   CONTRACT in `crates/core/src/layer/filter_prelude.wgsl` — sample via
308///   `morph_sample_from()`/`morph_sample_to()`/`morph_progress()` and output
309///   exactly the live sample at progress 1.0 (the identity contract).
310#[proc_macro_attribute]
311pub fn react_morph_filter(attr: TokenStream, item: TokenStream) -> TokenStream {
312    let mut name_override: Option<String> = None;
313    let mut shader: Option<LitStr> = None;
314    let arg_parser = syn::meta::parser(|meta| {
315        if try_parse_name_arg(&meta, &mut name_override)? {
316            Ok(())
317        } else if meta.path.is_ident("shader") {
318            shader = Some(meta.value()?.parse::<LitStr>()?);
319            Ok(())
320        } else {
321            Err(meta.error(
322                "unsupported `react_morph_filter` argument; expected `name = \"...\"` or \
323                 `shader = \"...\"` (morph passes re-run while the blend is in flight — \
324                 `outset`/`time` do not apply)",
325            ))
326        }
327    });
328    parse_macro_input!(attr with arg_parser);
329    expand_react_filter(
330        "react_morph_filter",
331        name_override,
332        shader,
333        0.0,
334        false,
335        true,
336        item,
337    )
338}
339
340/// The shared `#[react_filter]`/`#[react_morph_filter]` expansion:
341/// everything after argument parsing. `macro_name` personalizes the error
342/// messages; `is_morph` bakes `IS_MORPH` and adds the `ReactMorphFilter`
343/// marker impl.
344fn expand_react_filter(
345    macro_name: &str,
346    name_override: Option<String>,
347    shader: Option<LitStr>,
348    outset: f32,
349    time: bool,
350    is_morph: bool,
351    item: TokenStream,
352) -> TokenStream {
353    let Some(shader) = shader else {
354        return syn::Error::new(
355            proc_macro2::Span::call_site(),
356            format!("`{macro_name}` requires a `shader = \"path/to.wgsl\"` argument"),
357        )
358        .to_compile_error()
359        .into();
360    };
361
362    let mut input = parse_macro_input!(item as DeriveInput);
363    let name = name_override.unwrap_or_else(|| lower_first(&input.ident.to_string()));
364
365    let syn::Data::Struct(data) = &mut input.data else {
366        return syn::Error::new_spanned(&input.ident, format!("`{macro_name}` requires a struct"))
367            .to_compile_error()
368            .into();
369    };
370    let syn::Fields::Named(fields) = &mut data.fields else {
371        return syn::Error::new_spanned(
372            &input.ident,
373            format!("`{macro_name}` requires named fields (each field becomes a shader param)"),
374        )
375        .to_compile_error()
376        .into();
377    };
378
379    // Walk the fields in declaration order, assigning each a contiguous
380    // no-straddle slot in the packed vec4 array and collecting the generated
381    // slot/write/validation code (plus `#[ts(type)]` overrides for field
382    // types without a `ts_rs::TS` impl — recorded here, applied only after
383    // the walk proves error-free: the error path emits the struct without
384    // `#[derive(TS)]`, where a pushed `#[ts]` attr would add a spurious
385    // "cannot find attribute `ts`" error alongside the real one).
386    let mut ts_overrides: Vec<(usize, &'static str)> = Vec::new();
387    let mut errors: Vec<proc_macro2::TokenStream> = Vec::new();
388    let mut slots: Vec<proc_macro2::TokenStream> = Vec::new();
389    let mut writes: Vec<proc_macro2::TokenStream> = Vec::new();
390    let mut length_checks: Vec<proc_macro2::TokenStream> = Vec::new();
391    let mut vec_i = 0usize;
392    let mut comp = 0usize;
393    for (field_i, field) in fields.named.iter().enumerate() {
394        let ident = field.ident.clone().expect("named field");
395        let field_name = ident.to_string();
396        let Some(param) = classify_filter_field(&field.ty) else {
397            errors.push(
398                syn::Error::new_spanned(
399                    &field.ty,
400                    format!(
401                        "`{macro_name}` cannot pack field `{field_name}`: supported param types \
402                         are f32, Vec2, Vec3, Vec4, [f32; 2..=4], Angle, Length, and FilterColor"
403                    ),
404                )
405                .to_compile_error(),
406            );
407            continue;
408        };
409        let len = param.len();
410        // No-straddle rule: a param that would cross a vec4 boundary pads to
411        // the next vec4; the skipped components stay zero.
412        if comp + len > 4 {
413            vec_i += 1;
414            comp = 0;
415        }
416        let (v, c) = (vec_i, comp);
417        let kind = param.value_kind();
418        slots.push(quote! {
419            ::bevy_react::filters::ParamSlot {
420                name: #field_name,
421                kind: #kind,
422                vec: #v,
423                comp: #c,
424                len: #len,
425            }
426        });
427        match &param {
428            FilterField::Scalar => writes.push(quote! { params[#v][#c] = self.#ident; }),
429            FilterField::Vector(n) => {
430                for (i, axis) in ["x", "y", "z", "w"].iter().take(*n).enumerate() {
431                    let axis = syn::Ident::new(axis, proc_macro2::Span::call_site());
432                    let ci = c + i;
433                    writes.push(quote! { params[#v][#ci] = self.#ident.#axis; });
434                }
435            }
436            FilterField::Array(n) => {
437                for i in 0..*n {
438                    let ci = c + i;
439                    writes.push(quote! { params[#v][#ci] = self.#ident[#i]; });
440                }
441            }
442            FilterField::Angle => {
443                writes.push(quote! { params[#v][#c] = self.#ident.radians(); });
444            }
445            FilterField::Length => {
446                // Logical px, same packing blur uses; the chain resolver
447                // rewrites Length slots to physical px. The infallible pack
448                // falls back to 0.0 — a non-px unit can't reach the shader
449                // because the generated `outset`/`resolve` reject it first.
450                writes.push(quote! {
451                    params[#v][#c] = ::bevy_react::filters::length_logical_px(
452                        #name, #field_name, self.#ident,
453                    )
454                    .unwrap_or(0.0);
455                });
456                length_checks.push(quote! {
457                    ::bevy_react::filters::length_logical_px(#name, #field_name, self.#ident)?;
458                });
459            }
460            FilterField::Color => {
461                for i in 0..4usize {
462                    let ci = c + i;
463                    writes.push(quote! { params[#v][#ci] = self.#ident.0[#i]; });
464                }
465            }
466        }
467        comp += len;
468        if comp == 4 {
469            vec_i += 1;
470            comp = 0;
471        }
472        if let Some(ts) = param.ts_override() {
473            ts_overrides.push((field_i, ts));
474        }
475    }
476    if errors.is_empty() {
477        // Only a fully valid struct gets the `#[ts(type)]` overrides — the
478        // emitted struct below carries the `#[derive(TS)]` they need.
479        for (field_i, ts) in ts_overrides {
480            fields.named[field_i]
481                .attrs
482                .push(syn::parse_quote!(#[ts(type = #ts)]));
483        }
484    } else {
485        // Emit the struct exactly as written alongside the errors so
486        // downstream code still sees the type, without a half-generated
487        // `ReactFilter` impl.
488        return quote! { #input #(#errors)* }.into();
489    }
490    let total_vecs = if comp == 0 { vec_i } else { vec_i + 1 };
491
492    let ident = &input.ident;
493    let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
494    // Overriding `resolve` only matters when there are `Length` params to
495    // validate; otherwise the trait default (the same body) applies.
496    let resolve_override = (!length_checks.is_empty()).then(|| {
497        quote! {
498            fn resolve(
499                &self,
500                assets: &::bevy::asset::AssetServer,
501            ) -> ::std::result::Result<
502                ::std::vec::Vec<::bevy_react::filters::ResolvedFilterPass>,
503                ::std::string::String,
504            > {
505                #(#length_checks)*
506                ::bevy_react::filters::resolve_single_pass(self, assets)
507            }
508        }
509    });
510
511    let morph_marker = is_morph.then(|| {
512        quote! {
513            impl #impl_generics ::bevy_react::filters::ReactMorphFilter
514                for #ident #ty_generics #where_clause {}
515        }
516    });
517
518    quote! {
519        #[derive(::serde::Deserialize, ::ts_rs::TS)]
520        #[serde(deny_unknown_fields)]
521        #input
522
523        impl #impl_generics ::bevy_react::filters::ReactFilter for #ident #ty_generics #where_clause {
524            const NAME: &'static str = #name;
525            const USES_TIME: bool = #time;
526            const IS_MORPH: bool = #is_morph;
527
528            fn shader(
529                assets: &::bevy::asset::AssetServer,
530            ) -> ::bevy::asset::Handle<::bevy::shader::Shader> {
531                assets.load(#shader)
532            }
533
534            fn outset(&self) -> ::std::result::Result<f32, ::std::string::String> {
535                #(#length_checks)*
536                ::std::result::Result::Ok(#outset)
537            }
538
539            fn pack(
540                &self,
541            ) -> (
542                ::std::vec::Vec<::bevy::math::Vec4>,
543                ::std::sync::Arc<[::bevy_react::filters::ParamSlot]>,
544            ) {
545                static LAYOUT: ::std::sync::LazyLock<
546                    ::std::sync::Arc<[::bevy_react::filters::ParamSlot]>,
547                > = ::std::sync::LazyLock::new(|| {
548                    ::std::sync::Arc::from(::std::vec![#(#slots),*])
549                });
550                #[allow(unused_mut)]
551                let mut params = ::std::vec![::bevy::math::Vec4::ZERO; #total_vecs];
552                #(#writes)*
553                (params, LAYOUT.clone())
554            }
555
556            #resolve_override
557        }
558
559        #morph_marker
560    }
561    .into()
562}
563
564/// How one `react_filter` param field packs, keyed off its declared type's
565/// last path segment (or `[f32; N]` array shape).
566enum FilterField {
567    /// `f32`.
568    Scalar,
569    /// `Vec2`/`Vec3`/`Vec4` (2–4 scalar components).
570    Vector(usize),
571    /// `[f32; N]`, `N` in `2..=4`.
572    Array(usize),
573    /// `Angle` — packs radians.
574    Angle,
575    /// `Length` — packs logical px (px-only; validated in `outset`/`resolve`).
576    Length,
577    /// `FilterColor` — packs linear RGBA across 4 components.
578    Color,
579}
580
581impl FilterField {
582    /// Packed component count.
583    fn len(&self) -> usize {
584        match self {
585            Self::Scalar | Self::Angle | Self::Length => 1,
586            Self::Vector(n) | Self::Array(n) => *n,
587            Self::Color => 4,
588        }
589    }
590
591    /// The `ValueKind` tokens for this param's `ParamSlot`.
592    fn value_kind(&self) -> proc_macro2::TokenStream {
593        match self {
594            Self::Scalar | Self::Vector(_) | Self::Array(_) => {
595                quote!(::bevy_react::animations::ValueKind::Scalar)
596            }
597            Self::Angle => quote!(::bevy_react::animations::ValueKind::Angle),
598            Self::Length => quote!(::bevy_react::animations::ValueKind::Length),
599            Self::Color => quote!(::bevy_react::animations::ValueKind::Color),
600        }
601    }
602
603    /// `#[ts(type = "...")]` override for field types without a `ts_rs::TS`
604    /// impl (glam vectors, the wire-flexible `Angle`/`Length`). `f32`,
605    /// `[f32; N]`, and `FilterColor` have real impls — no override.
606    fn ts_override(&self) -> Option<&'static str> {
607        match self {
608            Self::Vector(2) => Some("[number, number]"),
609            Self::Vector(3) => Some("[number, number, number]"),
610            Self::Vector(4) => Some("[number, number, number, number]"),
611            Self::Angle | Self::Length => Some("number | string"),
612            _ => None,
613        }
614    }
615}
616
617/// Map a field's declared type to its packing, or `None` if unsupported.
618/// Matches on the type path's last segment, so `bevy::math::Vec2` and a bare
619/// `Vec2` both work.
620fn classify_filter_field(ty: &Type) -> Option<FilterField> {
621    match ty {
622        Type::Path(p) => {
623            let seg = p.path.segments.last()?;
624            if !seg.arguments.is_empty() {
625                return None;
626            }
627            match seg.ident.to_string().as_str() {
628                "f32" => Some(FilterField::Scalar),
629                "Vec2" => Some(FilterField::Vector(2)),
630                "Vec3" => Some(FilterField::Vector(3)),
631                "Vec4" => Some(FilterField::Vector(4)),
632                "Angle" => Some(FilterField::Angle),
633                "Length" => Some(FilterField::Length),
634                "FilterColor" => Some(FilterField::Color),
635                _ => None,
636            }
637        }
638        Type::Array(a) => {
639            let is_f32 = matches!(&*a.elem, Type::Path(p) if p.path.is_ident("f32"));
640            let syn::Expr::Lit(lit) = &a.len else {
641                return None;
642            };
643            let syn::Lit::Int(n) = &lit.lit else {
644                return None;
645            };
646            let n = n.base10_parse::<usize>().ok()?;
647            (is_f32 && (2..=4).contains(&n)).then_some(FilterField::Array(n))
648        }
649        _ => None,
650    }
651}
652
653/// Consume a `name = "..."` argument if that's what `meta` holds; returns
654/// whether it matched, so callers can chain their own arms after it.
655fn try_parse_name_arg(
656    meta: &syn::meta::ParseNestedMeta,
657    out: &mut Option<String>,
658) -> syn::Result<bool> {
659    if meta.path.is_ident("name") {
660        *out = Some(meta.value()?.parse::<LitStr>()?.value());
661        Ok(true)
662    } else {
663        Ok(false)
664    }
665}
666
667/// Parse an attribute argument list that accepts only `name = "..."` (the
668/// `react_message`/`react_event` form; `react_request` adds a `response` arm).
669fn parse_name_only_attr(attr: TokenStream, macro_name: &str) -> syn::Result<Option<String>> {
670    let mut name_override: Option<String> = None;
671    let parser = syn::meta::parser(|meta| {
672        if try_parse_name_arg(&meta, &mut name_override)? {
673            Ok(())
674        } else {
675            Err(meta.error(format!(
676                "unsupported `{macro_name}` argument; expected `name = \"...\"`"
677            )))
678        }
679    });
680    syn::parse::Parser::parse(parser, attr)?;
681    Ok(name_override)
682}
683
684/// The pieces every `react_*` macro pulls off the annotated struct.
685struct PayloadParts<'a> {
686    ident: &'a syn::Ident,
687    impl_generics: syn::ImplGenerics<'a>,
688    ty_generics: syn::TypeGenerics<'a>,
689    where_clause: Option<&'a syn::WhereClause>,
690    /// The wire name: the `name = "..."` override, or the struct ident with its
691    /// first letter lowercased (`Count` → `"count"`).
692    name: String,
693}
694
695fn payload_parts<'a>(input: &'a DeriveInput, name_override: Option<String>) -> PayloadParts<'a> {
696    let ident = &input.ident;
697    let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
698    PayloadParts {
699        ident,
700        impl_generics,
701        ty_generics,
702        where_clause,
703        name: name_override.unwrap_or_else(|| lower_first(&ident.to_string())),
704    }
705}
706
707/// Lowercase only the first character of `s` (`Count` → `count`).
708fn lower_first(s: &str) -> String {
709    let mut chars = s.chars();
710    match chars.next() {
711        Some(first) => first.to_lowercase().collect::<String>() + chars.as_str(),
712        None => String::new(),
713    }
714}