gemini_adk_macros_rs/
lib.rs

1#![cfg_attr(docsrs, feature(doc_cfg))]
2#![forbid(unsafe_code)]
3//! Procedural macros for `gemini-adk-rs`.
4//!
5//! This crate provides the [`macro@tool`] attribute macro, which turns a plain
6//! `async fn` into a registrable Gemini tool — eliminating the
7//! `TypedTool::new::<Args>` + separate-args-struct ceremony.
8//!
9//! You normally don't depend on this crate directly. The [`macro@tool`] macro is
10//! re-exported from `gemini-adk-rs` and the `gemini-adk-fluent-rs` prelude:
11//!
12//! ```ignore
13//! use gemini_adk_fluent_rs::prelude::*;   // brings `tool` into scope
14//! use serde_json::{json, Value};
15//!
16//! /// Get the current weather for a city.
17//! #[tool("Get the current weather for a city")]
18//! async fn get_weather(city: String, units: Option<String>) -> Result<Value, ToolError> {
19//!     Ok(json!({ "city": city, "units": units.unwrap_or("metric".into()) }))
20//! }
21//!
22//! // `get_weather()` returns a value implementing `ToolFunction`.
23//! let mut d = ToolDispatcher::new();
24//! d.register_function(std::sync::Arc::new(get_weather()));
25//! ```
26
27use proc_macro::TokenStream;
28use proc_macro2::Span;
29use quote::{format_ident, quote};
30use syn::{
31    Data, DeriveInput, Expr, ExprLit, Fields, FnArg, ItemFn, Lit, LitInt, LitStr, Meta, Pat,
32    PatType, ReturnType, Type, TypePath, parse_macro_input,
33};
34
35/// Where the runtime crate (`gemini-adk-rs`) is reachable from the expansion
36/// site, as a path and as the string form the derive `crate = ".."` attributes
37/// want.
38///
39/// A direct dependency wins (under whatever name it was renamed to); a crate
40/// that depends only on `gemini-adk-fluent-rs` reaches it through that
41/// crate's `gemini_adk_rs` re-export. Inside `gemini-adk-rs` itself the crate
42/// declares `extern crate self as gemini_adk_rs`, so the plain name works
43/// there and in its own tests.
44fn runtime() -> (proc_macro2::TokenStream, String) {
45    use proc_macro_crate::{FoundCrate, crate_name};
46    match crate_name("gemini-adk-rs") {
47        Ok(FoundCrate::Name(name)) => {
48            let ident = format_ident!("{name}");
49            (quote! { ::#ident }, name)
50        }
51        Ok(FoundCrate::Itself) => (quote! { ::gemini_adk_rs }, "gemini_adk_rs".to_string()),
52        Err(_) => match crate_name("gemini-adk-fluent-rs") {
53            Ok(FoundCrate::Name(name)) => {
54                let ident = format_ident!("{name}");
55                (
56                    quote! { ::#ident::gemini_adk_rs },
57                    format!("{name}::gemini_adk_rs"),
58                )
59            }
60            _ => (quote! { ::gemini_adk_rs }, "gemini_adk_rs".to_string()),
61        },
62    }
63}
64
65/// Turn an `async fn` into a registrable Gemini tool.
66///
67/// The attribute takes a single string literal — the tool's description, as
68/// surfaced to the model:
69///
70/// ```ignore
71/// #[tool("Get the current weather for a city")]
72/// async fn get_weather(city: String, units: Option<String>) -> Result<Value, ToolError> {
73///     Ok(json!({ "city": city, "units": units.unwrap_or("metric".into()) }))
74/// }
75/// ```
76///
77/// # What it generates
78///
79/// For a function `fn foo(...)`, the macro emits:
80///
81/// - A hidden args struct `__FooArgs` deriving `serde::Deserialize` and
82///   `schemars::JsonSchema`, with one field per parameter. This drives both
83///   argument deserialization and JSON-Schema generation.
84/// - A hidden tool type `__FooTool` implementing
85///   `gemini_adk_rs::tool::ToolFunction`:
86///   - `name()` returns the function name (`"foo"`).
87///   - `description()` returns the attribute string.
88///   - `parameters()` returns the schemars-generated JSON Schema.
89///   - `call(args)` deserializes `args` into `__FooArgs`, runs the original
90///     function body, and returns its `Result<Value, ToolError>`.
91/// - A public constructor `fn foo() -> __FooTool` (visibility matches the
92///   original fn) that you register with a `gemini_adk_rs::tool::ToolDispatcher`:
93///
94/// ```ignore
95/// dispatcher.register_function(std::sync::Arc::new(foo()));
96/// ```
97///
98/// # Supported parameters
99///
100/// Any parameter type that is `serde::Deserialize + schemars::JsonSchema` is
101/// supported. `Option<T>` parameters are optional in the schema. Zero-parameter
102/// tools are supported (the generated schema is an empty object).
103///
104/// # Path hygiene
105///
106/// Generated code reaches `serde`, `schemars`, `serde_json`, and `async_trait`
107/// through the runtime crate's `__macros` module (the derives are pointed
108/// there with `#[serde(crate = ..)]` / `#[schemars(crate = ..)]`), so none of
109/// them need to be in your `Cargo.toml`. The runtime crate itself is located
110/// at expansion time: `gemini-adk-rs` if it is a direct dependency (under
111/// whatever name), else through `gemini-adk-fluent-rs`'s re-export — so a
112/// crate that depends only on the fluent layer can use `#[tool]` from its
113/// prelude.
114///
115/// # Follow-ups (not yet supported)
116///
117/// - Per-parameter doc descriptions are not extracted into the schema in v1.
118///   Function parameters cannot carry doc comments in Rust, so this would
119///   require a `#[doc = "..."]`-style attribute on each param.
120#[proc_macro_attribute]
121pub fn tool(attr: TokenStream, item: TokenStream) -> TokenStream {
122    let description = parse_macro_input!(attr as LitStr);
123    let func = parse_macro_input!(item as ItemFn);
124
125    match expand(description, func) {
126        Ok(ts) => ts.into(),
127        Err(e) => e.to_compile_error().into(),
128    }
129}
130
131fn expand(description: LitStr, func: ItemFn) -> syn::Result<proc_macro2::TokenStream> {
132    let sig = &func.sig;
133
134    if sig.asyncness.is_none() {
135        return Err(syn::Error::new_spanned(
136            sig.fn_token,
137            "#[tool] requires an `async fn`",
138        ));
139    }
140    if let Some(variadic) = &sig.variadic {
141        return Err(syn::Error::new_spanned(
142            variadic,
143            "#[tool] does not support variadic functions",
144        ));
145    }
146    if !sig.generics.params.is_empty() {
147        return Err(syn::Error::new_spanned(
148            sig.generics.clone(),
149            "#[tool] does not support generic functions",
150        ));
151    }
152
153    let fn_name = &sig.ident;
154    let vis = &func.vis;
155    let body = &func.block;
156    let output = &sig.output;
157
158    // Collect (ident, type) for each parameter; reject `self` receivers.
159    let mut field_idents = Vec::new();
160    let mut field_types = Vec::new();
161    for input in &sig.inputs {
162        match input {
163            FnArg::Receiver(r) => {
164                return Err(syn::Error::new_spanned(
165                    r,
166                    "#[tool] cannot be applied to methods taking `self`",
167                ));
168            }
169            FnArg::Typed(PatType { pat, ty, .. }) => {
170                let ident = match pat.as_ref() {
171                    Pat::Ident(pat_ident) => pat_ident.ident.clone(),
172                    other => {
173                        return Err(syn::Error::new_spanned(
174                            other,
175                            "#[tool] parameters must be simple identifiers (no patterns)",
176                        ));
177                    }
178                };
179                field_idents.push(ident);
180                field_types.push((*ty).clone());
181            }
182        }
183    }
184
185    // The return type must be present (`-> Result<...>`); the body is reused
186    // verbatim, so we just forward whatever the user wrote.
187    let return_type: proc_macro2::TokenStream = match output {
188        ReturnType::Default => {
189            return Err(syn::Error::new_spanned(
190                sig,
191                "#[tool] requires a return type of `Result<serde_json::Value, ToolError>`",
192            ));
193        }
194        ReturnType::Type(_, ty) => quote! { #ty },
195    };
196
197    // Naming for generated items, derived from the (Pascal-cased) fn name.
198    let pascal = to_pascal_case(&fn_name.to_string());
199    let args_struct = format_ident!("__{}Args", pascal);
200    let tool_struct = format_ident!("__{}Tool", pascal);
201    // The inner async fn that holds the original body, invoked from `call`.
202    let inner_fn = format_ident!("__{}_impl", fn_name);
203
204    let fn_name_str = fn_name.to_string();
205
206    // Build the hidden args struct fields.
207    let struct_fields = field_idents
208        .iter()
209        .zip(field_types.iter())
210        .map(|(ident, ty)| {
211            // `Option<T>` fields default to `None` when absent from the JSON.
212            // No trailing comma here — `#(#struct_fields),*` adds the separators.
213            if is_option(ty) {
214                quote! {
215                    #[serde(default)]
216                    #ident: #ty
217                }
218            } else {
219                quote! { #ident: #ty }
220            }
221        });
222
223    // Destructure the args struct into the original parameter bindings, then
224    // forward them positionally into the inner impl fn.
225    let destructure = &field_idents;
226    let forward_args = &field_idents;
227
228    // Upstream crates are reached through `gemini_adk_rs::__macros` so the consumer
229    // doesn't need them in scope under those exact names.
230    let (rt, rt_str) = runtime();
231    let serde = quote! { #rt::__macros::serde };
232    let schemars = quote! { #rt::__macros::schemars };
233    let async_trait = quote! { #rt::__macros::async_trait };
234    let serde_json = quote! { #rt::__macros::serde_json };
235    let serde_crate = LitStr::new(&format!("{rt_str}::__macros::serde"), Span::call_site());
236    let schemars_crate = LitStr::new(&format!("{rt_str}::__macros::schemars"), Span::call_site());
237
238    let expanded = quote! {
239        // Hidden args struct: drives both deserialization and schema generation.
240        #[derive(#serde::Deserialize, #schemars::JsonSchema)]
241        #[serde(crate = #serde_crate)]
242        #[schemars(crate = #schemars_crate)]
243        #[allow(non_camel_case_types, non_snake_case)]
244        struct #args_struct {
245            #(#struct_fields),*
246        }
247
248        // The original function body, preserved verbatim as a free async fn.
249        #[allow(non_snake_case)]
250        async fn #inner_fn ( #(#field_idents : #field_types),* ) -> #return_type #body
251
252        // Hidden tool type implementing `ToolFunction`.
253        #[allow(non_camel_case_types)]
254        #vis struct #tool_struct;
255
256        #[#async_trait::async_trait]
257        impl #rt::tool::ToolFunction for #tool_struct {
258            fn name(&self) -> &str {
259                #fn_name_str
260            }
261
262            fn description(&self) -> &str {
263                #description
264            }
265
266            fn parameters(&self) -> ::core::option::Option<#serde_json::Value> {
267                let root = #schemars::schema_for!(#args_struct);
268                ::core::option::Option::Some(
269                    #serde_json::to_value(root)
270                        .expect("schemars schema should serialize to JSON"),
271                )
272            }
273
274            async fn call(
275                &self,
276                args: #serde_json::Value,
277            ) -> ::core::result::Result<#serde_json::Value, #rt::error::ToolError> {
278                let #args_struct { #(#destructure),* } =
279                    #serde_json::from_value(args).map_err(|e| {
280                        #rt::error::ToolError::InvalidArgs(
281                            ::std::format!("Failed to deserialize arguments: {e}"),
282                        )
283                    })?;
284                #inner_fn ( #(#forward_args),* ).await
285            }
286        }
287
288        // Public constructor: `fn foo() -> __FooTool`.
289        #[allow(non_snake_case)]
290        #vis fn #fn_name () -> #tool_struct {
291            #tool_struct
292        }
293    };
294
295    Ok(expanded)
296}
297
298/// Derive an `Extract` record builder from a struct's fields.
299///
300/// Each field carries a `#[recognize(..)]` attribute naming a deterministic
301/// recognizer; the macro generates an inherent `fn extract() -> Extract` that
302/// builds the record. The field name becomes the record field name and (by
303/// default) its `State` key.
304///
305/// ```ignore
306/// use gemini_adk_rs::extract::Extract;   // the type — same name, type namespace
307/// use gemini_adk_rs::Extract;            // the derive — macro namespace
308///
309/// #[derive(Extract)]
310/// #[extract(name = "order", window = 3)]
311/// struct Order {
312///     #[recognize(integer_near = ["want", "get"])]
313///     quantity: Option<i64>,
314///     #[recognize(one_of = ["pizza", "salad", "soda"])]
315///     item: Option<String>,
316///     #[recognize(datetime)]
317///     #[extract(state = "when")]
318///     pickup: Option<serde_json::Value>,
319///     #[recognize(yes_no)]
320///     confirmed: Option<bool>,
321/// }
322///
323/// let record: Extract = Order::extract();
324/// ```
325///
326/// # Recognizer forms
327///
328/// | Attribute | Recognizer |
329/// |---|---|
330/// | `#[recognize(integer)]` | `Recognizer::integer()` |
331/// | `#[recognize(integer_near = ["a", "b"])]` | `Recognizer::integer_near([..])` |
332/// | `#[recognize(money)]` | `Recognizer::money()` |
333/// | `#[recognize(regex = "pat")]` | `Recognizer::regex("pat")` |
334/// | `#[recognize(one_of = ["a", "b"])]` | `Recognizer::one_of([..])` |
335/// | `#[recognize(fuzzy = ["a", "b"])]` | `Recognizer::fuzzy([..])` |
336/// | `#[recognize(yes_no)]` | `Recognizer::yes_no()` |
337/// | `#[recognize(datetime)]` | `Recognizer::datetime()` |
338///
339/// # Options
340///
341/// - Container `#[extract(name = "...")]` — record name (default: the struct
342///   name in `snake_case`).
343/// - Container `#[extract(window = N)]` — transcript window (default `3`).
344/// - Field `#[extract(state = "key")]` — promote to a custom `State` key.
345///
346/// Fields without a `#[recognize(..)]` attribute are ignored.
347#[proc_macro_derive(Extract, attributes(recognize, extract))]
348pub fn derive_extract(item: TokenStream) -> TokenStream {
349    let input = parse_macro_input!(item as DeriveInput);
350    match expand_extract(input) {
351        Ok(ts) => ts.into(),
352        Err(e) => e.to_compile_error().into(),
353    }
354}
355
356fn expand_extract(input: DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
357    let (rt, _) = runtime();
358    let ident = &input.ident;
359
360    let fields = match &input.data {
361        Data::Struct(s) => match &s.fields {
362            Fields::Named(named) => &named.named,
363            _ => {
364                return Err(syn::Error::new_spanned(
365                    ident,
366                    "#[derive(Extract)] requires a struct with named fields",
367                ));
368            }
369        },
370        _ => {
371            return Err(syn::Error::new_spanned(
372                ident,
373                "#[derive(Extract)] can only be applied to structs",
374            ));
375        }
376    };
377
378    // Container options: name + window.
379    let mut name = to_snake_case(&ident.to_string());
380    let mut window: usize = 3;
381    for attr in &input.attrs {
382        if attr.path().is_ident("extract") {
383            attr.parse_nested_meta(|meta| {
384                if meta.path.is_ident("name") {
385                    let v: LitStr = meta.value()?.parse()?;
386                    name = v.value();
387                } else if meta.path.is_ident("window") {
388                    let v: LitInt = meta.value()?.parse()?;
389                    window = v.base10_parse()?;
390                } else {
391                    return Err(
392                        meta.error("unknown `extract` option (expected `name` or `window`)")
393                    );
394                }
395                Ok(())
396            })?;
397        }
398    }
399
400    // Every named field, referenced by a hidden marker method so that deriving
401    // `Extract` on an otherwise-unread struct does not trip `dead_code`.
402    let all_field_idents: Vec<_> = fields.iter().filter_map(|f| f.ident.clone()).collect();
403
404    // One `.field(..)` / `.field_to(..)` call per recognized field.
405    let mut field_calls = Vec::new();
406    for field in fields {
407        let Some(recognize) = field.attrs.iter().find(|a| a.path().is_ident("recognize")) else {
408            continue;
409        };
410        let fname = field.ident.as_ref().expect("named field").to_string();
411        let recognizer = recognizer_expr(recognize)?;
412
413        // Optional per-field state-key override.
414        let mut state_key: Option<String> = None;
415        for attr in &field.attrs {
416            if attr.path().is_ident("extract") {
417                attr.parse_nested_meta(|meta| {
418                    if meta.path.is_ident("state") {
419                        let v: LitStr = meta.value()?.parse()?;
420                        state_key = Some(v.value());
421                    } else {
422                        return Err(meta.error("unknown field `extract` option (expected `state`)"));
423                    }
424                    Ok(())
425                })?;
426            }
427        }
428
429        field_calls.push(match state_key {
430            Some(sk) => quote! { .field_to(#fname, #sk, #recognizer) },
431            None => quote! { .field(#fname, #recognizer) },
432        });
433    }
434
435    let doc = format!("The `Extract` record derived from `{ident}`'s `#[recognize(..)]` fields.");
436    Ok(quote! {
437        impl #ident {
438            #[doc = #doc]
439            pub fn extract() -> #rt::extract::Extract {
440                #rt::extract::Extract::record(#name)
441                    #(#field_calls)*
442                    .window(#window)
443                    .build()
444            }
445
446            #[allow(dead_code)]
447            #[doc(hidden)]
448            fn __extract_mark_fields_used(&self) {
449                #( let _ = &self.#all_field_idents; )*
450            }
451        }
452    })
453}
454
455/// Derive a [`Frame`] impl from a struct's `#[slot(..)]` fields.
456///
457/// Every named field becomes a slot (state key = field name unless overridden).
458/// The generated `fn frame() -> FrameSpec` carries each slot's prompt, reprompt,
459/// confirmation policy, and PII flag — the metadata the conversation compiler and
460/// repair use.
461///
462/// ```ignore
463/// #[derive(Frame)]
464/// #[frame(name = "booking")]
465/// struct Booking {
466///     #[slot(prompt = "For how many people?", confirm = "low_confidence")]
467///     party_size: u8,
468///     #[slot(prompt = "Name?", pii)]
469///     name: String,
470/// }
471/// ```
472///
473/// Field `#[slot(..)]` options: `prompt`, `reprompt`, `confirm`
474/// (`never`/`low_confidence`/`always`), `state` (key override), `pii` (flag).
475/// Container `#[frame(name = "...")]` sets the frame name.
476#[proc_macro_derive(Frame, attributes(slot, frame, recognize))]
477pub fn derive_frame(item: TokenStream) -> TokenStream {
478    let input = parse_macro_input!(item as DeriveInput);
479    match expand_frame(input) {
480        Ok(ts) => ts.into(),
481        Err(e) => e.to_compile_error().into(),
482    }
483}
484
485fn expand_frame(input: DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
486    let (rt, _) = runtime();
487    let ident = &input.ident;
488
489    let fields = match &input.data {
490        Data::Struct(s) => match &s.fields {
491            Fields::Named(named) => &named.named,
492            _ => {
493                return Err(syn::Error::new_spanned(
494                    ident,
495                    "#[derive(Frame)] requires a struct with named fields",
496                ));
497            }
498        },
499        _ => {
500            return Err(syn::Error::new_spanned(
501                ident,
502                "#[derive(Frame)] can only be applied to structs",
503            ));
504        }
505    };
506
507    // Container `#[frame(name = "...")]`.
508    let mut name = to_snake_case(&ident.to_string());
509    for attr in &input.attrs {
510        if attr.path().is_ident("frame") {
511            attr.parse_nested_meta(|meta| {
512                if meta.path.is_ident("name") {
513                    let v: LitStr = meta.value()?.parse()?;
514                    name = v.value();
515                    Ok(())
516                } else {
517                    Err(meta.error("unknown `frame` option (expected `name`)"))
518                }
519            })?;
520        }
521    }
522
523    let all_field_idents: Vec<_> = fields.iter().filter_map(|f| f.ident.clone()).collect();
524
525    let mut slot_exprs = Vec::new();
526    for field in fields {
527        let fname = field.ident.as_ref().expect("named field").to_string();
528        let mut state_key = fname.clone();
529        let mut prompt: Option<String> = None;
530        let mut reprompt: Option<String> = None;
531        let mut confirm = quote! { #rt::frame::ConfirmPolicy::Never };
532        let mut pii = false;
533        let mut min: Option<f64> = None;
534        let mut max: Option<f64> = None;
535        let mut non_empty = false;
536
537        // Optional `#[recognize(..)]` (same vocabulary as `#[derive(Extract)]`).
538        let recognizer = match field.attrs.iter().find(|a| a.path().is_ident("recognize")) {
539            Some(attr) => {
540                let r = slot_recognizer_expr(attr)?;
541                quote! { Some(#r) }
542            }
543            None => quote! { None },
544        };
545
546        for attr in &field.attrs {
547            if !attr.path().is_ident("slot") {
548                continue;
549            }
550            attr.parse_nested_meta(|meta| {
551                if meta.path.is_ident("prompt") {
552                    let v: LitStr = meta.value()?.parse()?;
553                    prompt = Some(v.value());
554                } else if meta.path.is_ident("reprompt") {
555                    let v: LitStr = meta.value()?.parse()?;
556                    reprompt = Some(v.value());
557                } else if meta.path.is_ident("state") {
558                    let v: LitStr = meta.value()?.parse()?;
559                    state_key = v.value();
560                } else if meta.path.is_ident("confirm") {
561                    let v: LitStr = meta.value()?.parse()?;
562                    confirm = match v.value().as_str() {
563                        "never" => quote! { #rt::frame::ConfirmPolicy::Never },
564                        "low_confidence" => {
565                            quote! { #rt::frame::ConfirmPolicy::LowConfidence }
566                        }
567                        "always" => quote! { #rt::frame::ConfirmPolicy::Always },
568                        other => {
569                            return Err(meta.error(format!(
570                                "unknown confirm policy '{other}' (expected never/low_confidence/always)"
571                            )))
572                        }
573                    };
574                } else if meta.path.is_ident("pii") {
575                    pii = true;
576                } else if meta.path.is_ident("min") {
577                    min = Some(lit_to_f64(&meta.value()?.parse()?)?);
578                } else if meta.path.is_ident("max") {
579                    max = Some(lit_to_f64(&meta.value()?.parse()?)?);
580                } else if meta.path.is_ident("non_empty") {
581                    non_empty = true;
582                } else {
583                    return Err(meta.error(
584                        "unknown `slot` option (expected prompt/reprompt/state/confirm/pii/min/max/non_empty)",
585                    ));
586                }
587                Ok(())
588            })?;
589        }
590
591        // Lower min/max/non_empty into a serializable SlotValidator.
592        let validate = if min.is_some() || max.is_some() {
593            let min_tok = match min {
594                Some(v) => quote! { Some(#v) },
595                None => quote! { None },
596            };
597            let max_tok = match max {
598                Some(v) => quote! { Some(#v) },
599                None => quote! { None },
600            };
601            quote! { Some(#rt::frame::SlotValidator::Range { min: #min_tok, max: #max_tok }) }
602        } else if non_empty {
603            quote! { Some(#rt::frame::SlotValidator::NonEmpty) }
604        } else {
605            quote! { None }
606        };
607
608        let prompt_tok = match prompt {
609            Some(p) => quote! { Some(#p.to_string()) },
610            None => quote! { None },
611        };
612        let reprompt_tok = match reprompt {
613            Some(p) => quote! { Some(#p.to_string()) },
614            None => quote! { None },
615        };
616        slot_exprs.push(quote! {
617            #rt::frame::SlotSpec {
618                name: #fname.to_string(),
619                state_key: #state_key.to_string(),
620                prompt: #prompt_tok,
621                reprompt: #reprompt_tok,
622                confirm: #confirm,
623                pii: #pii,
624                recognizer: #recognizer,
625                validate: #validate,
626            }
627        });
628    }
629
630    let doc = format!("The `FrameSpec` derived from `{ident}`'s `#[slot(..)]` fields.");
631    Ok(quote! {
632        impl #rt::frame::Frame for #ident {
633            #[doc = #doc]
634            fn frame() -> #rt::frame::FrameSpec {
635                #rt::frame::FrameSpec {
636                    name: #name.to_string(),
637                    slots: ::std::vec![ #(#slot_exprs),* ],
638                }
639            }
640        }
641
642        impl #ident {
643            #[allow(dead_code)]
644            #[doc(hidden)]
645            fn __frame_mark_fields_used(&self) {
646                #( let _ = &self.#all_field_idents; )*
647            }
648        }
649    })
650}
651
652/// Build the `Recognizer::..` expression for a single `#[recognize(..)]` attr.
653fn recognizer_expr(attr: &syn::Attribute) -> syn::Result<proc_macro2::TokenStream> {
654    let (rt, _) = runtime();
655    let r = quote! { #rt::extract::Recognizer };
656    let meta: Meta = attr.parse_args()?;
657    match meta {
658        Meta::Path(p) => {
659            let id = p
660                .get_ident()
661                .ok_or_else(|| syn::Error::new_spanned(&p, "expected a recognizer name"))?;
662            match id.to_string().as_str() {
663                "integer" => Ok(quote! { #r::integer() }),
664                "money" => Ok(quote! { #r::money() }),
665                "yes_no" => Ok(quote! { #r::yes_no() }),
666                "datetime" => Ok(quote! { #r::datetime() }),
667                other => Err(syn::Error::new_spanned(
668                    &p,
669                    format!("unknown recognizer `{other}`"),
670                )),
671            }
672        }
673        Meta::NameValue(nv) => {
674            let id = nv
675                .path
676                .get_ident()
677                .ok_or_else(|| syn::Error::new_spanned(&nv.path, "expected a recognizer name"))?;
678            match id.to_string().as_str() {
679                "integer_near" => {
680                    let a = str_array(&nv.value)?;
681                    Ok(quote! { #r::integer_near([ #(#a),* ]) })
682                }
683                "one_of" => {
684                    let a = str_array(&nv.value)?;
685                    Ok(quote! { #r::one_of([ #(#a),* ]) })
686                }
687                "fuzzy" => {
688                    let a = str_array(&nv.value)?;
689                    Ok(quote! { #r::fuzzy([ #(#a),* ]) })
690                }
691                "regex" => {
692                    let s = str_lit(&nv.value)?;
693                    Ok(quote! { #r::regex(#s) })
694                }
695                other => Err(syn::Error::new_spanned(
696                    &nv.path,
697                    format!("`{other}` does not take a value"),
698                )),
699            }
700        }
701        Meta::List(l) => Err(syn::Error::new_spanned(
702            l,
703            "unexpected nested list in `#[recognize(..)]`",
704        )),
705    }
706}
707
708/// Build a serializable `SlotRecognizer` expression for a `#[recognize(..)]` attr
709/// on a `#[derive(Frame)]` field (same vocabulary as the Extract derive).
710fn slot_recognizer_expr(attr: &syn::Attribute) -> syn::Result<proc_macro2::TokenStream> {
711    let (rt, _) = runtime();
712    let r = quote! { #rt::frame::SlotRecognizer };
713    let meta: Meta = attr.parse_args()?;
714    match meta {
715        Meta::Path(p) => {
716            let id = p
717                .get_ident()
718                .ok_or_else(|| syn::Error::new_spanned(&p, "expected a recognizer name"))?;
719            match id.to_string().as_str() {
720                "integer" => Ok(quote! { #r::Integer }),
721                "money" => Ok(quote! { #r::Money }),
722                "yes_no" => Ok(quote! { #r::YesNo }),
723                "datetime" => Ok(quote! { #r::DateTime }),
724                other => Err(syn::Error::new_spanned(
725                    &p,
726                    format!("unknown recognizer `{other}`"),
727                )),
728            }
729        }
730        Meta::NameValue(nv) => {
731            let id = nv
732                .path
733                .get_ident()
734                .ok_or_else(|| syn::Error::new_spanned(&nv.path, "expected a recognizer name"))?;
735            match id.to_string().as_str() {
736                "integer_near" => {
737                    let a = str_array(&nv.value)?;
738                    Ok(quote! { #r::IntegerNear(::std::vec![ #(#a.to_string()),* ]) })
739                }
740                "one_of" => {
741                    let a = str_array(&nv.value)?;
742                    Ok(quote! { #r::OneOf(::std::vec![ #(#a.to_string()),* ]) })
743                }
744                "fuzzy" => {
745                    let a = str_array(&nv.value)?;
746                    Ok(quote! { #r::Fuzzy(::std::vec![ #(#a.to_string()),* ]) })
747                }
748                "regex" => {
749                    let s = str_lit(&nv.value)?;
750                    Ok(quote! { #r::Regex(#s.to_string()) })
751                }
752                other => Err(syn::Error::new_spanned(
753                    &nv.path,
754                    format!("`{other}` does not take a value"),
755                )),
756            }
757        }
758        Meta::List(l) => Err(syn::Error::new_spanned(
759            l,
760            "unexpected nested list in `#[recognize(..)]`",
761        )),
762    }
763}
764
765/// Parse an integer or float literal into an `f64` (for slot `min`/`max`).
766fn lit_to_f64(lit: &Lit) -> syn::Result<f64> {
767    match lit {
768        Lit::Int(i) => i.base10_parse::<f64>(),
769        Lit::Float(f) => f.base10_parse::<f64>(),
770        other => Err(syn::Error::new_spanned(
771            other,
772            "expected a numeric literal for `min`/`max`",
773        )),
774    }
775}
776
777/// Parse an expression that must be an array of string literals.
778fn str_array(expr: &Expr) -> syn::Result<Vec<LitStr>> {
779    match expr {
780        Expr::Array(arr) => arr
781            .elems
782            .iter()
783            .map(|e| match e {
784                Expr::Lit(ExprLit {
785                    lit: Lit::Str(s), ..
786                }) => Ok(s.clone()),
787                other => Err(syn::Error::new_spanned(
788                    other,
789                    "expected a string literal in the array",
790                )),
791            })
792            .collect(),
793        other => Err(syn::Error::new_spanned(
794            other,
795            "expected an array of string literals, e.g. [\"a\", \"b\"]",
796        )),
797    }
798}
799
800/// Parse an expression that must be a single string literal.
801fn str_lit(expr: &Expr) -> syn::Result<LitStr> {
802    match expr {
803        Expr::Lit(ExprLit {
804            lit: Lit::Str(s), ..
805        }) => Ok(s.clone()),
806        other => Err(syn::Error::new_spanned(other, "expected a string literal")),
807    }
808}
809
810/// Convert a `PascalCase`/`camelCase` identifier to `snake_case`.
811fn to_snake_case(s: &str) -> String {
812    let mut out = String::with_capacity(s.len() + 4);
813    for (i, ch) in s.chars().enumerate() {
814        if ch.is_uppercase() {
815            if i != 0 {
816                out.push('_');
817            }
818            out.extend(ch.to_lowercase());
819        } else {
820            out.push(ch);
821        }
822    }
823    out
824}
825
826/// Returns `true` if `ty` is syntactically an `Option<...>`.
827///
828/// Accepts the prelude name (`Option`) and the spelled-out std/core paths
829/// (`option::Option`, `std::option::Option`, `core::option::Option`, with or
830/// without a leading `::`). The full path is checked — a user type like
831/// `my::Option` does NOT match. Purely syntactic: a type alias or renamed
832/// import of `Option` is invisible to the macro, as with any derive.
833fn is_option(ty: &Type) -> bool {
834    let Type::Path(TypePath { qself: None, path }) = ty else {
835        return false;
836    };
837    // Only the final `Option` segment may carry generic arguments.
838    if path
839        .segments
840        .iter()
841        .rev()
842        .skip(1)
843        .any(|seg| !seg.arguments.is_none())
844    {
845        return false;
846    }
847    let idents: Vec<&syn::Ident> = path.segments.iter().map(|seg| &seg.ident).collect();
848    match idents.as_slice() {
849        // `Option<T>` / `option::Option<T>` resolve via the prelude only when
850        // the path is relative.
851        [opt] => path.leading_colon.is_none() && *opt == "Option",
852        [module, opt] => path.leading_colon.is_none() && *module == "option" && *opt == "Option",
853        // `std::option::Option<T>` / `core::option::Option<T>`, `::`-rooted or not.
854        [root, module, opt] => {
855            (*root == "std" || *root == "core") && *module == "option" && *opt == "Option"
856        }
857        _ => false,
858    }
859}
860
861/// Convert a `snake_case` identifier to `PascalCase`.
862fn to_pascal_case(s: &str) -> String {
863    let mut out = String::with_capacity(s.len());
864    let mut upper_next = true;
865    for ch in s.chars() {
866        if ch == '_' {
867            upper_next = true;
868        } else if upper_next {
869            out.extend(ch.to_uppercase());
870            upper_next = false;
871        } else {
872            out.push(ch);
873        }
874    }
875    out
876}
877
878#[cfg(test)]
879mod tests {
880    use super::is_option;
881    use syn::parse_quote;
882
883    #[test]
884    fn is_option_accepts_std_core_paths() {
885        assert!(is_option(&parse_quote!(Option<String>)));
886        assert!(is_option(&parse_quote!(option::Option<String>)));
887        assert!(is_option(&parse_quote!(std::option::Option<String>)));
888        assert!(is_option(&parse_quote!(core::option::Option<String>)));
889        assert!(is_option(&parse_quote!(::std::option::Option<String>)));
890        assert!(is_option(&parse_quote!(::core::option::Option<String>)));
891    }
892
893    #[test]
894    fn is_option_rejects_lookalikes() {
895        assert!(!is_option(&parse_quote!(String)));
896        assert!(!is_option(&parse_quote!(Vec<Option<String>>)));
897        assert!(!is_option(&parse_quote!(my::Option<String>)));
898        assert!(!is_option(&parse_quote!(my::option::Option<String>)));
899        assert!(!is_option(&parse_quote!(::option::Option<String>)));
900        assert!(!is_option(&parse_quote!(<T as Trait>::Option)));
901    }
902}