use heck::{ToKebabCase, ToShoutySnakeCase};
use proc_macro2::TokenStream;
use quote::quote;
use syn::parse::Parser;
use syn::spanned::Spanned;
use syn::{
Data, DeriveInput, Fields, FieldsNamed, GenericArgument, Ident, LitStr, PathArguments, Type,
TypePath,
};
use crate::util::phoxal;
#[allow(clippy::large_enum_variant)] enum ApiDecl {
Publish(Type),
Subscribe(Type),
Serve { req: Type, resp: Type },
Ask { req: Type, resp: Type },
}
fn classify_api_field(ty: &Type) -> Option<Vec<ApiDecl>> {
let path = as_type_path(ty)?;
let seg = path.path.segments.last()?;
let name = seg.ident.to_string();
match name.as_str() {
"Publisher" => Some(vec![ApiDecl::Publish(generic_type(seg, 0)?)]),
"Subscriber" | "Latest" => Some(vec![ApiDecl::Subscribe(generic_type(seg, 0)?)]),
"Server" => Some(vec![ApiDecl::Serve {
req: generic_type(seg, 0)?,
resp: generic_type(seg, 1)?,
}]),
"Querier" => Some(vec![ApiDecl::Ask {
req: generic_type(seg, 0)?,
resp: generic_type(seg, 1)?,
}]),
"Vec" => classify_api_field(&generic_type(seg, 0)?),
"BTreeMap" | "HashMap" => classify_api_field(&generic_type(seg, 1)?),
_ => None,
}
}
fn as_type_path(ty: &Type) -> Option<&TypePath> {
match ty {
Type::Path(p) => Some(p),
_ => None,
}
}
fn generic_type(seg: &syn::PathSegment, n: usize) -> Option<Type> {
let PathArguments::AngleBracketed(args) = &seg.arguments else {
return None;
};
let types: Vec<&Type> = args
.args
.iter()
.filter_map(|a| match a {
GenericArgument::Type(t) => Some(t),
_ => None,
})
.collect();
types.get(n).cloned().cloned()
}
fn named_fields<'a>(input: &'a DeriveInput, derive_path: &str) -> syn::Result<&'a FieldsNamed> {
match &input.data {
Data::Struct(s) => match &s.fields {
Fields::Named(named) => Ok(named),
_ => Err(syn::Error::new_spanned(
&input.ident,
format!("{derive_path} requires a struct with named fields"),
)),
},
_ => Err(syn::Error::new_spanned(
&input.ident,
format!("{derive_path} can only be applied to structs"),
)),
}
}
fn json_escape(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"")
}
fn parse_external_attr(field: &syn::Field) -> syn::Result<bool> {
let mut external = false;
for attr in &field.attrs {
if !attr.path().is_ident("phoxal") {
continue;
}
attr.parse_nested_meta(|meta| {
if meta.path.is_ident("external") {
external = true;
Ok(())
} else {
Err(meta
.error("unknown #[phoxal(...)] key on an `Api` field (expected `external`)"))
}
})?;
}
Ok(external)
}
fn external_on_producer_error(field: &syn::Field, role: &str) -> syn::Error {
let field_name = field
.ident
.as_ref()
.map(Ident::to_string)
.unwrap_or_default();
syn::Error::new_spanned(
field,
format!(
"#[phoxal(external)] is only valid on a subscribe/ask field; `{field_name}` is a \
{role} field - producer roles are never required to have a counterpart, so the \
marker would be a no-op here"
),
)
}
fn link_section_attrs() -> TokenStream {
quote! {
#[used]
#[cfg_attr(target_os = "macos", unsafe(link_section = "__DATA,__phoxal_meta"))]
#[cfg_attr(not(target_os = "macos"), unsafe(link_section = ".phoxal_api_meta"))]
}
}
pub fn expand_api(input: TokenStream) -> syn::Result<TokenStream> {
let input: DeriveInput = syn::parse2(input)?;
let struct_name = &input.ident;
if !input.generics.params.is_empty() {
return Err(syn::Error::new_spanned(
&input.generics,
"#[derive(phoxal::Api)] does not support generic structs",
));
}
let fields = named_fields(&input, "#[derive(phoxal::Api)]")?;
let mut seen = std::collections::BTreeMap::<String, bool>::new();
let mut contract_entries = Vec::new();
let mut manifest_entry_tokens: Vec<TokenStream> = Vec::new();
let phoxal_for_manifest = phoxal();
let mut push = |field_span: proc_macro2::Span,
role_snake: &str,
role_pascal: &str,
body: &Type,
external: bool|
-> syn::Result<()> {
let body_str = normalized_body_key(body);
let key = format!("{role_snake}:{body_str}");
match seen.get(&key) {
Some(&prev_external) if prev_external != external => {
return Err(syn::Error::new(
field_span,
format!(
"#[phoxal(external)] disagreement: another `{role_snake}` field already \
recorded this contract as {}, but this field marks it {}; two fields \
naming the same contract in the same role must agree on the marker",
if prev_external {
"external"
} else {
"not external"
},
if external { "external" } else { "not external" },
),
));
}
Some(_) => return Ok(()),
None => {
seen.insert(key, external);
}
}
manifest_entry_tokens.push(manifest_entry_tokens_for(
&phoxal_for_manifest,
role_snake,
body,
external,
));
contract_entries.push(contract_use_entry(body, role_pascal));
Ok(())
};
let mut seen_declares = std::collections::BTreeSet::<String>::new();
let mut declare_impls = Vec::new();
let phoxal_for_declares = phoxal();
let mut declare = |key: String, tokens: TokenStream| {
if seen_declares.insert(key) {
declare_impls.push(tokens);
}
};
for field in &fields.named {
if field.ident.is_none() {
continue;
}
let Some(decls) = classify_api_field(&field.ty) else {
continue;
};
let field_span = field.span();
let field_external = parse_external_attr(field)?;
for decl in decls {
match decl {
ApiDecl::Publish(body) => {
if field_external {
return Err(external_on_producer_error(field, "publish"));
}
push(field_span, "publish", "Publish", &body, false)?;
let key = format!("declpub:{}", normalized_body_key(&body));
declare(
key,
quote! {
impl #phoxal_for_declares::participant::DeclaresPublish<#body> for #struct_name {}
},
);
}
ApiDecl::Subscribe(body) => {
push(field_span, "subscribe", "Subscribe", &body, field_external)?;
let key = format!("declsub:{}", normalized_body_key(&body));
declare(
key,
quote! {
impl #phoxal_for_declares::participant::DeclaresSubscribe<#body> for #struct_name {}
},
);
}
ApiDecl::Serve { req, resp } => {
if field_external {
return Err(external_on_producer_error(field, "serve"));
}
push(field_span, "serve", "Serve", &req, false)?;
push(field_span, "serve", "Serve", &resp, false)?;
let key = format!(
"declserve:{}=>{}",
normalized_body_key(&req),
normalized_body_key(&resp)
);
declare(
key,
quote! {
impl #phoxal_for_declares::participant::DeclaresServe<#req, #resp> for #struct_name {}
},
);
}
ApiDecl::Ask { req, resp } => {
push(field_span, "ask", "Ask", &req, field_external)?;
push(field_span, "ask", "Ask", &resp, field_external)?;
let key = format!(
"declask:{}=>{}",
normalized_body_key(&req),
normalized_body_key(&resp)
);
declare(
key,
quote! {
impl #phoxal_for_declares::participant::DeclaresAsk<#req, #resp> for #struct_name {}
},
);
}
}
}
}
let phoxal = phoxal();
let open_lit = json_lit(&format!(
"{{\"participant_api\":\"{}\",\"contracts\":[",
json_escape(&struct_name.to_string())
));
let close_lit = json_lit("]}");
let comma_lit = json_lit(",");
let mut manifest_args: Vec<TokenStream> = vec![open_lit];
for (i, entry) in manifest_entry_tokens.iter().enumerate() {
if i > 0 {
manifest_args.push(comma_lit.clone());
}
manifest_args.push(entry.clone());
}
manifest_args.push(close_lit);
let manifest_const_ident = Ident::new(
&format!(
"__PHOXAL_API_META_JSON_{}",
struct_name.to_string().to_shouty_snake_case()
),
struct_name.span(),
);
let manifest_len_ident = Ident::new(
&format!(
"__PHOXAL_API_META_LEN_{}",
struct_name.to_string().to_shouty_snake_case()
),
struct_name.span(),
);
let static_ident = Ident::new(
&format!(
"__PHOXAL_API_META_{}",
struct_name.to_string().to_shouty_snake_case()
),
struct_name.span(),
);
let link_section = link_section_attrs();
let clone_field_names: Vec<&Ident> = fields
.named
.iter()
.filter_map(|field| field.ident.as_ref())
.collect();
Ok(quote! {
impl #phoxal::participant::ParticipantApi for #struct_name {
const CONTRACTS: &'static [#phoxal::participant::ApiContractUse] = &[
#(#contract_entries),*
];
}
#(#declare_impls)*
impl ::core::clone::Clone for #struct_name {
fn clone(&self) -> Self {
Self {
#(#clone_field_names: ::core::clone::Clone::clone(&self.#clone_field_names),)*
}
}
}
#[doc(hidden)]
const #manifest_const_ident: &'static str = #phoxal::participant::api::__meta::__concatcp!(
#(#manifest_args),*
);
#[doc(hidden)]
const #manifest_len_ident: usize = #manifest_const_ident.len();
#link_section
#[doc(hidden)]
static #static_ident: [u8; #manifest_len_ident] =
#phoxal::participant::api::__meta::__bytes_of(#manifest_const_ident);
})
}
fn contract_use_entry(body: &Type, role: &str) -> TokenStream {
let phoxal = phoxal();
let role_ident = Ident::new(role, proc_macro2::Span::call_site());
quote! {
#phoxal::participant::ApiContractUse {
topic: <#body as #phoxal::bus::ContractBody>::TOPIC,
role: #phoxal::participant::ContractRole::#role_ident,
}
}
}
fn manifest_entry_tokens_for(
phoxal: &TokenStream,
role: &str,
body: &Type,
external: bool,
) -> TokenStream {
let prefix = json_lit(&format!("{{\"role\":\"{role}\",\"generation\":\""));
let mid = json_lit("\",\"contract\":\"");
let suffix = json_lit(&format!("\",\"external\":{external}}}"));
quote! {
#phoxal::participant::api::__meta::__concatcp!(
#prefix,
<#body as #phoxal::bus::ContractBody>::GENERATION,
#mid,
<#body as #phoxal::bus::ContractBody>::CONTRACT,
#suffix
)
}
}
fn json_lit(s: &str) -> TokenStream {
let lit = syn::LitStr::new(s, proc_macro2::Span::call_site());
quote!(#lit)
}
fn normalized_body_key(body: &Type) -> String {
quote!(#body).to_string().replace(' ', "")
}
pub fn expand_config(input: TokenStream) -> syn::Result<TokenStream> {
let input: DeriveInput = syn::parse2(input)?;
let struct_name = &input.ident;
if !input.generics.params.is_empty() {
return Err(syn::Error::new_spanned(
&input.generics,
"#[derive(phoxal::Config)] does not support generic structs",
));
}
let phoxal = phoxal();
Ok(quote! {
impl #phoxal::participant::ParticipantConfig for #struct_name {
const SCHEMA_JSON: &'static str = "{}";
}
})
}
#[derive(Clone, Copy)]
pub enum ParticipantKind {
Service,
Driver,
Simulator,
Tool,
}
impl ParticipantKind {
fn attr_name(self) -> &'static str {
match self {
ParticipantKind::Service => "#[phoxal::service]",
ParticipantKind::Driver => "#[phoxal::driver]",
ParticipantKind::Simulator => "#[phoxal::simulator]",
ParticipantKind::Tool => "#[phoxal::tool]",
}
}
fn artifact_kind(self) -> &'static str {
match self {
ParticipantKind::Service => "service",
ParticipantKind::Driver => "driver",
ParticipantKind::Simulator => "simulator",
ParticipantKind::Tool => "tool",
}
}
fn participant_class(self) -> &'static str {
match self {
ParticipantKind::Tool => "privileged",
ParticipantKind::Service | ParticipantKind::Driver | ParticipantKind::Simulator => {
"checked"
}
}
}
fn default_api(self) -> Type {
match self {
ParticipantKind::Tool => syn::parse_quote!(()),
_ => syn::parse_quote!(Api),
}
}
fn marker_impl(self, phoxal: &TokenStream, struct_name: &Ident) -> TokenStream {
match self {
ParticipantKind::Service => {
quote!(impl #phoxal::participant::TypedGraphSurface for #struct_name {})
}
ParticipantKind::Driver => quote! {
impl #phoxal::participant::IsDriver for #struct_name {}
impl #phoxal::participant::TypedGraphSurface for #struct_name {}
},
ParticipantKind::Simulator => quote! {
impl #phoxal::participant::IsSimulator for #struct_name {}
impl #phoxal::participant::TypedGraphSurface for #struct_name {}
},
ParticipantKind::Tool => quote!(impl #phoxal::participant::IsTool for #struct_name {}),
}
}
}
#[derive(Default)]
struct ParticipantArgs {
id: Option<String>,
config: Option<Type>,
api: Option<Type>,
}
impl ParticipantArgs {
fn parse(attr: TokenStream, attr_name: &str) -> syn::Result<Self> {
let mut args = ParticipantArgs::default();
let parser = syn::meta::parser(|meta: syn::meta::ParseNestedMeta| {
if meta.path.is_ident("id") {
let value: LitStr = meta.value()?.parse()?;
args.id = Some(value.value());
Ok(())
} else if meta.path.is_ident("config") {
let value: Type = meta.value()?.parse()?;
args.config = Some(value);
Ok(())
} else if meta.path.is_ident("api") {
let value: Type = meta.value()?.parse()?;
args.api = Some(value);
Ok(())
} else {
Err(meta.error(format!(
"unknown {attr_name}(...) key (expected id, config, or api)"
)))
}
});
parser.parse2(attr)?;
Ok(args)
}
}
pub fn expand_participant(
attr: TokenStream,
item: TokenStream,
kind: ParticipantKind,
) -> syn::Result<TokenStream> {
let item_struct: syn::ItemStruct = syn::parse2(item)?;
let struct_name = &item_struct.ident;
let attr_name = kind.attr_name();
if !item_struct.generics.params.is_empty() {
return Err(syn::Error::new_spanned(
&item_struct.generics,
format!("{attr_name} does not support generic participant structs"),
));
}
let args = ParticipantArgs::parse(attr, attr_name)?;
let id = args
.id
.unwrap_or_else(|| struct_name.to_string().to_kebab_case());
let config_ty: Type = args.config.unwrap_or_else(|| syn::parse_quote!(Config));
let api_ty: Type = args.api.unwrap_or_else(|| kind.default_api());
let phoxal = phoxal();
let artifact_kind = kind.artifact_kind();
let participant_class = kind.participant_class();
let marker = kind.marker_impl(&phoxal, struct_name);
Ok(quote! {
#item_struct
impl #phoxal::participant::Participant for #struct_name {
const KIND: &'static str = #artifact_kind;
const PARTICIPANT_CLASS: &'static str = #participant_class;
const ID: &'static str = #id;
type Config = #config_ty;
type Api = #api_ty;
}
#marker
})
}