use std::{env, fs, path::Path};
use proc_macro2::{Literal, Span, TokenStream as TokenStream2};
use quote::quote;
use syn::{FnArg, ItemFn, Pat, PatIdent, parse_macro_input, spanned::Spanned};
use toml::Value;
use crate::{
boilerplate::runtime_boilerplate,
util::{generated_wit_folder_at, strip_line_comment},
};
const SCRIPT_PACKAGE_VERSION: &str = "1.0.0";
pub(crate) struct ScriptConfig {
pub export_interface: &'static str,
pub guest_trait_path: &'static str,
}
pub(crate) struct GuestWrapperConfig {
pub export_interface: &'static str,
pub guest_trait_path: &'static str,
pub guest_struct_ident: syn::Ident,
pub guest_struct_doc: &'static str,
}
pub(crate) fn expand_guest_wrapper(
error_span: Span,
config: GuestWrapperConfig,
user_items: TokenStream2,
trait_impls: TokenStream2,
run_body: TokenStream2,
) -> syn::Result<TokenStream2> {
let inline_wit = build_script_wit(error_span, config.export_interface)?;
let inline_literal = Literal::string(&inline_wit);
let export_path: syn::Path = syn::parse_str(config.guest_trait_path).map_err(|err| {
syn::Error::new(
error_span,
format!("failed to parse guest trait path '{}': {err}", config.guest_trait_path),
)
})?;
let runtime_boilerplate = runtime_boilerplate();
let guest_struct_ident = config.guest_struct_ident;
let doc = config.guest_struct_doc;
Ok(quote! {
#runtime_boilerplate
#user_items
::miden::generate!(inline = #inline_literal);
self::bindings::export!(#guest_struct_ident);
#trait_impls
#[allow(unused_imports)]
use ::miden::active_account::ActiveAccount as _;
#[doc = #doc]
pub struct #guest_struct_ident;
impl #export_path for #guest_struct_ident {
fn run(arg: ::miden::Word) {
#run_body
}
}
})
}
pub(crate) fn expand(
attr: proc_macro::TokenStream,
item: proc_macro::TokenStream,
config: ScriptConfig,
) -> proc_macro::TokenStream {
if !attr.is_empty() {
return syn::Error::new(Span::call_site(), "this attribute does not accept arguments")
.into_compile_error()
.into();
}
let input_fn = parse_macro_input!(item as ItemFn);
let fn_ident = &input_fn.sig.ident;
if fn_ident != "run" {
return syn::Error::new(fn_ident.span(), "this attribute must be applied to `fn run`")
.into_compile_error()
.into();
}
if input_fn.sig.receiver().is_some() {
return syn::Error::new(input_fn.sig.span(), "this attribute cannot target methods")
.into_compile_error()
.into();
}
let injected_param = match parse_injected_param(&input_fn) {
Ok(param) => param,
Err(err) => return err.into_compile_error().into(),
};
let struct_ident = quote::format_ident!("Struct");
let (instantiation, call, trait_impl) = match &injected_param {
Some((ident, ty)) => (
quote! { let mut #ident = <#ty as ::core::default::Default>::default(); },
quote! { #fn_ident(arg, &mut #ident) },
quote! {
impl ::miden::active_account::ActiveAccount for #ty {}
},
),
None => (quote! {}, quote! { #fn_ident(arg) }, quote! {}),
};
let expanded = match expand_guest_wrapper(
Span::call_site(),
GuestWrapperConfig {
export_interface: config.export_interface,
guest_trait_path: config.guest_trait_path,
guest_struct_ident: struct_ident.clone(),
guest_struct_doc: "Guest entry point generated by the Miden script attribute.",
},
quote! { #input_fn },
trait_impl,
quote! {
#instantiation
#call;
},
) {
Ok(tokens) => tokens,
Err(err) => err.into_compile_error(),
};
expanded.into()
}
fn parse_injected_param(input_fn: &ItemFn) -> syn::Result<Option<(syn::Ident, syn::Type)>> {
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: &mut \
Account)",
));
}
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: &mut \
Account)",
));
}
let first_arg = input_fn.sig.inputs.first().unwrap();
match first_arg {
FnArg::Typed(pat_type) => {
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`)",
));
}
if !is_type_named(&pat_type.ty, "Word") {
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);
};
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",
));
}
};
let ty = expect_mut_account_type(&pat_type.ty)?;
Ok(Some((ident, ty)))
}
FnArg::Receiver(receiver) => {
Err(syn::Error::new(receiver.span(), "unexpected receiver argument"))
}
}
}
fn expect_mut_account_type(ty: &syn::Type) -> syn::Result<syn::Type> {
let syn::Type::Reference(type_ref) = ty else {
return Err(syn::Error::new(
ty.span(),
"second parameter must be typed as `account: &mut Account`",
));
};
if type_ref.mutability.is_none() {
return Err(syn::Error::new(
ty.span(),
"second parameter must be typed as `account: &mut Account`",
));
}
if !is_type_named(&type_ref.elem, "Account") {
return Err(syn::Error::new(
ty.span(),
"second parameter must be typed as `account: &mut Account`",
));
}
Ok((*type_ref.elem).clone())
}
fn is_type_named(ty: &syn::Type, name: &str) -> bool {
let syn::Type::Path(type_path) = ty else {
return false;
};
if type_path.qself.is_some() {
return false;
}
type_path
.path
.segments
.last()
.is_some_and(|seg| seg.ident == name && seg.arguments.is_empty())
}
pub(crate) fn build_script_wit(
error_span: Span,
export_interface: &'static str,
) -> Result<String, syn::Error> {
let manifest_dir = env::var("CARGO_MANIFEST_DIR").map_err(|err| {
syn::Error::new(error_span, 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| {
syn::Error::new(
error_span,
format!("failed to read manifest '{}': {err}", manifest_path.display()),
)
})?;
let manifest: Value = manifest_content.parse().map_err(|err| {
syn::Error::new(
error_span,
format!("failed to parse manifest '{}': {err}", manifest_path.display()),
)
})?;
let package_table = manifest
.get("package")
.and_then(Value::as_table)
.ok_or_else(|| syn::Error::new(error_span, "manifest missing [package] table"))?;
let crate_name = package_table
.get("name")
.and_then(Value::as_str)
.ok_or_else(|| syn::Error::new(error_span, "manifest package missing `name`"))?;
let component_package = package_table
.get("metadata")
.and_then(Value::as_table)
.and_then(|meta| meta.get("component"))
.and_then(Value::as_table)
.and_then(|component| component.get("package"))
.and_then(Value::as_str)
.ok_or_else(|| {
syn::Error::new(error_span, "manifest missing package.metadata.component.package")
})?;
let mut imports = collect_script_imports(&manifest_dir, package_table, error_span)?;
imports.sort();
let package_with_version = if component_package.contains('@') {
component_package.to_string()
} else {
format!("{component_package}@{SCRIPT_PACKAGE_VERSION}")
};
let world_name = format!("{}-world", crate_name.replace('_', "-"));
let mut wit_source = String::new();
wit_source.push_str(&format!("package {package_with_version};\n\n"));
wit_source.push_str(&format!("world {world_name} {{\n"));
for import in imports {
wit_source.push_str(" import ");
wit_source.push_str(&import);
wit_source.push_str(";\n");
}
wit_source.push_str(&format!(" export {export_interface};\n"));
wit_source.push('}');
wit_source.push('\n');
Ok(wit_source)
}
fn collect_script_imports(
manifest_dir: &str,
package_table: &toml::value::Table,
error_span: Span,
) -> Result<Vec<String>, syn::Error> {
let dependencies = package_table
.get("metadata")
.and_then(Value::as_table)
.and_then(|meta| meta.get("miden"))
.and_then(Value::as_table)
.and_then(|miden| miden.get("dependencies"))
.and_then(Value::as_table);
let mut imports = Vec::new();
if let Some(dep_table) = dependencies {
for (dep_name, dep_value) in dep_table.iter() {
let dep_config = dep_value.as_table().ok_or_else(|| {
syn::Error::new(
error_span,
format!(
"dependency '{dep_name}' under package.metadata.miden.dependencies must \
be a table"
),
)
})?;
let dependency_path =
dep_config.get("path").and_then(Value::as_str).ok_or_else(|| {
syn::Error::new(
error_span,
format!(
"dependency '{dep_name}' under package.metadata.miden.dependencies is \
missing a 'path' entry"
),
)
})?;
let absolute_path = Path::new(manifest_dir).join(dependency_path);
let canonical = fs::canonicalize(&absolute_path).map_err(|err| {
syn::Error::new(
error_span,
format!(
"failed to canonicalize dependency '{dep_name}' path '{}': {err}",
absolute_path.display()
),
)
})?;
let dependency_info = parse_dependency_wit(&canonical).map_err(|msg| {
syn::Error::new(
error_span,
format!("failed to process WIT for dependency '{dep_name}': {msg}"),
)
})?;
let version_suffix = dependency_info
.version
.as_ref()
.map(|version| format!("@{version}"))
.unwrap_or_default();
let interface = dependency_info
.exports
.first()
.ok_or_else(|| {
syn::Error::new(
error_span,
format!(
"dependency '{dep_name}' did not export any interfaces in its world \
definition"
),
)
})?
.clone();
imports.push(format!("{}/{interface}{version_suffix}", dependency_info.package));
}
}
Ok(imports)
}
struct DependencyWit {
package: String,
version: Option<String>,
exports: Vec<String>,
}
fn parse_dependency_wit(root: &Path) -> Result<DependencyWit, String> {
if root.is_file() {
return parse_wit_file(root)?.ok_or_else(|| {
format!("WIT file '{}' does not contain a world export", root.display())
});
}
let default_wit_dir = root.join("wit");
let generated_wit_dir = generated_wit_folder_at(root)?;
let wit_dirs = [default_wit_dir, generated_wit_dir];
for wit_dir in &wit_dirs {
if !wit_dir.exists() {
continue;
}
let mut entries = fs::read_dir(wit_dir)
.map_err(|err| format!("failed to read '{}': {err}", wit_dir.display()))?
.collect::<Result<Vec<_>, _>>()
.map_err(|err| format!("failed to iterate '{}': {err}", wit_dir.display()))?;
entries.sort_by_key(|entry| entry.file_name());
for entry in entries {
let path = entry.path();
if path.is_dir() {
if path.file_name().is_some_and(|name| name == "deps") {
continue;
}
continue;
}
if path.extension().and_then(|ext| ext.to_str()) != Some("wit") {
continue;
}
if let Some(info) = parse_wit_file(&path)? {
return Ok(info);
}
}
}
Err(format!("no WIT world definition found in directories '{wit_dirs:?}'"))
}
fn parse_wit_file(path: &Path) -> Result<Option<DependencyWit>, String> {
let contents = fs::read_to_string(path)
.map_err(|err| format!("failed to read WIT file '{}': {err}", path.display()))?;
let (package, version) = match extract_package_identifier(&contents) {
Some(parts) => parts,
None => return Ok(None),
};
let exports = extract_world_exports(&contents);
if exports.is_empty() {
return Ok(None);
}
Ok(Some(DependencyWit {
package,
version,
exports,
}))
}
fn extract_package_identifier(contents: &str) -> Option<(String, Option<String>)> {
for line in contents.lines() {
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('@') {
return Some((name.trim().to_string(), Some(version.trim().to_string())));
}
return Some((token.to_string(), None));
}
}
None
}
fn extract_world_exports(contents: &str) -> Vec<String> {
let mut exports = Vec::new();
for line in contents.lines() {
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(':') {
Some((_, interface)) => interface.trim(),
None => rest,
};
if !interface.is_empty() {
exports.push(interface.to_string());
}
}
}
exports
}