use std::collections::HashMap;
use syn::{
parse::{Parse, ParseStream},
punctuated::Punctuated,
spanned::Spanned,
Expr, ExprLit, ExprPath, ExprTuple, FnArg, GenericArgument, ImplItem, ImplItemFn, Lit, Meta,
MetaList, MetaNameValue, Pat, PathArguments, ReturnType, Token, Type,
};
#[derive(Debug, Clone)]
pub struct AuthResolver {
pub param_name: syn::Ident,
pub resolver_expr: syn::Expr,
}
pub(crate) const DEPRECATION_REMOVED_IN_UNSPECIFIED: &str = "unspecified";
#[derive(Debug, Clone)]
pub struct ParsedDeprecation {
pub since: String,
pub removed_in: String,
pub message: String,
}
#[derive(Debug, Clone)]
pub enum BidirType {
None,
Standard,
Custom { request: String, response: String },
}
#[derive(Debug, Clone)]
pub enum MethodRequestOverride {
Skip,
#[allow(dead_code)]
Type(syn::Type),
}
pub struct HubMethodAttrs {
pub name: Option<String>,
pub description: Option<String>,
pub param_docs: HashMap<String, String>,
pub returns_variants: Vec<String>,
pub streaming: bool,
pub bidirectional: BidirType,
pub http_method: Option<String>,
pub request_override: Option<MethodRequestOverride>,
}
impl Parse for HubMethodAttrs {
fn parse(input: ParseStream) -> syn::Result<Self> {
let mut name = None;
let mut description: Option<String> = None;
let mut param_docs = HashMap::new();
let mut returns_variants = Vec::new();
let mut streaming = false;
let mut bidirectional = BidirType::None;
let mut http_method = None;
let mut request_override: Option<MethodRequestOverride> = None;
if !input.is_empty() {
let metas = Punctuated::<Meta, Token![,]>::parse_terminated(input)?;
for meta in metas {
match meta {
Meta::Path(path) => {
if path.is_ident("override_call") {
return Err(syn::Error::new_spanned(
&path,
"override_call is deprecated. Use the streaming pattern instead:\n\
- Return `impl Stream<Item = YourEvent>` from the method\n\
- Forward PlexusStreamItem variants into your event enum\n\
- Add `streaming` attribute if the method yields multiple events\n\
See plexus.call implementation for an example.",
));
} else if path.is_ident("streaming") {
streaming = true;
} else if path.is_ident("bidirectional") {
bidirectional = BidirType::Standard;
}
}
Meta::NameValue(MetaNameValue { path, value, .. }) => {
if path.is_ident("name") {
if let Expr::Lit(ExprLit { lit: Lit::Str(s), .. }) = value {
name = Some(s.value());
}
} else if path.is_ident("description") {
if let Expr::Lit(ExprLit { lit: Lit::Str(s), .. }) = value {
description = Some(s.value());
}
} else if path.is_ident("http_method") {
if let Expr::Lit(ExprLit { lit: Lit::Str(s), .. }) = value {
let method = s.value().to_uppercase();
match method.as_str() {
"GET" | "POST" | "PUT" | "DELETE" | "PATCH" => {
http_method = Some(method);
}
_ => {
return Err(syn::Error::new_spanned(
s,
format!(
"Invalid HTTP method '{}'. Valid methods: GET, POST, PUT, DELETE, PATCH",
method
),
));
}
}
}
} else if path.is_ident("request") {
match value {
Expr::Tuple(ExprTuple { ref elems, .. }) if elems.is_empty() => {
request_override = Some(MethodRequestOverride::Skip);
}
Expr::Path(ExprPath { ref path, .. }) => {
let ty: Type = Type::Path(syn::TypePath {
qself: None,
path: path.clone(),
});
request_override = Some(MethodRequestOverride::Type(ty));
}
_ => {
}
}
}
}
Meta::List(MetaList { path, tokens, .. }) => {
if path.is_ident("params") {
let parser = Punctuated::<Meta, Token![,]>::parse_terminated;
let nested = syn::parse::Parser::parse2(parser, tokens.clone())?;
for meta in nested {
if let Meta::NameValue(MetaNameValue { path, value, .. }) = meta {
if let Some(ident) = path.get_ident() {
if let Expr::Lit(ExprLit { lit: Lit::Str(s), .. }) = value {
param_docs.insert(ident.to_string(), s.value());
}
}
}
}
} else if path.is_ident("returns") {
let parser = Punctuated::<syn::Ident, Token![,]>::parse_terminated;
let nested = syn::parse::Parser::parse2(parser, tokens.clone())?;
for ident in nested {
returns_variants.push(ident.to_string());
}
} else if path.is_ident("bidirectional") {
let parser = Punctuated::<Meta, Token![,]>::parse_terminated;
let nested = syn::parse::Parser::parse2(parser, tokens.clone())?;
let mut request = None;
let mut response = None;
for meta in nested {
if let Meta::NameValue(MetaNameValue { path, value, .. }) = meta {
if let Expr::Lit(ExprLit { lit: Lit::Str(s), .. }) = value {
if path.is_ident("request") {
request = Some(s.value());
} else if path.is_ident("response") {
response = Some(s.value());
}
}
}
}
match (request, response) {
(Some(req), Some(resp)) => {
bidirectional = BidirType::Custom { request: req, response: resp };
}
_ => {
return Err(syn::Error::new_spanned(
&path,
"bidirectional(...) requires both request and response parameters: bidirectional(request = \"MyReq\", response = \"MyResp\")"
));
}
}
}
}
}
}
}
Ok(HubMethodAttrs { name, description, param_docs, returns_variants, streaming, bidirectional, http_method, request_override })
}
}
const MAX_DESCRIPTION_WORDS: usize = 15;
pub struct HubMethodsAttrs {
pub namespace: String,
pub version: Option<String>,
pub description: Option<String>,
pub long_description: Option<String>,
pub crate_path: Option<String>,
pub resolve_handle: bool,
pub hub: bool,
pub hub_span: Option<proc_macro2::Span>,
pub plugin_id: Option<String>,
pub namespace_fn: Option<String>,
pub request_type: Option<syn::Type>,
pub children: Vec<syn::Ident>,
pub children_span: Option<proc_macro2::Span>,
}
impl Parse for HubMethodsAttrs {
fn parse(input: ParseStream) -> syn::Result<Self> {
let mut namespace = String::new();
let mut version: Option<String> = None;
let mut description = None;
let mut long_description = None;
let mut crate_path: Option<String> = None;
let mut resolve_handle = false;
let mut hub = false;
let mut hub_span: Option<proc_macro2::Span> = None;
let mut plugin_id = None;
let mut namespace_fn = None;
let mut request_type: Option<syn::Type> = None;
let mut children: Vec<syn::Ident> = Vec::new();
let mut children_span: Option<proc_macro2::Span> = None;
if !input.is_empty() {
let metas = Punctuated::<Meta, Token![,]>::parse_terminated(input)?;
for meta in metas {
match meta {
Meta::NameValue(MetaNameValue { path, value, .. }) => {
if path.is_ident("request") {
let ty: syn::Type = expr_to_type(&value).ok_or_else(|| {
syn::Error::new_spanned(&path, "request = <Type>: expected a type path (e.g. request = MyRequest or request = ())")
})?;
request_type = Some(ty);
continue;
}
if let Expr::Lit(ExprLit { lit: Lit::Str(s), .. }) = value {
if path.is_ident("namespace") {
namespace = s.value();
} else if path.is_ident("version") {
version = Some(s.value());
} else if path.is_ident("description") {
let desc = s.value();
let word_count = desc.split_whitespace().count();
if word_count > MAX_DESCRIPTION_WORDS {
return Err(syn::Error::new(
s.span(),
format!(
"description must be {} words or fewer (found {} words). \
Use long_description for detailed text.",
MAX_DESCRIPTION_WORDS, word_count
),
));
}
description = Some(desc);
} else if path.is_ident("long_description") {
long_description = Some(s.value());
} else if path.is_ident("crate_path") {
crate_path = Some(s.value());
} else if path.is_ident("plugin_id") {
let id_str = s.value();
if uuid::Uuid::parse_str(&id_str).is_err() {
return Err(syn::Error::new(
s.span(),
format!("plugin_id must be a valid UUID, got: {}", id_str),
));
}
plugin_id = Some(id_str);
} else if path.is_ident("namespace_fn") {
namespace_fn = Some(s.value());
}
}
}
Meta::Path(path) => {
if path.is_ident("resolve_handle") {
resolve_handle = true;
} else if path.is_ident("hub") {
hub = true;
hub_span = Some(path.span());
}
}
Meta::List(list) => {
if list.path.is_ident("children") {
let parser = Punctuated::<syn::Ident, Token![,]>::parse_terminated;
let idents = syn::parse::Parser::parse2(parser, list.tokens.clone())?;
children = idents.into_iter().collect();
children_span = Some(list.path.span());
}
}
}
}
}
if namespace.is_empty() {
return Err(syn::Error::new(
input.span(),
"hub_methods requires namespace = \"...\" attribute",
));
}
Ok(HubMethodsAttrs {
namespace,
version,
description,
long_description,
crate_path,
resolve_handle,
hub,
hub_span,
plugin_id,
namespace_fn,
request_type,
children,
children_span,
})
}
}
pub struct ParamInfo {
pub name: syn::Ident,
pub ty: Type,
pub description: Option<String>,
pub deprecation: Option<ParsedDeprecation>,
}
#[derive(Debug, Clone)]
pub struct ActivationParamInfo {
pub param_name: syn::Ident,
pub ty: Type,
}
#[derive(Debug, Clone)]
pub enum ChildMethodKind {
Static,
Dynamic,
}
#[derive(Debug, Clone)]
pub struct ChildMethodInfo {
pub fn_name: syn::Ident,
#[allow(dead_code)]
pub description: String,
pub kind: ChildMethodKind,
pub is_async: bool,
pub list_fn: Option<syn::Ident>,
pub search_fn: Option<syn::Ident>,
pub deprecation: Option<ParsedDeprecation>,
}
impl ChildMethodInfo {
pub fn from_fn(method: &ImplItemFn) -> syn::Result<Self> {
let fn_name = method.sig.ident.clone();
let is_async = method.sig.asyncness.is_some();
let description = extract_doc_description(&method.attrs).unwrap_or_default();
let (list_fn, search_fn) = parse_child_attr_args(&method.attrs)?;
let deprecation = parse_deprecation_attrs(&method.attrs, method.sig.span())?;
let mut typed_params: Vec<&syn::PatType> = Vec::new();
let mut has_receiver = false;
for arg in &method.sig.inputs {
match arg {
FnArg::Receiver(_) => has_receiver = true,
FnArg::Typed(pt) => typed_params.push(pt),
}
}
if !has_receiver {
return Err(syn::Error::new_spanned(
&method.sig,
"unsupported child method signature: a #[child] method must take `&self` \
(shapes: `fn NAME(&self) -> Child` or `fn NAME(&self, name: &str) -> Option<Child>`)",
));
}
let kind = match typed_params.len() {
0 => ChildMethodKind::Static,
1 => {
let pt = typed_params[0];
if !is_str_ref(&pt.ty) {
return Err(syn::Error::new_spanned(
&pt.ty,
"unsupported child method signature: the single parameter of a dynamic \
#[child] method must be `name: &str` \
(shapes: `fn NAME(&self) -> Child` or `fn NAME(&self, name: &str) -> Option<Child>`)",
));
}
ChildMethodKind::Dynamic
}
_ => {
return Err(syn::Error::new_spanned(
&method.sig,
"unsupported child method signature: #[child] methods accept either \
no extra arguments (static child) or a single `name: &str` argument \
(dynamic child)",
));
}
};
if matches!(method.sig.output, ReturnType::Default) {
return Err(syn::Error::new_spanned(
&method.sig,
"unsupported child method signature: a #[child] method must declare a return type \
(either `-> Child` for a static child or `-> Option<Child>` for a dynamic child)",
));
}
Ok(ChildMethodInfo {
fn_name,
description,
kind,
is_async,
list_fn,
search_fn,
deprecation,
})
}
}
fn parse_child_attr_args(
attrs: &[syn::Attribute],
) -> syn::Result<(Option<syn::Ident>, Option<syn::Ident>)> {
let mut list_fn: Option<syn::Ident> = None;
let mut search_fn: Option<syn::Ident> = None;
for attr in attrs {
let path = attr.path();
let last = path.segments.last().map(|s| s.ident.to_string());
if !matches!(last.as_deref(), Some("child")) {
continue;
}
match &attr.meta {
Meta::Path(_) => {}
Meta::List(list) => {
let metas = list.parse_args_with(
Punctuated::<Meta, Token![,]>::parse_terminated,
)?;
for meta in metas {
match meta {
Meta::NameValue(MetaNameValue { path, value, .. }) => {
let ident_key = match path.get_ident() {
Some(id) => id.to_string(),
None => {
return Err(syn::Error::new_spanned(
&path,
"unknown argument to #[plexus_macros::child]; \
expected `list = \"method\"` or `search = \"method\"`",
));
}
};
let s = match value {
Expr::Lit(ExprLit { lit: Lit::Str(ref s), .. }) => s.clone(),
_ => {
return Err(syn::Error::new_spanned(
&value,
format!(
"#[plexus_macros::child({} = \"...\")] expects a string literal naming a sibling method",
ident_key
),
));
}
};
let method_ident = syn::Ident::new(&s.value(), s.span());
match ident_key.as_str() {
"list" => {
if list_fn.is_some() {
return Err(syn::Error::new_spanned(
&s,
"duplicate `list = \"...\"` argument on #[plexus_macros::child]",
));
}
list_fn = Some(method_ident);
}
"search" => {
if search_fn.is_some() {
return Err(syn::Error::new_spanned(
&s,
"duplicate `search = \"...\"` argument on #[plexus_macros::child]",
));
}
search_fn = Some(method_ident);
}
other => {
return Err(syn::Error::new_spanned(
&path,
format!(
"unknown argument `{}` to #[plexus_macros::child]; \
expected `list = \"method\"` or `search = \"method\"`",
other
),
));
}
}
}
other => {
return Err(syn::Error::new_spanned(
other,
"unknown argument to #[plexus_macros::child]; \
expected `list = \"method\"` or `search = \"method\"`",
));
}
}
}
}
Meta::NameValue(_) => {
return Err(syn::Error::new_spanned(
attr,
"#[plexus_macros::child = ...] form is not supported; \
use `#[plexus_macros::child]` or `#[plexus_macros::child(list = \"...\", search = \"...\")]`",
));
}
}
}
Ok((list_fn, search_fn))
}
#[derive(Debug, Clone, Copy)]
pub enum ListSearchReturnShape {
ImplStream,
BoxStream,
}
#[derive(Debug, Clone)]
pub struct ListSearchMethodInfo {
pub fn_name: syn::Ident,
pub is_async: bool,
#[allow(dead_code)]
pub return_shape: ListSearchReturnShape,
}
#[derive(Debug, Clone, Copy)]
pub enum ListSearchKind {
List,
Search,
}
pub fn find_and_validate_list_search_method(
items: &[ImplItem],
name: &syn::Ident,
kind: ListSearchKind,
attr_owner: &syn::Ident,
) -> syn::Result<ListSearchMethodInfo> {
let method: &ImplItemFn = items
.iter()
.find_map(|it| match it {
ImplItem::Fn(f) if f.sig.ident == *name => Some(f),
_ => None,
})
.ok_or_else(|| {
syn::Error::new(
name.span(),
format!(
"method `{}` referenced by #[plexus_macros::child({} = \"{}\")] \
on `{}` not found in impl",
name,
match kind {
ListSearchKind::List => "list",
ListSearchKind::Search => "search",
},
name,
attr_owner,
),
)
})?;
let mut has_receiver = false;
let mut typed_params: Vec<&syn::PatType> = Vec::new();
for arg in method.sig.inputs.iter() {
match arg {
FnArg::Receiver(_) => has_receiver = true,
FnArg::Typed(pt) => typed_params.push(pt),
}
}
if !has_receiver {
return Err(syn::Error::new_spanned(
&method.sig,
format!(
"child method signature mismatch: `{}` must take `&self` (expected shape: `(async) fn {}({}) -> impl Stream<Item = String>` or `-> BoxStream<'_, String>`)",
name,
name,
match kind {
ListSearchKind::List => "&self",
ListSearchKind::Search => "&self, query: &str",
},
),
));
}
match kind {
ListSearchKind::List => {
if !typed_params.is_empty() {
return Err(syn::Error::new_spanned(
&method.sig,
format!(
"child method signature mismatch: list method `{}` must take only `&self` (got {} extra parameter{}); expected `(async) fn {}(&self) -> impl Stream<Item = String>` or `-> BoxStream<'_, String>`",
name,
typed_params.len(),
if typed_params.len() == 1 { "" } else { "s" },
name,
),
));
}
}
ListSearchKind::Search => {
if typed_params.len() != 1 {
return Err(syn::Error::new_spanned(
&method.sig,
format!(
"child method signature mismatch: search method `{}` must take `&self` and exactly one `query: &str` parameter (got {}); expected `(async) fn {}(&self, query: &str) -> impl Stream<Item = String>` or `-> BoxStream<'_, String>`",
name,
typed_params.len(),
name,
),
));
}
if !is_str_ref(&typed_params[0].ty) {
return Err(syn::Error::new_spanned(
&typed_params[0].ty,
format!(
"child method signature mismatch: the query parameter of search method `{}` must be `&str`",
name
),
));
}
}
}
let return_ty = match &method.sig.output {
ReturnType::Default => {
return Err(syn::Error::new_spanned(
&method.sig,
format!(
"child method signature mismatch: `{}` must declare a return type of \
`impl Stream<Item = String>` or `BoxStream<'_, String>`",
name
),
));
}
ReturnType::Type(_, ty) => ty.as_ref(),
};
let return_shape = classify_string_stream_return(return_ty).ok_or_else(|| {
syn::Error::new_spanned(
return_ty,
format!(
"child method signature mismatch: `{}` must return `impl Stream<Item = String>` or `BoxStream<'_, String>`",
name,
),
)
})?;
Ok(ListSearchMethodInfo {
fn_name: method.sig.ident.clone(),
is_async: method.sig.asyncness.is_some(),
return_shape,
})
}
fn classify_string_stream_return(ty: &Type) -> Option<ListSearchReturnShape> {
if let Type::ImplTrait(impl_trait) = ty {
for bound in &impl_trait.bounds {
if let syn::TypeParamBound::Trait(tb) = bound {
let last = tb.path.segments.last()?;
if last.ident == "Stream" {
if let PathArguments::AngleBracketed(args) = &last.arguments {
for arg in &args.args {
if let GenericArgument::AssocType(at) = arg {
if at.ident == "Item" && type_is_string(&at.ty) {
return Some(ListSearchReturnShape::ImplStream);
}
}
}
}
}
}
}
return None;
}
if let Type::Path(tp) = ty {
let last = tp.path.segments.last()?;
if last.ident == "BoxStream" {
if let PathArguments::AngleBracketed(args) = &last.arguments {
for arg in &args.args {
if let GenericArgument::Type(inner) = arg {
if type_is_string(inner) {
return Some(ListSearchReturnShape::BoxStream);
}
}
}
}
}
}
None
}
fn type_is_string(ty: &Type) -> bool {
if let Type::Path(tp) = ty {
if let Some(seg) = tp.path.segments.last() {
return seg.ident == "String" && matches!(seg.arguments, PathArguments::None);
}
}
false
}
fn is_str_ref(ty: &Type) -> bool {
if let Type::Reference(r) = ty {
if r.mutability.is_some() {
return false;
}
if let Type::Path(tp) = &*r.elem {
if let Some(seg) = tp.path.segments.last() {
return seg.ident == "str" && tp.path.segments.len() == 1;
}
}
}
false
}
pub fn parse_deprecation_attrs(
attrs: &[syn::Attribute],
item_span: proc_macro2::Span,
) -> syn::Result<Option<ParsedDeprecation>> {
let deprecated_attr = attrs.iter().find(|a| a.path().is_ident("deprecated"));
let removed_in_attr = attrs.iter().find(|a| {
let segs = &a.path().segments;
segs.last()
.map(|s| s.ident == "removed_in")
.unwrap_or(false)
});
let removed_in_from_sentinel: Option<String> = attrs
.iter()
.filter(|a| a.path().is_ident("doc"))
.filter_map(|a| {
if let Meta::NameValue(MetaNameValue { value, .. }) = &a.meta {
if let Expr::Lit(ExprLit { lit: Lit::Str(s), .. }) = value {
let v = s.value();
if let Some(rest) = v.strip_prefix("__plexus_removed_in:") {
return Some(rest.to_string());
}
}
}
None
})
.next();
if deprecated_attr.is_none() && removed_in_attr.is_none() && removed_in_from_sentinel.is_none() {
return Ok(None);
}
if deprecated_attr.is_none() {
let err_span = removed_in_attr
.map(|a| a.span())
.unwrap_or_else(proc_macro2::Span::call_site);
return Err(syn::Error::new(
err_span,
"#[plexus_macros::removed_in(\"...\")] requires a companion \
#[deprecated] attribute on the same item — `removed_in` by itself \
has no meaning. Add `#[deprecated(since = \"...\", note = \"...\")]` \
alongside, or remove the `#[removed_in]` attribute.",
));
}
let mut since = String::new();
let mut message = String::new();
let mut removed_in_from_deprecated: Option<String> = None;
let deprecated_attr = deprecated_attr.unwrap();
match &deprecated_attr.meta {
Meta::Path(_) => {}
Meta::List(list) => {
let inner: Punctuated<Meta, Token![,]> =
list.parse_args_with(Punctuated::<Meta, Token![,]>::parse_terminated)?;
for meta in inner {
if let Meta::NameValue(MetaNameValue { path, value, .. }) = meta {
if let Expr::Lit(ExprLit { lit: Lit::Str(s), .. }) = value {
if path.is_ident("since") {
since = s.value();
} else if path.is_ident("note") {
message = s.value();
} else if path.is_ident("removed_in") {
removed_in_from_deprecated = Some(s.value());
}
}
}
}
}
Meta::NameValue(_) => {
if let Meta::NameValue(MetaNameValue { value, .. }) = &deprecated_attr.meta {
if let Expr::Lit(ExprLit { lit: Lit::Str(s), .. }) = value {
message = s.value();
}
}
}
}
let mut removed_in_from_companion: Option<String> = None;
if let Some(attr) = removed_in_attr {
match &attr.meta {
Meta::List(list) => {
let s: syn::LitStr = syn::parse2(list.tokens.clone()).map_err(|_| {
syn::Error::new_spanned(
&list.tokens,
"#[plexus_macros::removed_in(\"...\")] expects a single string \
literal argument (e.g. #[plexus_macros::removed_in(\"0.7\")])",
)
})?;
removed_in_from_companion = Some(s.value());
}
Meta::NameValue(MetaNameValue { value, .. }) => {
if let Expr::Lit(ExprLit { lit: Lit::Str(s), .. }) = value {
removed_in_from_companion = Some(s.value());
} else {
return Err(syn::Error::new_spanned(
value,
"#[plexus_macros::removed_in = \"...\"] expects a string literal",
));
}
}
Meta::Path(_) => {
return Err(syn::Error::new(
attr.span(),
"#[plexus_macros::removed_in] requires an argument (e.g. \
#[plexus_macros::removed_in(\"0.7\")])",
));
}
}
}
let removed_in = removed_in_from_companion
.or(removed_in_from_sentinel)
.or(removed_in_from_deprecated)
.unwrap_or_else(|| DEPRECATION_REMOVED_IN_UNSPECIFIED.to_string());
let _ = item_span; Ok(Some(ParsedDeprecation {
since,
removed_in,
message,
}))
}
pub fn has_child_attr(method: &ImplItemFn) -> bool {
method.attrs.iter().any(|a| {
let path = a.path();
path.segments
.last()
.map(|s| s.ident == "child")
.unwrap_or(false)
})
}
pub fn has_method_attr(method: &ImplItemFn) -> bool {
method.attrs.iter().any(|a| {
let last = a.path().segments.last().map(|s| s.ident.to_string());
matches!(last.as_deref(), Some("method") | Some("hub_method"))
})
}
pub struct MethodInfo {
pub fn_name: syn::Ident,
pub method_name: String,
pub description: String,
pub params: Vec<ParamInfo>,
#[allow(dead_code)]
pub return_type: Type,
pub stream_item_type: Option<Type>,
pub returns_variants: Vec<String>,
pub streaming: bool,
pub bidirectional: BidirType,
pub http_method: Option<String>,
pub requires_auth: bool,
pub auth_resolvers: Vec<AuthResolver>,
pub activation_params: Vec<ActivationParamInfo>,
pub request_override: Option<MethodRequestOverride>,
pub deprecation: Option<ParsedDeprecation>,
}
impl MethodInfo {
pub fn from_fn(method: &ImplItemFn, hub_method_attrs: Option<&HubMethodAttrs>) -> syn::Result<Self> {
let fn_name = method.sig.ident.clone();
let method_name = hub_method_attrs
.and_then(|a| a.name.clone())
.unwrap_or_else(|| fn_name.to_string());
let description = match hub_method_attrs.and_then(|a| a.description.clone()) {
Some(explicit) => explicit,
None => extract_doc_description(&method.attrs).unwrap_or_default(),
};
let param_docs = hub_method_attrs
.map(|a| &a.param_docs)
.cloned()
.unwrap_or_default();
let returns_variants = hub_method_attrs
.map(|a| a.returns_variants.clone())
.unwrap_or_default();
let streaming = hub_method_attrs
.map(|a| a.streaming)
.unwrap_or(false);
let http_method = hub_method_attrs
.and_then(|a| a.http_method.clone());
let request_override = hub_method_attrs
.and_then(|a| a.request_override.clone());
let mut bidirectional = hub_method_attrs
.map(|a| a.bidirectional.clone())
.unwrap_or(BidirType::None);
let mut requires_auth = false;
let mut auth_resolvers: Vec<AuthResolver> = Vec::new();
let mut activation_params: Vec<ActivationParamInfo> = Vec::new();
let mut params = Vec::new();
for arg in &method.sig.inputs {
if let FnArg::Typed(pat_type) = arg {
if let Pat::Ident(ident) = &*pat_type.pat {
let name = ident.ident.clone();
let name_str = name.to_string();
if let Some(err) = check_activation_param_attr(&pat_type.attrs)? {
return Err(err);
}
if has_activation_param_attr(&pat_type.attrs) {
activation_params.push(ActivationParamInfo {
param_name: name,
ty: (*pat_type.ty).clone(),
});
continue;
}
if let Some(resolver) = extract_from_auth_attr(&pat_type.attrs) {
requires_auth = true;
auth_resolvers.push(AuthResolver {
param_name: name,
resolver_expr: resolver,
});
continue;
}
if name_str == "auth" {
if is_auth_context_type(&pat_type.ty) {
requires_auth = true;
continue;
}
}
if name_str == "ctx" || name_str == "context" {
if let Some(bidir_types) = extract_bidir_channel_types(&pat_type.ty) {
bidirectional = bidir_types;
continue;
}
}
let description = param_docs.get(&name_str).cloned();
let deprecation =
parse_deprecation_attrs(&pat_type.attrs, pat_type.span())?;
params.push(ParamInfo {
name,
ty: (*pat_type.ty).clone(),
description,
deprecation,
});
}
}
}
let return_type = match &method.sig.output {
ReturnType::Default => {
return Err(syn::Error::new_spanned(
&method.sig,
"hub_method requires a return type",
))
}
ReturnType::Type(_, ty) => (**ty).clone(),
};
let stream_item_type = extract_stream_item_type(&return_type);
if is_result_plexus_stream(&return_type) {
return Err(syn::Error::new_spanned(
&method.sig.output,
format!(
"Method `{}` returns Result<PlexusStream, _> which bypasses schema generation. \
Use the streaming pattern instead:\n\
- Return `impl Stream<Item = YourEvent>` from the method\n\
- Forward PlexusStreamItem variants into your event enum\n\
- Add `streaming` attribute if the method yields multiple events\n\
See plexus.call implementation for an example.",
fn_name
),
));
}
let deprecation = parse_deprecation_attrs(&method.attrs, method.sig.span())?;
Ok(MethodInfo {
fn_name,
method_name,
description,
params,
return_type,
stream_item_type,
returns_variants,
streaming,
bidirectional,
http_method,
requires_auth,
auth_resolvers,
activation_params,
request_override,
deprecation,
})
}
}
fn expr_to_type(expr: &Expr) -> Option<syn::Type> {
match expr {
Expr::Path(ExprPath { attrs: _, qself, path }) => {
Some(syn::Type::Path(syn::TypePath {
qself: qself.as_ref().map(|qs| syn::QSelf {
lt_token: qs.lt_token,
ty: qs.ty.clone(),
position: qs.position,
as_token: qs.as_token,
gt_token: qs.gt_token,
}),
path: path.clone(),
}))
}
Expr::Tuple(ExprTuple { elems, .. }) if elems.is_empty() => {
Some(syn::parse_quote! { () })
}
_ => None,
}
}
fn has_activation_param_attr(attrs: &[syn::Attribute]) -> bool {
attrs.iter().any(|attr| attr.path().is_ident("activation_param"))
}
fn check_activation_param_attr(attrs: &[syn::Attribute]) -> syn::Result<Option<syn::Error>> {
for attr in attrs {
if attr.path().is_ident("activation_param") {
if let syn::Meta::List(list) = &attr.meta {
return Ok(Some(syn::Error::new_spanned(
list,
"activation_param takes no arguments — extraction is defined on the request struct, not the method",
)));
}
}
}
Ok(None)
}
fn extract_from_auth_attr(attrs: &[syn::Attribute]) -> Option<syn::Expr> {
for attr in attrs {
if attr.path().is_ident("from_auth") {
let expr: syn::Expr = attr.parse_args().ok()?;
return Some(expr);
}
}
None
}
fn is_auth_context_type(ty: &Type) -> bool {
if let Type::Reference(type_ref) = ty {
if let Type::Path(type_path) = &*type_ref.elem {
if let Some(last_segment) = type_path.path.segments.last() {
if last_segment.ident == "AuthContext" {
return true;
}
}
}
}
if let Type::Path(type_path) = ty {
if let Some(last_segment) = type_path.path.segments.last() {
if last_segment.ident == "Option" {
if let PathArguments::AngleBracketed(args) = &last_segment.arguments {
if let Some(GenericArgument::Type(inner_ty)) = args.args.first() {
return is_auth_context_type(inner_ty);
}
}
}
}
}
false
}
fn extract_bidir_channel_types(ty: &Type) -> Option<BidirType> {
let inner_ty = if let Type::Reference(type_ref) = ty {
&*type_ref.elem
} else {
ty
};
if let Type::Path(type_path) = inner_ty {
let last_segment = type_path.path.segments.last()?;
if last_segment.ident == "Arc" {
if let PathArguments::AngleBracketed(args) = &last_segment.arguments {
if let Some(GenericArgument::Type(inner)) = args.args.first() {
return extract_bidir_from_path(inner);
}
}
return None;
}
return extract_bidir_from_path(inner_ty);
}
None
}
fn extract_bidir_from_path(ty: &Type) -> Option<BidirType> {
if let Type::Path(type_path) = ty {
let last_segment = type_path.path.segments.last()?;
if last_segment.ident == "StandardBidirChannel" {
return Some(BidirType::Standard);
}
if last_segment.ident == "BidirChannel" {
if let PathArguments::AngleBracketed(args) = &last_segment.arguments {
let args_vec: Vec<_> = args.args.iter().collect();
if args_vec.len() == 2 {
if let (GenericArgument::Type(req_ty), GenericArgument::Type(resp_ty)) =
(args_vec[0], args_vec[1])
{
let req_name = type_to_string(req_ty);
let resp_name = type_to_string(resp_ty);
if req_name == "StandardRequest" && resp_name == "StandardResponse" {
return Some(BidirType::Standard);
}
return Some(BidirType::Custom {
request: req_name,
response: resp_name,
});
}
}
}
}
}
None
}
fn type_to_string(ty: &Type) -> String {
if let Type::Path(type_path) = ty {
type_path
.path
.segments
.iter()
.map(|s| s.ident.to_string())
.collect::<Vec<_>>()
.join("::")
} else {
format!("{:?}", ty)
}
}
fn extract_stream_item_type(ty: &Type) -> Option<Type> {
if let Type::ImplTrait(impl_trait) = ty {
for bound in &impl_trait.bounds {
if let syn::TypeParamBound::Trait(trait_bound) = bound {
let last_segment = trait_bound.path.segments.last()?;
if last_segment.ident == "Stream" {
if let PathArguments::AngleBracketed(args) = &last_segment.arguments {
for arg in &args.args {
if let GenericArgument::AssocType(assoc) = arg {
if assoc.ident == "Item" {
return Some(assoc.ty.clone());
}
}
}
}
}
}
}
}
None
}
pub(crate) fn extract_doc_description(attrs: &[syn::Attribute]) -> Option<String> {
let mut raw_lines: Vec<String> = Vec::new();
for attr in attrs {
if attr.path().is_ident("doc") {
if let Meta::NameValue(MetaNameValue { value, .. }) = &attr.meta {
if let Expr::Lit(ExprLit { lit: Lit::Str(s), .. }) = value {
for line in s.value().split('\n') {
raw_lines.push(line.to_string());
}
}
}
}
}
if raw_lines.is_empty() {
return None;
}
Some(strip_common_leading_whitespace(&raw_lines))
}
fn strip_common_leading_whitespace(lines: &[String]) -> String {
let min_indent = lines
.iter()
.filter(|l| !l.trim().is_empty())
.map(|l| l.chars().take_while(|c| c.is_whitespace()).count())
.min()
.unwrap_or(0);
lines
.iter()
.map(|l| {
if l.trim().is_empty() {
String::new()
} else {
l.chars().skip(min_indent).collect::<String>()
}
})
.collect::<Vec<_>>()
.join("\n")
}
fn is_result_plexus_stream(ty: &Type) -> bool {
if let Type::Path(type_path) = ty {
if let Some(segment) = type_path.path.segments.last() {
if segment.ident == "Result" {
if let PathArguments::AngleBracketed(args) = &segment.arguments {
if let Some(GenericArgument::Type(first_type)) = args.args.first() {
if let Type::Path(inner_path) = first_type {
if let Some(inner_seg) = inner_path.path.segments.last() {
return inner_seg.ident == "PlexusStream";
}
}
}
}
}
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use proc_macro2::Span;
use syn::parse_quote;
#[test]
fn parse_deprecation_reads_inline_removed_in_key() {
let attrs: Vec<syn::Attribute> =
vec![parse_quote! { #[deprecated(since = "0.5", note = "use bar", removed_in = "0.7")] }];
let parsed = parse_deprecation_attrs(&attrs, Span::call_site())
.expect("parser accepts unknown key")
.expect("produces Some for a #[deprecated] attr");
assert_eq!(parsed.since, "0.5");
assert_eq!(parsed.removed_in, "0.7");
assert_eq!(parsed.message, "use bar");
}
#[test]
fn parse_deprecation_companion_overrides_inline_key() {
let attrs: Vec<syn::Attribute> = vec![
parse_quote! { #[deprecated(since = "0.5", note = "n", removed_in = "0.7")] },
parse_quote! { #[plexus_macros::removed_in("0.9")] },
];
let parsed = parse_deprecation_attrs(&attrs, Span::call_site()).unwrap().unwrap();
assert_eq!(parsed.removed_in, "0.9");
}
#[test]
fn parse_deprecation_fallback_is_unspecified() {
let attrs: Vec<syn::Attribute> =
vec![parse_quote! { #[deprecated(since = "0.5", note = "n")] }];
let parsed = parse_deprecation_attrs(&attrs, Span::call_site()).unwrap().unwrap();
assert_eq!(parsed.removed_in, DEPRECATION_REMOVED_IN_UNSPECIFIED);
}
#[test]
fn parse_deprecation_bare_emits_empty_since_and_message() {
let attrs: Vec<syn::Attribute> = vec![parse_quote! { #[deprecated] }];
let parsed = parse_deprecation_attrs(&attrs, Span::call_site()).unwrap().unwrap();
assert_eq!(parsed.since, "");
assert_eq!(parsed.message, "");
assert_eq!(parsed.removed_in, DEPRECATION_REMOVED_IN_UNSPECIFIED);
}
#[test]
fn parse_deprecation_companion_without_deprecated_errors() {
let attrs: Vec<syn::Attribute> =
vec![parse_quote! { #[plexus_macros::removed_in("0.6")] }];
let err = parse_deprecation_attrs(&attrs, Span::call_site())
.expect_err("removed_in alone must error");
let msg = err.to_string();
assert!(
msg.contains("#[deprecated]"),
"error must name #[deprecated] as required companion; got: {}",
msg
);
}
#[test]
fn parse_deprecation_no_attrs_yields_none() {
let attrs: Vec<syn::Attribute> = vec![parse_quote! { #[doc = "just a comment"] }];
let parsed = parse_deprecation_attrs(&attrs, Span::call_site()).unwrap();
assert!(parsed.is_none());
}
}