use proc_macro2::TokenStream;
use quote::quote;
use sha2::{Digest, Sha256};
use syn::parse::{Parse, ParseStream};
use syn::punctuated::Punctuated;
use syn::spanned::Spanned;
use syn::{
Attribute, Expr, Fields, GenericArgument, GenericParam, Ident, ItemEnum, ItemStruct, Lit,
LitStr, Path, PathArguments, Token, Type, TypePath,
};
use crate::util::body_derives;
mod kw {
syn::custom_keyword!(preview);
syn::custom_keyword!(version);
syn::custom_keyword!(extends);
syn::custom_keyword!(topic);
syn::custom_keyword!(command);
syn::custom_keyword!(state);
syn::custom_keyword!(query);
}
pub fn expand(input: TokenStream) -> syn::Result<TokenStream> {
let tree: ApiTree = syn::parse2(input)?;
tree.expand()
}
struct ApiTree {
versions: Vec<Version>,
}
struct Version {
is_preview: bool,
name: Ident,
extends: Option<Ident>,
nodes: Vec<Node>,
}
#[derive(Clone)]
struct Node {
name: Ident,
var: Option<Ident>,
types: Vec<TypeDef>,
topics: Vec<TopicDef>,
children: Vec<Node>,
}
#[derive(Clone)]
enum TypeDef {
Struct(ItemStruct),
Enum(ItemEnum),
}
impl TypeDef {
fn ident(&self) -> &Ident {
match self {
TypeDef::Struct(item) => &item.ident,
TypeDef::Enum(item) => &item.ident,
}
}
}
#[derive(Clone)]
struct TopicDef {
leaf: TopicLeaf,
kind: TopicKind,
role: TopicRole,
}
#[derive(Clone)]
enum TopicLeaf {
Named(Ident),
Node,
}
impl TopicLeaf {
fn key(&self) -> String {
match self {
TopicLeaf::Named(ident) => ident.to_string(),
TopicLeaf::Node => "self".to_string(),
}
}
fn method_ident(&self) -> Ident {
match self {
TopicLeaf::Named(ident) => ident.clone(),
TopicLeaf::Node => quote::format_ident!("topic"),
}
}
}
#[derive(Clone, Copy)]
enum TopicRole {
Command,
State,
Query,
}
impl TopicRole {
fn bus_variant(self) -> TokenStream {
match self {
TopicRole::Command => quote! { ::phoxal_bus::TopicRole::Command },
TopicRole::State => quote! { ::phoxal_bus::TopicRole::State },
TopicRole::Query => quote! { ::phoxal_bus::TopicRole::Query },
}
}
}
#[derive(Clone)]
enum TopicKind {
PubSub(Ident),
Query { request: Ident, response: Ident },
}
struct ManifestGeneration {
name: String,
is_preview: bool,
extends: Option<String>,
contracts: Vec<ManifestContract>,
}
struct ManifestContract {
family: String,
topic: String,
schema_id: String,
}
impl Parse for ApiTree {
fn parse(input: ParseStream) -> syn::Result<Self> {
let mut versions = Vec::new();
while !input.is_empty() {
versions.push(input.parse()?);
}
if versions.is_empty() {
return Err(input.error("phoxal_api_tree! requires at least one `version` block"));
}
Ok(ApiTree { versions })
}
}
impl Parse for Version {
fn parse(input: ParseStream) -> syn::Result<Self> {
let is_preview = if input.peek(kw::preview) {
input.parse::<kw::preview>()?;
true
} else {
false
};
input.parse::<kw::version>()?;
let name: Ident = input.parse()?;
let extends = if input.peek(kw::extends) {
input.parse::<kw::extends>()?;
Some(input.parse::<Ident>()?)
} else {
None
};
let body;
syn::braced!(body in input);
let mut nodes = Vec::new();
while !body.is_empty() {
nodes.push(body.parse()?);
}
Ok(Version {
is_preview,
name,
extends,
nodes,
})
}
}
impl Parse for Node {
fn parse(input: ParseStream) -> syn::Result<Self> {
let name: Ident = input.parse()?;
let var = if input.peek(syn::token::Paren) {
let content;
syn::parenthesized!(content in input);
let var: Ident = content.parse()?;
if !content.is_empty() {
return Err(content.error(
"a dynamic node binds exactly one variable, e.g. `motor(capability) { … }`",
));
}
Some(var)
} else {
None
};
let body;
syn::braced!(body in input);
let mut types = Vec::new();
let mut topics = Vec::new();
let mut children = Vec::new();
while !body.is_empty() {
let attrs = body.call(syn::Attribute::parse_outer)?;
if body.peek(kw::topic) {
if let Some(attr) = attrs.first() {
return Err(syn::Error::new_spanned(
attr,
"attributes are not allowed on a `topic` declaration",
));
}
topics.push(body.parse()?);
} else if body.peek(Token![struct]) {
let mut item: ItemStruct = body.parse()?;
item.attrs = attrs;
item.vis = syn::Visibility::Public(syn::token::Pub::default());
types.push(TypeDef::Struct(item));
} else if body.peek(Token![enum]) {
let mut item: ItemEnum = body.parse()?;
item.attrs = attrs;
item.vis = syn::Visibility::Public(syn::token::Pub::default());
types.push(TypeDef::Enum(item));
} else if body.peek(Ident)
&& (body.peek2(syn::token::Paren) || body.peek2(syn::token::Brace))
{
if let Some(attr) = attrs.first() {
return Err(syn::Error::new_spanned(
attr,
"attributes are not allowed on a child node declaration",
));
}
children.push(body.parse()?);
} else {
return Err(body.error(
"expected `struct`, `enum`, `topic …;`, or a child node `name { … }` / \
`name(var) { … }` inside an API node block",
));
}
}
Ok(Node {
name,
var,
types,
topics,
children,
})
}
}
impl Parse for TopicDef {
fn parse(input: ParseStream) -> syn::Result<Self> {
input.parse::<kw::topic>()?;
let leaf = if input.peek(Token![self]) {
input.parse::<Token![self]>()?;
TopicLeaf::Node
} else {
TopicLeaf::Named(input.parse()?)
};
input.parse::<Token![:]>()?;
let (kind, role) = if input.peek(kw::command) {
input.parse::<kw::command>()?;
let body: Ident = input.parse()?;
(TopicKind::PubSub(body), TopicRole::Command)
} else if input.peek(kw::state) {
input.parse::<kw::state>()?;
let body: Ident = input.parse()?;
(TopicKind::PubSub(body), TopicRole::State)
} else if input.peek(kw::query) {
input.parse::<kw::query>()?;
let request: Ident = input.parse()?;
input.parse::<Token![=>]>()?;
let response: Ident = input.parse()?;
(TopicKind::Query { request, response }, TopicRole::Query)
} else {
return Err(input.error(
"expected a topic role: `command <Body>`, `state <Body>`, or \
`query <Req> => <Resp>`",
));
};
input.parse::<Token![;]>()?;
Ok(TopicDef { leaf, kind, role })
}
}
impl ApiTree {
fn expand(&self) -> syn::Result<TokenStream> {
let mut out = TokenStream::new();
let mut manifest_generations = Vec::new();
let mut resolved: std::collections::HashMap<String, Vec<Node>> =
std::collections::HashMap::new();
for version in &self.versions {
let effective = match &version.extends {
None => version.nodes.clone(),
Some(parent) => {
let base = resolved.get(&parent.to_string()).ok_or_else(|| {
syn::Error::new_spanned(
parent,
format!(
"`extends {parent}`: unknown API version (it must be declared \
earlier in the same phoxal_api_tree! invocation)"
),
)
})?;
overlay_nodes(base, &version.nodes)?
}
};
let shapes = ShapeCatalog::new(&effective)?;
manifest_generations.push(ManifestGeneration {
name: version.name.to_string(),
is_preview: version.is_preview,
extends: version.extends.as_ref().map(Ident::to_string),
contracts: contract_manifest_entries(&effective, &shapes)?,
});
resolved.insert(version.name.to_string(), effective.clone());
out.extend(expand_version(version, &effective)?);
}
let manifest = expand_contract_manifest(&manifest_generations);
Ok(quote! {
#manifest
#out
})
}
}
fn expand_contract_manifest(generations: &[ManifestGeneration]) -> TokenStream {
let generation_entries = generations.iter().map(|generation| {
let name = &generation.name;
let is_preview = generation.is_preview;
let extends = match &generation.extends {
Some(parent) => quote! { ::core::option::Option::Some(#parent) },
None => quote! { ::core::option::Option::None },
};
let contracts = generation.contracts.iter().map(|contract| {
let family = &contract.family;
let topic = &contract.topic;
let schema_id = &contract.schema_id;
quote! {
ApiContractManifestContract {
family: #family,
topic: #topic,
schema_id: #schema_id,
}
}
});
quote! {
ApiContractManifestGeneration {
name: #name,
is_preview: #is_preview,
extends: #extends,
contracts: &[#(#contracts),*],
}
}
});
quote! {
#[doc(hidden)]
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ApiContractManifestGeneration {
pub name: &'static str,
pub is_preview: bool,
pub extends: ::core::option::Option<&'static str>,
pub contracts: &'static [ApiContractManifestContract],
}
#[doc(hidden)]
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ApiContractManifestContract {
pub family: &'static str,
pub topic: &'static str,
pub schema_id: &'static str,
}
#[doc(hidden)]
pub const API_CONTRACT_MANIFEST: &[ApiContractManifestGeneration] = &[#(#generation_entries),*];
}
}
fn contract_manifest_entries(
nodes: &[Node],
shapes: &ShapeCatalog,
) -> syn::Result<Vec<ManifestContract>> {
let mut contracts = Vec::new();
collect_contract_manifest_entries(nodes, "", "", shapes, &mut contracts)?;
contracts.sort_by(|left, right| {
left.family
.cmp(&right.family)
.then_with(|| left.topic.cmp(&right.topic))
});
Ok(contracts)
}
fn collect_contract_manifest_entries(
nodes: &[Node],
family_prefix: &str,
key_prefix: &str,
shapes: &ShapeCatalog,
contracts: &mut Vec<ManifestContract>,
) -> syn::Result<()> {
for node in nodes {
let name = node.name.to_string();
let family_path = join_seg(family_prefix, "::", &name);
let key_seg = match &node.var {
Some(var) => format!("{}/{{{}}}", name, var),
None => name.clone(),
};
let node_key_prefix = join_seg(key_prefix, "/", &key_seg);
for topic in &node.topics {
let topic_key = topic_key(&node_key_prefix, &topic.leaf);
match &topic.kind {
TopicKind::PubSub(body) => {
contracts.push(ManifestContract {
family: format!("{}::{}", family_path, body),
topic: topic_key,
schema_id: shapes.schema_id_for(&family_path, body)?,
});
}
TopicKind::Query { request, response } => {
contracts.push(ManifestContract {
family: format!("{}::{}", family_path, request),
topic: topic_key.clone(),
schema_id: shapes.schema_id_for(&family_path, request)?,
});
contracts.push(ManifestContract {
family: format!("{}::{}", family_path, response),
topic: topic_key,
schema_id: shapes.schema_id_for(&family_path, response)?,
});
}
}
}
collect_contract_manifest_entries(
&node.children,
&family_path,
&node_key_prefix,
shapes,
contracts,
)?;
}
Ok(())
}
fn overlay_nodes(base: &[Node], child: &[Node]) -> syn::Result<Vec<Node>> {
let mut result: Vec<Node> = base.to_vec();
for cn in child {
if let Some(bn) = result.iter_mut().find(|n| n.name == cn.name) {
if bn.var.as_ref().map(Ident::to_string) != cn.var.as_ref().map(Ident::to_string) {
return Err(syn::Error::new_spanned(
&cn.name,
format!(
"`extends`: node `{}` redeclares its dynamic variable differently from \
the inherited version; a same-named node must keep the same `(var)` (or \
its absence) so inherited topic keys stay stable",
cn.name
),
));
}
for ct in &cn.types {
if let Some(slot) = bn.types.iter_mut().find(|t| t.ident() == ct.ident()) {
*slot = ct.clone();
} else {
bn.types.push(ct.clone());
}
}
for ctp in &cn.topics {
if let Some(slot) = bn
.topics
.iter_mut()
.find(|t| t.leaf.key() == ctp.leaf.key())
{
*slot = ctp.clone();
} else {
bn.topics.push(ctp.clone());
}
}
bn.children = overlay_nodes(&bn.children, &cn.children)?;
} else {
result.push(cn.clone());
}
}
Ok(result)
}
fn expand_version(version: &Version, nodes: &[Node]) -> syn::Result<TokenStream> {
let mod_name = &version.name;
let id = version.name.to_string();
let is_preview = version.is_preview;
let feature_name = format!("preview-{id}");
let shapes = ShapeCatalog::new(nodes)?;
let mut node_mods = TokenStream::new();
for node in nodes {
node_mods.extend(expand_node_module(node, 1, "", "", &shapes)?);
}
let topic_mod = expand_topic_module(nodes)?;
let module_attrs = if is_preview {
quote! {
#[cfg(feature = #feature_name)]
}
} else {
TokenStream::new()
};
let module_doc = if is_preview {
format!(
"Preview dated API version `{id}`. This final-path module is available only with the `{feature_name}` Cargo feature."
)
} else {
format!("Dated API version `{id}` - version-local wire bodies + topics.")
};
Ok(quote! {
#[doc = #module_doc]
#module_attrs
pub mod #mod_name {
#[derive(Clone, Copy, Debug)]
pub enum Api {}
impl ::phoxal_bus::ApiVersion for Api {
const ID: &'static str = #id;
const IS_PREVIEW: bool = #is_preview;
}
#node_mods
#topic_mod
}
})
}
fn expand_node_module(
node: &Node,
depth: usize,
family_prefix: &str,
key_prefix: &str,
shapes: &ShapeCatalog,
) -> syn::Result<TokenStream> {
let name = &node.name;
let name_str = name.to_string();
let derives = body_derives();
let family_path = join_seg(family_prefix, "::", &name_str);
let key_seg = match &node.var {
Some(var) => format!("{}/{{{}}}", name_str, var),
None => name_str.clone(),
};
let node_key_prefix = join_seg(key_prefix, "/", &key_seg);
let api_supers = supers(depth);
let mut types = TokenStream::new();
for ty in &node.types {
match ty {
TypeDef::Struct(item) => {
let item = with_pub_fields_struct(item.clone());
types.extend(quote! { #derives #item });
}
TypeDef::Enum(item) => {
types.extend(quote! { #derives #item });
}
}
}
let mut impls = TokenStream::new();
for topic in &node.topics {
let key = topic_key(&node_key_prefix, &topic.leaf);
let role = topic.role.bus_variant();
match &topic.kind {
TopicKind::PubSub(body) => {
let family_const = format!("{}::{}", family_path, body);
let schema_id = shapes.schema_id_for(&family_path, body)?;
impls.extend(quote! {
impl ::phoxal_bus::ContractBody for #body {
type Api = #api_supers Api;
const FAMILY: &'static str = #family_const;
const SCHEMA_ID: &'static str = #schema_id;
const TOPIC: &'static str = #key;
}
impl #body {
#[doc(hidden)]
pub const ROLE: ::phoxal_bus::TopicRole = #role;
}
});
}
TopicKind::Query { request, response } => {
let req_family = format!("{}::{}", family_path, request);
let resp_family = format!("{}::{}", family_path, response);
let req_schema_id = shapes.schema_id_for(&family_path, request)?;
let resp_schema_id = shapes.schema_id_for(&family_path, response)?;
impls.extend(quote! {
impl ::phoxal_bus::ContractBody for #request {
type Api = #api_supers Api;
const FAMILY: &'static str = #req_family;
const SCHEMA_ID: &'static str = #req_schema_id;
const TOPIC: &'static str = #key;
}
impl ::phoxal_bus::ContractBody for #response {
type Api = #api_supers Api;
const FAMILY: &'static str = #resp_family;
const SCHEMA_ID: &'static str = #resp_schema_id;
const TOPIC: &'static str = #key;
}
impl #request {
#[doc(hidden)]
pub const ROLE: ::phoxal_bus::TopicRole = #role;
}
impl #response {
#[doc(hidden)]
pub const ROLE: ::phoxal_bus::TopicRole = #role;
}
});
}
}
}
let mut child_mods = TokenStream::new();
for child in &node.children {
child_mods.extend(expand_node_module(
child,
depth + 1,
&family_path,
&node_key_prefix,
shapes,
)?);
}
Ok(quote! {
pub mod #name {
#types
#impls
#child_mods
}
})
}
fn supers(n: usize) -> TokenStream {
let mut ts = TokenStream::new();
for _ in 0..n {
ts.extend(quote! { super:: });
}
ts
}
fn seg_field(i: usize) -> Ident {
quote::format_ident!("__seg{}", i)
}
fn join_seg(prefix: &str, sep: &str, seg: &str) -> String {
if prefix.is_empty() {
seg.to_string()
} else {
format!("{prefix}{sep}{seg}")
}
}
fn topic_key(node_key_prefix: &str, leaf: &TopicLeaf) -> String {
match leaf {
TopicLeaf::Named(leaf) => format!("{}/{}", node_key_prefix, leaf),
TopicLeaf::Node => node_key_prefix.to_string(),
}
}
#[derive(Clone)]
struct ShapeCatalog {
types: std::collections::HashMap<(String, String), TypeDef>,
}
impl ShapeCatalog {
fn new(nodes: &[Node]) -> syn::Result<Self> {
let mut catalog = ShapeCatalog {
types: std::collections::HashMap::new(),
};
catalog.collect_nodes(nodes, "")?;
Ok(catalog)
}
fn collect_nodes(&mut self, nodes: &[Node], family_prefix: &str) -> syn::Result<()> {
for node in nodes {
let name = node.name.to_string();
let family_path = join_seg(family_prefix, "::", &name);
for ty in &node.types {
let key = (family_path.clone(), ty.ident().to_string());
if self.types.insert(key, ty.clone()).is_some() {
return Err(syn::Error::new_spanned(
ty.ident(),
format!(
"duplicate type `{}` in API node `{family_path}` cannot have a stable schema_id",
ty.ident()
),
));
}
}
self.collect_nodes(&node.children, &family_path)?;
}
Ok(())
}
fn schema_id_for(&self, scope: &str, ident: &Ident) -> syn::Result<String> {
let mut stack = Vec::new();
let subst = TypeSubst::new();
let body = self.canonical_named(scope, ident, &[], &subst, &mut stack)?;
let canonical = format!("phoxal.schema/v0\n{body}");
Ok(schema_hash(&canonical))
}
fn canonical_named(
&self,
scope: &str,
ident: &Ident,
args: &[Type],
subst: &TypeSubst,
stack: &mut Vec<(String, String)>,
) -> syn::Result<String> {
let key = (scope.to_string(), ident.to_string());
let ty = self.types.get(&key).ok_or_else(|| {
syn::Error::new_spanned(
ident,
format!("topic body `{ident}` is not declared in API node `{scope}`"),
)
})?;
if stack.contains(&key) {
return Err(syn::Error::new_spanned(
ident,
format!("recursive wire type `{ident}` is not supported by schema_id"),
));
}
let params = type_params(ty)?;
if params.len() != args.len() {
return Err(syn::Error::new_spanned(
ident,
format!(
"type `{ident}` expects {} generic argument(s), got {}",
params.len(),
args.len()
),
));
}
let mut local_subst = TypeSubst::new();
for (param, arg) in params.into_iter().zip(args.iter()) {
local_subst.insert(param, substitute_type(arg, subst));
}
stack.push(key);
let result = match ty {
TypeDef::Struct(item) => self.canonical_struct(item, scope, &local_subst, stack),
TypeDef::Enum(item) => self.canonical_enum(item, scope, &local_subst, stack),
};
stack.pop();
result
}
fn canonical_struct(
&self,
item: &ItemStruct,
scope: &str,
subst: &TypeSubst,
stack: &mut Vec<(String, String)>,
) -> syn::Result<String> {
match &item.fields {
Fields::Named(fields) => {
let attrs = SerdeAttrs::parse(&item.attrs, SerdeAttrLocation::StructContainer)?;
let rename_all = attrs.rename_all.as_ref();
self.canonical_named_fields(
fields,
rename_all,
SerdeAttrLocation::StructField,
scope,
subst,
stack,
)
}
Fields::Unnamed(fields) => {
SerdeAttrs::parse(&item.attrs, SerdeAttrLocation::TupleStructContainer)?;
if fields.unnamed.len() == 1 {
let field = fields.unnamed.iter().next().expect("checked len");
let ty = self.canonical_field_type(
field,
SerdeAttrLocation::NewtypeField,
scope,
subst,
stack,
)?;
Ok(format!("newtype({ty})"))
} else {
self.canonical_unnamed_fields(
fields,
SerdeAttrLocation::TupleField,
scope,
subst,
stack,
)
}
}
Fields::Unit => {
SerdeAttrs::parse(&item.attrs, SerdeAttrLocation::UnitStructContainer)?;
Ok("unit".to_string())
}
}
}
fn canonical_enum(
&self,
item: &ItemEnum,
scope: &str,
subst: &TypeSubst,
stack: &mut Vec<(String, String)>,
) -> syn::Result<String> {
let attrs = SerdeAttrs::parse(&item.attrs, SerdeAttrLocation::EnumContainer)?;
let repr = enum_repr(&attrs, item)?;
let mut out = format!("enum({repr}){{");
for variant in &item.variants {
let variant_location = match &variant.fields {
Fields::Named(_) => SerdeAttrLocation::EnumStructVariant,
_ => SerdeAttrLocation::EnumVariant,
};
let variant_attrs = SerdeAttrs::parse(&variant.attrs, variant_location)?;
let wire_name = renamed_variant(
&variant.ident.to_string(),
variant_attrs.rename.as_deref(),
attrs.rename_all.as_ref(),
);
let payload = match &variant.fields {
Fields::Named(fields) => {
let rename_all = variant_attrs
.rename_all
.as_ref()
.or(attrs.rename_all_fields.as_ref());
self.canonical_named_fields(
fields,
rename_all,
SerdeAttrLocation::EnumVariantField,
scope,
subst,
stack,
)?
}
Fields::Unnamed(fields) => {
if fields.unnamed.len() == 1 {
let field = fields.unnamed.iter().next().expect("checked len");
let ty = self.canonical_field_type(
field,
SerdeAttrLocation::EnumVariantNewtypeField,
scope,
subst,
stack,
)?;
format!("newtype({ty})")
} else {
self.canonical_unnamed_fields(
fields,
SerdeAttrLocation::EnumVariantTupleField,
scope,
subst,
stack,
)?
}
}
Fields::Unit => "unit".to_string(),
};
out.push_str(&name_token('v', &wire_name));
out.push('=');
out.push_str(&payload);
out.push(';');
}
out.push('}');
Ok(out)
}
fn canonical_named_fields(
&self,
fields: &syn::FieldsNamed,
rename_all: Option<&RenameRule>,
field_location: SerdeAttrLocation,
scope: &str,
subst: &TypeSubst,
stack: &mut Vec<(String, String)>,
) -> syn::Result<String> {
let mut out = String::from("struct{");
for field in &fields.named {
let attrs = SerdeAttrs::parse(&field.attrs, field_location)?;
let ident = field.ident.as_ref().ok_or_else(|| {
syn::Error::new_spanned(field, "named field is missing an identifier")
})?;
let wire_name = renamed_field(&ident.to_string(), attrs.rename.as_deref(), rename_all);
let ty = self.canonical_field_type_with_attrs(field, &attrs, scope, subst, stack)?;
out.push_str(&name_token('f', &wire_name));
out.push('=');
out.push_str(&ty);
out.push(';');
}
out.push('}');
Ok(out)
}
fn canonical_unnamed_fields(
&self,
fields: &syn::FieldsUnnamed,
field_location: SerdeAttrLocation,
scope: &str,
subst: &TypeSubst,
stack: &mut Vec<(String, String)>,
) -> syn::Result<String> {
let mut out = String::from("tuple(");
for field in &fields.unnamed {
let ty = self.canonical_field_type(field, field_location, scope, subst, stack)?;
out.push_str(&ty);
out.push(';');
}
out.push(')');
Ok(out)
}
fn canonical_field_type(
&self,
field: &syn::Field,
location: SerdeAttrLocation,
scope: &str,
subst: &TypeSubst,
stack: &mut Vec<(String, String)>,
) -> syn::Result<String> {
let attrs = SerdeAttrs::parse(&field.attrs, location)?;
self.canonical_field_type_with_attrs(field, &attrs, scope, subst, stack)
}
fn canonical_field_type_with_attrs(
&self,
field: &syn::Field,
attrs: &SerdeAttrs,
scope: &str,
subst: &TypeSubst,
stack: &mut Vec<(String, String)>,
) -> syn::Result<String> {
if attrs.bytes {
let inner = self.canonical_type(&field.ty, scope, subst, stack)?;
if inner == "seq(u8)" {
return Ok("bytes".to_string());
}
return Err(syn::Error::new_spanned(
field,
"serde(with = \"serde_bytes\") is only supported on Vec<u8> fields for schema_id",
));
}
self.canonical_type(&field.ty, scope, subst, stack)
}
fn canonical_type(
&self,
ty: &Type,
scope: &str,
subst: &TypeSubst,
stack: &mut Vec<(String, String)>,
) -> syn::Result<String> {
match ty {
Type::Array(array) => {
let len = array_len(&array.len)?;
let elem = self.canonical_type(&array.elem, scope, subst, stack)?;
Ok(format!("array[{len}]({elem})"))
}
Type::Path(path) => self.canonical_path_type(path, scope, subst, stack),
Type::Reference(reference) => self.canonical_type(&reference.elem, scope, subst, stack),
Type::Slice(slice) => {
let elem = self.canonical_type(&slice.elem, scope, subst, stack)?;
Ok(format!("seq({elem})"))
}
Type::Tuple(tuple) => {
if tuple.elems.is_empty() {
return Ok("nil".to_string());
}
let mut out = String::from("tuple(");
for elem in &tuple.elems {
let elem = self.canonical_type(elem, scope, subst, stack)?;
out.push_str(&elem);
out.push(';');
}
out.push(')');
Ok(out)
}
_ => Err(syn::Error::new_spanned(
ty,
"type is not part of the schema_id grammar",
)),
}
}
fn canonical_path_type(
&self,
ty: &syn::TypePath,
scope: &str,
subst: &TypeSubst,
stack: &mut Vec<(String, String)>,
) -> syn::Result<String> {
if ty.qself.is_some() {
return Err(syn::Error::new_spanned(
ty,
"qualified self types are not part of the schema_id grammar",
));
}
let Some(segment) = ty.path.segments.last() else {
return Err(syn::Error::new_spanned(
ty,
"empty type path is not part of the schema_id grammar",
));
};
let ident = &segment.ident;
let ident_s = ident.to_string();
if is_bare_type_path(&ty.path) {
if let Some(replacement) = subst.get(&ident_s) {
return self.canonical_type(replacement, scope, subst, stack);
}
if self
.types
.contains_key(&(scope.to_string(), ident_s.clone()))
{
let args = path_type_args(segment, ty)?;
return self.canonical_named(scope, ident, &args, subst, stack);
}
}
match modeled_type_path(&ty.path) {
Some(ModeledTypePath::Scalar(name)) => {
ensure_no_generic_args(segment, ty)?;
Ok(name.to_string())
}
Some(ModeledTypePath::String) => {
ensure_no_generic_args(segment, ty)?;
Ok("string".to_string())
}
Some(ModeledTypePath::Option) => {
let args = generic_args(segment, 1, ty)?;
let inner = self.canonical_type(&args[0], scope, subst, stack)?;
Ok(format!("option({inner})"))
}
Some(ModeledTypePath::Seq) => {
let args = generic_args(segment, 1, ty)?;
let inner = self.canonical_type(&args[0], scope, subst, stack)?;
Ok(format!("seq({inner})"))
}
Some(ModeledTypePath::Map) => {
let args = generic_args(segment, 2, ty)?;
let key = self.canonical_type(&args[0], scope, subst, stack)?;
let value = self.canonical_type(&args[1], scope, subst, stack)?;
Ok(format!("map({key},{value})"))
}
None if ident_s == "usize" || ident_s == "isize" => Err(syn::Error::new_spanned(
ty,
"usize/isize are not allowed in schema_id because their wire width is not explicit",
)),
None => Err(unmodeled_type_error(ty, scope)),
}
}
}
#[derive(Clone, Copy)]
enum ModeledTypePath {
Scalar(&'static str),
String,
Option,
Seq,
Map,
}
fn modeled_type_path(path: &Path) -> Option<ModeledTypePath> {
for scalar in [
"bool", "str", "u8", "u16", "u32", "u64", "u128", "i8", "i16", "i32", "i64", "i128", "f32",
"f64",
] {
if path_is_bare_name(path, scalar)
|| path_is(path, &["std", "primitive", scalar])
|| path_is(path, &["core", "primitive", scalar])
{
return Some(ModeledTypePath::Scalar(scalar));
}
}
if path_is_bare_name(path, "String")
|| path_is(path, &["std", "string", "String"])
|| path_is(path, &["alloc", "string", "String"])
{
return Some(ModeledTypePath::String);
}
if path_is_bare_name(path, "Option")
|| path_is(path, &["std", "option", "Option"])
|| path_is(path, &["core", "option", "Option"])
{
return Some(ModeledTypePath::Option);
}
if path_is_bare_name(path, "Vec")
|| path_is(path, &["std", "vec", "Vec"])
|| path_is(path, &["alloc", "vec", "Vec"])
{
return Some(ModeledTypePath::Seq);
}
if path_is_bare_name(path, "HashMap") || path_is(path, &["std", "collections", "HashMap"]) {
return Some(ModeledTypePath::Map);
}
if path_is_bare_name(path, "BTreeMap")
|| path_is(path, &["std", "collections", "BTreeMap"])
|| path_is(path, &["alloc", "collections", "BTreeMap"])
{
return Some(ModeledTypePath::Map);
}
None
}
fn is_bare_type_path(path: &Path) -> bool {
path.leading_colon.is_none() && path.segments.len() == 1
}
fn path_is_bare_name(path: &Path, name: &str) -> bool {
is_bare_type_path(path) && path.segments[0].ident == name
}
fn path_is(path: &Path, names: &[&str]) -> bool {
path.leading_colon.is_some()
&& path.segments.len() == names.len()
&& path
.segments
.iter()
.zip(names)
.enumerate()
.all(|(idx, (segment, name))| {
segment.ident == *name
&& (idx + 1 == names.len() || matches!(segment.arguments, PathArguments::None))
})
}
fn ensure_no_generic_args(segment: &syn::PathSegment, span: &TypePath) -> syn::Result<()> {
let args = path_type_args(segment, span)?;
if args.is_empty() {
Ok(())
} else {
Err(syn::Error::new_spanned(
span,
format!(
"{} expects 0 generic argument(s), got {}",
segment.ident,
args.len()
),
))
}
}
fn unmodeled_type_error(ty: &TypePath, scope: &str) -> syn::Error {
syn::Error::new_spanned(
ty,
format!(
"type `{}` is not part of the schema_id type allowlist for API node `{scope}`; the canonicalizer does not model its wire shape",
quote!(#ty)
),
)
}
type TypeSubst = std::collections::HashMap<String, Type>;
fn substitute_type(ty: &Type, subst: &TypeSubst) -> Type {
substitute_type_inner(ty, subst, &mut Vec::new())
}
fn substitute_type_inner(ty: &Type, subst: &TypeSubst, seen: &mut Vec<String>) -> Type {
match ty {
Type::Array(array) => {
let mut array = array.clone();
array.elem = Box::new(substitute_type_inner(&array.elem, subst, seen));
Type::Array(array)
}
Type::Path(path) => substitute_path_type(path, subst, seen),
Type::Reference(reference) => {
let mut reference = reference.clone();
reference.elem = Box::new(substitute_type_inner(&reference.elem, subst, seen));
Type::Reference(reference)
}
Type::Slice(slice) => {
let mut slice = slice.clone();
slice.elem = Box::new(substitute_type_inner(&slice.elem, subst, seen));
Type::Slice(slice)
}
Type::Tuple(tuple) => {
let mut tuple = tuple.clone();
tuple.elems = tuple
.elems
.into_iter()
.map(|elem| substitute_type_inner(&elem, subst, seen))
.collect();
Type::Tuple(tuple)
}
_ => ty.clone(),
}
}
fn substitute_path_type(path: &TypePath, subst: &TypeSubst, seen: &mut Vec<String>) -> Type {
if path.qself.is_none()
&& path.path.segments.len() == 1
&& let Some(segment) = path.path.segments.first()
{
let ident = segment.ident.to_string();
if !seen.contains(&ident)
&& let Some(replacement) = subst.get(&ident)
{
seen.push(ident);
let replacement = substitute_type_inner(replacement, subst, seen);
seen.pop();
return replacement;
}
}
let mut path = path.clone();
for segment in &mut path.path.segments {
if let PathArguments::AngleBracketed(args) = &mut segment.arguments {
for arg in &mut args.args {
if let GenericArgument::Type(ty) = arg {
*ty = substitute_type_inner(ty, subst, seen);
}
}
}
}
Type::Path(path)
}
fn schema_hash(canonical: &str) -> String {
let digest = Sha256::digest(canonical.as_bytes());
let mut full = String::with_capacity(64);
for byte in digest {
full.push_str(&format!("{byte:02x}"));
}
full.truncate(16);
full
}
fn type_params(ty: &TypeDef) -> syn::Result<Vec<String>> {
let generics = match ty {
TypeDef::Struct(item) => &item.generics,
TypeDef::Enum(item) => &item.generics,
};
let mut params = Vec::new();
for param in &generics.params {
match param {
GenericParam::Type(type_param) => params.push(type_param.ident.to_string()),
GenericParam::Lifetime(param) => {
return Err(syn::Error::new_spanned(
param,
"lifetime generics are not part of the schema_id grammar",
));
}
GenericParam::Const(param) => {
return Err(syn::Error::new_spanned(
param,
"const generics are not part of the schema_id grammar",
));
}
}
}
Ok(params)
}
fn generic_args(
segment: &syn::PathSegment,
expected: usize,
span: &TypePath,
) -> syn::Result<Vec<Type>> {
let args = path_type_args(segment, span)?;
if args.len() != expected {
return Err(syn::Error::new_spanned(
span,
format!(
"{} expects {expected} generic argument(s), got {}",
segment.ident,
args.len()
),
));
}
Ok(args)
}
fn path_type_args(segment: &syn::PathSegment, span: &TypePath) -> syn::Result<Vec<Type>> {
match &segment.arguments {
PathArguments::None => Ok(Vec::new()),
PathArguments::AngleBracketed(args) => {
let mut types = Vec::new();
for arg in &args.args {
match arg {
GenericArgument::Type(ty) => types.push(ty.clone()),
_ => {
return Err(syn::Error::new_spanned(
arg,
"only type generic arguments are part of the schema_id grammar",
));
}
}
}
Ok(types)
}
PathArguments::Parenthesized(_) => Err(syn::Error::new_spanned(
span,
"function-style generic arguments are not part of the schema_id grammar",
)),
}
}
fn array_len(expr: &Expr) -> syn::Result<String> {
match expr {
Expr::Lit(expr_lit) => match &expr_lit.lit {
Lit::Int(lit) => Ok(lit.base10_digits().to_string()),
_ => Err(syn::Error::new_spanned(
expr,
"array length in schema_id must be an integer literal",
)),
},
_ => Err(syn::Error::new_spanned(
expr,
"array length in schema_id must be an integer literal",
)),
}
}
fn enum_repr(attrs: &SerdeAttrs, item: &ItemEnum) -> syn::Result<String> {
if attrs.untagged {
if attrs.tag.is_some() || attrs.content.is_some() {
return Err(syn::Error::new_spanned(
item,
"serde(untagged) cannot be combined with tag/content for schema_id",
));
}
return Ok("untagged".to_string());
}
match (attrs.tag.as_deref(), attrs.content.as_deref()) {
(Some(tag), Some(content)) => Ok(format!(
"adjacent(tag={},content={})",
raw_name(tag),
raw_name(content)
)),
(Some(tag), None) => Ok(format!("internal(tag={})", raw_name(tag))),
(None, Some(_)) => Err(syn::Error::new_spanned(
item,
"serde(content = ...) without serde(tag = ...) is not part of the schema_id grammar",
)),
(None, None) => Ok("external".to_string()),
}
}
#[derive(Clone)]
struct SerdeAttrs {
rename: Option<String>,
rename_all: Option<RenameRule>,
rename_all_fields: Option<RenameRule>,
tag: Option<String>,
content: Option<String>,
untagged: bool,
bytes: bool,
}
#[derive(Clone, Copy)]
enum SerdeAttrLocation {
StructContainer,
TupleStructContainer,
UnitStructContainer,
EnumContainer,
EnumVariant,
EnumStructVariant,
StructField,
NewtypeField,
TupleField,
EnumVariantField,
EnumVariantNewtypeField,
EnumVariantTupleField,
}
impl SerdeAttrLocation {
fn label(self) -> &'static str {
match self {
SerdeAttrLocation::StructContainer => "struct container",
SerdeAttrLocation::TupleStructContainer => "tuple struct container",
SerdeAttrLocation::UnitStructContainer => "unit struct container",
SerdeAttrLocation::EnumContainer => "enum container",
SerdeAttrLocation::EnumVariant => "enum variant",
SerdeAttrLocation::EnumStructVariant => "enum variant",
SerdeAttrLocation::StructField => "struct field",
SerdeAttrLocation::NewtypeField => "newtype field",
SerdeAttrLocation::TupleField => "tuple field",
SerdeAttrLocation::EnumVariantField => "enum variant field",
SerdeAttrLocation::EnumVariantNewtypeField => "enum variant newtype field",
SerdeAttrLocation::EnumVariantTupleField => "enum variant tuple field",
}
}
fn allows_rename(self) -> bool {
matches!(
self,
SerdeAttrLocation::EnumVariant
| SerdeAttrLocation::EnumStructVariant
| SerdeAttrLocation::StructField
| SerdeAttrLocation::EnumVariantField
)
}
fn allows_rename_all(self) -> bool {
matches!(
self,
SerdeAttrLocation::StructContainer
| SerdeAttrLocation::EnumContainer
| SerdeAttrLocation::EnumStructVariant
)
}
fn allows_rename_all_fields(self) -> bool {
matches!(self, SerdeAttrLocation::EnumContainer)
}
fn allows_enum_repr(self) -> bool {
matches!(self, SerdeAttrLocation::EnumContainer)
}
fn allows_bytes(self) -> bool {
matches!(
self,
SerdeAttrLocation::StructField
| SerdeAttrLocation::NewtypeField
| SerdeAttrLocation::TupleField
| SerdeAttrLocation::EnumVariantField
| SerdeAttrLocation::EnumVariantNewtypeField
| SerdeAttrLocation::EnumVariantTupleField
)
}
}
impl SerdeAttrs {
fn parse(attrs: &[Attribute], location: SerdeAttrLocation) -> syn::Result<Self> {
let mut parsed = SerdeAttrs {
rename: None,
rename_all: None,
rename_all_fields: None,
tag: None,
content: None,
untagged: false,
bytes: false,
};
for attr in attrs {
if !attr.path().is_ident("serde") {
validate_neutral_attr(attr)?;
continue;
}
attr.parse_nested_meta(|meta| {
if meta.path.is_ident("rename") {
reject_unless_allowed(&meta, location, location.allows_rename())?;
parsed.rename = Some(parse_lit_str(&meta, "rename")?);
return Ok(());
}
if meta.path.is_ident("rename_all") {
reject_unless_allowed(&meta, location, location.allows_rename_all())?;
let value = parse_lit_str(&meta, "rename_all")?;
parsed.rename_all = Some(RenameRule::parse(&value, meta.path.span())?);
return Ok(());
}
if meta.path.is_ident("rename_all_fields") {
reject_unless_allowed(&meta, location, location.allows_rename_all_fields())?;
let value = parse_lit_str(&meta, "rename_all_fields")?;
parsed.rename_all_fields = Some(RenameRule::parse(&value, meta.path.span())?);
return Ok(());
}
if meta.path.is_ident("tag") {
reject_unless_allowed(&meta, location, location.allows_enum_repr())?;
parsed.tag = Some(parse_lit_str(&meta, "tag")?);
return Ok(());
}
if meta.path.is_ident("content") {
reject_unless_allowed(&meta, location, location.allows_enum_repr())?;
parsed.content = Some(parse_lit_str(&meta, "content")?);
return Ok(());
}
if meta.path.is_ident("untagged") {
reject_unless_allowed(&meta, location, location.allows_enum_repr())?;
parsed.untagged = true;
return Ok(());
}
if meta.path.is_ident("with") {
let value = parse_lit_str(&meta, "with")?;
if location.allows_bytes() && is_serde_bytes_path(&value) {
parsed.bytes = true;
return Ok(());
}
if location.allows_bytes() {
return Err(meta.error(format!(
"serde(with = {value:?}) is not allowed on {} for schema_id; only serde_bytes is canonicalized as bytes",
location.label()
)));
}
return Err(serde_attr_not_allowed(&meta, location));
}
Err(serde_attr_not_allowed(&meta, location))
})?;
}
Ok(parsed)
}
}
fn reject_unless_allowed(
meta: &syn::meta::ParseNestedMeta<'_>,
location: SerdeAttrLocation,
allowed: bool,
) -> syn::Result<()> {
if allowed {
Ok(())
} else {
Err(serde_attr_not_allowed(meta, location))
}
}
fn serde_attr_not_allowed(
meta: &syn::meta::ParseNestedMeta<'_>,
location: SerdeAttrLocation,
) -> syn::Error {
meta.error(format!(
"serde({}) is not allowed on {} for schema_id; the canonicalizer does not model its wire effect",
path_name(&meta.path),
location.label()
))
}
fn is_serde_bytes_path(value: &str) -> bool {
value == "serde_bytes" || value == "::serde_bytes"
}
fn validate_neutral_attr(attr: &Attribute) -> syn::Result<()> {
if attr.path().is_ident("doc")
|| attr.path().is_ident("allow")
|| attr.path().is_ident("expect")
{
return Ok(());
}
if attr.path().is_ident("derive") {
let derives = attr.parse_args_with(Punctuated::<Path, Token![,]>::parse_terminated)?;
for derive in derives {
if !is_wire_neutral_derive(&derive) {
return Err(syn::Error::new_spanned(
derive,
"derive is not known wire-neutral for schema_id",
));
}
}
return Ok(());
}
Err(syn::Error::new_spanned(
attr,
format!(
"unsupported outer attribute `{}` for schema_id; only doc, derive, allow, expect, and serde attributes are supported",
path_name(attr.path())
),
))
}
fn is_wire_neutral_derive(path: &Path) -> bool {
let Some(segment) = path.segments.last() else {
return false;
};
if !matches!(segment.arguments, PathArguments::None) {
return false;
}
matches!(
segment.ident.to_string().as_str(),
"Copy" | "Clone" | "Debug" | "PartialEq" | "Eq" | "PartialOrd" | "Ord" | "Hash" | "Default"
)
}
fn path_name(path: &Path) -> String {
path.segments
.iter()
.map(|segment| segment.ident.to_string())
.collect::<Vec<_>>()
.join("::")
}
fn parse_lit_str(meta: &syn::meta::ParseNestedMeta<'_>, name: &str) -> syn::Result<String> {
let value = meta.value().map_err(|_| {
meta.error(format!(
"serde({name}) must be a single string literal for schema_id"
))
})?;
let lit: LitStr = value.parse()?;
Ok(lit.value())
}
#[derive(Clone, Debug)]
enum RenameRule {
Lower,
Upper,
Pascal,
Camel,
Snake,
ScreamingSnake,
Kebab,
ScreamingKebab,
}
impl RenameRule {
fn parse(value: &str, span: proc_macro2::Span) -> syn::Result<Self> {
match value {
"lowercase" => Ok(RenameRule::Lower),
"UPPERCASE" => Ok(RenameRule::Upper),
"PascalCase" => Ok(RenameRule::Pascal),
"camelCase" => Ok(RenameRule::Camel),
"snake_case" => Ok(RenameRule::Snake),
"SCREAMING_SNAKE_CASE" => Ok(RenameRule::ScreamingSnake),
"kebab-case" => Ok(RenameRule::Kebab),
"SCREAMING-KEBAB-CASE" => Ok(RenameRule::ScreamingKebab),
_ => Err(syn::Error::new(
span,
format!("unsupported serde rename_all rule `{value}` for schema_id"),
)),
}
}
fn apply_to_variant(&self, name: &str) -> String {
match self {
RenameRule::Lower => name.to_ascii_lowercase(),
RenameRule::Upper => name.to_ascii_uppercase(),
RenameRule::Pascal => name.to_string(),
RenameRule::Camel => lower_first(name),
RenameRule::Snake => {
let mut snake = String::new();
for (i, ch) in name.char_indices() {
if i > 0 && ch.is_uppercase() {
snake.push('_');
}
snake.push(ch.to_ascii_lowercase());
}
snake
}
RenameRule::ScreamingSnake => self.apply_screaming_snake_variant(name),
RenameRule::Kebab => self.apply_kebab_variant(name),
RenameRule::ScreamingKebab => {
self.apply_screaming_snake_variant(name).replace('_', "-")
}
}
}
fn apply_to_field(&self, name: &str) -> String {
match self {
RenameRule::Lower | RenameRule::Snake => name.to_string(),
RenameRule::Upper => name.to_ascii_uppercase(),
RenameRule::Pascal => {
let mut pascal = String::new();
let mut capitalize = true;
for ch in name.chars() {
if ch == '_' {
capitalize = true;
} else if capitalize {
pascal.push(ch.to_ascii_uppercase());
capitalize = false;
} else {
pascal.push(ch);
}
}
pascal
}
RenameRule::Camel => lower_first(&RenameRule::Pascal.apply_to_field(name)),
RenameRule::ScreamingSnake => name.to_ascii_uppercase(),
RenameRule::Kebab => name.replace('_', "-"),
RenameRule::ScreamingKebab => name.to_ascii_uppercase().replace('_', "-"),
}
}
fn apply_screaming_snake_variant(&self, name: &str) -> String {
RenameRule::Snake
.apply_to_variant(name)
.to_ascii_uppercase()
}
fn apply_kebab_variant(&self, name: &str) -> String {
RenameRule::Snake.apply_to_variant(name).replace('_', "-")
}
}
fn lower_first(name: &str) -> String {
let Some(first) = name.chars().next() else {
return String::new();
};
let rest = &name[first.len_utf8()..];
let mut lowered = String::new();
lowered.push(first.to_ascii_lowercase());
lowered.push_str(rest);
lowered
}
fn renamed_field(name: &str, rename: Option<&str>, rename_all: Option<&RenameRule>) -> String {
let name = unraw(name);
if let Some(rename) = rename {
rename.to_string()
} else if let Some(rule) = rename_all {
rule.apply_to_field(name)
} else {
name.to_string()
}
}
fn renamed_variant(name: &str, rename: Option<&str>, rename_all: Option<&RenameRule>) -> String {
let name = unraw(name);
if let Some(rename) = rename {
rename.to_string()
} else if let Some(rule) = rename_all {
rule.apply_to_variant(name)
} else {
name.to_string()
}
}
fn unraw(name: &str) -> &str {
name.strip_prefix("r#").unwrap_or(name)
}
fn name_token(prefix: char, name: &str) -> String {
format!("{prefix}{}:{name}", name.len())
}
fn raw_name(name: &str) -> String {
format!("{}:{name}", name.len())
}
#[derive(Clone, Copy)]
enum Side {
Client,
Owner,
}
fn expand_topic_module(nodes: &[Node]) -> syn::Result<TokenStream> {
let mut client_root_methods = TokenStream::new();
let mut client_builder_mods = TokenStream::new();
let mut owner_root_methods = TokenStream::new();
let mut owner_builder_mods = TokenStream::new();
for node in nodes {
client_root_methods.extend(node_entry_method(node));
client_builder_mods.extend(expand_builder_module(node, &[], Side::Client)?);
owner_root_methods.extend(node_entry_method(node));
owner_builder_mods.extend(expand_builder_module(node, &[], Side::Owner)?);
}
Ok(quote! {
pub mod topic {
pub fn new() -> Root {
Root
}
#[non_exhaustive]
pub struct Root;
impl Root {
#client_root_methods
}
#client_builder_mods
pub mod internal {
pub fn new(_cap: ::phoxal_bus::OwnerCap) -> Root {
Root
}
#[non_exhaustive]
pub struct Root;
impl Root {
#owner_root_methods
}
#owner_builder_mods
}
}
})
}
#[derive(Clone)]
struct NodeSeg {
name: Ident,
var: Option<Ident>,
}
fn node_entry_method(node: &Node) -> TokenStream {
let name = &node.name;
let name_str = name.to_string();
let target = quote!(#name::Builder);
match &node.var {
Some(var) => quote! {
#[doc = #name_str]
pub fn #name(self, #var: impl ::core::fmt::Display) -> #target {
#name::Builder::__from(self, #var.to_string())
}
},
None => quote! {
#[doc = #name_str]
pub fn #name(self) -> #target {
#name::Builder::__from(self)
}
},
}
}
fn expand_builder_module(
node: &Node,
ancestors: &[NodeSeg],
side: Side,
) -> syn::Result<TokenStream> {
let name = &node.name;
let name_str = name.to_string();
let depth = ancestors.len() + 1;
let mut path: Vec<NodeSeg> = ancestors.to_vec();
path.push(NodeSeg {
name: name.clone(),
var: node.var.clone(),
});
let vars: Vec<&Ident> = path.iter().filter_map(|s| s.var.as_ref()).collect();
let ancestor_vars: Vec<&Ident> = ancestors.iter().filter_map(|s| s.var.as_ref()).collect();
let field_idents: Vec<Ident> = (0..vars.len()).map(seg_field).collect();
let ancestor_field_idents: Vec<Ident> = (0..ancestor_vars.len()).map(seg_field).collect();
let field_decls: Vec<TokenStream> = field_idents
.iter()
.map(|f| quote! { pub(super) #f: String })
.collect();
let parent_builder_ty = if depth == 1 {
quote! { super::Root }
} else {
quote! { super::Builder }
};
let parent_fields: Vec<TokenStream> = ancestor_field_idents
.iter()
.map(|f| quote! { #f: __parent.#f })
.collect();
let parent_pat = if ancestor_vars.is_empty() {
quote! { _parent }
} else {
quote! { __parent }
};
let ctor = match &node.var {
Some(var) => {
let new_field = seg_field(ancestor_vars.len());
quote! {
pub(super) fn __from(#parent_pat: #parent_builder_ty, #var: String) -> Self {
Self { #(#parent_fields,)* #new_field: #var }
}
}
}
None => quote! {
pub(super) fn __from(#parent_pat: #parent_builder_ty) -> Self {
Self { #(#parent_fields,)* }
}
},
};
let mut leaf_methods = TokenStream::new();
for topic in &node.topics {
let leaf = topic.leaf.method_ident();
let kind_ty = builder_leaf_kind(topic, &path, depth, side);
let (fmt_str, doc_key) = builder_leaf_key_parts(&path, &topic.leaf);
let constructor = if field_idents.is_empty() {
quote! { ::phoxal_bus::Topic::new_static(#fmt_str) }
} else {
quote! {
::phoxal_bus::Topic::new_owned(::std::format!(#fmt_str, #(self.#field_idents),*))
}
};
leaf_methods.extend(quote! {
#[doc = #doc_key]
pub fn #leaf(self) -> ::phoxal_bus::Topic<#kind_ty> {
#constructor
}
});
}
let mut child_methods = TokenStream::new();
let mut child_mods = TokenStream::new();
for child in &node.children {
child_methods.extend(node_entry_method(child));
child_mods.extend(expand_builder_module(child, &path, side)?);
}
let builder_doc = format!("Topic builder for the `{name_str}` node.");
Ok(quote! {
pub mod #name {
#[doc = #builder_doc]
#[non_exhaustive]
pub struct Builder {
#(#field_decls,)*
}
impl Builder {
#ctor
#leaf_methods
#child_methods
}
#child_mods
}
})
}
fn builder_leaf_kind(topic: &TopicDef, path: &[NodeSeg], depth: usize, side: Side) -> TokenStream {
let supers_to_root = match side {
Side::Client => depth + 1,
Side::Owner => depth + 2,
};
let up = supers(supers_to_root);
let node_path: Vec<&Ident> = path.iter().map(|s| &s.name).collect();
let body_path = |body: &Ident| quote! { #up #(#node_path::)* #body };
match &topic.kind {
TopicKind::PubSub(body) => {
let b = body_path(body);
match (topic.role, side) {
(TopicRole::Command, Side::Client) | (TopicRole::State, Side::Owner) => {
quote! { ::phoxal_bus::Publish<#b> }
}
(TopicRole::State, Side::Client) | (TopicRole::Command, Side::Owner) => {
quote! { ::phoxal_bus::Subscribe<#b> }
}
(TopicRole::Query, _) => quote! { ::phoxal_bus::Subscribe<#b> },
}
}
TopicKind::Query { request, response } => {
let req = body_path(request);
let resp = body_path(response);
match side {
Side::Client => quote! { ::phoxal_bus::AskQuery<#req, #resp> },
Side::Owner => quote! { ::phoxal_bus::ServeQuery<#req, #resp> },
}
}
}
}
fn builder_leaf_key_parts(path: &[NodeSeg], leaf: &TopicLeaf) -> (String, String) {
let mut fmt_segs = Vec::new();
let mut doc_segs = Vec::new();
for seg in path {
let name = seg.name.to_string();
match &seg.var {
Some(var) => {
fmt_segs.push(format!("{name}/{{}}"));
doc_segs.push(format!("{name}/{{{var}}}"));
}
None => {
fmt_segs.push(name.clone());
doc_segs.push(name);
}
}
}
match leaf {
TopicLeaf::Named(leaf) => {
let leaf = leaf.to_string();
(
format!("{}/{}", fmt_segs.join("/"), leaf),
format!("{}/{}", doc_segs.join("/"), leaf),
)
}
TopicLeaf::Node => (fmt_segs.join("/"), doc_segs.join("/")),
}
}
fn with_pub_fields_struct(mut item: ItemStruct) -> ItemStruct {
if let syn::Fields::Named(named) = &mut item.fields {
for field in &mut named.named {
field.vis = syn::Visibility::Public(syn::token::Pub::default());
}
}
item
}
#[cfg(test)]
mod tests {
use super::{ApiTree, Node, ShapeCatalog, TypeSubst, expand, overlay_nodes};
use proc_macro2::{Span, TokenStream};
use quote::quote;
use syn::Ident;
fn catalog(input: TokenStream) -> ShapeCatalog {
let tree: ApiTree = syn::parse2(input).expect("test api tree parses");
ShapeCatalog::new(&tree.versions[0].nodes).expect("test catalog builds")
}
fn canonical_body(catalog: &ShapeCatalog, scope: &str, ident: &str) -> String {
let ident = Ident::new(ident, Span::call_site());
let subst = TypeSubst::new();
let mut stack = Vec::new();
catalog
.canonical_named(scope, &ident, &[], &subst, &mut stack)
.expect("test body canonicalizes")
}
fn schema_id(catalog: &ShapeCatalog, scope: &str, ident: &str) -> String {
let ident = Ident::new(ident, Span::call_site());
catalog
.schema_id_for(scope, &ident)
.expect("test schema id canonicalizes")
}
fn compact_tokens(tokens: TokenStream) -> String {
tokens
.to_string()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
fn effective_nodes(input: TokenStream, version_name: &str) -> (bool, Vec<Node>) {
let tree: ApiTree = syn::parse2(input).expect("test api tree parses");
let mut resolved: std::collections::HashMap<String, Vec<Node>> =
std::collections::HashMap::new();
for version in &tree.versions {
let effective = match &version.extends {
None => version.nodes.clone(),
Some(parent) => overlay_nodes(
resolved
.get(&parent.to_string())
.expect("test parent was declared earlier"),
&version.nodes,
)
.expect("test overlay succeeds"),
};
if version.name == version_name {
return (version.is_preview, effective);
}
resolved.insert(version.name.to_string(), effective);
}
panic!("test version {version_name} was not declared");
}
#[derive(Debug, PartialEq, Eq)]
struct ContractSnapshot {
api_version: String,
schema_id: String,
family: String,
topic: String,
}
fn sample_body_snapshot(input: TokenStream, version_name: &str) -> (bool, ContractSnapshot) {
let (is_preview, nodes) = effective_nodes(input, version_name);
let catalog = ShapeCatalog::new(&nodes).expect("test catalog builds");
let body = Ident::new("Body", Span::call_site());
(
is_preview,
ContractSnapshot {
api_version: version_name.to_string(),
schema_id: catalog
.schema_id_for("sample", &body)
.expect("test schema id canonicalizes"),
family: "sample::Body".to_string(),
topic: "sample/body".to_string(),
},
)
}
#[test]
fn extends_rejects_var_ness_flip() {
let input = quote! {
version a { comp(instance) { struct S { x: u8 } topic s: state S; } }
version b extends a { comp { struct S { x: u8, y: u8 } topic s: state S; } }
};
let err = expand(input).expect_err("a var-ness flip across `extends` must be rejected");
assert!(
err.to_string().contains("redeclares its dynamic variable"),
"unexpected error: {err}"
);
}
#[test]
fn extends_accepts_matching_var_ness() {
let input = quote! {
version a { comp(instance) { motor(cap) { enum C { Stop } topic command: command C; } } }
version b extends a {
comp(instance) { motor(cap) { enum C { Stop, Go } topic command: command C; } }
}
};
assert!(expand(input).is_ok());
}
#[test]
fn preview_version_emits_final_path_feature_gate_and_lifecycle_const() {
let expanded = compact_tokens(
expand(quote! {
version y2026_1 {
sample {
struct Body { value: u8 }
topic body: state Body;
}
}
preview version y2026_2 extends y2026_1 {}
})
.expect("preview tree expands"),
);
assert!(
expanded.contains("pub mod y2026_2"),
"preview generation must be emitted at its final path: {expanded}"
);
assert!(
!expanded.contains("pub mod preview"),
"preview generation must not be nested under a preview module: {expanded}"
);
assert!(
expanded.contains("# [cfg (feature = \"preview-y2026_2\")]"),
"preview generation must be gated by its per-generation feature: {expanded}"
);
assert!(
expanded.contains("Preview dated API version `y2026_2`"),
"preview generation should carry a discoverable doc note: {expanded}"
);
assert!(
expanded.contains("const IS_PREVIEW : bool = true ;"),
"preview ApiVersion must record IS_PREVIEW = true: {expanded}"
);
assert!(
expanded.contains("const IS_PREVIEW : bool = false ;"),
"promoted ApiVersion must record IS_PREVIEW = false: {expanded}"
);
}
#[test]
fn preview_lifecycle_is_wire_neutral_for_contract_identity() {
let preview = quote! {
version y2026_1 {
sample {
struct Body { value: u8, label: Option<String> }
topic body: state Body;
}
}
preview version y2026_2 extends y2026_1 {}
};
let promoted = quote! {
version y2026_1 {
sample {
struct Body { value: u8, label: Option<String> }
topic body: state Body;
}
}
version y2026_2 extends y2026_1 {}
};
let (preview_flag, preview_snapshot) = sample_body_snapshot(preview, "y2026_2");
let (promoted_flag, promoted_snapshot) = sample_body_snapshot(promoted, "y2026_2");
assert!(preview_flag);
assert!(!promoted_flag);
assert_eq!(preview_snapshot, promoted_snapshot);
}
#[test]
fn expansion_emits_root_contract_manifest_for_xtask() {
let expanded = compact_tokens(
expand(quote! {
version y2026_1 {
sample {
struct Body { value: u8 }
topic body: state Body;
}
}
preview version y2026_2 extends y2026_1 {}
})
.expect("tree expands"),
);
assert!(
expanded.contains("pub const API_CONTRACT_MANIFEST"),
"root manifest const should be emitted: {expanded}"
);
assert!(
expanded.contains("name : \"y2026_2\""),
"preview generation should be represented in the manifest: {expanded}"
);
assert!(
expanded.contains("is_preview : true"),
"manifest should carry preview lifecycle: {expanded}"
);
assert!(
expanded.contains("family : \"sample::Body\""),
"manifest should carry contract family: {expanded}"
);
assert!(
expanded.contains("topic : \"sample/body\""),
"manifest should carry contract topic: {expanded}"
);
}
#[test]
fn schema_id_distinguishes_newtype_struct_from_tuple_field() {
let catalog = catalog(quote! {
version v {
wire {
struct N(u8);
struct HoldsNewtype { value: N }
struct HoldsTuple { value: (u8,) }
topic newtype: state HoldsNewtype;
topic tuple: state HoldsTuple;
}
}
});
assert_eq!(canonical_body(&catalog, "wire", "N"), "newtype(u8)");
assert_eq!(
canonical_body(&catalog, "wire", "HoldsNewtype"),
"struct{f5:value=newtype(u8);}"
);
assert_eq!(
canonical_body(&catalog, "wire", "HoldsTuple"),
"struct{f5:value=tuple(u8;);}"
);
assert_eq!(
schema_id(&catalog, "wire", "HoldsNewtype"),
"a3bd530f6ec84c7c"
);
assert_eq!(
schema_id(&catalog, "wire", "HoldsTuple"),
"ec10e0df68cc072e"
);
assert_ne!(
schema_id(&catalog, "wire", "HoldsNewtype"),
schema_id(&catalog, "wire", "HoldsTuple")
);
}
#[test]
fn schema_id_unraws_raw_identifier_field_names() {
let catalog = catalog(quote! {
version v {
wire {
struct Raw { r#type: u8 }
struct Renamed {
#[serde(rename = "r#type")]
kind: u8
}
topic raw: state Raw;
topic renamed: state Renamed;
}
}
});
assert_eq!(
canonical_body(&catalog, "wire", "Raw"),
"struct{f4:type=u8;}"
);
assert_eq!(
canonical_body(&catalog, "wire", "Renamed"),
"struct{f6:r#type=u8;}"
);
assert_ne!(
schema_id(&catalog, "wire", "Raw"),
schema_id(&catalog, "wire", "Renamed")
);
}
#[test]
fn schema_id_distinguishes_unit_struct_from_nil_type() {
let catalog = catalog(quote! {
version v {
wire {
struct U;
struct HoldsUnitStruct { value: U }
struct HoldsNil { value: () }
topic unit_struct: state HoldsUnitStruct;
topic nil: state HoldsNil;
}
}
});
assert_eq!(canonical_body(&catalog, "wire", "U"), "unit");
assert_eq!(
canonical_body(&catalog, "wire", "HoldsUnitStruct"),
"struct{f5:value=unit;}"
);
assert_eq!(
canonical_body(&catalog, "wire", "HoldsNil"),
"struct{f5:value=nil;}"
);
assert_eq!(
schema_id(&catalog, "wire", "HoldsUnitStruct"),
"afadc807186b643b"
);
assert_eq!(schema_id(&catalog, "wire", "HoldsNil"), "e606e36865c13c05");
assert_ne!(
schema_id(&catalog, "wire", "HoldsUnitStruct"),
schema_id(&catalog, "wire", "HoldsNil")
);
}
#[test]
fn schema_id_rejects_unknown_outer_attribute() {
let input = quote! {
version v {
wire {
#[serde_as(as = "Vec<(_, _)>")]
struct Body { value: u8 }
topic body: state Body;
}
}
};
let err = expand(input).expect_err("unknown wire-affecting attrs must fail closed");
assert!(
err.to_string()
.contains("unsupported outer attribute `serde_as`"),
"unexpected error: {err}"
);
}
#[test]
fn schema_id_rejects_variant_level_with() {
let input = quote! {
version v {
wire {
enum Body {
#[serde(with = "custom_variant")]
Changed(u8),
}
topic body: state Body;
}
}
};
let err = expand(input).expect_err("variant-level serde(with) must fail closed");
assert!(
err.to_string()
.contains("serde(with) is not allowed on enum variant"),
"unexpected error: {err}"
);
}
#[test]
fn schema_id_rejects_variant_level_untagged() {
let input = quote! {
version v {
wire {
enum Body {
#[serde(untagged)]
Changed(u8),
}
topic body: state Body;
}
}
};
let err = expand(input).expect_err("variant-level serde(untagged) must fail closed");
assert!(
err.to_string()
.contains("serde(untagged) is not allowed on enum variant"),
"unexpected error: {err}"
);
}
#[test]
fn schema_id_rejects_field_level_serialize_with() {
let input = quote! {
version v {
wire {
struct Body {
#[serde(serialize_with = "custom_field")]
value: u8,
}
topic body: state Body;
}
}
};
let err = expand(input).expect_err("field-level serde(serialize_with) must fail closed");
assert!(
err.to_string()
.contains("serde(serialize_with) is not allowed on struct field"),
"unexpected error: {err}"
);
}
#[test]
fn schema_id_distinguishes_container_untagged_enum_repr() {
let catalog = catalog(quote! {
version v {
wire {
enum External { A, B }
#[serde(untagged)]
enum Untagged { A, B }
topic external: state External;
topic untagged: state Untagged;
}
}
});
assert_eq!(
canonical_body(&catalog, "wire", "External"),
"enum(external){v1:A=unit;v1:B=unit;}"
);
assert_eq!(
canonical_body(&catalog, "wire", "Untagged"),
"enum(untagged){v1:A=unit;v1:B=unit;}"
);
assert_eq!(schema_id(&catalog, "wire", "External"), "d7b8d2a0871cb0bb");
assert_eq!(schema_id(&catalog, "wire", "Untagged"), "33504d536a8c4e98");
assert_ne!(
schema_id(&catalog, "wire", "External"),
schema_id(&catalog, "wire", "Untagged")
);
}
#[test]
fn schema_id_canonicalizes_serde_bytes_field_as_bytes() {
let catalog = catalog(quote! {
version v {
wire {
struct Body {
#[serde(with = "serde_bytes")]
data: Vec<u8>,
}
topic body: state Body;
}
}
});
assert_eq!(
canonical_body(&catalog, "wire", "Body"),
"struct{f4:data=bytes;}"
);
}
#[test]
fn schema_id_rejects_serde_bytes_unless_canonicalized_as_bytes() {
let input = quote! {
version v {
wire {
struct Body {
#[serde(with = "serde_bytes")]
data: String,
}
topic body: state Body;
}
}
};
let err = expand(input).expect_err("serde_bytes on non-bytes field must fail closed");
assert!(
err.to_string()
.contains("serde(with = \"serde_bytes\") is only supported on Vec<u8> fields"),
"unexpected error: {err}"
);
}
#[test]
fn schema_id_rejects_qualified_std_box_when_local_box_exists() {
let local_catalog = catalog(quote! {
version v {
wire {
struct Box<T> { value: T }
struct Local { b: Box<u8> }
topic local: state Local;
}
}
});
assert_eq!(
canonical_body(&local_catalog, "wire", "Local"),
"struct{f1:b=struct{f5:value=u8;};}"
);
let input = quote! {
version v {
wire {
struct Box<T> { value: T }
struct Local { b: Box<u8> }
struct Std { b: ::std::boxed::Box<u8> }
topic local: state Local;
topic std: state Std;
}
}
};
let err = expand(input).expect_err("external std Box must fail closed");
let err = err.to_string();
assert!(
err.contains("std") && err.contains("Box") && err.contains("schema_id type allowlist"),
"unexpected error: {err}"
);
}
#[test]
fn schema_id_rejects_unknown_external_qualified_type() {
let input = quote! {
version v {
wire {
struct Body { widget: ::some_crate::Widget }
topic body: state Body;
}
}
};
let err = expand(input).expect_err("unknown external type must fail closed");
let err = err.to_string();
assert!(
err.contains("some_crate")
&& err.contains("Widget")
&& err.contains("schema_id type allowlist"),
"unexpected error: {err}"
);
}
#[test]
fn schema_id_rejects_relative_std_qualified_type() {
let input = quote! {
version v {
wire {
struct Body { v: std::vec::Vec<u8> }
topic body: state Body;
}
}
};
let err = expand(input).expect_err("relative std::vec::Vec must fail closed");
let err = err.to_string();
assert!(
err.contains("schema_id type allowlist"),
"unexpected error: {err}"
);
}
#[test]
fn schema_id_rejects_unknown_bare_single_segment_type() {
let input = quote! {
version v {
wire {
struct Body { widget: Widget }
topic body: state Body;
}
}
};
let err = expand(input).expect_err("unknown bare type must fail closed");
let err = err.to_string();
assert!(
err.contains("Widget") && err.contains("schema_id type allowlist"),
"unexpected error: {err}"
);
}
#[test]
fn schema_id_leading_colon_std_path_does_not_match_local_type() {
let catalog = catalog(quote! {
version v {
wire {
struct String { value: u8 }
struct Local { s: String }
struct Std { s: ::std::string::String }
topic local: state Local;
topic std: state Std;
}
}
});
assert_eq!(
canonical_body(&catalog, "wire", "Local"),
"struct{f1:s=struct{f5:value=u8;};}"
);
assert_eq!(
canonical_body(&catalog, "wire", "Std"),
"struct{f1:s=string;}"
);
assert_ne!(
schema_id(&catalog, "wire", "Local"),
schema_id(&catalog, "wire", "Std")
);
}
#[test]
fn nested_named_type_does_not_inherit_callers_generic_substitution() {
let catalog = catalog(quote! {
version v {
wire {
struct T { value: u8 }
struct Inner { value: T }
struct Outer<T> { inner: Inner, generic: T }
struct UsesU16 { outer: Outer<u16> }
topic uses: state UsesU16;
}
}
});
let body = canonical_body(&catalog, "wire", "UsesU16");
assert_eq!(
body,
"struct{f5:outer=struct{f5:inner=struct{f5:value=struct{f5:value=u8;};};f7:generic=u16;};}"
);
assert_eq!(schema_id(&catalog, "wire", "UsesU16"), "88b3f60c330328d9");
}
#[test]
fn rename_all_rules_match_serde_for_tricky_identifiers() {
let cases = [
(
"lowercase",
"__http_server2",
"HTTPServer2",
"__http_server2",
"httpserver2",
),
(
"UPPERCASE",
"__http_server2",
"HTTPServer2",
"__HTTP_SERVER2",
"HTTPSERVER2",
),
(
"PascalCase",
"__http_server2",
"HTTPServer2",
"HttpServer2",
"HTTPServer2",
),
(
"camelCase",
"__http_server2",
"HTTPServer2",
"httpServer2",
"hTTPServer2",
),
(
"snake_case",
"__http_server2",
"HTTPServer2",
"__http_server2",
"h_t_t_p_server2",
),
(
"SCREAMING_SNAKE_CASE",
"__http_server2",
"HTTPServer2",
"__HTTP_SERVER2",
"H_T_T_P_SERVER2",
),
(
"kebab-case",
"__http_server2",
"HTTPServer2",
"--http-server2",
"h-t-t-p-server2",
),
(
"SCREAMING-KEBAB-CASE",
"__http_server2",
"HTTPServer2",
"--HTTP-SERVER2",
"H-T-T-P-SERVER2",
),
];
for (rule, field, variant, expected_field, expected_variant) in cases {
let rule = super::RenameRule::parse(rule, Span::call_site())
.expect("supported serde rename_all rule parses");
assert_eq!(rule.apply_to_field(field), expected_field, "{rule:?} field");
assert_eq!(
rule.apply_to_variant(variant),
expected_variant,
"{rule:?} variant"
);
}
}
#[test]
fn canonical_schema_uses_serde_rename_all_wire_names() {
let catalog = catalog(quote! {
version v {
wire {
#[serde(rename_all = "camelCase")]
struct TrickyField { __http_server2: u8 }
#[serde(rename_all = "snake_case")]
enum TrickyVariant { HTTPServer2 }
topic field: state TrickyField;
topic variant: state TrickyVariant;
}
}
});
assert_eq!(
canonical_body(&catalog, "wire", "TrickyField"),
"struct{f11:httpServer2=u8;}"
);
assert_eq!(
canonical_body(&catalog, "wire", "TrickyVariant"),
"enum(external){v15:h_t_t_p_server2=unit;}"
);
}
}