use proc_macro2::TokenStream;
use quote::quote;
use syn::spanned::Spanned;
use syn::{Attribute, DeriveInput, Meta, Path};
use crate::attrs::{
Keyed, RetrySpec, default_crate_path, key_name, lit_str, parse_crate_path, parse_retry,
set_once,
};
#[derive(Debug, Default)]
pub(crate) struct JobAttr {
pub(crate) queue: Option<Keyed<Path>>,
pub(crate) name: Option<Keyed<String>>,
pub(crate) retry: Option<Keyed<RetrySpec>>,
pub(crate) core: Option<Keyed<Path>>,
}
impl JobAttr {
pub(crate) fn core_path(&self) -> Path {
self.core
.as_ref()
.map_or_else(default_crate_path, |keyed| keyed.value.clone())
}
}
pub(crate) fn parse_job_attr(attrs: &[Attribute]) -> syn::Result<JobAttr> {
let mut parsed = JobAttr::default();
for attr in attrs.iter().filter(|a| a.path().is_ident("job")) {
if matches!(attr.meta, Meta::Path(_)) {
continue;
}
attr.parse_nested_meta(|meta| {
if meta.path.is_ident("queue") {
let path: Path = meta.value()?.parse()?;
let span = path.span();
set_once(&mut parsed.queue, &meta, path)?;
if let Some(keyed) = parsed.queue.as_mut() {
keyed.span = span;
}
Ok(())
} else if meta.path.is_ident("name") {
let lit = lit_str(&meta)?;
set_once(&mut parsed.name, &meta, lit.value())
} else if meta.path.is_ident("retry") {
let spec = parse_retry(&meta)?;
set_once(&mut parsed.retry, &meta, spec)
} else if meta.path.is_ident("crate") {
let lit = lit_str(&meta)?;
let path = parse_crate_path(&lit)?;
set_once(&mut parsed.core, &meta, path)
} else {
Err(meta.error(format!(
"unknown key `{}` in `#[job(...)]`, expected one of `queue`, `name`, \
`retry`, `crate`",
key_name(&meta.path)
)))
}
})?;
}
Ok(parsed)
}
pub(crate) fn queue_set_of(path: &Path) -> syn::Result<Path> {
if path.segments.len() < 2 {
return Err(syn::Error::new(
path.span(),
"`queue` must be a path to an enum variant such as `AppQueues::Emails`",
));
}
let mut set = path.clone();
set.segments.pop();
set.segments.pop_punct();
Ok(set)
}
pub(crate) fn derive(input: &DeriveInput) -> syn::Result<TokenStream> {
if !input.generics.params.is_empty() || input.generics.where_clause.is_some() {
return Err(syn::Error::new(
input.generics.span(),
"generic job types are not supported",
));
}
let attr = parse_job_attr(&input.attrs)?;
let core = attr.core_path();
let queue = attr.queue.as_ref().ok_or_else(|| {
let span = input
.attrs
.iter()
.find(|a| a.path().is_ident("job"))
.map_or_else(|| input.ident.span(), Spanned::span);
syn::Error::new(
span,
"#[derive(Job)] requires `#[job(queue = MyQueues::Variant)]`",
)
})?;
let queue_path = &queue.value;
let queue_set = queue_set_of(queue_path)?;
let ident = &input.ident;
let name = match &attr.name {
Some(keyed) => {
let literal = syn::LitStr::new(&keyed.value, keyed.span);
quote!(#literal)
}
None => quote!(::core::concat!(
::core::module_path!(),
"::",
::core::stringify!(#ident)
)),
};
let retry_policy = attr.retry.as_ref().map(|keyed| {
let policy = keyed.value.to_tokens(&core);
quote! {
fn retry_policy() -> ::core::option::Option<#core::RetryPolicy> {
::core::option::Option::Some(#policy)
}
}
});
Ok(quote! {
#[automatically_derived]
#[allow(clippy::approx_constant)]
impl #core::Job for #ident {
type Queue = #queue_set;
const NAME: &'static ::core::primitive::str = #name;
const QUEUE: Self::Queue = #queue_path;
#retry_policy
}
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::attrs::BackoffSpec;
fn job_of(attrs: TokenStream) -> syn::Result<JobAttr> {
let input: DeriveInput = syn::parse_quote! {
#attrs
struct SendEmail { to: String }
};
parse_job_attr(&input.attrs)
}
fn expand(input: DeriveInput) -> syn::Result<String> {
derive(&input).map(|tokens| tokens.to_string())
}
#[test]
fn parses_all_keys() {
let parsed = job_of(quote!(#[job(
queue = AppQueues::Emails,
name = "emails.send",
retry(max_attempts = 5),
crate = "queuey",
)]))
.unwrap();
let queue_tokens = &parsed.queue.as_ref().unwrap().value;
assert_eq!(
quote!(#queue_tokens).to_string(),
"AppQueues :: Emails".to_owned()
);
assert_eq!(parsed.name.as_ref().unwrap().value, "emails.send");
assert_eq!(parsed.retry.as_ref().unwrap().value.max_attempts, 5);
let core = parsed.core_path();
assert_eq!(quote!(#core).to_string(), ":: queuey");
}
#[test]
fn defaults_to_core_crate() {
let parsed = job_of(quote!(#[job(queue = AppQueues::Emails)])).unwrap();
let core = parsed.core_path();
assert_eq!(quote!(#core).to_string(), ":: queuey_core");
assert!(parsed.retry.is_none());
assert!(parsed.name.is_none());
}
#[test]
fn rejects_unknown_and_duplicate_keys() {
let err = job_of(quote!(#[job(queue = A::B, kind = "x")]))
.unwrap_err()
.to_string();
assert!(err.contains("unknown key `kind`"), "{err}");
let err = job_of(quote!(#[job(queue = A::B, queue = A::C)]))
.unwrap_err()
.to_string();
assert!(err.contains("duplicate key `queue`"), "{err}");
}
#[test]
fn queue_set_is_the_path_minus_the_variant() {
let path: Path = syn::parse_quote!(AppQueues::Emails);
let set = queue_set_of(&path).unwrap();
assert_eq!(quote!(#set).to_string(), "AppQueues");
let path: Path = syn::parse_quote!(crate::queues::AppQueues::Emails);
let set = queue_set_of(&path).unwrap();
assert_eq!(
quote!(#set).to_string(),
"crate :: queues :: AppQueues".to_owned()
);
let path: Path = syn::parse_quote!(Emails);
let err = queue_set_of(&path).unwrap_err().to_string();
assert!(err.contains("path to an enum variant"), "{err}");
}
#[test]
fn expands_with_defaults() {
let expanded = expand(syn::parse_quote! {
#[job(queue = AppQueues::Emails)]
struct SendEmail { to: String }
})
.unwrap();
assert!(expanded.contains("Job for SendEmail"), "{expanded}");
assert!(expanded.contains("type Queue = AppQueues"), "{expanded}");
assert!(
expanded.contains("const QUEUE : Self :: Queue = AppQueues :: Emails"),
"{expanded}"
);
assert!(expanded.contains("module_path !"), "{expanded}");
assert!(!expanded.contains("retry_policy"), "{expanded}");
}
#[test]
fn expands_with_overrides() {
let expanded = expand(syn::parse_quote! {
#[job(queue = AppQueues::Emails, name = "send_email", retry(backoff = "none"))]
enum SendEmail { A }
})
.unwrap();
assert!(expanded.contains("\"send_email\""), "{expanded}");
assert!(expanded.contains("fn retry_policy ()"), "{expanded}");
assert!(expanded.contains("Backoff :: None"), "{expanded}");
}
#[test]
fn rejects_missing_queue() {
let err = expand(syn::parse_quote! {
#[job(name = "x")]
struct SendEmail;
})
.unwrap_err()
.to_string();
assert!(err.contains("requires `#[job(queue"), "{err}");
let err = expand(syn::parse_quote! {
struct SendEmail;
})
.unwrap_err()
.to_string();
assert!(err.contains("requires `#[job(queue"), "{err}");
}
#[test]
fn rejects_generics() {
let err = expand(syn::parse_quote! {
#[job(queue = AppQueues::Emails)]
struct SendEmail<T> { payload: T }
})
.unwrap_err()
.to_string();
assert!(err.contains("generic job types are not supported"), "{err}");
let err = expand(syn::parse_quote! {
#[job(queue = AppQueues::Emails)]
struct SendEmail where String: Clone { to: String }
})
.unwrap_err()
.to_string();
assert!(err.contains("generic job types are not supported"), "{err}");
}
#[test]
fn retry_grammar_is_shared_with_queues() {
let parsed = job_of(quote!(#[job(
queue = A::B,
retry(backoff = "fixed", delay = "250ms", max_attempts = 4)
)]))
.unwrap();
let retry = parsed.retry.unwrap().value;
assert_eq!(retry.max_attempts, 4);
assert_eq!(retry.backoff, BackoffSpec::Fixed { delay_ms: 250 });
}
}