use proc_macro2::TokenStream;
use quote::{format_ident, quote};
use syn::{FnArg, ImplItemFn};
use crate::args::last_segment;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Mark {
Resource,
Prompt,
}
impl Mark {
pub(crate) const fn attribute(self) -> &'static str {
match self {
Self::Resource => "resource",
Self::Prompt => "prompt",
}
}
pub(crate) const fn returns(self) -> &'static str {
match self {
Self::Resource => "Resource",
Self::Prompt => "Prompt",
}
}
pub(crate) const fn builder_method(self) -> &'static str {
match self {
Self::Resource => "resource",
Self::Prompt => "prompt",
}
}
pub(crate) const fn registers_with(self) -> &'static str {
match self {
Self::Resource => "resource_with",
Self::Prompt => "prompt_with",
}
}
pub(crate) fn of(method: &ImplItemFn) -> Option<Self> {
method.attrs.iter().find_map(|attr| {
if crate::is_attr(attr, "resource") {
Some(Self::Resource)
} else if crate::is_attr(attr, "prompt") {
Some(Self::Prompt)
} else {
None
}
})
}
pub(crate) fn check(self, method: &ImplItemFn) -> syn::Result<()> {
let attribute = self.attribute();
let expected = self.returns();
let name = method.sig.ident.clone();
if let Some(asyncness) = &method.sig.asyncness {
return Err(syn::Error::new_spanned(
asyncness,
format!(
"a #[{attribute}] method is registered once, while the server is being \
built, so there is nothing to await: write `fn {name}() -> {expected}` \
without `async`. A function that has to wait is a #[tool], and that one is \
called per request rather than per server."
),
));
}
if let Some(FnArg::Receiver(receiver)) = method.sig.inputs.first() {
return Err(syn::Error::new_spanned(
receiver,
format!(
"a #[{attribute}] method is registered as `Self::{name}()`, and that call \
has no receiver to supply one from: take no `self` argument. A primitive \
that needs state to describe itself belongs in `into_server` itself, or is \
added through `ServerBuilder::{}` where the value can be passed directly.",
self.registers_with()
),
));
}
let syn::ReturnType::Type(_, ty) = &method.sig.output else {
return Err(syn::Error::new_spanned(
&method.sig,
format!(
"a #[{attribute}] method must return `{expected}`: it is how the server \
describes the {} to a peer, and a method returning nothing describes none.",
lower_first(expected)
),
));
};
match last_segment(ty) {
Some(ident) if ident == expected => Ok(()),
Some(ident) => Err(syn::Error::new_spanned(
ty,
format!(
"a #[{attribute}] method must return `{expected}`, not `{ident}`: \
`ServerBuilder::{}` registers a `{expected}`, and the method is called \
once to produce it. A function that runs per request is a #[tool].",
self.builder_method()
),
)),
None => Err(syn::Error::new_spanned(
ty,
format!(
"a #[{attribute}] method must return `{expected}`, and this return type is \
not a path the macro can read a name from: write `{expected}` or a path \
ending in `{expected}`."
),
)),
}
}
pub(crate) fn registration(
self,
method: &ImplItemFn,
reader: Option<&syn::Ident>,
reader_fn: Option<&ImplItemFn>,
) -> syn::Result<TokenStream> {
let attribute = self.attribute();
let keyword = self.reader_keyword();
let expected = self.returns();
let name = &method.sig.ident;
let (Some(reader), Some(reader_fn)) = (reader, reader_fn) else {
if let Some(named) = reader {
return Err(syn::Error::new_spanned(
&method.sig,
format!(
"#[{attribute}] names a {noun} `{named}`, and this `impl` declares no \
method of that name",
noun = self.reader_noun(),
),
));
}
return Err(syn::Error::new_spanned(
&method.sig,
format!(
"#[{attribute}] needs `{keyword} = ..` naming the {} method, as in \
`#[{attribute}({keyword} = serve_it)]` on an `fn serve_it(...) -> \
Vec<{}>`. Registering the listing alone advertises a {expected} to peers who \
cannot then be served one.",
self.reader_word(),
self.reader_returns(),
),
));
};
let registers = format_ident!("{}", self.registers_with());
let _ = self.reader_shape(reader_fn)?;
let adapter = match self {
Self::Resource => {
quote! { ::protocol_droid::application::server::read::resource_reader }
}
Self::Prompt => {
quote! { ::protocol_droid::application::server::read::prompt_getter }
}
};
let await_token = if reader_fn.sig.asyncness.is_some() {
quote! { .await }
} else {
TokenStream::new()
};
let passed = quote! {
move |__pd_ctx, __pd_uri| {
let __pd_value = Self::#reader(__pd_ctx, &__pd_uri) #await_token;
async move { __pd_value }
}
};
Ok(quote! {
builder = builder.#registers(Self::#name(), #adapter(#passed));
})
}
fn reader_shape(self, reader: &ImplItemFn) -> syn::Result<ReaderShape> {
let expected = self.reader_returns();
let arity = reader.sig.inputs.len();
if arity != 2 {
return Err(syn::Error::new_spanned(
&reader.sig,
format!(
"a {} named by #[{}] takes a RequestContext and the request's {} — two \
arguments. The context is not optional: it carries the caller's \
identity, and a reader that could not see it would serve every \
request as whoever opened the connection. This one takes {arity}.",
self.reader_word(),
self.attribute(),
self.reader_word(),
),
));
}
Ok(ReaderShape::Context).and_then(|shape| {
let syn::ReturnType::Type(_, ty) = &reader.sig.output else {
return Err(syn::Error::new_spanned(
&reader.sig,
format!("a reader must return `{expected}`"),
));
};
let found = element_segment(ty);
if found.as_deref() == Some(expected) {
Ok(shape)
} else {
Err(syn::Error::new_spanned(
ty,
format!(
"a {} named by #[{}] returns a collection of `{expected}`, not `{}`",
self.reader_word(),
self.attribute(),
found
.as_deref()
.unwrap_or("a type that is not a named collection"),
),
))
}
})
}
pub(crate) const fn reader_returns(self) -> &'static str {
match self {
Self::Resource => "ResourceContents",
Self::Prompt => "PromptMessage",
}
}
pub(crate) const fn reader_word(self) -> &'static str {
match self {
Self::Resource => "URI",
Self::Prompt => "arguments",
}
}
pub(crate) fn reader_of(self, method: &ImplItemFn) -> syn::Result<Option<syn::Ident>> {
let attribute = self.attribute();
let keyword = self.reader_keyword();
if let Some(attr) = method
.attrs
.iter()
.find(|attr| crate::is_attr(attr, attribute))
{
if matches!(attr.meta, syn::Meta::Path(_)) {
return Ok(None);
}
let parsed = attr
.parse_args_with(
syn::punctuated::Punctuated::<syn::Meta, syn::Token![,]>::parse_terminated,
)
.map_err(|error| {
syn::Error::new(
error.span(),
format!(
"#[{attribute}] takes no arguments, or one `{keyword} = ..` naming \
the method that serves it"
),
)
})?;
if let Some(meta) = parsed.into_iter().next() {
let syn::Meta::NameValue(pair) = meta else {
return Err(syn::Error::new_spanned(
meta,
format!("#[{attribute}] takes no arguments, or one `{keyword} = ..`"),
));
};
if !pair.path.is_ident(keyword) {
return Err(syn::Error::new_spanned(
pair.path,
format!(
"#[{attribute}] takes no arguments, or one `{keyword} = ..` naming \
the method that serves it"
),
));
}
return match &pair.value {
syn::Expr::Path(path) => match path.path.get_ident() {
Some(ident) => Ok(Some(ident.clone())),
None => Err(syn::Error::new_spanned(
&pair.value,
format!("`{keyword}` names one method in this `impl`"),
)),
},
other => Err(syn::Error::new_spanned(
other,
format!("`{keyword}` names one method in this `impl`"),
)),
};
}
return Ok(None);
}
Ok(None)
}
pub(crate) const fn reader_noun(self) -> &'static str {
match self {
Self::Resource => "reader",
Self::Prompt => "getter",
}
}
pub(crate) const fn reader_keyword(self) -> &'static str {
match self {
Self::Resource => "reader",
Self::Prompt => "getter",
}
}
}
fn element_segment(ty: &syn::Type) -> Option<String> {
let syn::Type::Path(path) = ty else {
return None;
};
let segment = path.path.segments.last()?;
if segment.ident != "Vec" {
return Some(segment.ident.to_string());
}
let syn::PathArguments::AngleBracketed(arguments) = &segment.arguments else {
return Some(segment.ident.to_string());
};
arguments.args.iter().find_map(|argument| match argument {
syn::GenericArgument::Type(syn::Type::Path(inner_path)) => inner_path
.path
.segments
.last()
.map(|last| last.ident.to_string()),
_ => None,
})
}
fn lower_first(name: &str) -> String {
let mut characters = name.chars();
characters.next().map_or_else(String::new, |first| {
first.to_lowercase().chain(characters).collect()
})
}
enum ReaderShape {
Context,
}
#[cfg(test)]
mod tests {
use super::{Mark, lower_first};
use crate::args::last_segment;
use syn::{ImplItemFn, ItemImpl, Type};
#[derive(Debug)]
enum Failure {
Fixture(syn::Error),
Accepted(&'static str),
Refused(syn::Error),
}
impl core::fmt::Display for Failure {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::Fixture(error) => write!(f, "bad fixture: {error}"),
Self::Accepted(what) => write!(f, "the macro accepted {what}"),
Self::Refused(error) => write!(f, "the macro refused: {error}"),
}
}
}
impl From<syn::Error> for Failure {
fn from(error: syn::Error) -> Self {
Self::Fixture(error)
}
}
fn methods(source: &str) -> syn::Result<Vec<ImplItemFn>> {
let block: ItemImpl = syn::parse_str(source)?;
Ok(block
.items
.into_iter()
.filter_map(|item| match item {
syn::ImplItem::Fn(function) => Some(function),
_ => None,
})
.collect())
}
fn one_method(source: &str) -> Result<ImplItemFn, Failure> {
let mut found = methods(source)?;
match (found.len(), found.pop()) {
(1, Some(function)) => Ok(function),
(count, _) => Err(Failure::Fixture(syn::Error::new(
proc_macro2::Span::call_site(),
format!("the fixture should hold one method, and holds {count}"),
))),
}
}
#[test]
fn either_spelling_of_the_attribute_is_the_same_mark() -> Result<(), Failure> {
let qualified =
one_method("impl Demo { #[protocol_droid::resource] fn a() -> Resource { todo() } }")?;
let bare = one_method("impl Demo { #[resource] fn a() -> Resource { todo() } }")?;
assert_eq!(Mark::of(&qualified), Mark::of(&bare));
Ok(())
}
#[test]
fn an_unmarked_method_carries_no_mark() -> Result<(), Failure> {
let plain = one_method("impl Demo { fn a() -> crate::Resource { todo() } }")?;
assert_eq!(Mark::of(&plain), None);
Ok(())
}
#[test]
fn a_tool_is_not_a_primitive_mark() -> Result<(), Failure> {
let tool = one_method("impl Demo { #[tool] async fn a() -> String { todo() } }")?;
assert_eq!(Mark::of(&tool), None);
Ok(())
}
#[test]
fn each_mark_names_the_type_it_registers() {
assert_eq!(Mark::Resource.returns(), "Resource");
assert_eq!(Mark::Prompt.returns(), "Prompt");
assert_eq!(Mark::Resource.builder_method(), "resource");
assert_eq!(Mark::Prompt.builder_method(), "prompt");
assert_eq!(Mark::Resource.attribute(), "resource");
assert_eq!(Mark::Prompt.attribute(), "prompt");
}
#[test]
fn a_well_formed_method_is_accepted() -> Result<(), Failure> {
for source in [
"impl Demo { #[resource] fn readme() -> Resource { todo() } }",
"impl Demo { #[prompt] fn summarise() -> Prompt { todo() } }",
"impl Demo { #[resource] fn a() -> crate::registry::Resource { todo() } }",
] {
let method = one_method(source)?;
let mark = Mark::of(&method).ok_or(Failure::Accepted("a marked method"))?;
if let Err(error) = mark.check(&method) {
return Err(Failure::Refused(error));
}
}
Ok(())
}
#[test]
fn the_rejection_names_the_mark_the_type_written_and_the_type_wanted() -> Result<(), Failure> {
let method = one_method("impl Demo { #[prompt] fn a() -> String { todo() } }")?;
let Some(error) = Mark::Prompt.check(&method).err() else {
return Err(Failure::Accepted("a prompt returning String"));
};
let message = error.to_string();
for expected in ["#[prompt]", "Prompt", "String", "#[tool]"] {
assert!(message.contains(expected), "message was: {message}");
}
Ok(())
}
#[test]
fn a_type_the_macro_cannot_read_a_name_from_is_refused() -> Result<(), Failure> {
let method = one_method("impl Demo { fn a() -> impl Sized {} }")?;
assert!(Mark::Resource.check(&method).is_err());
Ok(())
}
#[test]
fn a_method_returning_nothing_is_refused() -> Result<(), Failure> {
let method = one_method("impl Demo { #[resource] fn a() {} }")?;
assert!(Mark::Resource.check(&method).is_err());
Ok(())
}
#[test]
fn a_receiver_is_refused_and_the_message_points_at_the_builder() -> Result<(), Failure> {
let method = one_method("impl Demo { #[resource] fn a(&self) -> Resource { todo() } }")?;
let Some(error) = Mark::Resource.check(&method).err() else {
return Err(Failure::Accepted("a resource method taking &self"));
};
assert!(error.to_string().contains("ServerBuilder::resource_with"));
Ok(())
}
#[test]
fn the_return_type_checked_is_the_one_in_the_signature() -> Result<(), Failure> {
let method = one_method("impl Demo { #[resource] fn a() -> Resource { todo() } }")?;
let syn::ReturnType::Type(_, ty) = &method.sig.output else {
return Err(Failure::Fixture(syn::Error::new(
proc_macro2::Span::call_site(),
"the fixture declares no return type",
)));
};
assert!(matches!(&**ty, Type::Path(_)));
assert_eq!(
last_segment(ty).map(ToString::to_string).as_deref(),
Some("Resource")
);
Ok(())
}
#[test]
fn a_type_name_lowercases_only_its_first_letter() {
for (name, expected) in [
("Resource", "resource"),
("Prompt", "prompt"),
("ResourceIcon", "resourceIcon"),
("", ""),
] {
assert_eq!(lower_first(name), expected, "for {name}");
}
}
}