use proc_macro2::TokenStream;
use quote::quote;
use syn::parse::{Parse, ParseStream};
use syn::visit_mut::{self, VisitMut};
use syn::{Ident, ItemEnum, ItemStruct, Token};
use crate::util::body_derives;
mod kw {
syn::custom_keyword!(extends);
syn::custom_keyword!(latest);
syn::custom_keyword!(remove);
syn::custom_keyword!(replace);
syn::custom_keyword!(version);
syn::custom_keyword!(topic);
syn::custom_keyword!(command);
syn::custom_keyword!(state);
syn::custom_keyword!(measurement);
syn::custom_keyword!(diagnostic);
syn::custom_keyword!(query);
syn::custom_keyword!(world_clock);
}
pub fn expand(input: TokenStream) -> syn::Result<TokenStream> {
let tree: ApiTree = syn::parse2(input)?;
tree.expand()
}
struct ApiTree {
versions: Vec<Version>,
latest: Ident,
}
struct Version {
name: Ident,
wire_id: String,
parent: Option<Ident>,
nodes: Vec<Node>,
removals: Vec<Removal>,
}
#[derive(Clone)]
struct Node {
name: Ident,
replace: bool,
var: Option<Ident>,
types: Vec<TypeDef>,
topics: Vec<TopicDef>,
children: Vec<Node>,
removals: Vec<Removal>,
}
#[derive(Clone)]
struct TypeDef {
replace: bool,
item: TypeItem,
}
#[derive(Clone)]
enum TypeItem {
Struct(ItemStruct),
Enum(ItemEnum),
}
#[derive(Clone)]
struct TopicDef {
replace: bool,
leaf: TopicLeaf,
kind: TopicKind,
role: TopicRole,
}
#[derive(Clone)]
struct Removal {
segments: Vec<Ident>,
}
#[derive(Clone)]
enum TopicLeaf {
Named(Ident),
Node,
}
impl TopicLeaf {
fn method_ident(&self) -> Ident {
match self {
TopicLeaf::Named(ident) => ident.clone(),
TopicLeaf::Node => quote::format_ident!("topic"),
}
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum TopicRole {
Command,
State,
Measurement,
Diagnostic,
Query,
WorldClock,
}
impl TopicRole {
fn bus_variant(self) -> TokenStream {
match self {
TopicRole::Command => quote! { ::phoxal_bus::TopicRole::Command },
TopicRole::State | TopicRole::WorldClock => quote! { ::phoxal_bus::TopicRole::State },
TopicRole::Measurement => quote! { ::phoxal_bus::TopicRole::Measurement },
TopicRole::Diagnostic => quote! { ::phoxal_bus::TopicRole::Diagnostic },
TopicRole::Query => quote! { ::phoxal_bus::TopicRole::Query },
}
}
fn marker_trait(self) -> Option<TokenStream> {
match self {
TopicRole::Command => Some(quote! { ::phoxal_bus::CommandContract }),
TopicRole::State => Some(quote! { ::phoxal_bus::StateContract }),
TopicRole::Measurement => Some(quote! { ::phoxal_bus::MeasurementContract }),
TopicRole::Diagnostic => Some(quote! { ::phoxal_bus::DiagnosticContract }),
TopicRole::WorldClock => Some(quote! { ::phoxal_bus::WorldClockContract }),
TopicRole::Query => None,
}
}
fn owner_publishes(self) -> bool {
!matches!(self, TopicRole::Command)
}
}
#[derive(Clone)]
enum TopicKind {
PubSub(Ident),
Query { request: Ident, response: Ident },
}
struct ManifestVersion {
name: String,
contracts: Vec<ManifestContract>,
}
struct ManifestContract {
family: String,
topic: String,
role: TopicRole,
}
impl Parse for ApiTree {
fn parse(input: ParseStream) -> syn::Result<Self> {
let mut versions = Vec::new();
while input.peek(kw::version) {
versions.push(input.parse()?);
}
if versions.is_empty() {
return Err(input.error("phoxal_api_tree! requires at least one `version` block"));
}
input.parse::<kw::latest>()?;
let latest = input.parse()?;
input.parse::<Token![;]>()?;
if !input.is_empty() {
return Err(input.error("expected exactly one final `latest <revision>;` declaration"));
}
Ok(ApiTree { versions, latest })
}
}
impl Parse for Version {
fn parse(input: ParseStream) -> syn::Result<Self> {
input.parse::<kw::version>()?;
let name: Ident = input.parse()?;
let name_text = name.to_string();
let Some(parts) = name_text.strip_prefix('v') else {
return Err(syn::Error::new(
name.span(),
"API revisions use Rust identifiers such as `v0_1` or `v1_0`",
));
};
let Some((major, minor)) = parts.split_once('_') else {
return Err(syn::Error::new(
name.span(),
"API revisions use two-part Rust identifiers such as `v0_1` or `v1_0`",
));
};
let valid_part = |part: &str| {
!part.is_empty()
&& (part == "0" || !part.starts_with('0'))
&& part.bytes().all(|byte| byte.is_ascii_digit())
};
if !valid_part(major) || !valid_part(minor) {
return Err(syn::Error::new(
name.span(),
"API revision components must be canonical decimal numbers, e.g. `v0_1`",
));
}
let wire_id = format!("v{major}.{minor}");
let parent = if input.peek(kw::extends) {
input.parse::<kw::extends>()?;
Some(input.parse()?)
} else {
None
};
let body;
syn::braced!(body in input);
let mut nodes = Vec::new();
let mut removals = Vec::new();
while !body.is_empty() {
if body.peek(kw::remove) {
removals.push(body.parse()?);
} else {
nodes.push(body.parse()?);
}
}
Ok(Version {
name,
wire_id,
parent,
nodes,
removals,
})
}
}
impl Parse for Node {
fn parse(input: ParseStream) -> syn::Result<Self> {
let replace = if input.peek(kw::replace) {
input.parse::<kw::replace>()?;
true
} else {
false
};
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();
let mut removals = Vec::new();
while !body.is_empty() {
let attrs = body.call(syn::Attribute::parse_outer)?;
let replace_item = if body.peek(kw::replace) {
body.parse::<kw::replace>()?;
true
} else {
false
};
if body.peek(kw::remove) {
if replace_item {
return Err(body.error("`replace remove` is not valid; use `remove <path>;`"));
}
if let Some(attr) = attrs.first() {
return Err(syn::Error::new_spanned(
attr,
"attributes are not allowed on a `remove` declaration",
));
}
removals.push(body.parse()?);
} else 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",
));
}
let mut topic: TopicDef = body.parse()?;
topic.replace = replace_item;
topics.push(topic);
} 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 {
replace: replace_item,
item: TypeItem::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 {
replace: replace_item,
item: TypeItem::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",
));
}
let mut child: Node = body.parse()?;
child.replace = replace_item;
children.push(child);
} else {
return Err(body.error(
"expected `struct`, `enum`, `topic …;`, or a child node `name { … }` / \
`name(var) { … }` inside an API node block",
));
}
}
Ok(Node {
name,
replace,
var,
types,
topics,
children,
removals,
})
}
}
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::measurement) {
input.parse::<kw::measurement>()?;
let body: Ident = input.parse()?;
(TopicKind::PubSub(body), TopicRole::Measurement)
} else if input.peek(kw::diagnostic) {
input.parse::<kw::diagnostic>()?;
let body: Ident = input.parse()?;
(TopicKind::PubSub(body), TopicRole::Diagnostic)
} 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 if input.peek(kw::world_clock) {
input.parse::<kw::world_clock>()?;
let body: Ident = input.parse()?;
(TopicKind::PubSub(body), TopicRole::WorldClock)
} else {
return Err(input.error(
"expected a topic role: `command <Body>`, `state <Body>`, \
`measurement <Body>`, `diagnostic <Body>`, `world_clock <Body>` \
(framework-reserved), or `query <Req> => <Resp>`",
));
};
input.parse::<Token![;]>()?;
Ok(TopicDef {
replace: false,
leaf,
kind,
role,
})
}
}
impl Parse for Removal {
fn parse(input: ParseStream) -> syn::Result<Self> {
input.parse::<kw::remove>()?;
let mut segments = vec![input.parse()?];
while input.peek(Token![::]) {
input.parse::<Token![::]>()?;
segments.push(input.parse()?);
}
input.parse::<Token![;]>()?;
Ok(Self { segments })
}
}
impl ApiTree {
fn expand(&self) -> syn::Result<TokenStream> {
let mut out = TokenStream::new();
let mut manifest_versions = Vec::new();
let mut materialized = std::collections::BTreeMap::<String, Vec<Node>>::new();
for version in &self.versions {
let name = version.name.to_string();
if materialized.contains_key(&name) {
return Err(syn::Error::new_spanned(
&version.name,
format!("duplicate API revision `{}`", version.name),
));
}
let nodes = if let Some(parent) = &version.parent {
let parent_name = parent.to_string();
let base = materialized.get(&parent_name).ok_or_else(|| {
syn::Error::new_spanned(
parent,
"an `extends` parent must be a concrete revision declared earlier",
)
})?;
apply_version_delta(base, version)?
} else {
validate_complete_tree(&version.nodes, &version.removals)?;
version.nodes.clone()
};
let concrete = MaterializedVersion {
name: version.name.clone(),
wire_id: version.wire_id.clone(),
nodes: nodes.clone(),
};
manifest_versions.push(ManifestVersion {
name: version.wire_id.clone(),
contracts: contract_manifest_entries(&version.wire_id, &nodes)?,
});
out.extend(expand_version(&concrete)?);
materialized.insert(name, nodes);
}
if !materialized.contains_key(&self.latest.to_string()) {
return Err(syn::Error::new_spanned(
&self.latest,
"`latest` must name a declared concrete API revision",
));
}
let latest = &self.latest;
let manifest = expand_contract_manifest(&manifest_versions);
Ok(quote! {
#manifest
#out
pub use #latest as latest;
})
}
}
struct MaterializedVersion {
name: Ident,
wire_id: String,
nodes: Vec<Node>,
}
fn validate_complete_tree(nodes: &[Node], removals: &[Removal]) -> syn::Result<()> {
if let Some(removal) = removals.first() {
return Err(syn::Error::new_spanned(
&removal.segments[0],
"`remove` is only valid inside a revision that `extends` another revision",
));
}
for node in nodes {
if node.replace {
return Err(syn::Error::new_spanned(
&node.name,
"`replace` is only valid inside a revision that `extends` another revision",
));
}
if let Some(removal) = node.removals.first() {
return Err(syn::Error::new_spanned(
&removal.segments[0],
"`remove` is only valid inside a revision that `extends` another revision",
));
}
for ty in &node.types {
if ty.replace {
return Err(syn::Error::new_spanned(
ty.ident(),
"`replace` is only valid inside a revision that `extends` another revision",
));
}
}
for topic in &node.topics {
if topic.replace {
return Err(syn::Error::new_spanned(
topic.leaf.method_ident(),
"`replace` is only valid inside a revision that `extends` another revision",
));
}
}
validate_complete_tree(&node.children, &[])?;
}
Ok(())
}
fn apply_version_delta(base: &[Node], version: &Version) -> syn::Result<Vec<Node>> {
let mut nodes = base.to_vec();
let parent = version
.parent
.as_ref()
.expect("version deltas always have an extends parent");
reroot_inherited_type_paths(&mut nodes, parent, &version.name);
apply_removals(&mut nodes, &version.removals)?;
merge_nodes(&mut nodes, &version.nodes)?;
Ok(nodes)
}
fn reroot_inherited_type_paths(nodes: &mut [Node], parent: &Ident, child: &Ident) {
struct RevisionPathRewriter<'a> {
parent: &'a Ident,
child: &'a Ident,
}
impl VisitMut for RevisionPathRewriter<'_> {
fn visit_path_mut(&mut self, path: &mut syn::Path) {
let mut segments = path.segments.iter_mut();
if segments
.next()
.is_some_and(|segment| segment.ident == "crate")
&& let Some(revision) = segments.next()
&& revision.ident == *self.parent
{
revision.ident = self.child.clone();
}
visit_mut::visit_path_mut(self, path);
}
}
fn rewrite_nodes(nodes: &mut [Node], rewriter: &mut RevisionPathRewriter<'_>) {
for node in nodes {
for ty in &mut node.types {
match &mut ty.item {
TypeItem::Struct(item) => rewriter.visit_item_struct_mut(item),
TypeItem::Enum(item) => rewriter.visit_item_enum_mut(item),
}
}
rewrite_nodes(&mut node.children, rewriter);
}
}
rewrite_nodes(nodes, &mut RevisionPathRewriter { parent, child });
}
fn merge_nodes(base: &mut Vec<Node>, deltas: &[Node]) -> syn::Result<()> {
for delta in deltas {
let existing = base.iter().position(|node| node.name == delta.name);
match (existing, delta.replace) {
(Some(index), true) => {
let mut replacement = delta.clone();
replacement.replace = false;
validate_complete_tree(&[replacement.clone()], &[])?;
base[index] = replacement;
}
(Some(index), false) => merge_node(&mut base[index], delta)?,
(None, true) => {
return Err(syn::Error::new_spanned(
&delta.name,
"`replace` target does not exist in the parent revision",
));
}
(None, false) => {
validate_complete_tree(std::slice::from_ref(delta), &[])?;
base.push(delta.clone());
}
}
}
Ok(())
}
fn merge_node(base: &mut Node, delta: &Node) -> syn::Result<()> {
if base.var.as_ref().map(Ident::to_string) != delta.var.as_ref().map(Ident::to_string) {
return Err(syn::Error::new_spanned(
&delta.name,
"an inherited node must keep the same static/dynamic binding",
));
}
apply_node_removals(base, &delta.removals)?;
for delta_type in &delta.types {
let ident = delta_type.ident();
let existing = base.types.iter().position(|item| item.ident() == ident);
match (existing, delta_type.replace) {
(Some(index), true) => {
let mut replacement = delta_type.clone();
replacement.replace = false;
base.types[index] = replacement;
}
(Some(_), false) => {
return Err(syn::Error::new_spanned(
ident,
"inherited type already exists; prefix the declaration with `replace`",
));
}
(None, true) => {
return Err(syn::Error::new_spanned(
ident,
"`replace` type target does not exist in the parent revision",
));
}
(None, false) => base.types.push(delta_type.clone()),
}
}
for delta_topic in &delta.topics {
let ident = delta_topic.leaf.method_ident();
let existing = base
.topics
.iter()
.position(|item| item.leaf.method_ident() == ident);
match (existing, delta_topic.replace) {
(Some(index), true) => {
let mut replacement = delta_topic.clone();
replacement.replace = false;
base.topics[index] = replacement;
}
(Some(_), false) => {
return Err(syn::Error::new_spanned(
ident,
"inherited topic already exists; prefix the declaration with `replace`",
));
}
(None, true) => {
return Err(syn::Error::new_spanned(
ident,
"`replace` topic target does not exist in the parent revision",
));
}
(None, false) => base.topics.push(delta_topic.clone()),
}
}
merge_nodes(&mut base.children, &delta.children)
}
fn apply_removals(nodes: &mut Vec<Node>, removals: &[Removal]) -> syn::Result<()> {
for removal in removals {
remove_from_nodes(nodes, &removal.segments)?;
}
Ok(())
}
fn remove_from_nodes(nodes: &mut Vec<Node>, path: &[Ident]) -> syn::Result<()> {
let Some((head, tail)) = path.split_first() else {
return Ok(());
};
let Some(index) = nodes.iter().position(|node| node.name == *head) else {
return Err(syn::Error::new_spanned(
head,
"`remove` path does not exist in the parent revision",
));
};
if tail.is_empty() {
nodes.remove(index);
return Ok(());
}
remove_from_node(&mut nodes[index], tail)
}
fn apply_node_removals(node: &mut Node, removals: &[Removal]) -> syn::Result<()> {
for removal in removals {
remove_from_node(node, &removal.segments)?;
}
Ok(())
}
fn remove_from_node(node: &mut Node, path: &[Ident]) -> syn::Result<()> {
let Some((head, tail)) = path.split_first() else {
return Ok(());
};
if !tail.is_empty() {
let Some(child) = node.children.iter_mut().find(|child| child.name == *head) else {
return Err(syn::Error::new_spanned(
head,
"`remove` path does not exist in the parent revision",
));
};
return remove_from_node(child, tail);
}
let type_index = node.types.iter().position(|item| item.ident() == head);
let topic_index = node
.topics
.iter()
.position(|item| item.leaf.method_ident() == *head);
let child_index = node.children.iter().position(|item| item.name == *head);
let matches = usize::from(type_index.is_some())
+ usize::from(topic_index.is_some())
+ usize::from(child_index.is_some());
if matches != 1 {
return Err(syn::Error::new_spanned(
head,
if matches == 0 {
"`remove` target does not exist in the parent revision"
} else {
"`remove` target is ambiguous; use a uniquely named path"
},
));
}
if let Some(index) = type_index {
node.types.remove(index);
} else if let Some(index) = topic_index {
node.topics.remove(index);
} else if let Some(index) = child_index {
node.children.remove(index);
}
Ok(())
}
impl TypeDef {
fn ident(&self) -> &Ident {
match &self.item {
TypeItem::Struct(item) => &item.ident,
TypeItem::Enum(item) => &item.ident,
}
}
}
fn expand_contract_manifest(versions: &[ManifestVersion]) -> TokenStream {
let version_entries = versions.iter().map(|version| {
let name = &version.name;
let contracts = version.contracts.iter().map(|contract| {
let family = &contract.family;
let topic = &contract.topic;
let role = contract.role.bus_variant();
quote! {
ApiContractManifestContract {
family: #family,
topic: #topic,
role: #role,
}
}
});
quote! {
ApiContractManifestVersion {
name: #name,
contracts: &[#(#contracts),*],
}
}
});
quote! {
#[cfg(test)]
#[doc(hidden)]
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ApiContractManifestVersion {
pub name: &'static str,
pub contracts: &'static [ApiContractManifestContract],
}
#[cfg(test)]
#[doc(hidden)]
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ApiContractManifestContract {
pub family: &'static str,
pub topic: &'static str,
pub role: ::phoxal_bus::TopicRole,
}
#[cfg(test)]
#[doc(hidden)]
pub const API_CONTRACT_MANIFEST: &[ApiContractManifestVersion] = &[#(#version_entries),*];
}
}
fn contract_manifest_entries(version: &str, nodes: &[Node]) -> syn::Result<Vec<ManifestContract>> {
let mut contracts = Vec::new();
collect_contract_manifest_entries(version, nodes, "", "", &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(
version: &str,
nodes: &[Node],
family_prefix: &str,
key_prefix: &str,
contracts: &mut Vec<ManifestContract>,
) {
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 = format!("{version}/{}", topic_key(&node_key_prefix, &topic.leaf));
match &topic.kind {
TopicKind::PubSub(body) => {
contracts.push(ManifestContract {
family: format!("{version}::{family_path}::{body}"),
topic: topic_key,
role: topic.role,
});
}
TopicKind::Query { request, response } => {
contracts.push(ManifestContract {
family: format!("{version}::{family_path}::{request}"),
topic: topic_key.clone(),
role: topic.role,
});
contracts.push(ManifestContract {
family: format!("{version}::{family_path}::{response}"),
topic: topic_key,
role: topic.role,
});
}
}
}
collect_contract_manifest_entries(
version,
&node.children,
&family_path,
&node_key_prefix,
contracts,
);
}
}
fn expand_version(version: &MaterializedVersion) -> syn::Result<TokenStream> {
let mod_name = &version.name;
let id = version.wire_id.clone();
let nodes = &version.nodes;
let mut node_mods = TokenStream::new();
for node in nodes {
node_mods.extend(expand_node_module(node, &id, "", "")?);
}
let topic_mod = expand_topic_module(&id, nodes)?;
let module_doc = format!("Concrete API revision `{id}` - version-local wire bodies + topics.");
Ok(quote! {
#[doc = #module_doc]
pub mod #mod_name {
#[derive(Clone, Copy, Debug)]
pub enum Api {}
impl ::phoxal_bus::ApiVersion for Api {
const ID: &'static str = #id;
}
#[doc(hidden)]
pub use self::Api as __PhoxalApiMarker;
#node_mods
#topic_mod
}
})
}
fn expand_node_module(
node: &Node,
version: &str,
family_prefix: &str,
key_prefix: &str,
) -> 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 mut types = TokenStream::new();
for ty in &node.types {
match &ty.item {
TypeItem::Struct(item) => {
let item = with_pub_fields_struct(item.clone());
types.extend(quote! { #derives #item });
}
TypeItem::Enum(item) => {
types.extend(quote! { #derives #item });
}
}
}
let mut impls = TokenStream::new();
for topic in &node.topics {
let key = format!("{version}/{}", topic_key(&node_key_prefix, &topic.leaf));
let role = topic.role.bus_variant();
let version = version.to_string();
let name_for = |body: &Ident| format!("{version}::{family_path}::{body}");
let contract_for = |body: &Ident| format!("{family_path}::{body}");
match &topic.kind {
TopicKind::PubSub(body) => {
let name = name_for(body);
let contract = contract_for(body);
let marker = topic
.role
.marker_trait()
.map(|marker| quote! { impl #marker for #body {} });
impls.extend(quote! {
impl ::phoxal_bus::ContractBody for #body {
type Api = self::__PhoxalApiMarker;
const NAME: &'static str = #name;
const VERSION: &'static str = #version;
const CONTRACT: &'static str = #contract;
const TOPIC: &'static str = #key;
const ROLE: ::phoxal_bus::TopicRole = #role;
}
#marker
});
}
TopicKind::Query { request, response } => {
let request_name = name_for(request);
let response_name = name_for(response);
let request_contract = contract_for(request);
let response_contract = contract_for(response);
impls.extend(quote! {
impl ::phoxal_bus::ContractBody for #request {
type Api = self::__PhoxalApiMarker;
const NAME: &'static str = #request_name;
const VERSION: &'static str = #version;
const CONTRACT: &'static str = #request_contract;
const TOPIC: &'static str = #key;
const ROLE: ::phoxal_bus::TopicRole = #role;
}
impl ::phoxal_bus::ContractBody for #response {
type Api = self::__PhoxalApiMarker;
const NAME: &'static str = #response_name;
const VERSION: &'static str = #version;
const CONTRACT: &'static str = #response_contract;
const TOPIC: &'static str = #key;
const ROLE: ::phoxal_bus::TopicRole = #role;
}
});
}
}
}
let mut child_mods = TokenStream::new();
for child in &node.children {
child_mods.extend(expand_node_module(
child,
version,
&family_path,
&node_key_prefix,
)?);
}
Ok(quote! {
pub mod #name {
#[doc(hidden)]
pub use super::__PhoxalApiMarker;
#types
#impls
#child_mods
}
})
}
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, Copy)]
enum Side {
Client,
Owner,
}
fn type_root_alias_ident(node_name: &Ident) -> Ident {
quote::format_ident!("__phoxal_type_root_{}", node_name)
}
fn expand_topic_module(version: &str, 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();
let mut type_root_seeds = TokenStream::new();
let mut type_root_forwards = TokenStream::new();
for node in nodes {
let name = &node.name;
let alias = type_root_alias_ident(name);
client_root_methods.extend(node_entry_method(node));
client_builder_mods.extend(expand_builder_module(node, version, &[], Side::Client)?);
owner_root_methods.extend(node_entry_method(node));
owner_builder_mods.extend(expand_builder_module(node, version, &[], Side::Owner)?);
type_root_seeds.extend(quote! {
#[doc(hidden)]
pub use super::#name as #alias;
});
type_root_forwards.extend(quote! {
#[doc(hidden)]
pub use super::#alias;
});
}
Ok(quote! {
pub mod topic {
pub fn client() -> Root {
Root
}
#[non_exhaustive]
pub struct Root;
impl Root {
#client_root_methods
}
#type_root_seeds
#client_builder_mods
pub fn owner() -> owner::Root {
owner::Root
}
pub mod owner {
#[non_exhaustive]
pub struct Root;
impl Root {
#owner_root_methods
}
#type_root_forwards
#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,
version: &str,
ancestors: &[NodeSeg],
side: Side,
) -> syn::Result<TokenStream> {
let name = &node.name;
let name_str = name.to_string();
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 ancestors.is_empty() {
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, side);
let (fmt_str, doc_key) = builder_leaf_key_parts(version, &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, version, &path, side)?);
}
let type_root_import = if ancestors.is_empty() {
let alias = type_root_alias_ident(name);
quote! {
#[doc(hidden)]
pub use super::#alias as __phoxal_type_root;
}
} else {
quote! {
#[doc(hidden)]
pub use super::__phoxal_type_root;
}
};
let builder_doc = format!("Topic builder for the `{name_str}` node.");
Ok(quote! {
pub mod #name {
#type_root_import
#[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], side: Side) -> TokenStream {
let rest_path: Vec<&Ident> = path[1..].iter().map(|s| &s.name).collect();
let body_path = |body: &Ident| quote! { self::__phoxal_type_root #(::#rest_path)* :: #body };
match &topic.kind {
TopicKind::PubSub(body) => {
let b = body_path(body);
let owner_publishes = topic.role.owner_publishes();
match side {
Side::Owner if owner_publishes => quote! { ::phoxal_bus::Publish<#b> },
Side::Client if !owner_publishes => quote! { ::phoxal_bus::Publish<#b> },
_ => 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(version: &str, path: &[NodeSeg], leaf: &TopicLeaf) -> (String, String) {
let mut fmt_segs = vec![version.to_string()];
let mut doc_segs = vec![version.to_string()];
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::expand;
use proc_macro2::TokenStream;
use quote::quote;
fn compact_tokens(tokens: TokenStream) -> String {
tokens
.to_string()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
#[test]
fn topic_and_family_are_version_qualified() {
let expanded = compact_tokens(
expand(quote! {
version v0_1 {
drive {
struct Target { linear_x_mps: f32 }
topic target: command Target;
}
}
latest v0_1;
})
.expect("tree expands"),
);
assert!(
expanded.contains("const TOPIC : & 'static str = \"v0.1/drive/target\""),
"TOPIC must fold the version into the wire key (D1): {expanded}"
);
assert!(
expanded.contains("const NAME : & 'static str = \"v0.1::drive::Target\""),
"NAME must be the version-qualified type path (D1): {expanded}"
);
assert!(
expanded.contains("const VERSION : & 'static str = \"v0.1\""),
"VERSION must be the bare version, split from CONTRACT (\
design §2): {expanded}"
);
assert!(
expanded.contains("const CONTRACT : & 'static str = \"drive::Target\""),
"CONTRACT must be the type path within its version, with no version \
prefix: {expanded}"
);
}
#[test]
fn dynamic_node_topic_is_version_and_var_qualified() {
let expanded = compact_tokens(
expand(quote! {
version v0_1 {
component(instance) {
motor(capability) {
enum Command { Stop }
topic command: command Command;
}
}
}
latest v0_1;
})
.expect("tree expands"),
);
assert!(
expanded.contains(
"const TOPIC : & 'static str = \"v0.1/component/{instance}/motor/{capability}/command\""
),
"dynamic-node TOPIC must carry both the version and the {{var}} placeholders: {expanded}"
);
assert!(
expanded.contains("const NAME : & 'static str = \"v0.1::component::motor::Command\""),
"dynamic-node NAME must be the clean type path with no {{var}} placeholders - \
dynamic-node vars are topic params, never type-path segments: {expanded}"
);
assert!(
expanded.contains("const CONTRACT : & 'static str = \"component::motor::Command\""),
"dynamic-node CONTRACT must also exclude every {{var}} placeholder: {expanded}"
);
}
#[test]
fn topic_builder_keys_are_version_qualified() {
let expanded = compact_tokens(
expand(quote! {
version v0_1 {
drive {
struct Target { linear_x_mps: f32 }
topic target: command Target;
}
}
latest v0_1;
})
.expect("tree expands"),
);
assert!(
expanded.contains("Topic :: new_static (\"v0.1/drive/target\")"),
"the api-local topic builder must build the same version-qualified key as \
ContractBody::TOPIC: {expanded}"
);
}
#[test]
fn duplicate_version_names_in_one_invocation_are_rejected() {
let err = expand(quote! {
version v0_1 { sample { struct Body { value: u8 } topic body: state Body; } }
version v0_1 { sample { struct Body { value: u8 } topic body: state Body; } }
latest v0_1;
})
.expect_err("a duplicate version name must be rejected");
assert!(
err.to_string().contains("duplicate API revision"),
"unexpected error: {err}"
);
}
#[test]
fn inherited_revision_materializes_add_replace_and_remove() {
let expanded = compact_tokens(
expand(quote! {
version v0_1 {
drive {
struct Target { value: u8 }
struct Removed { value: u8 }
topic target: command Target;
topic removed: state Removed;
}
}
version v0_2 extends v0_1 {
drive {
replace struct Target { value: u16 }
remove Removed;
remove removed;
struct Added { value: u32 }
topic added: state Added;
}
}
latest v0_2;
})
.expect("inherited tree expands"),
);
assert!(expanded.contains("pub mod v0_1"));
assert!(expanded.contains("pub mod v0_2"));
assert!(expanded.contains("\"v0.2/drive/target\""));
assert!(expanded.contains("\"v0.2/drive/added\""));
assert!(!expanded.contains("\"v0.2/drive/removed\""));
assert!(expanded.contains("pub use v0_2 as latest"));
}
#[test]
fn inherited_revision_reroots_absolute_parent_type_paths() {
let expanded = compact_tokens(
expand(quote! {
version v0_1 {
tool {
struct Cursor { sequence: u64 }
struct Page { cursor: crate::v0_1::tool::Cursor }
topic page: state Page;
}
}
version v0_2 extends v0_1 {
tool { replace struct Cursor { sequence: u128 } }
}
latest v0_2;
})
.expect("inherited absolute paths are re-rooted"),
);
assert!(
expanded.contains("cursor : crate :: v0_2 :: tool :: Cursor"),
"the materialized child body must use the child revision's replacement: {expanded}"
);
}
#[test]
fn inherited_revision_rejects_silent_shadowing() {
let error = expand(quote! {
version v0_1 {
sample { struct Body { value: u8 } topic body: state Body; }
}
version v0_2 extends v0_1 {
sample { struct Body { value: u16 } }
}
latest v0_2;
})
.expect_err("same-path declarations require replace");
assert!(
error
.to_string()
.contains("prefix the declaration with `replace`")
);
}
#[test]
fn nonstandard_version_names_are_rejected() {
for name in ["release_1", "preview2", "v0", "v01", "v1_beta"] {
let source = format!(
"version {name} {{ sample {{ struct Body {{ value: u8 }} topic value: state Body; }} }} latest {name};"
);
let tokens: TokenStream = source.parse().expect("test source tokenizes");
let error = expand(tokens).expect_err("nonstandard API version must fail");
assert!(
error.to_string().contains("API revision"),
"unexpected error for {name}: {error}"
);
}
}
#[test]
fn expansion_emits_root_contract_manifest() {
let expanded = compact_tokens(
expand(quote! {
version v0_1 {
sample {
struct Body { value: u8 }
topic body: state Body;
}
}
version v0_2 extends v0_1 {
sample {
struct Added { value: u16 }
topic added: state Added;
}
}
latest v0_2;
})
.expect("tree expands"),
);
assert!(
expanded.contains("pub const API_CONTRACT_MANIFEST"),
"root manifest const should be emitted: {expanded}"
);
assert!(
expanded.contains("# [cfg (test)] # [doc (hidden)] pub const API_CONTRACT_MANIFEST"),
"the manifest const (and its two supporting types) must be \
#[cfg(test)]-gated: {expanded}"
);
assert!(
expanded.contains(
"# [cfg (test)] # [doc (hidden)] # [derive (Clone , Copy , Debug , Eq , \
PartialEq)] pub struct ApiContractManifestVersion"
),
"ApiContractManifestVersion must be #[cfg(test)]-gated alongside the const: {expanded}"
);
assert!(
expanded.contains(
"# [cfg (test)] # [doc (hidden)] # [derive (Clone , Copy , Debug , Eq , \
PartialEq)] pub struct ApiContractManifestContract"
),
"ApiContractManifestContract must be #[cfg(test)]-gated alongside the const: {expanded}"
);
assert!(
expanded.contains("name : \"v0.2\""),
"child revision should be represented in the manifest: {expanded}"
);
assert!(
expanded.contains("family : \"v0.1::sample::Body\""),
"manifest family is the version-qualified contract identity (D1): {expanded}"
);
assert!(
expanded.contains("topic : \"v0.2/sample/body\""),
"manifest topic is the version-qualified wire key (D1): {expanded}"
);
assert!(
expanded.contains("family : \"v0.2::sample::Body\""),
"each version's contracts get their own version-qualified name: {expanded}"
);
assert!(
expanded.contains("topic : \"v0.2/sample/body\""),
"each version's contracts get their own version-qualified key: {expanded}"
);
assert!(
expanded.contains("pub use v0_2 as latest"),
"the selected concrete revision should be exported as latest: {expanded}"
);
}
#[test]
fn query_request_and_response_share_one_version_qualified_topic() {
let expanded = compact_tokens(
expand(quote! {
version v0_1 {
asset {
struct GetRequest { path: String }
enum GetResponse { Missing }
topic get: query GetRequest => GetResponse;
}
}
latest v0_1;
})
.expect("tree expands"),
);
assert_eq!(
expanded
.matches("const TOPIC : & 'static str = \"v0.1/asset/get\"")
.count(),
2,
"both the request and response bodies of a query topic share its \
version-qualified key: {expanded}"
);
assert!(
expanded.contains("const NAME : & 'static str = \"v0.1::asset::GetRequest\""),
"the request body gets its own type-path NAME: {expanded}"
);
assert!(
expanded.contains("const NAME : & 'static str = \"v0.1::asset::GetResponse\""),
"the response body gets its own type-path NAME, distinct from the \
request's even though they share one TOPIC: {expanded}"
);
assert!(
expanded.contains("const CONTRACT : & 'static str = \"asset::GetRequest\""),
"the request body's CONTRACT is its own type path, version excluded: {expanded}"
);
assert!(
expanded.contains("const CONTRACT : & 'static str = \"asset::GetResponse\""),
"the response body's CONTRACT is distinct from the request's: {expanded}"
);
}
#[test]
fn deeply_nested_dynamic_tree_never_emits_a_multi_hop_super_chain() {
let expanded = compact_tokens(
expand(quote! {
version v0_1 {
a(x) {
b(y) {
c(z) {
struct Body { v: u8 }
topic leaf: state Body;
}
}
}
}
latest v0_1;
})
.expect("tree expands"),
);
assert!(
!expanded.contains("super :: super"),
"no reference should ever chain more than one `super::` hop, on \
either the type-tree or the builder-tree side: {expanded}"
);
assert!(
expanded.contains("const TOPIC : & 'static str = \"v0.1/a/{x}/b/{y}/c/{z}/leaf\""),
"the three-level dynamic path must still be fully version- and \
var-qualified: {expanded}"
);
assert!(
expanded.contains("const NAME : & 'static str = \"v0.1::a::b::c::Body\""),
"NAME excludes every dynamic var from the type path, unlike TOPIC: {expanded}"
);
assert!(
expanded.contains("const CONTRACT : & 'static str = \"a::b::c::Body\""),
"CONTRACT excludes both the version and every dynamic var: {expanded}"
);
assert!(
expanded.contains("type Api = self :: __PhoxalApiMarker ;"),
"ContractBody::Api must resolve through the forwarded, single-hop \
alias at any depth: {expanded}"
);
assert!(
expanded.contains("self :: __phoxal_type_root :: b :: c :: Body"),
"a deeply nested builder leaf must reach its body type through the \
forwarded type-root alias plus the remaining node path, not a \
counted supers chain: {expanded}"
);
}
}