use std::{
collections::{HashMap, HashSet},
env, fs,
path::{Path, PathBuf},
};
use heck::{ToKebabCase, ToSnakeCase};
use miden_assembly_syntax::ast::{Path as MasmPath, PathComponent};
use miden_mast_package::{Package, PackageExport};
use miden_protocol::utils::serde::Deserializable;
use proc_macro2::{Span, TokenStream as TokenStream2};
use quote::{ToTokens, quote};
use syn::{
Attribute, Error, File, ImplItem, ImplItemFn, Item, ItemFn, ItemImpl, ItemStruct, ReturnType,
parse_quote,
};
use wit_bindgen_core::wit_parser::{
Docs, Function, FunctionKind, InterfaceId, Param, Resolve, Span as WitSpan, Type as WitType,
WorldId, WorldItem,
};
#[cfg(test)]
use crate::wit_world::DependencyInterface;
use crate::{
generate::{
collect_arg_idents, format_module_path, qualify_signature_types, should_generate_struct,
},
wit_world::SelectedDependency,
};
pub(crate) const WIT_FUNCTION_PREFIX: &str = "fpi-";
pub(crate) const RUST_FUNCTION_PREFIX: &str = "fpi_";
const NEW_METHOD: &str = "new";
const ACTIVE_ACCOUNT_METHODS: &[&str] = &[
"get_id",
"get_nonce",
"get_initial_commitment",
"compute_commitment",
"get_code_commitment",
"get_initial_storage_commitment",
"compute_storage_commitment",
"get_asset",
"get_initial_asset",
"get_balance",
"get_initial_balance",
"has_non_fungible_asset",
"get_initial_vault_root",
"get_vault_root",
"get_num_procedures",
"get_procedure_root",
"has_procedure",
];
pub(crate) fn inject_imports(
resolve: &mut Resolve,
world_id: WorldId,
dependency_imports: &[String],
) -> syn::Result<()> {
if dependency_imports.is_empty() {
return Ok(());
}
let dependency_imports = dependency_imports.iter().map(String::as_str).collect::<HashSet<_>>();
let imported_interfaces = resolve.worlds[world_id]
.imports
.values()
.filter_map(|item| match item {
WorldItem::Interface { id, .. } => Some(*id),
_ => None,
})
.filter(|id| {
interface_import_path(resolve, *id)
.as_ref()
.is_some_and(|path| dependency_imports.contains(path.as_str()))
})
.collect::<Vec<_>>();
if imported_interfaces.is_empty() {
return Ok(());
}
for interface_id in &imported_interfaces {
let import = interface_import_path(resolve, *interface_id)
.unwrap_or_else(|| "<unknown interface>".to_string());
validate_reserved_fpi_namespace(
&import,
resolve.interfaces[*interface_id].functions.values(),
)?;
}
let core_types = resolve_core_types(resolve)?;
for interface_id in imported_interfaces {
inject_functions_into_interface(resolve, interface_id, core_types);
}
Ok(())
}
pub(crate) fn is_function(func: &ItemFn) -> bool {
matches!(func.vis, syn::Visibility::Public(_))
&& func.sig.unsafety.is_none()
&& func.sig.ident.to_string().starts_with(RUST_FUNCTION_PREFIX)
}
pub(crate) fn is_plain_import_function(func: &ItemFn) -> bool {
matches!(func.vis, syn::Visibility::Public(_))
&& func.sig.unsafety.is_none()
&& !func.sig.ident.to_string().starts_with(RUST_FUNCTION_PREFIX)
}
#[derive(Clone, Copy)]
struct CoreTypes {
felt: WitType,
word: WitType,
}
pub(crate) struct Module {
pub(crate) module_path: Vec<syn::Ident>,
pub(crate) path_string: String,
pub(crate) functions: Vec<ItemFn>,
}
struct Dependency {
module_path: String,
package_path: PathBuf,
import: String,
roots: HashMap<ProcedureRootKey, ProcedureRoot>,
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
struct ProcedureRootKey {
interface: String,
function: String,
}
#[derive(Clone, Copy)]
struct ProcedureRoot {
felts: [u64; 4],
}
impl ProcedureRootKey {
fn new(interface: impl Into<String>, function: impl Into<String>) -> Self {
Self {
interface: interface.into(),
function: function.into(),
}
}
}
fn interface_import_path(resolve: &Resolve, interface_id: InterfaceId) -> Option<String> {
let interface = &resolve.interfaces[interface_id];
let interface_name = interface.name.as_deref()?;
let package_id = interface.package?;
Some(resolve.packages[package_id].name.interface_id(interface_name))
}
fn resolve_core_types(resolve: &Resolve) -> syn::Result<CoreTypes> {
let core_types = resolve
.packages
.iter()
.find_map(|(_, package)| {
if package.name.namespace != "miden" || package.name.name != "base" {
return None;
}
package.interfaces.get("core-types").map(|interface_id| {
let interface = &resolve.interfaces[*interface_id];
(interface.types.get("felt").copied(), interface.types.get("word").copied())
})
})
.ok_or_else(|| {
Error::new(
Span::call_site(),
"failed to resolve miden:base/core-types package for FPI imports",
)
})?;
let (Some(felt), Some(word)) = core_types else {
return Err(Error::new(
Span::call_site(),
"miden:base/core-types is missing felt or word type definitions",
));
};
Ok(CoreTypes {
felt: WitType::Id(felt),
word: WitType::Id(word),
})
}
fn inject_functions_into_interface(
resolve: &mut Resolve,
interface_id: InterfaceId,
core_types: CoreTypes,
) {
let interface = &mut resolve.interfaces[interface_id];
let functions = interface
.functions
.values()
.filter(|function| {
matches!(function.kind, FunctionKind::Freestanding)
&& !function.name.starts_with(WIT_FUNCTION_PREFIX)
})
.cloned()
.collect::<Vec<_>>();
for function in functions {
let fpi_name = format!("{WIT_FUNCTION_PREFIX}{}", function.name);
if interface.functions.contains_key(&fpi_name) {
continue;
}
interface
.functions
.insert(fpi_name.clone(), build_import_function(function, fpi_name, core_types));
}
}
fn validate_reserved_fpi_namespace<'a>(
import: &str,
functions: impl IntoIterator<Item = &'a Function>,
) -> syn::Result<()> {
if let Some(function) = functions
.into_iter()
.find(|function| function.name.starts_with(WIT_FUNCTION_PREFIX))
{
return Err(Error::new(
Span::call_site(),
format!(
"dependency interface `{import}` defines function `{}` with reserved FPI prefix \
`{}`; generated FPI imports use WIT names starting with `{}` and Rust names \
starting with `{}`, so dependency functions must use a different name",
function.name, WIT_FUNCTION_PREFIX, WIT_FUNCTION_PREFIX, RUST_FUNCTION_PREFIX
),
));
}
Ok(())
}
fn build_import_function(function: Function, fpi_name: String, core_types: CoreTypes) -> Function {
let mut params = Vec::with_capacity(function.params.len() + 3);
params.push(Param {
name: "account-id-prefix".to_string(),
ty: core_types.felt,
span: WitSpan::default(),
});
params.push(Param {
name: "account-id-suffix".to_string(),
ty: core_types.felt,
span: WitSpan::default(),
});
params.push(Param {
name: "foreign-proc-root".to_string(),
ty: core_types.word,
span: WitSpan::default(),
});
params.extend(function.params);
Function {
name: fpi_name,
kind: FunctionKind::Freestanding,
params,
result: function.result,
docs: Docs::default(),
stability: function.stability,
span: WitSpan::default(),
}
}
pub(crate) fn collect_import_modules(
items: &[Item],
filter: &dyn Fn(&ItemFn) -> bool,
) -> syn::Result<Vec<Module>> {
let mut modules = Vec::new();
collect_modules(items, &mut Vec::new(), &mut modules, filter)?;
Ok(modules)
}
fn collect_modules(
items: &[Item],
path: &mut Vec<syn::Ident>,
modules_out: &mut Vec<Module>,
filter: &dyn Fn(&ItemFn) -> bool,
) -> syn::Result<()> {
for item in items.iter() {
if let Item::Mod(module) = item {
path.push(module.ident.clone());
if let Some((_, ref content)) = module.content {
collect_modules(content, path, modules_out, filter)?;
collect_functions_from_module(content, path, modules_out, filter);
}
path.pop();
}
}
Ok(())
}
fn collect_functions_from_module(
items: &[Item],
path: &[syn::Ident],
modules_out: &mut Vec<Module>,
filter: &dyn Fn(&ItemFn) -> bool,
) {
if !should_generate_struct(path, items) {
return;
}
let functions = items
.iter()
.filter_map(|item| match item {
Item::Fn(func) if filter(func) => Some(func.clone()),
_ => None,
})
.collect::<Vec<_>>();
if functions.is_empty() {
return;
}
modules_out.push(Module {
module_path: path.to_vec(),
path_string: format_module_path(path),
functions,
});
}
pub(crate) fn augment_foreign_account_bindings(
bindings: TokenStream2,
account_struct: ItemStruct,
dependencies: Vec<SelectedDependency>,
binding_module_ident: syn::Ident,
) -> syn::Result<TokenStream2> {
let file: File = syn::parse2(bindings)?;
let modules = collect_import_modules(&file.items, &is_function)?;
if modules.is_empty() {
return Err(Error::new(
account_struct.ident.span(),
"account did not find any callable exports in the selected packages",
));
}
let dependencies =
dependencies.into_iter().map(load_dependency).collect::<syn::Result<Vec<_>>>()?;
let struct_item = foreign_account_struct(&account_struct)?;
let active_account_item = active_account_impl(&account_struct);
let mut impl_item = foreign_account_impl(&account_struct);
let mut seen_methods = HashMap::new();
let mut include_paths = Vec::new();
for module in modules {
let Some(dependency) = dependencies
.iter()
.find(|dependency| dependency.module_path == module.path_string)
else {
return Err(Error::new(
Span::call_site(),
format!(
"failed to resolve FPI dependency metadata for generated module `{}`",
module.path_string
),
));
};
if !include_paths.iter().any(|path| path == &dependency.package_path) {
include_paths.push(dependency.package_path.clone());
}
let mut signature_module_path = Vec::with_capacity(module.module_path.len() + 1);
signature_module_path.push(binding_module_ident.clone());
signature_module_path.extend(module.module_path.iter().cloned());
for func in &module.functions {
let wit_name = function_wit_name(func)?;
let root_key = ProcedureRootKey::new(dependency.import.as_str(), wit_name.as_str());
let root = dependency.roots.get(&root_key).ok_or_else(|| {
Error::new(
func.sig.ident.span(),
format!(
"failed to find procedure root for `{}#{wit_name}` in package '{}'",
dependency.import,
dependency.package_path.display()
),
)
})?;
let method = build_wrapper_method(
func,
&signature_module_path,
quote!(#binding_module_ident),
&module.module_path,
*root,
)?;
let method_name = method.sig.ident.to_string();
if let Some(existing_path) = seen_methods.get(&method_name) {
return Err(Error::new(
method.sig.ident.span(),
format!(
"account method name collision on `{method_name}`: generated from both \
`{existing_path}` and `{}`",
module.path_string
),
));
}
seen_methods.insert(method_name, module.path_string.clone());
impl_item.items.push(ImplItem::Fn(method));
}
}
let bindings = file.into_token_stream();
let package_includes = include_paths
.into_iter()
.map(|path| {
let utf8_path = path.to_str().ok_or_else(|| {
Error::new(
Span::call_site(),
format!("path '{}' contains invalid UTF-8", path.display()),
)
})?;
Ok(quote! {
const _: &[u8] = include_bytes!(#utf8_path);
})
})
.collect::<syn::Result<Vec<_>>>()?;
Ok(quote! {
#[doc(hidden)]
#[allow(dead_code)]
pub mod #binding_module_ident {
#bindings
}
#struct_item
#impl_item
#active_account_item
#(#package_includes)*
})
}
fn build_wrapper_method(
func: &ItemFn,
signature_module_path: &[syn::Ident],
call_base_path: TokenStream2,
call_module_path: &[syn::Ident],
procedure_root: ProcedureRoot,
) -> syn::Result<ImplItemFn> {
let foreign_fn_ident = func.sig.ident.clone();
let native_fn_ident = method_ident(func)?;
let mut sig = func.sig.clone();
sig.ident = native_fn_ident.clone();
if sig.inputs.len() < 3 {
return Err(Error::new(
sig.ident.span(),
"generated FPI function is missing account id and procedure root parameters",
));
}
let retained_inputs = sig.inputs.iter().skip(3).cloned().collect::<Vec<_>>();
sig.inputs.clear();
sig.inputs.push(parse_quote!(&self));
sig.inputs.extend(retained_inputs);
qualify_signature_types(&mut sig, signature_module_path);
let arg_idents = collect_arg_idents(func)?.into_iter().skip(3).collect::<Vec<_>>();
let root_tokens = procedure_root_tokens(procedure_root);
let mut path_tokens = call_base_path;
for ident in call_module_path {
path_tokens = quote! { #path_tokens :: #ident };
}
let foreign_call = quote! {
#path_tokens :: #foreign_fn_ident(
__miden_foreign_account_id.prefix,
__miden_foreign_account_id.suffix,
#root_tokens,
#(#arg_idents),*
)
};
let native_call = quote! {
#path_tokens :: #native_fn_ident(#(#arg_idents),*)
};
let dispatch = quote! {
match self.foreign_account_id {
::core::option::Option::Some(__miden_foreign_account_id) => { #foreign_call }
::core::option::Option::None => { #native_call }
}
};
let method_doc = format!(
"Invokes `{}` on the native account, or through `execute_foreign_procedure` when this \
binding targets a foreign account.",
native_fn_ident.to_string().to_kebab_case()
);
let doc_attr: Attribute = parse_quote!(#[doc = #method_doc]);
let inline_attr: Attribute = parse_quote!(#[inline(always)]);
let body_tokens = match &sig.output {
ReturnType::Default => quote!({ #dispatch; }),
_ => quote!({ #dispatch }),
};
let block = syn::parse2(body_tokens)?;
Ok(ImplItemFn {
attrs: vec![doc_attr, inline_attr],
vis: func.vis.clone(),
defaultness: None,
sig,
block,
})
}
fn foreign_account_struct(account_struct: &ItemStruct) -> syn::Result<ItemStruct> {
let attrs = &account_struct.attrs;
let vis = &account_struct.vis;
let ident = &account_struct.ident;
let derive_default =
(!user_derived_names(attrs).contains("Default")).then(|| quote!(#[derive(Default)]));
syn::parse2(quote! {
#(#attrs)*
#derive_default
#vis struct #ident {
foreign_account_id: ::core::option::Option<::miden::AccountId>,
}
})
}
fn user_derived_names(attrs: &[Attribute]) -> HashSet<String> {
attrs
.iter()
.filter(|attr| attr.path().is_ident("derive"))
.filter_map(|attr| {
attr.parse_args_with(
syn::punctuated::Punctuated::<syn::Path, syn::Token![,]>::parse_terminated,
)
.ok()
})
.flatten()
.filter_map(|path| path.segments.last().map(|segment| segment.ident.to_string()))
.collect()
}
fn foreign_account_impl(account_struct: &ItemStruct) -> ItemImpl {
let ident = &account_struct.ident;
parse_quote! {
impl #ident {
#[inline(always)]
pub fn new(account_id: ::miden::AccountId) -> Self {
Self {
foreign_account_id: ::core::option::Option::Some(account_id),
}
}
}
}
}
fn active_account_impl(account_struct: &ItemStruct) -> TokenStream2 {
let ident = &account_struct.ident;
let message = format!(
"active-account operation called on `{ident}` while it is bound to a foreign account; \
active-account methods are only valid for the transaction's active account"
);
quote! {
impl ::miden::active_account::ActiveAccount for #ident {
#[inline(always)]
fn __assert_active_account(&self) {
if self.foreign_account_id.is_some() {
::core::panic!(#message);
}
}
}
impl ::miden::active_account::AccountWrapper for #ident {}
}
}
fn method_ident(func: &ItemFn) -> syn::Result<syn::Ident> {
let fn_name = func.sig.ident.to_string();
let Some(method_name) = fn_name.strip_prefix(RUST_FUNCTION_PREFIX) else {
return Err(Error::new(
func.sig.ident.span(),
format!(
"expected generated FPI function name to start with `{}`",
RUST_FUNCTION_PREFIX
),
));
};
if method_name == NEW_METHOD {
return Err(Error::new(
func.sig.ident.span(),
format!(
"generated FPI function `{fn_name}` maps to reserved wrapper method \
`{NEW_METHOD}`; dependency functions must not be named `new`"
),
));
}
if ACTIVE_ACCOUNT_METHODS.contains(&method_name) {
return Err(Error::new(
func.sig.ident.span(),
format!(
"dependency function `{}` collides with the built-in `ActiveAccount` method \
`{method_name}`; the generated wrapper method would shadow it. Rename the \
dependency function",
method_name.to_kebab_case()
),
));
}
Ok(syn::Ident::new(method_name, func.sig.ident.span()))
}
fn function_wit_name(func: &ItemFn) -> syn::Result<String> {
Ok(method_ident(func)?.to_string().to_kebab_case())
}
fn procedure_root_tokens(root: ProcedureRoot) -> TokenStream2 {
let felts = root.felts.into_iter().map(|value| quote!(::miden::felt!(#value)));
quote!(::miden::Word::new([#(#felts),*]))
}
fn load_dependency(dependency: SelectedDependency) -> syn::Result<Dependency> {
let import = dependency.import().to_owned();
let module_path = import_module_path(&import);
let package_path = resolve_dependency_package_path(&dependency)?;
let package_bytes = fs::read(&package_path).map_err(|err| {
Error::new(
Span::call_site(),
format!("failed to read dependency package '{}': {err}", package_path.display()),
)
})?;
let package = Package::read_from_bytes(&package_bytes).map_err(|err| {
Error::new(
Span::call_site(),
format!("failed to deserialize dependency package '{}': {err}", package_path.display()),
)
})?;
let mut roots = HashMap::new();
for export in package.manifest.exports() {
let PackageExport::Procedure(proc_export) = export else {
continue;
};
let Some(root_key) = procedure_root_key_from_export_path(proc_export.path.as_ref()) else {
continue;
};
if root_key.interface != import {
continue;
}
roots.insert(root_key, procedure_root_from_digest(&proc_export.digest));
}
Ok(Dependency {
module_path,
package_path,
import,
roots,
})
}
pub(crate) fn dependency_type_with_entries(
dependencies: &[SelectedDependency],
) -> Vec<(String, wit_bindgen_rust::WithOption)> {
use heck::ToUpperCamelCase;
dependencies
.iter()
.flat_map(|dependency| {
let import = dependency.import();
let module_path = import_module_path(import);
dependency.type_names().iter().map(move |wit_type| {
(
format!("{import}/{wit_type}"),
wit_bindgen_rust::WithOption::Path(format!(
"crate::bindings::{}::{}",
module_path,
wit_type.to_upper_camel_case()
)),
)
})
})
.collect()
}
pub(crate) fn import_module_path(import: &str) -> String {
let without_version = import.split('@').next().unwrap_or(import);
without_version
.split([':', '/'])
.filter(|segment| !segment.is_empty())
.map(|segment| segment.to_snake_case())
.collect::<Vec<_>>()
.join("::")
}
fn resolve_dependency_package_path(dependency: &SelectedDependency) -> syn::Result<PathBuf> {
if dependency.root.is_file() {
return Ok(dependency.root.clone());
}
let preferred_profile = env::var("PROFILE").unwrap_or_else(|_| "debug".to_string());
let mut profiles = vec![preferred_profile.clone()];
if preferred_profile != "release" {
profiles.push("release".to_string());
}
if preferred_profile != "debug" {
profiles.push("debug".to_string());
}
let package_stems = dependency_package_stems(dependency);
let output_dirs = dependency_output_dirs(dependency, &profiles);
for dir in &output_dirs {
if let Some(package) = find_dependency_package_in_dir(dir, &package_stems)? {
return Ok(package.clone());
}
}
Err(Error::new(
Span::call_site(),
missing_dependency_package_message(dependency, &package_stems, &output_dirs, &profiles),
))
}
fn missing_dependency_package_message(
dependency: &SelectedDependency,
package_stems: &[String],
output_dirs: &[PathBuf],
profiles: &[String],
) -> String {
let searched = output_dirs
.iter()
.map(|dir| format!("'{}'", dir.display()))
.collect::<Vec<_>>()
.join(", ");
let expected_files = package_stems
.iter()
.flat_map(|stem| profiles.iter().map(move |profile| format!("{stem}.masp in {profile}")))
.collect::<Vec<_>>()
.join(", ");
let build_hint = dependency_build_hint(dependency);
format!(
"miden::generate! could not find a built `.masp` package for FPI dependency '{}' (import \
'{}', root '{}'). FPI wrappers need the dependency package during Rust macro expansion \
to read procedure roots. Expected one of: {expected_files}. Searched: {searched}. \
{build_hint}",
dependency.name,
dependency.import(),
dependency.root.display(),
)
}
fn dependency_build_hint(dependency: &SelectedDependency) -> String {
let manifest_path = dependency.root.join("Cargo.toml");
if manifest_path.is_file() {
format!(
"Build the dependency first with `cargo miden build --manifest-path {} --release`, or \
persist the compiled package to '{}/target/miden/<profile>' before compiling this \
crate.",
manifest_path.display(),
dependency.root.display(),
)
} else {
format!(
"Build the dependency first with `cargo miden build`, or persist the compiled package \
to '{}/target/miden/<profile>' before compiling this crate.",
dependency.root.display(),
)
}
}
fn dependency_output_dirs(dependency: &SelectedDependency, profiles: &[String]) -> Vec<PathBuf> {
let mut dirs = Vec::new();
push_profile_dirs(&mut dirs, dependency.root.join("target"), profiles);
push_manifest_ancestor_target_profile_dirs(&mut dirs, &dependency.root, profiles);
push_ancestor_target_profile_dirs(&mut dirs, &dependency.root, profiles);
if let Ok(target_dir) = env::var("CARGO_TARGET_DIR") {
push_profile_dirs(&mut dirs, PathBuf::from(target_dir), profiles);
}
if let Ok(out_dir) = env::var("OUT_DIR") {
for ancestor in Path::new(&out_dir).ancestors() {
push_profile_dirs(&mut dirs, ancestor.to_path_buf(), profiles);
}
}
if let Ok(current_dir) = env::current_dir() {
push_profile_dirs(&mut dirs, current_dir.join("target"), profiles);
push_manifest_ancestor_target_profile_dirs(&mut dirs, ¤t_dir, profiles);
push_ancestor_target_profile_dirs(&mut dirs, ¤t_dir, profiles);
}
dirs
}
fn push_profile_dirs(dirs: &mut Vec<PathBuf>, target_root: PathBuf, profiles: &[String]) {
for profile in profiles {
let dir = target_root.join("miden").join(profile);
if !dirs.iter().any(|existing| existing == &dir) {
dirs.push(dir);
}
}
}
fn push_ancestor_target_profile_dirs(dirs: &mut Vec<PathBuf>, path: &Path, profiles: &[String]) {
for ancestor in path.ancestors() {
if ancestor.file_name().is_some_and(|name| name == "target") {
push_profile_dirs(dirs, ancestor.to_path_buf(), profiles);
}
}
}
fn push_manifest_ancestor_target_profile_dirs(
dirs: &mut Vec<PathBuf>,
path: &Path,
profiles: &[String],
) {
for ancestor in path.ancestors() {
if ancestor.join("Cargo.toml").is_file() || ancestor.join("Cargo.lock").is_file() {
push_profile_dirs(dirs, ancestor.join("target"), profiles);
}
}
}
fn find_dependency_package_in_dir(
dir: &Path,
package_stems: &[String],
) -> syn::Result<Option<PathBuf>> {
if !dir.is_dir() {
return Ok(None);
}
let mut packages = fs::read_dir(dir)
.map_err(|err| {
Error::new(
Span::call_site(),
format!("failed to read dependency output directory '{}': {err}", dir.display()),
)
})?
.collect::<Result<Vec<_>, _>>()
.map_err(|err| {
Error::new(
Span::call_site(),
format!("failed to iterate dependency output directory '{}': {err}", dir.display()),
)
})?
.into_iter()
.map(|entry| entry.path())
.filter(|path| path.extension().is_some_and(|ext| ext == "masp"))
.collect::<Vec<_>>();
packages.sort();
for stem in package_stems {
if let Some(package) = packages.iter().find(|path| {
path.file_stem()
.and_then(|value| value.to_str())
.is_some_and(|file_stem| file_stem == stem)
}) {
return Ok(Some(package.clone()));
}
}
Ok((packages.len() == 1).then(|| packages[0].clone()))
}
fn dependency_package_stems(dependency: &SelectedDependency) -> Vec<String> {
let mut stems = Vec::new();
if let Some(package_name) = dependency_manifest_package_name(&dependency.root) {
push_dependency_stem(&mut stems, &package_name);
}
if let Some(name) = dependency.name.split([':', '/']).next_back() {
push_dependency_stem(&mut stems, name);
}
if let Some(name) = dependency.root.file_name().and_then(|name| name.to_str()) {
push_dependency_stem(&mut stems, name);
}
stems
}
fn dependency_manifest_package_name(root: &Path) -> Option<String> {
let manifest_path = root.join("Cargo.toml");
let manifest = fs::read_to_string(manifest_path).ok()?;
let manifest = manifest.parse::<toml::Table>().ok()?;
manifest
.get("package")
.and_then(toml::Value::as_table)
.and_then(|package| package.get("name"))
.and_then(toml::Value::as_str)
.map(ToOwned::to_owned)
}
fn push_dependency_stem(stems: &mut Vec<String>, name: &str) {
if !name.is_empty() && !stems.iter().any(|existing| existing == name) {
stems.push(name.to_owned());
}
let normalized = name.replace('-', "_");
if !normalized.is_empty() && !stems.iter().any(|existing| existing == &normalized) {
stems.push(normalized);
}
}
fn procedure_root_key_from_export_path(path: &MasmPath) -> Option<ProcedureRootKey> {
let interface = single_non_root_path_component(path.parent()?)?;
let function = path.last()?;
Some(ProcedureRootKey::new(interface, function))
}
fn single_non_root_path_component(path: &MasmPath) -> Option<&str> {
let mut component = None;
for next in path.components() {
let next = next.ok()?;
match next {
PathComponent::Root => continue,
PathComponent::Normal(_) => {
if component.replace(next.as_str()).is_some() {
return None;
}
}
}
}
component
}
fn procedure_root_from_digest(digest: &miden_protocol::Word) -> ProcedureRoot {
let elements = digest.as_elements();
ProcedureRoot {
felts: [
elements[0].as_canonical_u64(),
elements[1].as_canonical_u64(),
elements[2].as_canonical_u64(),
elements[3].as_canonical_u64(),
],
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn procedure_root_key_uses_full_wit_interface_component() {
let path =
MasmPath::validate(r#"::"miden:no-arg-account/no-arg-account@0.0.1"::"get-count""#)
.expect("fixture path must be valid");
let key = procedure_root_key_from_export_path(path)
.expect("WIT export path must produce a procedure root key");
assert_eq!(
key,
ProcedureRootKey::new("miden:no-arg-account/no-arg-account@0.0.1", "get-count")
);
}
#[test]
fn dependency_stem_preserves_package_filename_before_legacy_alias() {
let mut stems = Vec::new();
push_dependency_stem(&mut stems, "no-arg-account");
assert_eq!(stems, ["no-arg-account", "no_arg_account"]);
}
#[test]
fn dependency_output_dirs_include_manifest_ancestor_targets() {
let temp_root = env::temp_dir()
.join(format!("midenc-fpi-dependency-output-dirs-{}", std::process::id()));
let workspace_root = temp_root.join("workspace");
let dependency_root = workspace_root.join("tests/fixtures/dependency");
std::fs::create_dir_all(&dependency_root).unwrap();
std::fs::write(workspace_root.join("Cargo.lock"), "").unwrap();
std::fs::write(dependency_root.join("Cargo.toml"), "").unwrap();
let mut dirs = Vec::new();
push_manifest_ancestor_target_profile_dirs(
&mut dirs,
&dependency_root,
&[String::from("release")],
);
assert_eq!(dirs[0], dependency_root.join("target/miden/release"));
assert!(
dirs.contains(&workspace_root.join("target/miden/release")),
"expected workspace target in {dirs:?}"
);
std::fs::remove_dir_all(temp_root).unwrap();
}
#[test]
fn missing_dependency_package_message_explains_macro_time_requirement() {
let temp_root =
env::temp_dir().join(format!("midenc-fpi-missing-package-{}", std::process::id()));
std::fs::create_dir_all(&temp_root).unwrap();
std::fs::write(temp_root.join("Cargo.toml"), "[package]\nname = \"counter\"\n").unwrap();
let dependency = SelectedDependency {
name: "counter".to_string(),
root: temp_root.clone(),
interface: DependencyInterface {
name: "counter".to_string(),
import: "miden:counter/counter@0.0.1".to_string(),
types: Vec::new(),
},
};
let profiles = vec!["release".to_string(), "debug".to_string()];
let stems = vec!["counter".to_string(), "counter_component".to_string()];
let output_dirs =
vec![temp_root.join("target/miden/release"), temp_root.join("target/miden/debug")];
let message =
missing_dependency_package_message(&dependency, &stems, &output_dirs, &profiles);
assert!(message.contains("miden::generate! could not find a built `.masp` package"));
assert!(message.contains("FPI wrappers need the dependency package during Rust macro"));
assert!(message.contains("counter.masp in release"));
assert!(message.contains("counter_component.masp in debug"));
assert!(message.contains("cargo miden build --manifest-path"));
assert!(message.contains(&temp_root.display().to_string()));
std::fs::remove_dir_all(temp_root).unwrap();
}
#[test]
fn procedure_root_key_rejects_nested_non_wit_export_path() {
let path = MasmPath::validate(
r#"::"miden:no-arg-note/no-arg-note@0.0.1"::no_arg_note::cabi_realloc"#,
)
.expect("fixture path must be valid");
assert_eq!(procedure_root_key_from_export_path(path), None);
}
#[test]
fn procedure_root_key_separates_same_function_in_different_interfaces() {
let first = ProcedureRootKey::new("miden:foo/account@0.0.1", "get-count");
let second = ProcedureRootKey::new("miden:foo/account-admin@0.0.1", "get-count");
assert_ne!(first, second);
}
#[test]
fn reserved_fpi_namespace_allows_regular_dependency_functions() {
let functions = [test_function("get-count")];
validate_reserved_fpi_namespace("miden:counter/counter@0.0.1", functions.iter())
.expect("regular dependency function names must be allowed");
}
#[test]
fn reserved_fpi_namespace_rejects_real_fpi_prefixed_functions() {
let functions = [test_function("fpi-get-count")];
let err = validate_reserved_fpi_namespace("miden:counter/counter@0.0.1", functions.iter())
.expect_err("real dependency functions must not use the generated FPI prefix");
let message = err.to_string();
assert!(message.contains("miden:counter/counter@0.0.1"), "unexpected error: {message}");
assert!(message.contains("fpi-get-count"), "unexpected error: {message}");
assert!(message.contains("reserved FPI prefix `fpi-`"), "unexpected error: {message}");
assert!(message.contains("starting with `fpi_`"), "unexpected error: {message}");
}
#[test]
fn method_ident_rejects_reserved_new() {
let func: ItemFn = parse_quote! {
pub fn fpi_new(
account_id_prefix: ::miden::Felt,
account_id_suffix: ::miden::Felt,
foreign_proc_root: ::miden::Word,
) {}
};
let err = method_ident(&func)
.expect_err("FPI methods must not collide with the generated constructor");
let message = err.to_string();
assert!(message.contains("reserved wrapper method `new`"));
assert!(message.contains("must not be named `new`"));
}
#[test]
fn foreign_account_struct_derives_only_default() {
let marker: ItemStruct = parse_quote! {
#[derive(Clone, PartialEq)]
struct Wallet;
};
let expanded = foreign_account_struct(&marker).unwrap();
let rendered = expanded.to_token_stream().to_string();
for kept in ["Clone", "PartialEq"] {
assert_eq!(rendered.matches(kept).count(), 1, "expected `{kept}` kept: {rendered}");
}
assert_eq!(rendered.matches("Default").count(), 1, "expected added `Default`: {rendered}");
for absent in ["Copy", "Debug"] {
assert_eq!(
rendered.matches(absent).count(),
0,
"macro must not derive `{absent}`: {rendered}"
);
}
}
#[test]
fn foreign_account_struct_skips_duplicate_default_derive() {
let marker: ItemStruct = parse_quote! {
#[derive(Default)]
struct Wallet;
};
let expanded = foreign_account_struct(&marker).unwrap();
let rendered = expanded.to_token_stream().to_string();
assert_eq!(
rendered.matches("Default").count(),
1,
"expected exactly one `Default` in expansion: {rendered}"
);
}
#[test]
fn method_ident_rejects_active_account_collision() {
let func: ItemFn = parse_quote! {
pub fn fpi_get_id(
account_id_prefix: ::miden::Felt,
account_id_suffix: ::miden::Felt,
foreign_proc_root: ::miden::Word,
) {}
};
let err =
method_ident(&func).expect_err("FPI methods must not shadow `ActiveAccount` built-ins");
let message = err.to_string();
assert!(message.contains("`get-id`"), "unexpected error: {message}");
assert!(
message.contains("`ActiveAccount` method `get_id`"),
"unexpected error: {message}"
);
}
fn test_function(name: &str) -> Function {
Function {
name: name.to_string(),
kind: FunctionKind::Freestanding,
params: Vec::new(),
result: None,
docs: Docs::default(),
stability: Default::default(),
span: WitSpan::default(),
}
}
}