From 55340f89e4e2196ff421d98c190fbb70edf174e1 Mon Sep 17 00:00:00 2001 From: Denys Zadorozhnyi Date: Wed, 26 Nov 2025 12:48:41 +0200 Subject: [PATCH 01/12] feature: reconstruct account `struct` from the `wit-bingen` generated bindings --- Cargo.lock | 2 + examples/auth-component-no-auth/Cargo.lock | 2 + .../auth-component-rpo-falcon512/Cargo.lock | 2 + examples/basic-wallet-tx-script/Cargo.lock | 2 + examples/basic-wallet/Cargo.lock | 2 + examples/counter-contract/Cargo.lock | 2 + examples/counter-note/Cargo.lock | 2 + examples/p2id-note/Cargo.lock | 2 + examples/p2id-note/src/lib.rs | 4 +- examples/p2ide-note/Cargo.lock | 2 + examples/storage-example/Cargo.lock | 2 + sdk/base-macros/Cargo.toml | 2 + sdk/base-macros/src/generate.rs | 672 ++++++++++++++++-- tests/rust-apps-wasm/rust-sdk/add/Cargo.lock | 2 + .../component-macros-account/Cargo.lock | 2 + .../rust-sdk/component-macros-note/Cargo.lock | 2 + .../cross-ctx-account-word-arg/Cargo.lock | 2 + .../cross-ctx-account-word/Cargo.lock | 2 + .../rust-sdk/cross-ctx-account/Cargo.lock | 2 + .../cross-ctx-note-word-arg/Cargo.lock | 2 + .../rust-sdk/cross-ctx-note-word/Cargo.lock | 2 + .../rust-sdk/cross-ctx-note/Cargo.lock | 2 + 22 files changed, 666 insertions(+), 50 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 62fa3da20..e1b1880b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2278,6 +2278,8 @@ dependencies = [ "semver 1.0.27", "syn", "toml 0.8.23", + "wit-bindgen-core", + "wit-bindgen-rust", ] [[package]] diff --git a/examples/auth-component-no-auth/Cargo.lock b/examples/auth-component-no-auth/Cargo.lock index a3106a54a..1347cb036 100644 --- a/examples/auth-component-no-auth/Cargo.lock +++ b/examples/auth-component-no-auth/Cargo.lock @@ -880,6 +880,8 @@ dependencies = [ "semver 1.0.26", "syn", "toml 0.8.23", + "wit-bindgen-core", + "wit-bindgen-rust", ] [[package]] diff --git a/examples/auth-component-rpo-falcon512/Cargo.lock b/examples/auth-component-rpo-falcon512/Cargo.lock index 69b9a03c1..f0f96bb0a 100644 --- a/examples/auth-component-rpo-falcon512/Cargo.lock +++ b/examples/auth-component-rpo-falcon512/Cargo.lock @@ -881,6 +881,8 @@ dependencies = [ "semver 1.0.27", "syn", "toml 0.8.23", + "wit-bindgen-core", + "wit-bindgen-rust", ] [[package]] diff --git a/examples/basic-wallet-tx-script/Cargo.lock b/examples/basic-wallet-tx-script/Cargo.lock index a732370d1..b61a55f38 100644 --- a/examples/basic-wallet-tx-script/Cargo.lock +++ b/examples/basic-wallet-tx-script/Cargo.lock @@ -873,6 +873,8 @@ dependencies = [ "semver 1.0.26", "syn", "toml 0.8.23", + "wit-bindgen-core", + "wit-bindgen-rust", ] [[package]] diff --git a/examples/basic-wallet/Cargo.lock b/examples/basic-wallet/Cargo.lock index b7b698953..9b9ed7fe5 100644 --- a/examples/basic-wallet/Cargo.lock +++ b/examples/basic-wallet/Cargo.lock @@ -873,6 +873,8 @@ dependencies = [ "semver 1.0.26", "syn", "toml 0.8.23", + "wit-bindgen-core", + "wit-bindgen-rust", ] [[package]] diff --git a/examples/counter-contract/Cargo.lock b/examples/counter-contract/Cargo.lock index 94ff57c22..e6f8437f5 100644 --- a/examples/counter-contract/Cargo.lock +++ b/examples/counter-contract/Cargo.lock @@ -873,6 +873,8 @@ dependencies = [ "semver 1.0.26", "syn", "toml 0.8.23", + "wit-bindgen-core", + "wit-bindgen-rust", ] [[package]] diff --git a/examples/counter-note/Cargo.lock b/examples/counter-note/Cargo.lock index 6ebef5a68..9d5bc2547 100644 --- a/examples/counter-note/Cargo.lock +++ b/examples/counter-note/Cargo.lock @@ -873,6 +873,8 @@ dependencies = [ "semver 1.0.26", "syn", "toml 0.8.23", + "wit-bindgen-core", + "wit-bindgen-rust", ] [[package]] diff --git a/examples/p2id-note/Cargo.lock b/examples/p2id-note/Cargo.lock index c5530be26..8739e9261 100644 --- a/examples/p2id-note/Cargo.lock +++ b/examples/p2id-note/Cargo.lock @@ -866,6 +866,8 @@ dependencies = [ "semver 1.0.26", "syn", "toml 0.8.23", + "wit-bindgen-core", + "wit-bindgen-rust", ] [[package]] diff --git a/examples/p2id-note/src/lib.rs b/examples/p2id-note/src/lib.rs index dcf57ef77..6987775fe 100644 --- a/examples/p2id-note/src/lib.rs +++ b/examples/p2id-note/src/lib.rs @@ -9,7 +9,7 @@ use miden::*; -use crate::bindings::miden::basic_wallet::basic_wallet::receive_asset; +use crate::bindings::miden::basic_wallet::basic_wallet::BasicWallet; #[note_script] fn run(_arg: Word) { @@ -23,6 +23,6 @@ fn run(_arg: Word) { let assets = active_note::get_assets(); for asset in assets { - receive_asset(asset); + BasicWallet::default().receive_asset(asset); } } diff --git a/examples/p2ide-note/Cargo.lock b/examples/p2ide-note/Cargo.lock index 5dc6b40e9..3c5c6af45 100644 --- a/examples/p2ide-note/Cargo.lock +++ b/examples/p2ide-note/Cargo.lock @@ -1064,6 +1064,8 @@ dependencies = [ "semver 1.0.26", "syn", "toml 0.8.23", + "wit-bindgen-core", + "wit-bindgen-rust", ] [[package]] diff --git a/examples/storage-example/Cargo.lock b/examples/storage-example/Cargo.lock index 60d375101..10fdfef34 100644 --- a/examples/storage-example/Cargo.lock +++ b/examples/storage-example/Cargo.lock @@ -866,6 +866,8 @@ dependencies = [ "semver 1.0.26", "syn", "toml 0.8.23", + "wit-bindgen-core", + "wit-bindgen-rust", ] [[package]] diff --git a/sdk/base-macros/Cargo.toml b/sdk/base-macros/Cargo.toml index d25ecb2db..ec6813973 100644 --- a/sdk/base-macros/Cargo.toml +++ b/sdk/base-macros/Cargo.toml @@ -22,6 +22,8 @@ semver.workspace = true toml.workspace = true syn.workspace = true heck.workspace = true +wit-bindgen-core = "0.46" +wit-bindgen-rust = { version = "0.46", default-features = false } [dev-dependencies] # Use local paths for dev-only dependency to avoid relying on crates.io during packaging diff --git a/sdk/base-macros/src/generate.rs b/sdk/base-macros/src/generate.rs index eca0bff50..897b86254 100644 --- a/sdk/base-macros/src/generate.rs +++ b/sdk/base-macros/src/generate.rs @@ -4,12 +4,18 @@ use std::{ path::{Path, PathBuf}, }; -use proc_macro2::{Literal, Span, TokenStream as TokenStream2}; -use quote::quote; +use heck::ToUpperCamelCase; +use proc_macro2::{Span, TokenStream as TokenStream2}; +use quote::{quote, ToTokens}; use syn::{ parse::{Parse, ParseStream}, - Error, LitStr, Token, + parse_quote, + spanned::Spanned, + Attribute, Error, File, FnArg, ImplItem, ImplItemFn, Item, ItemFn, ItemImpl, ItemStruct, + LitStr, Pat, ReturnType, Token, }; +use wit_bindgen_core::wit_parser::{PackageId, Resolve, UnresolvedPackageGroup}; +use wit_bindgen_rust::{Opts, WithOption}; /// File name for the embedded Miden SDK WIT . const SDK_WIT_FILE_NAME: &str = "miden.wit"; @@ -19,7 +25,31 @@ pub(crate) const SDK_WIT_SOURCE: &str = include_str!("../wit/miden.wit"); #[derive(Default)] struct GenerateArgs { inline: Option, - with: Option, + /// Custom `with` entries parsed from the macro input. + /// Each entry maps a WIT interface/type to either `generate` or a Rust path. + /// Stored directly as `(String, WithOption)` to avoid an intermediate representation. + with_entries: Vec<(String, WithOption)>, +} + +/// Parses a single `with` entry like `"miden:foo/bar": generate` or `"miden:foo/bar": ::my::Path`. +fn parse_with_entry(input: ParseStream<'_>) -> syn::Result<(String, WithOption)> { + let key: LitStr = input.parse()?; + input.parse::()?; + let path: syn::Path = input.parse()?; + + // Check if the path is the special `generate` keyword + let option = if path.leading_colon.is_none() + && path.segments.len() == 1 + && path.segments.first().is_some_and(|seg| seg.ident == "generate") + { + WithOption::Generate + } else { + // Convert syn::Path to string, removing spaces for consistency + let path_str = path.to_token_stream().to_string().replace(' ', ""); + WithOption::Path(path_str) + }; + + Ok((key.value(), option)) } impl Parse for GenerateArgs { @@ -37,13 +67,18 @@ impl Parse for GenerateArgs { } args.inline = Some(input.parse()?); } else if name == "with" { - if args.with.is_some() { + if !args.with_entries.is_empty() { return Err(syn::Error::new(ident.span(), "duplicate `with` argument")); } let content; syn::braced!(content in input); - let tokens = content.parse::()?; - args.with = Some(tokens); + // Parse comma-separated with entries directly into (String, WithOption) pairs + while !content.is_empty() { + args.with_entries.push(parse_with_entry(&content)?); + if content.peek(Token![,]) { + content.parse::()?; + } + } } else { return Err(syn::Error::new( ident.span(), @@ -88,12 +123,6 @@ pub(crate) fn expand(input: proc_macro::TokenStream) -> proc_macro::TokenStream .into(); } - let path_literals: Vec<_> = - config.paths.iter().map(|path| Literal::string(path)).collect(); - - let inline_clause = args.inline.as_ref().map(|src| quote! { inline: #src, }); - let custom_with_entries = args.with.unwrap_or_else(TokenStream2::new); - let inline_world = args .inline .as_ref() @@ -109,47 +138,339 @@ pub(crate) fn expand(input: proc_macro::TokenStream) -> proc_macro::TokenStream .into(); } - let world_clause = world_value.as_ref().map(|world| { - let literal = Literal::string(world); - quote! { world: #literal, } - }); - - quote! { - // Wrap the bindings in the `bindings` module since `generate!` makes a top level - // module named after the package namespace which is `miden` for all our projects - // so its conflicts with the `miden` crate (SDK) - #[doc(hidden)] - #[allow(dead_code)] - pub mod bindings { - ::miden::wit_bindgen::generate!({ - #inline_clause - #world_clause - path: [#(#path_literals),*], - generate_all, - runtime_path: "::miden::wit_bindgen::rt", - // path to use in the generated `export!` macro - default_bindings_module: "bindings", - with: { - #custom_with_entries - "miden:base/core-types@1.0.0": generate, - "miden:base/core-types@1.0.0/felt": ::miden::Felt, - "miden:base/core-types@1.0.0/word": ::miden::Word, - "miden:base/core-types@1.0.0/asset": ::miden::Asset, - "miden:base/core-types@1.0.0/account-id": ::miden::AccountId, - "miden:base/core-types@1.0.0/tag": ::miden::Tag, - "miden:base/core-types@1.0.0/note-type": ::miden::NoteType, - "miden:base/core-types@1.0.0/recipient": ::miden::Recipient, - "miden:base/core-types@1.0.0/note-idx": ::miden::NoteIdx, - }, - }); - } + match generate_bindings(&args, &config, world_value.as_deref()) { + Ok(raw_bindings) => match augment_generated_bindings(raw_bindings) { + Ok(augmented) => { + quote! { + // Wrap the bindings in the `bindings` module since `generate!` makes a top level + // module named after the package namespace which is `miden` for all our projects + // so it conflicts with the `miden` crate (SDK) + #[doc(hidden)] + #[allow(dead_code)] + pub mod bindings { + #augmented + } + } + .into() + } + Err(err) => err.to_compile_error().into(), + }, + Err(err) => err.to_compile_error().into(), } - .into() } Err(err) => err.to_compile_error().into(), } } +fn generate_bindings( + args: &GenerateArgs, + config: &manifest_paths::ResolvedWit, + world_override: Option<&str>, +) -> Result { + let inline_src = args.inline.as_ref().map(|src| src.value()); + let inline_ref = inline_src.as_deref(); + let wit_sources = load_wit_sources(&config.paths, inline_ref)?; + + let world_spec = world_override.or(config.world.as_deref()); + let world = wit_sources + .resolve + .select_world(&wit_sources.packages, world_spec) + .map_err(|err| Error::new(Span::call_site(), err.to_string()))?; + + let mut opts = Opts { + generate_all: true, + runtime_path: Some("::miden::wit_bindgen::rt".to_string()), + default_bindings_module: Some("bindings".to_string()), + ..Opts::default() + }; + apply_with_entries(&mut opts, &args.with_entries); + push_default_with_entries(&mut opts); + + let mut generated_files = wit_bindgen_core::Files::default(); + let mut generator = opts.build(); + generator + .generate(&wit_sources.resolve, world, &mut generated_files) + .map_err(|err| Error::new(Span::call_site(), err.to_string()))?; + + let (_, src_bytes) = generated_files + .iter() + .next() + .ok_or_else(|| Error::new(Span::call_site(), "wit-bindgen emitted no bindings"))?; + let src = std::str::from_utf8(src_bytes) + .map_err(|err| Error::new(Span::call_site(), format!("invalid UTF-8: {err}")))?; + let mut tokens: TokenStream2 = src + .parse() + .map_err(|err| Error::new(Span::call_site(), format!("failed to parse bindings: {err}")))?; + + // Include a dummy `include_bytes!` for any files we read so rustc knows that + // we depend on the contents of those files. + for path in wit_sources.files_read { + let utf8_path = path.to_str().ok_or_else(|| { + Error::new( + Span::call_site(), + format!("path '{}' contains invalid UTF-8", path.display()), + ) + })?; + tokens.extend(quote! { + const _: &[u8] = include_bytes!(#utf8_path); + }); + } + + Ok(tokens) +} + +fn augment_generated_bindings(tokens: TokenStream2) -> syn::Result { + let mut file: File = syn::parse2(tokens)?; + transform_modules(&mut file.items, &mut Vec::new())?; + Ok(file.into_token_stream()) +} + +/// Result of loading WIT sources. +struct LoadedWitSources { + /// The resolved WIT definitions. + resolve: Resolve, + /// Package IDs to use for world selection. + packages: Vec, + /// File paths that were read to include a dummy `include_bytes!` so rustc knows that we depend + /// on the contents of those files. + files_read: Vec, +} + +/// Loads WIT sources from file paths and optionally an inline source. +fn load_wit_sources( + paths: &[String], + inline_source: Option<&str>, +) -> Result { + let manifest_dir = env::var("CARGO_MANIFEST_DIR").map_err(|err| { + Error::new(Span::call_site(), format!("failed to read CARGO_MANIFEST_DIR: {err}")) + })?; + let manifest_dir = PathBuf::from(manifest_dir); + + let mut resolve = Resolve::default(); + let mut packages = Vec::new(); + let mut files = Vec::new(); + + // Load WIT definitions from file paths. These are always loaded to populate the resolver + // with type definitions that the inline source may depend on. + for path in paths { + let path_buf = PathBuf::from(path); + let absolute = if path_buf.is_absolute() { + path_buf + } else { + manifest_dir.join(path_buf) + }; + let normalized = fs::canonicalize(&absolute).unwrap_or(absolute); + let (pkg, sources) = resolve + .push_path(normalized.clone()) + .map_err(|err| Error::new(Span::call_site(), err.to_string()))?; + packages.push(pkg); + files.extend(sources.paths().map(|p| p.to_owned())); + } + + if let Some(src) = inline_source { + // When inline source is provided, it becomes the primary package for world selection. + // We clear previously collected package IDs because the inline source defines the world + // we want to generate bindings for. The file-based packages are still loaded above and + // remain in the resolver - they provide type definitions that the inline world imports. + packages.clear(); + let group = UnresolvedPackageGroup::parse("inline", src) + .map_err(|err| Error::new(Span::call_site(), err.to_string()))?; + let pkg = resolve + .push_group(group) + .map_err(|err| Error::new(Span::call_site(), err.to_string()))?; + packages.push(pkg); + } + + Ok(LoadedWitSources { + resolve, + packages, + files_read: files, + }) +} + +/// Applies user-provided `with` entries to the wit-bindgen options. +fn apply_with_entries(opts: &mut Opts, entries: &[(String, WithOption)]) { + opts.with.extend(entries.iter().cloned()); +} + +fn push_default_with_entries(opts: &mut Opts) { + opts.with + .push(("miden:base/core-types@1.0.0".to_string(), WithOption::Generate)); + push_path_entry(opts, "miden:base/core-types@1.0.0/felt", "::miden::Felt"); + push_path_entry(opts, "miden:base/core-types@1.0.0/word", "::miden::Word"); + push_path_entry(opts, "miden:base/core-types@1.0.0/asset", "::miden::Asset"); + push_path_entry(opts, "miden:base/core-types@1.0.0/account-id", "::miden::AccountId"); + push_path_entry(opts, "miden:base/core-types@1.0.0/tag", "::miden::Tag"); + push_path_entry(opts, "miden:base/core-types@1.0.0/note-type", "::miden::NoteType"); + push_path_entry(opts, "miden:base/core-types@1.0.0/recipient", "::miden::Recipient"); + push_path_entry(opts, "miden:base/core-types@1.0.0/note-idx", "::miden::NoteIdx"); +} + +fn push_path_entry(opts: &mut Opts, key: &str, value: &str) { + opts.with.push((key.to_string(), WithOption::Path(value.to_string()))); +} + +fn transform_modules(items: &mut [Item], path: &mut Vec) -> syn::Result<()> { + for item in items.iter_mut() { + if let Item::Mod(module) = item { + path.push(module.ident.clone()); + if let Some((_, ref mut content)) = module.content { + transform_modules(content, path)?; + maybe_inject_struct_wrapper(content, path)?; + } + path.pop(); + } + } + + Ok(()) +} + +/// Injects a wrapper struct and impl block for public functions in a module. +/// +/// Note: We need `&mut Vec` here (not `&mut [Item]`) because we push new items +/// (the struct and impl block) to the vector. +fn maybe_inject_struct_wrapper(items: &mut Vec, path: &[syn::Ident]) -> syn::Result<()> { + if !should_generate_struct(path) { + return Ok(()); + } + + let functions: Vec = items + .iter() + .filter_map(|item| match item { + Item::Fn(func) if is_target_function(func) => Some(func.clone()), + _ => None, + }) + .collect(); + + if functions.is_empty() { + return Ok(()); + } + + let module_ident = + path.last().ok_or_else(|| Error::new(Span::call_site(), "empty module path"))?; + let struct_ident = + syn::Ident::new(&module_ident.to_string().to_upper_camel_case(), module_ident.span()); + + if items + .iter() + .any(|item| matches!(item, Item::Struct(existing) if existing.ident == struct_ident)) + { + return Ok(()); + } + + let struct_doc = + format!("Wrapper for functions defined in module `{}`.", format_module_path(path)); + let struct_item: ItemStruct = parse_quote! { + #[doc = #struct_doc] + #[derive(Clone, Copy, Default)] + pub struct #struct_ident; + }; + + let mut methods = Vec::new(); + for func in functions { + methods.push(build_wrapper_method(&func, path)?); + } + + if methods.is_empty() { + return Ok(()); + } + + let mut impl_item: ItemImpl = parse_quote! { + impl #struct_ident {} + }; + impl_item.items.extend(methods.into_iter().map(ImplItem::Fn)); + + items.push(Item::Struct(struct_item)); + items.push(Item::Impl(impl_item)); + + Ok(()) +} + +fn build_wrapper_method(func: &ItemFn, module_path: &[syn::Ident]) -> syn::Result { + let mut sig = func.sig.clone(); + sig.inputs.insert(0, parse_quote!(&self)); + + let arg_idents = collect_arg_idents(func)?; + let call_expr = wrapper_call_tokens(module_path, &sig.ident, &arg_idents); + + let method_doc = format!("Calls `{}` from `{}`.", sig.ident, format_module_path(module_path)); + let doc_attr: Attribute = parse_quote!(#[doc = #method_doc]); + let inline_attr: Attribute = parse_quote!(#[inline(always)]); + let allow_unused_attr: Attribute = parse_quote!(#[allow(unused_variables)]); + + let body_tokens = match &sig.output { + ReturnType::Default => quote!({ #call_expr; }), + _ => quote!({ #call_expr }), + }; + let block = syn::parse2(body_tokens)?; + + Ok(ImplItemFn { + attrs: vec![doc_attr, inline_attr, allow_unused_attr], + vis: func.vis.clone(), + defaultness: None, + sig, + block, + }) +} + +fn collect_arg_idents(func: &ItemFn) -> syn::Result> { + func.sig + .inputs + .iter() + .map(|arg| match arg { + FnArg::Receiver(_) => { + Err(Error::new(func.sig.ident.span(), "unexpected receiver in generated function")) + } + FnArg::Typed(pat_type) => match pat_type.pat.as_ref() { + Pat::Ident(pat_ident) => Ok(pat_ident.ident.clone()), + other => Err(Error::new( + other.span(), + "unsupported argument pattern in generated function", + )), + }, + }) + .collect() +} + +fn wrapper_call_tokens( + module_path: &[syn::Ident], + fn_ident: &syn::Ident, + args: &[syn::Ident], +) -> TokenStream2 { + let mut path_tokens = quote! { crate::bindings }; + for ident in module_path { + let current = ident.clone(); + path_tokens = quote! { #path_tokens :: #current }; + } + + quote! { #path_tokens :: #fn_ident(#(#args),*) } +} + +fn should_generate_struct(path: &[syn::Ident]) -> bool { + if path.is_empty() { + return false; + } + let first = path[0].to_string(); + if first == "exports" { + return false; + } + if first.starts_with('_') { + return false; + } + let last = path.last().unwrap().to_string(); + !last.starts_with('_') +} + +fn is_target_function(func: &ItemFn) -> bool { + matches!(func.vis, syn::Visibility::Public(_)) + && func.sig.unsafety.is_none() + && !func.sig.ident.to_string().starts_with('_') +} + +fn format_module_path(path: &[syn::Ident]) -> String { + path.iter().map(|ident| ident.to_string()).collect::>().join("::") +} + mod manifest_paths { use toml::Value; @@ -440,3 +761,258 @@ mod manifest_paths { } } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Helper to parse Rust source into a syn::File. + fn parse_file(src: &str) -> File { + syn::parse_str(src).expect("failed to parse test source") + } + + #[test] + fn test_should_generate_struct_empty_path() { + assert!(!should_generate_struct(&[])); + } + + #[test] + fn test_should_generate_struct_exports_excluded() { + let path = vec![syn::Ident::new("exports", Span::call_site())]; + assert!(!should_generate_struct(&path)); + + let path = vec![ + syn::Ident::new("exports", Span::call_site()), + syn::Ident::new("foo", Span::call_site()), + ]; + assert!(!should_generate_struct(&path)); + } + + #[test] + fn test_should_generate_struct_underscore_excluded() { + let path = vec![syn::Ident::new("_private", Span::call_site())]; + assert!(!should_generate_struct(&path)); + + let path = vec![ + syn::Ident::new("miden", Span::call_site()), + syn::Ident::new("_internal", Span::call_site()), + ]; + assert!(!should_generate_struct(&path)); + } + + #[test] + fn test_should_generate_struct_valid_paths() { + let path = vec![syn::Ident::new("miden", Span::call_site())]; + assert!(should_generate_struct(&path)); + + let path = vec![ + syn::Ident::new("miden", Span::call_site()), + syn::Ident::new("basic_wallet", Span::call_site()), + ]; + assert!(should_generate_struct(&path)); + } + + #[test] + fn test_is_target_function_public() { + let func: ItemFn = syn::parse_quote! { + pub fn receive_asset(asset: Asset) {} + }; + assert!(is_target_function(&func)); + } + + #[test] + fn test_is_target_function_private_excluded() { + let func: ItemFn = syn::parse_quote! { + fn private_fn() {} + }; + assert!(!is_target_function(&func)); + } + + #[test] + fn test_is_target_function_unsafe_excluded() { + let func: ItemFn = syn::parse_quote! { + pub unsafe fn unsafe_fn() {} + }; + assert!(!is_target_function(&func)); + } + + #[test] + fn test_is_target_function_underscore_excluded() { + let func: ItemFn = syn::parse_quote! { + pub fn _internal() {} + }; + assert!(!is_target_function(&func)); + } + + #[test] + fn test_format_module_path() { + let path = vec![ + syn::Ident::new("miden", Span::call_site()), + syn::Ident::new("basic_wallet", Span::call_site()), + ]; + assert_eq!(format_module_path(&path), "miden::basic_wallet"); + } + + #[test] + fn test_format_module_path_empty() { + assert_eq!(format_module_path(&[]), ""); + } + + #[test] + fn test_collect_arg_idents() { + let func: ItemFn = syn::parse_quote! { + pub fn foo(a: u32, b: String, c: Vec) {} + }; + let idents = collect_arg_idents(&func).unwrap(); + let names: Vec<_> = idents.iter().map(|i| i.to_string()).collect(); + assert_eq!(names, vec!["a", "b", "c"]); + } + + #[test] + fn test_collect_arg_idents_empty() { + let func: ItemFn = syn::parse_quote! { + pub fn no_args() {} + }; + let idents = collect_arg_idents(&func).unwrap(); + assert!(idents.is_empty()); + } + + #[test] + fn test_transform_modules_injects_struct() { + let src = r#" + mod miden { + mod basic_wallet { + mod basic_wallet { + pub fn receive_asset(asset: Asset) {} + pub fn send_asset(asset: Asset) {} + } + } + } + "#; + let mut file = parse_file(src); + transform_modules(&mut file.items, &mut Vec::new()).unwrap(); + + // Check that the innermost module now contains a struct and impl + let miden_mod = match &file.items[0] { + Item::Mod(m) => m, + _ => panic!("expected mod"), + }; + let basic_wallet_outer = match &miden_mod.content.as_ref().unwrap().1[0] { + Item::Mod(m) => m, + _ => panic!("expected mod"), + }; + let basic_wallet_inner = match &basic_wallet_outer.content.as_ref().unwrap().1[0] { + Item::Mod(m) => m, + _ => panic!("expected mod"), + }; + let inner_items = &basic_wallet_inner.content.as_ref().unwrap().1; + + // Should have: 2 functions + 1 struct + 1 impl = 4 items + assert_eq!(inner_items.len(), 4); + + // Check struct exists and has correct name + let struct_item = inner_items.iter().find_map(|item| match item { + Item::Struct(s) => Some(s), + _ => None, + }); + assert!(struct_item.is_some()); + assert_eq!(struct_item.unwrap().ident.to_string(), "BasicWallet"); + + // Check impl exists + let impl_item = inner_items.iter().find_map(|item| match item { + Item::Impl(i) => Some(i), + _ => None, + }); + assert!(impl_item.is_some()); + let impl_block = impl_item.unwrap(); + // Should have 2 methods + assert_eq!(impl_block.items.len(), 2); + } + + #[test] + fn test_transform_modules_skips_exports() { + let src = r#" + mod exports { + mod my_component { + pub fn exported_fn() {} + } + } + "#; + let mut file = parse_file(src); + transform_modules(&mut file.items, &mut Vec::new()).unwrap(); + + // exports module should not have any struct injected + let exports_mod = match &file.items[0] { + Item::Mod(m) => m, + _ => panic!("expected mod"), + }; + let my_component = match &exports_mod.content.as_ref().unwrap().1[0] { + Item::Mod(m) => m, + _ => panic!("expected mod"), + }; + let items = &my_component.content.as_ref().unwrap().1; + + // Should only have the original function, no struct added + assert_eq!(items.len(), 1); + assert!(matches!(items[0], Item::Fn(_))); + } + + #[test] + fn test_transform_modules_skips_empty_modules() { + let src = r#" + mod miden { + mod empty_module { + } + } + "#; + let mut file = parse_file(src); + transform_modules(&mut file.items, &mut Vec::new()).unwrap(); + + let miden_mod = match &file.items[0] { + Item::Mod(m) => m, + _ => panic!("expected mod"), + }; + let empty_module = match &miden_mod.content.as_ref().unwrap().1[0] { + Item::Mod(m) => m, + _ => panic!("expected mod"), + }; + let items = &empty_module.content.as_ref().unwrap().1; + + // Should remain empty + assert!(items.is_empty()); + } + + #[test] + fn test_build_wrapper_method_signature() { + let func: ItemFn = syn::parse_quote! { + pub fn receive_asset(asset: Asset) {} + }; + let path = vec![ + syn::Ident::new("miden", Span::call_site()), + syn::Ident::new("basic_wallet", Span::call_site()), + ]; + let method = build_wrapper_method(&func, &path).unwrap(); + + // Method should have &self as first parameter + assert_eq!(method.sig.inputs.len(), 2); + assert!(matches!(method.sig.inputs.first(), Some(FnArg::Receiver(_)))); + + // Should be public + assert!(matches!(method.vis, syn::Visibility::Public(_))); + + // Should have inline attribute + assert!(method.attrs.iter().any(|attr| { attr.path().is_ident("inline") })); + } + + #[test] + fn test_build_wrapper_method_with_return_type() { + let func: ItemFn = syn::parse_quote! { + pub fn get_value() -> u32 { 42 } + }; + let path = vec![syn::Ident::new("test_mod", Span::call_site())]; + let method = build_wrapper_method(&func, &path).unwrap(); + + // Return type should be preserved + assert!(matches!(method.sig.output, ReturnType::Type(_, _))); + } +} diff --git a/tests/rust-apps-wasm/rust-sdk/add/Cargo.lock b/tests/rust-apps-wasm/rust-sdk/add/Cargo.lock index c221a348a..5891e0a4a 100644 --- a/tests/rust-apps-wasm/rust-sdk/add/Cargo.lock +++ b/tests/rust-apps-wasm/rust-sdk/add/Cargo.lock @@ -866,6 +866,8 @@ dependencies = [ "semver 1.0.26", "syn", "toml 0.8.23", + "wit-bindgen-core", + "wit-bindgen-rust", ] [[package]] diff --git a/tests/rust-apps-wasm/rust-sdk/component-macros-account/Cargo.lock b/tests/rust-apps-wasm/rust-sdk/component-macros-account/Cargo.lock index 3707ae2a0..7dc8f3d9e 100644 --- a/tests/rust-apps-wasm/rust-sdk/component-macros-account/Cargo.lock +++ b/tests/rust-apps-wasm/rust-sdk/component-macros-account/Cargo.lock @@ -886,6 +886,8 @@ dependencies = [ "semver 1.0.27", "syn", "toml 0.8.23", + "wit-bindgen-core", + "wit-bindgen-rust", ] [[package]] diff --git a/tests/rust-apps-wasm/rust-sdk/component-macros-note/Cargo.lock b/tests/rust-apps-wasm/rust-sdk/component-macros-note/Cargo.lock index 863bc24c8..a81a3cfa8 100644 --- a/tests/rust-apps-wasm/rust-sdk/component-macros-note/Cargo.lock +++ b/tests/rust-apps-wasm/rust-sdk/component-macros-note/Cargo.lock @@ -886,6 +886,8 @@ dependencies = [ "semver 1.0.27", "syn", "toml 0.8.23", + "wit-bindgen-core", + "wit-bindgen-rust", ] [[package]] diff --git a/tests/rust-apps-wasm/rust-sdk/cross-ctx-account-word-arg/Cargo.lock b/tests/rust-apps-wasm/rust-sdk/cross-ctx-account-word-arg/Cargo.lock index e5e8eab32..22ceac4c7 100644 --- a/tests/rust-apps-wasm/rust-sdk/cross-ctx-account-word-arg/Cargo.lock +++ b/tests/rust-apps-wasm/rust-sdk/cross-ctx-account-word-arg/Cargo.lock @@ -873,6 +873,8 @@ dependencies = [ "semver 1.0.26", "syn", "toml 0.8.23", + "wit-bindgen-core", + "wit-bindgen-rust", ] [[package]] diff --git a/tests/rust-apps-wasm/rust-sdk/cross-ctx-account-word/Cargo.lock b/tests/rust-apps-wasm/rust-sdk/cross-ctx-account-word/Cargo.lock index 5ea05f47b..8a552f1c8 100644 --- a/tests/rust-apps-wasm/rust-sdk/cross-ctx-account-word/Cargo.lock +++ b/tests/rust-apps-wasm/rust-sdk/cross-ctx-account-word/Cargo.lock @@ -873,6 +873,8 @@ dependencies = [ "semver 1.0.26", "syn", "toml 0.8.23", + "wit-bindgen-core", + "wit-bindgen-rust", ] [[package]] diff --git a/tests/rust-apps-wasm/rust-sdk/cross-ctx-account/Cargo.lock b/tests/rust-apps-wasm/rust-sdk/cross-ctx-account/Cargo.lock index 1881f4ec9..430ae8693 100644 --- a/tests/rust-apps-wasm/rust-sdk/cross-ctx-account/Cargo.lock +++ b/tests/rust-apps-wasm/rust-sdk/cross-ctx-account/Cargo.lock @@ -873,6 +873,8 @@ dependencies = [ "semver 1.0.26", "syn", "toml 0.8.23", + "wit-bindgen-core", + "wit-bindgen-rust", ] [[package]] diff --git a/tests/rust-apps-wasm/rust-sdk/cross-ctx-note-word-arg/Cargo.lock b/tests/rust-apps-wasm/rust-sdk/cross-ctx-note-word-arg/Cargo.lock index 8a557f7fe..2a5e8e74b 100644 --- a/tests/rust-apps-wasm/rust-sdk/cross-ctx-note-word-arg/Cargo.lock +++ b/tests/rust-apps-wasm/rust-sdk/cross-ctx-note-word-arg/Cargo.lock @@ -873,6 +873,8 @@ dependencies = [ "semver 1.0.26", "syn", "toml 0.8.23", + "wit-bindgen-core", + "wit-bindgen-rust", ] [[package]] diff --git a/tests/rust-apps-wasm/rust-sdk/cross-ctx-note-word/Cargo.lock b/tests/rust-apps-wasm/rust-sdk/cross-ctx-note-word/Cargo.lock index 96af9c888..2374b4622 100644 --- a/tests/rust-apps-wasm/rust-sdk/cross-ctx-note-word/Cargo.lock +++ b/tests/rust-apps-wasm/rust-sdk/cross-ctx-note-word/Cargo.lock @@ -873,6 +873,8 @@ dependencies = [ "semver 1.0.26", "syn", "toml 0.8.23", + "wit-bindgen-core", + "wit-bindgen-rust", ] [[package]] diff --git a/tests/rust-apps-wasm/rust-sdk/cross-ctx-note/Cargo.lock b/tests/rust-apps-wasm/rust-sdk/cross-ctx-note/Cargo.lock index 4a4be16ab..bd187420d 100644 --- a/tests/rust-apps-wasm/rust-sdk/cross-ctx-note/Cargo.lock +++ b/tests/rust-apps-wasm/rust-sdk/cross-ctx-note/Cargo.lock @@ -873,6 +873,8 @@ dependencies = [ "semver 1.0.26", "syn", "toml 0.8.23", + "wit-bindgen-core", + "wit-bindgen-rust", ] [[package]] From eb50e489cd454d5444a39555afe1280e8b0db00f Mon Sep 17 00:00:00 2001 From: Denys Zadorozhnyi Date: Wed, 26 Nov 2025 13:47:27 +0200 Subject: [PATCH 02/12] refactor: refine names, doc comments --- examples/p2id-note/src/lib.rs | 3 +- sdk/base-macros/src/generate.rs | 49 ++++++++++++++++++++++++++------- 2 files changed, 41 insertions(+), 11 deletions(-) diff --git a/examples/p2id-note/src/lib.rs b/examples/p2id-note/src/lib.rs index 6987775fe..40277189a 100644 --- a/examples/p2id-note/src/lib.rs +++ b/examples/p2id-note/src/lib.rs @@ -21,8 +21,9 @@ fn run(_arg: Word) { let current_account = active_account::get_id(); assert_eq!(current_account, target_account); + let wallet = BasicWallet::default(); let assets = active_note::get_assets(); for asset in assets { - BasicWallet::default().receive_asset(asset); + wallet.receive_asset(asset); } } diff --git a/sdk/base-macros/src/generate.rs b/sdk/base-macros/src/generate.rs index 897b86254..051333447 100644 --- a/sdk/base-macros/src/generate.rs +++ b/sdk/base-macros/src/generate.rs @@ -162,6 +162,7 @@ pub(crate) fn expand(input: proc_macro::TokenStream) -> proc_macro::TokenStream } } +/// Generates WIT bindings using `wit-bindgen` directly instead of the `generate!` macro. fn generate_bindings( args: &GenerateArgs, config: &manifest_paths::ResolvedWit, @@ -183,7 +184,7 @@ fn generate_bindings( default_bindings_module: Some("bindings".to_string()), ..Opts::default() }; - apply_with_entries(&mut opts, &args.with_entries); + push_custom_with_entries(&mut opts, &args.with_entries); push_default_with_entries(&mut opts); let mut generated_files = wit_bindgen_core::Files::default(); @@ -219,6 +220,12 @@ fn generate_bindings( Ok(tokens) } +/// Post-processes wit-bindgen output to inject wrapper structs for imported interfaces. +/// +/// This transforms the raw bindings by walking all modules and injecting a wrapper struct +/// with methods that delegate to the generated free functions. This provides a more +/// ergonomic API (e.g., `BasicWallet::default().receive_asset(asset)` instead of +/// `basic_wallet::receive_asset(asset)`). fn augment_generated_bindings(tokens: TokenStream2) -> syn::Result { let mut file: File = syn::parse2(tokens)?; transform_modules(&mut file.items, &mut Vec::new())?; @@ -288,11 +295,12 @@ fn load_wit_sources( }) } -/// Applies user-provided `with` entries to the wit-bindgen options. -fn apply_with_entries(opts: &mut Opts, entries: &[(String, WithOption)]) { +/// Pushes user-provided `with` entries to the wit-bindgen options. +fn push_custom_with_entries(opts: &mut Opts, entries: &[(String, WithOption)]) { opts.with.extend(entries.iter().cloned()); } +/// Pushes default `with` entries that map Miden base types to SDK types. fn push_default_with_entries(opts: &mut Opts) { opts.with .push(("miden:base/core-types@1.0.0".to_string(), WithOption::Generate)); @@ -310,6 +318,9 @@ fn push_path_entry(opts: &mut Opts, key: &str, value: &str) { opts.with.push((key.to_string(), WithOption::Path(value.to_string()))); } +/// Recursively walks all modules and injects wrapper structs where appropriate. +/// +/// The `path` parameter tracks the current module path for naming and call generation. fn transform_modules(items: &mut [Item], path: &mut Vec) -> syn::Result<()> { for item in items.iter_mut() { if let Item::Mod(module) = item { @@ -386,6 +397,7 @@ fn maybe_inject_struct_wrapper(items: &mut Vec, path: &[syn::Ident]) -> sy Ok(()) } +/// Builds a wrapper method that delegates to the original free function. fn build_wrapper_method(func: &ItemFn, module_path: &[syn::Ident]) -> syn::Result { let mut sig = func.sig.clone(); sig.inputs.insert(0, parse_quote!(&self)); @@ -413,6 +425,10 @@ fn build_wrapper_method(func: &ItemFn, module_path: &[syn::Ident]) -> syn::Resul }) } +/// Extracts argument identifiers from a function signature. +/// +/// Returns an error if the function contains a receiver (`self`) or uses +/// unsupported argument patterns (e.g., destructuring patterns). fn collect_arg_idents(func: &ItemFn) -> syn::Result> { func.sig .inputs @@ -425,13 +441,17 @@ fn collect_arg_idents(func: &ItemFn) -> syn::Result> { Pat::Ident(pat_ident) => Ok(pat_ident.ident.clone()), other => Err(Error::new( other.span(), - "unsupported argument pattern in generated function", + format!( + "unsupported argument pattern `{}` in generated function", + quote!(#other) + ), )), }, }) .collect() } +/// Generates tokens for calling the original free function from the wrapper method. fn wrapper_call_tokens( module_path: &[syn::Ident], fn_ident: &syn::Ident, @@ -439,13 +459,18 @@ fn wrapper_call_tokens( ) -> TokenStream2 { let mut path_tokens = quote! { crate::bindings }; for ident in module_path { - let current = ident.clone(); - path_tokens = quote! { #path_tokens :: #current }; + path_tokens = quote! { #path_tokens :: #ident }; } quote! { #path_tokens :: #fn_ident(#(#args),*) } } +/// Determines whether a wrapper struct should be generated for the given module path. +/// +/// Returns `false` for: +/// - Empty paths +/// - `exports` modules (these are user-implemented exports, not imports) +/// - Modules starting with underscore (internal/private modules) fn should_generate_struct(path: &[syn::Ident]) -> bool { if path.is_empty() { return false; @@ -461,12 +486,16 @@ fn should_generate_struct(path: &[syn::Ident]) -> bool { !last.starts_with('_') } +/// Determines whether a function should have a wrapper method generated. +/// +/// Returns `true` for public, safe functions that don't start with underscore. fn is_target_function(func: &ItemFn) -> bool { matches!(func.vis, syn::Visibility::Public(_)) && func.sig.unsafety.is_none() && !func.sig.ident.to_string().starts_with('_') } +/// Formats a module path as a `::` separated string for use in documentation. fn format_module_path(path: &[syn::Ident]) -> String { path.iter().map(|ident| ident.to_string()).collect::>().join("::") } @@ -815,7 +844,7 @@ mod tests { #[test] fn test_is_target_function_public() { let func: ItemFn = syn::parse_quote! { - pub fn receive_asset(asset: Asset) {} + pub fn receive_asset(asset: u64) {} }; assert!(is_target_function(&func)); } @@ -883,8 +912,8 @@ mod tests { mod miden { mod basic_wallet { mod basic_wallet { - pub fn receive_asset(asset: Asset) {} - pub fn send_asset(asset: Asset) {} + pub fn receive_asset(asset: u64) {} + pub fn send_asset(asset: u64) {} } } } @@ -985,7 +1014,7 @@ mod tests { #[test] fn test_build_wrapper_method_signature() { let func: ItemFn = syn::parse_quote! { - pub fn receive_asset(asset: Asset) {} + pub fn receive_asset(asset: u64) {} }; let path = vec![ syn::Ident::new("miden", Span::call_site()), From 001cc604938167310380ce95c43a2a1dbc1b085c Mon Sep 17 00:00:00 2001 From: Denys Zadorozhnyi Date: Wed, 26 Nov 2025 13:58:02 +0200 Subject: [PATCH 03/12] refactor: generate `struct` only from the leaf modules --- sdk/base-macros/src/generate.rs | 51 ++++++++++++++++++++++++--------- 1 file changed, 38 insertions(+), 13 deletions(-) diff --git a/sdk/base-macros/src/generate.rs b/sdk/base-macros/src/generate.rs index 051333447..7ca2cc890 100644 --- a/sdk/base-macros/src/generate.rs +++ b/sdk/base-macros/src/generate.rs @@ -336,12 +336,16 @@ fn transform_modules(items: &mut [Item], path: &mut Vec) -> syn::Res Ok(()) } -/// Injects a wrapper struct and impl block for public functions in a module. +/// Injects a wrapper struct and impl block for public functions in a leaf module. +/// +/// A leaf module is one that contains no nested modules. Only leaf modules get wrapper +/// structs generated, as non-leaf modules typically represent namespace hierarchy rather +/// than concrete interfaces. /// /// Note: We need `&mut Vec` here (not `&mut [Item]`) because we push new items /// (the struct and impl block) to the vector. fn maybe_inject_struct_wrapper(items: &mut Vec, path: &[syn::Ident]) -> syn::Result<()> { - if !should_generate_struct(path) { + if !should_generate_struct(path, items) { return Ok(()); } @@ -465,13 +469,14 @@ fn wrapper_call_tokens( quote! { #path_tokens :: #fn_ident(#(#args),*) } } -/// Determines whether a wrapper struct should be generated for the given module path. +/// Determines whether a wrapper struct should be generated for the given module. /// /// Returns `false` for: /// - Empty paths /// - `exports` modules (these are user-implemented exports, not imports) /// - Modules starting with underscore (internal/private modules) -fn should_generate_struct(path: &[syn::Ident]) -> bool { +/// - Non-leaf modules (modules that contain nested modules) +fn should_generate_struct(path: &[syn::Ident], items: &[Item]) -> bool { if path.is_empty() { return false; } @@ -483,7 +488,11 @@ fn should_generate_struct(path: &[syn::Ident]) -> bool { return false; } let last = path.last().unwrap().to_string(); - !last.starts_with('_') + if last.starts_with('_') { + return false; + } + // Only generate for leaf modules (no nested modules) + !items.iter().any(|item| matches!(item, Item::Mod(_))) } /// Determines whether a function should have a wrapper method generated. @@ -802,43 +811,59 @@ mod tests { #[test] fn test_should_generate_struct_empty_path() { - assert!(!should_generate_struct(&[])); + let empty_items: Vec = vec![]; + assert!(!should_generate_struct(&[], &empty_items)); } #[test] fn test_should_generate_struct_exports_excluded() { + let empty_items: Vec = vec![]; let path = vec![syn::Ident::new("exports", Span::call_site())]; - assert!(!should_generate_struct(&path)); + assert!(!should_generate_struct(&path, &empty_items)); let path = vec![ syn::Ident::new("exports", Span::call_site()), syn::Ident::new("foo", Span::call_site()), ]; - assert!(!should_generate_struct(&path)); + assert!(!should_generate_struct(&path, &empty_items)); } #[test] fn test_should_generate_struct_underscore_excluded() { + let empty_items: Vec = vec![]; let path = vec![syn::Ident::new("_private", Span::call_site())]; - assert!(!should_generate_struct(&path)); + assert!(!should_generate_struct(&path, &empty_items)); let path = vec![ syn::Ident::new("miden", Span::call_site()), syn::Ident::new("_internal", Span::call_site()), ]; - assert!(!should_generate_struct(&path)); + assert!(!should_generate_struct(&path, &empty_items)); } #[test] - fn test_should_generate_struct_valid_paths() { + fn test_should_generate_struct_valid_leaf_modules() { + let empty_items: Vec = vec![]; let path = vec![syn::Ident::new("miden", Span::call_site())]; - assert!(should_generate_struct(&path)); + assert!(should_generate_struct(&path, &empty_items)); let path = vec![ syn::Ident::new("miden", Span::call_site()), syn::Ident::new("basic_wallet", Span::call_site()), ]; - assert!(should_generate_struct(&path)); + assert!(should_generate_struct(&path, &empty_items)); + } + + #[test] + fn test_should_generate_struct_non_leaf_excluded() { + let path = vec![syn::Ident::new("miden", Span::call_site())]; + // Items containing a nested module + let items_with_mod: Vec = vec![syn::parse_quote! { mod nested {} }]; + assert!(!should_generate_struct(&path, &items_with_mod)); + + // Items with only functions (leaf module) should be allowed + let items_with_fn: Vec = vec![syn::parse_quote! { pub fn foo() {} }]; + assert!(should_generate_struct(&path, &items_with_fn)); } #[test] From 4d4a751183ab7d13568b1ba2dbc27307fc659814 Mon Sep 17 00:00:00 2001 From: Denys Zadorozhnyi Date: Wed, 26 Nov 2025 14:21:40 +0200 Subject: [PATCH 04/12] feature: pass the generated `struct` as `fn run` parameter --- examples/basic-wallet-tx-script/src/lib.rs | 9 +- examples/p2id-note/src/lib.rs | 5 +- sdk/base-macros/src/script.rs | 125 ++++++++++++++++----- 3 files changed, 103 insertions(+), 36 deletions(-) diff --git a/examples/basic-wallet-tx-script/src/lib.rs b/examples/basic-wallet-tx-script/src/lib.rs index 469d59a88..220644b10 100644 --- a/examples/basic-wallet-tx-script/src/lib.rs +++ b/examples/basic-wallet-tx-script/src/lib.rs @@ -10,7 +10,7 @@ use miden::{intrinsics::advice::adv_push_mapvaln, *}; -use crate::bindings::miden::basic_wallet::basic_wallet; +use crate::bindings::miden::basic_wallet::basic_wallet::BasicWallet; // Input layout constants const TAG_INDEX: usize = 0; @@ -23,7 +23,7 @@ const ASSET_START: usize = 8; const ASSET_END: usize = 12; #[tx_script] -fn run(arg: Word) { +fn run(arg: Word, account: BasicWallet) { let num_felts = adv_push_mapvaln(arg.clone()); let num_felts_u64 = num_felts.as_u64(); assert_eq(Felt::from_u32((num_felts_u64 % 4) as u32), felt!(0)); @@ -35,7 +35,8 @@ fn run(arg: Word) { let note_type = input[NOTE_TYPE_INDEX]; let execution_hint = input[EXECUTION_HINT_INDEX]; let recipient: [Felt; 4] = input[RECIPIENT_START..RECIPIENT_END].try_into().unwrap(); - let note_idx = output_note::create(tag.into(), aux, note_type.into(), execution_hint, recipient.into()); + let note_idx = + output_note::create(tag.into(), aux, note_type.into(), execution_hint, recipient.into()); let asset: [Felt; 4] = input[ASSET_START..ASSET_END].try_into().unwrap(); - basic_wallet::move_asset_to_note(asset.into(), note_idx); + account.move_asset_to_note(asset.into(), note_idx); } diff --git a/examples/p2id-note/src/lib.rs b/examples/p2id-note/src/lib.rs index 40277189a..8af786700 100644 --- a/examples/p2id-note/src/lib.rs +++ b/examples/p2id-note/src/lib.rs @@ -12,7 +12,7 @@ use miden::*; use crate::bindings::miden::basic_wallet::basic_wallet::BasicWallet; #[note_script] -fn run(_arg: Word) { +fn run(_arg: Word, account: BasicWallet) { let inputs = active_note::get_inputs(); let target_account_id_prefix = inputs[0]; let target_account_id_suffix = inputs[1]; @@ -21,9 +21,8 @@ fn run(_arg: Word) { let current_account = active_account::get_id(); assert_eq!(current_account, target_account); - let wallet = BasicWallet::default(); let assets = active_note::get_assets(); for asset in assets { - wallet.receive_asset(asset); + account.receive_asset(asset); } } diff --git a/sdk/base-macros/src/script.rs b/sdk/base-macros/src/script.rs index 8d82a080c..ac1d81abe 100644 --- a/sdk/base-macros/src/script.rs +++ b/sdk/base-macros/src/script.rs @@ -2,7 +2,7 @@ use std::{env, fs, path::Path}; use proc_macro2::{Literal, Span}; use quote::quote; -use syn::{parse_macro_input, spanned::Spanned, FnArg, ItemFn, Pat, PatIdent}; +use syn::{parse_macro_input, spanned::Spanned, FnArg, ItemFn, Pat, PatIdent, Type}; use toml::Value; use crate::{boilerplate::runtime_boilerplate, util::generated_wit_folder_at}; @@ -44,27 +44,12 @@ pub(crate) fn expand( .into(); } - let mut call_args: Vec = Vec::new(); - for arg in &input_fn.sig.inputs { - match arg { - FnArg::Typed(pat_type) => match pat_type.pat.as_ref() { - Pat::Ident(PatIdent { ident, .. }) => call_args.push(ident.clone()), - other => { - return syn::Error::new( - other.span(), - "function arguments must be simple identifiers", - ) - .into_compile_error() - .into(); - } - }, - FnArg::Receiver(receiver) => { - return syn::Error::new(receiver.span(), "unexpected receiver argument") - .into_compile_error() - .into(); - } - } - } + // Parse function arguments, separating the first trait-required argument from + // additional "injected" arguments that will be instantiated by the macro. + let parsed_args = match parse_fn_args(&input_fn) { + Ok(args) => args, + Err(err) => return err.into_compile_error().into(), + }; let inline_wit = match build_script_wit(Span::call_site(), config.export_interface) { Ok(wit) => wit, @@ -73,14 +58,10 @@ pub(crate) fn expand( let inline_literal = Literal::string(&inline_wit); let struct_ident = quote::format_ident!("Struct"); - let fn_inputs = &input_fn.sig.inputs; let fn_output = &input_fn.sig.output; - let call = if call_args.is_empty() { - quote! { #fn_ident() } - } else { - quote! { #fn_ident(#(#call_args),*) } - }; + // Build the call to the user's run function with all arguments + let call = build_run_call(fn_ident, &parsed_args); let export_path: syn::Path = match syn::parse_str(config.guest_trait_path) { Ok(path) => path, @@ -96,6 +77,12 @@ pub(crate) fn expand( let runtime_boilerplate = runtime_boilerplate(); + // Generate the trait's fn signature (only includes the first arg if present) + let trait_fn_inputs = &parsed_args.trait_fn_inputs; + + // Generate instantiation statements for injected wrapper structs + let instantiations = &parsed_args.instantiations; + let expanded = quote! { #runtime_boilerplate @@ -108,7 +95,8 @@ pub(crate) fn expand( pub struct #struct_ident; impl #export_path for #struct_ident { - fn run(#fn_inputs) #fn_output { + fn run(#trait_fn_inputs) #fn_output { + #(#instantiations)* #call; } } @@ -117,6 +105,85 @@ pub(crate) fn expand( expanded.into() } +/// Parsed function arguments separated into trait-required and injected arguments. +struct ParsedFnArgs { + /// The first argument (if any) that matches the trait signature (e.g., `arg: Word`). + /// This is used in the generated trait impl signature. + trait_fn_inputs: proc_macro2::TokenStream, + /// Instantiation statements for injected wrapper structs. + /// Each statement is like `let wallet = Type::default();` + instantiations: Vec, + /// All argument identifiers in order, for calling the user's function. + all_arg_idents: Vec, +} + +/// Parses function arguments, separating the first trait-required argument from +/// additional "injected" arguments whose types will be instantiated via `Default`. +fn parse_fn_args(input_fn: &ItemFn) -> syn::Result { + let mut trait_fn_inputs = proc_macro2::TokenStream::new(); + let mut instantiations = Vec::new(); + let mut all_arg_idents = Vec::new(); + + for (idx, arg) in input_fn.sig.inputs.iter().enumerate() { + match arg { + FnArg::Typed(pat_type) => { + let ident = match pat_type.pat.as_ref() { + Pat::Ident(PatIdent { ident, .. }) => ident.clone(), + other => { + return Err(syn::Error::new( + other.span(), + "function arguments must be simple identifiers", + )); + } + }; + + all_arg_idents.push(ident.clone()); + + if idx == 0 { + // First argument is the trait-required argument (e.g., `arg: Word`) + trait_fn_inputs = quote! { #pat_type }; + } else { + // Additional arguments are injected wrapper structs + let ty = &pat_type.ty; + let instantiation = build_instantiation(&ident, ty); + instantiations.push(instantiation); + } + } + FnArg::Receiver(receiver) => { + return Err(syn::Error::new(receiver.span(), "unexpected receiver argument")); + } + } + } + + Ok(ParsedFnArgs { + trait_fn_inputs, + instantiations, + all_arg_idents, + }) +} + +/// Builds an instantiation statement for an injected wrapper struct. +/// +/// The type path is converted to use `crate::bindings::` prefix to reference +/// the generated bindings module. +fn build_instantiation(ident: &syn::Ident, ty: &Type) -> proc_macro2::TokenStream { + // For now, we just call Default::default() on the type as-is. + // The user is expected to use the full path or import the type. + quote! { + let #ident = <#ty as ::core::default::Default>::default(); + } +} + +/// Builds the call expression to the user's run function with all arguments. +fn build_run_call(fn_ident: &syn::Ident, parsed_args: &ParsedFnArgs) -> proc_macro2::TokenStream { + let args = &parsed_args.all_arg_idents; + if args.is_empty() { + quote! { #fn_ident() } + } else { + quote! { #fn_ident(#(#args),*) } + } +} + fn build_script_wit( error_span: Span, export_interface: &'static str, From 6d05f40444216e85850c2eac259bbfa187368ef8 Mon Sep 17 00:00:00 2001 From: Denys Zadorozhnyi Date: Thu, 27 Nov 2025 09:23:11 +0200 Subject: [PATCH 05/12] refactor: assume the `run` signature is known and expect exactly one generated `struct` to be the second parameter. --- sdk/base-macros/src/script.rs | 131 +++++++++++----------------------- 1 file changed, 41 insertions(+), 90 deletions(-) diff --git a/sdk/base-macros/src/script.rs b/sdk/base-macros/src/script.rs index ac1d81abe..7cfbcf658 100644 --- a/sdk/base-macros/src/script.rs +++ b/sdk/base-macros/src/script.rs @@ -2,7 +2,7 @@ use std::{env, fs, path::Path}; use proc_macro2::{Literal, Span}; use quote::quote; -use syn::{parse_macro_input, spanned::Spanned, FnArg, ItemFn, Pat, PatIdent, Type}; +use syn::{parse_macro_input, spanned::Spanned, FnArg, ItemFn, Pat, PatIdent}; use toml::Value; use crate::{boilerplate::runtime_boilerplate, util::generated_wit_folder_at}; @@ -44,10 +44,11 @@ pub(crate) fn expand( .into(); } - // Parse function arguments, separating the first trait-required argument from - // additional "injected" arguments that will be instantiated by the macro. - let parsed_args = match parse_fn_args(&input_fn) { - Ok(args) => args, + // Parse the optional second parameter (injected wrapper struct). + // The trait requires `fn run(arg: Word)`, so if the user declares a second parameter, + // it will be instantiated via `Default::default()` and passed to the user's function. + let injected_param = match parse_injected_param(&input_fn) { + Ok(param) => param, Err(err) => return err.into_compile_error().into(), }; @@ -58,11 +59,6 @@ pub(crate) fn expand( let inline_literal = Literal::string(&inline_wit); let struct_ident = quote::format_ident!("Struct"); - let fn_output = &input_fn.sig.output; - - // Build the call to the user's run function with all arguments - let call = build_run_call(fn_ident, &parsed_args); - let export_path: syn::Path = match syn::parse_str(config.guest_trait_path) { Ok(path) => path, Err(err) => { @@ -77,11 +73,14 @@ pub(crate) fn expand( let runtime_boilerplate = runtime_boilerplate(); - // Generate the trait's fn signature (only includes the first arg if present) - let trait_fn_inputs = &parsed_args.trait_fn_inputs; - - // Generate instantiation statements for injected wrapper structs - let instantiations = &parsed_args.instantiations; + // Generate the call to the user's function, with optional injected parameter + let (instantiation, call) = match &injected_param { + Some((ident, ty)) => ( + quote! { let #ident = <#ty as ::core::default::Default>::default(); }, + quote! { #fn_ident(arg, #ident) }, + ), + None => (quote! {}, quote! { #fn_ident(arg) }), + }; let expanded = quote! { #runtime_boilerplate @@ -95,8 +94,8 @@ pub(crate) fn expand( pub struct #struct_ident; impl #export_path for #struct_ident { - fn run(#trait_fn_inputs) #fn_output { - #(#instantiations)* + fn run(arg: ::miden::Word) { + #instantiation #call; } } @@ -105,82 +104,34 @@ pub(crate) fn expand( expanded.into() } -/// Parsed function arguments separated into trait-required and injected arguments. -struct ParsedFnArgs { - /// The first argument (if any) that matches the trait signature (e.g., `arg: Word`). - /// This is used in the generated trait impl signature. - trait_fn_inputs: proc_macro2::TokenStream, - /// Instantiation statements for injected wrapper structs. - /// Each statement is like `let wallet = Type::default();` - instantiations: Vec, - /// All argument identifiers in order, for calling the user's function. - all_arg_idents: Vec, -} +/// Parses the optional injected parameter from the user's `fn run` signature. +/// +/// The trait requires `fn run(arg: Word)`. If the user declares a second parameter, +/// it is treated as an "injected" wrapper struct that will be instantiated via +/// `Default::default()` and passed to the user's function. +/// +/// Returns `Some((ident, type))` if a second parameter exists, `None` otherwise. +fn parse_injected_param(input_fn: &ItemFn) -> syn::Result> { + let Some(second_arg) = input_fn.sig.inputs.iter().nth(1) else { + return Ok(None); + }; -/// Parses function arguments, separating the first trait-required argument from -/// additional "injected" arguments whose types will be instantiated via `Default`. -fn parse_fn_args(input_fn: &ItemFn) -> syn::Result { - let mut trait_fn_inputs = proc_macro2::TokenStream::new(); - let mut instantiations = Vec::new(); - let mut all_arg_idents = Vec::new(); - - for (idx, arg) in input_fn.sig.inputs.iter().enumerate() { - match arg { - FnArg::Typed(pat_type) => { - let ident = match pat_type.pat.as_ref() { - Pat::Ident(PatIdent { ident, .. }) => ident.clone(), - other => { - return Err(syn::Error::new( - other.span(), - "function arguments must be simple identifiers", - )); - } - }; - - all_arg_idents.push(ident.clone()); - - if idx == 0 { - // First argument is the trait-required argument (e.g., `arg: Word`) - trait_fn_inputs = quote! { #pat_type }; - } else { - // Additional arguments are injected wrapper structs - let ty = &pat_type.ty; - let instantiation = build_instantiation(&ident, ty); - instantiations.push(instantiation); + match second_arg { + FnArg::Typed(pat_type) => { + let ident = match pat_type.pat.as_ref() { + Pat::Ident(PatIdent { ident, .. }) => ident.clone(), + other => { + return Err(syn::Error::new( + other.span(), + "function arguments must be simple identifiers", + )); } - } - FnArg::Receiver(receiver) => { - return Err(syn::Error::new(receiver.span(), "unexpected receiver argument")); - } + }; + Ok(Some((ident, (*pat_type.ty).clone()))) + } + FnArg::Receiver(receiver) => { + Err(syn::Error::new(receiver.span(), "unexpected receiver argument")) } - } - - Ok(ParsedFnArgs { - trait_fn_inputs, - instantiations, - all_arg_idents, - }) -} - -/// Builds an instantiation statement for an injected wrapper struct. -/// -/// The type path is converted to use `crate::bindings::` prefix to reference -/// the generated bindings module. -fn build_instantiation(ident: &syn::Ident, ty: &Type) -> proc_macro2::TokenStream { - // For now, we just call Default::default() on the type as-is. - // The user is expected to use the full path or import the type. - quote! { - let #ident = <#ty as ::core::default::Default>::default(); - } -} - -/// Builds the call expression to the user's run function with all arguments. -fn build_run_call(fn_ident: &syn::Ident, parsed_args: &ParsedFnArgs) -> proc_macro2::TokenStream { - let args = &parsed_args.all_arg_idents; - if args.is_empty() { - quote! { #fn_ident() } - } else { - quote! { #fn_ident(#(#args),*) } } } From 9e2953085dfdbd8ac815fcbeb522a7ee56a96cb1 Mon Sep 17 00:00:00 2001 From: Denys Zadorozhnyi Date: Thu, 27 Nov 2025 13:00:41 +0200 Subject: [PATCH 06/12] refactor: extract `manifest_paths` module into a separate file --- sdk/base-macros/src/generate.rs | 302 +------------------------- sdk/base-macros/src/lib.rs | 1 + sdk/base-macros/src/manifest_paths.rs | 300 +++++++++++++++++++++++++ sdk/base-macros/src/types.rs | 2 +- 4 files changed, 304 insertions(+), 301 deletions(-) create mode 100644 sdk/base-macros/src/manifest_paths.rs diff --git a/sdk/base-macros/src/generate.rs b/sdk/base-macros/src/generate.rs index 7ca2cc890..c791826fe 100644 --- a/sdk/base-macros/src/generate.rs +++ b/sdk/base-macros/src/generate.rs @@ -1,8 +1,4 @@ -use std::{ - env, fs, - io::ErrorKind, - path::{Path, PathBuf}, -}; +use std::{env, fs, path::PathBuf}; use heck::ToUpperCamelCase; use proc_macro2::{Span, TokenStream as TokenStream2}; @@ -17,10 +13,7 @@ use syn::{ use wit_bindgen_core::wit_parser::{PackageId, Resolve, UnresolvedPackageGroup}; use wit_bindgen_rust::{Opts, WithOption}; -/// File name for the embedded Miden SDK WIT . -const SDK_WIT_FILE_NAME: &str = "miden.wit"; -/// Embedded Miden SDK WIT source. -pub(crate) const SDK_WIT_SOURCE: &str = include_str!("../wit/miden.wit"); +use crate::manifest_paths; #[derive(Default)] struct GenerateArgs { @@ -509,297 +502,6 @@ fn format_module_path(path: &[syn::Ident]) -> String { path.iter().map(|ident| ident.to_string()).collect::>().join("::") } -mod manifest_paths { - use toml::Value; - - use super::*; - use crate::util::bundled_wit_folder; - - /// WIT metadata extracted from the consuming crate. - pub struct ResolvedWit { - pub paths: Vec, - pub world: Option, - } - - #[derive(Default)] - pub struct ResolveOptions { - pub allow_missing_local_wit: bool, - } - - /// Collects WIT search paths and the target world from `Cargo.toml` + local files. - pub fn resolve_wit_paths(options: ResolveOptions) -> Result { - let manifest_dir = env::var("CARGO_MANIFEST_DIR").map_err(|err| { - Error::new(Span::call_site(), format!("failed to read CARGO_MANIFEST_DIR: {err}")) - })?; - let manifest_path = Path::new(&manifest_dir).join("Cargo.toml"); - - let manifest_content = fs::read_to_string(&manifest_path).map_err(|err| { - Error::new( - Span::call_site(), - format!("failed to read manifest '{}': {err}", manifest_path.display()), - ) - })?; - - let manifest: Value = manifest_content.parse().map_err(|err| { - Error::new( - Span::call_site(), - format!("failed to parse manifest '{}': {err}", manifest_path.display()), - ) - })?; - - let canonical_prelude_dir = ensure_sdk_wit()?; - - let mut resolved = Vec::new(); - - let prelude_dir = canonical_prelude_dir - .to_str() - .ok_or_else(|| { - Error::new( - Span::call_site(), - format!("path '{}' contains invalid UTF-8", canonical_prelude_dir.display()), - ) - })? - .to_owned(); - - resolved.push(prelude_dir); - - if let Some(dependencies) = manifest - .get("package") - .and_then(Value::as_table) - .and_then(|package| package.get("metadata")) - .and_then(Value::as_table) - .and_then(|metadata| metadata.get("component")) - .and_then(Value::as_table) - .and_then(|component| component.get("target")) - .and_then(Value::as_table) - .and_then(|target| target.get("dependencies")) - .and_then(Value::as_table) - { - for (name, dependency) in dependencies.iter() { - let table = dependency.as_table().ok_or_else(|| { - Error::new( - Span::call_site(), - format!( - "dependency '{name}' under \ - [package.metadata.component.target.dependencies] must be a table" - ), - ) - })?; - - let path_value = table.get("path").and_then(Value::as_str).ok_or_else(|| { - Error::new( - Span::call_site(), - format!("dependency '{name}' is missing a 'path' entry"), - ) - })?; - - let raw_path = PathBuf::from(path_value); - let absolute = if raw_path.is_absolute() { - raw_path - } else { - Path::new(&manifest_dir).join(&raw_path) - }; - - let canonical = fs::canonicalize(&absolute).unwrap_or_else(|_| absolute.clone()); - - let metadata = fs::metadata(&canonical).map_err(|err| { - Error::new( - Span::call_site(), - format!( - "failed to read metadata for dependency '{name}' path '{}': {err}", - canonical.display() - ), - ) - })?; - - let search_path = if metadata.is_dir() { - canonical - } else if let Some(parent) = canonical.parent() { - parent.to_path_buf() - } else { - return Err(Error::new( - Span::call_site(), - format!( - "dependency '{name}' path '{}' does not have a parent directory", - canonical.display() - ), - )); - }; - - let path_str = search_path.to_str().ok_or_else(|| { - Error::new( - Span::call_site(), - format!("dependency '{name}' path contains invalid UTF-8"), - ) - })?; - - if !resolved.iter().any(|existing| existing == path_str) { - resolved.push(path_str.to_owned()); - } - } - } - - let local_wit_root = Path::new(&manifest_dir).join("wit"); - let mut world = None; - - if local_wit_root.exists() && !options.allow_missing_local_wit { - let local_root = fs::canonicalize(&local_wit_root).unwrap_or(local_wit_root); - let local_root_str = local_root.to_str().ok_or_else(|| { - Error::new( - Span::call_site(), - format!("path '{}' contains invalid UTF-8", local_root.display()), - ) - })?; - if !resolved.iter().any(|existing| existing == local_root_str) { - resolved.push(local_root_str.to_owned()); - } - world = detect_world_name(&local_root)?; - } - - Ok(ResolvedWit { - paths: resolved, - world, - }) - } - - /// Ensures the embedded Miden SDK WIT is materialized in the project's folder. - fn ensure_sdk_wit() -> Result { - let autogenerated_wit_folder = bundled_wit_folder()?; - - let sdk_wit_path = autogenerated_wit_folder.join(super::SDK_WIT_FILE_NAME); - let sdk_version: &str = env!("CARGO_PKG_VERSION"); - let expected_source = format!( - "/// NOTE: This file is auto-generated from the Miden SDK.\n/// Version: \ - v{sdk_version}\n/// Any manual edits will be overwritten.\n\n{SDK_WIT_SOURCE}" - ); - let should_write_wit = match fs::read_to_string(&sdk_wit_path) { - Ok(existing) => existing != expected_source, - Err(err) if err.kind() == ErrorKind::NotFound => true, - Err(err) => { - return Err(Error::new( - Span::call_site(), - format!("failed to read '{}': {err}", sdk_wit_path.display()), - )); - } - }; - - if should_write_wit { - fs::write(&sdk_wit_path, expected_source).map_err(|err| { - Error::new( - Span::call_site(), - format!("failed to write '{}': {err}", sdk_wit_path.display()), - ) - })?; - } - - Ok(fs::canonicalize(&autogenerated_wit_folder).unwrap_or(autogenerated_wit_folder)) - } - - /// Scans the component's `wit` directory to find the default world. - fn detect_world_name(wit_root: &Path) -> Result, Error> { - let mut entries = fs::read_dir(wit_root) - .map_err(|err| { - Error::new( - Span::call_site(), - format!("failed to read '{}': {err}", wit_root.display()), - ) - })? - .collect::, _>>() - .map_err(|err| { - Error::new( - Span::call_site(), - format!("failed to iterate '{}': {err}", wit_root.display()), - ) - })?; - entries.sort_by_key(|entry| entry.file_name()); - - for entry in entries { - let path = entry.path(); - if path.file_name().is_some_and(|name| name == "deps") { - continue; - } - if path.is_dir() { - continue; - } - if path.extension().and_then(|ext| ext.to_str()) != Some("wit") { - continue; - } - - if let Some((package, world)) = parse_package_and_world(&path)? { - return Ok(Some(format!("{package}/{world}"))); - } - } - - Ok(None) - } - - /// Parses a WIT source file for its package declaration and first world definition. - fn parse_package_and_world(path: &Path) -> Result, Error> { - let contents = fs::read_to_string(path).map_err(|err| { - Error::new( - Span::call_site(), - format!("failed to read WIT file '{}': {err}", path.display()), - ) - })?; - - let package = extract_package_name(&contents); - let world = extract_world_name(&contents); - - match (package, world) { - (Some(package), Some(world)) => Ok(Some((package, world))), - _ => Ok(None), - } - } - - /// Returns the package identifier from a WIT source string, if present. - fn extract_package_name(contents: &str) -> Option { - for line in contents.lines() { - let trimmed = strip_comment(line).trim_start(); - if let Some(rest) = trimmed.strip_prefix("package ") { - let mut token = rest.trim(); - if let Some(idx) = token.find(';') { - token = &token[..idx]; - } - let mut name = token.trim(); - if let Some(idx) = name.find('@') { - name = &name[..idx]; - } - return Some(name.trim().to_string()); - } - } - None - } - - /// Returns the first world identifier from a WIT source string, if present. - pub(super) fn extract_world_name(contents: &str) -> Option { - for line in contents.lines() { - let trimmed = strip_comment(line).trim_start(); - if let Some(rest) = trimmed.strip_prefix("world ") { - let mut name = String::new(); - for ch in rest.trim().chars() { - if ch.is_alphanumeric() || ch == '-' || ch == '_' { - name.push(ch); - } else { - break; - } - } - if !name.is_empty() { - return Some(name); - } - } - } - None - } - - /// Strips line comments starting with `//` from the provided source line. - fn strip_comment(line: &str) -> &str { - match line.split_once("//") { - Some((before, _)) => before, - None => line, - } - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/sdk/base-macros/src/lib.rs b/sdk/base-macros/src/lib.rs index a93e60893..c84b78118 100644 --- a/sdk/base-macros/src/lib.rs +++ b/sdk/base-macros/src/lib.rs @@ -55,6 +55,7 @@ mod boilerplate; mod component_macro; mod export_type; mod generate; +mod manifest_paths; mod script; mod types; mod util; diff --git a/sdk/base-macros/src/manifest_paths.rs b/sdk/base-macros/src/manifest_paths.rs new file mode 100644 index 000000000..a92861316 --- /dev/null +++ b/sdk/base-macros/src/manifest_paths.rs @@ -0,0 +1,300 @@ +//! Utilities for resolving WIT paths from Cargo.toml manifest metadata. + +use std::{ + env, fs, + io::ErrorKind, + path::{Path, PathBuf}, +}; + +use proc_macro2::Span; +use syn::Error; +use toml::Value; + +use crate::util::bundled_wit_folder; + +/// File name for the embedded Miden SDK WIT. +const SDK_WIT_FILE_NAME: &str = "miden.wit"; + +/// Embedded Miden SDK WIT source. +pub(crate) const SDK_WIT_SOURCE: &str = include_str!("../wit/miden.wit"); + +/// WIT metadata extracted from the consuming crate. +pub(crate) struct ResolvedWit { + pub paths: Vec, + pub world: Option, +} + +#[derive(Default)] +pub(crate) struct ResolveOptions { + pub allow_missing_local_wit: bool, +} + +/// Collects WIT search paths and the target world from `Cargo.toml` + local files. +pub(crate) fn resolve_wit_paths(options: ResolveOptions) -> Result { + let manifest_dir = env::var("CARGO_MANIFEST_DIR").map_err(|err| { + Error::new(Span::call_site(), format!("failed to read CARGO_MANIFEST_DIR: {err}")) + })?; + let manifest_path = Path::new(&manifest_dir).join("Cargo.toml"); + + let manifest_content = fs::read_to_string(&manifest_path).map_err(|err| { + Error::new( + Span::call_site(), + format!("failed to read manifest '{}': {err}", manifest_path.display()), + ) + })?; + + let manifest: Value = manifest_content.parse().map_err(|err| { + Error::new( + Span::call_site(), + format!("failed to parse manifest '{}': {err}", manifest_path.display()), + ) + })?; + + let canonical_prelude_dir = ensure_sdk_wit()?; + + let mut resolved = Vec::new(); + + let prelude_dir = canonical_prelude_dir + .to_str() + .ok_or_else(|| { + Error::new( + Span::call_site(), + format!("path '{}' contains invalid UTF-8", canonical_prelude_dir.display()), + ) + })? + .to_owned(); + + resolved.push(prelude_dir); + + if let Some(dependencies) = manifest + .get("package") + .and_then(Value::as_table) + .and_then(|package| package.get("metadata")) + .and_then(Value::as_table) + .and_then(|metadata| metadata.get("component")) + .and_then(Value::as_table) + .and_then(|component| component.get("target")) + .and_then(Value::as_table) + .and_then(|target| target.get("dependencies")) + .and_then(Value::as_table) + { + for (name, dependency) in dependencies.iter() { + let table = dependency.as_table().ok_or_else(|| { + Error::new( + Span::call_site(), + format!( + "dependency '{name}' under \ + [package.metadata.component.target.dependencies] must be a table" + ), + ) + })?; + + let path_value = table.get("path").and_then(Value::as_str).ok_or_else(|| { + Error::new( + Span::call_site(), + format!("dependency '{name}' is missing a 'path' entry"), + ) + })?; + + let raw_path = PathBuf::from(path_value); + let absolute = if raw_path.is_absolute() { + raw_path + } else { + Path::new(&manifest_dir).join(&raw_path) + }; + + let canonical = fs::canonicalize(&absolute).unwrap_or_else(|_| absolute.clone()); + + let metadata = fs::metadata(&canonical).map_err(|err| { + Error::new( + Span::call_site(), + format!( + "failed to read metadata for dependency '{name}' path '{}': {err}", + canonical.display() + ), + ) + })?; + + let search_path = if metadata.is_dir() { + canonical + } else if let Some(parent) = canonical.parent() { + parent.to_path_buf() + } else { + return Err(Error::new( + Span::call_site(), + format!( + "dependency '{name}' path '{}' does not have a parent directory", + canonical.display() + ), + )); + }; + + let path_str = search_path.to_str().ok_or_else(|| { + Error::new( + Span::call_site(), + format!("dependency '{name}' path contains invalid UTF-8"), + ) + })?; + + if !resolved.iter().any(|existing| existing == path_str) { + resolved.push(path_str.to_owned()); + } + } + } + + let local_wit_root = Path::new(&manifest_dir).join("wit"); + let mut world = None; + + if local_wit_root.exists() && !options.allow_missing_local_wit { + let local_root = fs::canonicalize(&local_wit_root).unwrap_or(local_wit_root); + let local_root_str = local_root.to_str().ok_or_else(|| { + Error::new( + Span::call_site(), + format!("path '{}' contains invalid UTF-8", local_root.display()), + ) + })?; + if !resolved.iter().any(|existing| existing == local_root_str) { + resolved.push(local_root_str.to_owned()); + } + world = detect_world_name(&local_root)?; + } + + Ok(ResolvedWit { + paths: resolved, + world, + }) +} + +/// Ensures the embedded Miden SDK WIT is materialized in the project's folder. +fn ensure_sdk_wit() -> Result { + let autogenerated_wit_folder = bundled_wit_folder()?; + + let sdk_wit_path = autogenerated_wit_folder.join(SDK_WIT_FILE_NAME); + let sdk_version: &str = env!("CARGO_PKG_VERSION"); + let expected_source = format!( + "/// NOTE: This file is auto-generated from the Miden SDK.\n/// Version: \ + v{sdk_version}\n/// Any manual edits will be overwritten.\n\n{SDK_WIT_SOURCE}" + ); + let should_write_wit = match fs::read_to_string(&sdk_wit_path) { + Ok(existing) => existing != expected_source, + Err(err) if err.kind() == ErrorKind::NotFound => true, + Err(err) => { + return Err(Error::new( + Span::call_site(), + format!("failed to read '{}': {err}", sdk_wit_path.display()), + )); + } + }; + + if should_write_wit { + fs::write(&sdk_wit_path, expected_source).map_err(|err| { + Error::new( + Span::call_site(), + format!("failed to write '{}': {err}", sdk_wit_path.display()), + ) + })?; + } + + Ok(fs::canonicalize(&autogenerated_wit_folder).unwrap_or(autogenerated_wit_folder)) +} + +/// Scans the component's `wit` directory to find the default world. +fn detect_world_name(wit_root: &Path) -> Result, Error> { + let mut entries = fs::read_dir(wit_root) + .map_err(|err| { + Error::new(Span::call_site(), format!("failed to read '{}': {err}", wit_root.display())) + })? + .collect::, _>>() + .map_err(|err| { + Error::new( + Span::call_site(), + format!("failed to iterate '{}': {err}", wit_root.display()), + ) + })?; + entries.sort_by_key(|entry| entry.file_name()); + + for entry in entries { + let path = entry.path(); + if path.file_name().is_some_and(|name| name == "deps") { + continue; + } + if path.is_dir() { + continue; + } + if path.extension().and_then(|ext| ext.to_str()) != Some("wit") { + continue; + } + + if let Some((package, world)) = parse_package_and_world(&path)? { + return Ok(Some(format!("{package}/{world}"))); + } + } + + Ok(None) +} + +/// Parses a WIT source file for its package declaration and first world definition. +fn parse_package_and_world(path: &Path) -> Result, Error> { + let contents = fs::read_to_string(path).map_err(|err| { + Error::new( + Span::call_site(), + format!("failed to read WIT file '{}': {err}", path.display()), + ) + })?; + + let package = extract_package_name(&contents); + let world = extract_world_name(&contents); + + match (package, world) { + (Some(package), Some(world)) => Ok(Some((package, world))), + _ => Ok(None), + } +} + +/// Returns the package identifier from a WIT source string, if present. +fn extract_package_name(contents: &str) -> Option { + for line in contents.lines() { + let trimmed = strip_comment(line).trim_start(); + if let Some(rest) = trimmed.strip_prefix("package ") { + let mut token = rest.trim(); + if let Some(idx) = token.find(';') { + token = &token[..idx]; + } + let mut name = token.trim(); + if let Some(idx) = name.find('@') { + name = &name[..idx]; + } + return Some(name.trim().to_string()); + } + } + None +} + +/// Returns the first world identifier from a WIT source string, if present. +pub(crate) fn extract_world_name(contents: &str) -> Option { + for line in contents.lines() { + let trimmed = strip_comment(line).trim_start(); + if let Some(rest) = trimmed.strip_prefix("world ") { + let mut name = String::new(); + for ch in rest.trim().chars() { + if ch.is_alphanumeric() || ch == '-' || ch == '_' { + name.push(ch); + } else { + break; + } + } + if !name.is_empty() { + return Some(name); + } + } + } + None +} + +/// Strips line comments starting with `//` from the provided source line. +fn strip_comment(line: &str) -> &str { + match line.split_once("//") { + Some((before, _)) => before, + None => line, + } +} diff --git a/sdk/base-macros/src/types.rs b/sdk/base-macros/src/types.rs index ea01d012f..9951c56cf 100644 --- a/sdk/base-macros/src/types.rs +++ b/sdk/base-macros/src/types.rs @@ -9,7 +9,7 @@ use heck::ToKebabCase; use proc_macro2::Span; use syn::{spanned::Spanned, ItemStruct, Type}; -use crate::generate::SDK_WIT_SOURCE; +use crate::manifest_paths::SDK_WIT_SOURCE; #[derive(Clone, Debug)] pub(crate) struct TypeRef { From 32b2d640e8feadd64f414c5e0286f1f805887f46 Mon Sep 17 00:00:00 2001 From: Denys Zadorozhnyi Date: Thu, 27 Nov 2025 15:27:01 +0200 Subject: [PATCH 07/12] refactor: move `struct` generation up and call it `bindings::Account` --- examples/basic-wallet-tx-script/src/lib.rs | 4 +- examples/p2id-note/src/lib.rs | 4 +- sdk/base-macros/src/generate.rs | 307 ++++++++++++--------- 3 files changed, 182 insertions(+), 133 deletions(-) diff --git a/examples/basic-wallet-tx-script/src/lib.rs b/examples/basic-wallet-tx-script/src/lib.rs index 220644b10..d33761cef 100644 --- a/examples/basic-wallet-tx-script/src/lib.rs +++ b/examples/basic-wallet-tx-script/src/lib.rs @@ -10,7 +10,7 @@ use miden::{intrinsics::advice::adv_push_mapvaln, *}; -use crate::bindings::miden::basic_wallet::basic_wallet::BasicWallet; +use crate::bindings::Account; // Input layout constants const TAG_INDEX: usize = 0; @@ -23,7 +23,7 @@ const ASSET_START: usize = 8; const ASSET_END: usize = 12; #[tx_script] -fn run(arg: Word, account: BasicWallet) { +fn run(arg: Word, account: Account) { let num_felts = adv_push_mapvaln(arg.clone()); let num_felts_u64 = num_felts.as_u64(); assert_eq(Felt::from_u32((num_felts_u64 % 4) as u32), felt!(0)); diff --git a/examples/p2id-note/src/lib.rs b/examples/p2id-note/src/lib.rs index 8af786700..14aecef8a 100644 --- a/examples/p2id-note/src/lib.rs +++ b/examples/p2id-note/src/lib.rs @@ -9,10 +9,10 @@ use miden::*; -use crate::bindings::miden::basic_wallet::basic_wallet::BasicWallet; +use crate::bindings::Account; #[note_script] -fn run(_arg: Word, account: BasicWallet) { +fn run(_arg: Word, account: Account) { let inputs = active_note::get_inputs(); let target_account_id_prefix = inputs[0]; let target_account_id_suffix = inputs[1]; diff --git a/sdk/base-macros/src/generate.rs b/sdk/base-macros/src/generate.rs index c791826fe..5841241ce 100644 --- a/sdk/base-macros/src/generate.rs +++ b/sdk/base-macros/src/generate.rs @@ -1,14 +1,14 @@ use std::{env, fs, path::PathBuf}; -use heck::ToUpperCamelCase; use proc_macro2::{Span, TokenStream as TokenStream2}; use quote::{quote, ToTokens}; use syn::{ parse::{Parse, ParseStream}, parse_quote, spanned::Spanned, + visit_mut::VisitMut, Attribute, Error, File, FnArg, ImplItem, ImplItemFn, Item, ItemFn, ItemImpl, ItemStruct, - LitStr, Pat, ReturnType, Token, + LitStr, Pat, ReturnType, Token, TypePath, }; use wit_bindgen_core::wit_parser::{PackageId, Resolve, UnresolvedPackageGroup}; use wit_bindgen_rust::{Opts, WithOption}; @@ -215,13 +215,33 @@ fn generate_bindings( /// Post-processes wit-bindgen output to inject wrapper structs for imported interfaces. /// -/// This transforms the raw bindings by walking all modules and injecting a wrapper struct -/// with methods that delegate to the generated free functions. This provides a more -/// ergonomic API (e.g., `BasicWallet::default().receive_asset(asset)` instead of -/// `basic_wallet::receive_asset(asset)`). +/// This transforms the raw bindings by walking all modules and injecting an `Account` wrapper +/// struct at the bindings root level. The struct has methods that delegate to the generated +/// free functions in leaf modules. This provides a more ergonomic API +/// (e.g., `Account::default().receive_asset(asset)` instead of +/// `miden::basic_wallet::basic_wallet::receive_asset(asset)`). fn augment_generated_bindings(tokens: TokenStream2) -> syn::Result { let mut file: File = syn::parse2(tokens)?; - transform_modules(&mut file.items, &mut Vec::new())?; + let mut collected_methods = Vec::new(); + collect_wrapper_methods(&file.items, &mut Vec::new(), &mut collected_methods)?; + + if !collected_methods.is_empty() { + let struct_ident = syn::Ident::new("Account", Span::call_site()); + let struct_item: ItemStruct = parse_quote! { + /// Wrapper struct providing methods that delegate to imported interface functions. + #[derive(Clone, Copy, Default)] + pub struct #struct_ident; + }; + + let mut impl_item: ItemImpl = parse_quote! { + impl #struct_ident {} + }; + impl_item.items.extend(collected_methods.into_iter().map(ImplItem::Fn)); + + file.items.push(Item::Struct(struct_item)); + file.items.push(Item::Impl(impl_item)); + } + Ok(file.into_token_stream()) } @@ -311,16 +331,21 @@ fn push_path_entry(opts: &mut Opts, key: &str, value: &str) { opts.with.push((key.to_string(), WithOption::Path(value.to_string()))); } -/// Recursively walks all modules and injects wrapper structs where appropriate. +/// Recursively walks all modules and collects wrapper methods from leaf modules. /// -/// The `path` parameter tracks the current module path for naming and call generation. -fn transform_modules(items: &mut [Item], path: &mut Vec) -> syn::Result<()> { - for item in items.iter_mut() { +/// The `path` parameter tracks the current module path for generating correct call paths. +/// Collected methods are appended to `methods_out` and will be placed in the root `Account` struct. +fn collect_wrapper_methods( + items: &[Item], + path: &mut Vec, + methods_out: &mut Vec, +) -> syn::Result<()> { + for item in items.iter() { if let Item::Mod(module) = item { path.push(module.ident.clone()); - if let Some((_, ref mut content)) = module.content { - transform_modules(content, path)?; - maybe_inject_struct_wrapper(content, path)?; + if let Some((_, ref content)) = module.content { + collect_wrapper_methods(content, path, methods_out)?; + collect_methods_from_module(content, path, methods_out)?; } path.pop(); } @@ -329,76 +354,46 @@ fn transform_modules(items: &mut [Item], path: &mut Vec) -> syn::Res Ok(()) } -/// Injects a wrapper struct and impl block for public functions in a leaf module. +/// Collects wrapper methods from a leaf module's public functions. /// -/// A leaf module is one that contains no nested modules. Only leaf modules get wrapper -/// structs generated, as non-leaf modules typically represent namespace hierarchy rather -/// than concrete interfaces. -/// -/// Note: We need `&mut Vec` here (not `&mut [Item]`) because we push new items -/// (the struct and impl block) to the vector. -fn maybe_inject_struct_wrapper(items: &mut Vec, path: &[syn::Ident]) -> syn::Result<()> { +/// A leaf module is one that contains no nested modules. Only leaf modules contribute +/// methods, as non-leaf modules typically represent namespace hierarchy rather than +/// concrete interfaces. +fn collect_methods_from_module( + items: &[Item], + path: &[syn::Ident], + methods_out: &mut Vec, +) -> syn::Result<()> { if !should_generate_struct(path, items) { return Ok(()); } - let functions: Vec = items + let functions: Vec<&ItemFn> = items .iter() .filter_map(|item| match item { - Item::Fn(func) if is_target_function(func) => Some(func.clone()), + Item::Fn(func) if is_target_function(func) => Some(func), _ => None, }) .collect(); - if functions.is_empty() { - return Ok(()); - } - - let module_ident = - path.last().ok_or_else(|| Error::new(Span::call_site(), "empty module path"))?; - let struct_ident = - syn::Ident::new(&module_ident.to_string().to_upper_camel_case(), module_ident.span()); - - if items - .iter() - .any(|item| matches!(item, Item::Struct(existing) if existing.ident == struct_ident)) - { - return Ok(()); - } - - let struct_doc = - format!("Wrapper for functions defined in module `{}`.", format_module_path(path)); - let struct_item: ItemStruct = parse_quote! { - #[doc = #struct_doc] - #[derive(Clone, Copy, Default)] - pub struct #struct_ident; - }; - - let mut methods = Vec::new(); for func in functions { - methods.push(build_wrapper_method(&func, path)?); - } - - if methods.is_empty() { - return Ok(()); + methods_out.push(build_wrapper_method(func, path)?); } - let mut impl_item: ItemImpl = parse_quote! { - impl #struct_ident {} - }; - impl_item.items.extend(methods.into_iter().map(ImplItem::Fn)); - - items.push(Item::Struct(struct_item)); - items.push(Item::Impl(impl_item)); - Ok(()) } /// Builds a wrapper method that delegates to the original free function. +/// +/// Type paths in the signature are qualified with the module path prefix so they +/// resolve correctly when the method is placed at the bindings root level. fn build_wrapper_method(func: &ItemFn, module_path: &[syn::Ident]) -> syn::Result { let mut sig = func.sig.clone(); sig.inputs.insert(0, parse_quote!(&self)); + // Qualify type paths in the signature so they resolve from the bindings root + qualify_signature_types(&mut sig, module_path); + let arg_idents = collect_arg_idents(func)?; let call_expr = wrapper_call_tokens(module_path, &sig.ident, &arg_idents); @@ -422,6 +417,87 @@ fn build_wrapper_method(func: &ItemFn, module_path: &[syn::Ident]) -> syn::Resul }) } +/// Qualifies type paths in a function signature with the module path prefix. +/// +/// This transforms simple type names (e.g., `StructA`) into fully qualified paths +/// (e.g., `miden::component::component::StructA`) so they resolve correctly when +/// the method is placed at the bindings root level. +fn qualify_signature_types(sig: &mut syn::Signature, module_path: &[syn::Ident]) { + struct TypeQualifier<'a> { + module_path: &'a [syn::Ident], + } + + impl VisitMut for TypeQualifier<'_> { + fn visit_type_path_mut(&mut self, type_path: &mut TypePath) { + // Only qualify paths that: + // 1. Don't already have a leading colon (not absolute like `::foo`) + // 2. Are simple single-segment paths (like `StructA`, not `foo::Bar`) + // 3. Don't start with common primitive/std type names + if type_path.qself.is_none() + && type_path.path.leading_colon.is_none() + && type_path.path.segments.len() == 1 + { + let first_segment = &type_path.path.segments[0].ident; + let name = first_segment.to_string(); + + // Skip primitive types and common std types + if is_primitive_or_std_type(&name) { + return; + } + + // Build the qualified path: module_path::TypeName + let mut new_segments = syn::punctuated::Punctuated::new(); + for ident in self.module_path { + new_segments.push(syn::PathSegment { + ident: ident.clone(), + arguments: syn::PathArguments::None, + }); + } + // Add the original type segment (preserving generics) + new_segments.push(type_path.path.segments[0].clone()); + + type_path.path.segments = new_segments; + } + + // Continue visiting nested types (e.g., generics) + syn::visit_mut::visit_type_path_mut(self, type_path); + } + } + + let mut qualifier = TypeQualifier { module_path }; + qualifier.visit_signature_mut(sig); +} + +/// Returns true if the name is a primitive type or common std type that shouldn't be qualified. +fn is_primitive_or_std_type(name: &str) -> bool { + matches!( + name, + "bool" + | "char" + | "str" + | "u8" + | "u16" + | "u32" + | "u64" + | "u128" + | "usize" + | "i8" + | "i16" + | "i32" + | "i64" + | "i128" + | "isize" + | "f32" + | "f64" + | "String" + | "Vec" + | "Option" + | "Result" + | "Box" + | "Self" + ) +} + /// Extracts argument identifiers from a function signature. /// /// Returns an error if the function contains a receiver (`self`) or uses @@ -634,7 +710,7 @@ mod tests { } #[test] - fn test_transform_modules_injects_struct() { + fn test_collect_wrapper_methods_from_leaf_module() { let src = r#" mod miden { mod basic_wallet { @@ -645,48 +721,21 @@ mod tests { } } "#; - let mut file = parse_file(src); - transform_modules(&mut file.items, &mut Vec::new()).unwrap(); + let file = parse_file(src); + let mut methods = Vec::new(); + collect_wrapper_methods(&file.items, &mut Vec::new(), &mut methods).unwrap(); - // Check that the innermost module now contains a struct and impl - let miden_mod = match &file.items[0] { - Item::Mod(m) => m, - _ => panic!("expected mod"), - }; - let basic_wallet_outer = match &miden_mod.content.as_ref().unwrap().1[0] { - Item::Mod(m) => m, - _ => panic!("expected mod"), - }; - let basic_wallet_inner = match &basic_wallet_outer.content.as_ref().unwrap().1[0] { - Item::Mod(m) => m, - _ => panic!("expected mod"), - }; - let inner_items = &basic_wallet_inner.content.as_ref().unwrap().1; - - // Should have: 2 functions + 1 struct + 1 impl = 4 items - assert_eq!(inner_items.len(), 4); - - // Check struct exists and has correct name - let struct_item = inner_items.iter().find_map(|item| match item { - Item::Struct(s) => Some(s), - _ => None, - }); - assert!(struct_item.is_some()); - assert_eq!(struct_item.unwrap().ident.to_string(), "BasicWallet"); + // Should have collected 2 methods from the leaf module + assert_eq!(methods.len(), 2); - // Check impl exists - let impl_item = inner_items.iter().find_map(|item| match item { - Item::Impl(i) => Some(i), - _ => None, - }); - assert!(impl_item.is_some()); - let impl_block = impl_item.unwrap(); - // Should have 2 methods - assert_eq!(impl_block.items.len(), 2); + // Check method names + let method_names: Vec<_> = methods.iter().map(|m| m.sig.ident.to_string()).collect(); + assert!(method_names.contains(&"receive_asset".to_string())); + assert!(method_names.contains(&"send_asset".to_string())); } #[test] - fn test_transform_modules_skips_exports() { + fn test_collect_wrapper_methods_skips_exports() { let src = r#" mod exports { mod my_component { @@ -694,48 +743,48 @@ mod tests { } } "#; - let mut file = parse_file(src); - transform_modules(&mut file.items, &mut Vec::new()).unwrap(); - - // exports module should not have any struct injected - let exports_mod = match &file.items[0] { - Item::Mod(m) => m, - _ => panic!("expected mod"), - }; - let my_component = match &exports_mod.content.as_ref().unwrap().1[0] { - Item::Mod(m) => m, - _ => panic!("expected mod"), - }; - let items = &my_component.content.as_ref().unwrap().1; + let file = parse_file(src); + let mut methods = Vec::new(); + collect_wrapper_methods(&file.items, &mut Vec::new(), &mut methods).unwrap(); - // Should only have the original function, no struct added - assert_eq!(items.len(), 1); - assert!(matches!(items[0], Item::Fn(_))); + // exports module should not contribute any methods + assert!(methods.is_empty()); } #[test] - fn test_transform_modules_skips_empty_modules() { + fn test_collect_wrapper_methods_skips_empty_modules() { let src = r#" mod miden { mod empty_module { } } "#; - let mut file = parse_file(src); - transform_modules(&mut file.items, &mut Vec::new()).unwrap(); + let file = parse_file(src); + let mut methods = Vec::new(); + collect_wrapper_methods(&file.items, &mut Vec::new(), &mut methods).unwrap(); - let miden_mod = match &file.items[0] { - Item::Mod(m) => m, - _ => panic!("expected mod"), - }; - let empty_module = match &miden_mod.content.as_ref().unwrap().1[0] { - Item::Mod(m) => m, - _ => panic!("expected mod"), + // No methods should be collected from empty module + assert!(methods.is_empty()); + } + + #[test] + fn test_qualify_signature_types() { + let func: ItemFn = syn::parse_quote! { + pub fn test_fn(a: StructA, b: u64) -> StructB {} }; - let items = &empty_module.content.as_ref().unwrap().1; + let path = vec![ + syn::Ident::new("miden", Span::call_site()), + syn::Ident::new("component", Span::call_site()), + ]; + let method = build_wrapper_method(&func, &path).unwrap(); - // Should remain empty - assert!(items.is_empty()); + // Check that the types are qualified + let sig_str = method.sig.to_token_stream().to_string(); + assert!(sig_str.contains("miden :: component :: StructA")); + assert!(sig_str.contains("miden :: component :: StructB")); + // Primitives should not be qualified + assert!(sig_str.contains("u64")); + assert!(!sig_str.contains("miden :: component :: u64")); } #[test] From 88eaf94c2742e08174082ff7f260f79fcb459e78 Mon Sep 17 00:00:00 2001 From: Denys Zadorozhnyi Date: Thu, 27 Nov 2025 17:17:11 +0200 Subject: [PATCH 08/12] refactor: de-duplicate code, add comments, test --- sdk/base-macros/src/generate.rs | 140 +++++++++++++++++++++++++- sdk/base-macros/src/manifest_paths.rs | 16 +-- sdk/base-macros/src/script.rs | 16 ++- sdk/base-macros/src/util.rs | 10 ++ 4 files changed, 155 insertions(+), 27 deletions(-) diff --git a/sdk/base-macros/src/generate.rs b/sdk/base-macros/src/generate.rs index 5841241ce..3faca5787 100644 --- a/sdk/base-macros/src/generate.rs +++ b/sdk/base-macros/src/generate.rs @@ -245,14 +245,15 @@ fn augment_generated_bindings(tokens: TokenStream2) -> syn::Result Ok(file.into_token_stream()) } -/// Result of loading WIT sources. +/// Result of loading and parsing WIT sources from file paths and optional inline content. struct LoadedWitSources { - /// The resolved WIT definitions. + /// The resolved WIT definitions containing all types, interfaces, and worlds. resolve: Resolve, - /// Package IDs to use for world selection. + /// Package IDs to use for world selection. When inline source is provided, this contains + /// only the inline package; otherwise it contains all packages from file paths. packages: Vec, - /// File paths that were read to include a dummy `include_bytes!` so rustc knows that we depend - /// on the contents of those files. + /// File paths that were read during WIT parsing. Used to generate dummy `include_bytes!` + /// calls so rustc knows to recompile when these files change. files_read: Vec, } @@ -820,4 +821,133 @@ mod tests { // Return type should be preserved assert!(matches!(method.sig.output, ReturnType::Type(_, _))); } + + #[test] + fn test_augment_generated_bindings_adds_account_struct() { + let src = r#" + mod miden { + mod basic_wallet { + mod basic_wallet { + pub fn receive_asset(asset: u64) {} + pub fn send_asset(to: u32, amount: u64) -> bool { true } + } + } + } + "#; + let tokens: TokenStream2 = src.parse().unwrap(); + let result = augment_generated_bindings(tokens).unwrap(); + let result_str = result.to_string(); + + // Should contain the Account struct + assert!(result_str.contains("struct Account")); + assert!(result_str.contains("impl Account")); + + // Should contain wrapper methods + assert!(result_str.contains("fn receive_asset")); + assert!(result_str.contains("fn send_asset")); + + // Methods should have &self parameter + assert!(result_str.contains("& self")); + } + + #[test] + fn test_augment_generated_bindings_empty_input() { + let src = ""; + let tokens: TokenStream2 = src.parse().unwrap(); + let result = augment_generated_bindings(tokens).unwrap(); + let result_str = result.to_string(); + + // Should not add Account struct when there are no methods + assert!(!result_str.contains("struct Account")); + } + + #[test] + fn test_augment_generated_bindings_exports_only() { + let src = r#" + mod exports { + mod my_component { + pub fn exported_fn() {} + } + } + "#; + let tokens: TokenStream2 = src.parse().unwrap(); + let result = augment_generated_bindings(tokens).unwrap(); + let result_str = result.to_string(); + + // Should not add Account struct for exports-only bindings + assert!(!result_str.contains("struct Account")); + } + + #[test] + fn test_augment_generated_bindings_preserves_original_modules() { + let src = r#" + mod miden { + mod wallet { + pub fn get_balance() -> u64 { 0 } + } + } + "#; + let tokens: TokenStream2 = src.parse().unwrap(); + let result = augment_generated_bindings(tokens).unwrap(); + let result_str = result.to_string(); + + // Original module structure should be preserved + assert!(result_str.contains("mod miden")); + assert!(result_str.contains("mod wallet")); + assert!(result_str.contains("fn get_balance")); + } + + #[test] + fn test_wrapper_call_tokens_generates_correct_path() { + let path = vec![ + syn::Ident::new("miden", Span::call_site()), + syn::Ident::new("basic_wallet", Span::call_site()), + ]; + let fn_ident = syn::Ident::new("receive_asset", Span::call_site()); + let args = vec![syn::Ident::new("asset", Span::call_site())]; + + let tokens = wrapper_call_tokens(&path, &fn_ident, &args); + let result = tokens.to_string(); + + assert!(result.contains("crate :: bindings :: miden :: basic_wallet :: receive_asset")); + assert!(result.contains("asset")); + } + + #[test] + fn test_parse_with_entry_generate() { + let input: TokenStream2 = quote! { "miden:foo/bar": generate }; + let parsed = syn::parse2::(quote! { with = { #input } }).unwrap(); + + assert_eq!(parsed.with_entries.len(), 1); + assert_eq!(parsed.with_entries[0].0, "miden:foo/bar"); + assert!(matches!(parsed.with_entries[0].1, WithOption::Generate)); + } + + #[test] + fn test_parse_with_entry_path() { + let input: TokenStream2 = quote! { "miden:foo/bar": ::my::custom::Type }; + let parsed = syn::parse2::(quote! { with = { #input } }).unwrap(); + + assert_eq!(parsed.with_entries.len(), 1); + assert_eq!(parsed.with_entries[0].0, "miden:foo/bar"); + match &parsed.with_entries[0].1 { + WithOption::Path(p) => assert_eq!(p, "::my::custom::Type"), + _ => panic!("expected Path variant"), + } + } + + #[test] + fn test_parse_multiple_with_entries() { + let parsed = syn::parse2::(quote! { + with = { + "miden:a/b": generate, + "miden:c/d": ::foo::Bar + } + }) + .unwrap(); + + assert_eq!(parsed.with_entries.len(), 2); + assert_eq!(parsed.with_entries[0].0, "miden:a/b"); + assert_eq!(parsed.with_entries[1].0, "miden:c/d"); + } } diff --git a/sdk/base-macros/src/manifest_paths.rs b/sdk/base-macros/src/manifest_paths.rs index a92861316..9850ff521 100644 --- a/sdk/base-macros/src/manifest_paths.rs +++ b/sdk/base-macros/src/manifest_paths.rs @@ -10,7 +10,7 @@ use proc_macro2::Span; use syn::Error; use toml::Value; -use crate::util::bundled_wit_folder; +use crate::util::{bundled_wit_folder, strip_line_comment}; /// File name for the embedded Miden SDK WIT. const SDK_WIT_FILE_NAME: &str = "miden.wit"; @@ -254,7 +254,7 @@ fn parse_package_and_world(path: &Path) -> Result, Erro /// Returns the package identifier from a WIT source string, if present. fn extract_package_name(contents: &str) -> Option { for line in contents.lines() { - let trimmed = strip_comment(line).trim_start(); + let trimmed = strip_line_comment(line).trim_start(); if let Some(rest) = trimmed.strip_prefix("package ") { let mut token = rest.trim(); if let Some(idx) = token.find(';') { @@ -270,10 +270,10 @@ fn extract_package_name(contents: &str) -> Option { None } -/// Returns the first world identifier from a WIT source string, if present. +/// Extracts the first world identifier from a WIT source string. pub(crate) fn extract_world_name(contents: &str) -> Option { for line in contents.lines() { - let trimmed = strip_comment(line).trim_start(); + let trimmed = strip_line_comment(line).trim_start(); if let Some(rest) = trimmed.strip_prefix("world ") { let mut name = String::new(); for ch in rest.trim().chars() { @@ -290,11 +290,3 @@ pub(crate) fn extract_world_name(contents: &str) -> Option { } None } - -/// Strips line comments starting with `//` from the provided source line. -fn strip_comment(line: &str) -> &str { - match line.split_once("//") { - Some((before, _)) => before, - None => line, - } -} diff --git a/sdk/base-macros/src/script.rs b/sdk/base-macros/src/script.rs index 7cfbcf658..d3b9b7230 100644 --- a/sdk/base-macros/src/script.rs +++ b/sdk/base-macros/src/script.rs @@ -5,7 +5,10 @@ use quote::quote; use syn::{parse_macro_input, spanned::Spanned, FnArg, ItemFn, Pat, PatIdent}; use toml::Value; -use crate::{boilerplate::runtime_boilerplate, util::generated_wit_folder_at}; +use crate::{ + boilerplate::runtime_boilerplate, + util::{generated_wit_folder_at, strip_line_comment}, +}; const SCRIPT_PACKAGE_VERSION: &str = "1.0.0"; @@ -359,7 +362,7 @@ fn parse_wit_file(path: &Path) -> Result, String> { fn extract_package_identifier(contents: &str) -> Option<(String, Option)> { for line in contents.lines() { - let trimmed = strip_comment(line).trim_start(); + let trimmed = strip_line_comment(line).trim_start(); if let Some(rest) = trimmed.strip_prefix("package ") { let token = rest.trim_end_matches(';').trim(); if let Some((name, version)) = token.split_once('@') { @@ -375,7 +378,7 @@ fn extract_world_exports(contents: &str) -> Vec { let mut exports = Vec::new(); for line in contents.lines() { - let trimmed = strip_comment(line).trim(); + let trimmed = strip_line_comment(line).trim(); if let Some(rest) = trimmed.strip_prefix("export ") { let rest = rest.trim_end_matches(';').trim(); let interface = match rest.split_once(':') { @@ -390,10 +393,3 @@ fn extract_world_exports(contents: &str) -> Vec { exports } - -fn strip_comment(line: &str) -> &str { - match line.split_once("//") { - Some((before, _)) => before, - None => line, - } -} diff --git a/sdk/base-macros/src/util.rs b/sdk/base-macros/src/util.rs index 0d90f2fbf..198a52d65 100644 --- a/sdk/base-macros/src/util.rs +++ b/sdk/base-macros/src/util.rs @@ -59,3 +59,13 @@ pub fn generated_wit_folder_at(manifest_dir: &Path) -> Result { })?; Ok(wit_deps_dir) } + +/// Strips line comments starting with `//` from the provided source line. +/// +/// Returns the portion of the line before the comment, or the entire line if no comment exists. +pub fn strip_line_comment(line: &str) -> &str { + match line.split_once("//") { + Some((before, _)) => before, + None => line, + } +} From e1f8ac145b54e22083db0cef61f53517463bc1e4 Mon Sep 17 00:00:00 2001 From: Denys Zadorozhnyi Date: Thu, 27 Nov 2025 18:03:43 +0200 Subject: [PATCH 09/12] refactor: add an error if `run` has more than 2 arguments, add comments. --- sdk/base-macros/src/generate.rs | 2 +- sdk/base-macros/src/script.rs | 9 +++++++++ sdk/base-macros/src/util.rs | 3 +++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/sdk/base-macros/src/generate.rs b/sdk/base-macros/src/generate.rs index 3faca5787..d4dbec99c 100644 --- a/sdk/base-macros/src/generate.rs +++ b/sdk/base-macros/src/generate.rs @@ -585,7 +585,7 @@ mod tests { /// Helper to parse Rust source into a syn::File. fn parse_file(src: &str) -> File { - syn::parse_str(src).expect("failed to parse test source") + syn::parse_str(src).unwrap_or_else(|e| panic!("failed to parse test source: {e}\n{src}")) } #[test] diff --git a/sdk/base-macros/src/script.rs b/sdk/base-macros/src/script.rs index d3b9b7230..532be0f69 100644 --- a/sdk/base-macros/src/script.rs +++ b/sdk/base-macros/src/script.rs @@ -113,8 +113,17 @@ pub(crate) fn expand( /// it is treated as an "injected" wrapper struct that will be instantiated via /// `Default::default()` and passed to the user's function. /// +/// Only up to 2 parameters are supported: `(arg: Word)` or `(arg: Word, account: Account)`. +/// /// Returns `Some((ident, type))` if a second parameter exists, `None` otherwise. fn parse_injected_param(input_fn: &ItemFn) -> syn::Result> { + if input_fn.sig.inputs.len() > 2 { + return Err(syn::Error::new( + input_fn.sig.span(), + "fn run accepts at most 2 parameters: (arg: Word) or (arg: Word, account: Account)", + )); + } + let Some(second_arg) = input_fn.sig.inputs.iter().nth(1) else { return Ok(None); }; diff --git a/sdk/base-macros/src/util.rs b/sdk/base-macros/src/util.rs index 198a52d65..a4d43bab0 100644 --- a/sdk/base-macros/src/util.rs +++ b/sdk/base-macros/src/util.rs @@ -63,6 +63,9 @@ pub fn generated_wit_folder_at(manifest_dir: &Path) -> Result { /// Strips line comments starting with `//` from the provided source line. /// /// Returns the portion of the line before the comment, or the entire line if no comment exists. +/// +/// **Note:** This is a simple heuristic that doesn't account for `//` appearing +/// inside string literals. Only use for WIT source parsing where this is not an issue. pub fn strip_line_comment(line: &str) -> &str { match line.split_once("//") { Some((before, _)) => before, From 9368f9c5625d67a91fe80a27d77a1c19f8c807c3 Mon Sep 17 00:00:00 2001 From: Denys Zadorozhnyi Date: Thu, 27 Nov 2025 19:49:27 +0200 Subject: [PATCH 10/12] refactor: simplify `world` handling in `generate_bindings`, add test --- sdk/base-macros/src/generate.rs | 103 ++++++++++++++++++++++++++++++-- 1 file changed, 98 insertions(+), 5 deletions(-) diff --git a/sdk/base-macros/src/generate.rs b/sdk/base-macros/src/generate.rs index d4dbec99c..68918bd88 100644 --- a/sdk/base-macros/src/generate.rs +++ b/sdk/base-macros/src/generate.rs @@ -156,19 +156,22 @@ pub(crate) fn expand(input: proc_macro::TokenStream) -> proc_macro::TokenStream } /// Generates WIT bindings using `wit-bindgen` directly instead of the `generate!` macro. +/// +/// The `world` parameter specifies which world to generate bindings for. This should already +/// be resolved by the caller (either from inline WIT or from the local wit/ directory). +/// If `None`, wit-bindgen will attempt to select a default world from the loaded packages. fn generate_bindings( args: &GenerateArgs, config: &manifest_paths::ResolvedWit, - world_override: Option<&str>, + world: Option<&str>, ) -> Result { let inline_src = args.inline.as_ref().map(|src| src.value()); let inline_ref = inline_src.as_deref(); let wit_sources = load_wit_sources(&config.paths, inline_ref)?; - let world_spec = world_override.or(config.world.as_deref()); - let world = wit_sources + let world_id = wit_sources .resolve - .select_world(&wit_sources.packages, world_spec) + .select_world(&wit_sources.packages, world) .map_err(|err| Error::new(Span::call_site(), err.to_string()))?; let mut opts = Opts { @@ -183,7 +186,7 @@ fn generate_bindings( let mut generated_files = wit_bindgen_core::Files::default(); let mut generator = opts.build(); generator - .generate(&wit_sources.resolve, world, &mut generated_files) + .generate(&wit_sources.resolve, world_id, &mut generated_files) .map_err(|err| Error::new(Span::call_site(), err.to_string()))?; let (_, src_bytes) = generated_files @@ -950,4 +953,94 @@ mod tests { assert_eq!(parsed.with_entries[0].0, "miden:a/b"); assert_eq!(parsed.with_entries[1].0, "miden:c/d"); } + + /// Integration test verifying that `augment_generated_bindings` produces valid Rust code. + /// + /// This test simulates realistic wit-bindgen output with custom types, multiple methods, + /// and verifies the augmented output parses as valid Rust and contains the expected + /// wrapper struct with properly qualified type paths. + #[test] + fn test_augment_generated_bindings_integration() { + // Simulate more realistic wit-bindgen output with types and multiple leaf modules + let src = r#" + mod miden { + mod basic_wallet { + mod basic_wallet { + pub struct AssetInfo { + pub amount: u64, + } + + pub fn receive_asset(asset: AssetInfo) {} + pub fn move_asset_to_note(asset: AssetInfo, note_idx: u32) -> bool { true } + fn _internal_helper() {} // Should be skipped (underscore prefix) + } + } + mod other_component { + mod other_component { + pub fn do_something(value: u64) -> u64 { value } + } + } + } + mod exports { + mod my_export { + pub fn exported_fn() {} // Should be skipped (exports module) + } + } + "#; + + let tokens: TokenStream2 = src.parse().unwrap(); + let result = augment_generated_bindings(tokens).unwrap(); + + // Verify the output parses as valid Rust + let parsed: File = + syn::parse2(result.clone()).expect("augmented bindings should be valid Rust syntax"); + + // Find the Account struct and impl + let has_account_struct = parsed + .items + .iter() + .any(|item| matches!(item, Item::Struct(s) if s.ident == "Account")); + let has_account_impl = parsed.items.iter().any(|item| { + matches!(item, Item::Impl(i) if i.self_ty.to_token_stream().to_string() == "Account") + }); + + assert!(has_account_struct, "should generate Account struct"); + assert!(has_account_impl, "should generate Account impl"); + + // Find the impl block and verify methods + let impl_block = parsed + .items + .iter() + .find_map(|item| match item { + Item::Impl(i) if i.self_ty.to_token_stream().to_string() == "Account" => Some(i), + _ => None, + }) + .expect("Account impl should exist"); + + let method_names: Vec = impl_block + .items + .iter() + .filter_map(|item| match item { + ImplItem::Fn(f) => Some(f.sig.ident.to_string()), + _ => None, + }) + .collect(); + + // Should include methods from both leaf modules + assert!(method_names.contains(&"receive_asset".to_string())); + assert!(method_names.contains(&"move_asset_to_note".to_string())); + assert!(method_names.contains(&"do_something".to_string())); + + // Should NOT include internal helper or exported functions + assert!(!method_names.contains(&"_internal_helper".to_string())); + assert!(!method_names.contains(&"exported_fn".to_string())); + + // Verify type qualification in the result string + let result_str = result.to_string(); + // AssetInfo should be qualified with its module path + assert!( + result_str.contains("miden :: basic_wallet :: basic_wallet :: AssetInfo"), + "custom types should be qualified with module path" + ); + } } From 3cdf7f8c794ec1bbb384ecf26693bebee1ead805 Mon Sep 17 00:00:00 2001 From: Denys Zadorozhnyi Date: Fri, 28 Nov 2025 13:05:48 +0200 Subject: [PATCH 11/12] refactor: check the script's `run` signature against expected, extract struct name, add comments --- sdk/base-macros/src/generate.rs | 14 +++++++---- sdk/base-macros/src/script.rs | 42 +++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/sdk/base-macros/src/generate.rs b/sdk/base-macros/src/generate.rs index 68918bd88..d821617db 100644 --- a/sdk/base-macros/src/generate.rs +++ b/sdk/base-macros/src/generate.rs @@ -15,6 +15,9 @@ use wit_bindgen_rust::{Opts, WithOption}; use crate::manifest_paths; +/// Name of the wrapper struct generated to aggregate imported interface methods. +const WRAPPER_STRUCT_NAME: &str = "Account"; + #[derive(Default)] struct GenerateArgs { inline: Option, @@ -229,7 +232,7 @@ fn augment_generated_bindings(tokens: TokenStream2) -> syn::Result collect_wrapper_methods(&file.items, &mut Vec::new(), &mut collected_methods)?; if !collected_methods.is_empty() { - let struct_ident = syn::Ident::new("Account", Span::call_site()); + let struct_ident = syn::Ident::new(WRAPPER_STRUCT_NAME, Span::call_site()); let struct_item: ItemStruct = parse_quote! { /// Wrapper struct providing methods that delegate to imported interface functions. #[derive(Clone, Copy, Default)] @@ -404,7 +407,6 @@ fn build_wrapper_method(func: &ItemFn, module_path: &[syn::Ident]) -> syn::Resul let method_doc = format!("Calls `{}` from `{}`.", sig.ident, format_module_path(module_path)); let doc_attr: Attribute = parse_quote!(#[doc = #method_doc]); let inline_attr: Attribute = parse_quote!(#[inline(always)]); - let allow_unused_attr: Attribute = parse_quote!(#[allow(unused_variables)]); let body_tokens = match &sig.output { ReturnType::Default => quote!({ #call_expr; }), @@ -413,7 +415,7 @@ fn build_wrapper_method(func: &ItemFn, module_path: &[syn::Ident]) -> syn::Resul let block = syn::parse2(body_tokens)?; Ok(ImplItemFn { - attrs: vec![doc_attr, inline_attr, allow_unused_attr], + attrs: vec![doc_attr, inline_attr], vis: func.vis.clone(), defaultness: None, sig, @@ -473,6 +475,11 @@ fn qualify_signature_types(sig: &mut syn::Signature, module_path: &[syn::Ident]) } /// Returns true if the name is a primitive type or common std type that shouldn't be qualified. +/// +/// This list covers Rust primitives and common standard library types. WIT-generated bindings +/// only use a subset of these (primitives, String, Vec, Option, Result), but we include +/// additional common types for safety. Types like `Rc`, `Arc`, `RefCell` are not used by +/// wit-bindgen and are intentionally omitted. fn is_primitive_or_std_type(name: &str) -> bool { matches!( name, @@ -497,7 +504,6 @@ fn is_primitive_or_std_type(name: &str) -> bool { | "Vec" | "Option" | "Result" - | "Box" | "Self" ) } diff --git a/sdk/base-macros/src/script.rs b/sdk/base-macros/src/script.rs index 532be0f69..6d4b14b9f 100644 --- a/sdk/base-macros/src/script.rs +++ b/sdk/base-macros/src/script.rs @@ -117,6 +117,13 @@ pub(crate) fn expand( /// /// Returns `Some((ident, type))` if a second parameter exists, `None` otherwise. fn parse_injected_param(input_fn: &ItemFn) -> syn::Result> { + if input_fn.sig.inputs.is_empty() { + return Err(syn::Error::new( + input_fn.sig.span(), + "fn run requires at least one parameter: (arg: Word) or (arg: Word, account: Account)", + )); + } + if input_fn.sig.inputs.len() > 2 { return Err(syn::Error::new( input_fn.sig.span(), @@ -124,6 +131,29 @@ fn parse_injected_param(input_fn: &ItemFn) -> syn::Result { + if !matches!(pat_type.pat.as_ref(), Pat::Ident(_)) { + return Err(syn::Error::new( + pat_type.pat.span(), + "first parameter must be a simple identifier (e.g., `arg: Word`)", + )); + } + // Check that the type is `Word` + if !is_word_type(&pat_type.ty) { + return Err(syn::Error::new( + pat_type.ty.span(), + "first parameter must have type `Word` (e.g., `arg: Word`)", + )); + } + } + FnArg::Receiver(receiver) => { + return Err(syn::Error::new(receiver.span(), "unexpected receiver argument")); + } + } + let Some(second_arg) = input_fn.sig.inputs.iter().nth(1) else { return Ok(None); }; @@ -147,6 +177,18 @@ fn parse_injected_param(input_fn: &ItemFn) -> syn::Result bool { + let syn::Type::Path(type_path) = ty else { + return false; + }; + if type_path.qself.is_some() { + return false; + } + let last_segment = type_path.path.segments.last(); + last_segment.is_some_and(|seg| seg.ident == "Word" && seg.arguments.is_empty()) +} + fn build_script_wit( error_span: Span, export_interface: &'static str, From 35ccba4f888fa0de6b9f0de712b7258d49fe66b5 Mon Sep 17 00:00:00 2001 From: Denys Zadorozhnyi Date: Fri, 28 Nov 2025 15:41:52 +0200 Subject: [PATCH 12/12] refactor: add function name clashing check in `Account` --- sdk/base-macros/src/generate.rs | 127 ++++++++++++++++++++++++++++++-- 1 file changed, 121 insertions(+), 6 deletions(-) diff --git a/sdk/base-macros/src/generate.rs b/sdk/base-macros/src/generate.rs index d821617db..bafd83fe8 100644 --- a/sdk/base-macros/src/generate.rs +++ b/sdk/base-macros/src/generate.rs @@ -1,4 +1,4 @@ -use std::{env, fs, path::PathBuf}; +use std::{collections::HashMap, env, fs, path::PathBuf}; use proc_macro2::{Span, TokenStream as TokenStream2}; use quote::{quote, ToTokens}; @@ -226,11 +226,31 @@ fn generate_bindings( /// free functions in leaf modules. This provides a more ergonomic API /// (e.g., `Account::default().receive_asset(asset)` instead of /// `miden::basic_wallet::basic_wallet::receive_asset(asset)`). +/// +/// ## Leaf Module Selection +/// +/// Only "leaf" modules (those containing no nested modules) contribute methods to the +/// wrapper struct. This is because wit-bindgen generates a module hierarchy where: +/// - Non-leaf modules represent WIT package namespaces (e.g., `miden::basic_wallet`) +/// - Leaf modules represent actual WIT interfaces with callable functions +/// +/// For example, given the module structure: +/// ```text +/// miden/ +/// basic_wallet/ +/// basic_wallet/ <- leaf module, methods collected here +/// receive_asset() +/// send_asset() +/// ``` +/// Only functions from `miden::basic_wallet::basic_wallet` are wrapped. fn augment_generated_bindings(tokens: TokenStream2) -> syn::Result { let mut file: File = syn::parse2(tokens)?; let mut collected_methods = Vec::new(); collect_wrapper_methods(&file.items, &mut Vec::new(), &mut collected_methods)?; + // Check for method name collisions across different interfaces + check_method_name_collisions(&collected_methods)?; + if !collected_methods.is_empty() { let struct_ident = syn::Ident::new(WRAPPER_STRUCT_NAME, Span::call_site()); let struct_item: ItemStruct = parse_quote! { @@ -242,7 +262,9 @@ fn augment_generated_bindings(tokens: TokenStream2) -> syn::Result let mut impl_item: ItemImpl = parse_quote! { impl #struct_ident {} }; - impl_item.items.extend(collected_methods.into_iter().map(ImplItem::Fn)); + impl_item + .items + .extend(collected_methods.into_iter().map(|cm| ImplItem::Fn(cm.method))); file.items.push(Item::Struct(struct_item)); file.items.push(Item::Impl(impl_item)); @@ -338,6 +360,13 @@ fn push_path_entry(opts: &mut Opts, key: &str, value: &str) { opts.with.push((key.to_string(), WithOption::Path(value.to_string()))); } +/// A collected wrapper method along with its source module path. +struct CollectedMethod { + method: ImplItemFn, + /// The module path where this method originated (e.g., "miden::basic_wallet::basic_wallet"). + source_path: String, +} + /// Recursively walks all modules and collects wrapper methods from leaf modules. /// /// The `path` parameter tracks the current module path for generating correct call paths. @@ -345,7 +374,7 @@ fn push_path_entry(opts: &mut Opts, key: &str, value: &str) { fn collect_wrapper_methods( items: &[Item], path: &mut Vec, - methods_out: &mut Vec, + methods_out: &mut Vec, ) -> syn::Result<()> { for item in items.iter() { if let Item::Mod(module) = item { @@ -369,7 +398,7 @@ fn collect_wrapper_methods( fn collect_methods_from_module( items: &[Item], path: &[syn::Ident], - methods_out: &mut Vec, + methods_out: &mut Vec, ) -> syn::Result<()> { if !should_generate_struct(path, items) { return Ok(()); @@ -383,8 +412,12 @@ fn collect_methods_from_module( }) .collect(); + let source_path = format_module_path(path); for func in functions { - methods_out.push(build_wrapper_method(func, path)?); + methods_out.push(CollectedMethod { + method: build_wrapper_method(func, path)?, + source_path: source_path.clone(), + }); } Ok(()) @@ -588,6 +621,35 @@ fn format_module_path(path: &[syn::Ident]) -> String { path.iter().map(|ident| ident.to_string()).collect::>().join("::") } +/// Checks for method name collisions across collected wrapper methods. +/// +/// If multiple imported interfaces define functions with the same name, they would all be +/// added to the `Account` struct, causing a compilation error. This function detects such +/// collisions early and provides a clear error message indicating which interfaces conflict. +fn check_method_name_collisions(methods: &[CollectedMethod]) -> syn::Result<()> { + let mut seen: HashMap = HashMap::new(); + + for collected in methods { + let method_name = collected.method.sig.ident.to_string(); + + if let Some(existing_path) = seen.get(&method_name) { + return Err(Error::new( + Span::call_site(), + format!( + "method name collision in generated `{WRAPPER_STRUCT_NAME}` struct: \ + `{method_name}` is defined in both `{existing_path}` and `{}`. Consider \ + using the original module paths directly instead of the wrapper struct.", + collected.source_path + ), + )); + } + + seen.insert(method_name, &collected.source_path); + } + + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -739,7 +801,7 @@ mod tests { assert_eq!(methods.len(), 2); // Check method names - let method_names: Vec<_> = methods.iter().map(|m| m.sig.ident.to_string()).collect(); + let method_names: Vec<_> = methods.iter().map(|m| m.method.sig.ident.to_string()).collect(); assert!(method_names.contains(&"receive_asset".to_string())); assert!(method_names.contains(&"send_asset".to_string())); } @@ -1049,4 +1111,57 @@ mod tests { "custom types should be qualified with module path" ); } + + #[test] + fn test_method_name_collision_detected() { + // Two different interfaces with the same function name + let src = r#" + mod miden { + mod interface_a { + mod interface_a { + pub fn transfer(amount: u64) {} + } + } + mod interface_b { + mod interface_b { + pub fn transfer(value: u32) {} + } + } + } + "#; + + let tokens: TokenStream2 = src.parse().unwrap(); + let result = augment_generated_bindings(tokens); + + assert!(result.is_err(), "should detect method name collision"); + let err_msg = result.unwrap_err().to_string(); + assert!( + err_msg.contains("method name collision"), + "error should mention collision: {err_msg}" + ); + assert!(err_msg.contains("transfer"), "error should mention the colliding method name"); + } + + #[test] + fn test_no_collision_different_names() { + let src = r#" + mod miden { + mod interface_a { + mod interface_a { + pub fn transfer_a(amount: u64) {} + } + } + mod interface_b { + mod interface_b { + pub fn transfer_b(value: u32) {} + } + } + } + "#; + + let tokens: TokenStream2 = src.parse().unwrap(); + let result = augment_generated_bindings(tokens); + + assert!(result.is_ok(), "should not detect collision for different method names"); + } }