Skip to main content

cachekit_macros/
lib.rs

1use proc_macro::TokenStream;
2use proc_macro2::TokenStream as TokenStream2;
3use quote::{quote, quote_spanned};
4use syn::{
5    parse::{Parse, ParseStream},
6    parse_macro_input,
7    spanned::Spanned,
8    Ident, ItemFn, LitInt, LitStr, ReturnType, Token, Type,
9};
10
11// ── Attribute argument parsing ───────────────────────────────────────────────
12
13struct MacroArgs {
14    client: Ident,
15    ttl: u64,
16    interop: String,
17    namespace: String,
18    secure: bool,
19}
20
21/// Compile-time mirror of the interop/v1 segment grammar
22/// (`^[a-z0-9][a-z0-9._-]{0,63}$` — see `cachekit::interop`'s
23/// `validate_segment`, the canonical implementation). Duplicated because a
24/// proc-macro crate cannot depend on the runtime crate; `interop_key`
25/// re-validates at runtime, so drift fails loudly, never silently.
26fn segment_is_valid(segment: &str) -> bool {
27    let bytes = segment.as_bytes();
28    matches!(bytes.first(), Some(b) if b.is_ascii_lowercase() || b.is_ascii_digit())
29        && bytes.len() <= 64
30        && bytes[1..].iter().all(|b| {
31            b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'.' | b'_' | b'-')
32        })
33}
34
35/// Extract and validate an interop segment from a string literal, spanning
36/// the error to the literal.
37fn parse_segment(kind: &str, lit: &LitStr) -> syn::Result<String> {
38    let value = lit.value();
39    if segment_is_valid(&value) {
40        Ok(value)
41    } else {
42        Err(syn::Error::new(
43            lit.span(),
44            format!(
45                "`{kind}` {value:?} is not a valid interop/v1 key segment: must match \
46                 ^[a-z0-9][a-z0-9._-]{{0,63}}$ (lowercase ASCII letters, digits, '.', '_', \
47                 '-'; 1-64 chars)"
48            ),
49        ))
50    }
51}
52
53impl Parse for MacroArgs {
54    fn parse(input: ParseStream) -> syn::Result<Self> {
55        let mut client: Option<Ident> = None;
56        let mut ttl: Option<u64> = None;
57        let mut interop: Option<String> = None;
58        let mut namespace: Option<String> = None;
59        let mut secure = false;
60
61        while !input.is_empty() {
62            let key: Ident = input.parse()?;
63            match key.to_string().as_str() {
64                "client" => {
65                    input.parse::<Token![=]>()?;
66                    client = Some(input.parse()?);
67                }
68                "ttl" => {
69                    input.parse::<Token![=]>()?;
70                    let lit: LitInt = input.parse()?;
71                    ttl = Some(lit.base10_parse()?);
72                }
73                "interop" => {
74                    input.parse::<Token![=]>()?;
75                    let lit: LitStr = input.parse()?;
76                    interop = Some(parse_segment("interop", &lit)?);
77                }
78                "namespace" => {
79                    input.parse::<Token![=]>()?;
80                    let lit: LitStr = input.parse()?;
81                    namespace = Some(parse_segment("namespace", &lit)?);
82                }
83                "secure" => {
84                    secure = true;
85                }
86                other => {
87                    return Err(syn::Error::new(
88                        key.span(),
89                        format!("unknown attribute `{other}`"),
90                    ));
91                }
92            }
93
94            // Consume trailing comma if present
95            if !input.is_empty() {
96                input.parse::<Token![,]>()?;
97            }
98        }
99
100        let client = client
101            .ok_or_else(|| input.error("`client` is required: #[cachekit(client = name, ...)]"))?;
102        let ttl = ttl.ok_or_else(|| {
103            input.error("`ttl` is required: #[cachekit(client = name, ttl = 60)]")
104        })?;
105        let interop = interop.ok_or_else(|| {
106            input.error(
107                "`interop` is required: #[cachekit(client = name, ttl = 60, interop = \
108                 \"get_user\", namespace = \"users\")] — it names the operation segment of the \
109                 interop/v1 cache key `{namespace}:{operation}:{args_hash}`, and the spec \
110                 requires it to be explicit (protocol spec/interop-mode.md, SDK requirement 1)",
111            )
112        })?;
113        let namespace = namespace.ok_or_else(|| {
114            input.error(
115                "`namespace` is required: #[cachekit(client = name, ttl = 60, interop = \
116                 \"get_user\", namespace = \"users\")] — cache keys are interop/v1 \
117                 `{namespace}:{operation}:{args_hash}` and the namespace segment cannot be empty",
118            )
119        })?;
120
121        Ok(MacroArgs {
122            client,
123            ttl,
124            interop,
125            namespace,
126            secure,
127        })
128    }
129}
130
131// ── Return-type extraction ───────────────────────────────────────────────────
132
133/// Extract `T` from `Result<T, E>` in a function's return type.
134fn extract_ok_type(ret: &ReturnType) -> syn::Result<Type> {
135    let ty = match ret {
136        ReturnType::Type(_, ty) => ty.as_ref(),
137        ReturnType::Default => {
138            return Err(syn::Error::new_spanned(
139                ret,
140                "#[cachekit] function must return Result<T, CachekitError>",
141            ));
142        }
143    };
144
145    // Walk through the type to find Result<T, E> and extract T.
146    if let Type::Path(type_path) = ty {
147        if let Some(seg) = type_path.path.segments.last() {
148            if seg.ident != "Result" {
149                return Err(syn::Error::new_spanned(
150                    ty,
151                    "#[cachekit] function must return Result<T, CachekitError>",
152                ));
153            }
154            if let syn::PathArguments::AngleBracketed(args) = &seg.arguments {
155                if let Some(syn::GenericArgument::Type(first)) = args.args.first() {
156                    return Ok(first.clone());
157                }
158            }
159        }
160    }
161
162    Err(syn::Error::new_spanned(
163        ty,
164        "#[cachekit] function must return Result<T, CachekitError>",
165    ))
166}
167
168// ── Proc-macro entry point ──────────────────────────────────────────────────
169
170/// Cache the result of an async function under an interop/v1 cache key.
171///
172/// Keys are `{namespace}:{operation}:{args_hash}` per the protocol's
173/// interop-mode spec, so the same entry is addressable from the Python and
174/// TypeScript SDKs — Python's `@cache(interop="get_user",
175/// namespace="users")` computes the identical key for the same arguments.
176///
177/// # Attributes
178///
179/// - `client = <ident>` (required): Name of the `&CacheKit` parameter.
180/// - `ttl = <integer>` (required): TTL in seconds.
181/// - `interop = "<string>"` (required): explicit, language-neutral operation
182///   segment (`^[a-z0-9][a-z0-9._-]{0,63}$`) — same meaning as Python's
183///   `interop=` / TypeScript's `interop:`.
184/// - `namespace = "<string>"` (required): interop/v1 namespace segment,
185///   same grammar.
186/// - `secure` (optional flag): Use encrypted cache via `cache.secure()`.
187///
188/// # Requirements
189///
190/// - Every non-client argument must be a plain identifier (no destructuring
191///   patterns, no `self`) and must convert into
192///   `cachekit::interop::InteropValue` via `From` (`bool`, `i32`/`i64`/
193///   `u32`/`u64`/`i128`, `f64`, `&str`/`String`, `Uuid`). Unsupported
194///   argument types fail at compile time.
195/// - In-model *values* outside the interop ranges fail at CALL time with
196///   `CachekitError::Serialization` before the body runs: non-finite floats
197///   (`NAN`, `±INFINITY`) and `i128` outside `[-2^63, 2^64-1]`. This
198///   fail-loud contract matches the Python and TypeScript SDKs — silently
199///   running uncached would mask cross-SDK key divergence.
200/// - The client must be built **without** `.namespace()` /
201///   `CACHEKIT_NAMESPACE` — interop keys carry their own namespace segment,
202///   and reads fail closed on a namespaced client.
203/// - A stored entry that cannot be decoded as the return type is treated as
204///   a miss and overwritten (self-healing), never an error loop.
205///
206/// # Example
207///
208/// ```ignore
209/// #[cachekit(client = cache, ttl = 60, interop = "get_user", namespace = "users")]
210/// async fn get_user(cache: &CacheKit, id: u64) -> Result<User, CachekitError> {
211///     Ok(User { name: format!("User {id}") })
212/// }
213/// ```
214#[proc_macro_attribute]
215pub fn cachekit(attr: TokenStream, item: TokenStream) -> TokenStream {
216    let args = parse_macro_input!(attr as MacroArgs);
217    let input_fn = parse_macro_input!(item as ItemFn);
218
219    match expand(&args, input_fn) {
220        Ok(tokens) => tokens.into(),
221        Err(e) => e.to_compile_error().into(),
222    }
223}
224
225fn expand(args: &MacroArgs, mut func: ItemFn) -> syn::Result<TokenStream2> {
226    let client_ident = &args.client;
227    let ttl_secs = args.ttl;
228    let namespace = args.namespace.as_str();
229    let operation = args.interop.as_str();
230
231    // Extract the Ok type from Result<T, E>
232    let ok_type = extract_ok_type(&func.sig.output)?;
233
234    // Collect non-client parameter idents for cache key derivation. Every
235    // parameter MUST contribute to the key — a silently skipped parameter
236    // means two different calls share one cache entry (wrong-data collision),
237    // so anything we can't name is a compile error, never a skip.
238    let mut non_client_idents: Vec<&Ident> = Vec::new();
239    for arg in &func.sig.inputs {
240        match arg {
241            syn::FnArg::Receiver(recv) => {
242                return Err(syn::Error::new_spanned(
243                    recv,
244                    "#[cachekit] does not support methods: a `self` receiver cannot \
245                     contribute to the interop/v1 cache key — wrap a free function instead",
246                ));
247            }
248            syn::FnArg::Typed(pat) => match pat.pat.as_ref() {
249                syn::Pat::Ident(pi) => {
250                    if pi.ident != *client_ident {
251                        non_client_idents.push(&pi.ident);
252                    }
253                }
254                other => {
255                    return Err(syn::Error::new_spanned(
256                        other,
257                        "#[cachekit] parameters must be plain identifiers: destructuring \
258                         patterns cannot contribute to the cache key, and skipping them \
259                         would make different calls share one cache entry",
260                    ));
261                }
262            },
263        }
264    }
265
266    // Convert each non-client argument to an InteropValue. `.clone()` keeps
267    // the original binding usable by the function body; for Copy types it
268    // compiles to a copy. Types outside the interop data model fail here at
269    // COMPILE time with a missing `From<T> for InteropValue` — that is the
270    // contract, not a bug: interop/v1 keys only hash modelable values.
271    // quote_spanned pins that error to the offending parameter, not the
272    // attribute.
273    let interop_args = non_client_idents.iter().map(|id| {
274        quote_spanned! {id.span()=> cachekit::interop::InteropValue::from(#id.clone()) }
275    });
276
277    // Generate cache get/set calls depending on `secure` flag.
278    let (get_expr, set_expr) = if args.secure {
279        (
280            quote! {
281                {
282                    let __ck_sec = #client_ident.secure()?;
283                    __ck_sec.interop_get::<#ok_type>(&__ck_key).await
284                }
285            },
286            quote! {
287                let __ck_sec = #client_ident.secure()?;
288                let _ = __ck_sec.set_with_ttl(&__ck_key, __ck_val, std::time::Duration::from_secs(#ttl_secs)).await;
289            },
290        )
291    } else {
292        (
293            quote! { #client_ident.interop_get::<#ok_type>(&__ck_key).await },
294            quote! {
295                let _ = #client_ident.set_with_ttl(&__ck_key, __ck_val, std::time::Duration::from_secs(#ttl_secs)).await;
296            },
297        )
298    };
299
300    // Capture the original function body.
301    let original_body = &func.block;
302
303    // Extract the raw return type (without `->` arrow) for type annotation.
304    let ret_ty = match &func.sig.output {
305        ReturnType::Type(_, ty) => ty.as_ref(),
306        ReturnType::Default => {
307            return Err(syn::Error::new_spanned(
308                &func.sig,
309                "#[cachekit] function must return Result<T, CachekitError>",
310            ));
311        }
312    };
313
314    // Build the new function body.
315    let new_body: syn::Block = syn::parse_quote! {
316        {
317            // interop/v1 key: {namespace}:{operation}:{args_hash} — the same
318            // key is computable from any SDK (protocol spec/interop-mode.md).
319            // The generated `.clone()` per argument keeps the binding usable
320            // by the body; for Copy types it is a copy — hence the allow.
321            #[allow(clippy::clone_on_copy)]
322            let __ck_key = cachekit::interop::interop_key(
323                #namespace,
324                #operation,
325                &[#(#interop_args),*],
326            )?;
327
328            // Try cache hit. A Serialization error means the stored entry
329            // cannot be decoded as #ok_type (poisoned, foreign-shaped, or a
330            // Python-internal CK frame) — treat it as a miss so the fresh
331            // result OVERWRITES it, instead of hard-failing every call until
332            // TTL expiry. All other errors (namespaced-client Config, backend
333            // failures) propagate.
334            match #get_expr {
335                Ok(Some(__ck_cached)) => return Ok(__ck_cached),
336                Ok(None) => {}
337                Err(cachekit::error::CachekitError::Serialization(_)) => {}
338                Err(__ck_err) => return Err(__ck_err.into()),
339            }
340
341            // Execute original function body
342            let __ck_result: #ret_ty = (async #original_body).await;
343
344            // Cache on success
345            if let Ok(ref __ck_val) = __ck_result {
346                #set_expr
347            }
348
349            __ck_result
350        }
351    };
352
353    *func.block = new_body;
354    Ok(quote! { #func })
355}
356
357#[cfg(test)]
358mod tests {
359    use super::segment_is_valid;
360
361    /// Mirror of interop.rs's validate_segment acceptance table — the two
362    /// implementations must not drift (see the NOTE on validate_segment).
363    #[test]
364    fn segment_grammar_table() {
365        for ok in [
366            "a",
367            "0",
368            "users",
369            "users.fetch_by_id",
370            "a-b_c.d",
371            &"a".repeat(64),
372        ] {
373            assert!(segment_is_valid(ok), "{ok:?} should be accepted");
374        }
375        for bad in [
376            "",
377            ".users",
378            "-users",
379            "_users",
380            "Users",
381            "users\n",
382            "users:x",
383            "users/x",
384            "usérs",
385            &"a".repeat(65),
386        ] {
387            assert!(!segment_is_valid(bad), "{bad:?} should be rejected");
388        }
389    }
390}