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/// Consume a `name = "..."` argument if that's what `meta` holds; returns
179/// whether it matched, so callers can chain their own arms after it.
180fn try_parse_name_arg(
181    meta: &syn::meta::ParseNestedMeta,
182    out: &mut Option<String>,
183) -> syn::Result<bool> {
184    if meta.path.is_ident("name") {
185        *out = Some(meta.value()?.parse::<LitStr>()?.value());
186        Ok(true)
187    } else {
188        Ok(false)
189    }
190}
191
192/// Parse an attribute argument list that accepts only `name = "..."` (the
193/// `react_message`/`react_event` form; `react_request` adds a `response` arm).
194fn parse_name_only_attr(attr: TokenStream, macro_name: &str) -> syn::Result<Option<String>> {
195    let mut name_override: Option<String> = None;
196    let parser = syn::meta::parser(|meta| {
197        if try_parse_name_arg(&meta, &mut name_override)? {
198            Ok(())
199        } else {
200            Err(meta.error(format!(
201                "unsupported `{macro_name}` argument; expected `name = \"...\"`"
202            )))
203        }
204    });
205    syn::parse::Parser::parse(parser, attr)?;
206    Ok(name_override)
207}
208
209/// The pieces every `react_*` macro pulls off the annotated struct.
210struct PayloadParts<'a> {
211    ident: &'a syn::Ident,
212    impl_generics: syn::ImplGenerics<'a>,
213    ty_generics: syn::TypeGenerics<'a>,
214    where_clause: Option<&'a syn::WhereClause>,
215    /// The wire name: the `name = "..."` override, or the struct ident with its
216    /// first letter lowercased (`Count` → `"count"`).
217    name: String,
218}
219
220fn payload_parts<'a>(input: &'a DeriveInput, name_override: Option<String>) -> PayloadParts<'a> {
221    let ident = &input.ident;
222    let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
223    PayloadParts {
224        ident,
225        impl_generics,
226        ty_generics,
227        where_clause,
228        name: name_override.unwrap_or_else(|| lower_first(&ident.to_string())),
229    }
230}
231
232/// Lowercase only the first character of `s` (`Count` → `count`).
233fn lower_first(s: &str) -> String {
234    let mut chars = s.chars();
235    match chars.next() {
236        Some(first) => first.to_lowercase().collect::<String>() + chars.as_str(),
237        None => String::new(),
238    }
239}