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 filter** for the
179/// layer-based `filter` style chain.
180///
181/// Derives `serde::Deserialize` — adding `#[serde(deny_unknown_fields)]`, so
182/// unknown param keys reject like the built-ins; per-field `#[serde(default)]`
183/// attributes you write are preserved — plus `ts_rs::TS`, and implements
184/// `bevy_react::filters::ReactFilter`. Built-in filters stay hand-written
185/// (they share canonical shader layouts); this macro is for custom filters,
186/// which pack against their own shader.
187///
188/// Arguments:
189///
190/// - `name = "..."` (optional) — the wire name; defaults to the struct ident
191///   with its first letter lowercased (`Glow` → `"glow"`).
192/// - `shader = "path/to.wgsl"` (required) — loaded with `AssetServer::load`,
193///   so a plain asset path relative to your assets dir; `embedded://` paths
194///   also work verbatim.
195/// - `outset = <number>` (optional, default `0.0`) — extra *logical* px the
196///   effect bleeds outside the node's rect.
197/// - `time = <bool>` (optional, default `false`) — a time-driven effect
198///   re-renders its layer every frame.
199///
200/// The generated `pack()` fills the shader's `params` vec4 array
201/// contiguously **in field declaration order**, mapped by field type. Params
202/// pack into at most `MAX_FILTER_PARAM_VECS` (8) vec4s, enforced at resolve
203/// time — an over-cap filter is rejected at runtime with a devtools warning,
204/// not a compile error. Shader-side, the packed array is `uniforms.params`
205/// via `#import bevy_react::filter` (see
206/// `crates/core/src/layer/filter_prelude.wgsl` for the full binding
207/// contract).
208///
209/// | field type           | packs as                          | components |
210/// |----------------------|-----------------------------------|------------|
211/// | `f32`                | scalar                            | 1          |
212/// | `Vec2`/`Vec3`/`Vec4` | scalars                           | 2/3/4      |
213/// | `[f32; 2..=4]`       | scalars                           | 2–4        |
214/// | `Angle`              | radians (a bare wire number is degrees) | 1    |
215/// | `Length`             | logical px (px-only; other units reject the filter use via `outset`/`resolve`) | 1 |
216/// | `FilterColor`        | linear straight-alpha RGBA        | 4          |
217///
218/// A multi-component param that would straddle a vec4 boundary pads to the
219/// next vec4 (the skipped components stay zero) — a `ParamSlot` never crosses
220/// a vec4. Any other field type fails compilation with an error naming the
221/// field and listing the supported types.
222///
223/// ```ignore
224/// #[react_filter(name = "glow", shader = "shaders/glow.wgsl", outset = 4.0)]
225/// struct GlowParams {
226///     intensity: f32,          // params[0].x
227///     direction: Vec2,         // params[0].yz
228///     tint: FilterColor,       // params[1] (padded past params[0].w)
229/// }
230/// ```
231#[proc_macro_attribute]
232pub fn react_filter(attr: TokenStream, item: TokenStream) -> TokenStream {
233    let mut name_override: Option<String> = None;
234    let mut shader: Option<LitStr> = None;
235    let mut outset: f32 = 0.0;
236    let mut time = false;
237    let arg_parser = syn::meta::parser(|meta| {
238        if try_parse_name_arg(&meta, &mut name_override)? {
239            Ok(())
240        } else if meta.path.is_ident("shader") {
241            shader = Some(meta.value()?.parse::<LitStr>()?);
242            Ok(())
243        } else if meta.path.is_ident("outset") {
244            outset = match meta.value()?.parse::<syn::Lit>()? {
245                syn::Lit::Float(f) => f.base10_parse()?,
246                syn::Lit::Int(i) => i.base10_parse()?,
247                other => {
248                    return Err(syn::Error::new_spanned(
249                        other,
250                        "`outset` must be a number literal (logical px)",
251                    ));
252                }
253            };
254            Ok(())
255        } else if meta.path.is_ident("time") {
256            time = meta.value()?.parse::<syn::LitBool>()?.value;
257            Ok(())
258        } else {
259            Err(meta.error(
260                "unsupported `react_filter` argument; expected `name = \"...\"`, \
261                 `shader = \"...\"`, `outset = <number>`, or `time = <bool>`",
262            ))
263        }
264    });
265    parse_macro_input!(attr with arg_parser);
266
267    let Some(shader) = shader else {
268        return syn::Error::new(
269            proc_macro2::Span::call_site(),
270            "`react_filter` requires a `shader = \"path/to.wgsl\"` argument",
271        )
272        .to_compile_error()
273        .into();
274    };
275
276    let mut input = parse_macro_input!(item as DeriveInput);
277    let name = name_override.unwrap_or_else(|| lower_first(&input.ident.to_string()));
278
279    let syn::Data::Struct(data) = &mut input.data else {
280        return syn::Error::new_spanned(&input.ident, "`react_filter` requires a struct")
281            .to_compile_error()
282            .into();
283    };
284    let syn::Fields::Named(fields) = &mut data.fields else {
285        return syn::Error::new_spanned(
286            &input.ident,
287            "`react_filter` requires named fields (each field becomes a shader param)",
288        )
289        .to_compile_error()
290        .into();
291    };
292
293    // Walk the fields in declaration order, assigning each a contiguous
294    // no-straddle slot in the packed vec4 array and collecting the generated
295    // slot/write/validation code (plus `#[ts(type)]` overrides for field
296    // types without a `ts_rs::TS` impl — recorded here, applied only after
297    // the walk proves error-free: the error path emits the struct without
298    // `#[derive(TS)]`, where a pushed `#[ts]` attr would add a spurious
299    // "cannot find attribute `ts`" error alongside the real one).
300    let mut ts_overrides: Vec<(usize, &'static str)> = Vec::new();
301    let mut errors: Vec<proc_macro2::TokenStream> = Vec::new();
302    let mut slots: Vec<proc_macro2::TokenStream> = Vec::new();
303    let mut writes: Vec<proc_macro2::TokenStream> = Vec::new();
304    let mut length_checks: Vec<proc_macro2::TokenStream> = Vec::new();
305    let mut vec_i = 0usize;
306    let mut comp = 0usize;
307    for (field_i, field) in fields.named.iter().enumerate() {
308        let ident = field.ident.clone().expect("named field");
309        let field_name = ident.to_string();
310        let Some(param) = classify_filter_field(&field.ty) else {
311            errors.push(
312                syn::Error::new_spanned(
313                    &field.ty,
314                    format!(
315                        "`react_filter` cannot pack field `{field_name}`: supported param types \
316                         are f32, Vec2, Vec3, Vec4, [f32; 2..=4], Angle, Length, and FilterColor"
317                    ),
318                )
319                .to_compile_error(),
320            );
321            continue;
322        };
323        let len = param.len();
324        // No-straddle rule: a param that would cross a vec4 boundary pads to
325        // the next vec4; the skipped components stay zero.
326        if comp + len > 4 {
327            vec_i += 1;
328            comp = 0;
329        }
330        let (v, c) = (vec_i, comp);
331        let kind = param.value_kind();
332        slots.push(quote! {
333            ::bevy_react::filters::ParamSlot {
334                name: #field_name,
335                kind: #kind,
336                vec: #v,
337                comp: #c,
338                len: #len,
339            }
340        });
341        match &param {
342            FilterField::Scalar => writes.push(quote! { params[#v][#c] = self.#ident; }),
343            FilterField::Vector(n) => {
344                for (i, axis) in ["x", "y", "z", "w"].iter().take(*n).enumerate() {
345                    let axis = syn::Ident::new(axis, proc_macro2::Span::call_site());
346                    let ci = c + i;
347                    writes.push(quote! { params[#v][#ci] = self.#ident.#axis; });
348                }
349            }
350            FilterField::Array(n) => {
351                for i in 0..*n {
352                    let ci = c + i;
353                    writes.push(quote! { params[#v][#ci] = self.#ident[#i]; });
354                }
355            }
356            FilterField::Angle => {
357                writes.push(quote! { params[#v][#c] = self.#ident.radians(); });
358            }
359            FilterField::Length => {
360                // Logical px, same packing blur uses; the chain resolver
361                // rewrites Length slots to physical px. The infallible pack
362                // falls back to 0.0 — a non-px unit can't reach the shader
363                // because the generated `outset`/`resolve` reject it first.
364                writes.push(quote! {
365                    params[#v][#c] = ::bevy_react::filters::length_logical_px(
366                        #name, #field_name, self.#ident,
367                    )
368                    .unwrap_or(0.0);
369                });
370                length_checks.push(quote! {
371                    ::bevy_react::filters::length_logical_px(#name, #field_name, self.#ident)?;
372                });
373            }
374            FilterField::Color => {
375                for i in 0..4usize {
376                    let ci = c + i;
377                    writes.push(quote! { params[#v][#ci] = self.#ident.0[#i]; });
378                }
379            }
380        }
381        comp += len;
382        if comp == 4 {
383            vec_i += 1;
384            comp = 0;
385        }
386        if let Some(ts) = param.ts_override() {
387            ts_overrides.push((field_i, ts));
388        }
389    }
390    if errors.is_empty() {
391        // Only a fully valid struct gets the `#[ts(type)]` overrides — the
392        // emitted struct below carries the `#[derive(TS)]` they need.
393        for (field_i, ts) in ts_overrides {
394            fields.named[field_i]
395                .attrs
396                .push(syn::parse_quote!(#[ts(type = #ts)]));
397        }
398    } else {
399        // Emit the struct exactly as written alongside the errors so
400        // downstream code still sees the type, without a half-generated
401        // `ReactFilter` impl.
402        return quote! { #input #(#errors)* }.into();
403    }
404    let total_vecs = if comp == 0 { vec_i } else { vec_i + 1 };
405
406    let ident = &input.ident;
407    let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
408    // Overriding `resolve` only matters when there are `Length` params to
409    // validate; otherwise the trait default (the same body) applies.
410    let resolve_override = (!length_checks.is_empty()).then(|| {
411        quote! {
412            fn resolve(
413                &self,
414                assets: &::bevy::asset::AssetServer,
415            ) -> ::std::result::Result<
416                ::std::vec::Vec<::bevy_react::filters::ResolvedFilterPass>,
417                ::std::string::String,
418            > {
419                #(#length_checks)*
420                ::bevy_react::filters::resolve_single_pass(self, assets)
421            }
422        }
423    });
424
425    quote! {
426        #[derive(::serde::Deserialize, ::ts_rs::TS)]
427        #[serde(deny_unknown_fields)]
428        #input
429
430        impl #impl_generics ::bevy_react::filters::ReactFilter for #ident #ty_generics #where_clause {
431            const NAME: &'static str = #name;
432            const USES_TIME: bool = #time;
433
434            fn shader(
435                assets: &::bevy::asset::AssetServer,
436            ) -> ::bevy::asset::Handle<::bevy::shader::Shader> {
437                assets.load(#shader)
438            }
439
440            fn outset(&self) -> ::std::result::Result<f32, ::std::string::String> {
441                #(#length_checks)*
442                ::std::result::Result::Ok(#outset)
443            }
444
445            fn pack(
446                &self,
447            ) -> (
448                ::std::vec::Vec<::bevy::math::Vec4>,
449                ::std::sync::Arc<[::bevy_react::filters::ParamSlot]>,
450            ) {
451                static LAYOUT: ::std::sync::LazyLock<
452                    ::std::sync::Arc<[::bevy_react::filters::ParamSlot]>,
453                > = ::std::sync::LazyLock::new(|| {
454                    ::std::sync::Arc::from(::std::vec![#(#slots),*])
455                });
456                #[allow(unused_mut)]
457                let mut params = ::std::vec![::bevy::math::Vec4::ZERO; #total_vecs];
458                #(#writes)*
459                (params, LAYOUT.clone())
460            }
461
462            #resolve_override
463        }
464    }
465    .into()
466}
467
468/// How one `react_filter` param field packs, keyed off its declared type's
469/// last path segment (or `[f32; N]` array shape).
470enum FilterField {
471    /// `f32`.
472    Scalar,
473    /// `Vec2`/`Vec3`/`Vec4` (2–4 scalar components).
474    Vector(usize),
475    /// `[f32; N]`, `N` in `2..=4`.
476    Array(usize),
477    /// `Angle` — packs radians.
478    Angle,
479    /// `Length` — packs logical px (px-only; validated in `outset`/`resolve`).
480    Length,
481    /// `FilterColor` — packs linear RGBA across 4 components.
482    Color,
483}
484
485impl FilterField {
486    /// Packed component count.
487    fn len(&self) -> usize {
488        match self {
489            Self::Scalar | Self::Angle | Self::Length => 1,
490            Self::Vector(n) | Self::Array(n) => *n,
491            Self::Color => 4,
492        }
493    }
494
495    /// The `ValueKind` tokens for this param's `ParamSlot`.
496    fn value_kind(&self) -> proc_macro2::TokenStream {
497        match self {
498            Self::Scalar | Self::Vector(_) | Self::Array(_) => {
499                quote!(::bevy_react::animations::ValueKind::Scalar)
500            }
501            Self::Angle => quote!(::bevy_react::animations::ValueKind::Angle),
502            Self::Length => quote!(::bevy_react::animations::ValueKind::Length),
503            Self::Color => quote!(::bevy_react::animations::ValueKind::Color),
504        }
505    }
506
507    /// `#[ts(type = "...")]` override for field types without a `ts_rs::TS`
508    /// impl (glam vectors, the wire-flexible `Angle`/`Length`). `f32`,
509    /// `[f32; N]`, and `FilterColor` have real impls — no override.
510    fn ts_override(&self) -> Option<&'static str> {
511        match self {
512            Self::Vector(2) => Some("[number, number]"),
513            Self::Vector(3) => Some("[number, number, number]"),
514            Self::Vector(4) => Some("[number, number, number, number]"),
515            Self::Angle | Self::Length => Some("number | string"),
516            _ => None,
517        }
518    }
519}
520
521/// Map a field's declared type to its packing, or `None` if unsupported.
522/// Matches on the type path's last segment, so `bevy::math::Vec2` and a bare
523/// `Vec2` both work.
524fn classify_filter_field(ty: &Type) -> Option<FilterField> {
525    match ty {
526        Type::Path(p) => {
527            let seg = p.path.segments.last()?;
528            if !seg.arguments.is_empty() {
529                return None;
530            }
531            match seg.ident.to_string().as_str() {
532                "f32" => Some(FilterField::Scalar),
533                "Vec2" => Some(FilterField::Vector(2)),
534                "Vec3" => Some(FilterField::Vector(3)),
535                "Vec4" => Some(FilterField::Vector(4)),
536                "Angle" => Some(FilterField::Angle),
537                "Length" => Some(FilterField::Length),
538                "FilterColor" => Some(FilterField::Color),
539                _ => None,
540            }
541        }
542        Type::Array(a) => {
543            let is_f32 = matches!(&*a.elem, Type::Path(p) if p.path.is_ident("f32"));
544            let syn::Expr::Lit(lit) = &a.len else {
545                return None;
546            };
547            let syn::Lit::Int(n) = &lit.lit else {
548                return None;
549            };
550            let n = n.base10_parse::<usize>().ok()?;
551            (is_f32 && (2..=4).contains(&n)).then_some(FilterField::Array(n))
552        }
553        _ => None,
554    }
555}
556
557/// Consume a `name = "..."` argument if that's what `meta` holds; returns
558/// whether it matched, so callers can chain their own arms after it.
559fn try_parse_name_arg(
560    meta: &syn::meta::ParseNestedMeta,
561    out: &mut Option<String>,
562) -> syn::Result<bool> {
563    if meta.path.is_ident("name") {
564        *out = Some(meta.value()?.parse::<LitStr>()?.value());
565        Ok(true)
566    } else {
567        Ok(false)
568    }
569}
570
571/// Parse an attribute argument list that accepts only `name = "..."` (the
572/// `react_message`/`react_event` form; `react_request` adds a `response` arm).
573fn parse_name_only_attr(attr: TokenStream, macro_name: &str) -> syn::Result<Option<String>> {
574    let mut name_override: Option<String> = None;
575    let parser = syn::meta::parser(|meta| {
576        if try_parse_name_arg(&meta, &mut name_override)? {
577            Ok(())
578        } else {
579            Err(meta.error(format!(
580                "unsupported `{macro_name}` argument; expected `name = \"...\"`"
581            )))
582        }
583    });
584    syn::parse::Parser::parse(parser, attr)?;
585    Ok(name_override)
586}
587
588/// The pieces every `react_*` macro pulls off the annotated struct.
589struct PayloadParts<'a> {
590    ident: &'a syn::Ident,
591    impl_generics: syn::ImplGenerics<'a>,
592    ty_generics: syn::TypeGenerics<'a>,
593    where_clause: Option<&'a syn::WhereClause>,
594    /// The wire name: the `name = "..."` override, or the struct ident with its
595    /// first letter lowercased (`Count` → `"count"`).
596    name: String,
597}
598
599fn payload_parts<'a>(input: &'a DeriveInput, name_override: Option<String>) -> PayloadParts<'a> {
600    let ident = &input.ident;
601    let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
602    PayloadParts {
603        ident,
604        impl_generics,
605        ty_generics,
606        where_clause,
607        name: name_override.unwrap_or_else(|| lower_first(&ident.to_string())),
608    }
609}
610
611/// Lowercase only the first character of `s` (`Count` → `count`).
612fn lower_first(s: &str) -> String {
613    let mut chars = s.chars();
614    match chars.next() {
615        Some(first) => first.to_lowercase().collect::<String>() + chars.as_str(),
616        None => String::new(),
617    }
618}