use heck::ToShoutySnakeCase;
use proc_macro2::TokenStream;
use quote::quote;
use syn::parse::Parser;
use syn::{Data, DeriveInput, Fields, Ident, LitStr, Type, TypePath};
use crate::util::phoxal;
fn as_type_path(ty: &Type) -> Option<&TypePath> {
match ty {
Type::Path(p) => Some(p),
_ => None,
}
}
fn link_section_attrs() -> TokenStream {
quote! {
#[used]
#[cfg_attr(target_os = "macos", unsafe(link_section = "__DATA,__phoxal_meta"))]
#[cfg_attr(not(target_os = "macos"), unsafe(link_section = ".phoxal_meta"))]
}
}
fn json_lit(s: &str) -> TokenStream {
let lit = syn::LitStr::new(s, proc_macro2::Span::call_site());
quote!(#lit)
}
pub fn expand_config(input: TokenStream) -> syn::Result<TokenStream> {
let input: DeriveInput = syn::parse2(input)?;
let struct_name = &input.ident;
if !input.generics.params.is_empty() {
return Err(syn::Error::new_spanned(
&input.generics,
"#[derive(phoxal::Config)] does not support generic structs",
));
}
validate_config_serde_attributes(&input)?;
let cx = serde_derive_internals::Ctxt::new();
let container = serde_derive_internals::ast::Container::from_ast(
&cx,
&input,
serde_derive_internals::Derive::Deserialize,
);
cx.check()?;
let container = container.ok_or_else(|| {
syn::Error::new_spanned(&input, "unable to parse Config with Serde's derive model")
})?;
let serde_derive_internals::ast::Data::Struct(
serde_derive_internals::ast::Style::Struct,
fields,
) = &container.data
else {
return Err(syn::Error::new_spanned(
&input,
"#[derive(phoxal::Config)] supports only structs with named fields",
));
};
let phoxal = phoxal();
let title = container.attrs.name().deserialize_name();
let mut schema_args = vec![json_lit(&format!(
"{{\"$schema\":\"https://json-schema.org/draft/2020-12/schema\",\"title\":{},\"type\":\"object\",\"properties\":{{",
serde_json_string(title)
))];
let mut required = Vec::new();
for (index, field) in fields.iter().enumerate() {
if index > 0 {
schema_args.push(json_lit(","));
}
let field_name = field.attrs.name().deserialize_name();
schema_args.push(json_lit(&format!("{}:", serde_json_string(field_name))));
let ty = field.ty;
schema_args.push(quote!(<#ty as #phoxal::participant::ParticipantConfig>::SCHEMA_JSON));
if field.attrs.default().is_none() && !is_option_type(ty) {
required.push(field_name.to_string());
}
}
schema_args.push(json_lit("}"));
if !required.is_empty() {
let required_json = required
.iter()
.map(|name| serde_json_string(name))
.collect::<Vec<_>>()
.join(",");
schema_args.push(json_lit(&format!(",\"required\":[{required_json}]")));
}
if container.attrs.deny_unknown_fields() {
schema_args.push(json_lit(",\"additionalProperties\":false"));
}
schema_args.push(json_lit("}"));
Ok(quote! {
impl #phoxal::participant::ParticipantConfig for #struct_name {
const __SCHEMA: #phoxal::participant::api::__meta::ConstSchema =
#phoxal::participant::api::__meta::ConstSchema::new()
#(.push_str(#schema_args))*;
}
})
}
fn is_option_type(ty: &Type) -> bool {
as_type_path(ty)
.and_then(|path| path.path.segments.last())
.is_some_and(|segment| segment.ident == "Option")
}
fn serde_json_string(value: &str) -> String {
let mut out = String::with_capacity(value.len() + 2);
out.push('"');
for ch in value.chars() {
match ch {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
ch if ch <= '\u{1f}' => {
use std::fmt::Write;
write!(out, "\\u{:04x}", ch as u32).expect("writing to String cannot fail");
}
ch => out.push(ch),
}
}
out.push('"');
out
}
fn validate_config_serde_attributes(input: &DeriveInput) -> syn::Result<()> {
validate_serde_attrs(&input.attrs, SerdeAttrLocation::Container)?;
let Data::Struct(data) = &input.data else {
return Err(syn::Error::new_spanned(
input,
"#[derive(phoxal::Config)] supports only structs with named fields",
));
};
for field in &data.fields {
validate_serde_attrs(&field.attrs, SerdeAttrLocation::Field)?;
}
Ok(())
}
#[derive(Clone, Copy)]
enum SerdeAttrLocation {
Container,
Field,
}
fn validate_serde_attrs(attrs: &[syn::Attribute], location: SerdeAttrLocation) -> syn::Result<()> {
for attr in attrs.iter().filter(|attr| attr.path().is_ident("serde")) {
attr.parse_nested_meta(|meta| {
let path = &meta.path;
let name = meta
.path
.get_ident()
.map(ToString::to_string)
.unwrap_or_else(|| quote!(#path).to_string());
let supported = match location {
SerdeAttrLocation::Container => {
matches!(name.as_str(), "rename" | "rename_all" | "default" | "deny_unknown_fields")
}
SerdeAttrLocation::Field => matches!(name.as_str(), "rename" | "default"),
};
if !supported {
return Err(meta.error(format!(
"unsupported serde attribute `{name}` for #[derive(phoxal::Config)]; supported container attributes: rename, rename_all, default, deny_unknown_fields; supported field attributes: rename, default"
)));
}
match name.as_str() {
"rename" | "rename_all" => {
let _: LitStr = meta.value()?.parse()?;
}
"default" if meta.input.peek(syn::Token![=]) => {
let _: LitStr = meta.value()?.parse()?;
}
"default" | "deny_unknown_fields" if meta.input.is_empty() => {}
_ => {
return Err(meta.error(format!(
"unsupported form of serde attribute `{name}` for #[derive(phoxal::Config)]"
)));
}
}
Ok(())
})?;
}
Ok(())
}
#[derive(Clone, Copy)]
pub enum ParticipantKind {
Service,
Driver,
Simulator,
Tool,
}
impl ParticipantKind {
fn attr_name(self) -> &'static str {
match self {
ParticipantKind::Service => "#[phoxal::service]",
ParticipantKind::Driver => "#[phoxal::driver]",
ParticipantKind::Simulator => "#[phoxal::simulator]",
ParticipantKind::Tool => "#[phoxal::tool]",
}
}
fn artifact_kind(self) -> &'static str {
match self {
ParticipantKind::Service => "service",
ParticipantKind::Driver => "driver",
ParticipantKind::Simulator => "simulator",
ParticipantKind::Tool => "tool",
}
}
fn participant_class(self) -> &'static str {
match self {
ParticipantKind::Tool => "privileged",
ParticipantKind::Service | ParticipantKind::Driver | ParticipantKind::Simulator => {
"checked"
}
}
}
fn launch_policy(self, phoxal: &TokenStream) -> TokenStream {
match self {
ParticipantKind::Tool => {
quote!(#phoxal::participant::launch::ToolParticipantLaunch)
}
ParticipantKind::Simulator => {
quote!(#phoxal::participant::launch::SimulatorParticipantLaunch)
}
ParticipantKind::Service | ParticipantKind::Driver => {
quote!(#phoxal::participant::launch::ClockedParticipantLaunch)
}
}
}
fn marker_impl(self, phoxal: &TokenStream, struct_name: &Ident) -> TokenStream {
match self {
ParticipantKind::Service => {
quote! {
impl #phoxal::participant::TypedGraphSurface for #struct_name {}
impl #phoxal::participant::SchedulableSurface for #struct_name {}
}
}
ParticipantKind::Driver => quote! {
impl #phoxal::participant::spec::sealing::Sealed for #struct_name {}
impl #phoxal::participant::IsDriver for #struct_name {}
impl #phoxal::participant::TypedGraphSurface for #struct_name {}
impl #phoxal::participant::SchedulableSurface for #struct_name {}
},
ParticipantKind::Simulator => quote! {
impl #phoxal::participant::spec::sealing::Sealed for #struct_name {}
impl #phoxal::participant::IsSimulator for #struct_name {}
impl #phoxal::participant::TypedGraphSurface for #struct_name {}
},
ParticipantKind::Tool => quote! {
impl #phoxal::participant::spec::sealing::Sealed for #struct_name {}
impl #phoxal::participant::IsTool for #struct_name {}
},
}
}
}
fn is_valid_participant_id(id: &str) -> bool {
!id.is_empty()
&& id
.chars()
.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || matches!(ch, '_' | '-'))
}
fn validate_participant_id(value: &LitStr) -> syn::Result<()> {
let id = value.value();
if is_valid_participant_id(&id) {
Ok(())
} else {
Err(syn::Error::new_spanned(
value,
format!(
"invalid participant id '{id}': must be non-empty and contain only lowercase \
ASCII letters, digits, '_' or '-'"
),
))
}
}
const PARTICIPANT_PACKAGE_PREFIXES: &[&str] = &[
"phoxal-service-",
"phoxal-driver-",
"phoxal-tool-",
"phoxal-simulator-",
"phoxal-component-",
"phoxal-infrastructure-",
];
fn default_participant_id(pkg_name: &str) -> String {
for prefix in PARTICIPANT_PACKAGE_PREFIXES {
if let Some(stripped) = pkg_name.strip_prefix(prefix)
&& !stripped.is_empty()
{
return stripped.to_string();
}
}
pkg_name.to_string()
}
#[derive(Default)]
struct ParticipantArgs {
id: Option<String>,
config: Option<Type>,
state: Option<Type>,
api: Option<Type>,
}
impl ParticipantArgs {
fn parse(attr: TokenStream, attr_name: &str) -> syn::Result<Self> {
let mut args = ParticipantArgs::default();
let parser = syn::meta::parser(|meta: syn::meta::ParseNestedMeta| {
if meta.path.is_ident("id") {
let value: LitStr = meta.value()?.parse()?;
validate_participant_id(&value)?;
args.id = Some(value.value());
Ok(())
} else if meta.path.is_ident("config") {
let value: Type = meta.value()?.parse()?;
args.config = Some(value);
Ok(())
} else if meta.path.is_ident("state") {
let value: Type = meta.value()?.parse()?;
args.state = Some(value);
Ok(())
} else if meta.path.is_ident("api") {
let value: Type = meta.value()?.parse()?;
args.api = Some(value);
Ok(())
} else {
Err(meta.error(format!(
"unknown {attr_name}(...) key (expected id, config, state, or api)"
)))
}
});
parser.parse2(attr)?;
Ok(args)
}
}
pub fn expand_participant(
attr: TokenStream,
item: TokenStream,
kind: ParticipantKind,
) -> syn::Result<TokenStream> {
let item_struct: syn::ItemStruct = syn::parse2(item)?;
let struct_name = &item_struct.ident;
let attr_name = kind.attr_name();
if !item_struct.generics.params.is_empty() {
return Err(syn::Error::new_spanned(
&item_struct.generics,
format!("{attr_name} does not support generic participant structs"),
));
}
if !matches!(item_struct.fields, Fields::Unit) {
return Err(syn::Error::new_spanned(
&item_struct.fields,
format!(
"{attr_name} requires a unit marker struct; declare mutable data with `state = Type`"
),
));
}
let args = ParticipantArgs::parse(attr, attr_name)?;
let id = match args.id {
Some(id) => id,
None => {
let pkg_name = std::env::var("CARGO_PKG_NAME").map_err(|_| {
syn::Error::new_spanned(
struct_name,
format!(
"{attr_name} could not read CARGO_PKG_NAME to compute a default \
participant id (this build did not go through Cargo) - pass an \
explicit id = \"...\" instead"
),
)
})?;
let computed = default_participant_id(&pkg_name);
if !is_valid_participant_id(&computed) {
return Err(syn::Error::new_spanned(
struct_name,
format!(
"computed participant id '{computed}' (from crate `{pkg_name}`, with any \
leading phoxal-<kind>- prefix stripped) is invalid: must be non-empty \
and contain only lowercase ASCII letters, digits, '_' or '-' - pass an \
explicit id = \"...\" instead"
),
));
}
computed
}
};
let config_ty: Type = args.config.unwrap_or_else(|| syn::parse_quote!(()));
let state_ty: Type = args.state.unwrap_or_else(|| syn::parse_quote!(()));
let api_ty: Type = args.api.unwrap_or_else(|| syn::parse_quote!(()));
let phoxal = phoxal();
let artifact_kind = kind.artifact_kind();
let participant_class = kind.participant_class();
let launch_policy = kind.launch_policy(&phoxal);
let marker = kind.marker_impl(&phoxal, struct_name);
let metadata_const_ident = Ident::new(
&format!(
"__PHOXAL_PARTICIPANT_META_JSON_{}",
struct_name.to_string().to_shouty_snake_case()
),
struct_name.span(),
);
let metadata_len_ident = Ident::new(
&format!(
"__PHOXAL_PARTICIPANT_META_LEN_{}",
struct_name.to_string().to_shouty_snake_case()
),
struct_name.span(),
);
let metadata_static_ident = Ident::new(
&format!(
"__PHOXAL_PARTICIPANT_META_{}",
struct_name.to_string().to_shouty_snake_case()
),
struct_name.span(),
);
let link_section = link_section_attrs();
Ok(quote! {
#item_struct
impl #phoxal::participant::ParticipantSpec for #struct_name {
const KIND: &'static str = #artifact_kind;
const PARTICIPANT_CLASS: &'static str = #participant_class;
const ID: &'static str = #id;
type LaunchPolicy = #launch_policy;
type Config = #config_ty;
type State = #state_ty;
type Api = #api_ty;
#[doc(hidden)]
fn __new() -> Self {
Self
}
#[doc(hidden)]
fn __retain_embedded_metadata() {
::std::hint::black_box(&#metadata_static_ident);
}
}
#marker
#[doc(hidden)]
const #metadata_const_ident: &'static str =
#phoxal::participant::api::__meta::__concatcp!(
"{\"id\":\"",
#id,
"\",\"config_schema\":",
<#config_ty as #phoxal::participant::ParticipantConfig>::SCHEMA_JSON,
"}"
);
#[doc(hidden)]
const #metadata_len_ident: usize = #metadata_const_ident.len();
#link_section
#[doc(hidden)]
static #metadata_static_ident: [u8; #metadata_len_ident] =
#phoxal::participant::api::__meta::__bytes_of(#metadata_const_ident);
})
}
#[cfg(test)]
mod tests {
use super::*;
use quote::quote;
fn compact_tokens(tokens: TokenStream) -> String {
tokens
.to_string()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
#[test]
fn default_participant_id_strips_the_kind_prefix_for_every_official_directory() {
assert_eq!(default_participant_id("phoxal-service-drive"), "drive");
assert_eq!(default_participant_id("phoxal-driver-bno085"), "bno085");
assert_eq!(default_participant_id("phoxal-tool-bus"), "bus");
assert_eq!(
default_participant_id("phoxal-simulator-webots-controller"),
"webots-controller"
);
assert_eq!(
default_participant_id("phoxal-component-ddsm115"),
"ddsm115"
);
assert_eq!(
default_participant_id("phoxal-infrastructure-router"),
"router"
);
}
#[test]
fn default_participant_id_passes_through_an_unprefixed_package_name() {
assert_eq!(default_participant_id("avoid"), "avoid");
}
#[test]
fn default_participant_id_keeps_the_full_name_when_stripping_would_empty_it() {
assert_eq!(default_participant_id("phoxal-service-"), "phoxal-service-");
}
#[test]
fn an_invalid_computed_id_fails_the_same_grammar_check_expand_participant_applies() {
let computed = default_participant_id("MyRobot");
assert_eq!(computed, "MyRobot");
assert!(!is_valid_participant_id(&computed));
}
#[test]
fn omitted_id_defaults_to_this_crate_s_own_package_name() {
let expanded = compact_tokens(
expand_participant(
quote! {},
quote! { struct OmittedId; },
ParticipantKind::Tool,
)
.expect("expands with a defaulted id"),
);
assert!(
expanded.contains("const ID : & 'static str = \"phoxal-macros\""),
"the default id must come from CARGO_PKG_NAME, not the struct name \
(`omitted-id`, the old default, must NOT appear): {expanded}"
);
}
#[test]
fn an_explicit_id_overrides_the_package_name_default() {
let expanded = compact_tokens(
expand_participant(
quote! { id = "custom-id" },
quote! { struct ExplicitId; },
ParticipantKind::Tool,
)
.expect("expands with the explicit id"),
);
assert!(
expanded.contains("const ID : & 'static str = \"custom-id\""),
"an explicit id = \"...\" must win over CARGO_PKG_NAME: {expanded}"
);
}
#[test]
fn expand_participant_emits_a_black_box_read_of_its_own_metadata_static() {
let expanded = compact_tokens(
expand_participant(quote! {}, quote! { struct Probe; }, ParticipantKind::Tool)
.expect("expands"),
);
assert!(
expanded.contains("fn __retain_embedded_metadata () { :: std :: hint :: black_box (& __PHOXAL_PARTICIPANT_META_PROBE) ; }"),
"expected a black_box read of the participant's own metadata static: {expanded}"
);
}
}