use proc_macro2::TokenStream;
use quote::quote;
use syn::parse::{Parse, ParseStream};
use syn::{Ident, ItemEnum, ItemStruct, Token};
use crate::util::body_derives;
mod kw {
syn::custom_keyword!(preview);
syn::custom_keyword!(version);
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,
number: u64,
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),
}
#[derive(Clone)]
struct TopicDef {
leaf: TopicLeaf,
kind: TopicKind,
role: TopicRole,
}
#[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)]
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 ManifestVersion {
name: String,
is_preview: bool,
contracts: Vec<ManifestContract>,
}
struct ManifestContract {
family: String,
topic: 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 name_text = name.to_string();
let Some(number) = name_text.strip_prefix('v') else {
return Err(syn::Error::new(
name.span(),
"API versions use conventional `vN` names such as `v1` or `v2`",
));
};
if number.is_empty()
|| number.starts_with('0')
|| !number.bytes().all(|byte| byte.is_ascii_digit())
{
return Err(syn::Error::new(
name.span(),
"API versions use conventional positive `vN` names such as `v1` or `v2`",
));
}
let number = number.parse::<u64>().map_err(|_| {
syn::Error::new(
name.span(),
"API version number is too large; use a conventional positive `vN` name",
)
})?;
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,
number,
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_versions = Vec::new();
let mut seen_names = std::collections::HashSet::new();
let mut preview: Option<&Version> = None;
let mut max_stable_number = None;
if let Some(extra_preview) = self
.versions
.iter()
.filter(|version| version.is_preview)
.nth(1)
{
return Err(syn::Error::new_spanned(
&extra_preview.name,
"only one API version may be preview at a time; evolve the active preview in place",
));
}
for (index, version) in self.versions.iter().enumerate() {
if !seen_names.insert(version.name.to_string()) {
return Err(syn::Error::new_spanned(
&version.name,
format!(
"duplicate API version `{}` in the same phoxal_api_tree! invocation",
version.name
),
));
}
if version.is_preview {
if index + 1 != self.versions.len() {
return Err(syn::Error::new_spanned(
&version.name,
"the preview API version must be the final version block",
));
}
preview = Some(version);
} else {
max_stable_number = Some(
max_stable_number
.map_or(version.number, |number: u64| number.max(version.number)),
);
}
manifest_versions.push(ManifestVersion {
name: version.name.to_string(),
is_preview: version.is_preview,
contracts: contract_manifest_entries(&version.name.to_string(), &version.nodes)?,
});
out.extend(expand_version(version)?);
}
if let (Some(preview), Some(max_stable_number)) = (preview, max_stable_number) {
let expected_preview = max_stable_number.checked_add(1).ok_or_else(|| {
syn::Error::new_spanned(
&preview.name,
"latest stable API version is too large to create a next preview version",
)
})?;
if preview.number != expected_preview {
return Err(syn::Error::new_spanned(
&preview.name,
format!(
"preview API version must be the next version `v{expected_preview}` after the latest stable version; evolve the active preview in place"
),
));
}
}
let manifest = expand_contract_manifest(&manifest_versions);
Ok(quote! {
#manifest
#out
})
}
}
fn expand_contract_manifest(versions: &[ManifestVersion]) -> TokenStream {
let version_entries = versions.iter().map(|version| {
let name = &version.name;
let is_preview = version.is_preview;
let contracts = version.contracts.iter().map(|contract| {
let family = &contract.family;
let topic = &contract.topic;
quote! {
ApiContractManifestContract {
family: #family,
topic: #topic,
}
}
});
quote! {
ApiContractManifestVersion {
name: #name,
is_preview: #is_preview,
contracts: &[#(#contracts),*],
}
}
});
quote! {
#[doc(hidden)]
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ApiContractManifestVersion {
pub name: &'static str,
pub is_preview: bool,
pub contracts: &'static [ApiContractManifestContract],
}
#[doc(hidden)]
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ApiContractManifestContract {
pub family: &'static str,
pub topic: &'static str,
}
#[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,
});
}
TopicKind::Query { request, response } => {
contracts.push(ManifestContract {
family: format!("{version}::{family_path}::{request}"),
topic: topic_key.clone(),
});
contracts.push(ManifestContract {
family: format!("{version}::{family_path}::{response}"),
topic: topic_key,
});
}
}
}
collect_contract_manifest_entries(
version,
&node.children,
&family_path,
&node_key_prefix,
contracts,
);
}
}
fn expand_version(version: &Version) -> 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 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_attrs = if is_preview {
quote! {
#[cfg(feature = #feature_name)]
}
} else {
TokenStream::new()
};
let module_doc = if is_preview {
format!(
"Preview API version `{id}`. This final-path module is available only with the `{feature_name}` Cargo feature."
)
} else {
format!("Production 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;
}
#[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 {
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 = 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);
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;
}
impl #body {
#[doc(hidden)]
pub const ROLE: ::phoxal_bus::TopicRole = #role;
}
});
}
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;
}
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;
}
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,
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 new() -> Root {
Root
}
#[non_exhaustive]
pub struct Root;
impl Root {
#client_root_methods
}
#type_root_seeds
#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
}
#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);
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(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 v1 {
drive {
struct Target { linear_x_mps: f32 }
topic target: command Target;
}
}
})
.expect("tree expands"),
);
assert!(
expanded.contains("const TOPIC : & 'static str = \"v1/drive/target\""),
"TOPIC must fold the version into the wire key (D1): {expanded}"
);
assert!(
expanded.contains("const NAME : & 'static str = \"v1::drive::Target\""),
"NAME must be the version-qualified type path (D1): {expanded}"
);
assert!(
expanded.contains("const VERSION : & 'static str = \"v1\""),
"VERSION must be the bare version, split from CONTRACT (coherence-gate \
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}"
);
assert!(
!expanded.contains("SCHEMA_ID"),
"there is no schema_id left to emit: {expanded}"
);
assert!(
!expanded.contains("FAMILY"),
"ContractBody no longer carries FAMILY (D1): {expanded}"
);
}
#[test]
fn dynamic_node_topic_is_version_and_var_qualified() {
let expanded = compact_tokens(
expand(quote! {
version v1 {
component(instance) {
motor(capability) {
enum Command { Stop }
topic command: command Command;
}
}
}
})
.expect("tree expands"),
);
assert!(
expanded.contains(
"const TOPIC : & 'static str = \"v1/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 = \"v1::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 v1 {
drive {
struct Target { linear_x_mps: f32 }
topic target: command Target;
}
}
})
.expect("tree expands"),
);
assert!(
expanded.contains("Topic :: new_static (\"v1/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 v1 { sample { struct Body { value: u8 } topic body: state Body; } }
version v1 { sample { struct Body { value: u8 } topic body: state Body; } }
})
.expect_err("a duplicate version name must be rejected");
assert!(
err.to_string().contains("duplicate API version"),
"unexpected error: {err}"
);
}
#[test]
fn multiple_preview_versions_are_rejected() {
let err = expand(quote! {
version v1 { sample { struct Body { value: u8 } topic body: state Body; } }
preview version v2 { sample { struct Body { value: u8 } topic body: state Body; } }
preview version v3 { sample { struct Body { value: u8 } topic body: state Body; } }
})
.expect_err("the active preview must evolve in place");
assert!(
err.to_string()
.contains("only one API version may be preview"),
"unexpected error: {err}"
);
}
#[test]
fn preview_must_be_the_next_version_after_the_latest_stable() {
let err = expand(quote! {
version v1 { sample { struct Body { value: u8 } topic body: state Body; } }
preview version v3 { sample { struct Body { value: u8 } topic body: state Body; } }
})
.expect_err("v2 must remain the active preview after v1");
assert!(
err.to_string().contains("next version `v2`"),
"unexpected error: {err}"
);
}
#[test]
fn preview_must_be_the_final_version_block() {
let err = expand(quote! {
preview version v2 { sample { struct Body { value: u8 } topic body: state Body; } }
version v3 { sample { struct Body { value: u8 } topic body: state Body; } }
})
.expect_err("a stable version cannot follow the active preview");
assert!(
err.to_string().contains("final version block"),
"unexpected error: {err}"
);
}
#[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; }} }}"
);
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("conventional"),
"unexpected error for {name}: {error}"
);
}
}
#[test]
fn standalone_preview_version_emits_final_path_feature_gate_and_lifecycle_const() {
let expanded = compact_tokens(
expand(quote! {
preview version v2 {
sample {
struct Body { value: u8 }
topic body: state Body;
}
}
})
.expect("preview tree expands"),
);
assert!(
expanded.contains("pub mod v2"),
"preview version must be emitted at its final path: {expanded}"
);
assert!(
!expanded.contains("pub mod preview"),
"preview version must not be nested under a preview module: {expanded}"
);
assert!(
expanded.contains("# [cfg (feature = \"preview-v2\")]"),
"preview version must be gated by its per-version feature: {expanded}"
);
assert!(
expanded.contains("Preview API version `v2`"),
"preview version 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 TOPIC : & 'static str = \"v2/sample/body\""),
"a preview version's wire key is version-qualified exactly like a \
released one: {expanded}"
);
}
#[test]
fn preview_lifecycle_is_wire_neutral_for_contract_identity() {
let preview = quote! {
preview version v2 {
sample {
struct Body { value: u8, label: Option<String> }
topic body: state Body;
}
}
};
let promoted = quote! {
version v2 {
sample {
struct Body { value: u8, label: Option<String> }
topic body: state Body;
}
}
};
let preview_expanded = compact_tokens(expand(preview).expect("preview tree expands"));
let promoted_expanded = compact_tokens(expand(promoted).expect("promoted tree expands"));
assert!(preview_expanded.contains("\"v2/sample/body\""));
assert!(promoted_expanded.contains("\"v2/sample/body\""));
}
#[test]
fn expansion_emits_root_contract_manifest_for_xtask() {
let expanded = compact_tokens(
expand(quote! {
version v1 {
sample {
struct Body { value: u8 }
topic body: state Body;
}
}
preview version v2 {
sample {
struct Body { value: u8 }
topic body: state Body;
}
}
})
.expect("tree expands"),
);
assert!(
expanded.contains("pub const API_CONTRACT_MANIFEST"),
"root manifest const should be emitted: {expanded}"
);
assert!(
expanded.contains("name : \"v2\""),
"preview version should be represented in the manifest: {expanded}"
);
assert!(
expanded.contains("is_preview : true"),
"manifest should carry preview lifecycle: {expanded}"
);
assert!(
expanded.contains("family : \"v1::sample::Body\""),
"manifest family is the version-qualified contract identity (D1): {expanded}"
);
assert!(
expanded.contains("topic : \"v1/sample/body\""),
"manifest topic is the version-qualified wire key (D1): {expanded}"
);
assert!(
expanded.contains("family : \"v2::sample::Body\""),
"each version's contracts get their own version-qualified name: {expanded}"
);
assert!(
expanded.contains("topic : \"v2/sample/body\""),
"each version's contracts get their own version-qualified key: {expanded}"
);
assert!(
!expanded.contains("schema_id :"),
"there is no schema_id field left in the manifest (D1): {expanded}"
);
assert!(
!expanded.contains("extends :"),
"there is no extends field left in the manifest: {expanded}"
);
}
#[test]
fn query_request_and_response_share_one_version_qualified_topic() {
let expanded = compact_tokens(
expand(quote! {
version v1 {
asset {
struct GetRequest { path: String }
enum GetResponse { Missing }
topic get: query GetRequest => GetResponse;
}
}
})
.expect("tree expands"),
);
assert_eq!(
expanded
.matches("const TOPIC : & 'static str = \"v1/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 = \"v1::asset::GetRequest\""),
"the request body gets its own type-path NAME: {expanded}"
);
assert!(
expanded.contains("const NAME : & 'static str = \"v1::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 v1 {
a(x) {
b(y) {
c(z) {
struct Body { v: u8 }
topic leaf: state Body;
}
}
}
}
})
.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 = \"v1/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 = \"v1::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}"
);
}
}