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 }
205
206 #main
207 })
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213
214 fn error(result: syn::Result<TokenStream>) -> String {
215 result.unwrap_err().to_string()
216 }
217
218 #[test]
219 fn a_function_keeps_itself_and_gains_its_handle() {
220 let out = expand_function(
221 quote!(),
222 quote! {
223 pub fn greet(input: In) -> Result<Out, ToolError> { todo!() }
227 },
228 )
229 .unwrap()
230 .to_string();
231 assert!(out.contains("pub fn greet"));
232 assert!(out.contains("pub fn __subako_function_greet"));
233 assert!(out.contains(r#""Greet someone.\n\nBy name.""#));
234 }
235
236 #[test]
237 fn a_function_needs_a_doc_comment_and_no_arguments() {
238 let item = quote!(
239 fn greet(input: In) -> Result<Out, ToolError> {
240 todo!()
241 }
242 );
243 assert!(error(expand_function(quote!(), item.clone())).contains("doc comment"));
244 assert!(error(expand_function(quote!(name = "x"), item)).contains("no arguments"));
245 }
246
247 #[test]
248 fn a_package_names_its_handles_and_main() {
249 let out = expand_package(quote! {
250 name: "hello",
251 description: "Says hello.",
252 functions: [greet, tools::farewell],
253 })
254 .unwrap()
255 .to_string();
256 assert!(out.contains("__subako_function_greet ()"));
257 assert!(out.contains("tools :: __subako_function_farewell ()"));
258 assert!(out.contains("fn main"));
259 }
260
261 #[test]
262 fn main_false_leaves_main_out() {
263 let out = expand_package(quote! {
264 name: "hello", description: "d", functions: [], main: false
265 })
266 .unwrap()
267 .to_string();
268 assert!(out.contains("fn package"));
269 assert!(!out.contains("fn main"));
270 }
271
272 #[test]
273 fn a_package_refuses_missing_duplicate_and_unknown_keys() {
274 for (input, message) in [
275 (quote!(name: "a", functions: []), "needs `description`"),
276 (
277 quote!(name: "a", name: "b", description: "d", functions: []),
278 "duplicate key `name`",
279 ),
280 (
281 quote!(name: "a", description: "d", functions: [], extra: 1),
282 "unknown key `extra`",
283 ),
284 (quote!(description: "d", functions: []), "needs `name`"),
285 (quote!(name: "a", description: "d"), "needs `functions`"),
286 (
287 quote!(name: "a", description: "d", description: "e", functions: []),
288 "duplicate key `description`",
289 ),
290 (
291 quote!(name: "a", description: "d", functions: [], functions: []),
292 "duplicate key `functions`",
293 ),
294 (
295 quote!(name: "a", description: "d", functions: [], main: true, main: false),
296 "duplicate key `main`",
297 ),
298 ] {
299 assert!(error(expand_package(input)).contains(message));
300 }
301 }
302
303 #[test]
304 fn a_package_refuses_malformed_syntax() {
305 for input in [
306 quote!(1: "a"),
307 quote!(name "a"),
308 quote!(name: , description: "d", functions: []),
309 quote!(name: "a", description: , functions: []),
310 quote!(name: "a", description: "d", functions: [1]),
311 quote!(name: "a", description: "d", functions: [], main: 1),
312 quote!(name: "a" description: "d"),
313 ] {
314 assert!(expand_package(input).is_err());
315 }
316 }
317
318 #[test]
319 fn a_function_refuses_a_non_function_and_skips_non_literal_docs() {
320 assert!(
321 expand_function(
322 quote!(),
323 quote!(
324 struct Greet;
325 )
326 )
327 .is_err()
328 );
329 let out = expand_function(
330 quote!(),
331 quote! {
332 #[doc = concat!("ignored")]
334 fn greet(input: In) -> Result<Out, ToolError> { todo!() }
335 },
336 )
337 .unwrap()
338 .to_string();
339 assert!(out.contains(r#""Greet.""#));
340 }
341}