subako_function_sdk_macros/
lib.rs1#![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#[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#[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
58fn 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
87fn 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 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 .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 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 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 #[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}