use proc_macro2::TokenStream;
use quote::quote;
use syn::{ItemFn, parse2};
fn gen_for_each_macro(
macro_name: &proc_macro2::Ident,
meta_idents: &[syn::Ident],
native_only: bool,
) -> TokenStream {
let gate = if native_only {
quote! { #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] }
} else {
quote! {}
};
quote! {
#gate
macro_rules! #macro_name {
($callback:ident, $app:ident, $($base:tt)+) => {
#(
$($base)+ :: #meta_idents ! ($callback, $app);
)*
};
}
#gate
pub(crate) use #macro_name;
}
}
fn flatten_body(func: &ItemFn) -> Vec<proc_macro2::TokenTree> {
fn recurse(
tokens: impl IntoIterator<Item = proc_macro2::TokenTree>,
) -> Vec<proc_macro2::TokenTree> {
let mut result = Vec::new();
for tt in tokens {
match &tt {
proc_macro2::TokenTree::Group(group) => {
result.push(tt.clone());
result.extend(recurse(group.stream()));
}
_ => result.push(tt),
}
}
result
}
func.block
.stmts
.iter()
.flat_map(|stmt| {
let tokens: TokenStream = quote! { #stmt };
recurse(tokens)
})
.collect()
}
fn extract_endpoint_paths(body_tokens: &[proc_macro2::TokenTree]) -> Vec<TokenStream> {
let mut paths = Vec::new();
let mut i = 0;
while i < body_tokens.len() {
if i + 2 < body_tokens.len()
&& let proc_macro2::TokenTree::Punct(p) = &body_tokens[i]
&& p.as_char() == '.'
&& let proc_macro2::TokenTree::Ident(ident) = &body_tokens[i + 1]
&& ident == "endpoint"
&& let proc_macro2::TokenTree::Group(group) = &body_tokens[i + 2]
&& group.delimiter() == proc_macro2::Delimiter::Parenthesis
{
paths.push(group.stream());
i += 3;
continue;
}
i += 1;
}
paths
}
fn extract_consumer_paths(body_tokens: &[proc_macro2::TokenTree]) -> Vec<TokenStream> {
let mut paths = Vec::new();
let mut i = 0;
while i < body_tokens.len() {
if i + 2 < body_tokens.len()
&& let proc_macro2::TokenTree::Punct(p) = &body_tokens[i]
&& p.as_char() == '.'
&& let proc_macro2::TokenTree::Ident(ident) = &body_tokens[i + 1]
&& ident == "consumer"
&& let proc_macro2::TokenTree::Group(group) = &body_tokens[i + 2]
&& group.delimiter() == proc_macro2::Delimiter::Parenthesis
{
paths.push(group.stream());
i += 3;
continue;
}
i += 1;
}
paths
}
struct ChainCall {
expr_path: TokenStream,
}
fn extract_chain_calls(
body_tokens: &[proc_macro2::TokenTree],
method_name: &str,
) -> Vec<ChainCall> {
let mut calls = Vec::new();
let mut i = 0;
while i < body_tokens.len() {
if i + 2 < body_tokens.len()
&& let proc_macro2::TokenTree::Punct(p) = &body_tokens[i]
&& p.as_char() == '.'
&& let proc_macro2::TokenTree::Ident(ident) = &body_tokens[i + 1]
&& ident == method_name
&& let proc_macro2::TokenTree::Group(group) = &body_tokens[i + 2]
&& group.delimiter() == proc_macro2::Delimiter::Parenthesis
{
let mut found_comma = false;
let mut expr_tokens = Vec::new();
for tt in group.stream() {
if found_comma {
if let proc_macro2::TokenTree::Group(g) = &tt
&& g.delimiter() == proc_macro2::Delimiter::Parenthesis
{
continue;
}
expr_tokens.push(tt);
} else if let proc_macro2::TokenTree::Punct(p) = &tt
&& p.as_char() == ','
{
found_comma = true;
}
}
if !expr_tokens.is_empty() {
let expr_path: TokenStream = expr_tokens.into_iter().collect();
calls.push(ChainCall { expr_path });
}
i += 3;
continue;
}
i += 1;
}
calls
}
pub(crate) struct ViewsetWithActionsCall {
pub factory: TokenStream,
pub marker: TokenStream,
}
fn extract_viewset_with_actions_calls(
body_tokens: &[proc_macro2::TokenTree],
) -> Vec<ViewsetWithActionsCall> {
let mut calls = Vec::new();
let mut i = 0;
while i < body_tokens.len() {
if i + 2 < body_tokens.len()
&& let proc_macro2::TokenTree::Punct(p) = &body_tokens[i]
&& p.as_char() == '.'
&& let proc_macro2::TokenTree::Ident(ident) = &body_tokens[i + 1]
&& ident == "viewset_with_actions"
&& let proc_macro2::TokenTree::Group(group) = &body_tokens[i + 2]
&& group.delimiter() == proc_macro2::Delimiter::Parenthesis
{
if let Some(call) = parse_viewset_with_actions_args(group.stream()) {
calls.push(call);
}
i += 3;
continue;
}
i += 1;
}
calls
}
fn parse_viewset_with_actions_args(args: TokenStream) -> Option<ViewsetWithActionsCall> {
let mut regions: Vec<Vec<proc_macro2::TokenTree>> = vec![Vec::new()];
for tt in args {
if let proc_macro2::TokenTree::Punct(ref p) = tt
&& p.as_char() == ','
&& regions.len() < 3
{
regions.push(Vec::new());
continue;
}
regions.last_mut().unwrap().push(tt);
}
if regions.len() < 3 {
return None;
}
let mut factory_tokens = Vec::new();
for tt in ®ions[1] {
if let proc_macro2::TokenTree::Group(g) = tt
&& g.delimiter() == proc_macro2::Delimiter::Parenthesis
{
continue; }
factory_tokens.push(tt.clone());
}
let marker_tokens: Vec<proc_macro2::TokenTree> = extract_phantom_inner(®ions[2])?;
Some(ViewsetWithActionsCall {
factory: factory_tokens.into_iter().collect(),
marker: marker_tokens.into_iter().collect(),
})
}
fn extract_phantom_inner(tokens: &[proc_macro2::TokenTree]) -> Option<Vec<proc_macro2::TokenTree>> {
let lt_idx = tokens.iter().position(|t| {
matches!(t,
proc_macro2::TokenTree::Punct(p) if p.as_char() == '<')
})?;
let gt_idx = tokens.iter().rposition(|t| {
matches!(t,
proc_macro2::TokenTree::Punct(p) if p.as_char() == '>')
})?;
if gt_idx <= lt_idx + 1 {
return None;
}
Some(tokens[lt_idx + 1..gt_idx].to_vec())
}
fn build_resolver_reexport(path: &TokenStream) -> TokenStream {
let parsed: syn::Path = match syn::parse2(path.clone()) {
Ok(p) => p,
Err(_) => return quote! {},
};
if parsed.segments.is_empty() {
return quote! {};
}
let last_segment = &parsed.segments.last().unwrap().ident;
let resolver_mod = syn::Ident::new(
&format!("__url_resolver_{last_segment}"),
last_segment.span(),
);
let first_segment = parsed.segments.first().unwrap().ident.to_string();
let is_absolute =
first_segment == "crate" || first_segment == "super" || parsed.leading_colon.is_some();
let parent_segments: Vec<&syn::Ident> = parsed
.segments
.iter()
.take(parsed.segments.len() - 1)
.map(|s| &s.ident)
.collect();
if is_absolute {
quote! {
pub use #(#parent_segments ::)* #resolver_mod::*;
}
} else {
quote! {
pub use super:: #(#parent_segments ::)* #resolver_mod::*;
}
}
}
fn build_meta_ident(path: &TokenStream) -> Option<syn::Ident> {
let parsed: syn::Path = syn::parse2(path.clone()).ok()?;
let last_segment = &parsed.segments.last()?.ident;
Some(syn::Ident::new(
&format!("__url_resolver_meta_{last_segment}"),
last_segment.span(),
))
}
fn build_viewset_reexport(call: &ChainCall) -> TokenStream {
let parsed: syn::Path = match syn::parse2(call.expr_path.clone()) {
Ok(p) => p,
Err(_) => return quote! {},
};
if parsed.segments.is_empty() {
return quote! {};
}
let last_segment = &parsed.segments.last().unwrap().ident;
let bundle_mod = syn::Ident::new(
&format!("__viewset_resolvers_{last_segment}"),
last_segment.span(),
);
let module_segments: Vec<&syn::Ident> = parsed
.segments
.iter()
.take(parsed.segments.len() - 1)
.map(|s| &s.ident)
.collect();
let first_segment = parsed.segments.first().unwrap().ident.to_string();
let is_absolute = first_segment == "crate" || first_segment == "super";
if is_absolute {
if module_segments.is_empty() {
quote! { pub use #bundle_mod::*; }
} else {
quote! { pub use #(#module_segments ::)* #bundle_mod::*; }
}
} else if module_segments.is_empty() {
quote! { pub use super::#bundle_mod::*; }
} else {
quote! { pub use super:: #(#module_segments ::)* #bundle_mod::*; }
}
}
fn build_mount_reexport(call: &ChainCall) -> TokenStream {
let parsed: syn::Path = match syn::parse2(call.expr_path.clone()) {
Ok(p) => p,
Err(_) => return quote! {},
};
if parsed.segments.is_empty() {
return quote! {};
}
let module_segments: Vec<&syn::Ident> = parsed
.segments
.iter()
.take(parsed.segments.len() - 1)
.map(|s| &s.ident)
.collect();
if module_segments.is_empty() {
return quote! {};
}
let first_segment = parsed.segments.first().unwrap().ident.to_string();
let is_absolute = first_segment == "crate" || first_segment == "super";
if is_absolute {
quote! {
pub use #(#module_segments ::)* url_resolvers::*;
}
} else {
quote! {
pub use super:: #(#module_segments ::)* url_resolvers::*;
}
}
}
struct ClientNamedRoute {
name: String,
pattern: String,
typed_params: Option<Vec<(syn::Ident, syn::Type)>>,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum NamedRouteVariant {
None,
Path,
Untyped,
}
fn classify_named_route(ident: &proc_macro2::Ident) -> Option<NamedRouteVariant> {
if ident == "named_route" {
Some(NamedRouteVariant::None)
} else if ident == "named_route_path"
|| ident == "named_route_path2"
|| ident == "named_route_path3"
{
Some(NamedRouteVariant::Path)
} else if ident == "named_route_params" || ident == "named_route_result" {
Some(NamedRouteVariant::Untyped)
} else {
None
}
}
fn extract_named_route_calls(body_tokens: &[proc_macro2::TokenTree]) -> Vec<ClientNamedRoute> {
let mut routes = Vec::new();
let mut i = 0;
while i < body_tokens.len() {
if i + 2 < body_tokens.len()
&& let proc_macro2::TokenTree::Punct(p) = &body_tokens[i]
&& p.as_char() == '.'
&& let proc_macro2::TokenTree::Ident(ident) = &body_tokens[i + 1]
&& let Some(variant) = classify_named_route(ident)
&& let proc_macro2::TokenTree::Group(group) = &body_tokens[i + 2]
&& group.delimiter() == proc_macro2::Delimiter::Parenthesis
{
if let Some(route) = parse_named_route_args(group.stream(), variant) {
routes.push(route);
}
i += 3;
continue;
}
i += 1;
}
routes
}
fn parse_named_route_args(
stream: proc_macro2::TokenStream,
variant: NamedRouteVariant,
) -> Option<ClientNamedRoute> {
let parser = syn::punctuated::Punctuated::<syn::Expr, syn::Token![,]>::parse_terminated;
if let Ok(exprs) = syn::parse::Parser::parse2(parser, stream.clone()) {
let mut iter = exprs.iter();
let name = lit_str_value(iter.next()?)?;
let pattern = lit_str_value(iter.next()?)?;
let typed_params = match variant {
NamedRouteVariant::Path => match iter.next() {
Some(syn::Expr::Closure(closure)) => parse_closure_typed_params(closure),
_ => None,
},
NamedRouteVariant::None => Some(Vec::new()),
NamedRouteVariant::Untyped => None,
};
return Some(ClientNamedRoute {
name,
pattern,
typed_params,
});
}
let mut literals = Vec::new();
for tt in stream {
if let proc_macro2::TokenTree::Literal(lit) = tt {
let lit_str = lit.to_string();
if lit_str.starts_with('"') && lit_str.ends_with('"') && lit_str.len() >= 2 {
literals.push(lit_str[1..lit_str.len() - 1].to_string());
if literals.len() == 2 {
break;
}
}
}
}
if literals.len() >= 2 {
Some(ClientNamedRoute {
name: literals[0].clone(),
pattern: literals[1].clone(),
typed_params: None,
})
} else {
None
}
}
fn lit_str_value(expr: &syn::Expr) -> Option<String> {
match expr {
syn::Expr::Lit(syn::ExprLit {
lit: syn::Lit::Str(s),
..
}) => Some(s.value()),
_ => None,
}
}
fn parse_closure_typed_params(closure: &syn::ExprClosure) -> Option<Vec<(syn::Ident, syn::Type)>> {
let mut params = Vec::with_capacity(closure.inputs.len());
for input in &closure.inputs {
let pat_type = match input {
syn::Pat::Type(pt) => pt,
_ => return None,
};
let binding = match pat_type.pat.as_ref() {
syn::Pat::TupleStruct(ts) if ts.elems.len() == 1 => {
let last_seg = ts.path.segments.last()?;
if last_seg.ident != "ClientPath" {
return None;
}
match &ts.elems[0] {
syn::Pat::Ident(pi) => pi.ident.clone(),
_ => return None,
}
}
_ => return None,
};
let inner_ty = client_path_inner_type(pat_type.ty.as_ref())?;
params.push((binding, inner_ty));
}
Some(params)
}
fn client_path_inner_type(ty: &syn::Type) -> Option<syn::Type> {
let type_path = match ty {
syn::Type::Path(tp) => tp,
_ => return None,
};
let last = type_path.path.segments.last()?;
if last.ident != "ClientPath" {
return None;
}
let args = match &last.arguments {
syn::PathArguments::AngleBracketed(args) => args,
_ => return None,
};
if args.args.len() != 1 {
return None;
}
match &args.args[0] {
syn::GenericArgument::Type(t) => Some(t.clone()),
_ => None,
}
}
pub(crate) fn extract_url_params_pub(pattern: &str) -> Vec<String> {
extract_url_params(pattern)
}
fn extract_url_params(pattern: &str) -> Vec<String> {
let mut params = Vec::new();
let mut in_param = false;
let mut name = String::new();
for ch in pattern.chars() {
match ch {
'{' => {
in_param = true;
name.clear();
}
'}' => {
if in_param && !name.is_empty() {
if let Some(pos) = name.find(':') {
name.truncate(pos);
}
params.push(name.clone());
}
in_param = false;
}
_ if in_param => name.push(ch),
_ => {}
}
}
params
}
struct UrlPatternsArgs {
app_path: syn::ExprPath,
mode: UrlPatternsMode,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum UrlPatternsMode {
Server,
Client,
Unified,
Ws,
}
fn parse_url_patterns_args(args: TokenStream) -> syn::Result<UrlPatternsArgs> {
if args.is_empty() {
return Err(syn::Error::new(
proc_macro2::Span::call_site(),
"`#[url_patterns]` requires two arguments: an InstalledApp variant and a mode.\n\
\n Example: #[url_patterns(InstalledApp::auth, mode = server)]\n \
Valid modes: `server`, `client`, `unified`",
));
}
let parser = |input: syn::parse::ParseStream<'_>| -> syn::Result<UrlPatternsArgs> {
let app_path: syn::ExprPath = input.parse()?;
input.parse::<syn::Token![,]>()?;
let key: syn::Ident = input.parse()?;
if key != "mode" {
return Err(syn::Error::new(
key.span(),
"expected `mode = server|client|unified`",
));
}
input.parse::<syn::Token![=]>()?;
let mode_ident: syn::Ident = input.parse()?;
let mode = match mode_ident.to_string().as_str() {
"server" => UrlPatternsMode::Server,
"client" => UrlPatternsMode::Client,
"unified" => UrlPatternsMode::Unified,
"ws" => UrlPatternsMode::Ws,
other => {
return Err(syn::Error::new(
mode_ident.span(),
format!(
"unknown mode `{other}`, expected `server`, `client`, `unified`, or `ws`"
),
));
}
};
if input.peek(syn::Token![,]) {
input.parse::<syn::Token![,]>()?;
}
Ok(UrlPatternsArgs { app_path, mode })
};
syn::parse::Parser::parse2(parser, args)
}
fn split_enum_type_and_variant(app_path: &syn::ExprPath) -> syn::Result<(syn::Path, syn::Path)> {
if app_path.path.segments.len() < 2 {
return Err(syn::Error::new_spanned(
app_path,
"expected a path of the form `InstalledApp::variant` (at least two segments)",
));
}
let segments: syn::punctuated::Punctuated<_, syn::Token![::]> = app_path
.path
.segments
.iter()
.take(app_path.path.segments.len() - 1)
.cloned()
.collect();
let type_path = syn::Path {
leading_colon: app_path.path.leading_colon,
segments,
};
Ok((type_path, app_path.path.clone()))
}
fn build_viewset_meta_forwarder(factory: &TokenStream) -> TokenStream {
let Some(alias) = build_viewset_meta_alias_ident(factory) else {
return quote! {};
};
quote! { $($base)+ :: #alias!($callback, $app); }
}
fn build_viewset_meta_alias_ident(factory: &TokenStream) -> Option<syn::Ident> {
let parsed: syn::Path = syn::parse2(factory.clone()).ok()?;
if parsed.segments.is_empty() {
return None;
}
let alias_id: String = parsed
.segments
.iter()
.map(|s| s.ident.to_string())
.collect::<Vec<_>>()
.join("_");
let span = parsed.segments.last().unwrap().ident.span();
Some(syn::Ident::new(
&format!("__for_each_meta_{alias_id}"),
span,
))
}
fn build_viewset_meta_alias_reexport(factory: &TokenStream) -> TokenStream {
let Ok(parsed) = syn::parse2::<syn::Path>(factory.clone()) else {
return quote! {};
};
if parsed.segments.is_empty() {
return quote! {};
}
let Some(alias) = build_viewset_meta_alias_ident(factory) else {
return quote! {};
};
let fn_name = &parsed.segments.last().unwrap().ident;
let bundle_mod = syn::Ident::new(&format!("__viewset_resolvers_{fn_name}"), fn_name.span());
let module_segments: Vec<&syn::Ident> = parsed
.segments
.iter()
.take(parsed.segments.len() - 1)
.map(|s| &s.ident)
.collect();
let first_segment = parsed.segments.first().unwrap().ident.to_string();
let is_absolute = parsed.leading_colon.is_some()
|| first_segment == "crate"
|| first_segment == "super"
|| first_segment == "self";
if is_absolute {
if module_segments.is_empty() {
quote! { pub use #bundle_mod::__for_each_meta as #alias; }
} else {
quote! {
pub use #(#module_segments ::)* #bundle_mod::__for_each_meta as #alias;
}
}
} else if module_segments.is_empty() {
quote! { pub use super::#bundle_mod::__for_each_meta as #alias; }
} else {
quote! {
pub use super:: #(#module_segments ::)* #bundle_mod::__for_each_meta as #alias;
}
}
}
fn build_viewset_action_forwarder(marker: &TokenStream) -> TokenStream {
let parsed: syn::Type = match syn::parse2(marker.clone()) {
Ok(t) => t,
Err(_) => return quote! {},
};
let syn::Type::Path(tp) = &parsed else {
return quote! {};
};
let Some(last) = tp.path.segments.last() else {
return quote! {};
};
let snake = camel_to_snake_str(&last.ident.to_string());
let manifest = syn::Ident::new(
&format!("__for_each_viewset_action_meta_{snake}"),
last.ident.span(),
);
quote! { $crate::#manifest!($callback, $app); }
}
fn camel_to_snake_str(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 4);
for (i, c) in s.chars().enumerate() {
if c.is_ascii_uppercase() && i != 0 {
out.push('_');
}
out.push(c.to_ascii_lowercase());
}
out
}
fn gen_for_each_macro_with_forwarders(
macro_name: &proc_macro2::Ident,
meta_idents: &[syn::Ident],
viewset_meta_forwarders: &[TokenStream],
viewset_action_forwarders: &[TokenStream],
native_only: bool,
) -> TokenStream {
let gate = if native_only {
quote! { #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] }
} else {
quote! {}
};
quote! {
#gate
macro_rules! #macro_name {
($callback:ident, $app:ident, $($base:tt)+) => {
#(
$($base)+ :: #meta_idents ! ($callback, $app);
)*
#(#viewset_meta_forwarders)*
#(#viewset_action_forwarders)*
};
}
#gate
pub(crate) use #macro_name;
}
}
fn build_server_resolvers(body_tokens: &[proc_macro2::TokenTree]) -> TokenStream {
let endpoint_paths = extract_endpoint_paths(body_tokens);
let viewset_calls = extract_chain_calls(body_tokens, "viewset");
let vw_actions_calls = extract_viewset_with_actions_calls(body_tokens);
let mount_calls = extract_chain_calls(body_tokens, "mount");
let endpoint_re_exports: Vec<_> = endpoint_paths.iter().map(build_resolver_reexport).collect();
let viewset_re_exports: Vec<_> = viewset_calls.iter().map(build_viewset_reexport).collect();
let vw_actions_re_exports: Vec<_> = vw_actions_calls
.iter()
.map(|c| {
build_viewset_reexport(&ChainCall {
expr_path: c.factory.clone(),
})
})
.collect();
let mount_re_exports: Vec<_> = mount_calls.iter().map(build_mount_reexport).collect();
let endpoint_meta_idents: Vec<syn::Ident> =
endpoint_paths.iter().filter_map(build_meta_ident).collect();
let viewset_meta_forwarders: Vec<TokenStream> = viewset_calls
.iter()
.map(|c| build_viewset_meta_forwarder(&c.expr_path))
.chain(
vw_actions_calls
.iter()
.map(|c| build_viewset_meta_forwarder(&c.factory)),
)
.collect();
let viewset_meta_alias_reexports: Vec<TokenStream> = viewset_calls
.iter()
.map(|c| build_viewset_meta_alias_reexport(&c.expr_path))
.chain(
vw_actions_calls
.iter()
.map(|c| build_viewset_meta_alias_reexport(&c.factory)),
)
.collect();
let viewset_action_forwarders: Vec<TokenStream> = vw_actions_calls
.iter()
.map(|c| build_viewset_action_forwarder(&c.marker))
.collect();
let for_each_resolver_macro = gen_for_each_macro_with_forwarders(
&syn::Ident::new("__for_each_url_resolver", proc_macro2::Span::call_site()),
&endpoint_meta_idents,
&viewset_meta_forwarders,
&viewset_action_forwarders,
true,
);
quote! {
#[doc(hidden)]
pub mod url_resolvers {
#(
#[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
#endpoint_re_exports
)*
#(
#[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
#viewset_re_exports
)*
#(
#[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
#vw_actions_re_exports
)*
#(
#[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
#viewset_meta_alias_reexports
)*
#(
#[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
#mount_re_exports
)*
#for_each_resolver_macro
}
}
}
fn build_client_resolvers(
body_tokens: &[proc_macro2::TokenTree],
app_path: &syn::ExprPath,
) -> syn::Result<TokenStream> {
let named_routes = extract_named_route_calls(body_tokens);
let mut meta_macro_defs: Vec<TokenStream> = Vec::new();
let mut meta_idents: Vec<syn::Ident> = Vec::new();
let mut typed_helper_defs: Vec<TokenStream> = Vec::new();
let (enum_type_path, variant_path) = split_enum_type_and_variant(app_path)?;
let apps_crate = crate::crate_paths::get_reinhardt_apps_crate();
let urls_crate = crate::crate_paths::get_reinhardt_urls_crate();
for route in &named_routes {
if syn::parse_str::<syn::Ident>(&route.name).is_err() {
return Err(syn::Error::new(
proc_macro2::Span::call_site(),
format!(
"Client route name `{}` is not a valid Rust identifier. \
Route names must be valid identifiers (no hyphens, dots, or leading digits).",
route.name
),
));
}
let method_ident = syn::Ident::new(&route.name, proc_macro2::Span::call_site());
let route_name_str = &route.name;
let meta_macro_ident = syn::Ident::new(
&format!("__client_url_resolver_meta_{}", route.name),
proc_macro2::Span::call_site(),
);
let url_param_names = extract_url_params(&route.pattern);
let url_param_strs: Vec<&str> = url_param_names.iter().map(|s| s.as_str()).collect();
let meta_def = if url_param_names.is_empty() {
quote! {
#[doc(hidden)]
macro_rules! #meta_macro_ident {
($callback:ident, $app:ident) => {
$callback!($app, #method_ident, #route_name_str, );
};
}
pub(crate) use #meta_macro_ident;
}
} else {
quote! {
#[doc(hidden)]
macro_rules! #meta_macro_ident {
($callback:ident, $app:ident) => {
$callback!($app, #method_ident, #route_name_str, #(#url_param_strs),* );
};
}
pub(crate) use #meta_macro_ident;
}
};
meta_macro_defs.push(meta_def);
meta_idents.push(meta_macro_ident);
let Some(typed_params) = route.typed_params.as_ref() else {
continue;
};
if typed_params.len() != url_param_names.len() {
continue;
}
let placeholder_positions: std::collections::HashMap<&str, usize> = url_param_names
.iter()
.enumerate()
.map(|(i, name)| (name.as_str(), i))
.collect();
let mut binding_to_placeholder: Vec<usize> = Vec::with_capacity(typed_params.len());
let mut name_mismatch = false;
for (binding, _) in typed_params {
let binding_str = binding.to_string();
let normalized = binding_str.strip_prefix('_').unwrap_or(&binding_str);
match placeholder_positions.get(normalized) {
Some(&idx) => binding_to_placeholder.push(idx),
None => {
name_mismatch = true;
break;
}
}
}
if name_mismatch {
continue;
}
let helper_ident = syn::Ident::new(&route.name, proc_macro2::Span::call_site());
let helper_args: Vec<TokenStream> = typed_params
.iter()
.map(|(name, ty)| quote! { #name: #ty })
.collect();
let owned_pushes: Vec<TokenStream> = typed_params
.iter()
.map(|(binding, _)| quote! { ::std::string::ToString::to_string(&#binding) })
.collect();
let key_strs: Vec<&str> = binding_to_placeholder
.iter()
.map(|&idx| url_param_names[idx].as_str())
.collect();
let helper_doc = format!(
"Resolve the `{route_name}` route registered with `#[url_patterns]`. \
Returns the URL the global `ClientUrlReverser` currently produces \
for `{route_name}` (the app namespace is prepended automatically).",
route_name = route.name,
);
typed_helper_defs.push(quote! {
#[doc = #helper_doc]
pub fn #helper_ident(#(#helper_args),*) -> ::std::string::String {
let __reverser = #urls_crate::routers::get_client_reverser()
.expect(
"client URL reverser is not registered. \
Register the client URL reverser globally \
(e.g. via `UnifiedRouter::register_globally()` or \
`register_client_reverser(...)`) before calling \
the typed `urls::*` helpers.",
);
let __namespaced = ::std::format!(
"{}:{}",
super::__reinhardt_url_patterns_app_namespace(),
#route_name_str,
);
let __owned: ::std::vec::Vec<::std::string::String> =
::std::vec![ #(#owned_pushes),* ];
let __params: ::std::vec::Vec<(&str, &str)> = __owned
.iter()
.enumerate()
.map(|(__i, __v)| {
let __keys: &[&str] = &[ #(#key_strs),* ];
(__keys[__i], __v.as_str())
})
.collect();
match __reverser.reverse(&__namespaced, &__params) {
::std::option::Option::Some(url) => url,
::std::option::Option::None => ::std::panic!(
"named client route `{}` is not registered with the global reverser",
__namespaced,
),
}
}
});
}
let for_each_client_resolver_macro = gen_for_each_macro(
&syn::Ident::new(
"__for_each_client_url_resolver",
proc_macro2::Span::call_site(),
),
&meta_idents,
false,
);
let urls_module_doc = "Typed URL helpers generated by `#[url_patterns]`.\n\n\
Each named route declared in this app appears here as a free \
function whose signature mirrors the path parameters and their \
declared types. The function resolves through the global \
`ClientUrlReverser`, so a route-pattern change in the surrounding \
`#[url_patterns]` body propagates without any call-site edits.\n\n\
Added by Issue #4644.";
let namespace_helper = quote! {
#[doc(hidden)]
fn __reinhardt_url_patterns_app_namespace() -> &'static str {
<#enum_type_path as #apps_crate::apps::AppLabel>::path(&#variant_path)
}
};
Ok(quote! {
#namespace_helper
#[doc(hidden)]
pub mod client_url_resolvers {
#(
#meta_macro_defs
)*
#for_each_client_resolver_macro
}
#[doc = #urls_module_doc]
pub mod urls {
#(#typed_helper_defs)*
}
})
}
pub(crate) fn url_patterns_impl(args: TokenStream, input: TokenStream) -> syn::Result<TokenStream> {
let parsed_args = parse_url_patterns_args(args)?;
match parsed_args.mode {
UrlPatternsMode::Server => url_patterns_server_impl(parsed_args, input),
UrlPatternsMode::Client => url_patterns_client_impl(parsed_args, input),
UrlPatternsMode::Unified => url_patterns_unified_impl(parsed_args, input),
UrlPatternsMode::Ws => url_patterns_ws_impl(parsed_args, input),
}
}
fn build_wrapper_and_assertion(
func: &ItemFn,
app_path: &syn::ExprPath,
) -> syn::Result<(TokenStream, TokenStream)> {
let (type_path, variant_path) = split_enum_type_and_variant(app_path)?;
let apps_crate = crate::crate_paths::get_reinhardt_apps_crate();
let fn_vis = &func.vis;
let fn_attrs = &func.attrs;
let fn_sig = &func.sig;
let fn_block = &func.block;
let wrapper = quote! {
#(#fn_attrs)*
#fn_vis #fn_sig {
let __router = (|| #fn_block)();
__router.with_namespace(
<#type_path as #apps_crate::apps::AppLabel>::path(&#variant_path)
)
}
};
let trait_assertion = quote! {
const _: fn() = || {
fn __assert_app_label<T: #apps_crate::apps::AppLabel>(_: T) {}
__assert_app_label(#variant_path);
};
};
Ok((wrapper, trait_assertion))
}
fn url_patterns_server_impl(
parsed_args: UrlPatternsArgs,
input: TokenStream,
) -> syn::Result<TokenStream> {
let func: ItemFn = parse2(input)?;
let body_tokens = flatten_body(&func);
let resolvers = build_server_resolvers(&body_tokens);
let (wrapper, trait_assertion) = build_wrapper_and_assertion(&func, &parsed_args.app_path)?;
Ok(quote! {
#wrapper
#trait_assertion
#resolvers
})
}
fn url_patterns_client_impl(
parsed_args: UrlPatternsArgs,
input: TokenStream,
) -> syn::Result<TokenStream> {
let func: ItemFn = parse2(input)?;
let body_tokens = flatten_body(&func);
let resolvers = build_client_resolvers(&body_tokens, &parsed_args.app_path)?;
let (wrapper, trait_assertion) = build_wrapper_and_assertion(&func, &parsed_args.app_path)?;
Ok(quote! {
#wrapper
#trait_assertion
#resolvers
})
}
fn build_ws_resolvers(body_tokens: &[proc_macro2::TokenTree]) -> TokenStream {
let consumer_paths = extract_consumer_paths(body_tokens);
let re_exports: Vec<_> = consumer_paths
.iter()
.map(|path| {
let parsed: syn::Path = match syn::parse2(path.clone()) {
Ok(p) => p,
Err(_) => {
return syn::Error::new_spanned(
path,
"`.consumer(...)` argument must be a path to a handler function",
)
.to_compile_error();
}
};
if parsed.segments.is_empty() {
return quote! {};
}
let last_segment = &parsed.segments.last().unwrap().ident;
let resolver_mod = syn::Ident::new(
&format!("__ws_url_resolver_{last_segment}"),
last_segment.span(),
);
let first_segment = parsed.segments.first().unwrap().ident.to_string();
let is_absolute = first_segment == "crate"
|| first_segment == "super"
|| parsed.leading_colon.is_some();
let parent_segments: Vec<&syn::Ident> = parsed
.segments
.iter()
.take(parsed.segments.len() - 1)
.map(|s| &s.ident)
.collect();
if is_absolute {
quote! { pub use #(#parent_segments ::)* #resolver_mod::*; }
} else {
quote! { pub use super:: #(#parent_segments ::)* #resolver_mod::*; }
}
})
.collect();
let meta_idents: Vec<syn::Ident> = consumer_paths
.iter()
.filter_map(|path| {
let parsed: syn::Path = syn::parse2(path.clone()).ok()?;
let last = &parsed.segments.last()?.ident;
Some(syn::Ident::new(
&format!("__ws_url_resolver_meta_{last}"),
last.span(),
))
})
.collect();
let for_each_macro = gen_for_each_macro(
&syn::Ident::new("__for_each_ws_url_resolver", proc_macro2::Span::call_site()),
&meta_idents,
true,
);
quote! {
#[doc(hidden)]
pub mod ws_url_resolvers {
#(
#[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
#re_exports
)*
#for_each_macro
}
}
}
fn url_patterns_ws_impl(
parsed_args: UrlPatternsArgs,
input: TokenStream,
) -> syn::Result<TokenStream> {
let func: ItemFn = parse2(input)?;
let body_tokens = flatten_body(&func);
let resolvers = build_ws_resolvers(&body_tokens);
let (wrapper, trait_assertion) = build_wrapper_and_assertion(&func, &parsed_args.app_path)?;
Ok(quote! {
#wrapper
#trait_assertion
#resolvers
})
}
fn url_patterns_unified_impl(
parsed_args: UrlPatternsArgs,
input: TokenStream,
) -> syn::Result<TokenStream> {
let func: ItemFn = parse2(input)?;
let body_tokens = flatten_body(&func);
let server_resolvers = build_server_resolvers(&body_tokens);
let client_resolvers = build_client_resolvers(&body_tokens, &parsed_args.app_path)?;
let (wrapper, trait_assertion) = build_wrapper_and_assertion(&func, &parsed_args.app_path)?;
Ok(quote! {
#wrapper
#trait_assertion
#server_resolvers
#client_resolvers
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_single_endpoint() {
let func: ItemFn = parse2(quote! {
pub fn url_patterns() -> ServerRouter {
ServerRouter::new()
.endpoint(views::login)
}
})
.unwrap();
let body_tokens = flatten_body(&func);
let paths = extract_endpoint_paths(&body_tokens);
assert_eq!(paths.len(), 1);
assert_eq!(paths[0].to_string(), "views :: login");
}
#[test]
fn extract_multiple_endpoints() {
let func: ItemFn = parse2(quote! {
pub fn url_patterns() -> ServerRouter {
ServerRouter::new()
.endpoint(views::login)
.endpoint(views::register)
.endpoint(views::profile)
}
})
.unwrap();
let body_tokens = flatten_body(&func);
let paths = extract_endpoint_paths(&body_tokens);
assert_eq!(paths.len(), 3);
}
#[test]
fn extract_no_endpoints() {
let func: ItemFn = parse2(quote! {
pub fn url_patterns() -> ServerRouter {
ServerRouter::new()
}
})
.unwrap();
let body_tokens = flatten_body(&func);
let paths = extract_endpoint_paths(&body_tokens);
assert_eq!(paths.len(), 0);
}
#[test]
fn extract_endpoints_mixed_with_other_calls() {
let func: ItemFn = parse2(quote! {
pub fn url_patterns() -> ServerRouter {
ServerRouter::new()
.with_middleware(auth_middleware)
.endpoint(views::login)
.endpoint(views::register)
}
})
.unwrap();
let body_tokens = flatten_body(&func);
let paths = extract_endpoint_paths(&body_tokens);
assert_eq!(paths.len(), 2);
}
#[test]
fn build_reexport_relative_path() {
let path: TokenStream = quote! { views::login };
let result = build_resolver_reexport(&path);
let expected = "pub use super :: views :: __url_resolver_login :: * ;";
assert_eq!(result.to_string(), expected);
}
#[test]
fn build_reexport_crate_path() {
let path: TokenStream = quote! { crate::views::login };
let result = build_resolver_reexport(&path);
let expected = "pub use crate :: views :: __url_resolver_login :: * ;";
assert_eq!(result.to_string(), expected);
}
#[test]
fn build_reexport_super_path() {
let path: TokenStream = quote! { super::views::login };
let result = build_resolver_reexport(&path);
let expected = "pub use super :: views :: __url_resolver_login :: * ;";
assert_eq!(result.to_string(), expected);
}
#[test]
fn build_reexport_deeply_nested_path() {
let path: TokenStream = quote! { api::v1::views::login };
let result = build_resolver_reexport(&path);
let expected = "pub use super :: api :: v1 :: views :: __url_resolver_login :: * ;";
assert_eq!(result.to_string(), expected);
}
#[test]
fn build_meta_ident_relative_path() {
let path: TokenStream = quote! { views::login };
let result = build_meta_ident(&path).unwrap();
assert_eq!(result.to_string(), "__url_resolver_meta_login");
}
#[test]
fn build_meta_ident_crate_path() {
let path: TokenStream = quote! { crate::views::login };
let result = build_meta_ident(&path).unwrap();
assert_eq!(result.to_string(), "__url_resolver_meta_login");
}
#[test]
fn build_meta_ident_deeply_nested() {
let path: TokenStream = quote! { api::v1::views::login };
let result = build_meta_ident(&path).unwrap();
assert_eq!(result.to_string(), "__url_resolver_meta_login");
}
fn normalize_ws(s: &str) -> String {
s.split_whitespace().collect::<Vec<_>>().join(" ")
}
#[test]
fn parse_server_mode_simple_path() {
let args = quote! { InstalledApp::auth, mode = server };
let parsed = parse_url_patterns_args(args).expect("should parse");
assert!(matches!(parsed.mode, UrlPatternsMode::Server));
assert_eq!(
parsed
.app_path
.path
.segments
.last()
.unwrap()
.ident
.to_string(),
"auth",
);
}
#[test]
fn parse_client_mode() {
let args = quote! { InstalledApp::auth, mode = client };
let parsed = parse_url_patterns_args(args).expect("should parse");
assert!(matches!(parsed.mode, UrlPatternsMode::Client));
}
#[test]
fn parse_unified_mode() {
let args = quote! { InstalledApp::auth, mode = unified };
let parsed = parse_url_patterns_args(args).expect("should parse");
assert!(matches!(parsed.mode, UrlPatternsMode::Unified));
}
#[test]
fn parse_crate_qualified_path() {
let args = quote! { crate::config::apps::InstalledApp::auth, mode = server };
let parsed = parse_url_patterns_args(args).expect("should parse");
let segments: Vec<String> = parsed
.app_path
.path
.segments
.iter()
.map(|s| s.ident.to_string())
.collect();
assert_eq!(
segments,
vec!["crate", "config", "apps", "InstalledApp", "auth"]
);
}
#[test]
fn parse_trailing_comma_after_mode() {
let args = quote! { InstalledApp::auth, mode = server, };
assert!(parse_url_patterns_args(args).is_ok());
}
#[test]
fn reject_missing_mode() {
let args = quote! { InstalledApp::auth };
assert!(parse_url_patterns_args(args).is_err());
}
#[test]
fn reject_unknown_mode() {
let args = quote! { InstalledApp::auth, mode = foo };
let err = parse_url_patterns_args(args)
.err()
.expect("should return an Err")
.to_string();
assert!(err.contains("server"));
assert!(err.contains("client"));
assert!(err.contains("unified"));
}
#[test]
fn reject_wrong_key() {
let args = quote! { InstalledApp::auth, kind = server };
assert!(parse_url_patterns_args(args).is_err());
}
#[test]
fn reject_mode_as_string_literal() {
let args = quote! { InstalledApp::auth, mode = "server" };
assert!(parse_url_patterns_args(args).is_err());
}
#[test]
fn reject_string_literal_first_arg() {
let args = quote! { "auth", mode = server };
assert!(parse_url_patterns_args(args).is_err());
}
#[test]
fn reject_empty_args() {
let args = quote! {};
assert!(parse_url_patterns_args(args).is_err());
}
#[test]
fn reject_integer_first_arg() {
let args = quote! { 42, mode = server };
assert!(parse_url_patterns_args(args).is_err());
}
#[test]
fn server_mode_generates_trait_assertion_and_with_namespace() {
let args = quote! { InstalledApp::users, mode = server };
let input = quote! {
pub fn server_url_patterns() -> ServerRouter {
ServerRouter::new().endpoint(views::login)
}
};
let out = url_patterns_impl(args, input).expect("should generate");
let out_s = normalize_ws(&out.to_string());
assert!(
out_s.contains("AppLabel"),
"generated code must reference AppLabel trait; got: {out_s}"
);
assert!(
out_s.contains("__assert_app_label"),
"generated code must contain the trait-bound assertion fn"
);
assert!(
out_s.contains("with_namespace"),
"generated code must call .with_namespace"
);
}
#[test]
fn server_mode_generates_url_resolvers_module_and_for_each_macro() {
let args = quote! { InstalledApp::users, mode = server };
let input = quote! {
pub fn server_url_patterns() -> ServerRouter {
ServerRouter::new()
.endpoint(views::login)
.endpoint(views::register)
}
};
let out = url_patterns_impl(args, input).unwrap();
let normalized = normalize_ws(&out.to_string());
assert!(
normalized.contains("pub mod url_resolvers"),
"server mode must emit url_resolvers module"
);
assert!(
normalized.contains("__for_each_url_resolver"),
"server mode must emit __for_each_url_resolver macro"
);
assert!(
normalized.contains("$ ($ base : tt) +"),
"__for_each_url_resolver must use $($base:tt)+ to allow :: extension"
);
assert!(
!normalized.contains("$ base : path"),
"__for_each_url_resolver must NOT use $base:path"
);
assert!(normalized.contains("__url_resolver_meta_login"));
assert!(normalized.contains("__url_resolver_meta_register"));
}
#[test]
fn server_mode_generated_code_is_valid_rust() {
let args = quote! { InstalledApp::users, mode = server };
let input = quote! {
pub fn server_url_patterns() -> ServerRouter {
ServerRouter::new().endpoint(views::login)
}
};
let out = url_patterns_impl(args, input).unwrap();
let wrapped = quote! {
mod __test_wrapper {
#out
}
};
let parsed: Result<syn::File, _> = syn::parse2(wrapped);
assert!(
parsed.is_ok(),
"generated code is not valid Rust: {}",
parsed.unwrap_err()
);
}
#[test]
fn client_mode_generates_client_url_resolvers_module() {
let args = quote! { InstalledApp::users, mode = client };
let input = quote! {
pub fn client_url_patterns() -> ClientRouter {
ClientRouter::new().named_route("login", "/login/", || {})
}
};
let out = url_patterns_impl(args, input).expect("should generate");
let out_s = normalize_ws(&out.to_string());
assert!(
out_s.contains("client_url_resolvers"),
"client mode must emit `client_url_resolvers` module"
);
assert!(
out_s.contains("__for_each_client_url_resolver"),
"client mode must emit __for_each_client_url_resolver macro"
);
assert!(
out_s.contains("AppLabel"),
"client mode must include AppLabel trait assertion"
);
}
#[test]
fn unified_mode_generates_both_resolver_modules() {
let args = quote! { InstalledApp::users, mode = unified };
let input = quote! {
pub fn unified_url_patterns() -> UnifiedRouter {
UnifiedRouter::new()
.server(|s| s.endpoint(views::login))
.client(|c| c.named_route("login_page", "/login/", || {}))
}
};
let out = url_patterns_impl(args, input).expect("should generate");
let out_s = normalize_ws(&out.to_string());
assert!(
out_s.contains("pub mod url_resolvers"),
"unified mode must emit url_resolvers module"
);
assert!(
out_s.contains("pub mod client_url_resolvers"),
"unified mode must emit client_url_resolvers module"
);
assert!(out_s.contains("__for_each_url_resolver"));
assert!(out_s.contains("__for_each_client_url_resolver"));
assert!(out_s.contains("AppLabel"));
assert!(out_s.contains("with_namespace"));
}
#[test]
fn extract_viewset_single() {
let func: ItemFn = parse2(quote! {
pub fn url_patterns() -> ServerRouter {
ServerRouter::new()
.viewset("/snippets", views::viewset())
}
})
.unwrap();
let body_tokens = flatten_body(&func);
let calls = extract_chain_calls(&body_tokens, "viewset");
assert_eq!(calls.len(), 1);
let path_str = calls[0].expr_path.to_string();
assert!(path_str.contains("views"));
assert!(path_str.contains("viewset"));
assert!(!path_str.ends_with("()"));
}
#[test]
fn extract_viewset_none() {
let func: ItemFn = parse2(quote! {
pub fn url_patterns() -> ServerRouter {
ServerRouter::new()
.endpoint(views::hello)
}
})
.unwrap();
let body_tokens = flatten_body(&func);
let calls = extract_chain_calls(&body_tokens, "viewset");
assert_eq!(calls.len(), 0);
}
#[test]
fn extract_viewset_multiple() {
let func: ItemFn = parse2(quote! {
pub fn url_patterns() -> ServerRouter {
ServerRouter::new()
.viewset("/users", views::user_viewset())
.viewset("/posts", views::post_viewset())
}
})
.unwrap();
let body_tokens = flatten_body(&func);
let calls = extract_chain_calls(&body_tokens, "viewset");
assert_eq!(calls.len(), 2);
}
#[test]
fn extract_mount_single() {
let func: ItemFn = parse2(quote! {
pub fn url_patterns() -> ServerRouter {
ServerRouter::new()
.mount("/api/", crate::apps::api::urls::url_patterns())
}
})
.unwrap();
let body_tokens = flatten_body(&func);
let calls = extract_chain_calls(&body_tokens, "mount");
assert_eq!(calls.len(), 1);
let path_str = calls[0].expr_path.to_string();
assert!(path_str.contains("apps"));
assert!(path_str.contains("url_patterns"));
}
#[test]
fn extract_mount_none() {
let func: ItemFn = parse2(quote! {
pub fn url_patterns() -> ServerRouter {
ServerRouter::new()
.endpoint(views::hello)
}
})
.unwrap();
let body_tokens = flatten_body(&func);
let calls = extract_chain_calls(&body_tokens, "mount");
assert_eq!(calls.len(), 0);
}
#[test]
fn build_viewset_reexport_relative() {
let call = ChainCall {
expr_path: quote! { views::viewset },
};
let result = build_viewset_reexport(&call);
let output = result.to_string();
assert!(output.contains("__viewset_resolvers_viewset"));
assert!(output.contains("super :: views"));
}
#[test]
fn build_viewset_reexport_crate_path() {
let call = ChainCall {
expr_path: quote! { crate::apps::snippets::views::viewset },
};
let result = build_viewset_reexport(&call);
let output = result.to_string();
assert!(
output.contains("crate :: apps :: snippets :: views :: __viewset_resolvers_viewset")
);
}
#[test]
fn build_mount_reexport_crate_path() {
let call = ChainCall {
expr_path: quote! { crate::apps::api::urls::url_patterns },
};
let result = build_mount_reexport(&call);
let expected = "pub use crate :: apps :: api :: urls :: url_resolvers :: * ;";
assert_eq!(result.to_string(), expected);
}
#[test]
fn build_mount_reexport_relative_path() {
let call = ChainCall {
expr_path: quote! { api::urls::url_patterns },
};
let result = build_mount_reexport(&call);
let expected = "pub use super :: api :: urls :: url_resolvers :: * ;";
assert_eq!(result.to_string(), expected);
}
#[test]
fn extract_all_types_combined() {
let func: ItemFn = parse2(quote! {
pub fn url_patterns() -> ServerRouter {
ServerRouter::new()
.endpoint(views::hello_world)
.viewset("/snippets", views::viewset())
.mount("/api/", crate::apps::api::urls::url_patterns())
}
})
.unwrap();
let body_tokens = flatten_body(&func);
assert_eq!(extract_endpoint_paths(&body_tokens).len(), 1);
assert_eq!(extract_chain_calls(&body_tokens, "viewset").len(), 1);
assert_eq!(extract_chain_calls(&body_tokens, "mount").len(), 1);
}
#[test]
fn extract_endpoints_inside_if_else() {
let func: ItemFn = parse2(quote! {
pub fn url_patterns() -> ServerRouter {
if std::env::var("USE_VIEWSET").is_ok() {
ServerRouter::new()
.viewset("/snippets-viewset", views::viewset())
} else {
ServerRouter::new()
.endpoint(views::list)
.endpoint(views::create)
.endpoint(views::retrieve)
}
}
})
.unwrap();
let body_tokens = flatten_body(&func);
let endpoints = extract_endpoint_paths(&body_tokens);
assert_eq!(endpoints.len(), 3);
assert_eq!(endpoints[0].to_string(), "views :: list");
assert_eq!(endpoints[1].to_string(), "views :: create");
assert_eq!(endpoints[2].to_string(), "views :: retrieve");
let viewsets = extract_chain_calls(&body_tokens, "viewset");
assert_eq!(viewsets.len(), 1);
}
#[test]
fn extract_endpoints_inside_match() {
let func: ItemFn = parse2(quote! {
pub fn url_patterns() -> ServerRouter {
match mode {
Mode::Full => {
ServerRouter::new()
.endpoint(views::list)
.endpoint(views::create)
}
Mode::Readonly => {
ServerRouter::new()
.endpoint(views::list)
}
}
}
})
.unwrap();
let body_tokens = flatten_body(&func);
let endpoints = extract_endpoint_paths(&body_tokens);
assert_eq!(endpoints.len(), 3);
}
#[test]
fn parse_ws_mode() {
let args = quote! { InstalledApp::chat, mode = ws };
let parsed = parse_url_patterns_args(args).unwrap();
assert!(matches!(parsed.mode, UrlPatternsMode::Ws));
}
#[test]
fn extract_consumer_paths_basic() {
let func: ItemFn = parse2(quote! {
pub fn ws_url_patterns() -> WebSocketRouter {
WebSocketRouter::new()
.consumer(chat_ws)
.consumer(notif_ws)
}
})
.unwrap();
let body_tokens = flatten_body(&func);
let paths = extract_consumer_paths(&body_tokens);
assert_eq!(paths.len(), 2);
assert_eq!(paths[0].to_string(), "chat_ws");
assert_eq!(paths[1].to_string(), "notif_ws");
}
#[test]
fn ws_mode_generates_ws_url_resolvers_module() {
let args = quote! { InstalledApp::chat, mode = ws };
let input = quote! {
pub fn ws_url_patterns() -> WebSocketRouter {
WebSocketRouter::new()
.consumer(chat_ws)
.consumer(notif_ws)
}
};
let out = url_patterns_impl(args, input).unwrap();
let normalized = normalize_ws(&out.to_string());
assert!(
normalized.contains("pub mod ws_url_resolvers"),
"ws mode must emit ws_url_resolvers module"
);
assert!(
normalized.contains("__for_each_ws_url_resolver"),
"ws mode must emit __for_each_ws_url_resolver macro"
);
assert!(normalized.contains("__ws_url_resolver_meta_chat_ws"));
assert!(normalized.contains("__ws_url_resolver_meta_notif_ws"));
}
#[test]
fn reject_unknown_mode_now_includes_ws() {
let args = quote! { InstalledApp::auth, mode = foo };
let err = parse_url_patterns_args(args)
.err()
.expect("should return an Err")
.to_string();
assert!(err.contains("ws"), "error message should mention ws mode");
}
#[test]
fn extract_viewset_with_actions_basic() {
let func: ItemFn = parse2(quote! {
pub fn url_patterns() -> ServerRouter {
ServerRouter::new()
.viewset_with_actions(
"/snippets",
views::viewset(),
PhantomData::<views::SnippetViewSet>,
)
}
})
.unwrap();
let body_tokens = flatten_body(&func);
let calls = extract_viewset_with_actions_calls(&body_tokens);
assert_eq!(calls.len(), 1);
assert!(calls[0].factory.to_string().contains("views"));
assert!(calls[0].factory.to_string().contains("viewset"));
assert!(calls[0].marker.to_string().contains("SnippetViewSet"));
}
#[test]
fn extract_viewset_with_actions_none() {
let func: ItemFn = parse2(quote! {
pub fn url_patterns() -> ServerRouter {
ServerRouter::new().viewset("/snippets", views::viewset())
}
})
.unwrap();
let body_tokens = flatten_body(&func);
let calls = extract_viewset_with_actions_calls(&body_tokens);
assert_eq!(calls.len(), 0);
}
#[test]
fn server_mode_forwards_viewset_meta_and_action_manifest() {
let args = quote! { InstalledApp::snippets, mode = server };
let input = quote! {
pub fn url_patterns() -> ServerRouter {
ServerRouter::new().viewset_with_actions(
"/snippets",
views::viewset(),
PhantomData::<views::SnippetViewSet>,
)
}
};
let out_s = url_patterns_impl(args, input).unwrap().to_string();
assert!(
out_s.contains("__for_each_meta_views_viewset"),
"fn-form manifest must be re-exported and forwarded via per-call alias; got: {out_s}"
);
assert!(
out_s.contains("__for_each_viewset_action_meta_snippet_view_set"),
"action manifest must be forwarded with snake_case type; got: {out_s}"
);
}
#[test]
fn plain_viewset_call_does_not_forward_action_manifest() {
let args = quote! { InstalledApp::snippets, mode = server };
let input = quote! {
pub fn url_patterns() -> ServerRouter {
ServerRouter::new().viewset("/snippets", views::viewset())
}
};
let out_s = url_patterns_impl(args, input).unwrap().to_string();
assert!(
out_s.contains("__for_each_meta_views_viewset"),
"fn-form manifest must be forwarded via per-call alias; got: {out_s}"
);
assert!(
!out_s.contains("__for_each_viewset_action_meta_"),
"plain .viewset() must NOT forward action manifest; got: {out_s}"
);
}
#[test]
fn extract_mounts_inside_control_flow() {
let func: ItemFn = parse2(quote! {
pub fn url_patterns() -> ServerRouter {
if enabled {
ServerRouter::new()
.mount("/api/", crate::apps::api::urls::url_patterns())
} else {
ServerRouter::new()
}
}
})
.unwrap();
let body_tokens = flatten_body(&func);
let mounts = extract_chain_calls(&body_tokens, "mount");
assert_eq!(mounts.len(), 1);
}
}