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 ¶m {
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}