diff --git a/sui-move-call/src/lib.rs b/sui-move-call/src/lib.rs index 26568dd..488c38c 100644 --- a/sui-move-call/src/lib.rs +++ b/sui-move-call/src/lib.rs @@ -365,6 +365,55 @@ pub enum CallSpecError { /// The provided function string is not a valid Move identifier. #[error("invalid Move identifier for function: `{0}`")] Function(String), + /// A call argument could not be encoded or converted. + #[error(transparent)] + Arg(#[from] CallArgError), +} + +/// Move call target without encoded arguments. +/// +/// This is the lower-level boundary for PTB composition: generated code owns package, module, +/// function, and type arguments, while `sui-move-ptb` can wire arbitrary PTB `Argument`s including +/// previous command results. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct CallTarget { + /// Package ID that contains the Move module. + pub package: Address, + /// Move module identifier. + pub module: Identifier, + /// Move function identifier. + pub function: Identifier, + /// Type arguments for the call (Move `TypeTag`s). + pub type_arguments: Vec, +} + +impl CallTarget { + /// Create an empty call target for a `(package, module, function)` triple. + pub fn new( + package: Address, + module: impl AsRef, + function: impl AsRef, + ) -> Result { + let module_str = module.as_ref(); + let function_str = function.as_ref(); + + let module = Identifier::from_str(module_str) + .map_err(|_| CallSpecError::Module(module_str.to_string()))?; + let function = Identifier::from_str(function_str) + .map_err(|_| CallSpecError::Function(function_str.to_string()))?; + + Ok(Self { + package, + module, + function, + type_arguments: Vec::new(), + }) + } + + /// Append a type argument derived from a `MoveType`. + pub fn push_type_arg(&mut self) { + self.type_arguments.push(T::type_tag_static()); + } } /// A description of a Move function call. @@ -419,21 +468,30 @@ impl CallSpec { module: impl AsRef, function: impl AsRef, ) -> Result { - let module_str = module.as_ref(); - let function_str = function.as_ref(); - - let module = Identifier::from_str(module_str) - .map_err(|_| CallSpecError::Module(module_str.to_string()))?; - let function = Identifier::from_str(function_str) - .map_err(|_| CallSpecError::Function(function_str.to_string()))?; + Ok(Self::from_target(CallTarget::new( + package, module, function, + )?)) + } - Ok(Self { - package, - module, - function, - type_arguments: Vec::new(), + /// Create an empty call spec from a generated call target. + pub fn from_target(target: CallTarget) -> Self { + Self { + package: target.package, + module: target.module, + function: target.function, + type_arguments: target.type_arguments, arguments: Vec::new(), - }) + } + } + + /// Return this call spec's target without its encoded arguments. + pub fn target(&self) -> CallTarget { + CallTarget { + package: self.package, + module: self.module.clone(), + function: self.function.clone(), + type_arguments: self.type_arguments.clone(), + } } /// Append a type argument derived from a `MoveType`. @@ -471,8 +529,8 @@ impl CallSpec { /// Convenience re-exports for downstream code. pub mod prelude { pub use crate::{ - CallArg, CallArgError, CallSpec, CallSpecError, MoveObject, ObjectArg, ReceivingMoveObject, - SharedMoveObject, ToCallArg, ToCallArgMut, + CallArg, CallArgError, CallSpec, CallSpecError, CallTarget, MoveObject, ObjectArg, + ReceivingMoveObject, SharedMoveObject, ToCallArg, ToCallArgMut, }; pub use sui_move::prelude::*; pub use sui_sdk_types::Mutability; diff --git a/sui-move-codegen/README.md b/sui-move-codegen/README.md index c77e8ff..996c904 100644 --- a/sui-move-codegen/README.md +++ b/sui-move-codegen/README.md @@ -33,18 +33,20 @@ The pipeline is intentionally split in two: (`NormalizedPackage`). 2. **Render (offline)**: render Rust source from that IR. -Because the IR is JSON-friendly, you can commit it and re-render deterministically in CI without +Because the IR is JSON-serializable, you can commit it and re-render deterministically in CI without needing network access. ## What gets generated Given a `NormalizedPackage` (either fetched from gRPC or loaded from JSON), this crate can render: -- A `pub const PACKAGE: Address` (the on-chain package id) +- `CALL_PACKAGE` / `TYPE_PACKAGE` constants for generated calls and type identity +- `call_package()` / `type_package()` / `with_packages(...)` helpers for scoped package-id overrides - One Rust module per Move module (or a flat layout via `RenderOptions::flatten`) - Move datatypes as Rust types (structs use `#[sui_move::move_struct]` via `sui-move`’s `derive` feature) -- Move functions as Rust functions that return `sui_move_call::CallSpec` +- Move functions as generated `*_target` functions +- Optional typed Rust functions that return `sui_move_call::CallSpec` - (optional) A `TxExt` trait implemented for `sui_move_runtime::Tx` (enable with `RenderOptions::emit_tx_ext`) @@ -52,6 +54,27 @@ Those generated call builders are designed to be used directly in higher layers: - `sui-move-ptb` can consume `CallSpec` to build a `ProgrammableTransaction` - `sui-move-runtime` can consume `CallSpec` via its tx builder (or `sui_move_runtime::tx!`) +Use `with_packages` when generated calls target one deployment package while type tags retain the +package that defines the Move types: + +```rust +# use sui_sdk_types::Address; +# mod bindings { +# pub fn with_packages( +# _call_package: sui_sdk_types::Address, +# _type_package: sui_sdk_types::Address, +# f: impl FnOnce() -> R, +# ) -> R { f() } +# pub mod m { +# pub fn f(_arg0: u64) {} +# } +# } +# let localnet_package = Address::ZERO; +bindings::with_packages(localnet_package, localnet_package, || { + bindings::m::f(10); +}); +``` + ## Example: render from an in-memory IR ```rust @@ -101,7 +124,7 @@ let pkg = NormalizedPackage { }; let code = render_package(&pkg, &RenderOptions::default()); -assert!(code.contains("pub const PACKAGE")); +assert!(code.contains("pub const CALL_PACKAGE")); assert!(code.contains("pub struct S")); assert!(code.contains("pub fn f")); ``` @@ -185,7 +208,7 @@ let pkg = NormalizedPackage { }; let code = render_package(&pkg, &RenderOptions::default()); -let start = code.find("pub fn mutate").unwrap(); +let start = code.find("pub fn mutate(").unwrap(); let sig_end = start + code[start..].find('{').unwrap(); let sig = &code[start..sig_end]; @@ -312,14 +335,14 @@ let pkg = NormalizedPackage { }; let code = render_package(&pkg, &RenderOptions::default()); -let start = code.find("pub fn id").unwrap(); +let start = code.find("pub fn id(").unwrap(); let sig_end = start + code[start..].find('{').unwrap(); let sig = &code[start..sig_end]; assert!(sig.contains("pub fn id(arg0: Vec)")); assert!(sig.contains("where")); assert!(sig.contains("T0: sm::MoveType + sm::HasStore")); -assert!(code.contains("spec.push_type_arg::();")); +assert!(code.contains("target.push_type_arg::();")); ``` ## Example: fetch over gRPC @@ -334,14 +357,14 @@ let package_id: Address = "0x2".parse()?; let pkg = fetch_package(&mut client, package_id).await?; let json = pkg.to_json_string()?; -println!("{json}"); +println!("{} bytes", json.len()); # Ok(()) # } ``` ## Recommended workflow -To keep builds deterministic, fetch metadata once and commit it (JSON), then render from JSON: +To keep builds deterministic, fetch metadata once and commit it as JSON, then render from JSON: 1. Fetch and save `NormalizedPackage` JSON (out-of-band; not in `build.rs`) 2. Render Rust bindings from that JSON during builds or as a pre-generation step diff --git a/sui-move-codegen/src/ir.rs b/sui-move-codegen/src/ir.rs index b38a19b..e67c3be 100644 --- a/sui-move-codegen/src/ir.rs +++ b/sui-move-codegen/src/ir.rs @@ -66,6 +66,22 @@ fn normalize_address(input: &str) -> String { format!("0x{addr}") } +fn replace_address(address: &mut String, replacements: &[(A, B)]) +where + A: AsRef, + B: AsRef, +{ + let normalized = normalize_address(address); + if let Some((_, replacement)) = replacements + .iter() + .find(|(actual, _)| normalize_address(actual.as_ref()) == normalized) + { + *address = normalize_address(replacement.as_ref()); + } else { + *address = normalized; + } +} + /// A Move type reference from package metadata. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum TypeRef { @@ -107,6 +123,64 @@ pub enum TypeRef { TypeParameter(u32), } +impl TypeRef { + fn replace_addresses(&mut self, replacements: &[(A, B)]) + where + A: AsRef, + B: AsRef, + { + match self { + TypeRef::Vector(inner) | TypeRef::Ref { inner, .. } => { + inner.replace_addresses(replacements); + } + TypeRef::Datatype { + type_name, + type_arguments, + } => { + replace_address(&mut type_name.address, replacements); + for ty in type_arguments { + ty.replace_addresses(replacements); + } + } + TypeRef::Address + | TypeRef::Bool + | TypeRef::U8 + | TypeRef::U16 + | TypeRef::U32 + | TypeRef::U64 + | TypeRef::U128 + | TypeRef::U256 + | TypeRef::TypeParameter(_) => {} + } + } + + fn collect_addresses<'a>(&'a self, out: &mut Vec<&'a str>) { + match self { + TypeRef::Vector(inner) | TypeRef::Ref { inner, .. } => { + inner.collect_addresses(out); + } + TypeRef::Datatype { + type_name, + type_arguments, + } => { + out.push(type_name.address.as_str()); + for ty in type_arguments { + ty.collect_addresses(out); + } + } + TypeRef::Address + | TypeRef::Bool + | TypeRef::U8 + | TypeRef::U16 + | TypeRef::U32 + | TypeRef::U64 + | TypeRef::U128 + | TypeRef::U256 + | TypeRef::TypeParameter(_) => {} + } + } +} + /// A function parameter (name is synthesized; Sui metadata does not carry parameter names). #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct FunctionParam { @@ -237,16 +311,157 @@ pub struct NormalizedPackage { pub modules: BTreeMap, } +/// Function parameter-name overlay failed because source and IR arities differ. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FunctionParameterNameMismatch { + /// Move module name. + pub module: String, + /// Move function name. + pub function: String, + /// Number of parameters in the normalized IR. + pub ir_count: usize, + /// Number of parameters recovered from source. + pub source_count: usize, +} + +impl std::fmt::Display for FunctionParameterNameMismatch { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "`{}::{}` function parameter count mismatch for source names: IR has {}, source has {}", + self.module, self.function, self.ir_count, self.source_count + ) + } +} + +impl std::error::Error for FunctionParameterNameMismatch {} + impl NormalizedPackage { /// Serialize the normalized package to pretty JSON. - pub fn to_json_string(&self) -> serde_json::Result { + pub fn to_json_string(&self) -> Result { serde_json::to_string_pretty(self) } /// Parse a normalized package from JSON. - pub fn from_json_str(input: &str) -> serde_json::Result { + pub fn from_json_str(input: &str) -> Result { serde_json::from_str(input) } + + /// Rewrite every package/type address in this IR using normalized `actual -> replacement` + /// pairs. + pub fn replace_addresses(&mut self, replacements: &[(A, B)]) + where + A: AsRef, + B: AsRef, + { + replace_address(&mut self.storage_id, replacements); + if let Some(original_id) = &mut self.original_id { + replace_address(original_id, replacements); + } + + for module in self.modules.values_mut() { + for datatype in &mut module.datatypes { + replace_address(&mut datatype.type_name.address, replacements); + match &mut datatype.kind { + DatatypeKind::Struct { fields } => { + for field in fields { + field.ty.replace_addresses(replacements); + } + } + DatatypeKind::Enum { variants } => { + for variant in variants { + for field in &mut variant.fields { + field.ty.replace_addresses(replacements); + } + } + } + } + } + + for function in &mut module.functions { + for parameter in &mut function.parameters { + parameter.ty.replace_addresses(replacements); + } + for return_type in &mut function.return_types { + return_type.replace_addresses(replacements); + } + } + } + } + + /// Return every package/type address referenced by this IR. + pub fn referenced_addresses(&self) -> Vec<&str> { + let mut addresses = vec![self.storage_id.as_str()]; + if let Some(original_id) = &self.original_id { + addresses.push(original_id.as_str()); + } + + for module in self.modules.values() { + for datatype in &module.datatypes { + addresses.push(datatype.type_name.address.as_str()); + match &datatype.kind { + DatatypeKind::Struct { fields } => { + for field in fields { + field.ty.collect_addresses(&mut addresses); + } + } + DatatypeKind::Enum { variants } => { + for variant in variants { + for field in &variant.fields { + field.ty.collect_addresses(&mut addresses); + } + } + } + } + } + + for function in &module.functions { + for parameter in &function.parameters { + parameter.ty.collect_addresses(&mut addresses); + } + for return_type in &function.return_types { + return_type.collect_addresses(&mut addresses); + } + } + } + + addresses + } + + /// Replace synthesized `argN` function parameter names with names recovered from Move source. + pub fn apply_function_parameter_names( + &mut self, + names: &BTreeMap<(String, String), Vec>, + ) -> Result<(), FunctionParameterNameMismatch> { + for (module_name, module) in &mut self.modules { + for function in &mut module.functions { + let Some(source_names) = + names.get(&(module_name.to_owned(), function.name.to_owned())) + else { + continue; + }; + + if function.parameters.len() != source_names.len() { + return Err(FunctionParameterNameMismatch { + module: module_name.to_owned(), + function: function.name.to_owned(), + ir_count: function.parameters.len(), + source_count: source_names.len(), + }); + } + + for (index, (parameter, source_name)) in + function.parameters.iter_mut().zip(source_names).enumerate() + { + if parameter.name == format!("arg{index}") { + parameter.name = source_name.to_owned(); + } + } + } + } + + Ok(()) + } } #[cfg(test)] @@ -320,10 +535,116 @@ mod tests { )]), }; - let json = pkg.to_json_string().expect("serialize"); - let decoded = NormalizedPackage::from_json_str(&json).expect("deserialize"); + let encoded = pkg.to_json_string().expect("serialize"); + let decoded = NormalizedPackage::from_json_str(&encoded).expect("deserialize"); assert_eq!(pkg, decoded); - assert!(json.contains("\"storage_id\"")); - assert!(json.contains("\"modules\"")); + assert!(!encoded.is_empty()); + } + + #[test] + fn replace_addresses_rewrites_package_and_nested_type_refs() { + let mut pkg = NormalizedPackage { + storage_id: "0x0009".into(), + original_id: Some("0x9".into()), + version: 1, + modules: BTreeMap::from([( + "m".into(), + NormalizedModule { + name: "m".into(), + datatypes: vec![Datatype { + type_name: TypeName::parse("0x09::m::Obj").unwrap(), + module: "m".into(), + name: "Obj".into(), + abilities: vec![], + type_parameters: vec![], + kind: DatatypeKind::Struct { + fields: vec![Field { + name: "inner".into(), + position: 0, + ty: TypeRef::Datatype { + type_name: TypeName::parse("0x0009::m::Inner").unwrap(), + type_arguments: vec![TypeRef::Datatype { + type_name: TypeName::parse("0x9::m::Leaf").unwrap(), + type_arguments: vec![], + }], + }, + }], + }, + }], + functions: vec![Function { + name: "use_obj".into(), + visibility: Visibility::Public, + is_entry: false, + type_parameters: vec![], + parameters: vec![FunctionParam { + name: "arg0".into(), + ty: TypeRef::Ref { + mutable: true, + inner: Box::new(TypeRef::Datatype { + type_name: TypeName::parse("0x9::m::Obj").unwrap(), + type_arguments: vec![], + }), + }, + }], + return_types: vec![TypeRef::Datatype { + type_name: TypeName::parse("0x9::m::Obj").unwrap(), + type_arguments: vec![], + }], + }], + }, + )]), + }; + + pkg.replace_addresses(&[("0x9", "0xa1")]); + + assert_eq!(pkg.storage_id, "0xa1"); + assert_eq!(pkg.original_id.as_deref(), Some("0xa1")); + assert!(pkg + .referenced_addresses() + .into_iter() + .all(|address| address == "0xa1")); + } + + #[test] + fn applies_source_names_only_to_synthesized_parameters() { + let mut pkg = NormalizedPackage { + storage_id: "0x1".into(), + original_id: None, + version: 1, + modules: BTreeMap::from([( + "m".into(), + NormalizedModule { + name: "m".into(), + datatypes: vec![], + functions: vec![Function { + name: "f".into(), + visibility: Visibility::Public, + is_entry: true, + type_parameters: vec![], + parameters: vec![ + FunctionParam { + name: "arg0".into(), + ty: TypeRef::U64, + }, + FunctionParam { + name: "explicit".into(), + ty: TypeRef::Bool, + }, + ], + return_types: vec![], + }], + }, + )]), + }; + let names = BTreeMap::from([( + ("m".to_string(), "f".to_string()), + vec!["amount".to_string(), "flag".to_string()], + )]); + + pkg.apply_function_parameter_names(&names).unwrap(); + let parameters = &pkg.modules["m"].functions[0].parameters; + + assert_eq!(parameters[0].name, "amount"); + assert_eq!(parameters[1].name, "explicit"); } } diff --git a/sui-move-codegen/src/lib.rs b/sui-move-codegen/src/lib.rs index c2720ab..4479751 100644 --- a/sui-move-codegen/src/lib.rs +++ b/sui-move-codegen/src/lib.rs @@ -4,6 +4,7 @@ //! See `README.md` for the crate-level overview. mod source; +mod source_names; /// Normalized, serde-friendly package IR. pub mod ir; @@ -12,6 +13,10 @@ pub mod ir; pub mod render; pub use crate::source::fetch_package; +pub use crate::source_names::{ + apply_function_parameter_names_from_sources, function_parameter_names_from_sources, + SourceNameError, +}; /// Sui gRPC client used by package metadata fetching. pub type GrpcClient = sui_rpc::Client; diff --git a/sui-move-codegen/src/render/calls.rs b/sui-move-codegen/src/render/calls.rs index 8cd4b81..70953a0 100644 --- a/sui-move-codegen/src/render/calls.rs +++ b/sui-move-codegen/src/render/calls.rs @@ -1,14 +1,19 @@ -//! Call-stub rendering (Move functions → `CallSpec` builders). +//! Call-stub rendering (Move functions → call targets and optional `CallSpec` builders). //! //! Design goals: //! - keep the generated API “honest”: it mirrors the Move signature shape (generic params, `&mut`) -//! - keep generated calls composable: every function returns a `CallSpec` +//! - keep generated calls composable: every function exposes a `CallTarget` +//! - optionally mirror the Move signature as a typed `CallSpec` builder //! - keep `TxContext` out of user code: higher layers supply it during PTB building use proc_macro2::TokenStream; use quote::{format_ident, quote}; +use std::collections::{BTreeMap, BTreeSet}; -use crate::ir::{Ability, Function, NormalizedModule, NormalizedPackage, TypeRef, Visibility}; +use crate::ir::{ + Ability, Datatype, DatatypeKind, Field, Function, NormalizedModule, NormalizedPackage, + TypeName, TypeRef, Visibility, +}; use super::{builtins, idents, types, RenderOptions}; @@ -20,7 +25,7 @@ pub(crate) fn render_functions( module .functions .iter() - .filter(|f| matches!(f.visibility, Visibility::Public)) + .filter(|f| matches!(f.visibility, Visibility::Public) || f.is_entry) .map(|f| render_function(module, f, pkg, opts)) .collect() } @@ -38,23 +43,29 @@ fn render_function( }; let fn_ident = idents::ident(&f.name); + let target_fn_ident = idents::ident(&format!("{}_target", f.name)); let type_params = (0..f.type_parameters.len()) .map(|i| format_ident!("T{i}")) .collect::>(); let fn_generics = type_generics(&type_params); - let bounds = type_param_bounds(f, opts.use_aliases); + let bounds = type_param_bounds(f, pkg, opts.use_aliases); let where_clause = where_clause(&bounds); let module_name = syn::LitStr::new(&module.name, proc_macro2::Span::call_site()); let function_name = syn::LitStr::new(&f.name, proc_macro2::Span::call_site()); + let target_init = if type_params.is_empty() { + quote! { let target = #sm_call::CallTarget::new(call_package(), #module_name, #function_name)?; } + } else { + quote! { let mut target = #sm_call::CallTarget::new(call_package(), #module_name, #function_name)?; } + }; + let push_type_args = type_params .iter() - .map(|ty| quote! { spec.push_type_arg::<#ty>(); }); - - let (params, pushes, skipped_tx_context) = render_params_and_pushes(module, f, pkg, opts); + .map(|ty| quote! { target.push_type_arg::<#ty>(); }); + let skipped_tx_context = has_tx_context(f); let signature = move_signature_string(module, f); let doc = doc_lines(&[ format!("Move: `{signature}`"), @@ -69,19 +80,44 @@ fn render_function( "Note: this function is not marked `entry`.".to_string() }, ]); + let target_call = if type_params.is_empty() { + quote! { #target_fn_ident() } + } else { + quote! { #target_fn_ident::<#(#type_params),*>() } + }; + let spec_builder = if opts.emit_call_specs { + let (params, pushes) = render_params_and_pushes(module, f, pkg, opts); + let spec_init = if pushes.is_empty() { + quote! { let spec = #sm_call::CallSpec::from_target(#target_call?); } + } else { + quote! { let mut spec = #sm_call::CallSpec::from_target(#target_call?); } + }; + + quote! { + #doc + pub fn #fn_ident #fn_generics ( #(#params),* ) -> Result<#sm_call::CallSpec, #sm_call::CallSpecError> + #where_clause + { + #spec_init + #(#pushes)* + Ok(spec) + } + } + } else { + quote! {} + }; quote! { #doc - #[must_use] - pub fn #fn_ident #fn_generics ( #(#params),* ) -> #sm_call::CallSpec + pub fn #target_fn_ident #fn_generics () -> Result<#sm_call::CallTarget, #sm_call::CallSpecError> #where_clause { - let mut spec = #sm_call::CallSpec::new(PACKAGE, #module_name, #function_name) - .expect("valid Move identifiers"); + #target_init #(#push_type_args)* - #(#pushes)* - spec + Ok(target) } + + #spec_builder } } @@ -90,7 +126,7 @@ fn render_params_and_pushes( f: &Function, pkg: &NormalizedPackage, opts: &RenderOptions, -) -> (Vec, Vec, bool) { +) -> (Vec, Vec) { let sm_call = if opts.use_aliases { quote! { sm_call } } else { @@ -99,12 +135,10 @@ fn render_params_and_pushes( let mut params = Vec::new(); let mut pushes = Vec::new(); - let mut skipped_tx_context = false; let mut arg_idx: usize = 0; for p in &f.parameters { if is_tx_context(&p.ty) { - skipped_tx_context = true; continue; } @@ -123,18 +157,22 @@ fn render_params_and_pushes( }; params.push(quote! { #arg_ident: #param_ty }); if ref_mutable { - pushes.push(quote! { spec.push_arg_mut(#arg_ident).expect("encode arg"); }); + pushes.push(quote! { spec.push_arg_mut(#arg_ident)?; }); } else { - pushes.push(quote! { spec.push_arg(#arg_ident).expect("encode arg"); }); + pushes.push(quote! { spec.push_arg(#arg_ident)?; }); } } else { let value_ty = types::render_type_ref_in_module(inner, &module.name, pkg, opts); params.push(quote! { #arg_ident: #value_ty }); - pushes.push(quote! { spec.push_arg(&#arg_ident).expect("encode arg"); }); + pushes.push(quote! { spec.push_arg(&#arg_ident)?; }); } } - (params, pushes, skipped_tx_context) + (params, pushes) +} + +fn has_tx_context(f: &Function) -> bool { + f.parameters.iter().any(|p| is_tx_context(&p.ty)) } fn is_tx_context(ty: &TypeRef) -> bool { @@ -189,27 +227,35 @@ fn is_object_type( } } -fn type_param_bounds(f: &Function, use_aliases: bool) -> Vec { +pub(super) fn type_param_bounds( + f: &Function, + pkg: &NormalizedPackage, + use_aliases: bool, +) -> Vec { let sm = if use_aliases { quote! { sm } } else { quote! { sui_move } }; + let required = required_type_param_abilities(f, pkg); + f.type_parameters .iter() .enumerate() - .map(|(idx, p)| { + .map(|(idx, _)| { let ty = format_ident!("T{idx}"); - let base = if p.constraints.contains(&Ability::Key) { + let abilities = required.get(&idx).cloned().unwrap_or_default(); + + let base = if abilities.contains(&Ability::Key) { quote! { #sm::MoveStruct } } else { quote! { #sm::MoveType } }; let mut bounds: Vec = vec![base]; - for a in &p.constraints { + for a in abilities { bounds.push(match a { Ability::Copy => quote! { #sm::HasCopy }, Ability::Drop => quote! { #sm::HasDrop }, @@ -222,6 +268,175 @@ fn type_param_bounds(f: &Function, use_aliases: bool) -> Vec { .collect() } +fn required_type_param_abilities( + f: &Function, + pkg: &NormalizedPackage, +) -> BTreeMap> { + let mut required: BTreeMap> = f + .type_parameters + .iter() + .enumerate() + .map(|(idx, p)| (idx, p.constraints.iter().cloned().collect())) + .collect(); + + for p in &f.parameters { + if is_tx_context(&p.ty) { + continue; + } + + let (_, inner) = split_ref(&p.ty); + collect_move_type_bounds(inner, pkg, &mut required); + } + + required +} + +fn collect_move_type_bounds( + ty: &TypeRef, + pkg: &NormalizedPackage, + required: &mut BTreeMap>, +) { + match ty { + TypeRef::Vector(inner) => collect_move_type_bounds(inner, pkg, required), + TypeRef::Ref { inner, .. } => collect_move_type_bounds(inner, pkg, required), + TypeRef::Datatype { + type_name, + type_arguments, + } => { + if let Some(dt) = find_local_datatype(pkg, type_name) { + collect_datatype_impl_bounds(dt, type_arguments, pkg, required); + } + } + _ => {} + } +} + +fn collect_ability_bounds( + ty: &TypeRef, + ability: Ability, + pkg: &NormalizedPackage, + required: &mut BTreeMap>, +) { + match ty { + TypeRef::TypeParameter(idx) => { + required.entry(*idx as usize).or_default().insert(ability); + } + TypeRef::Vector(inner) => collect_ability_bounds(inner, ability, pkg, required), + TypeRef::Ref { inner, .. } => collect_ability_bounds(inner, ability, pkg, required), + TypeRef::Datatype { + type_name, + type_arguments, + } => { + if let Some(dt) = find_local_datatype(pkg, type_name) { + collect_datatype_impl_bounds(dt, type_arguments, pkg, required); + } + } + _ => {} + } +} + +fn collect_datatype_impl_bounds( + dt: &Datatype, + type_arguments: &[TypeRef], + pkg: &NormalizedPackage, + required: &mut BTreeMap>, +) { + for (idx, param) in dt.type_parameters.iter().enumerate() { + let Some(arg) = type_arguments.get(idx) else { + continue; + }; + + collect_move_type_bounds(arg, pkg, required); + for ability in ¶m.constraints { + collect_ability_bounds(arg, ability.clone(), pkg, required); + } + } + + match &dt.kind { + DatatypeKind::Struct { fields } => { + collect_field_ability_bounds(fields, &dt.abilities, type_arguments, pkg, required); + } + DatatypeKind::Enum { variants } => { + for variant in variants { + collect_field_ability_bounds( + &variant.fields, + &dt.abilities, + type_arguments, + pkg, + required, + ); + } + } + } +} + +fn collect_field_ability_bounds( + fields: &[Field], + abilities: &[Ability], + type_arguments: &[TypeRef], + pkg: &NormalizedPackage, + required: &mut BTreeMap>, +) { + for field in fields { + let ty = substitute_type_params(&field.ty, type_arguments); + for ability in abilities { + match ability { + Ability::Copy | Ability::Drop | Ability::Store => { + collect_ability_bounds(&ty, ability.clone(), pkg, required); + } + Ability::Key => {} + } + } + } +} + +fn substitute_type_params(ty: &TypeRef, type_arguments: &[TypeRef]) -> TypeRef { + match ty { + TypeRef::Vector(inner) => { + TypeRef::Vector(Box::new(substitute_type_params(inner, type_arguments))) + } + TypeRef::Ref { mutable, inner } => TypeRef::Ref { + mutable: *mutable, + inner: Box::new(substitute_type_params(inner, type_arguments)), + }, + TypeRef::Datatype { + type_name, + type_arguments: args, + } => TypeRef::Datatype { + type_name: type_name.clone(), + type_arguments: args + .iter() + .map(|arg| substitute_type_params(arg, type_arguments)) + .collect(), + }, + TypeRef::TypeParameter(idx) => type_arguments + .get(*idx as usize) + .cloned() + .unwrap_or(TypeRef::TypeParameter(*idx)), + other => other.clone(), + } +} + +fn find_local_datatype<'a>( + pkg: &'a NormalizedPackage, + type_name: &TypeName, +) -> Option<&'a Datatype> { + if type_name.address != pkg.storage_id + && match &pkg.original_id { + Some(original_id) => type_name.address != *original_id, + None => true, + } + { + return None; + } + + pkg.modules + .get(&type_name.module)? + .datatypes + .iter() + .find(|dt| dt.type_name.name == type_name.name) +} + fn where_clause(bounds: &[TokenStream]) -> TokenStream { if bounds.is_empty() { quote! {} diff --git a/sui-move-codegen/src/render/mod.rs b/sui-move-codegen/src/render/mod.rs index 020582b..8c2ae50 100644 --- a/sui-move-codegen/src/render/mod.rs +++ b/sui-move-codegen/src/render/mod.rs @@ -7,7 +7,8 @@ //! //! The generated code is designed to plug into the rest of the workspace: //! - generated types implement `sui-move` traits (`MoveType` / `MoveStruct`) and ability markers -//! - generated functions return `sui-move-call::CallSpec` +//! - generated call targets identify Move functions for PTB builders +//! - generated call-spec builders can return `sui-move-call::CallSpec` //! - optionally, a `TxExt` trait is emitted to add calls directly to `sui-move-runtime::Tx` use std::collections::BTreeMap; @@ -32,6 +33,11 @@ pub struct RenderOptions { pub emit_types: bool, /// Emit call builder functions. pub emit_calls: bool, + /// Emit typed `CallSpec` builders in addition to generated `*_target` functions. + /// + /// Set this to `false` for consumers that compose PTBs from generated targets and explicit + /// `sui_sdk_types::Argument`s. + pub emit_call_specs: bool, /// Emit runtime helpers for `sui-move-runtime` (`TxExt`). /// /// When enabled, generated code includes a `TxExt` trait implemented for @@ -49,6 +55,8 @@ pub struct RenderOptions { /// - `use sui_move as sm;` /// - `use sui_move_call as sm_call;` pub use_aliases: bool, + /// If `true`, re-export generated datatypes from the package root. + pub emit_reexports: bool, /// Rust paths for Move datatypes defined outside the package currently being rendered. /// /// This is the explicit cross-package symbol table. The renderer still treats unknown external @@ -75,9 +83,11 @@ impl Default for RenderOptions { Self { emit_types: true, emit_calls: true, + emit_call_specs: true, emit_tx_ext: false, flatten: false, use_aliases: true, + emit_reexports: true, external_types: BTreeMap::new(), } } @@ -174,6 +184,19 @@ fn external_type_names(pkg: &NormalizedPackage, dt: &crate::ir::Datatype) -> Vec names } +/// Rendered package split into package-root code and one generated module block per Move module. +/// +/// This is useful for build scripts that need to choose their own `include!` layout without +/// parsing rendered Rust source text. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RenderedPackageParts { + /// Package-root code: package constants, package scoping helpers, optional `TxExt`, and + /// optional root re-exports. + pub root: String, + /// `pub mod ... { ... }` blocks keyed by Move module name. + pub modules: BTreeMap, +} + /// Render a normalized package into a single Rust source string. /// /// The output is valid Rust source that you can write to a `.rs` file (or `include!` as a module). @@ -199,18 +222,39 @@ fn external_type_names(pkg: &NormalizedPackage, dt: &crate::ir::Datatype) -> Vec /// }; /// /// let code = render_package(&pkg, &RenderOptions::default()); -/// assert!(code.contains("pub const PACKAGE")); +/// assert!(code.contains("pub const CALL_PACKAGE")); /// ``` pub fn render_package(pkg: &NormalizedPackage, opts: &RenderOptions) -> String { let tokens = util::render_package_tokens(pkg, opts); util::prettify(tokens) } +/// Render a normalized package into package-root source and per-module `pub mod ...` blocks. +/// +/// Unlike [`render_package_split`], this does not write files or assume `mod.rs` plus sibling +/// module files. Callers decide how to include or store the returned strings. +pub fn render_package_parts(pkg: &NormalizedPackage, opts: &RenderOptions) -> RenderedPackageParts { + let mut parts_opts = opts.clone(); + parts_opts.flatten = false; + + let root = util::prettify(util::render_package_root_tokens(pkg, &parts_opts, false)); + let modules = pkg + .modules + .values() + .map(|module| { + let tokens = util::render_module(module, pkg, &parts_opts); + (module.name.clone(), util::prettify(tokens)) + }) + .collect(); + + RenderedPackageParts { root, modules } +} + /// Render a normalized package into multiple files (`mod.rs` + one file per Move module). /// /// This is convenient if you want the generated code to mirror the Move module structure on disk. /// The output directory will contain: -/// - `mod.rs` (with `PACKAGE`, `pub mod ...;`, and `pub use ...;` re-exports) +/// - `mod.rs` (with package scope helpers, `pub mod ...;`, and `pub use ...;` re-exports) /// - one `*.rs` file per Move module pub fn render_package_split( pkg: &NormalizedPackage, @@ -245,6 +289,102 @@ mod tests { use crate::ir::*; fn demo_pkg() -> NormalizedPackage { + NormalizedPackage { + storage_id: "0x1".into(), + original_id: None, + version: 0, + modules: BTreeMap::from([( + "m".into(), + NormalizedModule { + name: "m".into(), + datatypes: vec![ + Datatype { + type_name: TypeName::parse("0x1::m::Obj").unwrap(), + module: "m".into(), + name: "Obj".into(), + abilities: vec![Ability::Key, Ability::Store], + type_parameters: vec![], + kind: DatatypeKind::Struct { + fields: vec![Field { + name: "id".into(), + position: 0, + ty: TypeRef::Datatype { + type_name: TypeName::parse("0x2::object::UID").unwrap(), + type_arguments: vec![], + }, + }], + }, + }, + Datatype { + type_name: TypeName::parse("0x1::m::Pair").unwrap(), + module: "m".into(), + name: "Pair".into(), + abilities: vec![Ability::Store, Ability::Copy, Ability::Drop], + type_parameters: vec![], + kind: DatatypeKind::Struct { + fields: vec![ + Field { + name: "left".into(), + position: 0, + ty: TypeRef::U8, + }, + Field { + name: "right".into(), + position: 1, + ty: TypeRef::Bool, + }, + ], + }, + }, + ], + functions: vec![ + Function { + name: "mutate".into(), + visibility: Visibility::Public, + is_entry: true, + type_parameters: vec![], + parameters: vec![ + FunctionParam { + name: "arg0".into(), + ty: TypeRef::Ref { + mutable: true, + inner: Box::new(TypeRef::Datatype { + type_name: TypeName::parse("0x1::m::Obj").unwrap(), + type_arguments: vec![], + }), + }, + }, + FunctionParam { + name: "arg1".into(), + ty: TypeRef::Ref { + mutable: true, + inner: Box::new(TypeRef::Datatype { + type_name: TypeName::parse( + "0x2::tx_context::TxContext", + ) + .unwrap(), + type_arguments: vec![], + }), + }, + }, + ], + return_types: vec![], + }, + Function { + name: "private_entry".into(), + visibility: Visibility::Private, + is_entry: true, + type_parameters: vec![], + parameters: vec![], + return_types: vec![], + }, + ], + }, + )]), + } + } + + fn generic_value_arg_pkg() -> NormalizedPackage { NormalizedPackage { storage_id: "0x1".into(), original_id: None, @@ -254,63 +394,411 @@ mod tests { NormalizedModule { name: "m".into(), datatypes: vec![Datatype { - type_name: TypeName::parse("0x1::m::Obj").unwrap(), + type_name: TypeName::parse("0x1::m::Cloneable").unwrap(), module: "m".into(), - name: "Obj".into(), - abilities: vec![Ability::Key, Ability::Store], - type_parameters: vec![], + name: "Cloneable".into(), + abilities: vec![Ability::Store, Ability::Copy, Ability::Drop], + type_parameters: vec![TypeParameter { + constraints: vec![Ability::Store, Ability::Copy], + is_phantom: false, + }], kind: DatatypeKind::Struct { fields: vec![Field { - name: "id".into(), + name: "value".into(), position: 0, - ty: TypeRef::Datatype { - type_name: TypeName::parse("0x2::object::UID").unwrap(), - type_arguments: vec![], - }, + ty: TypeRef::TypeParameter(0), }], }, }], functions: vec![Function { - name: "mutate".into(), + name: "use_cloneable".into(), visibility: Visibility::Public, is_entry: true, - type_parameters: vec![], - parameters: vec![ - FunctionParam { - name: "arg0".into(), - ty: TypeRef::Ref { - mutable: true, - inner: Box::new(TypeRef::Datatype { - type_name: TypeName::parse("0x1::m::Obj").unwrap(), - type_arguments: vec![], - }), + type_parameters: vec![TypeParameter { + constraints: vec![Ability::Store, Ability::Copy], + is_phantom: false, + }], + parameters: vec![FunctionParam { + name: "arg0".into(), + ty: TypeRef::Datatype { + type_name: TypeName::parse("0x1::m::Cloneable").unwrap(), + type_arguments: vec![TypeRef::TypeParameter(0)], + }, + }], + return_types: vec![], + }], + }, + )]), + } + } + + fn option_pkg() -> NormalizedPackage { + NormalizedPackage { + storage_id: "0x1".into(), + original_id: None, + version: 0, + modules: BTreeMap::from([( + "option".into(), + NormalizedModule { + name: "option".into(), + datatypes: vec![Datatype { + type_name: TypeName::parse("0x1::option::Option").unwrap(), + module: "option".into(), + name: "Option".into(), + abilities: vec![Ability::Store, Ability::Copy, Ability::Drop], + type_parameters: vec![TypeParameter { + constraints: vec![], + is_phantom: false, + }], + kind: DatatypeKind::Struct { + fields: vec![Field { + name: "vec".into(), + position: 0, + ty: TypeRef::Vector(Box::new(TypeRef::TypeParameter(0))), + }], + }, + }], + functions: vec![], + }, + )]), + } + } + + fn layout_helper_pkg() -> NormalizedPackage { + NormalizedPackage { + storage_id: "0x2".into(), + original_id: None, + version: 0, + modules: BTreeMap::from([ + ( + "table_vec".into(), + NormalizedModule { + name: "table_vec".into(), + datatypes: vec![Datatype { + type_name: TypeName::parse("0x2::table_vec::TableVec").unwrap(), + module: "table_vec".into(), + name: "TableVec".into(), + abilities: vec![Ability::Store], + type_parameters: vec![TypeParameter { + constraints: vec![Ability::Store], + is_phantom: true, + }], + kind: DatatypeKind::Struct { + fields: vec![Field { + name: "contents".into(), + position: 0, + ty: TypeRef::Datatype { + type_name: TypeName::parse("0x2::table::Table").unwrap(), + type_arguments: vec![ + TypeRef::U64, + TypeRef::TypeParameter(0), + ], + }, + }], + }, + }], + functions: vec![], + }, + ), + ( + "vec_map".into(), + NormalizedModule { + name: "vec_map".into(), + datatypes: vec![ + Datatype { + type_name: TypeName::parse("0x2::vec_map::Entry").unwrap(), + module: "vec_map".into(), + name: "Entry".into(), + abilities: vec![Ability::Store, Ability::Copy, Ability::Drop], + type_parameters: vec![ + TypeParameter { + constraints: vec![Ability::Copy], + is_phantom: false, + }, + TypeParameter { + constraints: vec![], + is_phantom: false, + }, + ], + kind: DatatypeKind::Struct { + fields: vec![ + Field { + name: "key".into(), + position: 0, + ty: TypeRef::TypeParameter(0), + }, + Field { + name: "value".into(), + position: 1, + ty: TypeRef::TypeParameter(1), + }, + ], }, }, - FunctionParam { - name: "arg1".into(), - ty: TypeRef::Ref { - mutable: true, - inner: Box::new(TypeRef::Datatype { - type_name: TypeName::parse("0x2::tx_context::TxContext") - .unwrap(), - type_arguments: vec![], - }), + Datatype { + type_name: TypeName::parse("0x2::vec_map::VecMap").unwrap(), + module: "vec_map".into(), + name: "VecMap".into(), + abilities: vec![Ability::Store, Ability::Copy, Ability::Drop], + type_parameters: vec![ + TypeParameter { + constraints: vec![Ability::Copy], + is_phantom: false, + }, + TypeParameter { + constraints: vec![], + is_phantom: false, + }, + ], + kind: DatatypeKind::Struct { + fields: vec![Field { + name: "contents".into(), + position: 0, + ty: TypeRef::Vector(Box::new(TypeRef::Datatype { + type_name: TypeName::parse("0x2::vec_map::Entry") + .unwrap(), + type_arguments: vec![ + TypeRef::TypeParameter(0), + TypeRef::TypeParameter(1), + ], + })), + }], }, }, ], - return_types: vec![], - }], + functions: vec![], + }, + ), + ]), + } + } + + fn rust_copy_shape_pkg() -> NormalizedPackage { + NormalizedPackage { + storage_id: "0x1".into(), + original_id: None, + version: 0, + modules: BTreeMap::from([( + "m".into(), + NormalizedModule { + name: "m".into(), + datatypes: vec![ + Datatype { + type_name: TypeName::parse("0x1::m::Scalar").unwrap(), + module: "m".into(), + name: "Scalar".into(), + abilities: vec![Ability::Store, Ability::Copy, Ability::Drop], + type_parameters: vec![], + kind: DatatypeKind::Struct { + fields: vec![Field { + name: "value".into(), + position: 0, + ty: TypeRef::U64, + }], + }, + }, + Datatype { + type_name: TypeName::parse("0x1::m::Nested").unwrap(), + module: "m".into(), + name: "Nested".into(), + abilities: vec![Ability::Store, Ability::Copy, Ability::Drop], + type_parameters: vec![], + kind: DatatypeKind::Struct { + fields: vec![Field { + name: "scalar".into(), + position: 0, + ty: TypeRef::Datatype { + type_name: TypeName::parse("0x1::m::Scalar").unwrap(), + type_arguments: vec![], + }, + }], + }, + }, + Datatype { + type_name: TypeName::parse("0x1::m::Bytes").unwrap(), + module: "m".into(), + name: "Bytes".into(), + abilities: vec![Ability::Store, Ability::Copy, Ability::Drop], + type_parameters: vec![], + kind: DatatypeKind::Struct { + fields: vec![Field { + name: "value".into(), + position: 0, + ty: TypeRef::Vector(Box::new(TypeRef::U8)), + }], + }, + }, + Datatype { + type_name: TypeName::parse("0x1::m::Generic").unwrap(), + module: "m".into(), + name: "Generic".into(), + abilities: vec![Ability::Store, Ability::Copy, Ability::Drop], + type_parameters: vec![TypeParameter { + constraints: vec![Ability::Store, Ability::Copy], + is_phantom: false, + }], + kind: DatatypeKind::Struct { + fields: vec![Field { + name: "value".into(), + position: 0, + ty: TypeRef::TypeParameter(0), + }], + }, + }, + Datatype { + type_name: TypeName::parse("0x1::m::ScalarChoice").unwrap(), + module: "m".into(), + name: "ScalarChoice".into(), + abilities: vec![Ability::Store, Ability::Copy, Ability::Drop], + type_parameters: vec![], + kind: DatatypeKind::Enum { + variants: vec![ + Variant { + name: "None".into(), + position: 0, + fields: vec![], + }, + Variant { + name: "Some".into(), + position: 1, + fields: vec![Field { + name: "value".into(), + position: 0, + ty: TypeRef::Datatype { + type_name: TypeName::parse("0x1::m::Nested") + .unwrap(), + type_arguments: vec![], + }, + }], + }, + ], + }, + }, + ], + functions: vec![], }, )]), } } + fn item_prefix<'a>(code: &'a str, item: &str) -> &'a str { + let end = code.find(item).expect("rendered item"); + let start = code[..end].rfind("///Move type:").unwrap_or(0); + &code[start..end] + } + #[test] fn renders_mutable_object_params_with_push_arg_mut() { let code = render_package(&demo_pkg(), &RenderOptions::default()); assert!(code.contains("push_arg_mut(arg0)")); } + #[test] + fn generated_bindings_use_scoped_package_for_calls_and_type_tags() { + let code = render_package(&demo_pkg(), &RenderOptions::default()); + + assert!(code.contains("thread_local!")); + assert!(code.contains("pub fn call_package() -> sui_move::prelude::Address")); + assert!(code.contains("pub fn type_package() -> sui_move::prelude::Address")); + assert!(code.contains("pub fn with_packages")); + assert!(!code.contains("pub fn package()")); + assert!(!code.contains("pub fn with_package")); + assert!(code.contains("CallTarget::new(call_package(), \"m\", \"mutate\")")); + assert!(code.contains("address_fn = \"super::type_package\"")); + } + + #[test] + fn generated_bindings_split_call_and_type_package_scopes() { + let code = render_package(&demo_pkg(), &RenderOptions::default()); + + assert!(code.contains("pub const CALL_PACKAGE")); + assert!(code.contains("pub const TYPE_PACKAGE")); + assert!(code.contains("pub fn call_package() -> sui_move::prelude::Address")); + assert!(code.contains("pub fn type_package() -> sui_move::prelude::Address")); + assert!(code.contains("pub fn with_packages")); + assert!(code.contains("CallTarget::new(call_package(), \"m\", \"mutate\")")); + assert!(code.contains("address_fn = \"super::type_package\"")); + } + + #[test] + fn generated_calls_include_entry_functions_even_when_not_public() { + let code = render_package(&demo_pkg(), &RenderOptions::default()); + assert!(code.contains("pub fn private_entry")); + assert!(code.contains("pub fn private_entry_target")); + } + + #[test] + fn generated_calls_return_result_instead_of_panicking() { + let code = render_package(&demo_pkg(), &RenderOptions::default()); + assert!(code.contains("-> Result Result")); + assert!(code.contains("T0: sm::MoveType + sm::HasCopy + sm::HasDrop + sm::HasStore")); + } + + #[test] + fn generated_structs_include_named_field_constructors() { + let code = render_package(&demo_pkg(), &RenderOptions::default()); + + assert!(code.contains("pub fn new(left: u8, right: bool) -> Self")); + assert!(code.contains("left: left.into()")); + assert!(code.contains("right: right.into()")); + } + + #[test] + fn generated_option_layout_helpers_do_not_require_move_type_bounds() { + let code = render_package(&option_pkg(), &RenderOptions::default()); + + assert!(code.contains("pub fn from_option(value: std::option::Option) -> Self")); + assert!(code.contains("impl Default for Option")); + assert!(code.contains("impl From> for Option")); + assert!(!code.contains("T0: sm::MoveType")); + } + + #[test] + fn generated_collection_layout_helpers_use_minimal_rust_bounds() { + let code = render_package(&layout_helper_pkg(), &RenderOptions::default()); + + assert!(code.contains("pub fn size_u64(&self) -> u64")); + assert!(code.contains("pub fn into_hash_map(self) -> std::collections::HashMap")); + assert!(!code.contains("T0: sm::MoveType")); + assert!(!code.contains("T1: sm::MoveType")); + assert!(!code.contains("T0: sm::MoveType + sm::HasCopy")); + } + + #[test] + fn generated_rust_copy_tracks_rust_carrier_shape() { + let code = render_package(&rust_copy_shape_pkg(), &RenderOptions::default()); + + assert!(item_prefix(&code, "pub struct Scalar").contains("#[derive(::core::marker::Copy)]")); + assert!(item_prefix(&code, "pub struct Nested").contains("#[derive(::core::marker::Copy)]")); + assert!(!item_prefix(&code, "pub struct Bytes").contains("#[derive(::core::marker::Copy)]")); + assert!( + !item_prefix(&code, "pub struct Generic").contains("#[derive(::core::marker::Copy)]") + ); + assert!(item_prefix(&code, "pub enum ScalarChoice").contains("::core::marker::Copy")); + } + #[test] fn renders_structs_with_sui_move_move_struct_attribute() { let opts = RenderOptions { @@ -405,6 +893,52 @@ mod tests { assert!(code.contains("&mut impl sm_call::ObjectArg")); } + #[test] + fn render_package_parts_returns_root_and_modules_without_string_parsing() { + let opts = RenderOptions { + emit_reexports: false, + ..RenderOptions::default() + }; + let parts = render_package_parts(&demo_pkg(), &opts); + + assert!(parts.root.contains("pub const CALL_PACKAGE")); + assert!(parts.root.contains("pub fn with_packages")); + assert!(!parts.root.contains("pub mod m")); + assert!(!parts.root.contains("pub use m::Obj")); + + let module = parts.modules.get("m").expect("module body"); + assert!(module.contains("pub mod m")); + assert!(module.contains("use super::{call_package, type_package};")); + assert!(module.contains("pub struct Obj")); + assert!(module.contains("pub fn mutate")); + } + + #[test] + fn render_package_parts_can_emit_targets_without_call_specs() { + let opts = RenderOptions { + emit_call_specs: false, + ..RenderOptions::default() + }; + let parts = render_package_parts(&demo_pkg(), &opts); + let module = parts.modules.get("m").expect("module body"); + + assert!(module.contains("pub fn mutate_target() -> Result TxExt for sui_move_runtime::Tx<'a, S>")); - assert!(code.contains("self.call(m::mutate")); + assert!(code.contains("let spec = m::mutate")); + assert!(code.contains("self.call(spec)")); } #[test] diff --git a/sui-move-codegen/src/render/tx_ext.rs b/sui-move-codegen/src/render/tx_ext.rs index ce1a425..8b20aa7 100644 --- a/sui-move-codegen/src/render/tx_ext.rs +++ b/sui-move-codegen/src/render/tx_ext.rs @@ -24,7 +24,7 @@ pub(crate) fn render_tx_ext(pkg: &NormalizedPackage, opts: &RenderOptions) -> To for f in module .functions .iter() - .filter(|f| matches!(f.visibility, Visibility::Public)) + .filter(|f| matches!(f.visibility, Visibility::Public) || f.is_entry) { let (trait_method, impl_method) = render_method(module, f, pkg, opts); trait_methods.push(trait_method); @@ -77,7 +77,7 @@ fn render_method( .map(|i| format_ident!("T{i}")) .collect::>(); let fn_generics = type_generics(&type_params); - let bounds = type_param_bounds(f, opts.use_aliases); + let bounds = super::calls::type_param_bounds(f, pkg, opts.use_aliases); let where_clause = where_clause(&bounds); let (params, args, skipped_tx_context) = render_params_and_args(f, pkg, opts); @@ -111,7 +111,8 @@ fn render_method( -> Result #where_clause { - self.call(#call_expr) + let spec = #call_expr?; + self.call(spec) } }; @@ -216,39 +217,6 @@ fn is_object_type( } } -fn type_param_bounds(f: &Function, use_aliases: bool) -> Vec { - let sm = if use_aliases { - quote! { sm } - } else { - quote! { sui_move } - }; - - f.type_parameters - .iter() - .enumerate() - .map(|(idx, p)| { - let ty = format_ident!("T{idx}"); - - let base = if p.constraints.contains(&Ability::Key) { - quote! { #sm::MoveStruct } - } else { - quote! { #sm::MoveType } - }; - - let mut bounds: Vec = vec![base]; - for a in &p.constraints { - bounds.push(match a { - Ability::Copy => quote! { #sm::HasCopy }, - Ability::Drop => quote! { #sm::HasDrop }, - Ability::Store => quote! { #sm::HasStore }, - Ability::Key => quote! { #sm::HasKey }, - }); - } - quote! { #ty: #(#bounds)+* } - }) - .collect() -} - fn where_clause(bounds: &[TokenStream]) -> TokenStream { if bounds.is_empty() { quote! {} diff --git a/sui-move-codegen/src/render/types.rs b/sui-move-codegen/src/render/types.rs index 3a74023..b25836b 100644 --- a/sui-move-codegen/src/render/types.rs +++ b/sui-move-codegen/src/render/types.rs @@ -6,6 +6,8 @@ //! Enums are emitted as Rust `enum`s with manual `MoveType` / `MoveStruct` impls. (Move enum //! support is still evolving, so this layer keeps the implementation small and explicit.) +use std::collections::BTreeSet; + use proc_macro2::TokenStream; use quote::{format_ident, quote}; @@ -18,9 +20,14 @@ pub(crate) fn render_datatype( pkg: &NormalizedPackage, opts: &RenderOptions, ) -> TokenStream { - match &dt.kind { + let datatype = match &dt.kind { DatatypeKind::Struct { fields } => render_struct(dt, fields, pkg, opts), DatatypeKind::Enum { variants } => render_enum(dt, variants, pkg, opts), + }; + let helpers = render_canonical_helpers(dt, pkg, opts); + quote! { + #datatype + #helpers } } @@ -82,6 +89,11 @@ fn render_struct( let abilities_arg = abilities_lit.map(|lit| quote! { abilities = #lit, }); let phantoms_arg = phantoms_lit.map(|lit| quote! { phantoms = #lit, }); let type_abilities_arg = type_abilities_lit.map(|lit| quote! { type_abilities = #lit, }); + let address_fn = if opts.flatten { + syn::LitStr::new("type_package", proc_macro2::Span::call_site()) + } else { + syn::LitStr::new("super::type_package", proc_macro2::Span::call_site()) + }; let fields_tokens = fields.iter().map(|f| { let ident = idents::ident(&f.name); @@ -102,11 +114,18 @@ fn render_struct( } else { quote! { sui_move::move_struct } }; + let rust_copy_derive = is_rust_copy_datatype(dt, pkg).then(|| { + quote! { + #[derive(::core::marker::Copy)] + } + }); quote! { #doc + #rust_copy_derive #[#macro_path( address = #address_lit, + address_fn = #address_fn, module = #module_lit, #name_arg #abilities_arg @@ -135,6 +154,7 @@ fn render_enum( let abilities = abilities_string(&dt.abilities); let bounds = type_param_bounds(dt, opts.use_aliases); + let rust_copy = is_rust_copy_datatype(dt, pkg); let sm = if opts.use_aliases { quote! { sm } @@ -144,7 +164,7 @@ fn render_enum( let struct_tag_builder = struct_tag_builder_tokens(dt, opts.use_aliases); let where_clause = where_clause(&bounds); - let derives = enum_derives(&dt.abilities, opts.use_aliases); + let derives = enum_derives(&dt.abilities, rust_copy, opts.use_aliases); let serde_crate_attr = serde_crate_attr(); let variants_tokens = variants.iter().map(|v| { @@ -257,6 +277,693 @@ fn ability_impls_for_datatype( quote! { #(#out)* } } +fn render_canonical_helpers( + dt: &Datatype, + pkg: &NormalizedPackage, + opts: &RenderOptions, +) -> TokenStream { + if !matches!(dt.kind, DatatypeKind::Struct { .. }) { + return quote! {}; + } + + let mut helpers = Vec::new(); + if let Some(helper) = render_struct_constructor_helpers(dt, pkg, opts) { + helpers.push(helper); + } + if is_type(&dt.type_name, "0x1", "ascii", "String") { + helpers.push(render_ascii_string_helpers(dt)); + } + if is_type(&dt.type_name, "0x1", "type_name", "TypeName") { + helpers.push(render_type_name_helpers(dt, pkg, opts)); + } else if let Some(helper) = render_ascii_name_wrapper_helpers(dt, pkg, opts) { + helpers.push(helper); + } + if is_type(&dt.type_name, "0x1", "option", "Option") { + helpers.push(render_option_helpers(dt, opts)); + } + if is_type(&dt.type_name, "0x2", "object", "ID") { + helpers.push(render_object_id_helpers(dt)); + } + if is_type(&dt.type_name, "0x2", "object", "UID") { + helpers.push(render_object_uid_helpers(dt)); + } + if is_type(&dt.type_name, "0x2", "table", "Table") { + helpers.push(render_table_like_helpers( + dt, + opts, + quote! { + Self { + id: super::object::UID::new(id), + size, + phantom_t0: std::marker::PhantomData, + phantom_t1: std::marker::PhantomData, + } + }, + )); + } + if is_type(&dt.type_name, "0x2", "linked_table", "LinkedTable") { + helpers.push(render_table_like_helpers( + dt, + opts, + quote! { + Self { + id: super::object::UID::new(id), + size, + head: Default::default(), + tail: Default::default(), + phantom_t1: std::marker::PhantomData, + } + }, + )); + } + if is_type(&dt.type_name, "0x2", "object_table", "ObjectTable") { + helpers.push(render_id_size_helpers(dt, opts)); + } + if is_type(&dt.type_name, "0x2", "bag", "Bag") + || is_type(&dt.type_name, "0x2", "object_bag", "ObjectBag") + { + helpers.push(render_table_like_helpers( + dt, + opts, + quote! { + Self { + id: super::object::UID::new(id), + size, + } + }, + )); + } + if is_type(&dt.type_name, "0x2", "table_vec", "TableVec") { + helpers.push(render_table_vec_helpers(dt, opts)); + } + if is_type(&dt.type_name, "0x2", "vec_map", "VecMap") { + helpers.push(render_vec_map_helpers(dt, opts)); + } + + quote! { #(#helpers)* } +} + +fn is_rust_copy_datatype(dt: &Datatype, pkg: &NormalizedPackage) -> bool { + rust_copy_type_keys(pkg).contains(&local_type_key(&dt.type_name, pkg)) +} + +fn rust_copy_type_keys(pkg: &NormalizedPackage) -> BTreeSet { + let datatypes = pkg + .modules + .values() + .flat_map(|module| module.datatypes.iter()) + .collect::>(); + let mut copy_types = BTreeSet::new(); + + loop { + let mut changed = false; + for dt in &datatypes { + let key = local_type_key(&dt.type_name, pkg); + if copy_types.contains(&key) { + continue; + } + if datatype_has_rust_copy_shape(dt, pkg, ©_types) { + copy_types.insert(key); + changed = true; + } + } + + if !changed { + return copy_types; + } + } +} + +fn datatype_has_rust_copy_shape( + dt: &Datatype, + pkg: &NormalizedPackage, + copy_types: &BTreeSet, +) -> bool { + if !dt.abilities.contains(&Ability::Copy) || !dt.type_parameters.is_empty() { + return false; + } + + match &dt.kind { + DatatypeKind::Struct { fields } => fields + .iter() + .all(|field| type_has_rust_copy_shape(&field.ty, pkg, copy_types)), + DatatypeKind::Enum { variants } => variants.iter().all(|variant| { + variant + .fields + .iter() + .all(|field| type_has_rust_copy_shape(&field.ty, pkg, copy_types)) + }), + } +} + +fn type_has_rust_copy_shape( + ty: &TypeRef, + pkg: &NormalizedPackage, + copy_types: &BTreeSet, +) -> bool { + match ty { + TypeRef::Address + | TypeRef::Bool + | TypeRef::U8 + | TypeRef::U16 + | TypeRef::U32 + | TypeRef::U64 + | TypeRef::U128 + | TypeRef::U256 => true, + TypeRef::Ref { mutable, .. } => !mutable, + TypeRef::Datatype { + type_name, + type_arguments, + } => { + type_arguments.is_empty() + && is_local_type(type_name, pkg) + && copy_types.contains(&local_type_key(type_name, pkg)) + } + TypeRef::Vector(_) | TypeRef::TypeParameter(_) => false, + } +} + +fn local_type_key(type_name: &TypeName, pkg: &NormalizedPackage) -> String { + let address = if is_local_type(type_name, pkg) { + normalize_address(&pkg.storage_id) + } else { + normalize_address(&type_name.address) + }; + format!("{}::{}::{}", address, type_name.module, type_name.name) +} + +fn render_struct_constructor_helpers( + dt: &Datatype, + pkg: &NormalizedPackage, + opts: &RenderOptions, +) -> Option { + if has_specialized_new_helper(dt, pkg, opts) { + return None; + } + + let DatatypeKind::Struct { fields } = &dt.kind else { + return None; + }; + + let type_ident = idents::ident(&dt.name); + let type_params = type_params_idents(dt.type_parameters.len()); + let (impl_generics, type_generics) = impl_and_type_generics(&type_params); + + let mut params = Vec::new(); + let mut initializers = Vec::new(); + let field_names = fields + .iter() + .map(|field| field.name.as_str()) + .collect::>(); + for field in fields { + let field_ident = idents::ident(&field.name); + if field.name.starts_with("phantom_") { + initializers.push(quote! { #field_ident: std::marker::PhantomData }); + continue; + } + + let field_ty = render_type_ref(&field.ty, &dt.type_name, pkg, opts); + let param_ty = constructor_param_type(&field.ty, field_ty); + params.push(quote! { #field_ident: #param_ty }); + initializers.push(quote! { #field_ident: #field_ident.into() }); + } + for (idx, type_param) in dt.type_parameters.iter().enumerate() { + let field_name = format!("phantom_t{idx}"); + if type_param.is_phantom && !field_names.contains(&field_name.as_str()) { + let field_ident = format_ident!("phantom_t{idx}"); + initializers.push(quote! { #field_ident: std::marker::PhantomData }); + } + } + + Some(quote! { + impl #impl_generics #type_ident #type_generics + { + pub fn new(#(#params),*) -> Self { + Self { + #(#initializers),* + } + } + } + }) +} + +fn constructor_param_type(ty: &TypeRef, rendered: TokenStream) -> TokenStream { + match ty { + TypeRef::Datatype { .. } | TypeRef::TypeParameter(_) => quote! { impl Into<#rendered> }, + TypeRef::Address + | TypeRef::Bool + | TypeRef::U8 + | TypeRef::U16 + | TypeRef::U32 + | TypeRef::U64 + | TypeRef::U128 + | TypeRef::U256 + | TypeRef::Vector(_) + | TypeRef::Ref { .. } => rendered, + } +} + +fn has_specialized_new_helper( + dt: &Datatype, + pkg: &NormalizedPackage, + opts: &RenderOptions, +) -> bool { + is_type(&dt.type_name, "0x1", "type_name", "TypeName") + || ascii_name_field(dt, pkg, opts).is_some() + || is_type(&dt.type_name, "0x2", "object", "ID") + || is_type(&dt.type_name, "0x2", "object", "UID") + || is_type(&dt.type_name, "0x2", "table", "Table") + || is_type(&dt.type_name, "0x2", "linked_table", "LinkedTable") + || is_type(&dt.type_name, "0x2", "bag", "Bag") + || is_type(&dt.type_name, "0x2", "object_bag", "ObjectBag") + || is_type(&dt.type_name, "0x2", "table_vec", "TableVec") +} + +fn is_type(type_name: &TypeName, address: &str, module: &str, name: &str) -> bool { + normalize_address(&type_name.address) == normalize_address(address) + && type_name.module == module + && type_name.name == name +} + +fn normalize_address(address: &str) -> String { + let trimmed = address.trim_start_matches("0x"); + let trimmed = trimmed.trim_start_matches('0'); + if trimmed.is_empty() { + "0".to_string() + } else { + trimmed.to_ascii_lowercase() + } +} + +fn render_ascii_string_helpers(dt: &Datatype) -> TokenStream { + let type_ident = idents::ident(&dt.name); + quote! { + impl #type_ident { + pub fn as_str(&self) -> &str { + std::str::from_utf8(&self.bytes).expect("generated Move ASCII string must be UTF-8") + } + + pub fn into_string(self) -> std::string::String { + std::string::String::from_utf8(self.bytes) + .expect("generated Move ASCII string must be UTF-8") + } + } + + impl From<&str> for #type_ident { + fn from(value: &str) -> Self { + Self { + bytes: value.as_bytes().to_vec(), + } + } + } + + impl From for #type_ident { + fn from(value: std::string::String) -> Self { + Self { + bytes: value.into_bytes(), + } + } + } + + impl From<#type_ident> for std::string::String { + fn from(value: #type_ident) -> Self { + value.into_string() + } + } + + impl AsRef for #type_ident { + fn as_ref(&self) -> &str { + self.as_str() + } + } + + impl std::fmt::Display for #type_ident { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } + } + } +} + +fn render_type_name_helpers( + dt: &Datatype, + pkg: &NormalizedPackage, + opts: &RenderOptions, +) -> TokenStream { + let type_ident = idents::ident(&dt.name); + let Some((field_ident, field_ty)) = ascii_name_field(dt, pkg, opts) else { + return quote! {}; + }; + + quote! { + impl #type_ident { + pub fn new(name: &str) -> Self { + Self { + #field_ident: #field_ty::from(name), + } + } + + pub fn as_str(&self) -> &str { + self.#field_ident.as_str() + } + + fn normalize(name: &str) -> std::borrow::Cow<'_, str> { + let trimmed = name.trim_start_matches("0x"); + if trimmed.len() == name.len() { + std::borrow::Cow::Borrowed(name) + } else { + std::borrow::Cow::Owned(trimmed.to_string()) + } + } + + pub fn matches_qualified_name(&self, expected: &str) -> bool { + Self::normalize(self.as_str()).eq_ignore_ascii_case(&Self::normalize(expected)) + } + } + + impl From<&str> for #type_ident { + fn from(name: &str) -> Self { + #type_ident::new(name) + } + } + + impl From for #type_ident { + fn from(name: std::string::String) -> Self { + #type_ident { + #field_ident: #field_ty::from(name), + } + } + } + + impl std::fmt::Display for #type_ident { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.#field_ident.fmt(f) + } + } + } +} + +fn render_ascii_name_wrapper_helpers( + dt: &Datatype, + pkg: &NormalizedPackage, + opts: &RenderOptions, +) -> Option { + let type_ident = idents::ident(&dt.name); + let (field_ident, field_ty) = ascii_name_field(dt, pkg, opts)?; + + Some(quote! { + impl #type_ident { + pub fn new(name: impl Into<#field_ty>) -> Self { + Self { #field_ident: name.into() } + } + + pub fn as_str(&self) -> &str { + self.#field_ident.as_str() + } + } + + impl From<&str> for #type_ident { + fn from(value: &str) -> Self { + Self::new(value) + } + } + + impl From for #type_ident { + fn from(value: std::string::String) -> Self { + Self::new(value) + } + } + }) +} + +fn ascii_name_field( + dt: &Datatype, + pkg: &NormalizedPackage, + opts: &RenderOptions, +) -> Option<(syn::Ident, TokenStream)> { + let DatatypeKind::Struct { fields } = &dt.kind else { + return None; + }; + let [field] = fields.as_slice() else { + return None; + }; + if field.name != "name" || !is_ascii_string_ref(&field.ty) { + return None; + } + let field_ident = idents::ident(&field.name); + let field_ty = render_type_ref(&field.ty, &dt.type_name, pkg, opts); + Some((field_ident, field_ty)) +} + +fn is_ascii_string_ref(ty: &TypeRef) -> bool { + matches!( + ty, + TypeRef::Datatype { + type_name, + type_arguments + } if type_arguments.is_empty() && is_type(type_name, "0x1", "ascii", "String") + ) +} + +fn render_option_helpers(dt: &Datatype, _opts: &RenderOptions) -> TokenStream { + let type_ident = idents::ident(&dt.name); + let type_params = type_params_idents(dt.type_parameters.len()); + if type_params.len() != 1 { + return quote! {}; + } + let t0 = &type_params[0]; + let (impl_generics, type_generics) = impl_and_type_generics(&type_params); + + quote! { + impl #impl_generics #type_ident #type_generics + { + pub fn from_option(value: std::option::Option<#t0>) -> Self { + Self { + vec: value.into_iter().collect(), + } + } + + pub fn into_option(self) -> std::option::Option<#t0> { + self.vec.into_iter().next() + } + + pub fn as_option(&self) -> std::option::Option<&#t0> { + self.vec.first() + } + + pub fn copied_option(&self) -> std::option::Option<#t0> + where + #t0: Copy, + { + self.as_option().copied() + } + + pub fn cloned_option(&self) -> std::option::Option<#t0> + where + #t0: Clone, + { + self.as_option().cloned() + } + } + + impl #impl_generics Default for #type_ident #type_generics + { + fn default() -> Self { + Self::from_option(None) + } + } + + impl #impl_generics From> for #type_ident #type_generics + { + fn from(value: std::option::Option<#t0>) -> Self { + Self::from_option(value) + } + } + } +} + +fn render_object_id_helpers(dt: &Datatype) -> TokenStream { + let type_ident = idents::ident(&dt.name); + quote! { + impl #type_ident { + pub fn new(bytes: sui_move::prelude::Address) -> Self { + Self { bytes } + } + + pub fn address(&self) -> sui_move::prelude::Address { + self.bytes + } + } + + impl From for #type_ident { + fn from(value: sui_move::prelude::Address) -> Self { + Self::new(value) + } + } + + impl From<#type_ident> for sui_move::prelude::Address { + fn from(value: #type_ident) -> Self { + value.bytes + } + } + + impl std::fmt::Display for #type_ident { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.bytes.fmt(f) + } + } + } +} + +fn render_object_uid_helpers(dt: &Datatype) -> TokenStream { + let type_ident = idents::ident(&dt.name); + quote! { + impl #type_ident { + pub fn new(bytes: sui_move::prelude::Address) -> Self { + Self { + id: ID::new(bytes), + } + } + + pub fn address(&self) -> sui_move::prelude::Address { + self.id.bytes + } + } + + impl From for #type_ident { + fn from(value: sui_move::prelude::Address) -> Self { + Self::new(value) + } + } + + impl From<#type_ident> for sui_move::prelude::Address { + fn from(value: #type_ident) -> Self { + value.id.bytes + } + } + } +} + +fn render_table_like_helpers( + dt: &Datatype, + _opts: &RenderOptions, + constructor: TokenStream, +) -> TokenStream { + let type_ident = idents::ident(&dt.name); + let type_params = type_params_idents(dt.type_parameters.len()); + let (impl_generics, type_generics) = impl_and_type_generics(&type_params); + + quote! { + impl #impl_generics #type_ident #type_generics + { + pub fn new(id: sui_move::prelude::Address, size: u64) -> Self { + #constructor + } + + pub fn id(&self) -> sui_move::prelude::Address { + self.id.id.bytes + } + + pub fn size(&self) -> usize { + usize::try_from(self.size).unwrap_or(usize::MAX) + } + + pub fn size_u64(&self) -> u64 { + self.size + } + } + } +} + +fn render_id_size_helpers(dt: &Datatype, _opts: &RenderOptions) -> TokenStream { + let type_ident = idents::ident(&dt.name); + let type_params = type_params_idents(dt.type_parameters.len()); + let (impl_generics, type_generics) = impl_and_type_generics(&type_params); + + quote! { + impl #impl_generics #type_ident #type_generics + { + pub fn id(&self) -> sui_move::prelude::Address { + self.id.id.bytes + } + + pub fn size(&self) -> usize { + usize::try_from(self.size).unwrap_or(usize::MAX) + } + + pub fn size_u64(&self) -> u64 { + self.size + } + } + } +} + +fn render_table_vec_helpers(dt: &Datatype, _opts: &RenderOptions) -> TokenStream { + let type_ident = idents::ident(&dt.name); + let type_params = type_params_idents(dt.type_parameters.len()); + let (impl_generics, type_generics) = impl_and_type_generics(&type_params); + + quote! { + impl #impl_generics #type_ident #type_generics + { + pub fn new(id: sui_move::prelude::Address, size: u64) -> Self { + Self { + contents: super::table::Table::new(id, size), + phantom_t0: std::marker::PhantomData, + } + } + + pub fn id(&self) -> sui_move::prelude::Address { + self.contents.id() + } + + pub fn size(&self) -> usize { + self.contents.size() + } + + pub fn size_u64(&self) -> u64 { + self.contents.size_u64() + } + } + } +} + +fn render_vec_map_helpers(dt: &Datatype, _opts: &RenderOptions) -> TokenStream { + let type_ident = idents::ident(&dt.name); + let type_params = type_params_idents(dt.type_parameters.len()); + if type_params.len() != 2 { + return quote! {}; + } + let k = &type_params[0]; + let v = &type_params[1]; + let (impl_generics, type_generics) = impl_and_type_generics(&type_params); + + quote! { + impl #impl_generics #type_ident #type_generics + { + pub fn get(&self, key: &#k) -> std::option::Option<&#v> + where + #k: Eq, + { + self.contents + .iter() + .find(|entry| &entry.key == key) + .map(|entry| &entry.value) + } + + pub fn into_hash_map(self) -> std::collections::HashMap<#k, #v> + where + #k: Eq + std::hash::Hash, + { + self.contents + .into_iter() + .map(|entry| (entry.key, entry.value)) + .collect() + } + } + } +} + fn struct_tag_builder_tokens(dt: &Datatype, use_aliases: bool) -> TokenStream { let sm = if use_aliases { quote! { sm } @@ -264,7 +971,6 @@ fn struct_tag_builder_tokens(dt: &Datatype, use_aliases: bool) -> TokenStream { quote! { sui_move } }; - let address = syn::LitStr::new(&dt.type_name.address, proc_macro2::Span::call_site()); let module = syn::LitStr::new(&dt.type_name.module, proc_macro2::Span::call_site()); let name = syn::LitStr::new(&dt.type_name.name, proc_macro2::Span::call_site()); @@ -275,7 +981,7 @@ fn struct_tag_builder_tokens(dt: &Datatype, use_aliases: bool) -> TokenStream { quote! { #sm::__private::sui_sdk_types::StructTag::new( - #sm::parse_address(#address).expect("invalid address literal"), + type_package(), #sm::parse_identifier(#module).expect("invalid module"), #sm::parse_identifier(#name).expect("invalid struct name"), vec![#(#ty_params_for_tag),*], @@ -283,7 +989,7 @@ fn struct_tag_builder_tokens(dt: &Datatype, use_aliases: bool) -> TokenStream { } } -fn enum_derives(abilities: &[Ability], use_aliases: bool) -> TokenStream { +fn enum_derives(abilities: &[Ability], rust_copy: bool, use_aliases: bool) -> TokenStream { let sm = if use_aliases { quote! { sm } } else { @@ -291,13 +997,16 @@ fn enum_derives(abilities: &[Ability], use_aliases: bool) -> TokenStream { }; let has_copy = abilities.contains(&Ability::Copy); + let rust_copy_derive = rust_copy.then(|| quote! { ::core::marker::Copy, }); if has_copy { quote! { + #rust_copy_derive ::core::clone::Clone, ::core::fmt::Debug, ::core::cmp::PartialEq, ::core::cmp::Eq, + ::core::hash::Hash, #sm::__private::serde::Serialize, #sm::__private::serde::Deserialize, } @@ -306,6 +1015,7 @@ fn enum_derives(abilities: &[Ability], use_aliases: bool) -> TokenStream { ::core::fmt::Debug, ::core::cmp::PartialEq, ::core::cmp::Eq, + ::core::hash::Hash, #sm::__private::serde::Serialize, #sm::__private::serde::Deserialize, } @@ -539,11 +1249,12 @@ fn prelude_type(use_aliases: bool, name: TokenStream) -> TokenStream { } fn is_local_type(type_name: &TypeName, pkg: &NormalizedPackage) -> bool { - if type_name.address == pkg.storage_id { + let address = normalize_address(&type_name.address); + if address == normalize_address(&pkg.storage_id) { return true; } match &pkg.original_id { - Some(orig) => type_name.address == *orig, + Some(orig) => address == normalize_address(orig), None => false, } } diff --git a/sui-move-codegen/src/render/util.rs b/sui-move-codegen/src/render/util.rs index d7ece25..e98a2ea 100644 --- a/sui-move-codegen/src/render/util.rs +++ b/sui-move-codegen/src/render/util.rs @@ -8,45 +8,16 @@ use crate::ir::{NormalizedModule, NormalizedPackage}; use super::{calls, idents, tx_ext, types, RenderOptions}; pub(crate) fn render_package_tokens(pkg: &NormalizedPackage, opts: &RenderOptions) -> TokenStream { - let root_aliases = if opts.emit_tx_ext && !opts.flatten { - aliases(opts) - } else { - quote! {} - }; - - let package_const = package_const_tokens(pkg, opts); - let mut modules = Vec::new(); for module in pkg.modules.values() { modules.push(render_module(module, pkg, opts)); } - let tx_ext = if opts.emit_tx_ext { - tx_ext::render_tx_ext(pkg, opts) - } else { - quote! {} - }; - - let reexports = if opts.flatten || !opts.emit_types { - quote! {} - } else { - let mut reexp = Vec::new(); - for module in pkg.modules.values() { - let module_ident = idents::ident(&module.name); - for dt in &module.datatypes { - let ty_ident = idents::ident(&dt.name); - reexp.push(quote! { pub use #module_ident::#ty_ident; }); - } - } - quote! { #(#reexp)* } - }; + let root = render_package_root_tokens(pkg, opts, false); quote! { - #root_aliases - #package_const + #root #(#modules)* - #tx_ext - #reexports } } @@ -54,20 +25,34 @@ pub(crate) fn render_split_mod_rs_tokens( pkg: &NormalizedPackage, opts: &RenderOptions, ) -> TokenStream { - let root_aliases = if opts.emit_tx_ext { + render_package_root_tokens(pkg, opts, true) +} + +pub(crate) fn render_package_root_tokens( + pkg: &NormalizedPackage, + opts: &RenderOptions, + emit_module_decls: bool, +) -> TokenStream { + let root_aliases = if opts.emit_tx_ext && (emit_module_decls || !opts.flatten) { aliases(opts) } else { quote! {} }; let package_const = package_const_tokens(pkg, opts); + let package_scope = package_scope_tokens(opts); - let module_decls = pkg.modules.values().map(|module| { - let module_ident = idents::ident(&module.name); - quote! { pub mod #module_ident; } - }); + let module_decls = if emit_module_decls { + let module_decls = pkg.modules.values().map(|module| { + let module_ident = idents::ident(&module.name); + quote! { pub mod #module_ident; } + }); + quote! { #(#module_decls)* } + } else { + quote! {} + }; - let reexports = if !opts.emit_types { + let reexports = if !opts.emit_types || !opts.emit_reexports { quote! {} } else { let mut out = Vec::new(); @@ -90,7 +75,8 @@ pub(crate) fn render_split_mod_rs_tokens( quote! { #root_aliases #package_const - #(#module_decls)* + #package_scope + #module_decls #tx_ext #reexports } @@ -118,7 +104,7 @@ pub(crate) fn render_module_file( quote! { #aliases - use super::PACKAGE; + use super::{call_package, type_package}; #(#items)* } } @@ -150,7 +136,7 @@ pub(crate) fn render_module( quote! { pub mod #module_ident { #aliases - use super::PACKAGE; + use super::{call_package, type_package}; #(#items)* } } @@ -158,12 +144,102 @@ pub(crate) fn render_module( } fn package_const_tokens(pkg: &NormalizedPackage, opts: &RenderOptions) -> TokenStream { - let addr = pkg.storage_id.as_str(); + let call_addr = pkg.storage_id.as_str(); + let type_addr = pkg + .original_id + .as_deref() + .unwrap_or(pkg.storage_id.as_str()); let _ = opts; let address_ty = quote! { sui_move::prelude::Address }; quote! { - /// Package address (the on-chain package object id). - pub const PACKAGE: #address_ty = #address_ty::from_static(#addr); + /// Package address used as the target for generated Move calls. + pub const CALL_PACKAGE: #address_ty = #address_ty::from_static(#call_addr); + + /// Package address used for generated Move type identity. + pub const TYPE_PACKAGE: #address_ty = #address_ty::from_static(#type_addr); + } +} + +fn package_scope_tokens(opts: &RenderOptions) -> TokenStream { + let _ = opts; + let address_ty = quote! { sui_move::prelude::Address }; + quote! { + std::thread_local! { + static CALL_PACKAGE_OVERRIDE: std::cell::Cell> = + std::cell::Cell::new(None); + static TYPE_PACKAGE_OVERRIDE: std::cell::Cell> = + std::cell::Cell::new(None); + } + + /// Current call package address for this generated binding. + /// + /// Returns the scoped override set by [`with_call_package`] or [`with_packages`], or + /// [`CALL_PACKAGE`] when no override is active. + pub fn call_package() -> #address_ty { + CALL_PACKAGE_OVERRIDE.with(|slot| slot.get().unwrap_or(CALL_PACKAGE)) + } + + /// Current type package address for this generated binding. + /// + /// Returns the scoped override set by [`with_type_package`] or [`with_packages`], or + /// [`TYPE_PACKAGE`] when no override is active. + pub fn type_package() -> #address_ty { + TYPE_PACKAGE_OVERRIDE.with(|slot| slot.get().unwrap_or(TYPE_PACKAGE)) + } + + /// Run a closure with this generated binding scoped to `package` for Move calls. + /// + /// The previous call package override is restored when the closure returns or unwinds. + pub fn with_call_package(package: #address_ty, f: impl FnOnce() -> R) -> R { + struct Reset(Option<#address_ty>); + + impl Drop for Reset { + fn drop(&mut self) { + CALL_PACKAGE_OVERRIDE.with(|slot| slot.set(self.0)); + } + } + + let previous = CALL_PACKAGE_OVERRIDE.with(|slot| { + let previous = slot.get(); + slot.set(Some(package)); + previous + }); + let _reset = Reset(previous); + f() + } + + /// Run a closure with this generated binding scoped to `package` for Move type identity. + /// + /// The previous type package override is restored when the closure returns or unwinds. + pub fn with_type_package(package: #address_ty, f: impl FnOnce() -> R) -> R { + struct Reset(Option<#address_ty>); + + impl Drop for Reset { + fn drop(&mut self) { + TYPE_PACKAGE_OVERRIDE.with(|slot| slot.set(self.0)); + } + } + + let previous = TYPE_PACKAGE_OVERRIDE.with(|slot| { + let previous = slot.get(); + slot.set(Some(package)); + previous + }); + let _reset = Reset(previous); + f() + } + + /// Run a closure with explicit call and type package scopes. + /// + /// Use this for upgraded packages where calls target the current package object but type + /// tags must retain the original defining package address. + pub fn with_packages( + call_package: #address_ty, + type_package: #address_ty, + f: impl FnOnce() -> R, + ) -> R { + with_call_package(call_package, || with_type_package(type_package, f)) + } } } diff --git a/sui-move-codegen/src/source.rs b/sui-move-codegen/src/source.rs index ec9f23c..a58ade5 100644 --- a/sui-move-codegen/src/source.rs +++ b/sui-move-codegen/src/source.rs @@ -29,7 +29,7 @@ use crate::{Error, GrpcClient}; /// /// let pkg = fetch_package(&mut client, package_id).await?; /// let json = pkg.to_json_string()?; -/// println!("{json}"); +/// println!("{} bytes", json.len()); /// # Ok(()) /// # } /// ``` diff --git a/sui-move-codegen/src/source_names.rs b/sui-move-codegen/src/source_names.rs new file mode 100644 index 0000000..cd0b39a --- /dev/null +++ b/sui-move-codegen/src/source_names.rs @@ -0,0 +1,414 @@ +//! Recover Move function parameter names from source files. + +use { + crate::ir::{FunctionParameterNameMismatch, NormalizedPackage}, + std::{ + collections::BTreeMap, + io, + path::{Path, PathBuf}, + }, +}; + +/// Errors from recovering function parameter names from Move sources. +#[derive(Debug, thiserror::Error)] +pub enum SourceNameError { + /// Reading the source directory failed. + #[error("read Move source directory {}: {source}", path.display())] + ReadDir { + /// Directory that was read. + path: PathBuf, + /// Underlying filesystem error. + #[source] + source: io::Error, + }, + + /// Reading an entry from the source directory failed. + #[error("read entry under {}: {source}", path.display())] + ReadDirEntry { + /// Directory being iterated. + path: PathBuf, + /// Underlying filesystem error. + #[source] + source: io::Error, + }, + + /// Reading a Move source file failed. + #[error("read Move source {}: {source}", path.display())] + ReadSource { + /// Source file that was read. + path: PathBuf, + /// Underlying filesystem error. + #[source] + source: io::Error, + }, + + /// A source file did not contain a supported `module package::name;` header. + #[error("Move source {} does not declare a `module package::name;` header", path.display())] + MissingModuleHeader { + /// Source file that was parsed. + path: PathBuf, + }, + + /// Source names could not be applied because source and IR arities differ. + #[error("{0}")] + FunctionParameterNameMismatch(#[from] FunctionParameterNameMismatch), +} + +/// Apply recovered source parameter names to synthesized `argN` names in a normalized package. +/// +/// `source_dir` is the package `sources/` directory. Missing directories are treated as empty so +/// callers can invoke this uniformly for packages that may not have local sources. +pub fn apply_function_parameter_names_from_sources( + package: &mut NormalizedPackage, + source_dir: impl AsRef, +) -> Result<(), SourceNameError> { + let names = function_parameter_names_from_sources(source_dir)?; + if names.is_empty() { + return Ok(()); + } + + package.apply_function_parameter_names(&names)?; + Ok(()) +} + +/// Recover function parameter names from all `.move` files under a package `sources/` directory. +/// +/// The returned map is keyed by `(module_name, function_name)`. +pub fn function_parameter_names_from_sources( + source_dir: impl AsRef, +) -> Result>, SourceNameError> { + let source_dir = source_dir.as_ref(); + if !source_dir.is_dir() { + return Ok(BTreeMap::new()); + } + + let mut names = BTreeMap::new(); + let entries = std::fs::read_dir(source_dir).map_err(|source| SourceNameError::ReadDir { + path: source_dir.to_path_buf(), + source, + })?; + + for entry in entries { + let entry = entry.map_err(|source| SourceNameError::ReadDirEntry { + path: source_dir.to_path_buf(), + source, + })?; + let path = entry.path(); + if path.extension().and_then(|ext| ext.to_str()) != Some("move") { + continue; + } + + let source = + std::fs::read_to_string(&path).map_err(|source| SourceNameError::ReadSource { + path: path.clone(), + source, + })?; + let source = strip_move_comments(&source); + let module_name = parse_move_module_name(&source) + .ok_or_else(|| SourceNameError::MissingModuleHeader { path: path.clone() })?; + + for (function_name, parameter_names) in parse_move_function_parameter_names(&source) { + names.insert((module_name.clone(), function_name), parameter_names); + } + } + + Ok(names) +} + +fn strip_move_comments(source: &str) -> String { + let mut stripped = String::with_capacity(source.len()); + let mut chars = source.chars().peekable(); + let mut in_block_comment = false; + + while let Some(ch) = chars.next() { + if in_block_comment { + if ch == '*' && chars.peek() == Some(&'/') { + chars.next(); + in_block_comment = false; + } + continue; + } + + if ch == '/' && chars.peek() == Some(&'/') { + for next in chars.by_ref() { + if next == '\n' { + stripped.push('\n'); + break; + } + } + continue; + } + if ch == '/' && chars.peek() == Some(&'*') { + chars.next(); + in_block_comment = true; + continue; + } + + stripped.push(ch); + } + + stripped +} + +fn parse_move_module_name(source: &str) -> Option { + let marker = "module "; + let start = source.find(marker)? + marker.len(); + let rest = &source[start..]; + let module_start = rest.find("::")? + 2; + let module = rest[module_start..] + .chars() + .take_while(|ch| ch.is_ascii_alphanumeric() || *ch == '_') + .collect::(); + if module.is_empty() { + None + } else { + Some(module) + } +} + +fn parse_move_function_parameter_names(source: &str) -> Vec<(String, Vec)> { + let mut functions = Vec::new(); + let bytes = source.as_bytes(); + let mut offset = 0; + + while let Some(relative_fun) = source[offset..].find("fun") { + let fun_start = offset + relative_fun; + if !is_keyword_at(bytes, fun_start, b"fun") { + offset = fun_start + 3; + continue; + } + + let mut cursor = fun_start + 3; + cursor = skip_ascii_whitespace(source, cursor); + let name_start = cursor; + while cursor < source.len() && is_identifier_byte(source.as_bytes()[cursor]) { + cursor += 1; + } + if cursor == name_start { + offset = fun_start + 3; + continue; + } + let function_name = source[name_start..cursor].to_string(); + cursor = skip_ascii_whitespace(source, cursor); + if source[cursor..].starts_with('<') { + let Some(after_type_parameters) = skip_balanced(source, cursor, '<', '>') else { + offset = cursor; + continue; + }; + cursor = skip_ascii_whitespace(source, after_type_parameters); + } + if !source[cursor..].starts_with('(') { + offset = cursor; + continue; + } + let Some(params_end) = skip_balanced(source, cursor, '(', ')') else { + offset = cursor + 1; + continue; + }; + let params = &source[cursor + 1..params_end - 1]; + functions.push((function_name, parse_move_parameter_names(params))); + offset = params_end; + } + + functions +} + +fn parse_move_parameter_names(params: &str) -> Vec { + split_top_level_commas(params) + .into_iter() + .filter_map(|parameter| { + let parameter = parameter.trim(); + if parameter.is_empty() { + return None; + } + let colon = parameter.find(':')?; + let name = parameter[..colon].trim(); + let name = name.strip_prefix("mut ").unwrap_or(name).trim(); + if name.is_empty() { + None + } else { + Some(name.to_string()) + } + }) + .collect() +} + +fn split_top_level_commas(input: &str) -> Vec<&str> { + let mut segments = Vec::new(); + let mut start = 0; + let mut angle_depth = 0u64; + let mut paren_depth = 0u64; + + for (index, ch) in input.char_indices() { + match ch { + '<' => angle_depth += 1, + '>' if angle_depth > 0 => angle_depth -= 1, + '(' => paren_depth += 1, + ')' if paren_depth > 0 => paren_depth -= 1, + ',' if angle_depth == 0 && paren_depth == 0 => { + segments.push(&input[start..index]); + start = index + ch.len_utf8(); + } + _ => {} + } + } + segments.push(&input[start..]); + segments +} + +fn skip_ascii_whitespace(source: &str, mut cursor: usize) -> usize { + while cursor < source.len() && source.as_bytes()[cursor].is_ascii_whitespace() { + cursor += 1; + } + cursor +} + +fn skip_balanced(source: &str, start: usize, open: char, close: char) -> Option { + let mut depth = 0u64; + for (relative, ch) in source[start..].char_indices() { + if ch == open { + depth += 1; + } else if ch == close { + depth = depth.checked_sub(1)?; + if depth == 0 { + return Some(start + relative + ch.len_utf8()); + } + } + } + None +} + +fn is_keyword_at(bytes: &[u8], index: usize, keyword: &[u8]) -> bool { + bytes[index..].starts_with(keyword) + && index + .checked_sub(1) + .map(|prev| !is_identifier_byte(bytes[prev])) + .unwrap_or(true) + && bytes + .get(index + keyword.len()) + .map(|next| !is_identifier_byte(*next)) + .unwrap_or(true) +} + +fn is_identifier_byte(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || byte == b'_' +} + +#[cfg(test)] +mod tests { + use { + super::*, + crate::ir::{Function, FunctionParam, NormalizedModule, TypeRef, Visibility}, + std::time::{SystemTime, UNIX_EPOCH}, + }; + + #[test] + fn parses_move_source_parameter_names() { + let source = strip_move_comments( + r#" +module nexus_workflow::execution_settlement; + +// fun ignored(arg0: u64) {} + public fun record_committed_tool_result_gas_charge_by_leader( + execution: &mut DAGExecution, + leader_cap: &CloneableOwnerCap, + mut walk_index: u64, + commit_tx_digest: vector, + commit_gas_charge: u64, + settlement_gas_charge: u64, +) {} +"#, + ); + + assert_eq!( + parse_move_module_name(&source).as_deref(), + Some("execution_settlement") + ); + let functions = parse_move_function_parameter_names(&source); + assert_eq!(functions.len(), 1); + assert_eq!( + functions[0].0, + "record_committed_tool_result_gas_charge_by_leader" + ); + assert_eq!( + functions[0].1, + vec![ + "execution", + "leader_cap", + "walk_index", + "commit_tx_digest", + "commit_gas_charge", + "settlement_gas_charge" + ] + ); + } + + #[test] + fn applies_source_parameter_names_to_package() { + let temp = std::env::temp_dir().join(format!( + "sui-move-codegen-source-names-{}", + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock after epoch") + .as_nanos() + )); + let source_dir = temp.join("sources"); + std::fs::create_dir_all(&source_dir).expect("create source dir"); + std::fs::write( + source_dir.join("execution_settlement.move"), + r#" +module nexus_workflow::execution_settlement; + +public fun record_committed_tool_result_gas_charge_by_leader( + execution: &mut DAGExecution, + leader_cap: &CloneableOwnerCap, + walk_index: u64, +) {} +"#, + ) + .expect("write source"); + + let mut package = NormalizedPackage { + storage_id: "0xa4".to_string(), + original_id: None, + version: 1, + modules: BTreeMap::from([( + "execution_settlement".to_string(), + NormalizedModule { + name: "execution_settlement".to_string(), + datatypes: vec![], + functions: vec![Function { + name: "record_committed_tool_result_gas_charge_by_leader".to_string(), + visibility: Visibility::Public, + is_entry: false, + type_parameters: vec![], + parameters: vec![ + FunctionParam { + name: "arg0".to_string(), + ty: TypeRef::U64, + }, + FunctionParam { + name: "arg1".to_string(), + ty: TypeRef::U64, + }, + FunctionParam { + name: "arg2".to_string(), + ty: TypeRef::U64, + }, + ], + return_types: vec![], + }], + }, + )]), + }; + + apply_function_parameter_names_from_sources(&mut package, &source_dir) + .expect("apply names"); + + let parameters = &package.modules["execution_settlement"].functions[0].parameters; + assert_eq!(parameters[0].name, "execution"); + assert_eq!(parameters[1].name, "leader_cap"); + assert_eq!(parameters[2].name, "walk_index"); + std::fs::remove_dir_all(temp).expect("remove temp dir"); + } +} diff --git a/sui-move-derive/README.md b/sui-move-derive/README.md index 74555ab..1cb5116 100644 --- a/sui-move-derive/README.md +++ b/sui-move-derive/README.md @@ -80,6 +80,8 @@ Required arguments: Optional arguments: +- `address_fn = "path::to::fn"`: Function returning the Move address to use for `StructTag`s. + `address` remains the default/documented address. - `name = "..."`: Override the Move struct name (defaults to the Rust struct name) - `abilities = "key, store, copy, drop"`: Move abilities (comma-separated) - `copy` implies `drop` diff --git a/sui-move-derive/src/args.rs b/sui-move-derive/src/args.rs index f620f20..a30e151 100644 --- a/sui-move-derive/src/args.rs +++ b/sui-move-derive/src/args.rs @@ -15,6 +15,7 @@ use syn::Lit; #[derive(Default)] pub(crate) struct MoveStructArgs { pub(crate) address: Option, + pub(crate) address_fn: Option, pub(crate) module: Option, pub(crate) name: Option, pub(crate) abilities: Vec, @@ -43,6 +44,22 @@ impl Parse for MoveStructArgs { return Err(syn::Error::new(lit.span(), "address must be a string")); } } + "address_fn" => { + if let Lit::Str(ref s) = lit { + let path: syn::Path = syn::parse_str(&s.value()).map_err(|_| { + syn::Error::new( + s.span(), + "address_fn must be a valid Rust function path", + ) + })?; + args.address_fn = Some(path); + } else { + return Err(syn::Error::new( + lit.span(), + "address_fn must be a string literal path", + )); + } + } "module" => { if let Lit::Str(s) = lit { args.module = Some(s.value()); diff --git a/sui-move-derive/src/expand.rs b/sui-move-derive/src/expand.rs index ebe9858..797305e 100644 --- a/sui-move-derive/src/expand.rs +++ b/sui-move-derive/src/expand.rs @@ -16,7 +16,10 @@ use quote::{format_ident, quote}; use syn::parse::Parser; use syn::parse_quote; use syn::spanned::Spanned; -use syn::{Data, DeriveInput, Field, FieldMutability, Fields, GenericParam, TypeParam}; +use syn::{ + Data, DeriveInput, Field, FieldMutability, Fields, GenericParam, TypeParam, WhereClause, + WherePredicate, +}; use crate::abilities::{parse_inline_abilities, AbilityFlags}; use crate::args::MoveStructArgs; @@ -137,7 +140,7 @@ pub(crate) fn expand_move_struct( } } - let mut where_bounds: Vec = Vec::new(); + let mut type_param_bounds: Vec = Vec::new(); for param in &type_param_idents { let ident = ¶m.ident; let mut bounds: Vec = vec![parse_quote!(::sui_move::MoveType)]; @@ -157,7 +160,7 @@ pub(crate) fn expand_move_struct( } } - where_bounds.push(parse_quote!(#ident: #(#bounds)+*)); + type_param_bounds.push(parse_quote!(#ident: #(#bounds)+*)); } if has_key @@ -171,19 +174,24 @@ pub(crate) fn expand_move_struct( )); } + let mut store_bounds = type_param_bounds.clone(); + let mut copy_bounds = type_param_bounds.clone(); + let mut drop_bounds = type_param_bounds.clone(); + let key_bounds = type_param_bounds.clone(); + for field in &original_fields { if is_phantom_field_type(&field.ty) { continue; } let ty = &field.ty; if has_copy { - where_bounds.push(parse_quote!(#ty: ::sui_move::HasCopy)); + copy_bounds.push(parse_quote!(#ty: ::sui_move::HasCopy)); } if has_drop { - where_bounds.push(parse_quote!(#ty: ::sui_move::HasDrop)); + drop_bounds.push(parse_quote!(#ty: ::sui_move::HasDrop)); } if has_store { - where_bounds.push(parse_quote!(#ty: ::sui_move::HasStore)); + store_bounds.push(parse_quote!(#ty: ::sui_move::HasStore)); } } @@ -203,26 +211,29 @@ pub(crate) fn expand_move_struct( }) .collect::>(); + let address_expr = if let Some(address_fn) = &args.address_fn { + quote! { #address_fn() } + } else { + quote! { ::sui_move::parse_address(#address).expect("invalid address literal") } + }; + let struct_tag_builder = quote! { ::sui_move::__private::sui_sdk_types::StructTag::new( - ::sui_move::parse_address(#address).expect("invalid address literal"), + #address_expr, ::sui_move::parse_identifier(#module_name).expect("invalid module"), ::sui_move::parse_identifier(#struct_name).expect("invalid struct name"), vec![#(#ty_params_for_tag),*], ) }; - let mut derives: Vec = Vec::new(); - if has_copy { - derives.push(parse_quote!(::core::clone::Clone)); - } - derives.extend([ + let derives: Vec = vec![ parse_quote!(::core::fmt::Debug), parse_quote!(::core::cmp::PartialEq), parse_quote!(::core::cmp::Eq), + parse_quote!(::core::hash::Hash), parse_quote!(::sui_move::__private::serde::Serialize), parse_quote!(::sui_move::__private::serde::Deserialize), - ]); + ]; let mut output_struct = input; output_struct.ident = struct_ident.clone(); @@ -285,52 +296,71 @@ pub(crate) fn expand_move_struct( } output_struct.attrs.extend(serde_attrs); - let (impl_generics, ty_generics, where_clause) = generics.split_for_impl(); - let where_clause = { - let mut where_clause = where_clause.cloned(); - if !where_bounds.is_empty() { - if let Some(ref mut w) = where_clause { - w.predicates.extend(where_bounds.iter().cloned()); - } else { - let preds = where_bounds.iter().cloned(); - where_clause = Some(syn::WhereClause { - where_token: Default::default(), - predicates: preds.collect(), - }); - } - } - where_clause - }; + let mut clone_bounds = type_param_bounds.clone(); + clone_bounds.extend( + original_fields + .iter() + .filter(|field| !is_phantom_field_type(&field.ty)) + .map(|field| { + let ty = &field.ty; + parse_quote!(#ty: ::core::clone::Clone) + }) + .collect::>(), + ); + let clone_where_clause = where_clause_with(&generics, &clone_bounds); + + let (impl_generics, ty_generics, _) = generics.split_for_impl(); + let move_type_where_clause = where_clause_with(&generics, &type_param_bounds); + let key_where_clause = where_clause_with(&generics, &key_bounds); + let store_where_clause = where_clause_with(&generics, &store_bounds); + let copy_where_clause = where_clause_with(&generics, ©_bounds); + let drop_where_clause = where_clause_with(&generics, &drop_bounds); let ability_impls = { let mut impls = Vec::new(); if has_key { impls.push(quote! { - impl #impl_generics ::sui_move::HasKey for #struct_ident #ty_generics #where_clause {} + impl #impl_generics ::sui_move::HasKey for #struct_ident #ty_generics #key_where_clause {} }); } if has_store { impls.push(quote! { - impl #impl_generics ::sui_move::HasStore for #struct_ident #ty_generics #where_clause {} + impl #impl_generics ::sui_move::HasStore for #struct_ident #ty_generics #store_where_clause {} }); } if has_copy { impls.push(quote! { - impl #impl_generics ::sui_move::HasCopy for #struct_ident #ty_generics #where_clause {} + impl #impl_generics ::sui_move::HasCopy for #struct_ident #ty_generics #copy_where_clause {} }); } if has_drop { impls.push(quote! { - impl #impl_generics ::sui_move::HasDrop for #struct_ident #ty_generics #where_clause {} + impl #impl_generics ::sui_move::HasDrop for #struct_ident #ty_generics #drop_where_clause {} }); } quote! { #(#impls)* } }; + let cloned_fields = fields.iter().map(|field| { + let ident = field + .ident + .as_ref() + .expect("move_struct supports only named fields"); + quote! { #ident: ::core::clone::Clone::clone(&self.#ident), } + }); + Ok(quote! { #output_struct - impl #impl_generics ::sui_move::MoveType for #struct_ident #ty_generics #where_clause { + impl #impl_generics ::core::clone::Clone for #struct_ident #ty_generics #clone_where_clause { + fn clone(&self) -> Self { + Self { + #(#cloned_fields)* + } + } + } + + impl #impl_generics ::sui_move::MoveType for #struct_ident #ty_generics #move_type_where_clause { fn type_tag_static() -> ::sui_move::__private::sui_sdk_types::TypeTag { ::sui_move::__private::sui_sdk_types::TypeTag::Struct(Box::new( ::struct_tag_static(), @@ -338,7 +368,7 @@ pub(crate) fn expand_move_struct( } } - impl #impl_generics ::sui_move::MoveStruct for #struct_ident #ty_generics #where_clause { + impl #impl_generics ::sui_move::MoveStruct for #struct_ident #ty_generics #move_type_where_clause { fn struct_tag_static() -> ::sui_move::__private::sui_sdk_types::StructTag { #struct_tag_builder } @@ -347,3 +377,20 @@ pub(crate) fn expand_move_struct( #ability_impls }) } + +fn where_clause_with(generics: &syn::Generics, bounds: &[WherePredicate]) -> Option { + let mut where_clause = generics.where_clause.clone(); + if bounds.is_empty() { + return where_clause; + } + + if let Some(ref mut existing) = where_clause { + existing.predicates.extend(bounds.iter().cloned()); + return where_clause; + } + + Some(WhereClause { + where_token: Default::default(), + predicates: bounds.iter().cloned().collect(), + }) +} diff --git a/sui-move-derive/src/lib.rs b/sui-move-derive/src/lib.rs index dfeef83..e8d4879 100644 --- a/sui-move-derive/src/lib.rs +++ b/sui-move-derive/src/lib.rs @@ -47,6 +47,8 @@ pub fn move_module(_args: TokenStream, input: TokenStream) -> TokenStream { /// /// # Arguments /// - `address = "0x..."` (required): Move address +/// - `address_fn = "path::to::fn"` (optional): Rust function returning the Move address to use +/// when building `StructTag`s; `address` remains the default/documented address /// - `module = "..."` (required): Move module name /// - `name = "..."` (optional): override Move struct name (defaults to Rust name) /// - `abilities = "key, store, copy, drop"` (optional): comma-separated Move abilities @@ -121,12 +123,19 @@ mod tests { fn parse_args() { let args: MoveStructArgs = syn::parse_quote!( address = "0x1", + address_fn = "crate::package", module = "vault", abilities = "key, store", phantoms = "T", type_abilities = "T: store, copy" ); assert_eq!(args.address.as_deref(), Some("0x1")); + assert_eq!( + args.address_fn + .as_ref() + .map(|path| quote::ToTokens::to_token_stream(path).to_string()), + Some("crate :: package".to_string()) + ); assert_eq!(args.module.as_deref(), Some("vault")); assert_eq!(args.name, None); assert_eq!(args.abilities, vec!["key".to_string(), "store".to_string()]); diff --git a/sui-move-ptb/Cargo.toml b/sui-move-ptb/Cargo.toml index 399766f..d94e4ca 100644 --- a/sui-move-ptb/Cargo.toml +++ b/sui-move-ptb/Cargo.toml @@ -9,7 +9,8 @@ repository.workspace = true homepage.workspace = true [dependencies] -sui-move-call = { version = "0.1.0", path = "../sui-move-call" } +sui-move = { path = "../sui-move" } +sui-move-call = { path = "../sui-move-call" } sui-sdk-types = { workspace = true } thiserror = { workspace = true } diff --git a/sui-move-ptb/src/lib.rs b/sui-move-ptb/src/lib.rs index 8a1ccc6..3f18631 100644 --- a/sui-move-ptb/src/lib.rs +++ b/sui-move-ptb/src/lib.rs @@ -1,12 +1,18 @@ #![doc = include_str!("../README.md")] #![deny(missing_docs)] -use sui_move_call::{CallArg, CallArgError, CallSpec, ToCallArg}; +use std::borrow::Borrow; + +use sui_move_call::{CallArg, CallArgError, CallSpec, CallSpecError, CallTarget, ToCallArg}; use sui_sdk_types::{ - Address, Argument, Command, MakeMoveVector, MergeCoins, MoveCall, Mutability, - ProgrammableTransaction, Publish, SharedInput, SplitCoins, TransferObjects, TypeTag, + Address, Argument, Command, FundsWithdrawal, MakeMoveVector, MergeCoins, MoveCall, Mutability, + ObjectReference, Owner, ProgrammableTransaction, Publish, SharedInput, SplitCoins, + TransferObjects, TypeTag, WithdrawFrom, }; +/// Fixed shared object ID for `0x2::clock::Clock`. +pub const CLOCK_OBJECT_ID: Address = Address::from_static("0x6"); + /// Errors that can occur while building a `ProgrammableTransaction`. #[derive(thiserror::Error, Debug)] pub enum BuildError { @@ -14,6 +20,10 @@ pub enum BuildError { #[error(transparent)] CallArg(#[from] CallArgError), + /// A generated call spec could not be constructed. + #[error(transparent)] + CallSpec(#[from] CallSpecError), + /// Too many inputs were added (PTB uses `u16` indices). #[error("too many PTB inputs (u16 overflow)")] TooManyInputs, @@ -30,6 +40,22 @@ pub enum BuildError { /// Duplicated object id. object_id: Address, }, + + /// Ownership metadata cannot be represented as a normal transaction input. + #[error("object {object_id} has unsupported owner for transaction input: {owner:?}")] + UnsupportedOwner { + /// Object id whose owner could not be represented. + object_id: Address, + /// Unsupported owner metadata returned by Sui. + owner: Owner, + }, + + /// A nested result was requested from an argument that is not a command result. + #[error("expected command result argument, got {argument:?}")] + ExpectedCommandResult { + /// Argument that was expected to be `Argument::Result`. + argument: Argument, + }, } /// Mutable builder for `ProgrammableTransaction`. @@ -183,6 +209,93 @@ impl PtbBuilder { self.input(value.to_call_arg()?) } + /// Add pre-encoded BCS bytes as a pure PTB input. + /// + /// Prefer [`Self::arg`] when the Rust type is available. Use this for dynamic ABI edges where + /// the exact Move type is discovered at runtime and the value has already been BCS encoded. + pub fn pure_bcs(&mut self, bytes: Vec) -> Result { + self.input(CallArg::Pure(bytes)) + } + + /// Add an owned or immutable object reference as a PTB input. + pub fn owned_object(&mut self, object: &ObjectReference) -> Result { + self.input(CallArg::ImmutableOrOwned(object.clone())) + } + + /// Add a shared object reference as a PTB input. + /// + /// The object's version is used as the initial shared version. If the same shared object is + /// added later with stronger mutability, the existing input is upgraded in place. + pub fn shared_object>( + &mut self, + object: &ObjectReference, + mutability: M, + ) -> Result { + self.shared_object_by_id(*object.object_id(), object.version(), mutability) + } + + /// Add a shared object input from its ID and initial shared version. + pub fn shared_object_by_id>( + &mut self, + object_id: Address, + initial_shared_version: u64, + mutability: M, + ) -> Result { + self.input(CallArg::Shared(SharedInput::new( + object_id, + initial_shared_version, + mutability, + ))) + } + + /// Add an object input using ownership metadata returned by Sui RPC. + /// + /// Shared-like objects become `Input::Shared`; immutable/address-owned objects can be used + /// only when immutable access was requested. + pub fn object_from_owner( + &mut self, + object: &ObjectReference, + owner: O, + mutability: M, + ) -> Result + where + O: Borrow, + M: Into, + { + let owner = *owner.borrow(); + let mutability = mutability.into(); + let object_id = *object.object_id(); + match owner { + Owner::Shared(initial_shared_version) => { + self.shared_object_by_id(object_id, initial_shared_version, mutability) + } + Owner::ConsensusAddress { start_version, .. } => { + self.shared_object_by_id(object_id, start_version, mutability) + } + Owner::Immutable if mutability == Mutability::Immutable => self.owned_object(object), + Owner::Address(_) if mutability == Mutability::Immutable => self.owned_object(object), + owner => Err(BuildError::UnsupportedOwner { object_id, owner }), + } + } + + /// Add a coin withdrawn from the transaction sender's SIP-58 address balance. + pub fn funds_withdrawal_coin( + &mut self, + coin_type: TypeTag, + amount: u64, + ) -> Result { + self.input(CallArg::FundsWithdrawal(FundsWithdrawal::new( + amount, + coin_type, + WithdrawFrom::Sender, + ))) + } + + /// Add the Sui clock object as an immutable shared PTB input. + pub fn clock(&mut self) -> Result { + self.shared_object_by_id(CLOCK_OBJECT_ID, 1, Mutability::Immutable) + } + /// Add a Move call command from a typed `CallSpec`. /// /// This consumes the spec, allocates all of its inputs into the PTB input table (reusing @@ -209,6 +322,27 @@ impl PtbBuilder { Ok(Argument::Result(cmd_idx)) } + /// Add a Move call command from a generated call target and explicit PTB arguments. + /// + /// Use this for composed PTBs where some arguments are previous command results instead of + /// fresh transaction inputs. Generated bindings should still provide the [`CallTarget`], so + /// package/module/function/type-argument identity does not fall back to hand-written strings. + pub fn call_target( + &mut self, + target: CallTarget, + arguments: Vec, + ) -> Result { + let cmd_idx = self.push_command(Command::MoveCall(MoveCall { + package: target.package, + module: target.module, + function: target.function, + type_arguments: target.type_arguments, + arguments, + }))?; + + Ok(Argument::Result(cmd_idx)) + } + /// Add a `TransferObjects` command. /// /// This is a thin wrapper around `sui_sdk_types::Command::TransferObjects`. @@ -264,6 +398,14 @@ impl PtbBuilder { Ok(Argument::Result(cmd_idx)) } + /// Build a typed Move `vector` from already-built PTB arguments. + pub fn move_vector( + &mut self, + elements: Vec, + ) -> Result { + self.make_move_vector(Some(T::type_tag_static()), elements) + } + /// Add a `Publish` command. /// /// This is a thin wrapper around `sui_sdk_types::Command::Publish`. @@ -271,12 +413,17 @@ impl PtbBuilder { &mut self, modules: Vec>, dependencies: Vec
, - ) -> Result<(), BuildError> { - self.push_command(Command::Publish(Publish { + ) -> Result { + let cmd_idx = self.push_command(Command::Publish(Publish { modules, dependencies, }))?; - Ok(()) + Ok(Argument::Result(cmd_idx)) + } + + /// Return a nested result argument for a multi-return command. + pub fn nested_result(&self, argument: Argument, ix: u16) -> Result { + nested_result(argument, ix) } /// Finish and return the underlying `ProgrammableTransaction`. @@ -317,6 +464,16 @@ pub fn ptb( Ok(builder.finish()) } +/// Return a nested result argument for a multi-return command. +/// +/// This is a checked wrapper around [`Argument::nested`] so transaction builders can propagate a +/// normal [`BuildError`] instead of panicking or unwrapping. +pub fn nested_result(argument: Argument, ix: u16) -> Result { + argument + .nested(ix) + .ok_or(BuildError::ExpectedCommandResult { argument }) +} + /// Build a `ProgrammableTransaction` using a scoped `PtbBuilder`. /// /// This is a thin macro wrapper around [`ptb`]. It exists purely for call-site ergonomics. @@ -365,7 +522,7 @@ macro_rules! ptb { /// Convenience re-exports for downstream code. pub mod prelude { - pub use crate::{ptb, BuildError, PtbBuilder}; + pub use crate::{ptb, BuildError, PtbBuilder, CLOCK_OBJECT_ID}; pub use sui_move_call::prelude::*; pub use sui_sdk_types::{Argument, Command, ProgrammableTransaction}; } diff --git a/sui-move-ptb/tests/basic.rs b/sui-move-ptb/tests/basic.rs index e6b4e6f..073a1e4 100644 --- a/sui-move-ptb/tests/basic.rs +++ b/sui-move-ptb/tests/basic.rs @@ -1,10 +1,12 @@ use std::str::FromStr; -use sui_move_call::{CallArg, CallSpec, MoveObject, ReceivingMoveObject, SharedMoveObject}; +use sui_move_call::{ + CallArg, CallSpec, CallTarget, MoveObject, ReceivingMoveObject, SharedMoveObject, +}; use sui_move_ptb::{ptb, BuildError, PtbBuilder}; use sui_sdk_types::{ - Address, Argument, Command, Digest, FundsWithdrawal, Mutability, ObjectReference, TypeTag, - WithdrawFrom, + Address, Argument, Command, Digest, FundsWithdrawal, Mutability, ObjectReference, Owner, + TypeTag, WithdrawFrom, }; #[sui_move::move_struct(address = "0x2", module = "object", abilities = "copy, store")] @@ -26,6 +28,10 @@ fn mk_obj(id: &str, version: u64) -> ObjectReference { ObjectReference::new(Address::from_str(id).unwrap(), version, Digest::default()) } +fn addr(byte: u8) -> Address { + Address::new([byte; 32]) +} + #[test] fn build_move_call_from_callspec() { let package = Address::from_str("0x1").unwrap(); @@ -50,6 +56,80 @@ fn build_move_call_from_callspec() { assert_eq!(call.arguments, vec![Argument::Input(0), Argument::Input(1)]); } +#[test] +fn generic_object_helpers_build_canonical_inputs() { + let object = mk_obj("0x2", 3); + let shared_id = Address::from_str("0x3").unwrap(); + + let mut tx = PtbBuilder::new(); + let owned = tx.owned_object(&object).unwrap(); + let shared = tx.shared_object_by_id(shared_id, 7, true).unwrap(); + let clock = tx.clock().unwrap(); + + assert_eq!(owned, Argument::Input(0)); + assert_eq!(shared, Argument::Input(1)); + assert_eq!(clock, Argument::Input(2)); + + assert!(matches!(tx.inputs()[0], CallArg::ImmutableOrOwned(_))); + + let CallArg::Shared(shared) = &tx.inputs()[1] else { + panic!("expected shared input") + }; + assert_eq!(shared.object_id(), shared_id); + assert_eq!(shared.version(), 7); + assert_eq!(shared.mutability(), Mutability::Mutable); + + let CallArg::Shared(clock) = &tx.inputs()[2] else { + panic!("expected shared clock input") + }; + assert_eq!(clock.object_id(), sui_move_ptb::CLOCK_OBJECT_ID); + assert_eq!(clock.version(), 1); + assert_eq!(clock.mutability(), Mutability::Immutable); +} + +#[test] +fn object_from_owner_uses_owner_shape() { + let mut tx = PtbBuilder::new(); + let shared_object = ObjectReference::new(addr(0x02), 9, Digest::default()); + let owned_object = ObjectReference::new(addr(0x03), 9, Digest::default()); + let mutable_owned_object = ObjectReference::new(addr(0x04), 9, Digest::default()); + + let shared = tx + .object_from_owner(&shared_object, Owner::Shared(5), true) + .unwrap(); + let owned = tx + .object_from_owner(&owned_object, Owner::Address(addr(0xAA)), false) + .unwrap(); + + assert_eq!(shared, Argument::Input(0)); + assert_eq!(owned, Argument::Input(1)); + assert!(matches!(tx.inputs()[0], CallArg::Shared(_))); + assert!(matches!(tx.inputs()[1], CallArg::ImmutableOrOwned(_))); + + let err = tx + .object_from_owner(&mutable_owned_object, Owner::Address(addr(0xAA)), true) + .unwrap_err(); + assert!(matches!(err, BuildError::UnsupportedOwner { .. })); +} + +#[test] +fn builds_raw_bcs_inputs_funds_withdrawals_and_typed_vectors() { + let mut tx = PtbBuilder::new(); + let raw = tx.pure_bcs(vec![1, 2, 3]).unwrap(); + let amount = tx.arg(&10u64).unwrap(); + let coin = tx.funds_withdrawal_coin(TypeTag::U64, 11).unwrap(); + let vector = tx.move_vector::(vec![amount]).unwrap(); + + assert_eq!(raw, Argument::Input(0)); + assert_eq!(coin, Argument::Input(2)); + assert_eq!(vector, Argument::Result(0)); + + let pt = tx.finish(); + assert!(matches!(pt.inputs[0], CallArg::Pure(_))); + assert!(matches!(pt.inputs[2], CallArg::FundsWithdrawal(_))); + assert!(matches!(pt.commands[0], Command::MakeMoveVector(_))); +} + #[test] fn supports_shared_and_receiving_inputs() { let package = Address::from_str("0x1").unwrap(); @@ -71,6 +151,69 @@ fn supports_shared_and_receiving_inputs() { assert!(matches!(pt.inputs[1], CallArg::Receiving(_))); } +#[test] +fn build_move_call_from_target_and_existing_arguments() { + let package = Address::from_str("0x1").unwrap(); + let mut target = CallTarget::new(package, "m", "composed").unwrap(); + target.push_type_arg::(); + + let mut builder = PtbBuilder::new(); + let value = builder.arg(&7u64).unwrap(); + let result = builder + .call_target(target, vec![Argument::Gas, value]) + .unwrap(); + + assert_eq!(result, Argument::Result(0)); + let pt = builder.finish(); + assert_eq!(pt.commands.len(), 1); + + let Command::MoveCall(call) = &pt.commands[0] else { + panic!("expected move call"); + }; + assert_eq!(call.package, package); + assert_eq!(call.module.as_str(), "m"); + assert_eq!(call.function.as_str(), "composed"); + assert_eq!(call.arguments, vec![Argument::Gas, Argument::Input(0)]); +} + +#[test] +fn build_call_target_from_existing_arguments() { + let package = Address::from_str("0x1").unwrap(); + let mut target = CallTarget::new(package, "m", "composed").unwrap(); + target.push_type_arg::(); + + let mut builder = PtbBuilder::new(); + let value = builder.arg(&7u64).unwrap(); + let result = builder + .call_target(target, vec![Argument::Gas, value]) + .unwrap(); + + assert_eq!(result, Argument::Result(0)); + let pt = builder.finish(); + assert_eq!(pt.commands.len(), 1); + + let Command::MoveCall(call) = &pt.commands[0] else { + panic!("expected move call"); + }; + assert_eq!(call.package, package); + assert_eq!(call.module.as_str(), "m"); + assert_eq!(call.function.as_str(), "composed"); + assert_eq!(call.arguments, vec![Argument::Gas, Argument::Input(0)]); +} + +#[test] +fn publish_returns_upgrade_cap_result() { + let mut builder = PtbBuilder::new(); + let result = builder + .publish(vec![vec![1, 2, 3]], vec![Address::from_str("0x1").unwrap()]) + .unwrap(); + + assert_eq!(result, Argument::Result(0)); + let pt = builder.finish(); + assert_eq!(pt.commands.len(), 1); + assert!(matches!(pt.commands[0], Command::Publish(_))); +} + #[test] fn dedups_identical_inputs_except_funds_withdrawal() { let package = Address::from_str("0x1").unwrap(); @@ -145,3 +288,20 @@ fn rejects_duplicate_object_ids_between_receiving_and_input_objects() { let err = tx.arg(&receiving).unwrap_err(); assert!(matches!(err, BuildError::DuplicateObjectRefInput { .. })); } + +#[test] +fn nested_result_helper_requires_command_result() { + let tx = PtbBuilder::new(); + let result = Argument::Result(7); + assert_eq!( + tx.nested_result(result, 2).unwrap(), + Argument::NestedResult(7, 2) + ); + assert_eq!( + sui_move_ptb::nested_result(result, 2).unwrap(), + Argument::NestedResult(7, 2) + ); + + let err = sui_move_ptb::nested_result(Argument::Input(0), 0).unwrap_err(); + assert!(matches!(err, BuildError::ExpectedCommandResult { .. })); +} diff --git a/sui-move-runtime/src/lib.rs b/sui-move-runtime/src/lib.rs index 3843c49..63f8119 100644 --- a/sui-move-runtime/src/lib.rs +++ b/sui-move-runtime/src/lib.rs @@ -34,6 +34,10 @@ pub enum Error { #[error(transparent)] Build(#[from] sui_move_ptb::BuildError), + /// Constructing a generated Move call failed. + #[error(transparent)] + CallSpec(#[from] sui_move_call::CallSpecError), + /// Signing or submitting failed. #[error(transparent)] Tx(#[from] tx::TxError), @@ -615,7 +619,7 @@ impl<'a, S: SuiSigner> Tx<'a, S> { &mut self, modules: Vec>, dependencies: Vec
, - ) -> Result<(), Error> { + ) -> Result { Ok(self.ptb.publish(modules, dependencies)?) } diff --git a/sui-move/Cargo.toml b/sui-move/Cargo.toml index ae8c0eb..e0e4bc3 100644 --- a/sui-move/Cargo.toml +++ b/sui-move/Cargo.toml @@ -13,8 +13,7 @@ serde = { workspace = true, features = ["derive"] } bcs = { workspace = true } sui-sdk-types = { workspace = true } thiserror = { workspace = true } -serde_json = { workspace = true } -sui-move-derive = { version = "0.1.0", path = "../sui-move-derive", optional = true } +sui-move-derive = { path = "../sui-move-derive", optional = true } [features] default = [] diff --git a/sui-move/src/builtins.rs b/sui-move/src/builtins.rs index 0287130..9488902 100644 --- a/sui-move/src/builtins.rs +++ b/sui-move/src/builtins.rs @@ -22,6 +22,7 @@ impl_primitive!(u16, U16); impl_primitive!(u32, U32); impl_primitive!(u64, U64); impl_primitive!(u128, U128); +impl_primitive!(crate::U256, U256); impl_primitive!(bool, Bool); impl MoveType for sui_sdk_types::Address { @@ -49,9 +50,23 @@ impl_ability_markers_primitive!(u16); impl_ability_markers_primitive!(u32); impl_ability_markers_primitive!(u64); impl_ability_markers_primitive!(u128); +impl_ability_markers_primitive!(crate::U256); impl_ability_markers_primitive!(bool); impl_ability_markers_primitive!(sui_sdk_types::Address); impl HasCopy for Vec {} impl HasDrop for Vec {} impl HasStore for Vec {} + +#[cfg(test)] +mod tests { + use crate::MoveType; + + #[test] + fn u256_has_move_type_tag_and_fixed_width_bcs() { + let value = crate::U256::from_le_bytes([7u8; 32]); + + assert_eq!(crate::U256::type_tag_static(), sui_sdk_types::TypeTag::U256); + assert_eq!(bcs::to_bytes(&value).unwrap().len(), 32); + } +} diff --git a/sui-move/src/lib.rs b/sui-move/src/lib.rs index 60423b8..51cc955 100644 --- a/sui-move/src/lib.rs +++ b/sui-move/src/lib.rs @@ -16,7 +16,7 @@ pub mod prelude { pub use crate::{move_module, move_struct}; pub use crate::{ Copyable, Droppable, HasCopy, HasDrop, HasKey, HasStore, MoveInstance, MoveStruct, - MoveType, Storable, + MoveType, Storable, U256, }; pub use sui_sdk_types::{Address, Identifier, StructTag, TypeTag}; } @@ -31,6 +31,27 @@ mod builtins; pub mod decode; pub use decode::{decode_copyable, decode_keyed, decode_storable}; +/// Move `u256` value represented as 32 little-endian bytes. +/// +/// This intentionally keeps arithmetic out of `sui-move`; the type exists so generated bindings +/// can carry, serialize, and type-tag Move `u256` values without inventing package-specific +/// representations. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct U256([u8; 32]); + +impl U256 { + /// Construct a `u256` from its little-endian byte representation. + pub const fn from_le_bytes(bytes: [u8; 32]) -> Self { + Self(bytes) + } + + /// Return the little-endian byte representation. + pub const fn to_le_bytes(self) -> [u8; 32] { + self.0 + } +} + /// A Rust type that corresponds to a Move type. /// /// Implementors provide a static [`TypeTag`](sui_sdk_types::TypeTag) (including any type @@ -64,11 +85,6 @@ pub trait MoveType: Serialize + for<'de> Deserialize<'de> + fmt::Debug + Partial { bcs::from_bytes(bytes) } - - /// Convert this value into JSON using `serde_json`. - fn to_json(&self) -> serde_json::Value { - serde_json::to_value(self).expect("serialization should not fail") - } } /// A Move struct type (including any type parameters). diff --git a/sui-move/tests/derive.rs b/sui-move/tests/derive.rs index af0d786..54ba1df 100644 --- a/sui-move/tests/derive.rs +++ b/sui-move/tests/derive.rs @@ -59,6 +59,54 @@ mod bounded { } } +mod phantom_copy { + #[sui_move::move_struct( + address = "0x1", + module = "phantom_copy", + abilities = "copy, drop, store", + phantoms = "T" + )] + pub struct Marker { + pub value: u64, + } +} + +mod conditional_abilities { + #[sui_move::move_struct(address = "0x1", module = "conditional_abilities", abilities = "store")] + pub struct StoreOnly { + pub value: u64, + } + + #[sui_move::move_struct( + address = "0x1", + module = "conditional_abilities", + abilities = "copy, drop, store" + )] + pub struct OptionLike { + pub vec: Vec, + } +} + +mod dynamic_address { + use std::str::FromStr; + + use sui_move::prelude::Address; + + pub fn package() -> Address { + Address::from_str("0x9").unwrap() + } + + #[sui_move::move_struct( + address = "0x1", + address_fn = "crate::dynamic_address::package", + module = "dynamic_address", + abilities = "copy, drop, store" + )] + pub struct Value { + pub value: u64, + } +} + #[test] fn type_tag_matches_move_definition() { let tag = vault::Vault::::type_tag_static(); @@ -75,6 +123,19 @@ fn type_tag_matches_move_definition() { } } +#[test] +fn address_fn_overrides_literal_address_for_type_tags() { + let tag = dynamic_address::Value::type_tag_static(); + match tag { + TypeTag::Struct(inner) => { + assert_eq!(*inner.address(), Address::from_str("0x9").unwrap()); + assert_eq!(inner.module().to_string(), "dynamic_address"); + assert_eq!(inner.name().to_string(), "Value"); + } + _ => panic!("expected struct type tag"), + } +} + #[test] fn local_uid_type_matches_framework_identity_without_core_exports() { let tag = object::UID::type_tag_static(); @@ -137,6 +198,21 @@ fn tag_verification_and_bcs_roundtrip() { fn require_store(_: &T) {} fn require_copy(_: &T) {} +#[derive( + Debug, + PartialEq, + Eq, + sui_move::__private::serde::Serialize, + sui_move::__private::serde::Deserialize, +)] +struct NonCloneType; + +impl MoveType for NonCloneType { + fn type_tag_static() -> TypeTag { + TypeTag::Bool + } +} + #[test] fn type_abilities_are_respected() { let boxed = bounded::Boxed:: { value: 5 }; @@ -152,6 +228,40 @@ fn type_abilities_are_respected() { assert_eq!(nested.balance[0].value, 7); } +#[test] +fn phantom_copy_type_parameter_does_not_require_clone() { + let marker = phantom_copy::Marker:: { + value: 11, + phantom_t: std::marker::PhantomData, + }; + + let cloned = marker.clone(); + require_copy(&marker); + assert_eq!(cloned.value, 11); +} + +#[test] +fn ability_marker_impls_use_per_ability_field_bounds() { + let wrapped = conditional_abilities::OptionLike { + vec: vec![conditional_abilities::StoreOnly { value: 9 }], + }; + + require_store(&wrapped); + assert_eq!(wrapped.vec[0].value, 9); +} + +#[test] +fn generated_structs_clone_from_move_copy_abilities() { + let store_only = conditional_abilities::StoreOnly { value: 3 }; + let cloned = store_only.clone(); + assert_eq!(cloned.value, 3); + + let copy_value = dynamic_address::Value { value: 4 }; + let cloned_copy_value = copy_value.clone(); + require_copy(©_value); + assert_eq!(cloned_copy_value.value, 4); +} + fn uid_with_byte(byte: u8) -> object::UID { object::UID { id: object::ID {