Skip to content

Instantly share code, notes, and snippets.

@mrkybe
Created February 21, 2026 00:55
Show Gist options
  • Select an option

  • Save mrkybe/c044ade5d923b71936f9e6c493997d10 to your computer and use it in GitHub Desktop.

Select an option

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.
//! # 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