use proc_macro2::{Span, TokenStream};
use quote::quote;
use syn::ItemFn;
#[cfg(test)]
use syn::parse2;
fn extract_basename(func: &ItemFn) -> Option<String> {
let body_tokens: Vec<proc_macro2::TokenTree> = func
.block
.stmts
.iter()
.flat_map(|stmt| {
let tokens: TokenStream = quote! { #stmt };
tokens.into_iter().collect::<Vec<_>>()
})
.collect();
let mut i = 0;
while i < body_tokens.len() {
if i + 2 < body_tokens.len()
&& let proc_macro2::TokenTree::Ident(type_ident) = &body_tokens[i]
&& (type_ident == "ModelViewSet" || type_ident == "GenericViewSet")
&& let proc_macro2::TokenTree::Punct(p1) = &body_tokens[i + 1]
&& p1.as_char() == ':'
&& let proc_macro2::TokenTree::Punct(p2) = &body_tokens[i + 2]
&& p2.as_char() == ':'
{
if i + 4 < body_tokens.len()
&& let proc_macro2::TokenTree::Ident(new_ident) = &body_tokens[i + 3]
&& new_ident == "new"
&& let proc_macro2::TokenTree::Group(group) = &body_tokens[i + 4]
&& group.delimiter() == proc_macro2::Delimiter::Parenthesis
{
for tt in group.stream() {
if let proc_macro2::TokenTree::Literal(lit) = tt {
let lit_str = lit.to_string();
if lit_str.starts_with('"') && lit_str.ends_with('"') {
return Some(lit_str[1..lit_str.len() - 1].to_string());
}
}
}
}
}
i += 1;
}
None
}
fn to_pascal_case(s: &str) -> String {
let mut result = String::new();
for segment in s.split('_') {
let mut chars = segment.chars();
if let Some(first) = chars.next() {
result.push(first.to_ascii_uppercase());
result.extend(chars);
}
}
result
}
fn generate_viewset_resolver_tokens(basename: &str) -> TokenStream {
let pascal = to_pascal_case(basename);
let list_mod_ident = syn::Ident::new(
&format!("__url_resolver_{basename}_list"),
Span::call_site(),
);
let detail_mod_ident = syn::Ident::new(
&format!("__url_resolver_{basename}_detail"),
Span::call_site(),
);
let list_trait_ident = syn::Ident::new(&format!("Resolve{pascal}List"), Span::call_site());
let detail_trait_ident = syn::Ident::new(&format!("Resolve{pascal}Detail"), Span::call_site());
let list_method_ident = syn::Ident::new(&format!("{basename}_list"), Span::call_site());
let detail_method_ident = syn::Ident::new(&format!("{basename}_detail"), Span::call_site());
let list_route_name = format!("{basename}-list");
let detail_route_name = format!("{basename}-detail");
let list_doc = format!("Resolve URL for route `{list_route_name}`.");
let detail_doc = format!("Resolve URL for route `{detail_route_name}`.");
let deprecated_note_list = format!("use `urls.server().<app>().{basename}_list()` instead");
let deprecated_note_detail =
format!("use `urls.server().<app>().{basename}_detail(id)` instead");
let reinhardt_crate = crate::crate_paths::get_reinhardt_crate();
quote! {
#[doc(hidden)]
pub mod #list_mod_ident {
#[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
#[deprecated(since = "0.1.0-rc.29", note = #deprecated_note_list)]
#[doc = #list_doc]
pub trait #list_trait_ident: #reinhardt_crate::UrlResolverUnprefixed {
#[deprecated(since = "0.1.0-rc.29", note = #deprecated_note_list)]
#[doc = #list_doc]
fn #list_method_ident(&self) -> String {
#[allow(deprecated)]
self.resolve_url_unprefixed(#list_route_name, &[])
}
}
#[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
#[allow(deprecated)]
impl<T: #reinhardt_crate::UrlResolverUnprefixed> #list_trait_ident for T {}
}
#[doc(hidden)]
#[allow(deprecated)]
pub use #list_mod_ident::*;
#[doc(hidden)]
pub mod #detail_mod_ident {
#[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
#[deprecated(since = "0.1.0-rc.29", note = #deprecated_note_detail)]
#[doc = #detail_doc]
pub trait #detail_trait_ident: #reinhardt_crate::UrlResolverUnprefixed {
#[deprecated(since = "0.1.0-rc.29", note = #deprecated_note_detail)]
#[doc = #detail_doc]
fn #detail_method_ident(&self, id: &str) -> String {
#[allow(deprecated)]
self.resolve_url_unprefixed(#detail_route_name, &[("id", id)])
}
}
#[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
#[allow(deprecated)]
impl<T: #reinhardt_crate::UrlResolverUnprefixed> #detail_trait_ident for T {}
}
#[doc(hidden)]
#[allow(deprecated)]
pub use #detail_mod_ident::*;
}
}
fn emit_meta_macro(
fn_name: &syn::Ident,
basename: &str,
kind: &str,
include_id: bool,
) -> TokenStream {
let macro_name = syn::Ident::new(
&format!("__url_resolver_meta_{fn_name}_{basename}_{kind}"),
Span::call_site(),
);
let method_ident = syn::Ident::new(&format!("{basename}_{kind}"), Span::call_site());
let route_literal = format!("{basename}-{kind}");
let body = if include_id {
quote! { $callback!($app, #method_ident, #route_literal, "id"); }
} else {
quote! { $callback!($app, #method_ident, #route_literal, ); }
};
quote! {
#[doc(hidden)]
#[macro_export]
macro_rules! #macro_name {
($callback:ident, $app:ident) => { #body };
}
}
}
fn emit_per_fn_manifest(fn_name: &syn::Ident, basename: &str) -> TokenStream {
let manifest_name = syn::Ident::new(
&format!("__for_each_viewset_meta_{fn_name}_{basename}"),
Span::call_site(),
);
let list_meta = syn::Ident::new(
&format!("__url_resolver_meta_{fn_name}_{basename}_list"),
Span::call_site(),
);
let detail_meta = syn::Ident::new(
&format!("__url_resolver_meta_{fn_name}_{basename}_detail"),
Span::call_site(),
);
quote! {
#[doc(hidden)]
#[macro_export]
macro_rules! #manifest_name {
($callback:ident, $app:ident) => {
$crate::#list_meta!($callback, $app);
$crate::#detail_meta!($callback, $app);
};
}
}
}
pub(crate) fn viewset_macro_impl(
args: TokenStream,
input: TokenStream,
) -> syn::Result<TokenStream> {
if let Ok(item_fn) = syn::parse2::<ItemFn>(input.clone()) {
return viewset_fn_impl(args, item_fn);
}
if let Ok(item_impl) = syn::parse2::<syn::ItemImpl>(input.clone()) {
return viewset_impl_impl(args, item_impl);
}
Err(syn::Error::new(
Span::call_site(),
"#[viewset] must be applied to a `pub fn ... -> ModelViewSet<...>` \
or to an `impl YourViewSet` block",
))
}
fn parse_optional_basename_arg(args: TokenStream) -> syn::Result<Option<String>> {
if args.is_empty() {
return Ok(None);
}
let parser = |input: syn::parse::ParseStream<'_>| -> syn::Result<String> {
let key: syn::Ident = input.parse()?;
if key != "basename" {
return Err(syn::Error::new(
key.span(),
"#[viewset] only accepts `basename = \"...\"`. \
Example: #[viewset(basename = \"snippet\")]",
));
}
input.parse::<syn::Token![=]>()?;
let lit: syn::LitStr = input.parse()?;
Ok(lit.value())
};
syn::parse::Parser::parse2(parser, args).map(Some)
}
fn emit_basename_fallback_deprecation(fn_name: &syn::Ident, basename: &str) -> TokenStream {
let module_ident = syn::Ident::new(
&format!("__viewset_basename_inferred_{fn_name}"),
Span::call_site(),
);
let note = format!(
"#[viewset] inferred basename = \"{basename}\" from the function body. \
Prefer the explicit form #[viewset(basename = \"{basename}\")]; the body-walker \
fallback will be removed in v0.2.0 (see Issue #4549)."
);
quote! {
#[doc(hidden)]
#[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
#[allow(non_snake_case)]
mod #module_ident {
#[deprecated(note = #note)]
pub const REASON: () = ();
#[allow(deprecated_in_future, clippy::no_effect)]
const _: () = REASON;
}
}
}
fn viewset_fn_impl(args: TokenStream, func: ItemFn) -> syn::Result<TokenStream> {
let fn_name = &func.sig.ident;
let explicit_basename = parse_optional_basename_arg(args)?;
let used_fallback = explicit_basename.is_none();
let basename = match explicit_basename {
Some(b) => b,
None => extract_basename(&func).ok_or_else(|| {
syn::Error::new_spanned(
&func.sig.ident,
"#[viewset] could not extract basename. \
Pass it explicitly via #[viewset(basename = \"...\")], or \
ensure the function body contains ModelViewSet::new(\"basename\") \
or GenericViewSet::new(\"basename\", ...).",
)
})?,
};
let resolver_tokens = generate_viewset_resolver_tokens(&basename);
let list_meta = emit_meta_macro(fn_name, &basename, "list", false);
let detail_meta = emit_meta_macro(fn_name, &basename, "detail", true);
let manifest = emit_per_fn_manifest(fn_name, &basename);
let bundle_mod_ident =
syn::Ident::new(&format!("__viewset_resolvers_{fn_name}"), Span::call_site());
let list_mod_ident = syn::Ident::new(
&format!("__url_resolver_{basename}_list"),
Span::call_site(),
);
let detail_mod_ident = syn::Ident::new(
&format!("__url_resolver_{basename}_detail"),
Span::call_site(),
);
let manifest_name = syn::Ident::new(
&format!("__for_each_viewset_meta_{fn_name}_{basename}"),
Span::call_site(),
);
let deprecation_marker = if used_fallback {
emit_basename_fallback_deprecation(fn_name, &basename)
} else {
TokenStream::new()
};
Ok(quote! {
#func
#deprecation_marker
#resolver_tokens
#list_meta
#detail_meta
#manifest
#[doc(hidden)]
pub mod #bundle_mod_ident {
#[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
pub use super::#list_mod_ident::*;
#[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
pub use super::#detail_mod_ident::*;
#[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
pub use crate::#manifest_name as __for_each_meta;
}
})
}
fn parse_impl_basename_arg(args: TokenStream) -> syn::Result<String> {
let parser = |input: syn::parse::ParseStream<'_>| -> syn::Result<String> {
let key: syn::Ident = input.parse()?;
if key != "basename" {
return Err(syn::Error::new(
key.span(),
"#[viewset] on impl block requires basename = \"...\". \
Example: #[viewset(basename = \"snippet\")]",
));
}
input.parse::<syn::Token![=]>()?;
let lit: syn::LitStr = input.parse()?;
Ok(lit.value())
};
if args.is_empty() {
return Err(syn::Error::new(
Span::call_site(),
"#[viewset] on impl block requires basename = \"...\". \
Example: #[viewset(basename = \"snippet\")]",
));
}
syn::parse::Parser::parse2(parser, args)
}
fn viewset_impl_impl(args: TokenStream, item_impl: syn::ItemImpl) -> syn::Result<TokenStream> {
let basename = parse_impl_basename_arg(args)?;
let (action_metas, url_names) = collect_actions(&item_impl, &basename)?;
let type_snake = type_name_to_snake(&item_impl.self_ty)?;
let manifest = emit_impl_action_manifest(&type_snake, &basename, &url_names);
let runtime_registrations = emit_runtime_action_registrations(&item_impl)?;
Ok(quote! {
#item_impl
#(#action_metas)*
#manifest
#runtime_registrations
})
}
fn emit_runtime_action_registrations(
item_impl: &syn::ItemImpl,
) -> syn::Result<proc_macro2::TokenStream> {
let marker_ty = &item_impl.self_ty;
let views_crate = crate::crate_paths::get_reinhardt_views_crate();
let hyper_crate = crate::crate_paths::get_hyper_crate();
let type_snake = type_name_to_snake(marker_ty)?;
let ctor_fn_ident = syn::Ident::new(
&format!("__reinhardt_register_viewset_actions_{type_snake}"),
Span::call_site(),
);
let mut registrations: Vec<proc_macro2::TokenStream> = Vec::new();
for item in &item_impl.items {
let syn::ImplItem::Fn(method) = item else {
continue;
};
let Some(action_attr) = method.attrs.iter().find(|a| a.path().is_ident("action")) else {
continue;
};
let attr_tokens = match &action_attr.meta {
syn::Meta::List(ml) => ml.tokens.clone(),
syn::Meta::Path(_) => proc_macro2::TokenStream::new(),
_ => {
return Err(syn::Error::new_spanned(
action_attr,
"unexpected #[action] form",
));
}
};
let parsed =
crate::action::parse_action_args_with_defaults(attr_tokens, &method.sig.ident)?;
let url_name_lit = syn::LitStr::new(&parsed.url_name, Span::call_site());
let detail = parsed.detail;
let with_url_path = if parsed.url_path.is_empty() {
quote! {}
} else {
let lit = syn::LitStr::new(&parsed.url_path, Span::call_site());
quote! { .with_url_path(#lit) }
};
let method_lits: Vec<proc_macro2::TokenStream> = parsed
.methods
.iter()
.map(|m| {
let normalized = m.to_ascii_uppercase();
let lit = syn::LitStr::new(&normalized, Span::call_site());
quote! {
#hyper_crate::Method::from_bytes(#lit.as_bytes())
.expect("#[action] methods are uppercased at macro expansion")
}
})
.collect();
registrations.push(quote! {
#views_crate::viewsets::register_action(
::std::any::type_name::<#marker_ty>(),
#views_crate::viewsets::ActionMetadata::new(#url_name_lit)
.with_detail(#detail)
.with_url_name(#url_name_lit)
.with_methods(vec![#(#method_lits),*])
#with_url_path,
);
});
}
if registrations.is_empty() {
return Ok(proc_macro2::TokenStream::new());
}
Ok(quote! {
#[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
#[doc(hidden)]
#[::ctor::ctor]
fn #ctor_fn_ident() {
#(#registrations)*
}
})
}
fn type_name_to_snake(ty: &syn::Type) -> syn::Result<String> {
let path = match ty {
syn::Type::Path(tp) => &tp.path,
_ => return Err(syn::Error::new_spanned(ty, "expected a type path")),
};
let ident = &path
.segments
.last()
.ok_or_else(|| syn::Error::new_spanned(ty, "empty type path"))?
.ident;
Ok(camel_to_snake(&ident.to_string()))
}
fn camel_to_snake(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 collect_actions(
item_impl: &syn::ItemImpl,
basename: &str,
) -> syn::Result<(Vec<proc_macro2::TokenStream>, Vec<String>)> {
let mut metas = Vec::new();
let mut url_names = Vec::new();
for item in &item_impl.items {
let syn::ImplItem::Fn(method) = item else {
continue;
};
let Some(action_attr) = method.attrs.iter().find(|a| a.path().is_ident("action")) else {
continue;
};
let attr_tokens = match &action_attr.meta {
syn::Meta::List(ml) => ml.tokens.clone(),
syn::Meta::Path(_) => proc_macro2::TokenStream::new(),
_ => {
return Err(syn::Error::new_spanned(
action_attr,
"unexpected #[action] form",
));
}
};
let parsed =
crate::action::parse_action_args_with_defaults(attr_tokens, &method.sig.ident)?;
let meta = parse_action_meta_for_viewset(action_attr, &method.sig.ident, basename)?;
metas.push(meta);
url_names.push(parsed.url_name);
}
Ok((metas, url_names))
}
fn parse_action_meta_for_viewset(
attr: &syn::Attribute,
fn_ident: &syn::Ident,
basename: &str,
) -> syn::Result<proc_macro2::TokenStream> {
let attr_tokens = match &attr.meta {
syn::Meta::List(ml) => ml.tokens.clone(),
syn::Meta::Path(_) => proc_macro2::TokenStream::new(),
_ => return Err(syn::Error::new_spanned(attr, "unexpected #[action] form")),
};
let meta = crate::action::parse_action_args_with_defaults(attr_tokens, fn_ident)?;
let macro_name = syn::Ident::new(
&format!("__url_resolver_meta_action_{basename}_{}", meta.url_name),
Span::call_site(),
);
let method_ident = syn::Ident::new(&meta.url_name, Span::call_site());
let route_literal = format!("{basename}-{}", meta.url_name);
let mut param_literals: Vec<proc_macro2::TokenStream> = Vec::new();
if meta.detail {
param_literals.push(quote! { "id" });
}
for p in crate::url_patterns::extract_url_params_pub(&meta.url_path) {
let lit = syn::LitStr::new(&p, Span::call_site());
param_literals.push(quote! { #lit });
}
let body = if param_literals.is_empty() {
quote! { $callback!($app, #method_ident, #route_literal, ); }
} else {
quote! { $callback!($app, #method_ident, #route_literal, #(#param_literals),*); }
};
Ok(quote! {
#[doc(hidden)]
#[macro_export]
macro_rules! #macro_name {
($callback:ident, $app:ident) => { #body };
}
})
}
fn emit_impl_action_manifest(
type_snake: &str,
basename: &str,
url_names: &[String],
) -> proc_macro2::TokenStream {
let manifest_name = syn::Ident::new(
&format!("__for_each_viewset_action_meta_{type_snake}"),
Span::call_site(),
);
let meta_calls: Vec<proc_macro2::TokenStream> = url_names
.iter()
.map(|n| {
let meta_name = syn::Ident::new(
&format!("__url_resolver_meta_action_{basename}_{n}"),
Span::call_site(),
);
quote! { $crate::#meta_name!($callback, $app); }
})
.collect();
quote! {
#[doc(hidden)]
#[macro_export]
macro_rules! #manifest_name {
($callback:ident, $app:ident) => {
#(#meta_calls)*
};
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_basename_model_viewset() {
let func: ItemFn = parse2(quote! {
pub fn viewset() -> ModelViewSet<Snippet, SnippetSerializer> {
ModelViewSet::new("snippet")
.with_pagination(PaginationConfig::page_number(10, Some(100)))
}
})
.unwrap();
assert_eq!(extract_basename(&func), Some("snippet".to_string()));
}
#[test]
fn extract_basename_generic_viewset() {
let func: ItemFn = parse2(quote! {
pub fn viewset() -> GenericViewSet<User> {
GenericViewSet::new("user", ())
}
})
.unwrap();
assert_eq!(extract_basename(&func), Some("user".to_string()));
}
#[test]
fn extract_basename_not_found() {
let func: ItemFn = parse2(quote! {
pub fn viewset() -> ServerRouter {
ServerRouter::new()
}
})
.unwrap();
assert_eq!(extract_basename(&func), None);
}
#[test]
fn to_pascal_case_single() {
assert_eq!(to_pascal_case("snippet"), "Snippet");
}
#[test]
fn to_pascal_case_multi() {
assert_eq!(to_pascal_case("auth_user"), "AuthUser");
}
#[test]
fn generate_resolver_tokens_contains_expected_identifiers() {
let tokens = generate_viewset_resolver_tokens("snippet");
let output = tokens.to_string();
assert!(output.contains("__url_resolver_snippet_list"));
assert!(output.contains("__url_resolver_snippet_detail"));
assert!(output.contains("ResolveSnippetList"));
assert!(output.contains("ResolveSnippetDetail"));
assert!(output.contains("snippet_list"));
assert!(output.contains("snippet_detail"));
}
#[test]
fn fn_version_emits_list_and_detail_meta_macros() {
let input = quote! {
pub fn viewset() -> ModelViewSet<Snippet, SnippetSerializer> {
ModelViewSet::new("snippet")
}
};
let out = viewset_macro_impl(quote! {}, input).expect("should expand");
let out_s = out.to_string();
assert!(
out_s.contains("__url_resolver_meta_viewset_snippet_list"),
"list meta macro must be emitted; got: {out_s}"
);
assert!(
out_s.contains("\"snippet-list\""),
"list route-name literal must be emitted"
);
assert!(out_s.contains("__url_resolver_meta_viewset_snippet_detail"));
assert!(out_s.contains("\"snippet-detail\""));
assert!(out_s.contains("\"id\""));
}
#[test]
fn fn_version_emits_for_each_viewset_meta_manifest() {
let input = quote! {
pub fn viewset() -> ModelViewSet<Snippet, SnippetSerializer> {
ModelViewSet::new("snippet")
}
};
let out_s = viewset_macro_impl(quote! {}, input).unwrap().to_string();
assert!(
out_s.contains("__for_each_viewset_meta_viewset_snippet"),
"fn-form must emit per-fn manifest macro keyed on <fn>_<basename>; got: {out_s}"
);
assert!(
out_s.contains(
"pub use crate :: __for_each_viewset_meta_viewset_snippet as __for_each_meta"
),
"bundle module must re-export the manifest under fixed alias `__for_each_meta`; got: {out_s}"
);
}
#[test]
fn fn_version_manifest_does_not_collide_across_basenames() {
let snippet_input = quote! {
pub fn viewset() -> ModelViewSet<Snippet, SnippetSerializer> {
ModelViewSet::new("snippet")
}
};
let post_input = quote! {
pub fn viewset() -> ModelViewSet<Post, PostSerializer> {
ModelViewSet::new("post")
}
};
let snippet_out = viewset_macro_impl(quote! {}, snippet_input)
.unwrap()
.to_string();
let post_out = viewset_macro_impl(quote! {}, post_input)
.unwrap()
.to_string();
assert!(snippet_out.contains("__for_each_viewset_meta_viewset_snippet"));
assert!(post_out.contains("__for_each_viewset_meta_viewset_post"));
assert!(!snippet_out.contains("__for_each_viewset_meta_viewset_post"));
assert!(!post_out.contains("__for_each_viewset_meta_viewset_snippet"));
}
#[test]
fn impl_version_requires_basename_arg() {
let args = quote! {};
let input = quote! {
impl SnippetViewSet {
#[action(methods = "POST", detail = true, url_name = "highlight")]
async fn highlight(&self) -> () {}
}
};
let err = viewset_macro_impl(args, input).unwrap_err().to_string();
assert!(err.contains("requires basename"), "got: {err}");
}
#[test]
fn impl_version_accepts_basename_arg() {
let args = quote! { basename = "snippet" };
let input = quote! {
impl SnippetViewSet {
#[action(methods = "POST", detail = true, url_name = "highlight")]
async fn highlight(&self) -> () {}
}
};
let out = viewset_macro_impl(args, input).expect("impl form should expand");
let out_s = out.to_string();
assert!(
out_s.contains("SnippetViewSet"),
"impl block should be preserved; got: {out_s}"
);
}
#[test]
fn type_name_to_snake_camel_case() {
let ty: syn::Type = syn::parse_quote! { SnippetViewSet };
assert_eq!(type_name_to_snake(&ty).unwrap(), "snippet_view_set");
}
#[test]
fn type_name_to_snake_path() {
let ty: syn::Type = syn::parse_quote! { views::SnippetViewSet };
assert_eq!(type_name_to_snake(&ty).unwrap(), "snippet_view_set");
}
#[test]
fn collect_actions_two_actions() {
let item_impl: syn::ItemImpl = syn::parse_quote! {
impl SnippetViewSet {
#[action(methods = "POST", detail = true, url_name = "highlight")]
async fn highlight(&self) -> () {}
#[action(methods = "GET", detail = false, url_name = "export")]
async fn export(&self) -> () {}
}
};
let (metas, url_names) = collect_actions(&item_impl, "snippet").unwrap();
assert_eq!(metas.len(), 2);
assert_eq!(
url_names,
vec!["highlight".to_string(), "export".to_string()]
);
let combined = metas.iter().map(|t| t.to_string()).collect::<String>();
assert!(combined.contains("__url_resolver_meta_action_snippet_highlight"));
assert!(combined.contains("__url_resolver_meta_action_snippet_export"));
}
#[test]
fn collect_actions_skips_non_action_methods() {
let item_impl: syn::ItemImpl = syn::parse_quote! {
impl SnippetViewSet {
#[action(methods = "POST", detail = true, url_name = "highlight")]
async fn highlight(&self) -> () {}
async fn helper(&self) -> () {}
}
};
let (metas, url_names) = collect_actions(&item_impl, "snippet").unwrap();
assert_eq!(metas.len(), 1);
assert_eq!(url_names.len(), 1);
}
#[test]
fn impl_form_manifest_references_each_action_meta() {
let args = quote! { basename = "snippet" };
let input = quote! {
impl SnippetViewSet {
#[action(methods = "POST", detail = true, url_name = "highlight")]
async fn highlight(&self) -> () {}
#[action(methods = "GET", detail = false, url_name = "export")]
async fn export(&self) -> () {}
}
};
let out_s = viewset_macro_impl(args, input).unwrap().to_string();
assert!(out_s.contains("__for_each_viewset_action_meta_snippet_view_set"));
assert!(out_s.contains("__url_resolver_meta_action_snippet_highlight"));
assert!(out_s.contains("__url_resolver_meta_action_snippet_export"));
}
#[test]
fn impl_action_meta_without_id_when_detail_false() {
let args = quote! { basename = "snippet" };
let input = quote! {
impl SnippetViewSet {
#[action(methods = "GET", detail = false, url_name = "export")]
async fn export(&self) -> () {}
}
};
let out_s = viewset_macro_impl(args, input).unwrap().to_string();
let pos = out_s
.find("__url_resolver_meta_action_snippet_export")
.expect("export meta must be present");
let snippet = &out_s[pos..(pos + 600).min(out_s.len())];
assert!(
!snippet.contains("\"id\""),
"detail=false meta must omit \"id\" param; got: {snippet}"
);
}
#[test]
fn impl_action_meta_with_url_path_params() {
let args = quote! { basename = "snippet" };
let input = quote! {
impl SnippetViewSet {
#[action(
methods = "GET",
detail = true,
url_name = "child",
url_path = "/children/{child_id}"
)]
async fn child(&self) -> () {}
}
};
let out_s = viewset_macro_impl(args, input).unwrap().to_string();
let pos = out_s
.find("__url_resolver_meta_action_snippet_child")
.unwrap();
let snippet = &out_s[pos..(pos + 800).min(out_s.len())];
assert!(snippet.contains("\"id\""), "detail=true must include id");
assert!(
snippet.contains("\"child_id\""),
"url_path placeholders must be added"
);
}
#[test]
fn fn_form_marks_legacy_blanket_trait_as_deprecated() {
let input = quote! {
pub fn viewset() -> ModelViewSet<Snippet, S> {
ModelViewSet::new("snippet")
}
};
let out_s = viewset_macro_impl(quote! {}, input).unwrap().to_string();
let occurrences =
out_s.matches("# [deprecated").count() + out_s.matches("#[deprecated").count();
assert!(
occurrences >= 4,
"expected >= 4 #[deprecated] markers (trait+method for list+detail), got {occurrences}; out={out_s}"
);
}
#[test]
fn fn_form_legacy_trait_uses_url_resolver_unprefixed_supertrait() {
let input = quote! {
pub fn viewset() -> ModelViewSet<Snippet, S> {
ModelViewSet::new("snippet")
}
};
let out_s = viewset_macro_impl(quote! {}, input).unwrap().to_string();
assert!(
out_s.contains("UrlResolverUnprefixed"),
"trait must reference UrlResolverUnprefixed as supertrait; got: {out_s}"
);
}
#[test]
fn fn_form_emits_deprecation_when_basename_arg_absent() {
let input = quote! {
pub fn viewset() -> ModelViewSet<Snippet, S> {
ModelViewSet::new("snippet")
}
};
let out_s = viewset_macro_impl(quote! {}, input).unwrap().to_string();
assert!(
out_s.contains("__viewset_basename_inferred_viewset"),
"fallback path must emit per-fn deprecation marker module; got: {out_s}"
);
assert!(
out_s.contains("#[viewset(basename = \\\"snippet\\\")]"),
"deprecation note must show the recommended explicit form; got: {out_s}"
);
assert!(
out_s.contains("const _ : () = REASON ;") || out_s.contains("const _: () = REASON;"),
"deprecation marker must read the deprecated const; got: {out_s}"
);
}
#[test]
fn fn_form_skips_deprecation_when_basename_arg_provided() {
let args = quote! { basename = "snippet" };
let input = quote! {
pub fn viewset() -> ModelViewSet<Snippet, S> {
ModelViewSet::new("snippet")
}
};
let out_s = viewset_macro_impl(args, input).unwrap().to_string();
assert!(
!out_s.contains("__viewset_basename_inferred_viewset"),
"explicit basename must NOT emit the deprecation marker; got: {out_s}"
);
}
#[test]
fn fn_form_explicit_basename_overrides_body_walker() {
let args = quote! { basename = "snippet" };
let input = quote! {
pub fn viewset() -> ModelViewSet<Snippet, S> {
ModelViewSet::new("auto_snippet")
}
};
let out_s = viewset_macro_impl(args, input).unwrap().to_string();
assert!(
out_s.contains("__url_resolver_snippet_list"),
"explicit basename must drive the list resolver mod name; got: {out_s}"
);
assert!(
!out_s.contains("__url_resolver_auto_snippet_list"),
"body-walker result must be ignored when basename is explicit; got: {out_s}"
);
}
#[test]
fn fn_form_rejects_unknown_attribute_arg() {
let args = quote! { foo = "bar" };
let input = quote! {
pub fn viewset() -> ModelViewSet<Snippet, S> {
ModelViewSet::new("snippet")
}
};
let err = viewset_macro_impl(args, input).unwrap_err().to_string();
assert!(
err.contains("only accepts `basename = \"...\"`"),
"unknown attr arg must produce the explicit error; got: {err}"
);
}
#[test]
fn impl_form_manifest_uses_url_name_in_meta_calls() {
let args = quote! { basename = "snippet" };
let input = quote! {
impl SnippetViewSet {
#[action(methods = "POST", detail = true, url_name = "highlight_code")]
async fn highlight(&self) -> () {}
}
};
let out_s = viewset_macro_impl(args, input).unwrap().to_string();
assert!(
out_s.contains("__url_resolver_meta_action_snippet_highlight_code"),
"manifest body must reference the url_name-derived meta name; got: {out_s}"
);
}
}