#![cfg_attr(
not(test),
deny(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::unreachable,
clippy::todo,
clippy::unimplemented,
clippy::indexing_slicing,
)
)]
use proc_macro2::{Span, TokenStream};
use quote::{format_ident, quote};
use syn::parse::{Parse, ParseStream};
use syn::punctuated::Punctuated;
use syn::{Expr, Ident, ItemFn, LitBool, Path, Token, bracketed};
#[proc_macro_attribute]
pub fn function(
attr: proc_macro::TokenStream,
item: proc_macro::TokenStream,
) -> proc_macro::TokenStream {
expand_function(attr.into(), item.into())
.unwrap_or_else(syn::Error::into_compile_error)
.into()
}
#[proc_macro]
pub fn package(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
expand_package(input.into())
.unwrap_or_else(syn::Error::into_compile_error)
.into()
}
fn handle(name: &Ident) -> Ident {
format_ident!("__subako_function_{}", name)
}
fn expand_function(attr: TokenStream, item: TokenStream) -> syn::Result<TokenStream> {
if !attr.is_empty() {
return Err(syn::Error::new_spanned(
attr,
"#[function] takes no arguments",
));
}
let function: ItemFn = syn::parse2(item)?;
let name = &function.sig.ident;
let description = description(&function)?;
let tool = name.to_string();
let vis = &function.vis;
let handle = handle(name);
Ok(quote! {
#function
#[doc(hidden)]
#vis fn #handle() -> ::subako_function_sdk::Function {
::subako_function_sdk::Function::new(#tool, #description, #name)
}
})
}
fn description(function: &ItemFn) -> syn::Result<String> {
let lines: Vec<String> = function
.attrs
.iter()
.filter(|a| a.path().is_ident("doc"))
.filter_map(|a| match &a.meta {
syn::Meta::NameValue(syn::MetaNameValue {
value:
Expr::Lit(syn::ExprLit {
lit: syn::Lit::Str(text),
..
}),
..
}) => Some(text.value()),
_ => None,
})
.map(|line| line.strip_prefix(' ').unwrap_or(&line).to_string())
.collect();
let description = lines.join("\n").trim().to_string();
match description.is_empty() {
false => Ok(description),
true => Err(syn::Error::new_spanned(
&function.sig.ident,
"a function's doc comment is the description the model reads; add one",
)),
}
}
struct PackageInput {
name: Expr,
description: Expr,
functions: Vec<Path>,
main: bool,
}
impl Parse for PackageInput {
fn parse(input: ParseStream) -> syn::Result<Self> {
let (mut name, mut description, mut functions, mut main) = (None, None, None, None);
while !input.is_empty() {
let key: Ident = input.parse()?;
input.parse::<Token![:]>()?;
let duplicate = || syn::Error::new(key.span(), format!("duplicate key `{key}`"));
match key.to_string().as_str() {
"name" => name
.replace(input.parse()?)
.map_or(Ok(()), |_| Err(duplicate()))?,
"description" => description
.replace(input.parse()?)
.map_or(Ok(()), |_| Err(duplicate()))?,
"functions" => {
let list;
bracketed!(list in input);
let paths = Punctuated::<Path, Token![,]>::parse_terminated(&list)?;
functions
.replace(paths.into_iter().collect())
.map_or(Ok(()), |_| Err(duplicate()))?
}
"main" => main
.replace(input.parse::<LitBool>()?.value)
.map_or(Ok(()), |_| Err(duplicate()))?,
_ => {
return Err(syn::Error::new(
key.span(),
format!(
"unknown key `{key}`; expected name, description, functions, or main"
),
));
}
}
if !input.is_empty() {
input.parse::<Token![,]>()?;
}
}
let missing =
|key: &str| syn::Error::new(Span::call_site(), format!("package! needs `{key}`"));
Ok(PackageInput {
name: name.ok_or_else(|| missing("name"))?,
description: description.ok_or_else(|| missing("description"))?,
functions: functions.ok_or_else(|| missing("functions"))?,
main: main.unwrap_or(true),
})
}
}
fn expand_package(input: TokenStream) -> syn::Result<TokenStream> {
let PackageInput {
name,
description,
functions,
main,
} = syn::parse2(input)?;
let handles = functions
.into_iter()
.map(|mut path| {
let last = path
.segments
.last_mut()
.ok_or_else(|| syn::Error::new(Span::call_site(), "an empty function path"))?;
last.ident = handle(&last.ident);
Ok(path)
})
.collect::<syn::Result<Vec<_>>>()?;
let main = main.then(|| {
quote! {
fn main() -> ::std::process::ExitCode {
package().run()
}
}
});
Ok(quote! {
pub fn package() -> ::subako_function_sdk::Package {
::subako_function_sdk::Package::new(#name, #description, ::std::vec![#(#handles()),*])
}
#main
})
}
#[cfg(test)]
mod tests {
use super::*;
fn error(result: syn::Result<TokenStream>) -> String {
result.unwrap_err().to_string()
}
#[test]
fn a_function_keeps_itself_and_gains_its_handle() {
let out = expand_function(
quote!(),
quote! {
pub fn greet(input: In) -> Result<Out, ToolError> { todo!() }
},
)
.unwrap()
.to_string();
assert!(out.contains("pub fn greet"));
assert!(out.contains("pub fn __subako_function_greet"));
assert!(out.contains(r#""Greet someone.\n\nBy name.""#));
}
#[test]
fn a_function_needs_a_doc_comment_and_no_arguments() {
let item = quote!(
fn greet(input: In) -> Result<Out, ToolError> {
todo!()
}
);
assert!(error(expand_function(quote!(), item.clone())).contains("doc comment"));
assert!(error(expand_function(quote!(name = "x"), item)).contains("no arguments"));
}
#[test]
fn a_package_names_its_handles_and_main() {
let out = expand_package(quote! {
name: "hello",
description: "Says hello.",
functions: [greet, tools::farewell],
})
.unwrap()
.to_string();
assert!(out.contains("__subako_function_greet ()"));
assert!(out.contains("tools :: __subako_function_farewell ()"));
assert!(out.contains("fn main"));
}
#[test]
fn main_false_leaves_main_out() {
let out = expand_package(quote! {
name: "hello", description: "d", functions: [], main: false
})
.unwrap()
.to_string();
assert!(out.contains("fn package"));
assert!(!out.contains("fn main"));
}
#[test]
fn a_package_refuses_missing_duplicate_and_unknown_keys() {
for (input, message) in [
(quote!(name: "a", functions: []), "needs `description`"),
(
quote!(name: "a", name: "b", description: "d", functions: []),
"duplicate key `name`",
),
(
quote!(name: "a", description: "d", functions: [], extra: 1),
"unknown key `extra`",
),
(quote!(description: "d", functions: []), "needs `name`"),
(quote!(name: "a", description: "d"), "needs `functions`"),
(
quote!(name: "a", description: "d", description: "e", functions: []),
"duplicate key `description`",
),
(
quote!(name: "a", description: "d", functions: [], functions: []),
"duplicate key `functions`",
),
(
quote!(name: "a", description: "d", functions: [], main: true, main: false),
"duplicate key `main`",
),
] {
assert!(error(expand_package(input)).contains(message));
}
}
#[test]
fn a_package_refuses_malformed_syntax() {
for input in [
quote!(1: "a"),
quote!(name "a"),
quote!(name: , description: "d", functions: []),
quote!(name: "a", description: , functions: []),
quote!(name: "a", description: "d", functions: [1]),
quote!(name: "a", description: "d", functions: [], main: 1),
quote!(name: "a" description: "d"),
] {
assert!(expand_package(input).is_err());
}
}
#[test]
fn a_function_refuses_a_non_function_and_skips_non_literal_docs() {
assert!(
expand_function(
quote!(),
quote!(
struct Greet;
)
)
.is_err()
);
let out = expand_function(
quote!(),
quote! {
#[doc = concat!("ignored")]
fn greet(input: In) -> Result<Out, ToolError> { todo!() }
},
)
.unwrap()
.to_string();
assert!(out.contains(r#""Greet.""#));
}
}