Skip to main content

subako_function_sdk_macros/
lib.rs

1//! `#[function]` and `package!`, re-exported by `subako-function-sdk`.
2
3#![cfg_attr(
4    not(test),
5    deny(
6        clippy::unwrap_used,
7        clippy::expect_used,
8        clippy::panic,
9        clippy::unreachable,
10        clippy::todo,
11        clippy::unimplemented,
12        clippy::indexing_slicing,
13    )
14)]
15
16use proc_macro2::{Span, TokenStream};
17use quote::{format_ident, quote};
18use syn::parse::{Parse, ParseStream};
19use syn::punctuated::Punctuated;
20use syn::{Expr, Ident, ItemFn, LitBool, Path, Token, bracketed};
21
22/// Makes a function a tool: its name is the tool's, its doc comment the
23/// description the model reads, and its one argument and `Ok` type the input
24/// and output schemas. The function itself is left as written.
25///
26/// ```ignore
27/// /// Greet someone by name.
28/// #[function]
29/// fn greet(input: GreetInput) -> Result<Greeting, ToolError> { ... }
30/// ```
31#[proc_macro_attribute]
32pub fn function(
33    attr: proc_macro::TokenStream,
34    item: proc_macro::TokenStream,
35) -> proc_macro::TokenStream {
36    expand_function(attr.into(), item.into())
37        .unwrap_or_else(syn::Error::into_compile_error)
38        .into()
39}
40
41/// Declares the package: `pub fn package()` building it and, unless
42/// `main: false`, the program's `main` serving it.
43///
44/// ```ignore
45/// package! {
46///     name: "hello",
47///     description: "Says hello.",
48///     functions: [greet, tools::farewell],
49/// }
50/// ```
51#[proc_macro]
52pub fn package(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
53    expand_package(input.into())
54        .unwrap_or_else(syn::Error::into_compile_error)
55        .into()
56}
57
58/// The hidden function `#[function]` adds beside `name`, which `package!`
59/// calls to get its [`Function`](subako_function_sdk::Function).
60fn handle(name: &Ident) -> Ident {
61    format_ident!("__subako_function_{}", name)
62}
63
64fn expand_function(attr: TokenStream, item: TokenStream) -> syn::Result<TokenStream> {
65    if !attr.is_empty() {
66        return Err(syn::Error::new_spanned(
67            attr,
68            "#[function] takes no arguments",
69        ));
70    }
71    let function: ItemFn = syn::parse2(item)?;
72    let name = &function.sig.ident;
73    let description = description(&function)?;
74    let tool = name.to_string();
75    let vis = &function.vis;
76    let handle = handle(name);
77    Ok(quote! {
78        #function
79
80        #[doc(hidden)]
81        #vis fn #handle() -> ::subako_function_sdk::Function {
82            ::subako_function_sdk::Function::new(#tool, #description, #name)
83        }
84    })
85}
86
87/// The doc comment's lines, each without the one space `///` leaves, joined
88/// and trimmed.
89fn description(function: &ItemFn) -> syn::Result<String> {
90    let lines: Vec<String> = function
91        .attrs
92        .iter()
93        .filter(|a| a.path().is_ident("doc"))
94        .filter_map(|a| match &a.meta {
95            syn::Meta::NameValue(syn::MetaNameValue {
96                value:
97                    Expr::Lit(syn::ExprLit {
98                        lit: syn::Lit::Str(text),
99                        ..
100                    }),
101                ..
102            }) => Some(text.value()),
103            _ => None,
104        })
105        .map(|line| line.strip_prefix(' ').unwrap_or(&line).to_string())
106        .collect();
107    let description = lines.join("\n").trim().to_string();
108    match description.is_empty() {
109        false => Ok(description),
110        true => Err(syn::Error::new_spanned(
111            &function.sig.ident,
112            "a function's doc comment is the description the model reads; add one",
113        )),
114    }
115}
116
117struct PackageInput {
118    name: Expr,
119    description: Expr,
120    functions: Vec<Path>,
121    main: bool,
122}
123
124impl Parse for PackageInput {
125    fn parse(input: ParseStream) -> syn::Result<Self> {
126        let (mut name, mut description, mut functions, mut main) = (None, None, None, None);
127        while !input.is_empty() {
128            let key: Ident = input.parse()?;
129            input.parse::<Token![:]>()?;
130            let duplicate = || syn::Error::new(key.span(), format!("duplicate key `{key}`"));
131            match key.to_string().as_str() {
132                "name" => name
133                    .replace(input.parse()?)
134                    .map_or(Ok(()), |_| Err(duplicate()))?,
135                "description" => description
136                    .replace(input.parse()?)
137                    .map_or(Ok(()), |_| Err(duplicate()))?,
138                "functions" => {
139                    let list;
140                    bracketed!(list in input);
141                    let paths = Punctuated::<Path, Token![,]>::parse_terminated(&list)?;
142                    functions
143                        .replace(paths.into_iter().collect())
144                        .map_or(Ok(()), |_| Err(duplicate()))?
145                }
146                "main" => main
147                    .replace(input.parse::<LitBool>()?.value)
148                    .map_or(Ok(()), |_| Err(duplicate()))?,
149                _ => {
150                    return Err(syn::Error::new(
151                        key.span(),
152                        format!(
153                            "unknown key `{key}`; expected name, description, functions, or main"
154                        ),
155                    ));
156                }
157            }
158            if !input.is_empty() {
159                input.parse::<Token![,]>()?;
160            }
161        }
162        let missing =
163            |key: &str| syn::Error::new(Span::call_site(), format!("package! needs `{key}`"));
164        Ok(PackageInput {
165            name: name.ok_or_else(|| missing("name"))?,
166            description: description.ok_or_else(|| missing("description"))?,
167            functions: functions.ok_or_else(|| missing("functions"))?,
168            main: main.unwrap_or(true),
169        })
170    }
171}
172
173fn expand_package(input: TokenStream) -> syn::Result<TokenStream> {
174    let PackageInput {
175        name,
176        description,
177        functions,
178        main,
179    } = syn::parse2(input)?;
180    let handles = functions
181        .into_iter()
182        .map(|mut path| {
183            // COVERAGE(never): a parsed `Path` always has at least one segment.
184            let last = path
185                .segments
186                .last_mut()
187                .ok_or_else(|| syn::Error::new(Span::call_site(), "an empty function path"))?;
188            last.ident = handle(&last.ident);
189            Ok(path)
190        })
191        // COVERAGE(never): the map above never fails, as its marker says.
192        .collect::<syn::Result<Vec<_>>>()?;
193    let main = main.then(|| {
194        quote! {
195            fn main() -> ::std::process::ExitCode {
196                package().run()
197            }
198        }
199    });
200    Ok(quote! {
201        /// This package: its functions and the manifest they describe.
202        pub fn package() -> ::subako_function_sdk::Package {
203            ::subako_function_sdk::Package::new(#name, #description, ::std::vec![#(#handles()),*])
204                .with_version(::core::env!("CARGO_PKG_VERSION"))
205        }
206
207        #main
208    })
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214
215    fn error(result: syn::Result<TokenStream>) -> String {
216        result.unwrap_err().to_string()
217    }
218
219    #[test]
220    fn a_function_keeps_itself_and_gains_its_handle() {
221        let out = expand_function(
222            quote!(),
223            quote! {
224                /// Greet someone.
225                ///
226                /// By name.
227                pub fn greet(input: In) -> Result<Out, ToolError> { todo!() }
228            },
229        )
230        .unwrap()
231        .to_string();
232        assert!(out.contains("pub fn greet"));
233        assert!(out.contains("pub fn __subako_function_greet"));
234        assert!(out.contains(r#""Greet someone.\n\nBy name.""#));
235    }
236
237    #[test]
238    fn a_function_needs_a_doc_comment_and_no_arguments() {
239        let item = quote!(
240            fn greet(input: In) -> Result<Out, ToolError> {
241                todo!()
242            }
243        );
244        assert!(error(expand_function(quote!(), item.clone())).contains("doc comment"));
245        assert!(error(expand_function(quote!(name = "x"), item)).contains("no arguments"));
246    }
247
248    #[test]
249    fn a_package_names_its_handles_and_main() {
250        let out = expand_package(quote! {
251            name: "hello",
252            description: "Says hello.",
253            functions: [greet, tools::farewell],
254        })
255        .unwrap()
256        .to_string();
257        assert!(out.contains("__subako_function_greet ()"));
258        assert!(out.contains("tools :: __subako_function_farewell ()"));
259        assert!(out.contains("fn main"));
260    }
261
262    #[test]
263    fn main_false_leaves_main_out() {
264        let out = expand_package(quote! {
265            name: "hello", description: "d", functions: [], main: false
266        })
267        .unwrap()
268        .to_string();
269        assert!(out.contains("fn package"));
270        assert!(!out.contains("fn main"));
271    }
272
273    #[test]
274    fn a_package_refuses_missing_duplicate_and_unknown_keys() {
275        for (input, message) in [
276            (quote!(name: "a", functions: []), "needs `description`"),
277            (
278                quote!(name: "a", name: "b", description: "d", functions: []),
279                "duplicate key `name`",
280            ),
281            (
282                quote!(name: "a", description: "d", functions: [], extra: 1),
283                "unknown key `extra`",
284            ),
285            (quote!(description: "d", functions: []), "needs `name`"),
286            (quote!(name: "a", description: "d"), "needs `functions`"),
287            (
288                quote!(name: "a", description: "d", description: "e", functions: []),
289                "duplicate key `description`",
290            ),
291            (
292                quote!(name: "a", description: "d", functions: [], functions: []),
293                "duplicate key `functions`",
294            ),
295            (
296                quote!(name: "a", description: "d", functions: [], main: true, main: false),
297                "duplicate key `main`",
298            ),
299        ] {
300            assert!(error(expand_package(input)).contains(message));
301        }
302    }
303
304    #[test]
305    fn a_package_refuses_malformed_syntax() {
306        for input in [
307            quote!(1: "a"),
308            quote!(name "a"),
309            quote!(name: , description: "d", functions: []),
310            quote!(name: "a", description: , functions: []),
311            quote!(name: "a", description: "d", functions: [1]),
312            quote!(name: "a", description: "d", functions: [], main: 1),
313            quote!(name: "a" description: "d"),
314        ] {
315            assert!(expand_package(input).is_err());
316        }
317    }
318
319    #[test]
320    fn a_function_refuses_a_non_function_and_skips_non_literal_docs() {
321        assert!(
322            expand_function(
323                quote!(),
324                quote!(
325                    struct Greet;
326                )
327            )
328            .is_err()
329        );
330        let out = expand_function(
331            quote!(),
332            quote! {
333                /// Greet.
334                #[doc = concat!("ignored")]
335                fn greet(input: In) -> Result<Out, ToolError> { todo!() }
336            },
337        )
338        .unwrap()
339        .to_string();
340        assert!(out.contains(r#""Greet.""#));
341    }
342}