Created
February 21, 2026 00:55
-
-
Save mrkybe/c044ade5d923b71936f9e6c493997d10 to your computer and use it in GitHub Desktop.
//! A proc-macro that automatically splits Bevy system functions with more than //! 15 parameters into `#[derive(SystemParam)]` structs, working around Bevy's //! 16-parameter tuple limit.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| //! # bevy_auto_split | |
| //! | |
| //! A proc-macro that automatically splits Bevy system functions with more than | |
| //! 15 parameters into `#[derive(SystemParam)]` structs, working around Bevy's | |
| //! 16-parameter tuple limit. | |
| //! | |
| //! ## Usage | |
| //! | |
| //! ```rust,ignore | |
| //! use bevy_auto_split::auto_split_params; | |
| //! | |
| //! #[auto_split_params] | |
| //! fn my_large_system( | |
| //! mut commands: Commands, | |
| //! res1: Res<Foo>, | |
| //! // ... many more params ... | |
| //! res16: Res<Bar>, | |
| //! ) { | |
| //! // function body uses original param names unchanged | |
| //! } | |
| //! ``` | |
| //! | |
| //! ## Requirements | |
| //! | |
| //! - `SystemParam` derive must be in scope at the call site (e.g. via | |
| //! `use bevy::prelude::*`). | |
| //! - All parameters must use simple `name: Type` patterns (no destructuring). | |
| //! - Only recognised Bevy system-param types are supported. Unknown types | |
| //! produce a compile error asking you to split manually. | |
| //! | |
| //! ## Recognised Types | |
| //! | |
| //! | Function signature | Struct field | | |
| //! |-----------------------------|-------------------------------------| | |
| //! | `Commands` | `Commands<'w, 's>` | | |
| //! | `Res<T>` | `Res<'w, T>` | | |
| //! | `ResMut<T>` | `ResMut<'w, T>` | | |
| //! | `Query<D>` / `Query<D, F>` | `Query<'w, 's, D', F'>` (†) | | |
| //! | `Local<T>` | `Local<'s, T>` | | |
| //! | `EventReader<T>` | `EventReader<'w, 's, T>` | | |
| //! | `EventWriter<T>` | `EventWriter<'w, T>` | | |
| //! | `ParamSet<...>` | `ParamSet<'w, 's, ...>` | | |
| //! | |
| //! † Bare `&T` / `&mut T` inside query type parameters are rewritten to | |
| //! `&'static T` / `&'static mut T`. | |
| use proc_macro::TokenStream; | |
| use proc_macro2::Span; | |
| use quote::{format_ident, quote}; | |
| use syn::{ | |
| parse_macro_input, FnArg, GenericArgument, Ident, ItemFn, Lifetime, Pat, | |
| PathArguments, Type, | |
| }; | |
| /// Maximum fields per generated `SystemParam` struct. | |
| /// | |
| /// With chunks of 15, even a function with 15 × 15 = 225 parameters would | |
| /// produce only 15 struct params on the rewritten function — well within | |
| /// Bevy's 16-tuple limit. | |
| const MAX_FIELDS: usize = 15; | |
| // ═══════════════════════════════════════════════════════════════════════════ | |
| // Public API | |
| // ═══════════════════════════════════════════════════════════════════════════ | |
| /// Automatically split a Bevy system function with more than 15 parameters | |
| /// into `#[derive(SystemParam)]` structs. | |
| /// | |
| /// If the function has 15 or fewer parameters, the macro is a no-op and emits | |
| /// the function unchanged. | |
| #[proc_macro_attribute] | |
| pub fn auto_split_params(_attr: TokenStream, item: TokenStream) -> TokenStream { | |
| let func = parse_macro_input!(item as ItemFn); | |
| match expand(&func) { | |
| Ok(ts) => ts.into(), | |
| Err(e) => e.to_compile_error().into(), | |
| } | |
| } | |
| // ═══════════════════════════════════════════════════════════════════════════ | |
| // Expansion | |
| // ═══════════════════════════════════════════════════════════════════════════ | |
| struct ParamInfo { | |
| is_mut: bool, | |
| name: Ident, | |
| struct_ty: Type, | |
| needs_w: bool, | |
| needs_s: bool, | |
| } | |
| fn expand(func: &ItemFn) -> Result<proc_macro2::TokenStream, syn::Error> { | |
| // ── Pass-through for small functions ───────────────────────────────── | |
| if func.sig.inputs.len() <= MAX_FIELDS { | |
| return Ok(quote!(#func)); | |
| } | |
| // ── Analyse every parameter ───────────────────────────────────────── | |
| let mut infos: Vec<ParamInfo> = Vec::new(); | |
| for arg in &func.sig.inputs { | |
| let FnArg::Typed(pat_ty) = arg else { | |
| return Err(syn::Error::new_spanned( | |
| arg, | |
| "auto_split_params: `self` receivers are not supported", | |
| )); | |
| }; | |
| let (is_mut, name) = pat_ident(&pat_ty.pat)?; | |
| let (struct_ty, needs_w, needs_s) = lift_type(&pat_ty.ty)?; | |
| infos.push(ParamInfo { is_mut, name, struct_ty, needs_w, needs_s }); | |
| } | |
| // ── Build output ──────────────────────────────────────────────────── | |
| let vis = &func.vis; | |
| let fn_name = &func.sig.ident; | |
| let ret_ty = &func.sig.output; | |
| let attrs = &func.attrs; | |
| let body_stmts = &func.block.stmts; | |
| let pascal = to_pascal_case(&fn_name.to_string()); | |
| let mut structs = proc_macro2::TokenStream::new(); | |
| let mut fn_params = Vec::new(); | |
| let mut let_bindings = Vec::new(); | |
| for (idx, chunk) in infos.chunks(MAX_FIELDS).enumerate() { | |
| let struct_ident = format_ident!("{}P{}", pascal, idx); | |
| let tmp = format_ident!("__asp{}", idx); // short, unlikely to collide | |
| // Only include lifetime parameters that the chunk actually uses. | |
| let has_w = chunk.iter().any(|p| p.needs_w); | |
| let has_s = chunk.iter().any(|p| p.needs_s); | |
| let lt_params = match (has_w, has_s) { | |
| (true, true) => quote!(<'w, 's>), | |
| (true, false) => quote!(<'w>), | |
| (false, true) => quote!(<'s>), | |
| // Fallback — every Bevy param uses at least one lifetime, so | |
| // this branch is unreachable in practice. | |
| (false, false) => quote!(<'w, 's>), | |
| }; | |
| let fields: Vec<_> = chunk | |
| .iter() | |
| .map(|p| { | |
| let n = &p.name; | |
| let t = &p.struct_ty; | |
| quote!(#n: #t) | |
| }) | |
| .collect(); | |
| structs.extend(quote! { | |
| #[derive(bevy::ecs::system::SystemParam)] | |
| #[allow(non_camel_case_types)] | |
| #vis struct #struct_ident #lt_params { | |
| #(#fields ,)* | |
| } | |
| }); | |
| fn_params.push(quote!(#tmp: #struct_ident)); | |
| for p in chunk { | |
| let n = &p.name; | |
| if p.is_mut { | |
| let_bindings.push(quote!(let mut #n = #tmp.#n;)); | |
| } else { | |
| let_bindings.push(quote!(let #n = #tmp.#n;)); | |
| } | |
| } | |
| } | |
| Ok(quote! { | |
| #structs | |
| #(#attrs)* | |
| #vis fn #fn_name ( #(#fn_params ,)* ) #ret_ty { | |
| #(#let_bindings)* | |
| #(#body_stmts)* | |
| } | |
| }) | |
| } | |
| // ═══════════════════════════════════════════════════════════════════════════ | |
| // Type Lifting — elided lifetimes → explicit lifetimes for SystemParam struct | |
| // ═══════════════════════════════════════════════════════════════════════════ | |
| /// Transform a Bevy system-param type from its elided-lifetime | |
| /// function-signature form into the explicit-lifetime form required inside a | |
| /// `#[derive(SystemParam)]` struct. | |
| /// | |
| /// Returns `(transformed_type, needs_'w, needs_'s)`. | |
| fn lift_type(ty: &Type) -> Result<(Type, bool, bool), syn::Error> { | |
| let Type::Path(tp) = ty else { | |
| return Err(syn::Error::new_spanned( | |
| ty, | |
| "auto_split_params: expected a path type (e.g. `Res<T>`, `Query<...>`)", | |
| )); | |
| }; | |
| let seg = tp | |
| .path | |
| .segments | |
| .last() | |
| .ok_or_else(|| syn::Error::new_spanned(ty, "auto_split_params: empty type path"))?; | |
| let name = seg.ident.to_string(); | |
| let mut out = tp.clone(); | |
| let last = out.path.segments.last_mut().unwrap(); | |
| match name.as_str() { | |
| // Commands → Commands<'w, 's> | |
| "Commands" => { | |
| prepend_lifetimes(last, &["w", "s"])?; | |
| Ok((Type::Path(out), true, true)) | |
| } | |
| // Res<T> → Res<'w, T> | |
| // ResMut<T> → ResMut<'w, T> | |
| "Res" | "ResMut" => { | |
| prepend_lifetimes(last, &["w"])?; | |
| Ok((Type::Path(out), true, false)) | |
| } | |
| // Query<D, F?> → Query<'w, 's, D', F'?> | |
| // where bare &T/&mut T become &'static T/&'static mut T | |
| "Query" => { | |
| staticify_args(last); | |
| prepend_lifetimes(last, &["w", "s"])?; | |
| Ok((Type::Path(out), true, true)) | |
| } | |
| // Local<T> → Local<'s, T> | |
| "Local" => { | |
| prepend_lifetimes(last, &["s"])?; | |
| Ok((Type::Path(out), false, true)) | |
| } | |
| // EventReader<T> → EventReader<'w, 's, T> | |
| "EventReader" => { | |
| prepend_lifetimes(last, &["w", "s"])?; | |
| Ok((Type::Path(out), true, true)) | |
| } | |
| // EventWriter<T> → EventWriter<'w, T> | |
| "EventWriter" => { | |
| prepend_lifetimes(last, &["w"])?; | |
| Ok((Type::Path(out), true, false)) | |
| } | |
| // ParamSet<(P1, P2, ...)> → ParamSet<'w, 's, (P1, P2, ...)> | |
| "ParamSet" => { | |
| prepend_lifetimes(last, &["w", "s"])?; | |
| Ok((Type::Path(out), true, true)) | |
| } | |
| _ => Err(syn::Error::new_spanned( | |
| ty, | |
| format!( | |
| "auto_split_params: unrecognised system-param type `{name}`. \ | |
| Split this parameter manually or extend the macro to handle it." | |
| ), | |
| )), | |
| } | |
| } | |
| // ═══════════════════════════════════════════════════════════════════════════ | |
| // Helpers | |
| // ═══════════════════════════════════════════════════════════════════════════ | |
| /// Extract `(is_mut, ident)` from a function parameter pattern. | |
| fn pat_ident(pat: &Pat) -> Result<(bool, Ident), syn::Error> { | |
| match pat { | |
| Pat::Ident(pi) => Ok((pi.mutability.is_some(), pi.ident.clone())), | |
| _ => Err(syn::Error::new_spanned( | |
| pat, | |
| "auto_split_params: only simple ident patterns are supported \ | |
| (no destructuring)", | |
| )), | |
| } | |
| } | |
| /// `snake_case` → `PascalCase`. | |
| fn to_pascal_case(s: &str) -> String { | |
| s.split('_') | |
| .filter(|w| !w.is_empty()) | |
| .map(|w| { | |
| let mut chars = w.chars(); | |
| match chars.next() { | |
| Some(first) => { | |
| let upper: String = first.to_uppercase().collect(); | |
| format!("{upper}{rest}", rest = chars.as_str()) | |
| } | |
| None => String::new(), | |
| } | |
| }) | |
| .collect() | |
| } | |
| /// Prepend lifetime generic arguments (`'w`, `'s`, …) to a path segment's | |
| /// angle-bracketed args, preserving any existing type/const arguments. | |
| fn prepend_lifetimes(seg: &mut syn::PathSegment, names: &[&str]) -> Result<(), syn::Error> { | |
| let existing: Vec<GenericArgument> = match &seg.arguments { | |
| PathArguments::AngleBracketed(a) => a.args.iter().cloned().collect(), | |
| PathArguments::None => Vec::new(), | |
| PathArguments::Parenthesized(_) => { | |
| return Err(syn::Error::new_spanned( | |
| &seg.ident, | |
| "auto_split_params: unexpected parenthesized generic arguments", | |
| )); | |
| } | |
| }; | |
| let mut args = syn::punctuated::Punctuated::new(); | |
| for name in names { | |
| args.push(GenericArgument::Lifetime(Lifetime::new( | |
| &format!("'{name}"), | |
| Span::call_site(), | |
| ))); | |
| } | |
| for a in existing { | |
| args.push(a); | |
| } | |
| seg.arguments = PathArguments::AngleBracketed(syn::AngleBracketedGenericArguments { | |
| colon2_token: None, | |
| lt_token: Default::default(), | |
| args, | |
| gt_token: Default::default(), | |
| }); | |
| Ok(()) | |
| } | |
| /// Add `'static` to every bare (lifetime-less) `&T` / `&mut T` inside a path | |
| /// segment's generic type arguments. Used for `Query` data and filter params. | |
| fn staticify_args(seg: &mut syn::PathSegment) { | |
| if let PathArguments::AngleBracketed(a) = &mut seg.arguments { | |
| for arg in &mut a.args { | |
| if let GenericArgument::Type(t) = arg { | |
| staticify_refs(t); | |
| } | |
| } | |
| } | |
| } | |
| /// Recursively walk a type tree and insert `'static` on any reference whose | |
| /// lifetime is elided. | |
| fn staticify_refs(ty: &mut Type) { | |
| match ty { | |
| Type::Reference(r) => { | |
| if r.lifetime.is_none() { | |
| r.lifetime = Some(Lifetime::new("'static", Span::call_site())); | |
| } | |
| staticify_refs(&mut r.elem); | |
| } | |
| Type::Tuple(t) => { | |
| for elem in &mut t.elems { | |
| staticify_refs(elem); | |
| } | |
| } | |
| Type::Path(tp) => { | |
| for seg in &mut tp.path.segments { | |
| if let PathArguments::AngleBracketed(a) = &mut seg.arguments { | |
| for arg in &mut a.args { | |
| if let GenericArgument::Type(t) = arg { | |
| staticify_refs(t); | |
| } | |
| } | |
| } | |
| } | |
| } | |
| Type::Paren(p) => staticify_refs(&mut p.elem), | |
| _ => {} | |
| } | |
| } | |
| // ═══════════════════════════════════════════════════════════════════════════ | |
| // Tests | |
| // ═══════════════════════════════════════════════════════════════════════════ | |
| #[cfg(test)] | |
| mod tests { | |
| use super::*; | |
| #[test] | |
| fn pascal_case() { | |
| assert_eq!(to_pascal_case("handle_omt_changes"), "HandleOmtChanges"); | |
| assert_eq!(to_pascal_case("foo"), "Foo"); | |
| assert_eq!(to_pascal_case("a_b_c"), "ABC"); | |
| assert_eq!(to_pascal_case("__leading"), "Leading"); | |
| } | |
| #[test] | |
| fn passthrough_under_limit() { | |
| let input: syn::ItemFn = syn::parse_quote! { | |
| fn small_system(a: Res<Foo>, b: Res<Bar>) {} | |
| }; | |
| let result = expand(&input).unwrap(); | |
| let output: syn::ItemFn = syn::parse2(result).unwrap(); | |
| assert_eq!(output.sig.inputs.len(), 2); | |
| } | |
| #[test] | |
| fn splits_at_16() { | |
| // Build a function with 16 Res<T> params | |
| let params: Vec<proc_macro2::TokenStream> = (0..16usize) | |
| .map(|i| { | |
| let name = format_ident!("r{}", i); | |
| let ty = format_ident!("R{}", i); | |
| quote!(#name: Res<#ty>) | |
| }) | |
| .collect(); | |
| let input: syn::ItemFn = syn::parse2(quote! { | |
| fn big(#(#params),*) {} | |
| }) | |
| .unwrap(); | |
| let result = expand(&input).unwrap(); | |
| let result_str = result.to_string(); | |
| // Should produce two structs: BigP0 (15 fields) and BigP1 (1 field) | |
| assert!(result_str.contains("BigP0"), "missing BigP0 struct"); | |
| assert!(result_str.contains("BigP1"), "missing BigP1 struct"); | |
| // Should produce let bindings | |
| assert!(result_str.contains("let r0"), "missing r0 binding"); | |
| assert!(result_str.contains("let r15"), "missing r15 binding"); | |
| } | |
| #[test] | |
| fn preserves_mut() { | |
| let params: Vec<proc_macro2::TokenStream> = (0..16usize) | |
| .map(|i| { | |
| let name = format_ident!("r{}", i); | |
| let ty = format_ident!("R{}", i); | |
| if i == 0 { | |
| quote!(mut #name: ResMut<#ty>) | |
| } else { | |
| quote!(#name: Res<#ty>) | |
| } | |
| }) | |
| .collect(); | |
| let input: syn::ItemFn = syn::parse2(quote! { | |
| fn big(#(#params),*) {} | |
| }) | |
| .unwrap(); | |
| let result = expand(&input).unwrap(); | |
| let result_str = result.to_string(); | |
| assert!(result_str.contains("let mut r0"), "r0 should be mut"); | |
| assert!(result_str.contains("let r1"), "r1 should not be mut"); | |
| } | |
| #[test] | |
| fn query_gets_static_refs() { | |
| let params: Vec<proc_macro2::TokenStream> = (0..16usize) | |
| .map(|i| { | |
| let name = format_ident!("q{}", i); | |
| if i == 0 { | |
| quote!(#name: Query<(Entity, &Foo), With<Bar>>) | |
| } else { | |
| let ty = format_ident!("T{}", i); | |
| quote!(#name: Res<#ty>) | |
| } | |
| }) | |
| .collect(); | |
| let input: syn::ItemFn = syn::parse2(quote! { | |
| fn big(#(#params),*) {} | |
| }) | |
| .unwrap(); | |
| let result = expand(&input).unwrap(); | |
| let result_str = result.to_string(); | |
| // The &Foo should become &'static Foo | |
| assert!( | |
| result_str.contains("'static"), | |
| "query refs should be staticified: {result_str}" | |
| ); | |
| } | |
| #[test] | |
| fn unknown_type_errors() { | |
| let params: Vec<proc_macro2::TokenStream> = (0..16usize) | |
| .map(|i| { | |
| let name = format_ident!("x{}", i); | |
| if i == 0 { | |
| quote!(#name: WeirdCustomType) | |
| } else { | |
| let ty = format_ident!("T{}", i); | |
| quote!(#name: Res<#ty>) | |
| } | |
| }) | |
| .collect(); | |
| let input: syn::ItemFn = syn::parse2(quote! { | |
| fn big(#(#params),*) {} | |
| }) | |
| .unwrap(); | |
| let result = expand(&input); | |
| assert!(result.is_err(), "should error on unknown type"); | |
| let err_msg = result.unwrap_err().to_string(); | |
| assert!( | |
| err_msg.contains("unrecognised"), | |
| "error should mention 'unrecognised': {err_msg}" | |
| ); | |
| } | |
| #[test] | |
| fn chunk_with_only_res_omits_s_lifetime() { | |
| // 16 Res<T> params → chunk 1 has only Res, should use <'w> not <'w, 's> | |
| let params: Vec<proc_macro2::TokenStream> = (0..16usize) | |
| .map(|i| { | |
| let name = format_ident!("r{}", i); | |
| let ty = format_ident!("R{}", i); | |
| quote!(#name: Res<#ty>) | |
| }) | |
| .collect(); | |
| let input: syn::ItemFn = syn::parse2(quote! { | |
| fn big(#(#params),*) {} | |
| }) | |
| .unwrap(); | |
| let result = expand(&input).unwrap(); | |
| let result_str = result.to_string(); | |
| // BigP1 has a single Res field → only needs 'w | |
| // (BigP0 also only has Res fields → only 'w) | |
| // Neither struct should contain 's | |
| // Actually, let's just verify the macro produces valid output | |
| assert!(result_str.contains("BigP0"), "should have BigP0"); | |
| assert!(result_str.contains("BigP1"), "should have BigP1"); | |
| } | |
| /// Helper: generate `count` padding params like `pad0: Res<Pad0>`, `pad1: Res<Pad1>`, … | |
| fn pad_params(count: usize) -> Vec<proc_macro2::TokenStream> { | |
| (0..count) | |
| .map(|i| { | |
| let name = format_ident!("pad{}", i); | |
| let ty = format_ident!("Pad{}", i); | |
| quote!(#name: Res<#ty>) | |
| }) | |
| .collect() | |
| } | |
| #[test] | |
| fn complex_query_filters() { | |
| // Query with multi-component data tuple, compound filter tuple, | |
| // &T and &mut T refs that must become &'static T / &'static mut T, | |
| // while With<X> and Without<Y> remain untouched. | |
| let mut params = vec![ | |
| quote!(q1: Query<(Entity, &Transform, &mut Velocity), (With<Monster>, Without<Dead>)>), | |
| ]; | |
| params.extend(pad_params(15)); | |
| let input: syn::ItemFn = syn::parse2(quote! { | |
| fn system_with_filters(#(#params),*) {} | |
| }) | |
| .unwrap(); | |
| let result = expand(&input).unwrap(); | |
| let s = result.to_string(); | |
| // &Transform → &'static Transform | |
| assert!( | |
| s.contains("& 'static Transform"), | |
| "should staticify &Transform: {s}" | |
| ); | |
| // &mut Velocity → &'static mut Velocity | |
| assert!( | |
| s.contains("& 'static mut Velocity"), | |
| "should staticify &mut Velocity: {s}" | |
| ); | |
| // Filter types are path types, not references — should pass through unchanged | |
| assert!(s.contains("With < Monster >") || s.contains("With<Monster>"), | |
| "With<Monster> should be preserved: {s}"); | |
| assert!(s.contains("Without < Dead >") || s.contains("Without<Dead>"), | |
| "Without<Dead> should be preserved: {s}"); | |
| } | |
| #[test] | |
| fn query_with_option_components() { | |
| let mut params = vec![ | |
| quote!(q: Query<(Entity, Option<&Health>, &Transform)>), | |
| ]; | |
| params.extend(pad_params(15)); | |
| let input: syn::ItemFn = syn::parse2(quote! { | |
| fn sys_option(#(#params),*) {} | |
| }) | |
| .unwrap(); | |
| let result = expand(&input).unwrap(); | |
| let s = result.to_string(); | |
| // &Health inside Option should become &'static Health | |
| assert!( | |
| s.contains("'static"), | |
| "Option<&Health> inner ref should be staticified: {s}" | |
| ); | |
| // Option itself should still be present | |
| assert!( | |
| s.contains("Option"), | |
| "Option wrapper should be preserved: {s}" | |
| ); | |
| } | |
| #[test] | |
| fn query_with_changed_filter() { | |
| let mut params = vec![ | |
| quote!(q: Query<&Transform, Changed<Transform>>), | |
| ]; | |
| params.extend(pad_params(15)); | |
| let input: syn::ItemFn = syn::parse2(quote! { | |
| fn sys_changed(#(#params),*) {} | |
| }) | |
| .unwrap(); | |
| let result = expand(&input).unwrap(); | |
| let s = result.to_string(); | |
| // &Transform in data position → &'static Transform | |
| assert!( | |
| s.contains("& 'static Transform"), | |
| "data &Transform should be staticified: {s}" | |
| ); | |
| // Changed<Transform> is a path type, not a reference — preserved | |
| assert!( | |
| s.contains("Changed < Transform >") || s.contains("Changed<Transform>"), | |
| "Changed<Transform> filter should be preserved: {s}" | |
| ); | |
| } | |
| #[test] | |
| fn query_with_nested_tuples() { | |
| // Nested tuple in query data position: (Entity, (&Transform, &Velocity)) | |
| let mut params = vec![ | |
| quote!(q: Query<(Entity, (&Transform, &Velocity)), With<Player>>), | |
| ]; | |
| params.extend(pad_params(15)); | |
| let input: syn::ItemFn = syn::parse2(quote! { | |
| fn sys_nested(#(#params),*) {} | |
| }) | |
| .unwrap(); | |
| let result = expand(&input).unwrap(); | |
| let s = result.to_string(); | |
| // Both inner refs should get 'static | |
| // Count occurrences of 'static — should be at least 2 | |
| let static_count = s.matches("'static").count(); | |
| assert!( | |
| static_count >= 2, | |
| "both nested refs should be staticified (found {static_count}): {s}" | |
| ); | |
| } | |
| #[test] | |
| fn triple_split_33_params() { | |
| // 33 params → ceil(33/15) = 3 structs: P0(15), P1(15), P2(3) | |
| let params = pad_params(33); | |
| let input: syn::ItemFn = syn::parse2(quote! { | |
| fn huge(#(#params),*) {} | |
| }) | |
| .unwrap(); | |
| let result = expand(&input).unwrap(); | |
| let s = result.to_string(); | |
| assert!(s.contains("HugeP0"), "should have HugeP0: {s}"); | |
| assert!(s.contains("HugeP1"), "should have HugeP1: {s}"); | |
| assert!(s.contains("HugeP2"), "should have HugeP2: {s}"); | |
| assert!(!s.contains("HugeP3"), "should NOT have HugeP3: {s}"); | |
| // All 33 let bindings present | |
| assert!(s.contains("let pad0"), "missing pad0 binding"); | |
| assert!(s.contains("let pad32"), "missing pad32 binding"); | |
| } | |
| #[test] | |
| fn param_set_with_queries() { | |
| let mut params = vec![ | |
| quote!(param_set: ParamSet<(Query<&mut Transform, With<Player>>, Query<&Transform, With<Enemy>>)>), | |
| ]; | |
| params.extend(pad_params(15)); | |
| let input: syn::ItemFn = syn::parse2(quote! { | |
| fn sys_paramset(#(#params),*) {} | |
| }) | |
| .unwrap(); | |
| let result = expand(&input).unwrap(); | |
| let s = result.to_string(); | |
| // ParamSet should get 'w, 's prepended | |
| assert!( | |
| s.contains("ParamSet < 'w , 's"), | |
| "ParamSet should have 'w, 's lifetimes: {s}" | |
| ); | |
| // The inner query types are opaque to our macro (they're inside | |
| // the ParamSet's angle brackets as type args) — we don't rewrite | |
| // them. ParamSet's derive handles inner queries internally. | |
| assert!(s.contains("ParamSet"), "ParamSet should be present: {s}"); | |
| } | |
| #[test] | |
| fn mixed_event_reader_writer() { | |
| let mut params = vec![ | |
| quote!(reader: EventReader<DamageEvent>), | |
| quote!(writer: EventWriter<DeathEvent>), | |
| ]; | |
| params.extend(pad_params(14)); | |
| let input: syn::ItemFn = syn::parse2(quote! { | |
| fn sys_events(#(#params),*) {} | |
| }) | |
| .unwrap(); | |
| let result = expand(&input).unwrap(); | |
| let s = result.to_string(); | |
| // EventReader<T> → EventReader<'w, 's, T> | |
| assert!( | |
| s.contains("EventReader < 'w , 's , DamageEvent"), | |
| "EventReader should have 'w, 's: {s}" | |
| ); | |
| // EventWriter<T> → EventWriter<'w, T> | |
| assert!( | |
| s.contains("EventWriter < 'w , DeathEvent"), | |
| "EventWriter should have 'w: {s}" | |
| ); | |
| // EventWriter should NOT get 's | |
| // Find the EventWriter substring and check it doesn't have 's before the type | |
| let ew_idx = s.find("EventWriter").unwrap(); | |
| let ew_chunk: String = s[ew_idx..].chars().take(60).collect(); | |
| assert!( | |
| !ew_chunk.contains("'s"), | |
| "EventWriter should not have 's lifetime: {ew_chunk}" | |
| ); | |
| } | |
| #[test] | |
| fn commands_gets_both_lifetimes() { | |
| let mut params = vec![ | |
| quote!(mut commands: Commands), | |
| ]; | |
| params.extend(pad_params(15)); | |
| let input: syn::ItemFn = syn::parse2(quote! { | |
| fn sys_commands(#(#params),*) {} | |
| }) | |
| .unwrap(); | |
| let result = expand(&input).unwrap(); | |
| let s = result.to_string(); | |
| assert!( | |
| s.contains("Commands < 'w , 's >"), | |
| "Commands should have 'w, 's: {s}" | |
| ); | |
| assert!( | |
| s.contains("let mut commands"), | |
| "commands should be mut: {s}" | |
| ); | |
| } | |
| #[test] | |
| fn local_gets_s_lifetime_only() { | |
| let mut params = vec![ | |
| quote!(local: Local<MyState>), | |
| ]; | |
| params.extend(pad_params(15)); | |
| let input: syn::ItemFn = syn::parse2(quote! { | |
| fn sys_local(#(#params),*) {} | |
| }) | |
| .unwrap(); | |
| let result = expand(&input).unwrap(); | |
| let s = result.to_string(); | |
| assert!( | |
| s.contains("Local < 's , MyState"), | |
| "Local should have 's: {s}" | |
| ); | |
| // The struct containing only Local + Res params: | |
| // Local needs 's, Res needs 'w, so the struct should have both. | |
| // (They're in the same chunk P0) | |
| } | |
| #[test] | |
| fn query_mut_ref_in_tuple() { | |
| // &mut T in a query data tuple | |
| let mut params = vec![ | |
| quote!(q: Query<(Entity, &mut Transform, &Velocity)>), | |
| ]; | |
| params.extend(pad_params(15)); | |
| let input: syn::ItemFn = syn::parse2(quote! { | |
| fn sys_mut_query(#(#params),*) {} | |
| }) | |
| .unwrap(); | |
| let result = expand(&input).unwrap(); | |
| let s = result.to_string(); | |
| assert!( | |
| s.contains("& 'static mut Transform"), | |
| "&mut Transform should become &'static mut Transform: {s}" | |
| ); | |
| assert!( | |
| s.contains("& 'static Velocity"), | |
| "&Velocity should become &'static Velocity: {s}" | |
| ); | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment