use proc_macro2::TokenStream;
use quote::{format_ident, quote, ToTokens};
use sha2::{Digest, Sha256};
use syn::{parse::Parser, parse2, Attribute, Field, Fields, ItemStruct, LitInt, LitStr, Result};
#[derive(Clone)]
struct StateOptions {
disc: Option<u8>,
version: u8,
dynamic_tail: Option<syn::Type>,
dynamic_tail_schema: Option<String>,
}
impl Default for StateOptions {
fn default() -> Self {
Self {
disc: None,
version: 1,
dynamic_tail: None,
dynamic_tail_schema: None,
}
}
}
#[derive(Default, Clone)]
struct FieldMeta {
role: String,
invariant: String,
}
pub fn expand(attr: TokenStream, item: TokenStream) -> Result<TokenStream> {
let options = parse_state_options(attr)?;
let dynamic_tail = options.dynamic_tail.clone();
let mut input: ItemStruct = parse2(item)?;
let name = input.ident.clone();
let vis = input.vis.clone();
if !has_repr_c(&input.attrs) {
return Err(syn::Error::new_spanned(
&input,
"hopper_state requires #[repr(C)] so segment offsets and typed loads stay stable",
));
}
if !matches!(input.fields, Fields::Named(_)) {
return Err(syn::Error::new_spanned(
&input,
"hopper_state requires a struct with named fields",
));
}
let mut segment_entries = Vec::new();
let mut module_items = Vec::new();
let mut inherent_items = Vec::new();
let mut field_name_literals = Vec::new();
let mut field_type_literals = Vec::new();
let mut field_types = Vec::new();
let mut field_intent_tokens: Vec<TokenStream> = Vec::new();
let mut field_role_literals: Vec<LitStr> = Vec::new();
let mut field_invariant_literals: Vec<LitStr> = Vec::new();
let mut running_offset = quote! { 0u32 };
let struct_name_upper = to_screaming_snake(&name.to_string());
let field_metas: Vec<FieldMeta> = match &mut input.fields {
Fields::Named(named) => {
let mut out = Vec::with_capacity(named.named.len());
for field in named.named.iter_mut() {
let meta = parse_field_meta(field)?;
strip_hopper_field_attrs(field);
out.push(meta);
}
out
}
_ => unreachable!("checked above"),
};
let fields = match &input.fields {
Fields::Named(f) => &f.named,
_ => unreachable!("checked above"),
};
for (field, meta) in fields.iter().zip(field_metas.iter()) {
let field_name = field.ident.as_ref().unwrap();
let field_name_str = field_name.to_string();
let field_ty = &field.ty;
let field_name_upper = to_screaming_snake(&field_name_str);
let current_offset = running_offset.clone();
field_name_literals.push(syn::LitStr::new(&field_name_str, field_name.span()));
field_type_literals.push(syn::LitStr::new(
&field_ty.to_token_stream().to_string().replace(' ', ""),
field_name.span(),
));
field_types.push(field_ty.clone());
field_intent_tokens.push(role_to_intent_tokens(&meta.role, field_name.span())?);
field_role_literals.push(LitStr::new(&meta.role, field_name.span()));
field_invariant_literals.push(LitStr::new(&meta.invariant, field_name.span()));
segment_entries.push(quote! {
::hopper::hopper_core::segment_map::StaticSegment::new(
#field_name_str,
#current_offset,
core::mem::size_of::<#field_ty>() as u32,
)
});
let const_name = format_ident!("{}_{}_OFFSET", struct_name_upper, field_name_upper);
let const_abs_name = format_ident!("{}_{}_ABS_OFFSET", struct_name_upper, field_name_upper);
let const_size_name = format_ident!("{}_{}_SIZE", struct_name_upper, field_name_upper);
let const_type_name = format_ident!("{}_{}_TYPE", struct_name_upper, field_name_upper);
let assoc_offset_name = format_ident!("{}_OFFSET", field_name_upper);
let assoc_abs_offset_name = format_ident!("{}_ABS_OFFSET", field_name_upper);
let assoc_size_name = format_ident!("{}_SIZE", field_name_upper);
module_items.push(quote! {
#vis const #const_name: u32 = #current_offset;
#vis const #const_abs_name: u32 =
::hopper::hopper_core::account::HEADER_LEN as u32 + #current_offset;
#vis const #const_size_name: u32 = core::mem::size_of::<#field_ty>() as u32;
#vis type #const_type_name = #field_ty;
});
inherent_items.push(quote! {
#vis const #assoc_offset_name: u32 = #current_offset;
#vis const #assoc_abs_offset_name: u32 =
::hopper::hopper_core::account::HEADER_LEN as u32 + #current_offset;
#vis const #assoc_size_name: u32 = core::mem::size_of::<#field_ty>() as u32;
});
running_offset = quote! {
#current_offset + core::mem::size_of::<#field_ty>() as u32
};
}
let body_size = running_offset.clone();
let version = options.version;
let dynamic_tail_fingerprint = options
.dynamic_tail_schema
.as_deref()
.map(str::to_owned)
.or_else(|| {
dynamic_tail
.as_ref()
.map(|ty| format!("type:{}", ty.to_token_stream().to_string().replace(' ', "")))
});
let layout_id = layout_id_bytes(&name, version, fields, dynamic_tail_fingerprint.as_deref());
let disc = options.disc.unwrap_or_else(|| {
for byte in layout_id.iter() {
if *byte != 0 {
return *byte;
}
}
1u8
});
let layout_id_tokens = byte_array_literal(&layout_id);
let field_count = field_name_literals.len();
let layout_id_hex = layout_id
.iter()
.map(|byte| format!("{:02X}", byte))
.collect::<String>();
let layout_id_anchor_ident = format_ident!(
"__HOPPER_LAYOUT_ID_ANCHOR_{}_{}",
struct_name_upper,
layout_id_hex
);
let dynamic_tail_methods = if let Some(tail_ty) = &dynamic_tail {
quote! {
pub const HAS_DYNAMIC_TAIL: bool = true;
pub const TAIL_PREFIX_OFFSET: usize = Self::LEN;
#[inline]
pub fn tail_len(data: &[u8]) -> ::core::result::Result<
u32,
::hopper::__runtime::ProgramError,
> {
::hopper::__runtime::read_tail_len(data, Self::TAIL_PREFIX_OFFSET)
}
#[inline]
pub fn tail_read(data: &[u8]) -> ::core::result::Result<
#tail_ty,
::hopper::__runtime::ProgramError,
> {
::hopper::__runtime::read_tail::<#tail_ty>(data, Self::TAIL_PREFIX_OFFSET)
}
#[inline]
pub fn tail_write(
data: &mut [u8],
tail: &#tail_ty,
) -> ::core::result::Result<
usize,
::hopper::__runtime::ProgramError,
> {
::hopper::__runtime::write_tail::<#tail_ty>(
data,
Self::TAIL_PREFIX_OFFSET,
tail,
)
}
}
} else {
quote! {
pub const HAS_DYNAMIC_TAIL: bool = false;
}
};
let state_param_inits = fields
.iter()
.map(state_field_param_init)
.collect::<Result<Vec<_>>>()?;
let constructor_params: Vec<TokenStream> = state_param_inits
.iter()
.map(|item| item.0.clone())
.collect();
let constructor_inits: Vec<TokenStream> = state_param_inits
.iter()
.map(|item| item.1.clone())
.collect();
let set_inner_assigns: Vec<TokenStream> = state_param_inits
.iter()
.map(|item| item.2.clone())
.collect();
let constructor_method = if options.dynamic_tail_schema.is_none() {
quote! {
#[inline(always)]
#vis const fn new(#(#constructor_params),*) -> Self {
Self {
#(#constructor_inits),*
}
}
}
} else {
TokenStream::new()
};
let set_inner_method = quote! {
#[inline(always)]
#vis fn set_inner(
&mut self,
#(#constructor_params),*
) -> ::core::result::Result<(), ::hopper::__runtime::ProgramError> {
#(#set_inner_assigns)*
Ok(())
}
};
let expanded = quote! {
#input
const _: () = {
assert!(
core::mem::align_of::<#name>() == 1,
"hopper_state layouts must use alignment-1 field types such as WireU64 or TypedAddress",
);
assert!(
core::mem::size_of::<#name>() == ((#body_size) as usize),
"hopper_state layouts must be #[repr(C)] with no implicit padding",
);
assert!(
core::mem::size_of::<#name>() > 0,
"hopper_state layouts must have at least one field; zero-sized overlays project to dangling pointers",
);
assert!(
#disc != 0,
"hopper_state discriminator must be non-zero: a zero discriminator cannot be distinguished from an uninitialized account",
);
};
#(#module_items)*
impl #name {
#constructor_method
#set_inner_method
#(#inherent_items)*
pub const BODY_SIZE: usize = core::mem::size_of::<Self>();
pub const LEN: usize = ::hopper::hopper_core::account::HEADER_LEN + Self::BODY_SIZE;
pub const DISC: u8 = #disc;
pub const VERSION: u8 = #version;
pub const LAYOUT_ID: [u8; 8] = #layout_id_tokens;
pub const INIT_SPACE: usize = Self::LEN;
#[inline(always)]
pub fn write_init_header(
data: &mut [u8],
) -> ::core::result::Result<(), ::hopper::__runtime::ProgramError> {
::hopper::hopper_core::account::write_header(
data,
Self::DISC,
Self::VERSION,
&Self::LAYOUT_ID,
)
}
#[inline(always)]
pub fn overlay(
data: &[u8],
) -> ::core::result::Result<&Self, ::hopper::__runtime::ProgramError> {
::hopper::hopper_core::account::pod_from_bytes::<Self>(data)
}
#[inline(always)]
pub fn overlay_mut(
data: &mut [u8],
) -> ::core::result::Result<&mut Self, ::hopper::__runtime::ProgramError> {
::hopper::hopper_core::account::pod_from_bytes_mut::<Self>(data)
}
#[inline(always)]
pub fn load<'a>(
account: &'a ::hopper::prelude::AccountView,
program_id: &::hopper::prelude::Address,
) -> ::core::result::Result<
::hopper::__runtime::Ref<'a, Self>,
::hopper::__runtime::ProgramError,
> {
account.check_owned_by(program_id)?;
account.load::<Self>()
}
#[inline(always)]
pub fn load_mut<'a>(
account: &'a ::hopper::prelude::AccountView,
program_id: &::hopper::prelude::Address,
) -> ::core::result::Result<
::hopper::__runtime::RefMut<'a, Self>,
::hopper::__runtime::ProgramError,
> {
account.check_owned_by(program_id)?.check_writable()?;
account.load_mut::<Self>()
}
#[inline(always)]
#[deprecated(since = "0.2.0", note = "renamed to load_cross_program()")]
pub fn load_foreign<'a>(
account: &'a ::hopper::prelude::AccountView,
expected_owner: &::hopper::prelude::Address,
) -> ::core::result::Result<
::hopper::__runtime::Ref<'a, Self>,
::hopper::__runtime::ProgramError,
> {
Self::load_cross_program(account, expected_owner)
}
#[inline(always)]
pub fn load_cross_program<'a>(
account: &'a ::hopper::prelude::AccountView,
expected_owner: &::hopper::prelude::Address,
) -> ::core::result::Result<
::hopper::__runtime::Ref<'a, Self>,
::hopper::__runtime::ProgramError,
> {
account.check_owned_by(expected_owner)?;
account.load::<Self>()
}
#dynamic_tail_methods
pub const FIELD_ROLES: &'static [(&'static str, &'static str)] = &[
#( (#field_name_literals, #field_role_literals) ),*
];
pub const FIELD_INVARIANTS: &'static [(&'static str, &'static str)] = &[
#( (#field_name_literals, #field_invariant_literals) ),*
];
}
#[doc(hidden)]
const _: () = {
struct __StateCopyProof<T: ::core::clone::Clone + ::core::marker::Copy>(
::core::marker::PhantomData<T>,
);
const _: __StateCopyProof<#name> =
__StateCopyProof(::core::marker::PhantomData);
};
#[doc(hidden)]
const _: () = {
struct __FieldPodProof<
T: ::hopper::__runtime::__hopper_native::bytemuck::Pod
+ ::hopper::__runtime::__hopper_native::bytemuck::Zeroable,
>(::core::marker::PhantomData<T>);
#(
#[allow(dead_code)]
const _: __FieldPodProof<#field_types> =
__FieldPodProof(::core::marker::PhantomData);
)*
};
unsafe impl ::hopper::__runtime::__hopper_native::bytemuck::Zeroable for #name {}
unsafe impl ::hopper::__runtime::__hopper_native::bytemuck::Pod for #name {}
unsafe impl ::hopper::hopper_core::account::Pod for #name {}
unsafe impl ::hopper::__runtime::__sealed::HopperZeroCopySealed for #name {}
#[used]
#[doc(hidden)]
#[no_mangle]
pub static #layout_id_anchor_ident: [u8; 8] = #name::LAYOUT_ID;
impl ::hopper::hopper_core::account::FixedLayout for #name {
const SIZE: usize = core::mem::size_of::<Self>();
}
impl ::hopper::hopper_core::segment_map::SegmentMap for #name {
const SEGMENTS: &'static [::hopper::hopper_core::segment_map::StaticSegment] = &[
#(#segment_entries),*
];
}
impl ::hopper::hopper_core::field_map::FieldMap for #name {
const FIELDS: &'static [::hopper::hopper_core::field_map::FieldInfo] = {
const FIELD_COUNT: usize = #field_count;
const NAMES: [&str; FIELD_COUNT] = [#(#field_name_literals),*];
const SIZES: [usize; FIELD_COUNT] = [#(core::mem::size_of::<#field_types>()),*];
const FIELDS: [::hopper::hopper_core::field_map::FieldInfo; FIELD_COUNT] = {
let mut result = [::hopper::hopper_core::field_map::FieldInfo::new("", 0, 0); FIELD_COUNT];
let mut offset = ::hopper::hopper_core::account::HEADER_LEN;
let mut index = 0;
while index < FIELD_COUNT {
result[index] = ::hopper::hopper_core::field_map::FieldInfo::new(
NAMES[index],
offset,
SIZES[index],
);
offset += SIZES[index];
index += 1;
}
result
};
&FIELDS
};
}
impl ::hopper::hopper_runtime::LayoutContract for #name {
const DISC: u8 = #name::DISC;
const VERSION: u8 = #name::VERSION;
const LAYOUT_ID: [u8; 8] = #name::LAYOUT_ID;
const SIZE: usize = #name::LEN;
}
impl ::hopper::hopper_core::check::modifier::HopperLayout for #name {
const DISC: u8 = #name::DISC;
const VERSION: u8 = #name::VERSION;
const LAYOUT_ID: [u8; 8] = #name::LAYOUT_ID;
const LEN_WITH_HEADER: usize = #name::LEN;
}
impl ::hopper::hopper_schema::SchemaExport for #name {
fn layout_manifest() -> ::hopper::hopper_schema::LayoutManifest {
const FIELD_COUNT: usize = #field_count;
const NAMES: [&str; FIELD_COUNT] = [#(#field_name_literals),*];
const TYPES: [&str; FIELD_COUNT] = [#(#field_type_literals),*];
const SIZES: [u16; FIELD_COUNT] = [#(core::mem::size_of::<#field_types>() as u16),*];
const INTENTS: [::hopper::hopper_schema::FieldIntent; FIELD_COUNT] = [
#(#field_intent_tokens),*
];
const FIELDS: [::hopper::hopper_schema::FieldDescriptor; FIELD_COUNT] = {
let mut result = [::hopper::hopper_schema::FieldDescriptor {
name: "",
canonical_type: "",
size: 0,
offset: 0,
intent: ::hopper::hopper_schema::FieldIntent::Custom,
}; FIELD_COUNT];
let mut offset = ::hopper::hopper_core::account::HEADER_LEN as u16;
let mut index = 0;
while index < FIELD_COUNT {
result[index] = ::hopper::hopper_schema::FieldDescriptor {
name: NAMES[index],
canonical_type: TYPES[index],
size: SIZES[index],
offset,
intent: INTENTS[index],
};
offset += SIZES[index];
index += 1;
}
result
};
::hopper::hopper_schema::LayoutManifest {
name: stringify!(#name),
version: #name::VERSION,
disc: #name::DISC,
layout_id: #name::LAYOUT_ID,
total_size: #name::LEN,
field_count: FIELD_COUNT,
fields: &FIELDS,
}
}
}
};
Ok(expanded)
}
fn parse_field_meta(field: &Field) -> Result<FieldMeta> {
let mut meta = FieldMeta::default();
for attr in &field.attrs {
if attr.path().is_ident("role") {
if let Ok(nv) = attr.meta.require_name_value() {
if let syn::Expr::Lit(syn::ExprLit {
lit: syn::Lit::Str(s),
..
}) = &nv.value
{
meta.role = s.value();
continue;
}
return Err(syn::Error::new_spanned(
&nv.value,
"#[role = \"...\"] expects a string literal (e.g. \"balance\", \"authority\")",
));
}
if let Ok(list) = attr.meta.require_list() {
let tokens: TokenStream = list.tokens.clone();
let parsed: syn::Ident = parse2(tokens).map_err(|_| {
syn::Error::new_spanned(
&list.tokens,
"#[role(...)] expects a single identifier (e.g. balance, authority)",
)
})?;
meta.role = parsed.to_string();
continue;
}
return Err(syn::Error::new_spanned(
attr,
"unsupported #[role] form; use #[role = \"balance\"] or #[role(balance)]",
));
}
if attr.path().is_ident("invariant") {
let nv = attr.meta.require_name_value().map_err(|_| {
syn::Error::new_spanned(
attr,
"#[invariant] on a field expects name-value form: #[invariant = \"balance_nonzero\"]",
)
})?;
if let syn::Expr::Lit(syn::ExprLit {
lit: syn::Lit::Str(s),
..
}) = &nv.value
{
meta.invariant = s.value();
continue;
}
return Err(syn::Error::new_spanned(
&nv.value,
"#[invariant = \"...\"] expects a string literal (the invariant name)",
));
}
}
Ok(meta)
}
fn strip_hopper_field_attrs(field: &mut Field) {
field
.attrs
.retain(|a| !a.path().is_ident("role") && !a.path().is_ident("invariant"));
}
fn role_to_intent_tokens(role: &str, span: proc_macro2::Span) -> Result<TokenStream> {
let normalized = role.to_ascii_lowercase();
let variant = match normalized.as_str() {
"" => "Custom",
"balance" => "Balance",
"authority" => "Authority",
"timestamp" => "Timestamp",
"counter" => "Counter",
"index" => "Index",
"basis_points" | "basispoints" | "bps" => "BasisPoints",
"flag" | "bool" => "Flag",
"address" | "pubkey" => "Address",
"hash" | "fingerprint" => "Hash",
"pda_seed" | "pdaseed" | "seed" => "PDASeed",
"version" => "Version",
"bump" => "Bump",
"nonce" => "Nonce",
"supply" | "total_supply" => "Supply",
"limit" | "cap" | "ceiling" => "Limit",
"threshold" => "Threshold",
"owner" => "Owner",
"delegate" => "Delegate",
"status" | "state" | "lifecycle" => "Status",
"custom" => "Custom",
other => {
return Err(syn::Error::new(
span,
format!(
"unknown #[role = \"{}\"]. accepted: balance, authority, timestamp, \
counter, index, basis_points, flag, address, hash, pda_seed, version, \
bump, nonce, supply, limit, threshold, owner, delegate, status, custom",
other
),
));
}
};
let ident = syn::Ident::new(variant, span);
Ok(quote! { ::hopper::hopper_schema::FieldIntent::#ident })
}
fn state_field_param_init(field: &Field) -> Result<(TokenStream, TokenStream, TokenStream)> {
let field_name = field
.ident
.as_ref()
.ok_or_else(|| syn::Error::new_spanned(field, "hopper_state requires named fields"))?;
let field_ty = &field.ty;
let (param_ty, init_expr) = if let Some(native_ty) = native_wire_param_type(field_ty) {
(native_ty, quote! { <#field_ty>::new(#field_name) })
} else {
(quote! { #field_ty }, quote! { #field_name })
};
Ok((
quote! { #field_name: #param_ty },
quote! { #field_name: #init_expr },
quote! { self.#field_name = #init_expr; },
))
}
fn native_wire_param_type(ty: &syn::Type) -> Option<TokenStream> {
let syn::Type::Path(type_path) = ty else {
return None;
};
let ident = type_path.path.segments.last()?.ident.to_string();
let native = match ident.as_str() {
"WireBool" => quote! { bool },
"WireU16" => quote! { u16 },
"WireU32" => quote! { u32 },
"WireU64" => quote! { u64 },
"WireU128" => quote! { u128 },
"WireI16" => quote! { i16 },
"WireI32" => quote! { i32 },
"WireI64" => quote! { i64 },
"WireI128" => quote! { i128 },
_ => return None,
};
Some(native)
}
fn parse_state_options(attr: TokenStream) -> Result<StateOptions> {
if attr.is_empty() {
return Ok(StateOptions::default());
}
let mut options = StateOptions::default();
let parser = syn::meta::parser(|meta| {
if meta.path.is_ident("disc") || meta.path.is_ident("discriminator") {
let value: LitInt = meta.value()?.parse()?;
options.disc = Some(value.base10_parse()?);
return Ok(());
}
if meta.path.is_ident("version") {
let value: LitInt = meta.value()?.parse()?;
options.version = value.base10_parse()?;
return Ok(());
}
if meta.path.is_ident("dynamic_tail") {
let ty: syn::Type = meta.value()?.parse()?;
options.dynamic_tail = Some(ty);
return Ok(());
}
if meta.path.is_ident("dynamic_tail_schema") || meta.path.is_ident("tail_schema") {
let value: LitStr = meta.value()?.parse()?;
options.dynamic_tail_schema = Some(value.value());
return Ok(());
}
Err(meta.error("unsupported hopper_state option; expected `disc = N`, `discriminator = N`, `version = N`, `dynamic_tail = T`, or `dynamic_tail_schema = \"...\"`"))
});
parser.parse2(attr)?;
Ok(options)
}
fn has_repr_c(attrs: &[Attribute]) -> bool {
attrs.iter().any(|attr| {
if !attr.path().is_ident("repr") {
return false;
}
let mut has_c = false;
let _ = attr.parse_nested_meta(|meta| {
if meta.path.is_ident("C") {
has_c = true;
}
Ok(())
});
has_c
})
}
fn layout_id_bytes(
name: &syn::Ident,
version: u8,
fields: &syn::punctuated::Punctuated<syn::Field, syn::token::Comma>,
dynamic_tail_fingerprint: Option<&str>,
) -> [u8; 8] {
let mut input = format!("hopper:wire:v2|S:{}|V:{}", name, version);
for (idx, field) in fields.iter().enumerate() {
let field_name = field.ident.as_ref().expect("named fields only");
let stem = canonical_wire_stem(&field.ty);
input.push_str(&format!("|f{}:{}:{}", idx, field_name, stem));
}
if let Some(tail) = dynamic_tail_fingerprint {
input.push_str("|tail:");
input.push_str(tail);
}
let digest = Sha256::digest(input.as_bytes());
let mut layout_id = [0u8; 8];
layout_id.copy_from_slice(&digest[..8]);
layout_id
}
fn canonical_wire_stem(ty: &syn::Type) -> String {
match ty {
syn::Type::Path(type_path) => {
if let Some(last) = type_path.path.segments.last() {
last.ident.to_string()
} else {
"unknown_path".to_string()
}
}
syn::Type::Array(arr) => {
let elem = canonical_wire_stem(&arr.elem);
let raw = arr
.len
.to_token_stream()
.to_string()
.replace(char::is_whitespace, "");
let canonical_len = strip_int_literal_suffix(&raw);
format!("arr_{}_{}", elem, canonical_len)
}
syn::Type::Tuple(tup) if tup.elems.is_empty() => "unit".to_string(),
other => other
.to_token_stream()
.to_string()
.replace(char::is_whitespace, ""),
}
}
fn byte_array_literal(bytes: &[u8; 8]) -> TokenStream {
let items = bytes.iter();
quote! { [#(#items),*] }
}
fn strip_int_literal_suffix(raw: &str) -> String {
if !raw.chars().next().map_or(false, |c| c.is_ascii_digit()) {
return raw.to_string();
}
const SUFFIXES: &[&str] = &[
"usize", "isize", "u128", "i128", "u64", "i64", "u32", "i32", "u16", "i16", "u8", "i8",
];
for suffix in SUFFIXES {
if let Some(stripped) = raw.strip_suffix(suffix) {
let before_ok = stripped
.chars()
.last()
.map_or(false, |c| c.is_ascii_digit() || c == '_');
if before_ok {
return stripped.to_string();
}
}
}
raw.to_string()
}
#[cfg(test)]
mod fingerprint_tests {
use super::*;
use syn::parse_quote;
fn fp(ty: syn::Type) -> String {
canonical_wire_stem(&ty)
}
#[test]
fn path_spelling_drift_does_not_change_stem() {
assert_eq!(fp(parse_quote!(WireU64)), fp(parse_quote!(crate::WireU64)));
assert_eq!(
fp(parse_quote!(WireU64)),
fp(parse_quote!(hopper_native::wire::WireU64)),
);
assert_eq!(fp(parse_quote!(WireU64)), fp(parse_quote!(self::WireU64)));
}
#[test]
fn phantom_generic_parameters_are_stripped() {
assert_eq!(
fp(parse_quote!(TypedAddress<Authority>)),
fp(parse_quote!(TypedAddress<Token>)),
);
assert_eq!(
fp(parse_quote!(TypedAddress<Authority>)),
fp(parse_quote!(TypedAddress)),
);
}
#[test]
fn arrays_normalize_whitespace_and_usize_suffix() {
assert_eq!(fp(parse_quote!([u8; 32])), fp(parse_quote!([u8; 32])));
assert_eq!(fp(parse_quote!([u8; 32])), fp(parse_quote!([u8; 32usize])));
}
#[test]
fn different_wire_types_still_distinguishable() {
assert_ne!(fp(parse_quote!(WireU64)), fp(parse_quote!(WireU32)));
assert_ne!(fp(parse_quote!([u8; 32])), fp(parse_quote!([u8; 64])));
assert_ne!(fp(parse_quote!(u8)), fp(parse_quote!(u16)));
}
#[test]
fn unit_and_tuple_roll_up_cleanly() {
assert_eq!(fp(parse_quote!(())), "unit");
}
}
#[cfg(test)]
mod field_attr_tests {
use super::*;
use syn::parse_quote;
fn parse_first_field(f: syn::Field) -> FieldMeta {
parse_field_meta(&f).expect("valid field meta")
}
#[test]
fn name_value_role_parses() {
let f: syn::Field = parse_quote!(
#[role = "balance"]
pub amount: WireU64
);
let m = parse_first_field(f);
assert_eq!(m.role, "balance");
assert_eq!(m.invariant, "");
}
#[test]
fn path_form_role_parses() {
let f: syn::Field = parse_quote!(
#[role(authority)]
pub owner: TypedAddress<Authority>
);
let m = parse_first_field(f);
assert_eq!(m.role, "authority");
}
#[test]
fn invariant_attr_parses() {
let f: syn::Field = parse_quote!(
#[invariant = "balance_nonzero"]
pub amount: WireU64
);
let m = parse_first_field(f);
assert_eq!(m.invariant, "balance_nonzero");
assert_eq!(m.role, "");
}
#[test]
fn both_attrs_parse_together() {
let f: syn::Field = parse_quote!(
#[role = "balance"]
#[invariant = "balance_nonzero"]
pub amount: WireU64
);
let m = parse_first_field(f);
assert_eq!(m.role, "balance");
assert_eq!(m.invariant, "balance_nonzero");
}
#[test]
fn unknown_role_is_rejected() {
let span = proc_macro2::Span::call_site();
let err = role_to_intent_tokens("authorty", span).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("unknown #[role = \"authorty\"]"),
"expected helpful error, got: {msg}",
);
assert!(
msg.contains("authority"),
"error message should suggest valid vocabulary"
);
}
#[test]
fn empty_role_maps_to_custom() {
let span = proc_macro2::Span::call_site();
let tokens = role_to_intent_tokens("", span).expect("empty role is valid");
let rendered = tokens.to_string().replace(' ', "");
assert!(
rendered.ends_with("FieldIntent::Custom"),
"empty role should render FieldIntent::Custom, got: {rendered}",
);
}
#[test]
fn role_mapping_covers_full_vocabulary() {
let expected = [
("balance", "Balance"),
("authority", "Authority"),
("timestamp", "Timestamp"),
("counter", "Counter"),
("index", "Index"),
("basis_points", "BasisPoints"),
("flag", "Flag"),
("address", "Address"),
("hash", "Hash"),
("pda_seed", "PDASeed"),
("version", "Version"),
("bump", "Bump"),
("nonce", "Nonce"),
("supply", "Supply"),
("limit", "Limit"),
("threshold", "Threshold"),
("owner", "Owner"),
("delegate", "Delegate"),
("status", "Status"),
];
let span = proc_macro2::Span::call_site();
for (role, variant) in expected {
let tokens = role_to_intent_tokens(role, span).unwrap_or_else(|e| {
panic!("role `{role}` should map to FieldIntent::{variant}: {e}")
});
let rendered = tokens.to_string().replace(' ', "");
assert!(
rendered.ends_with(&format!("FieldIntent::{variant}")),
"role `{role}` should map to FieldIntent::{variant}, got: {rendered}",
);
}
}
#[test]
fn case_variants_and_aliases_resolve() {
let span = proc_macro2::Span::call_site();
let a = role_to_intent_tokens("Balance", span).unwrap().to_string();
let b = role_to_intent_tokens("balance", span).unwrap().to_string();
assert_eq!(a, b);
let c = role_to_intent_tokens("bps", span).unwrap().to_string();
let d = role_to_intent_tokens("basis_points", span)
.unwrap()
.to_string();
assert_eq!(c, d);
let e = role_to_intent_tokens("seed", span).unwrap().to_string();
let f = role_to_intent_tokens("pda_seed", span).unwrap().to_string();
assert_eq!(e, f);
}
#[test]
fn strip_removes_hopper_attrs_but_preserves_others() {
let mut f: syn::Field = parse_quote!(
#[role = "balance"]
#[invariant = "balance_nonzero"]
#[serde(skip)]
pub amount: WireU64
);
strip_hopper_field_attrs(&mut f);
assert_eq!(f.attrs.len(), 1);
assert!(f.attrs[0].path().is_ident("serde"));
}
}
fn to_screaming_snake(s: &str) -> String {
let mut result = String::with_capacity(s.len() + 4);
for (i, c) in s.chars().enumerate() {
if c.is_uppercase() && i > 0 {
result.push('_');
}
result.push(c.to_ascii_uppercase());
}
result
}