use proc_macro::TokenStream;
use proc_macro2::TokenStream as TokenStream2;
use quote::{quote, quote_spanned};
use syn::{
parse::{Parse, ParseStream},
parse_macro_input,
spanned::Spanned,
Ident, ItemFn, LitInt, LitStr, ReturnType, Token, Type,
};
struct MacroArgs {
client: Ident,
ttl: u64,
interop: String,
namespace: String,
secure: bool,
}
fn segment_is_valid(segment: &str) -> bool {
let bytes = segment.as_bytes();
matches!(bytes.first(), Some(b) if b.is_ascii_lowercase() || b.is_ascii_digit())
&& bytes.len() <= 64
&& bytes[1..].iter().all(|b| {
b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'.' | b'_' | b'-')
})
}
fn parse_segment(kind: &str, lit: &LitStr) -> syn::Result<String> {
let value = lit.value();
if !segment_is_valid(&value) {
return Err(syn::Error::new(
lit.span(),
format!(
"`{kind}` {value:?} is not a valid interop/v1 key segment: must match \
^[a-z0-9][a-z0-9._-]{{0,63}}$ (lowercase ASCII letters, digits, '.', '_', \
'-'; 1-64 chars)"
),
));
}
if value.contains("..") {
return Err(syn::Error::new(
lit.span(),
format!(
"`{kind}` {value:?} must not contain \"..\": the CachekitIO server rejects \
\"..\" anywhere in a key"
),
));
}
if kind == "namespace" && matches!(value.as_str(), "ns" | "nsapi") {
return Err(syn::Error::new(
lit.span(),
format!(
"`namespace` {value:?} is reserved: the CachekitIO server parses a key \
starting \"{value}:\" as namespace-prefixed"
),
));
}
Ok(value)
}
impl Parse for MacroArgs {
fn parse(input: ParseStream) -> syn::Result<Self> {
let mut client: Option<Ident> = None;
let mut ttl: Option<u64> = None;
let mut interop: Option<String> = None;
let mut namespace: Option<String> = None;
let mut secure = false;
while !input.is_empty() {
let key: Ident = input.parse()?;
match key.to_string().as_str() {
"client" => {
input.parse::<Token![=]>()?;
client = Some(input.parse()?);
}
"ttl" => {
input.parse::<Token![=]>()?;
let lit: LitInt = input.parse()?;
ttl = Some(lit.base10_parse()?);
}
"interop" => {
input.parse::<Token![=]>()?;
let lit: LitStr = input.parse()?;
interop = Some(parse_segment("interop", &lit)?);
}
"namespace" => {
input.parse::<Token![=]>()?;
let lit: LitStr = input.parse()?;
namespace = Some(parse_segment("namespace", &lit)?);
}
"secure" => {
secure = true;
}
other => {
return Err(syn::Error::new(
key.span(),
format!("unknown attribute `{other}`"),
));
}
}
if !input.is_empty() {
input.parse::<Token![,]>()?;
}
}
let client = client
.ok_or_else(|| input.error("`client` is required: #[cachekit(client = name, ...)]"))?;
let ttl = ttl.ok_or_else(|| {
input.error("`ttl` is required: #[cachekit(client = name, ttl = 60)]")
})?;
let interop = interop.ok_or_else(|| {
input.error(
"`interop` is required: #[cachekit(client = name, ttl = 60, interop = \
\"get_user\", namespace = \"users\")] — it names the operation segment of the \
interop/v1 cache key `{namespace}:{operation}:{args_hash}`, and the spec \
requires it to be explicit (protocol spec/interop-mode.md, SDK requirement 1)",
)
})?;
let namespace = namespace.ok_or_else(|| {
input.error(
"`namespace` is required: #[cachekit(client = name, ttl = 60, interop = \
\"get_user\", namespace = \"users\")] — cache keys are interop/v1 \
`{namespace}:{operation}:{args_hash}` and the namespace segment cannot be empty",
)
})?;
Ok(MacroArgs {
client,
ttl,
interop,
namespace,
secure,
})
}
}
fn extract_ok_type(ret: &ReturnType) -> syn::Result<Type> {
let ty = match ret {
ReturnType::Type(_, ty) => ty.as_ref(),
ReturnType::Default => {
return Err(syn::Error::new_spanned(
ret,
"#[cachekit] function must return Result<T, E> where E: From<CachekitError>",
));
}
};
if let Type::Path(type_path) = ty {
if let Some(seg) = type_path.path.segments.last() {
if seg.ident != "Result" {
return Err(syn::Error::new_spanned(
ty,
"#[cachekit] function must return Result<T, E> where E: From<CachekitError>",
));
}
if let syn::PathArguments::AngleBracketed(args) = &seg.arguments {
if let Some(syn::GenericArgument::Type(first)) = args.args.first() {
return Ok(first.clone());
}
}
}
}
Err(syn::Error::new_spanned(
ty,
"#[cachekit] function must return Result<T, E> where E: From<CachekitError>",
))
}
#[proc_macro_attribute]
pub fn cachekit(attr: TokenStream, item: TokenStream) -> TokenStream {
let args = parse_macro_input!(attr as MacroArgs);
let input_fn = parse_macro_input!(item as ItemFn);
match expand(&args, input_fn) {
Ok(tokens) => tokens.into(),
Err(e) => e.to_compile_error().into(),
}
}
fn expand(args: &MacroArgs, mut func: ItemFn) -> syn::Result<TokenStream2> {
let client_ident = &args.client;
let ttl_secs = args.ttl;
let namespace = args.namespace.as_str();
let operation = args.interop.as_str();
if func.sig.asyncness.is_none() {
return Err(syn::Error::new_spanned(
func.sig.fn_token,
"#[cachekit] requires an `async fn`: the cache read, cold-miss single-flight, \
and stale-while-revalidate background refresh all await — annotate the \
function `async` (a sync function cannot be cached by this macro)",
));
}
let ok_type = extract_ok_type(&func.sig.output)?;
let mut non_client_params: Vec<(&Ident, &Type)> = Vec::new();
for arg in &func.sig.inputs {
match arg {
syn::FnArg::Receiver(recv) => {
return Err(syn::Error::new_spanned(
recv,
"#[cachekit] does not support methods: a `self` receiver cannot \
contribute to the interop/v1 cache key — wrap a free function instead",
));
}
syn::FnArg::Typed(pat) => match pat.pat.as_ref() {
syn::Pat::Ident(pi) => {
if pi.ident != *client_ident {
non_client_params.push((&pi.ident, pat.ty.as_ref()));
}
}
other => {
return Err(syn::Error::new_spanned(
other,
"#[cachekit] parameters must be plain identifiers: destructuring \
patterns cannot contribute to the cache key, and skipping them \
would make different calls share one cache entry",
));
}
},
}
}
let non_client_idents: Vec<&Ident> = non_client_params.iter().map(|(id, _)| *id).collect();
let interop_args = non_client_idents.iter().map(|id| {
quote_spanned! {id.span()=> cachekit::interop::InteropValue::from(#id.clone()) }
});
let (get_expr, get_swr_expr, set_expr, complete_refresh_expr) = if args.secure {
(
quote! {
{
let __ck_sec = #client_ident.secure_cache()?;
__ck_sec.interop_get::<#ok_type>(&__ck_key).await
}
},
quote! {
{
let __ck_sec = #client_ident.secure_cache()?;
__ck_sec.interop_get_swr::<#ok_type>(&__ck_key).await
}
},
quote! {
let __ck_sec = #client_ident.secure_cache()?;
__ck_sec.set_with_ttl(&__ck_key, __ck_val, std::time::Duration::from_secs(#ttl_secs)).await
},
quote! {
let __ck_sec = #client_ident.secure_cache()?;
__ck_sec.__complete_swr_refresh(
&__ck_key,
__ck_val,
std::time::Duration::from_secs(#ttl_secs),
__ck_swr_token,
).await
},
)
} else {
(
quote! { #client_ident.interop_get::<#ok_type>(&__ck_key).await },
quote! { #client_ident.interop_get_swr::<#ok_type>(&__ck_key).await },
quote! {
#client_ident.set_with_ttl(&__ck_key, __ck_val, std::time::Duration::from_secs(#ttl_secs)).await
},
quote! {
#client_ident.__complete_swr_refresh(
&__ck_key,
__ck_val,
std::time::Duration::from_secs(#ttl_secs),
__ck_swr_token,
).await
},
)
};
let mut swr_captures = TokenStream2::new();
let mut swr_rebinds = TokenStream2::new();
for (i, (id, ty)) in non_client_params.iter().enumerate() {
let holder = quote::format_ident!("__ck_swr_arg_{i}");
match ty {
Type::Reference(r) => {
if r.mutability.is_some() {
return Err(syn::Error::new_spanned(
ty,
"#[cachekit] does not support `&mut` parameters: the SWR background \
refresh re-executes the function from an owned snapshot, so mutation \
through the reference cannot be preserved — take the argument by \
owned value instead",
));
}
swr_captures.extend(quote_spanned! {id.span()=>
let #holder = ::std::borrow::ToOwned::to_owned(#id);
});
let is_str = matches!(r.elem.as_ref(),
Type::Path(p) if p.path.is_ident("str"));
if is_str {
swr_rebinds.extend(quote! { let #id = #holder.as_str(); });
} else {
swr_rebinds.extend(quote! { let #id = &#holder; });
}
}
_ => {
swr_captures.extend(quote_spanned! {id.span()=>
#[allow(clippy::clone_on_copy)]
let #holder = #id.clone();
});
swr_rebinds.extend(quote! { let #id = #holder; });
}
}
}
let fail_open_arm = if args.secure {
quote! {}
} else {
quote! {
Err(cachekit::error::CachekitError::Backend(__ck_be))
if __ck_be.kind.is_retryable()
|| matches!(
__ck_be.kind,
cachekit::error::BackendErrorKind::CircuitOpen
| cachekit::error::BackendErrorKind::Backpressure
) => {}
}
};
let original_body = &func.block;
let ret_ty = match &func.sig.output {
ReturnType::Type(_, ty) => ty.as_ref(),
ReturnType::Default => {
return Err(syn::Error::new_spanned(
&func.sig,
"#[cachekit] function must return Result<T, E> where E: From<CachekitError>",
));
}
};
let new_body: syn::Block = syn::parse_quote! {
{
#[allow(clippy::clone_on_copy, noop_method_call)]
let __ck_key = cachekit::interop::interop_key(
#namespace,
#operation,
&[#(#interop_args),*],
)?;
match #get_swr_expr {
Ok(cachekit::SwrRead::Fresh(__ck_cached)) => return Ok(__ck_cached),
Ok(cachekit::SwrRead::Stale(__ck_cached, __ck_swr_token)) => {
let __ck_swr_client = ::std::clone::Clone::clone(#client_ident);
let __ck_swr_key = __ck_key.clone();
#swr_captures
cachekit::__swr_spawn(async move {
let #client_ident = &__ck_swr_client;
let __ck_key = __ck_swr_key;
#swr_rebinds
let _: ::std::result::Result<(), cachekit::error::CachekitError> = async {
let Some(__ck_flight) = #client_ident.__refresh_flight(&__ck_key).await else {
return Ok(());
};
let __ck_result: #ret_ty = (async #original_body).await;
let __ck_ok = __ck_result.ok();
if let Some(ref __ck_val) = __ck_ok {
let __ck_commit = { #complete_refresh_expr };
if let Err(ref __ck_err) = __ck_commit {
cachekit::metrics::__trace_store_error(&__ck_key, __ck_err);
}
}
__ck_flight.release().await;
Ok(())
}
.await;
});
return Ok(__ck_cached);
}
Ok(cachekit::SwrRead::Miss) => {}
Err(cachekit::error::CachekitError::Serialization(_)) => {}
#fail_open_arm
Err(__ck_err) => return Err(__ck_err.into()),
}
let mut __ck_flight = #client_ident.single_flight(&__ck_key).await;
while __ck_flight.wait_for_fill().await {
let Some(__ck_read) = __ck_flight.__bounded_poll(async { #get_expr }).await else {
continue;
};
match __ck_read {
Ok(Some(__ck_cached)) => {
__ck_flight.release().await;
return Ok(__ck_cached);
}
Ok(None) => {}
Err(cachekit::error::CachekitError::Serialization(_)) => {}
#fail_open_arm
Err(__ck_err) => {
__ck_flight.release().await;
return Err(__ck_err.into());
}
}
}
let __ck_result: #ret_ty = (async #original_body).await;
let __ck_stored = if let Ok(ref __ck_val) = __ck_result {
let __ck_store = { #set_expr };
if let Err(ref __ck_err) = __ck_store {
cachekit::metrics::__trace_store_error(&__ck_key, __ck_err);
}
__ck_store.is_ok()
} else {
false
};
if __ck_stored {
__ck_flight.__release_detached().await;
} else {
__ck_flight.release().await;
}
__ck_result
}
};
*func.block = new_body;
Ok(quote! { #func })
}
#[cfg(test)]
mod tests {
use super::{expand, segment_is_valid, MacroArgs};
#[test]
fn sync_fn_is_a_clear_decoration_time_error() {
let args: MacroArgs = syn::parse_quote!(
client = cache,
ttl = 60,
interop = "get_user",
namespace = "users"
);
let func: syn::ItemFn = syn::parse_quote! {
fn get_user(cache: &CacheKit, id: u64) -> Result<String, CachekitError> {
Ok(format!("User {id}"))
}
};
let err = expand(&args, func).expect_err("sync fn must be rejected");
let msg = err.to_string();
assert!(
msg.contains("requires an `async fn`"),
"error must name the fix, got: {msg}"
);
}
#[test]
fn mut_ref_param_is_a_clear_decoration_time_error() {
let args: MacroArgs = syn::parse_quote!(
client = cache,
ttl = 60,
interop = "get_user",
namespace = "users"
);
let func: syn::ItemFn = syn::parse_quote! {
async fn get_user(cache: &CacheKit, id: &mut u64) -> Result<String, CachekitError> {
Ok(format!("User {id}"))
}
};
let err = expand(&args, func).expect_err("&mut param must be rejected");
assert!(
err.to_string().contains("`&mut` parameters"),
"error must name the constraint, got: {err}"
);
}
#[test]
fn async_fn_expands() {
let args: MacroArgs = syn::parse_quote!(
client = cache,
ttl = 60,
interop = "get_user",
namespace = "users"
);
let func: syn::ItemFn = syn::parse_quote! {
async fn get_user(cache: &CacheKit, id: u64) -> Result<String, CachekitError> {
Ok(format!("User {id}"))
}
};
expand(&args, func).expect("async fn must expand");
}
#[test]
fn segment_grammar_table() {
for ok in [
"a",
"0",
"users",
"users.fetch_by_id",
"a-b_c.d",
&"a".repeat(64),
] {
assert!(segment_is_valid(ok), "{ok:?} should be accepted");
}
for bad in [
"",
".users",
"-users",
"_users",
"Users",
"users\n",
"users:x",
"users/x",
"usérs",
&"a".repeat(65),
] {
assert!(!segment_is_valid(bad), "{bad:?} should be rejected");
}
}
#[test]
fn reserved_namespace_is_a_compile_error() {
let attr = |interop: &str, namespace: &str| {
syn::parse_str::<MacroArgs>(&format!(
"client = cache, ttl = 60, interop = {interop:?}, namespace = {namespace:?}"
))
};
for reserved in ["ns", "nsapi"] {
let Err(err) = attr("get_user", reserved) else {
panic!("namespace {reserved:?} must be rejected");
};
assert!(
err.to_string().contains("reserved"),
"error must name the reservation, got: {err}"
);
assert!(
attr(reserved, "users").is_ok(),
"operation {reserved:?} must be accepted"
);
}
assert!(attr("get_user", "nsx").is_ok());
assert!(attr("get_user", "nsapi2").is_ok());
}
#[test]
fn double_dot_segment_is_a_compile_error() {
let attr = |interop: &str, namespace: &str| {
syn::parse_str::<MacroArgs>(&format!(
"client = cache, ttl = 60, interop = {interop:?}, namespace = {namespace:?}"
))
};
for (interop, namespace) in [("get_user", "a..b"), ("x..y", "users"), ("x..", "users")] {
let Err(err) = attr(interop, namespace) else {
panic!("{interop:?}/{namespace:?} must be rejected");
};
assert!(
err.to_string().contains("must not contain"),
"error must name the '..' ban, got: {err}"
);
}
assert!(attr("users.fetch.by_id", "app.").is_ok());
}
}