use proc_macro2::TokenStream;
use quote::{format_ident, quote};
use syn::parse::ParseStream;
use syn::{Attribute, Ident, ItemFn, LitStr, Token};
const SEO_KEYS: &[&str] = &[
"title",
"description",
"canonical",
"og_title",
"og_description",
"og_image",
"og_type",
"og_url",
"twitter_card",
"twitter_title",
"twitter_description",
"twitter_image",
"robots",
];
#[derive(Default)]
pub struct SeoAttrArgs {
fields: Vec<(String, LitStr)>,
}
impl SeoAttrArgs {
pub fn parse_group(input: ParseStream) -> syn::Result<Self> {
let span = input.span();
let content;
syn::parenthesized!(content in input);
let mut fields: Vec<(String, LitStr)> = Vec::new();
while !content.is_empty() {
let key: Ident = content.parse()?;
let key_name = key.to_string();
let key_name = key_name.strip_prefix("r#").unwrap_or(&key_name).to_owned();
if !SEO_KEYS.contains(&key_name.as_str()) {
return Err(syn::Error::new(
key.span(),
format!(
"unknown `seo(...)` key `{key_name}`. Supported keys: `{}`.",
SEO_KEYS.join("`, `")
),
));
}
if fields.iter().any(|(existing, _)| *existing == key_name) {
return Err(syn::Error::new(
key.span(),
format!("duplicate `seo(...)` key `{key_name}`. Declare each key once."),
));
}
let _eq: Token![=] = content.parse()?;
let value_span = content.span();
let value: LitStr = content.parse().map_err(|_| {
syn::Error::new(
value_span,
format!("`seo({key_name} = ...)` expects a string literal."),
)
})?;
fields.push((key_name, value));
if content.peek(Token![,]) {
let _comma: Token![,] = content.parse()?;
} else {
break;
}
}
if !content.is_empty() {
return Err(content.error("expected `,` between `seo(...)` keys"));
}
if fields.is_empty() {
return Err(syn::Error::new(
span,
format!(
"empty `seo(...)`. Declare at least one key, e.g. \
`seo(title = \"…\")`. Supported keys: `{}`.",
SEO_KEYS.join("`, `")
),
));
}
Ok(Self { fields })
}
pub fn emit(&self) -> TokenStream {
let setters = self.fields.iter().map(|(key, value)| {
let setter = format_ident!("with_{}", key);
quote! { .#setter(#value) }
});
quote! {
::autumn_web::seo::SeoRouteDefaults::EMPTY #(#setters)*
}
}
}
pub struct RouteAttrArgs {
pub path: LitStr,
pub name_override: Option<LitStr>,
pub api_version: Option<LitStr>,
pub sunset_opt_out: bool,
pub timeout: RouteTimeoutAttr,
pub criticality: RouteCriticalityAttr,
pub idempotent: bool,
pub seo: SeoAttrArgs,
}
#[derive(Clone, Copy)]
pub enum RouteTimeoutAttr {
Inherit,
Ms(u64),
Disabled,
}
#[derive(Clone, Copy)]
pub enum RouteCriticalityAttr {
Critical,
Default,
Sheddable,
}
impl RouteAttrArgs {
pub fn helper_ident(&self, handler_name: &Ident) -> Ident {
self.name_override.as_ref().map_or_else(
|| handler_name.clone(),
|lit| format_ident!("{}", lit.value()),
)
}
}
impl syn::parse::Parse for RouteAttrArgs {
fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
let path: LitStr = input.parse()?;
let mut name_override = None;
let mut api_version = None;
let mut sunset_opt_out = false;
let mut timeout = RouteTimeoutAttr::Inherit;
let mut criticality = RouteCriticalityAttr::Default;
let mut idempotent = true;
let mut seo = SeoAttrArgs::default();
let mut seen_seo = false;
while input.peek(Token![,]) {
let _comma: Token![,] = input.parse()?;
if input.is_empty() {
break;
}
let key: Ident = input.parse()?;
if key == "seo" {
if !input.peek(syn::token::Paren) {
return Err(syn::Error::new(
key.span(),
"`seo` takes parenthesized keys, e.g. \
`seo(title = \"About\", description = \"…\")`.",
));
}
if seen_seo {
return Err(syn::Error::new(
key.span(),
"duplicate `seo(...)` argument. Declare all SEO keys in one `seo(...)`.",
));
}
seen_seo = true;
seo = SeoAttrArgs::parse_group(input)?;
continue;
}
let _eq: Token![=] = input.parse()?;
if key == "name" {
name_override = Some(input.parse::<LitStr>()?);
} else if key == "api_version" {
api_version = Some(input.parse::<LitStr>()?);
} else if key == "sunset_opt_out" {
let val: syn::LitBool = input.parse()?;
sunset_opt_out = val.value();
} else if key == "timeout_ms" {
let val: syn::LitInt = input.parse()?;
timeout = RouteTimeoutAttr::Ms(val.base10_parse::<u64>()?);
} else if key == "timeout" {
let val: LitStr = input.parse()?;
match val.value().as_str() {
"off" | "disabled" | "none" => timeout = RouteTimeoutAttr::Disabled,
other => {
return Err(syn::Error::new(
val.span(),
format!(
"invalid `timeout` value {other:?}. Use `timeout = \"off\"` to \
disable the request deadline, or `timeout_ms = <millis>` to \
override it."
),
));
}
}
} else if key == "idempotent" {
let val: syn::LitBool = input.parse()?;
idempotent = val.value();
} else if key == "criticality" {
let val: LitStr = input.parse()?;
criticality = match val.value().as_str() {
"critical" => RouteCriticalityAttr::Critical,
"default" => RouteCriticalityAttr::Default,
"sheddable" => RouteCriticalityAttr::Sheddable,
other => {
return Err(syn::Error::new(
val.span(),
format!(
"invalid `criticality` value {other:?}. Use \"critical\", \
\"default\" or \"sheddable\"."
),
));
}
};
} else {
return Err(syn::Error::new(
key.span(),
format!(
"unknown route attribute key `{key}`. Supported keys: `name`, \
`api_version`, `sunset_opt_out`, `timeout_ms`, `timeout`, \
`criticality`, `idempotent`, `seo(...)`."
),
));
}
}
Ok(Self {
path,
name_override,
api_version,
sunset_opt_out,
timeout,
criticality,
idempotent,
seo,
})
}
}
pub fn parse_route_attr(attr: TokenStream) -> Result<RouteAttrArgs, TokenStream> {
let args: RouteAttrArgs = syn::parse2(attr).map_err(|err| err.to_compile_error())?;
validate_path(&args.path)?;
if let Some(ref name_lit) = args.name_override {
syn::parse_str::<Ident>(&name_lit.value()).map_err(|_| {
syn::Error::new(
name_lit.span(),
format!(
"route `name` override {:?} is not a valid Rust identifier",
name_lit.value()
),
)
.to_compile_error()
})?;
}
Ok(args)
}
pub fn parse_route_path(attr: TokenStream) -> Result<LitStr, TokenStream> {
let path: LitStr = syn::parse2(attr).map_err(|err| err.to_compile_error())?;
validate_path(&path)?;
Ok(path)
}
fn validate_path(path: &LitStr) -> Result<(), TokenStream> {
if path.value().is_empty() {
return Err(syn::Error::new(path.span(), "Route path must not be empty").to_compile_error());
}
if !path.value().starts_with('/') {
let suggested = format!("/{}", path.value());
return Err(syn::Error::new(
path.span(),
format!("Route path must start with '/'. Did you mean \"{suggested}\"?"),
)
.to_compile_error());
}
Ok(())
}
pub fn split_leading_items_and_fn(
item: &TokenStream,
) -> Result<(TokenStream, ItemFn), TokenStream> {
if let Ok(input_fn) = syn::parse2::<ItemFn>(item.clone()) {
return Ok((TokenStream::new(), input_fn));
}
let not_a_function = || {
syn::Error::new_spanned(
item.clone(),
"route macros can only be applied to functions",
)
.to_compile_error()
};
let mut file: syn::File = syn::parse2(item.clone()).map_err(|_| not_a_function())?;
let Some(syn::Item::Fn(input_fn)) = file.items.pop() else {
return Err(not_a_function());
};
let leading_items = file.items;
Ok((quote! { #(#leading_items)* }, input_fn))
}
#[allow(clippy::needless_pass_by_value)]
pub fn parse_async_handler_with_leading_items(
item: TokenStream,
) -> Result<(TokenStream, ItemFn), TokenStream> {
let (leading_items, input_fn) = split_leading_items_and_fn(&item)?;
if input_fn.sig.asyncness.is_none() {
return Err(syn::Error::new_spanned(
input_fn.sig.fn_token,
"Autumn route handlers must be async functions",
)
.to_compile_error());
}
Ok((leading_items, input_fn))
}
pub fn extract_interceptors(attrs: &mut Vec<Attribute>) -> Vec<syn::Path> {
let mut interceptors = Vec::new();
attrs.retain(|attr| {
if attr.path().is_ident("intercept") {
if let Ok(path) = attr.parse_args::<syn::Path>() {
interceptors.push(path);
}
false } else {
true }
});
interceptors
}
pub fn arm_pat_and_guard(arm: &syn::Arm) -> (&syn::Pat, Option<&syn::Expr>) {
match &arm.pat {
syn::Pat::Guard(g) => (&g.pat, Some(&g.guard)),
pat => (pat, None),
}
}
#[cfg(test)]
mod tests {
use super::arm_pat_and_guard;
#[test]
fn arm_guard_is_split_from_its_pattern() {
let arm: syn::Arm = syn::parse_quote!(Some(x) if x > 0 => x);
let (pat, guard) = arm_pat_and_guard(&arm);
assert!(matches!(pat, syn::Pat::TupleStruct(_)));
let guard = guard.expect("guard present");
assert_eq!(quote::quote!(#guard).to_string(), "x > 0");
}
#[test]
fn unguarded_arm_has_no_guard() {
let arm: syn::Arm = syn::parse_quote!(None => 0);
let (pat, guard) = arm_pat_and_guard(&arm);
assert!(matches!(pat, syn::Pat::Ident(_)));
assert!(guard.is_none());
}
}