diff --git a/Cargo.lock b/Cargo.lock index a08d082d9c..a006fa70ae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4541,6 +4541,7 @@ dependencies = [ "convert_case 0.6.0", "dojo-test-utils", "dojo-world", + "log", "scarb", "serde", "serde_json", diff --git a/crates/dojo-bindgen/Cargo.toml b/crates/dojo-bindgen/Cargo.toml index 747a70a796..bd5a505730 100644 --- a/crates/dojo-bindgen/Cargo.toml +++ b/crates/dojo-bindgen/Cargo.toml @@ -11,6 +11,7 @@ async-trait.workspace = true camino.workspace = true chrono.workspace = true convert_case.workspace = true +log.workspace = true serde.workspace = true serde_json.workspace = true starknet.workspace = true diff --git a/crates/dojo-bindgen/src/lib.rs b/crates/dojo-bindgen/src/lib.rs index 4f8f3f7f15..c6c9145c4e 100644 --- a/crates/dojo-bindgen/src/lib.rs +++ b/crates/dojo-bindgen/src/lib.rs @@ -11,6 +11,7 @@ pub mod error; use error::BindgenResult; mod plugins; +use plugins::recs::TypescriptRecsPlugin; use plugins::typescript::TypescriptPlugin; use plugins::typescript_v2::TypeScriptV2Plugin; use plugins::unity::UnityPlugin; @@ -89,6 +90,7 @@ impl PluginManager { BuiltinPlugins::Typescript => Box::new(TypescriptPlugin::new()), BuiltinPlugins::Unity => Box::new(UnityPlugin::new()), BuiltinPlugins::TypeScriptV2 => Box::new(TypeScriptV2Plugin::new()), + BuiltinPlugins::Recs => Box::new(TypescriptRecsPlugin::new()), }; let files = builder.generate_code(&data).await?; diff --git a/crates/dojo-bindgen/src/plugins/mod.rs b/crates/dojo-bindgen/src/plugins/mod.rs index b603262e44..a706498cf5 100644 --- a/crates/dojo-bindgen/src/plugins/mod.rs +++ b/crates/dojo-bindgen/src/plugins/mod.rs @@ -1,12 +1,15 @@ use std::collections::HashMap; use std::fmt; +use std::ops::{Deref, DerefMut}; use std::path::PathBuf; use async_trait::async_trait; +use cainome::parser::tokens::{Composite, Function}; use crate::error::BindgenResult; -use crate::DojoData; +use crate::{DojoContract, DojoData}; +pub mod recs; pub mod typescript; pub mod typescript_v2; pub mod unity; @@ -16,6 +19,7 @@ pub enum BuiltinPlugins { Typescript, Unity, TypeScriptV2, + Recs, } impl fmt::Display for BuiltinPlugins { @@ -24,12 +28,52 @@ impl fmt::Display for BuiltinPlugins { BuiltinPlugins::Typescript => write!(f, "typescript"), BuiltinPlugins::Unity => write!(f, "unity"), BuiltinPlugins::TypeScriptV2 => write!(f, "typescript_v2"), + BuiltinPlugins::Recs => write!(f, "recs"), } } } +pub struct Buffer(Vec); +impl Buffer { + pub fn new() -> Self { + Self(Vec::new()) + } + + pub fn has(&self, s: &str) -> bool { + self.0.iter().any(|b| b.contains(s)) + } + + pub fn push(&mut self, s: String) { + self.0.push(s.clone()); + } + + pub fn insert_after(&mut self, s: String, pos: &str, sep: &str, idx: usize) { + let pos = self.0.iter().position(|b| b.contains(pos)).unwrap(); + if let Some(st) = self.0.get_mut(pos) { + let indices = st.match_indices(sep).map(|(i, _)| i).collect::>(); + let append_after = indices[indices.len() - idx] + 1; + st.insert_str(append_after, &s); + } + } + pub fn join(&mut self, sep: &str) -> String { + self.0.join(sep) + } +} + +impl Deref for Buffer { + type Target = Vec; + fn deref(&self) -> &Self::Target { + &self.0 + } +} +impl DerefMut for Buffer { + fn deref_mut(&mut self) -> &mut Vec { + &mut self.0 + } +} + #[async_trait] -pub trait BuiltinPlugin { +pub trait BuiltinPlugin: Sync { /// Generates code by executing the plugin. /// /// # Arguments @@ -37,3 +81,33 @@ pub trait BuiltinPlugin { /// * `data` - Dojo data gathered from the compiled project. async fn generate_code(&self, data: &DojoData) -> BindgenResult>>; } + +pub trait BindgenWriter: Sync { + /// Writes the generated code to the specified path. + /// + /// # Arguments + /// + /// * `code` - The generated code. + fn write(&self, path: &str, data: &DojoData) -> BindgenResult<(PathBuf, Vec)>; + fn get_path(&self) -> &str; +} + +pub trait BindgenModelGenerator: Sync { + /// Generates code by executing the plugin. + /// The generated code is written to the specified path. + /// This will write file sequentially (for now) so we need one generator per part of the file. + /// (header, type definitions, interfaces, functions and so on) + /// TODO: add &mut ref to what's currently generated to place specific code at specific places. + /// + /// # Arguments + fn generate(&self, token: &Composite, buffer: &mut Buffer) -> BindgenResult; +} + +pub trait BindgenContractGenerator: Sync { + fn generate( + &self, + contract: &DojoContract, + token: &Function, + buffer: &mut Buffer, + ) -> BindgenResult; +} diff --git a/crates/dojo-bindgen/src/plugins/recs/mod.rs b/crates/dojo-bindgen/src/plugins/recs/mod.rs new file mode 100644 index 0000000000..6c8daa98d1 --- /dev/null +++ b/crates/dojo-bindgen/src/plugins/recs/mod.rs @@ -0,0 +1,618 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use async_trait::async_trait; +use cainome::parser::tokens::{Composite, CompositeType, Function, Token}; +use dojo_world::contracts::naming; + +use crate::error::BindgenResult; +use crate::plugins::BuiltinPlugin; +use crate::{compare_tokens_by_type_name, DojoContract, DojoData, DojoModel}; + +#[cfg(test)] +mod tests; + +pub struct TypescriptRecsPlugin {} + +impl TypescriptRecsPlugin { + pub fn new() -> Self { + Self {} + } + + // Maps cairo types to C#/Unity SDK defined types + fn map_type(token: &Token) -> String { + match token.type_name().as_str() { + "bool" => "RecsType.Boolean".to_string(), + "i8" => "RecsType.Number".to_string(), + "i16" => "RecsType.Number".to_string(), + "i32" => "RecsType.Number".to_string(), + "i64" => "RecsType.Number".to_string(), + "i128" => "RecsType.BigInt".to_string(), + "u8" => "RecsType.Number".to_string(), + "u16" => "RecsType.Number".to_string(), + "u32" => "RecsType.Number".to_string(), + "u64" => "RecsType.Number".to_string(), + "u128" => "RecsType.BigInt".to_string(), + "u256" => "RecsType.BigInt".to_string(), + "usize" => "RecsType.Number".to_string(), + "felt252" => "RecsType.BigInt".to_string(), + "bytes31" => "RecsType.String".to_string(), + "ClassHash" => "RecsType.BigInt".to_string(), + "ContractAddress" => "RecsType.BigInt".to_string(), + "ByteArray" => "RecsType.String".to_string(), + "array" => { + if let Token::Array(array) = token { + let mut mapped = TypescriptRecsPlugin::map_type(&array.inner); + if mapped == array.inner.type_name() { + mapped = "RecsType.String".to_string(); + } + + format!("{}Array", mapped) + } else { + panic!("Invalid array token: {:?}", token); + } + } + "generic_arg" => { + if let Token::GenericArg(g) = &token { + g.clone() + } else { + panic!("Invalid generic arg token: {:?}", token); + } + } + + // we consider tuples as essentially objects + _ => { + let mut type_name = token.type_name().to_string(); + + if let Token::Composite(composite) = token { + if !composite.generic_args.is_empty() { + type_name += &format!( + "<{}>", + composite + .generic_args + .iter() + .map(|(_, t)| TypescriptRecsPlugin::map_type(t)) + .collect::>() + .join(", ") + ) + } + } + + type_name + } + } + } + + fn generated_header() -> String { + format!( + " +// Generated by dojo-bindgen on {}. Do not modify this file manually. +// Import the necessary types from the recs SDK +// generate again with `sozo build --typescript` +", + chrono::Utc::now().to_rfc2822() + ) + } + + // Token should be a struct + // This will be formatted into a C# struct + // using C# and unity SDK types + fn format_struct(token: &Composite) -> String { + let mut native_fields = String::new(); + let mut fields = String::new(); + + for field in &token.inners { + let mapped = TypescriptRecsPlugin::map_type(&field.token); + + if mapped == field.token.type_name() { + native_fields += + format!("{}: {};\n ", field.name, field.token.type_name()).as_str(); + fields += format!("{}: {}Definition,\n ", field.name, mapped,).as_str(); + } else { + native_fields += format!( + "{}: {};\n ", + field.name, + mapped.replace("RecsType.", "").replace("Array", "[]") + ) + .as_str(); + fields += format!("{}: {},\n ", field.name, mapped,).as_str(); + } + } + + format!( + " +// Type definition for `{path}` struct +export interface {name} {{ + {native_fields} +}} +export const {name}Definition = {{ + {fields} +}}; +", + path = token.type_path, + name = token.type_name(), + fields = fields, + native_fields = native_fields + ) + } + + // Token should be an enum + // This will be formatted into a C# enum + // Enum is mapped using index of cairo enum + fn format_enum(token: &Composite) -> String { + // filter out common types + // TODO: Make cleaner + if token.type_path == "core::option::Option::" + || token.type_path == "core::option::Option::" + || token.type_path == "core::option::Option::" + || token.type_path == "core::option::Option::" + || token.type_path == "core::option::Option::" + || token.type_path == "core::option::Option::" + { + return String::new(); // Return an empty string for these enums + } + + let name = TypescriptRecsPlugin::map_type(&Token::Composite(token.clone())); + + let mut result = format!( + " +// Type definition for `{}` enum +export type {} = ", + token.type_path, name + ); + + let mut variants = Vec::new(); + + for field in &token.inners { + let field_type = TypescriptRecsPlugin::map_type(&field.token).replace("()", ""); + + let variant_definition = if field_type.is_empty() { + // No associated data + format!("{{ type: '{}'; }}", field.name) + } else { + // With associated data + format!("{{ type: '{}'; value: {}; }}", field.name, field_type) + }; + + variants.push(variant_definition); + } + + result += &variants.join(" | "); + + result += ";\n"; + + result += format!( + " +export const {name}Definition = {{ + type: RecsType.String,{} +}}; + ", + if !token.inners.is_empty() { + "\n value: RecsType.String".to_string() + } else { + "".to_string() + } + ) + .as_str(); + + result + } + + // Token should be a model + // This will be formatted into a C# class inheriting from ModelInstance + // Fields are mapped using C# and unity SDK types + fn format_model(namespace: &str, model: &Composite) -> String { + let mut custom_types = Vec::::new(); + let mut types = Vec::::new(); + let (fields, _composite_type) = + model.inners.iter().fold((Vec::new(), model.r#type), |(mut fields, _), field| { + let mapped = TypescriptRecsPlugin::map_type(&field.token); + + let field_str = match field.token { + Token::Composite(ref c) if c.r#type == CompositeType::Enum => { + types.push(format!("\"{}\"", field.token.type_name())); + format!("{}: RecsType.String,", field.name) + } + Token::Composite(_) => { + custom_types.push(format!("\"{}\"", field.token.type_name())); + format!("{}: {}Definition,", field.name, mapped) + } + _ if mapped == field.token.type_name() => { + custom_types.push(format!("\"{}\"", field.token.type_name())); + format!("{}: {}Definition,", field.name, mapped) + } + _ => { + types.push(format!("\"{}\"", field.token.type_name())); + format!("{}: {},", field.name, mapped) + } + }; + + fields.push(field_str); + (fields, model.r#type) + }); + + let fields_str = fields.join("\n "); + + format!( + " + // Model definition for `{path}` model + {model}: (() => {{ + return defineComponent( + world, + {{ + {fields_str} + }}, + {{ + metadata: {{ + namespace: \"{namespace}\", + name: \"{model}\", + types: [{types}], + customTypes: [{custom_types}], + }}, + }} + ); + }})(), +", + path = model.type_path, + model = model.type_name(), + types = types.join(", "), + custom_types = custom_types.join(", ") + ) + } + + // Handles a model definition and its referenced tokens + // Will map all structs and enums to TS types + // Will format the models into a object + fn handle_model(&self, models: &[&DojoModel], handled_tokens: &mut Vec) -> String { + let mut out = String::new(); + out += TypescriptRecsPlugin::generated_header().as_str(); + out += "import { defineComponent, Type as RecsType, World } from \"@dojoengine/recs\";\n"; + out += "\n"; + out += "export type ContractComponents = Awaited>;\n"; + + out += "\n\n"; + + let mut models_structs = Vec::new(); + for model in models { + let tokens = &model.tokens; + + let mut sorted_structs = tokens.structs.clone(); + sorted_structs.sort_by(compare_tokens_by_type_name); + + let mut sorted_enums = tokens.enums.clone(); + sorted_enums.sort_by(compare_tokens_by_type_name); + + for token in &sorted_enums { + handled_tokens.push(token.to_composite().unwrap().to_owned()); + } + for token in &sorted_structs { + handled_tokens.push(token.to_composite().unwrap().to_owned()); + } + + for token in &sorted_enums { + if handled_tokens.iter().filter(|t| t.type_name() == token.type_name()).count() > 1 + { + continue; + } + out += TypescriptRecsPlugin::format_enum(token.to_composite().unwrap()).as_str(); + } + + for token in &sorted_structs { + if handled_tokens.iter().filter(|t| t.type_name() == token.type_name()).count() > 1 + { + continue; + } + + // first index is our model struct + if token.type_name() == naming::get_name_from_tag(&model.tag) { + models_structs.push(( + naming::get_namespace_from_tag(&model.tag), + token.to_composite().unwrap().clone(), + )); + } + + out += TypescriptRecsPlugin::format_struct(token.to_composite().unwrap()).as_str(); + } + + out += "\n"; + } + + out += " +export function defineContractComponents(world: World) { + return { +"; + + for (namespace, model) in models_structs { + out += TypescriptRecsPlugin::format_model(&namespace, &model).as_str(); + } + + out += " }; +}\n"; + + out + } + + // Formats a system into a C# method used by the contract class + // Handled tokens should be a list of all structs and enums used by the contract + // Such as a set of referenced tokens from a model + fn format_system(system: &Function, handled_tokens: &[Composite], namespace: String) -> String { + if [ + "contract_name", + "namespace", + "tag", + "name_hash", + "selector", + "dojo_init", + "namespace_hash", + ] + .contains(&system.name.as_str()) + { + return String::new(); + } + fn map_type(token: &Token) -> String { + match token { + Token::CoreBasic(_) => TypescriptRecsPlugin::map_type(token) + .replace("RecsType.", "").replace("Array", "[]") + // types should be lowercased + .to_lowercase(), + Token::Composite(t) => format!("models.{}", t.type_name()), + Token::Array(_) => TypescriptRecsPlugin::map_type(token), + _ => panic!("Unsupported token type: {:?}", token), + } + } + + let args = system + .inputs + .iter() + .map(|arg| format!("{}: {}", arg.0, map_type(&arg.1))) + .collect::>() + .join(", "); + + fn handle_arg_recursive( + arg_name: &str, + arg: &Token, + handled_tokens: &[Composite], + ) -> String { + match arg { + Token::Composite(_) => { + match handled_tokens.iter().find(|t| t.type_name() == arg.type_name()) { + Some(t) => { + // Need to flatten the struct members. + match t.r#type { + CompositeType::Struct if t.type_name() == "ByteArray" => { + format!("byteArray.byteArrayFromString(props.{})", arg_name) + } + CompositeType::Struct => t + .inners + .iter() + .map(|field| format!("props.{}.{}", arg_name, field.name)) + .collect::>() + .join(",\n "), + CompositeType::Enum => format!( + "[{}].indexOf(props.{}.type)", + t.inners + .iter() + .map(|field| format!("\"{}\"", field.name)) + .collect::>() + .join(", "), + arg_name + ), + _ => { + format!("props.{}", arg_name) + } + } + } + None => format!("props.{}", arg_name), + } + } + Token::Array(_) => format!("...props.{}", arg_name), + Token::Tuple(t) => format!( + "...[{}]", + t.inners + .iter() + .enumerate() + .map(|(idx, t)| handle_arg_recursive( + &format!("props.{arg_name}[{idx}]"), + t, + handled_tokens + )) + .collect::>() + .join(", ") + ), + _ => format!("props.{}", arg_name), + } + } + + let calldata = system + .inputs + .iter() + .map(|arg| handle_arg_recursive(&arg.0, &arg.1, handled_tokens)) + .collect::>() + .join(",\n "); + + format!( + " + // Call the `{system_name}` system with the specified Account and calldata + const {system_name} = async (props: {{ account: Account{arg_sep}{args} }}) => {{ + try {{ + return await provider.execute( + props.account, + {{ + contractName: contract_name, + entrypoint: \"{system_name}\", + calldata: [{calldata}], + }}, + \"{namespace}\" + ); + }} catch (error) {{ + console.error(\"Error executing {system_name}:\", error); + throw error; + }} + }}; + ", + // selector for execute + system_name = system.name, + // add comma if we have args + arg_sep = if !args.is_empty() { ", " } else { "" }, + // formatted args to use our mapped types + args = args, + // calldata for execute + calldata = calldata, + namespace = TypescriptRecsPlugin::get_namespace_from_tag(&namespace) + ) + } + + // Formats a contract tag into a pretty contract name + // eg. dojo_examples-actions -> actions + fn formatted_contract_name(tag: &str) -> String { + naming::get_name_from_tag(tag) + } + + fn get_namespace_from_tag(tag: &str) -> String { + tag.split('-').next().unwrap_or(tag).to_string() + } + + // Handles a contract definition and its underlying systems + // Will format the contract into a C# class and + // all systems into C# methods + // Handled tokens should be a list of all structs and enums used by the contract + fn handle_contracts( + &self, + contracts: &[&DojoContract], + handled_tokens: &[Composite], + ) -> String { + let mut out = String::new(); + out += TypescriptRecsPlugin::generated_header().as_str(); + out += "import { Account, byteArray } from \"starknet\";\n"; + out += "import { DojoProvider } from \"@dojoengine/core\";\n"; + out += "import * as models from \"./models.gen\";\n"; + out += "\n"; + out += "export type IWorld = Awaited>;"; + + out += "\n\n"; + + out += "export async function setupWorld(provider: DojoProvider) {"; + + for contract in contracts { + let systems = contract + .systems + .iter() + .filter(|system| { + let name = system.to_function().unwrap().name.as_str(); + ![ + "contract_name", + "namespace", + "tag", + "name_hash", + "selector", + "dojo_init", + "namespace_hash", + ] + .contains(&name) + }) + .map(|system| { + TypescriptRecsPlugin::format_system( + system.to_function().unwrap(), + handled_tokens, + contract.tag.clone(), + ) + }) + .collect::>() + .join("\n\n "); + + out += &format!( + " + // System definitions for `{}` contract + function {}() {{ + const contract_name = \"{}\"; + + {} + + return {{ + {} + }}; + }} +", + contract.tag, + // capitalize contract name + TypescriptRecsPlugin::formatted_contract_name(&contract.tag), + TypescriptRecsPlugin::formatted_contract_name(&contract.tag), + systems, + contract + .systems + .iter() + .filter(|system| { + let name = system.to_function().unwrap().name.as_str(); + ![ + "contract_name", + "namespace", + "tag", + "name_hash", + "selector", + "dojo_init", + "namespace_hash", + ] + .contains(&name) + }) + .map(|system| { system.to_function().unwrap().name.to_string() }) + .collect::>() + .join(", ") + ); + } + + out += " + return { + "; + + out += &contracts + .iter() + .map(|c| { + format!( + "{}: {}()", + TypescriptRecsPlugin::formatted_contract_name(&c.tag), + TypescriptRecsPlugin::formatted_contract_name(&c.tag) + ) + }) + .collect::>() + .join(",\n "); + + out += " + }; +}\n"; + + out + } +} + +#[async_trait] +impl BuiltinPlugin for TypescriptRecsPlugin { + async fn generate_code(&self, data: &DojoData) -> BindgenResult>> { + let mut out: HashMap> = HashMap::new(); + let mut handled_tokens = Vec::::new(); + + // Handle codegen for models + let models_path = Path::new("models.gen.ts").to_owned(); + let mut models = data.models.values().collect::>(); + + // Sort models based on their tag to ensure deterministic output. + models.sort_by(|a, b| a.tag.cmp(&b.tag)); + + let code = self.handle_model(models.as_slice(), &mut handled_tokens); + + out.insert(models_path, code.as_bytes().to_vec()); + + // Handle codegen for contracts & systems + let contracts_path = Path::new("contracts.gen.ts").to_owned(); + let mut contracts = data.contracts.values().collect::>(); + + // Sort contracts based on their tag to ensure deterministic output. + contracts.sort_by(|a, b| a.tag.cmp(&b.tag)); + + let code = self.handle_contracts(contracts.as_slice(), &handled_tokens); + + out.insert(contracts_path, code.as_bytes().to_vec()); + + Ok(out) + } +} diff --git a/crates/dojo-bindgen/src/plugins/typescript/tests.rs b/crates/dojo-bindgen/src/plugins/recs/tests.rs similarity index 87% rename from crates/dojo-bindgen/src/plugins/typescript/tests.rs rename to crates/dojo-bindgen/src/plugins/recs/tests.rs index 078d9813ac..c6a18bbab2 100644 --- a/crates/dojo-bindgen/src/plugins/typescript/tests.rs +++ b/crates/dojo-bindgen/src/plugins/recs/tests.rs @@ -6,12 +6,12 @@ use cainome::parser::tokens::{ Composite, CompositeInner, CompositeInnerKind, CompositeType, Token, }; -use crate::plugins::typescript::TypescriptPlugin; +use crate::plugins::recs::TypescriptRecsPlugin; use crate::{BuiltinPlugin, DojoData, DojoWorld}; #[tokio::test] async fn test_typescript_plugin_generate_code() { - let plugin = TypescriptPlugin::new(); + let plugin = TypescriptRecsPlugin::new(); let data = create_mock_dojo_data(); let result = plugin.generate_code(&data).await; @@ -38,23 +38,26 @@ async fn test_typescript_plugin_generate_code() { fn test_map_type() { let bool_token = Token::CoreBasic(cainome::parser::tokens::CoreBasic { type_path: "bool".to_string() }); - assert_eq!(TypescriptPlugin::map_type(&bool_token), "RecsType.Boolean"); + assert_eq!(TypescriptRecsPlugin::map_type(&bool_token), "RecsType.Boolean"); let u32_token = Token::CoreBasic(cainome::parser::tokens::CoreBasic { type_path: "u32".to_string() }); - assert_eq!(TypescriptPlugin::map_type(&u32_token), "RecsType.Number"); + assert_eq!(TypescriptRecsPlugin::map_type(&u32_token), "RecsType.Number"); } #[test] fn test_formatted_contract_name() { - assert_eq!(TypescriptPlugin::formatted_contract_name("dojo_examples-actions"), "actions"); - assert_eq!(TypescriptPlugin::formatted_contract_name("my-contract"), "contract"); + assert_eq!(TypescriptRecsPlugin::formatted_contract_name("dojo_examples-actions"), "actions"); + assert_eq!(TypescriptRecsPlugin::formatted_contract_name("my-contract"), "contract"); } #[test] fn test_get_namespace_from_tag() { - assert_eq!(TypescriptPlugin::get_namespace_from_tag("dojo_examples-actions"), "dojo_examples"); - assert_eq!(TypescriptPlugin::get_namespace_from_tag("my-contract"), "my"); + assert_eq!( + TypescriptRecsPlugin::get_namespace_from_tag("dojo_examples-actions"), + "dojo_examples" + ); + assert_eq!(TypescriptRecsPlugin::get_namespace_from_tag("my-contract"), "my"); } #[test] @@ -100,7 +103,7 @@ fn test_format_model() { }; let namespace = "game"; - let formatted = TypescriptPlugin::format_model(namespace, &model); + let formatted = TypescriptRecsPlugin::format_model(namespace, &model); let expected = r#" // Model definition for `game::models::Position` model @@ -147,7 +150,7 @@ fn test_format_enum_model() { }; let namespace = "game"; - let formatted = TypescriptPlugin::format_model(namespace, &model); + let formatted = TypescriptRecsPlugin::format_model(namespace, &model); let expected = r#" // Model definition for `game::models::Direction` model diff --git a/crates/dojo-bindgen/src/plugins/typescript/generator/enum.rs b/crates/dojo-bindgen/src/plugins/typescript/generator/enum.rs new file mode 100644 index 0000000000..ebbee65c47 --- /dev/null +++ b/crates/dojo-bindgen/src/plugins/typescript/generator/enum.rs @@ -0,0 +1,138 @@ +use cainome::parser::tokens::{Composite, CompositeType}; + +use crate::error::BindgenResult; +use crate::plugins::{BindgenModelGenerator, Buffer}; + +pub(crate) struct TsEnumGenerator; + +impl BindgenModelGenerator for TsEnumGenerator { + fn generate(&self, token: &Composite, buffer: &mut Buffer) -> BindgenResult { + if token.r#type != CompositeType::Enum || token.inners.is_empty() { + return Ok(String::new()); + } + + let gen = format!( + "// Type definition for `{path}` enum +export enum {name} {{ +{variants} +}} +", + path = token.type_path, + name = token.type_name(), + variants = token + .inners + .iter() + .map(|inner| format!("\t{},", inner.name)) + .collect::>() + .join("\n") + ); + + if buffer.has(&gen) { + return Ok(String::new()); + } + + Ok(gen) + } +} + +#[cfg(test)] +mod tests { + use cainome::parser::tokens::{ + CompositeInner, CompositeInnerKind, CompositeType, CoreBasic, Token, + }; + + use super::*; + use crate::plugins::Buffer; + + #[test] + fn test_enumeration_without_inners() { + let mut buff = Buffer::new(); + let writer = TsEnumGenerator; + let token = Composite { + type_path: "core::test::Test".to_owned(), + inners: vec![], + generic_args: vec![], + r#type: CompositeType::Enum, + is_event: false, + alias: None, + }; + let result = writer.generate(&token, &mut buff).unwrap(); + assert_eq!(result, ""); + } + + #[test] + fn test_enumeration_not_enum() { + let mut buff = Buffer::new(); + let writer = TsEnumGenerator; + let token = Composite { + type_path: "core::test::Test".to_owned(), + inners: vec![], + generic_args: vec![], + r#type: CompositeType::Struct, + is_event: false, + alias: None, + }; + let result = writer.generate(&token, &mut buff).unwrap(); + assert_eq!(result, ""); + } + + #[test] + fn test_enumeration_with_inners() { + let mut buff = Buffer::new(); + let writer = TsEnumGenerator; + let token = create_available_theme_enum_token(); + let result = writer.generate(&token, &mut buff).unwrap(); + + assert_eq!( + result, + "// Type definition for `core::test::AvailableTheme` enum\nexport enum AvailableTheme \ + {\n\tLight,\n\tDark,\n\tDojo,\n}\n" + ); + } + + #[test] + fn test_it_does_not_duplicates_enum() { + let mut buff = Buffer::new(); + let writer = TsEnumGenerator; + buff.push( + "// Type definition for `core::test::AvailableTheme` enum\nexport enum AvailableTheme \ + {\n\tLight,\n\tDark,\n\tDojo,\n}\n" + .to_owned(), + ); + + let token_dup = create_available_theme_enum_token(); + let result = writer.generate(&token_dup, &mut buff).unwrap(); + assert_eq!(buff.len(), 1); + assert!(result.is_empty()) + } + + fn create_available_theme_enum_token() -> Composite { + Composite { + type_path: "core::test::AvailableTheme".to_owned(), + inners: vec![ + CompositeInner { + index: 0, + name: "Light".to_owned(), + kind: CompositeInnerKind::Key, + token: Token::CoreBasic(CoreBasic { type_path: "()".to_owned() }), + }, + CompositeInner { + index: 1, + name: "Dark".to_owned(), + kind: CompositeInnerKind::Key, + token: Token::CoreBasic(CoreBasic { type_path: "()".to_owned() }), + }, + CompositeInner { + index: 2, + name: "Dojo".to_owned(), + kind: CompositeInnerKind::Key, + token: Token::CoreBasic(CoreBasic { type_path: "()".to_owned() }), + }, + ], + generic_args: vec![], + r#type: CompositeType::Enum, + is_event: false, + alias: None, + } + } +} diff --git a/crates/dojo-bindgen/src/plugins/typescript/generator/erc.rs b/crates/dojo-bindgen/src/plugins/typescript/generator/erc.rs new file mode 100644 index 0000000000..162bbc6990 --- /dev/null +++ b/crates/dojo-bindgen/src/plugins/typescript/generator/erc.rs @@ -0,0 +1,116 @@ +use cainome::parser::tokens::Composite; + +use super::get_namespace_and_path; +use crate::error::BindgenResult; +use crate::plugins::{BindgenModelGenerator, Buffer}; + +const ERC_TORII_TPL: &str = "// Type definition for ERC__Balance struct +export type ERC__Type = 'ERC20' | 'ERC721'; +export interface ERC__Balance { + fieldOrder: string[]; + balance: string; + type: string; + tokenMetadata: ERC__Token; +} +export interface ERC__Token { + fieldOrder: string[]; + name: string; + symbol: string; + tokenId: string; + decimals: string; + contractAddress: string; +} +export interface ERC__Transfer { + fieldOrder: string[]; + from: string; + to: string; + amount: string; + type: string; + executedAt: string; + tokenMetadata: ERC__Token; + transactionHash: string; +}"; +const ERC_TORII_TYPES: &str = "\n\t\tERC__Balance: ERC__Balance,\n\t\tERC__Token: \ + ERC__Token,\n\t\tERC__Transfer: ERC__Transfer,"; +const ERC_TORII_INIT: &str = " +\t\tERC__Balance: { +\t\t\tfieldorder: ['balance', 'type', 'tokenmetadata'], +\t\t\tbalance: '', +\t\t\ttype: 'ERC20', +\t\t\ttokenMetadata: { +\t\t\t\tfieldorder: ['name', 'symbol', 'tokenId', 'decimals', 'contractAddress'], +\t\t\t\tname: '', +\t\t\t\tsymbol: '', +\t\t\t\ttokenId: '', +\t\t\t\tdecimals: '', +\t\t\t\tcontractAddress: '', +\t\t\t}, +\t\t}, +\t\tERC__Token: { +\t\t\tfieldOrder: ['name', 'symbol', 'tokenId', 'decimals', 'contractAddress'], +\t\t\tname: '', +\t\t\tsymbol: '', +\t\t\ttokenId: '', +\t\t\tdecimals: '', +\t\t\tcontractAddress: '', +\t\t}, +\t\tERC__Transfer: { +\t\t\tfieldOrder: ['from', 'to', 'amount', 'type', 'executed', 'tokenMetadata'], +\t\t\tfrom: '', +\t\t\tto: '', +\t\t\tamount: '', +\t\t\ttype: 'ERC20', +\t\t\texecutedAt: '', +\t\t\ttokenMetadata: { +\t\t\t\tfieldOrder: ['name', 'symbol', 'tokenId', 'decimals', 'contractAddress'], +\t\t\t\tname: '', +\t\t\t\tsymbol: '', +\t\t\t\ttokenId: '', +\t\t\t\tdecimals: '', +\t\t\t\tcontractAddress: '', +\t\t\t}, +\t\t\ttransactionHash: '', +\t\t}, +"; + +pub(crate) struct TsErcGenerator; + +impl TsErcGenerator { + fn add_schema_type(&self, buffer: &mut Buffer, token: &Composite) { + let (_, namespace, _) = get_namespace_and_path(token); + let schema_type = format!("export interface {namespace}SchemaType extends SchemaType"); + if buffer.has(&schema_type) { + if buffer.has(ERC_TORII_TYPES) { + return; + } + + buffer.insert_after(ERC_TORII_TYPES.to_owned(), &schema_type, ",", 2); + } + } + + fn add_schema_type_init(&self, buffer: &mut Buffer, token: &Composite) { + let (_, namespace, _) = get_namespace_and_path(token); + let const_type = format!("export const schema: {namespace}SchemaType"); + if buffer.has(&const_type) { + if buffer.has(ERC_TORII_INIT) { + return; + } + buffer.insert_after(ERC_TORII_INIT.to_owned(), &const_type, ",", 2); + } + } +} + +impl BindgenModelGenerator for TsErcGenerator { + fn generate(&self, token: &Composite, buffer: &mut Buffer) -> BindgenResult { + if buffer.has(ERC_TORII_TPL) { + return Ok(String::new()); + } + + // As this generator is separated from schema.rs we need to check if schema is present in + // buffer and also adding torii types to schema to query this data through grpc + self.add_schema_type(buffer, token); + self.add_schema_type_init(buffer, token); + + Ok(ERC_TORII_TPL.to_owned()) + } +} diff --git a/crates/dojo-bindgen/src/plugins/typescript/generator/function.rs b/crates/dojo-bindgen/src/plugins/typescript/generator/function.rs new file mode 100644 index 0000000000..5af320fa17 --- /dev/null +++ b/crates/dojo-bindgen/src/plugins/typescript/generator/function.rs @@ -0,0 +1,300 @@ +use cainome::parser::tokens::{CompositeType, Function, Token}; +use convert_case::{Case, Casing}; +use dojo_world::contracts::naming; + +use super::JsType; +use crate::error::BindgenResult; +use crate::plugins::{BindgenContractGenerator, Buffer}; +use crate::DojoContract; + +pub(crate) struct TsFunctionGenerator; +impl TsFunctionGenerator { + fn check_imports(&self, buffer: &mut Buffer) { + if !buffer.has("import { DojoProvider } from ") { + buffer.insert(0, "import { DojoProvider } from \"@dojoengine/core\";".to_owned()); + buffer.insert(1, "import { Account } from \"starknet\";".to_owned()); + buffer.insert(2, "import * as models from \"./models.gen\";\n".to_owned()); + } + } + + fn setup_function_wrapper_start(&self, buffer: &mut Buffer) -> usize { + let fn_wrapper = "export async function setupWorld(provider: DojoProvider) {{\n"; + + if !buffer.has(fn_wrapper) { + buffer.push(fn_wrapper.to_owned()); + } + + buffer.iter().position(|b| b.contains(fn_wrapper)).unwrap() + } + + fn generate_system_function(&self, contract_name: &str, token: &Function) -> String { + format!( + "\tconst {} = async ({}) => {{ +\t\ttry {{ +\t\t\treturn await provider.execute(\n +\t\t\t\taccount, +\t\t\t\t{{ +\t\t\t\t\tcontractName: \"{contract_name}\", +\t\t\t\t\tentryPoint: \"{}\", +\t\t\t\t\tcalldata: [{}], +\t\t\t\t}} +\t\t\t); +\t\t}} catch (error) {{ +\t\t\tconsole.error(error); +\t\t}} +\t}};\n", + token.name.to_case(Case::Camel), + self.format_function_inputs(token), + token.name, + self.format_function_calldata(token) + ) + } + + fn format_function_inputs(&self, token: &Function) -> String { + let inputs = vec!["account: Account".to_owned()]; + token + .inputs + .iter() + .fold(inputs, |mut acc, input| { + let prefix = match &input.1 { + Token::Composite(t) => { + if t.r#type == CompositeType::Enum { + "models." + } else { + "" + } + } + _ => "", + }; + acc.push(format!( + "{}: {}{}", + input.0.to_case(Case::Camel), + prefix, + JsType::from(&input.1) + )); + acc + }) + .join(", ") + } + + fn format_function_calldata(&self, token: &Function) -> String { + token + .inputs + .iter() + .fold(Vec::new(), |mut acc, input| { + acc.push(input.0.to_case(Case::Camel)); + acc + }) + .join(", ") + } + + fn append_function_body(&self, idx: usize, buffer: &mut Buffer, body: String) { + // check if function was already appended to body, if so, append after other functions + let pos = if buffer.len() - idx > 2 { buffer.len() - 2 } else { idx }; + + buffer.insert(pos + 1, body); + } + + fn setup_function_wrapper_end(&self, token: &Function, buffer: &mut Buffer) { + let return_token = "\treturn {"; + if !buffer.has(return_token) { + buffer + .push(format!("\treturn {{\n\t\t{},\n\t}};\n}}", token.name.to_case(Case::Camel))); + return; + } + + buffer.insert_after( + format!("\n\t\t{},", token.name.to_case(Case::Camel)), + return_token, + ",", + 1, + ); + } +} + +impl BindgenContractGenerator for TsFunctionGenerator { + fn generate( + &self, + contract: &DojoContract, + token: &Function, + buffer: &mut Buffer, + ) -> BindgenResult { + self.check_imports(buffer); + let idx = self.setup_function_wrapper_start(buffer); + self.append_function_body( + idx, + buffer, + self.generate_system_function(naming::get_name_from_tag(&contract.tag).as_str(), token), + ); + self.setup_function_wrapper_end(token, buffer); + Ok(String::new()) + } +} + +#[cfg(test)] +mod tests { + use cainome::parser::tokens::{CoreBasic, Function, Token}; + use cainome::parser::TokenizedAbi; + use dojo_world::contracts::naming; + + use super::TsFunctionGenerator; + use crate::plugins::{BindgenContractGenerator, Buffer}; + use crate::DojoContract; + + #[test] + fn test_check_imports() { + let generator = TsFunctionGenerator {}; + let mut buff = Buffer::new(); + + // check imports are added only once + generator.check_imports(&mut buff); + assert_eq!(buff.len(), 3); + generator.check_imports(&mut buff); + assert_eq!(buff.len(), 3); + } + + #[test] + fn test_setup_function_wrapper_start() { + let generator = TsFunctionGenerator {}; + let mut buff = Buffer::new(); + let idx = generator.setup_function_wrapper_start(&mut buff); + + assert_eq!(buff.len(), 1); + assert_eq!(idx, 0); + } + + #[test] + fn test_generate_system_function() { + let generator = TsFunctionGenerator {}; + let function = create_change_theme_function(); + let expected = "\tconst changeTheme = async (account: Account, value: number) => { +\t\ttry { +\t\t\treturn await provider.execute(\n +\t\t\t\taccount, +\t\t\t\t{ +\t\t\t\t\tcontractName: \"actions\", +\t\t\t\t\tentryPoint: \"change_theme\", +\t\t\t\t\tcalldata: [value], +\t\t\t\t} +\t\t\t); +\t\t} catch (error) { +\t\t\tconsole.error(error); +\t\t} +\t};\n"; + + let contract = create_dojo_contract(); + assert_eq!( + expected, + generator.generate_system_function( + naming::get_name_from_tag(&contract.tag).as_str(), + &function + ) + ) + } + + #[test] + fn test_format_function_inputs() { + let generator = TsFunctionGenerator {}; + let function = create_change_theme_function(); + let expected = "account: Account, value: number"; + assert_eq!(expected, generator.format_function_inputs(&function)) + } + + #[test] + fn test_format_function_inputs_complex() { + let generator = TsFunctionGenerator {}; + let function = create_change_theme_function(); + let expected = "account: Account, value: number"; + assert_eq!(expected, generator.format_function_inputs(&function)) + } + + #[test] + fn test_format_function_calldata() { + let generator = TsFunctionGenerator {}; + let function = create_change_theme_function(); + let expected = "value"; + assert_eq!(expected, generator.format_function_calldata(&function)) + } + + #[test] + fn test_append_function_body() { + let generator = TsFunctionGenerator {}; + let mut buff = Buffer::new(); + buff.push("import".to_owned()); + buff.push("function wrapper".to_owned()); + + generator.append_function_body(1, &mut buff, "function body".to_owned()); + + assert_eq!(buff[2], "function body".to_owned()); + } + + #[test] + fn test_setup_function_wrapper_end() { + let generator = TsFunctionGenerator {}; + let mut buff = Buffer::new(); + + generator.setup_function_wrapper_end(&create_change_theme_function(), &mut buff); + + let expected = "\treturn { +\t\tchangeTheme, +\t}; +}"; + + assert_eq!(1, buff.len()); + assert_eq!(expected, buff[0]); + + generator.setup_function_wrapper_end(&create_increate_global_counter_function(), &mut buff); + let expected_2 = "\treturn { +\t\tchangeTheme, +\t\tincreaseGlobalCounter, +\t}; +}"; + assert_eq!(1, buff.len()); + assert_eq!(expected_2, buff[0]); + } + + #[test] + fn test_it_generates_function() { + let generator = TsFunctionGenerator {}; + let mut buffer = Buffer::new(); + let change_theme = create_change_theme_function(); + + let _ = generator.generate(&create_dojo_contract(), &change_theme, &mut buffer); + assert_eq!(buffer.len(), 6); + let increase_global_counter = create_increate_global_counter_function(); + let _ = generator.generate(&create_dojo_contract(), &increase_global_counter, &mut buffer); + assert_eq!(buffer.len(), 7); + } + + fn create_change_theme_function() -> Function { + create_test_function( + "change_theme", + vec![( + "value".to_owned(), + Token::CoreBasic(CoreBasic { type_path: "core::integer::u8".to_owned() }), + )], + ) + } + + fn create_increate_global_counter_function() -> Function { + create_test_function("increase_global_counter", vec![]) + } + + fn create_test_function(name: &str, inputs: Vec<(String, Token)>) -> Function { + Function { + name: name.to_owned(), + state_mutability: cainome::parser::tokens::StateMutability::External, + inputs, + outputs: vec![], + named_outputs: vec![], + } + } + + fn create_dojo_contract() -> DojoContract { + DojoContract { + tag: "onchain_dash-actions".to_owned(), + tokens: TokenizedAbi::default(), + systems: vec![], + } + } +} diff --git a/crates/dojo-bindgen/src/plugins/typescript/generator/interface.rs b/crates/dojo-bindgen/src/plugins/typescript/generator/interface.rs new file mode 100644 index 0000000000..f75efadf94 --- /dev/null +++ b/crates/dojo-bindgen/src/plugins/typescript/generator/interface.rs @@ -0,0 +1,119 @@ +use cainome::parser::tokens::{Composite, CompositeType}; + +use super::JsType; +use crate::error::BindgenResult; +use crate::plugins::{BindgenModelGenerator, Buffer}; + +pub(crate) struct TsInterfaceGenerator; + +impl BindgenModelGenerator for TsInterfaceGenerator { + fn generate(&self, token: &Composite, _buffer: &mut Buffer) -> BindgenResult { + if token.r#type != CompositeType::Struct || token.inners.is_empty() { + return Ok(String::new()); + } + + Ok(format!( + "// Type definition for `{path}` struct +export interface {name} {{ +\tfieldOrder: string[]; +{fields} +}} +", + path = token.type_path, + name = token.type_name(), + fields = token + .inners + .iter() + .map(|inner| { format!("\t{}: {};", inner.name, JsType::from(&inner.token)) }) + .collect::>() + .join("\n") + )) + } +} + +#[cfg(test)] +mod tests { + use cainome::parser::tokens::{ + CompositeInner, CompositeInnerKind, CompositeType, CoreBasic, Token, + }; + + use super::*; + use crate::plugins::Buffer; + + #[test] + fn test_interface_without_inners() { + let mut buff = Buffer::new(); + let writer = TsInterfaceGenerator; + let token = Composite { + type_path: "core::test::Test".to_string(), + inners: vec![], + generic_args: vec![], + r#type: CompositeType::Struct, + is_event: false, + alias: None, + }; + let result = writer.generate(&token, &mut buff).unwrap(); + assert_eq!(result, ""); + } + + #[test] + fn test_interface_not_struct() { + let mut buff = Buffer::new(); + let writer = TsInterfaceGenerator; + let token = Composite { + type_path: "core::test::Test".to_string(), + inners: vec![], + generic_args: vec![], + r#type: CompositeType::Enum, + is_event: false, + alias: None, + }; + let result = writer.generate(&token, &mut buff).unwrap(); + assert_eq!(result, ""); + } + + #[test] + fn test_interface_with_inners() { + let mut buff = Buffer::new(); + let writer = TsInterfaceGenerator; + let token = create_test_struct_token(); + let result = writer.generate(&token, &mut buff).unwrap(); + + assert_eq!( + result, + "// Type definition for `core::test::TestStruct` struct\nexport interface TestStruct \ + {\n\tfieldOrder: string[];\n\tfield1: number;\n\tfield2: number;\n\tfield3: \ + number;\n}\n" + ); + } + + fn create_test_struct_token() -> Composite { + Composite { + type_path: "core::test::TestStruct".to_owned(), + inners: vec![ + CompositeInner { + index: 0, + name: "field1".to_owned(), + kind: CompositeInnerKind::Key, + token: Token::CoreBasic(CoreBasic { type_path: "core::felt252".to_owned() }), + }, + CompositeInner { + index: 1, + name: "field2".to_owned(), + kind: CompositeInnerKind::Key, + token: Token::CoreBasic(CoreBasic { type_path: "core::felt252".to_owned() }), + }, + CompositeInner { + index: 2, + name: "field3".to_owned(), + kind: CompositeInnerKind::Key, + token: Token::CoreBasic(CoreBasic { type_path: "core::felt252".to_owned() }), + }, + ], + generic_args: vec![], + r#type: CompositeType::Struct, + is_event: false, + alias: None, + } + } +} diff --git a/crates/dojo-bindgen/src/plugins/typescript/generator/mod.rs b/crates/dojo-bindgen/src/plugins/typescript/generator/mod.rs new file mode 100644 index 0000000000..9e53bb2ffb --- /dev/null +++ b/crates/dojo-bindgen/src/plugins/typescript/generator/mod.rs @@ -0,0 +1,330 @@ +use cainome::parser::tokens::{Composite, Token}; +use convert_case::{Case, Casing}; + +pub(crate) mod r#enum; +pub(crate) mod erc; +pub(crate) mod function; +pub(crate) mod interface; +pub(crate) mod schema; + +/// Get the namespace and path of a model +/// eg. dojo_examples-actions -> actions +/// or just get the raw type name -> actions +pub(crate) fn get_namespace_and_path(token: &Composite) -> (String, String, String) { + let ns_split = token.type_path.split("::").collect::>(); + if ns_split.len() < 2 { + panic!("type is invalid type_path has to be at least namespace::type"); + } + let ns = ns_split[0]; + let type_name = ns_split[ns_split.len() - 1]; + let namespace = ns.to_case(Case::Pascal); + (ns.to_owned(), namespace, type_name.to_owned()) +} + +/// Generates default values for each fields of the struct. +pub(crate) fn generate_type_init(token: &Composite) -> String { + format!( + "{{\n\t\t\tfieldOrder: [{}],\n{}\n\t\t}}", + token.inners.iter().map(|i| format!("'{}'", i.name)).collect::>().join(", "), + token + .inners + .iter() + .map(|i| { + match i.token.to_composite() { + Ok(c) => { + format!("\t\t\t{}: {},", i.name, JsDefaultValue::from(c)) + } + Err(_) => { + // this will fail on core types as + // `core::starknet::contract_address::ContractAddress` + // `core::felt252` + // `core::integer::u64` + // and so son + format!("\t\t\t{}: {},", i.name, JsDefaultValue::from(&i.token)) + } + } + }) + .collect::>() + .join("\n") + ) +} + +#[derive(Debug)] +pub(crate) struct JsType(String); +impl From<&str> for JsType { + fn from(value: &str) -> Self { + match value { + "felt252" => JsType("number".to_owned()), + "ContractAddress" => JsType("string".to_owned()), + "ByteArray" => JsType("string".to_owned()), + "u8" => JsType("number".to_owned()), + "u16" => JsType("number".to_owned()), + "u32" => JsType("number".to_owned()), + "u64" => JsType("number".to_owned()), + "u128" => JsType("number".to_owned()), + "u256" => JsType("number".to_owned()), + "U256" => JsType("number".to_owned()), + "bool" => JsType("boolean".to_owned()), + _ => JsType(value.to_owned()), + } + } +} + +impl From<&Token> for JsType { + fn from(value: &Token) -> Self { + match value { + Token::Array(a) => JsType::from(format!("Array<{}>", JsType::from(&*a.inner)).as_str()), + Token::Tuple(t) => JsType::from( + format!( + "[{}]", + t.inners + .iter() + .map(|i| JsType::from(i.type_name().as_str()).to_string()) + .collect::>() + .join(", ") + .as_str() + ) + .as_str(), + ), + _ => JsType::from(value.type_name().as_str()), + } + } +} + +impl std::fmt::Display for JsType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +#[derive(Debug)] +pub(crate) struct JsDefaultValue(String); +impl From<&str> for JsDefaultValue { + fn from(value: &str) -> Self { + match value { + "felt252" => JsDefaultValue("0".to_string()), + "ContractAddress" => JsDefaultValue("\"\"".to_string()), + "ByteArray" => JsDefaultValue("\"\"".to_string()), + "u8" => JsDefaultValue("0".to_string()), + "u16" => JsDefaultValue("0".to_string()), + "u32" => JsDefaultValue("0".to_string()), + "u64" => JsDefaultValue("0".to_string()), + "u128" => JsDefaultValue("0".to_string()), + "u256" => JsDefaultValue("0".to_string()), + "U256" => JsDefaultValue("0".to_string()), + "bool" => JsDefaultValue("false".to_string()), + _ => JsDefaultValue(value.to_string()), + } + } +} +impl From<&Composite> for JsDefaultValue { + fn from(value: &Composite) -> Self { + match value.r#type { + cainome::parser::tokens::CompositeType::Enum => { + JsDefaultValue(format!("{}.{}", value.type_name(), value.inners[0].name)) + } + cainome::parser::tokens::CompositeType::Struct => JsDefaultValue(format!( + "{{ {} }}", + value + .inners + .iter() + .map(|i| format!("{}: {},", i.name, JsDefaultValue::from(&i.token))) + .collect::>() + .join("\n") + )), + _ => JsDefaultValue::from(value.type_name().as_str()), + } + } +} + +impl From<&Token> for JsDefaultValue { + fn from(value: &Token) -> Self { + match value { + Token::Array(a) => { + JsDefaultValue::from(format!("[{}]", JsDefaultValue::from(&*a.inner)).as_str()) + } + Token::Tuple(t) => JsDefaultValue::from( + format!( + "[{}]", + t.inners + .iter() + .map(|i| JsDefaultValue::from(i.type_name().as_str()).to_string()) + .collect::>() + .join(", ") + .as_str() + ) + .as_str(), + ), + Token::Composite(c) => JsDefaultValue::from(c), + _ => JsDefaultValue::from(value.type_name().as_str()), + } + } +} + +impl std::fmt::Display for JsDefaultValue { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +#[cfg(test)] +mod tests { + use cainome::parser::tokens::{ + Array, Composite, CompositeInner, CompositeInnerKind, CompositeType, CoreBasic, Token, + Tuple, + }; + + use crate::plugins::typescript::generator::{generate_type_init, JsDefaultValue, JsType}; + + impl PartialEq for &str { + fn eq(&self, other: &JsType) -> bool { + self == &other.0.as_str() + } + } + + impl PartialEq for &str { + fn eq(&self, other: &JsDefaultValue) -> bool { + self == &other.0.as_str() + } + } + + #[test] + fn test_js_type_basics() { + assert_eq!( + "number", + JsType::from(&Token::CoreBasic(CoreBasic { + type_path: "core::integer::u8".to_owned() + })) + ); + assert_eq!( + "number", + JsType::from(&Token::CoreBasic(CoreBasic { type_path: "core::felt252".to_owned() })) + ) + } + + #[test] + fn test_tuple_type() { + assert_eq!( + "[number, number]", + JsType::from(&Token::Tuple(Tuple { + type_path: "(core::integer::u8,core::integer::u128)".to_owned(), + inners: vec![ + Token::CoreBasic(CoreBasic { type_path: "core::integer::u8".to_owned() }), + Token::CoreBasic(CoreBasic { type_path: "core::integer::u128".to_owned() }) + ] + })) + ); + } + + #[test] + fn test_array_type() { + assert_eq!( + "Array<[number, number]>", + JsType::from(&Token::Array(Array { + type_path: "core::array::Span<(core::integer::u8,core::integer::u128)>".to_owned(), + inner: Box::new(Token::Tuple(Tuple { + type_path: "(core::integer::u8,core::integer::u128)".to_owned(), + inners: vec![ + Token::CoreBasic(CoreBasic { type_path: "core::integer::u8".to_owned() }), + Token::CoreBasic(CoreBasic { type_path: "core::integer::u128".to_owned() }) + ] + })), + is_legacy: false, + })) + ) + } + + #[test] + fn test_default_value_basics() { + assert_eq!( + "0", + JsDefaultValue::from(&Token::CoreBasic(CoreBasic { + type_path: "core::integer::u8".to_owned() + })) + ); + assert_eq!( + "0", + JsDefaultValue::from(&Token::CoreBasic(CoreBasic { + type_path: "core::felt252".to_owned() + })) + ) + } + + #[test] + fn test_tuple_default_value() { + assert_eq!( + "[0, 0]", + JsDefaultValue::from(&Token::Tuple(Tuple { + type_path: "(core::integer::u8,core::integer::u128)".to_owned(), + inners: vec![ + Token::CoreBasic(CoreBasic { type_path: "core::integer::u8".to_owned() }), + Token::CoreBasic(CoreBasic { type_path: "core::integer::u128".to_owned() }) + ] + })) + ); + } + + #[test] + fn test_array_default_value() { + assert_eq!( + "[[0, 0]]", + JsDefaultValue::from(&Token::Array(Array { + type_path: "core::array::Span<(core::integer::u8,core::integer::u128)>".to_owned(), + inner: Box::new(Token::Tuple(Tuple { + type_path: "(core::integer::u8,core::integer::u128)".to_owned(), + inners: vec![ + Token::CoreBasic(CoreBasic { type_path: "core::integer::u8".to_owned() }), + Token::CoreBasic(CoreBasic { type_path: "core::integer::u128".to_owned() }) + ] + })), + is_legacy: false, + })) + ) + } + + #[test] + fn test_generate_type_init() { + let token = create_test_struct_token("TestStruct"); + let init_type = generate_type_init(&token); + + // we expect having something like this: + // the content of generate_type_init is wrapped in a function that adds brackets before and + // after + let expected = "{ +\t\t\tfieldOrder: ['field1', 'field2', 'field3'], +\t\t\tfield1: 0, +\t\t\tfield2: 0, +\t\t\tfield3: 0, +\t\t}"; + assert_eq!(expected, init_type); + } + fn create_test_struct_token(name: &str) -> Composite { + Composite { + type_path: format!("onchain_dash::{name}"), + inners: vec![ + CompositeInner { + index: 0, + name: "field1".to_owned(), + kind: CompositeInnerKind::Key, + token: Token::CoreBasic(CoreBasic { type_path: "core::felt252".to_owned() }), + }, + CompositeInner { + index: 1, + name: "field2".to_owned(), + kind: CompositeInnerKind::Key, + token: Token::CoreBasic(CoreBasic { type_path: "core::felt252".to_owned() }), + }, + CompositeInner { + index: 2, + name: "field3".to_owned(), + kind: CompositeInnerKind::Key, + token: Token::CoreBasic(CoreBasic { type_path: "core::felt252".to_owned() }), + }, + ], + generic_args: vec![], + r#type: CompositeType::Struct, + is_event: false, + alias: None, + } + } +} diff --git a/crates/dojo-bindgen/src/plugins/typescript/generator/schema.rs b/crates/dojo-bindgen/src/plugins/typescript/generator/schema.rs new file mode 100644 index 0000000000..d72efddca8 --- /dev/null +++ b/crates/dojo-bindgen/src/plugins/typescript/generator/schema.rs @@ -0,0 +1,229 @@ +use cainome::parser::tokens::{Composite, CompositeType}; + +use super::{generate_type_init, get_namespace_and_path}; +use crate::error::BindgenResult; +use crate::plugins::{BindgenModelGenerator, Buffer}; + +/// This generator will build a schema based on previous generator results. +/// first we need to generate interface of schema which will be used in dojo.js sdk to fully type +/// sdk +/// then we need to build the schema const which contains default values for all fieleds +pub(crate) struct TsSchemaGenerator {} +impl TsSchemaGenerator { + /// Import only needs to be present once + fn import_schema_type(&self, buffer: &mut Buffer) { + if !buffer.has("import type { SchemaType }") { + buffer.insert(0, "import type { SchemaType } from \"@dojoengine/sdk\";\n".to_owned()); + } + } + + /// Generates the type definition for the schema + fn handle_schema_type(&self, token: &Composite, buffer: &mut Buffer) { + let (ns, namespace, type_name) = get_namespace_and_path(token); + let schema_type = format!("export interface {namespace}SchemaType extends SchemaType"); + if !buffer.has(&schema_type) { + buffer.push(format!( + "export interface {namespace}SchemaType extends SchemaType {{\n\t{ns}: \ + {{\n\t\t{}: {},\n\t}},\n}}", + type_name, type_name + )); + return; + } + + // type has already been initialized + let gen = format!("\n\t\t{type_name}: {type_name},"); + if buffer.has(&gen) { + return; + } + + // fastest way to add a field to the interface is to search for the n-1 `,` and add the + // field directly after it. + // to improve this logic, we would need to either have some kind of code parsing. + // we could otherwise have some intermediate representation that we pass to this generator + // function. + buffer.insert_after(gen, &schema_type, ",", 2); + } + + /// Generates the default values for the schema + fn handle_schema_const(&self, token: &Composite, buffer: &mut Buffer) { + let (ns, namespace, type_name) = get_namespace_and_path(token); + let const_type = format!("export const schema: {namespace}SchemaType"); + if !buffer.has(&const_type) { + buffer.push(format!( + "export const schema: {namespace}SchemaType = {{\n\t{ns}: {{\n\t\t{}: \ + {},\n\t}},\n}};", + type_name, + generate_type_init(token) + )); + return; + } + + // type has already been initialized + let gen = format!("\n\t\t{type_name}: {},", generate_type_init(token)); + if buffer.has(&gen) { + return; + } + buffer.insert_after(gen, &const_type, ",", 2); + } +} + +impl BindgenModelGenerator for TsSchemaGenerator { + fn generate(&self, token: &Composite, buffer: &mut Buffer) -> BindgenResult { + if token.inners.is_empty() || token.r#type != CompositeType::Struct { + return Ok(String::new()); + } + self.import_schema_type(buffer); + + // in buffer search for interface named {pascal_case(namespace)}SchemaType extends + // SchemaType + // this should be hold in a buffer item + self.handle_schema_type(token, buffer); + + // in buffer search for const schema: InterfaceName = named + // {pascal_case(namespace)}SchemaType extends SchemaType + // this should be hold in a buffer item + self.handle_schema_const(token, buffer); + + Ok(String::new()) + } +} + +/// Those tests may not test the returned value because it is supporsed to be called sequentially +/// after other generators have been called. +/// This generator will be state based on an external mutable buffer which is a carry +#[cfg(test)] +mod tests { + use cainome::parser::tokens::{ + CompositeInner, CompositeInnerKind, CompositeType, CoreBasic, Token, + }; + + use super::*; + use crate::plugins::BindgenModelGenerator; + + #[test] + fn test_it_does_nothing_if_no_inners() { + let generator = TsSchemaGenerator {}; + let mut buffer = Buffer::new(); + + let token = Composite { + type_path: "core::test::Test".to_owned(), + inners: vec![], + generic_args: vec![], + r#type: CompositeType::Enum, + is_event: false, + alias: None, + }; + let _result = generator.generate(&token, &mut buffer); + assert_eq!(0, buffer.len()); + } + + #[test] + fn test_it_adds_imports() { + let generator = TsSchemaGenerator {}; + let mut buffer = Buffer::new(); + + let token = create_test_struct_token("TestStruct"); + let _result = generator.generate(&token, &mut buffer); + + // token is not empty, we should have an import + assert_eq!("import type { SchemaType } from \"@dojoengine/sdk\";\n", buffer[0]); + } + + /// NOTE: For the following tests, we assume that the `enum.rs` and `interface.rs` generators + /// successfully ran and generated related output to generator base interfaces + enums. + #[test] + fn test_it_appends_schema_type() { + let generator = TsSchemaGenerator {}; + let mut buffer = Buffer::new(); + + let token = create_test_struct_token("TestStruct"); + let _result = generator.generate(&token, &mut buffer); + assert_eq!( + "export interface OnchainDashSchemaType extends SchemaType {\n\tonchain_dash: \ + {\n\t\tTestStruct: TestStruct,\n\t},\n}", + buffer[1] + ); + } + + #[test] + fn test_handle_schema_type() { + let generator = TsSchemaGenerator {}; + let mut buffer = Buffer::new(); + + let token = create_test_struct_token("TestStruct"); + generator.handle_schema_type(&token, &mut buffer); + + assert_ne!(0, buffer.len()); + assert_eq!( + "export interface OnchainDashSchemaType extends SchemaType {\n\tonchain_dash: \ + {\n\t\tTestStruct: TestStruct,\n\t},\n}", + buffer[0] + ); + + let token_2 = create_test_struct_token("AvailableTheme"); + generator.handle_schema_type(&token_2, &mut buffer); + assert_eq!( + "export interface OnchainDashSchemaType extends SchemaType {\n\tonchain_dash: \ + {\n\t\tTestStruct: TestStruct,\n\t\tAvailableTheme: AvailableTheme,\n\t},\n}", + buffer[0] + ); + } + + #[test] + fn test_handle_schema_const() { + let generator = TsSchemaGenerator {}; + let mut buffer = Buffer::new(); + let token = create_test_struct_token("TestStruct"); + + generator.handle_schema_const(&token, &mut buffer); + assert_eq!(buffer.len(), 1); + assert_eq!( + buffer[0], + "export const schema: OnchainDashSchemaType = {\n\tonchain_dash: {\n\t\tTestStruct: \ + {\n\t\t\tfieldOrder: ['field1', 'field2', 'field3'],\n\t\t\tfield1: \ + 0,\n\t\t\tfield2: 0,\n\t\t\tfield3: 0,\n\t\t},\n\t},\n};" + ); + + let token_2 = create_test_struct_token("AvailableTheme"); + generator.handle_schema_const(&token_2, &mut buffer); + assert_eq!(buffer.len(), 1); + assert_eq!( + buffer[0], + "export const schema: OnchainDashSchemaType = {\n\tonchain_dash: {\n\t\tTestStruct: \ + {\n\t\t\tfieldOrder: ['field1', 'field2', 'field3'],\n\t\t\tfield1: \ + 0,\n\t\t\tfield2: 0,\n\t\t\tfield3: 0,\n\t\t},\n\t\tAvailableTheme: \ + {\n\t\t\tfieldOrder: ['field1', 'field2', 'field3'],\n\t\t\tfield1: \ + 0,\n\t\t\tfield2: 0,\n\t\t\tfield3: 0,\n\t\t},\n\t},\n};" + ); + } + + fn create_test_struct_token(name: &str) -> Composite { + Composite { + type_path: format!("onchain_dash::{name}"), + inners: vec![ + CompositeInner { + index: 0, + name: "field1".to_owned(), + kind: CompositeInnerKind::Key, + token: Token::CoreBasic(CoreBasic { type_path: "core::felt252".to_owned() }), + }, + CompositeInner { + index: 1, + name: "field2".to_owned(), + kind: CompositeInnerKind::Key, + token: Token::CoreBasic(CoreBasic { type_path: "core::felt252".to_owned() }), + }, + CompositeInner { + index: 2, + name: "field3".to_owned(), + kind: CompositeInnerKind::Key, + token: Token::CoreBasic(CoreBasic { type_path: "core::felt252".to_owned() }), + }, + ], + generic_args: vec![], + r#type: CompositeType::Struct, + is_event: false, + alias: None, + } + } +} diff --git a/crates/dojo-bindgen/src/plugins/typescript/mod.rs b/crates/dojo-bindgen/src/plugins/typescript/mod.rs index 4d4645840d..d9d7f57e1d 100644 --- a/crates/dojo-bindgen/src/plugins/typescript/mod.rs +++ b/crates/dojo-bindgen/src/plugins/typescript/mod.rs @@ -1,587 +1,45 @@ use std::collections::HashMap; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use async_trait::async_trait; -use cainome::parser::tokens::{Composite, CompositeType, Function, Token}; -use dojo_world::contracts::naming; - +use generator::r#enum::TsEnumGenerator; +use generator::erc::TsErcGenerator; +use generator::function::TsFunctionGenerator; +use generator::interface::TsInterfaceGenerator; +use generator::schema::TsSchemaGenerator; +use writer::{TsFileContractWriter, TsFileWriter}; + +use super::BindgenWriter; use crate::error::BindgenResult; use crate::plugins::BuiltinPlugin; -use crate::{compare_tokens_by_type_name, DojoContract, DojoData, DojoModel}; +use crate::DojoData; -#[cfg(test)] -mod tests; +pub(crate) mod generator; +pub(crate) mod writer; -pub struct TypescriptPlugin {} +pub struct TypescriptPlugin { + writers: Vec>, +} impl TypescriptPlugin { pub fn new() -> Self { - Self {} - } - - // Maps cairo types to C#/Unity SDK defined types - fn map_type(token: &Token) -> String { - match token.type_name().as_str() { - "bool" => "RecsType.Boolean".to_string(), - "i8" => "RecsType.Number".to_string(), - "i16" => "RecsType.Number".to_string(), - "i32" => "RecsType.Number".to_string(), - "i64" => "RecsType.Number".to_string(), - "i128" => "RecsType.BigInt".to_string(), - "u8" => "RecsType.Number".to_string(), - "u16" => "RecsType.Number".to_string(), - "u32" => "RecsType.Number".to_string(), - "u64" => "RecsType.Number".to_string(), - "u128" => "RecsType.BigInt".to_string(), - "u256" => "RecsType.BigInt".to_string(), - "usize" => "RecsType.Number".to_string(), - "felt252" => "RecsType.BigInt".to_string(), - "bytes31" => "RecsType.String".to_string(), - "ClassHash" => "RecsType.BigInt".to_string(), - "ContractAddress" => "RecsType.BigInt".to_string(), - "ByteArray" => "RecsType.String".to_string(), - "array" => { - if let Token::Array(array) = token { - let mut mapped = TypescriptPlugin::map_type(&array.inner); - if mapped == array.inner.type_name() { - mapped = "RecsType.String".to_string(); - } - - format!("{}Array", mapped) - } else { - panic!("Invalid array token: {:?}", token); - } - } - "generic_arg" => { - if let Token::GenericArg(g) = &token { - g.clone() - } else { - panic!("Invalid generic arg token: {:?}", token); - } - } - - // we consider tuples as essentially objects - _ => { - let mut type_name = token.type_name().to_string(); - - if let Token::Composite(composite) = token { - if !composite.generic_args.is_empty() { - type_name += &format!( - "<{}>", - composite - .generic_args - .iter() - .map(|(_, t)| TypescriptPlugin::map_type(t)) - .collect::>() - .join(", ") - ) - } - } - - type_name - } - } - } - - fn generated_header() -> String { - format!( - " -// Generated by dojo-bindgen on {}. Do not modify this file manually. -// Import the necessary types from the recs SDK -// generate again with `sozo build --typescript` -", - chrono::Utc::now().to_rfc2822() - ) - } - - // Token should be a struct - // This will be formatted into a C# struct - // using C# and unity SDK types - fn format_struct(token: &Composite) -> String { - let mut native_fields = String::new(); - let mut fields = String::new(); - - for field in &token.inners { - let mapped = TypescriptPlugin::map_type(&field.token); - - if mapped == field.token.type_name() { - native_fields += - format!("{}: {};\n ", field.name, field.token.type_name()).as_str(); - fields += format!("{}: {}Definition,\n ", field.name, mapped,).as_str(); - } else { - native_fields += format!( - "{}: {};\n ", - field.name, - mapped.replace("RecsType.", "").replace("Array", "[]") - ) - .as_str(); - fields += format!("{}: {},\n ", field.name, mapped,).as_str(); - } - } - - format!( - " -// Type definition for `{path}` struct -export interface {name} {{ - {native_fields} -}} -export const {name}Definition = {{ - {fields} -}}; -", - path = token.type_path, - name = token.type_name(), - fields = fields, - native_fields = native_fields - ) - } - - // Token should be an enum - // This will be formatted into a C# enum - // Enum is mapped using index of cairo enum - fn format_enum(token: &Composite) -> String { - // filter out common types - // TODO: Make cleaner - if token.type_path == "core::option::Option::" - || token.type_path == "core::option::Option::" - || token.type_path == "core::option::Option::" - || token.type_path == "core::option::Option::" - || token.type_path == "core::option::Option::" - || token.type_path == "core::option::Option::" - { - return String::new(); // Return an empty string for these enums - } - - let name = TypescriptPlugin::map_type(&Token::Composite(token.clone())); - - let mut result = format!( - " -// Type definition for `{}` enum -export type {} = ", - token.type_path, name - ); - - let mut variants = Vec::new(); - - for field in &token.inners { - let field_type = TypescriptPlugin::map_type(&field.token).replace("()", ""); - - let variant_definition = if field_type.is_empty() { - // No associated data - format!("{{ type: '{}'; }}", field.name) - } else { - // With associated data - format!("{{ type: '{}'; value: {}; }}", field.name, field_type) - }; - - variants.push(variant_definition); - } - - result += &variants.join(" | "); - - result += ";\n"; - - result += format!( - " -export const {name}Definition = {{ - type: RecsType.String,{} -}}; - ", - if !token.inners.is_empty() { - "\n value: RecsType.String".to_string() - } else { - "".to_string() - } - ) - .as_str(); - - result - } - - // Token should be a model - // This will be formatted into a C# class inheriting from ModelInstance - // Fields are mapped using C# and unity SDK types - fn format_model(namespace: &str, model: &Composite) -> String { - let mut custom_types = Vec::::new(); - let mut types = Vec::::new(); - let (fields, _composite_type) = - model.inners.iter().fold((Vec::new(), model.r#type), |(mut fields, _), field| { - let mapped = TypescriptPlugin::map_type(&field.token); - - let field_str = match field.token { - Token::Composite(ref c) if c.r#type == CompositeType::Enum => { - types.push(format!("\"{}\"", field.token.type_name())); - format!("{}: RecsType.String,", field.name) - } - Token::Composite(_) => { - custom_types.push(format!("\"{}\"", field.token.type_name())); - format!("{}: {}Definition,", field.name, mapped) - } - _ if mapped == field.token.type_name() => { - custom_types.push(format!("\"{}\"", field.token.type_name())); - format!("{}: {}Definition,", field.name, mapped) - } - _ => { - types.push(format!("\"{}\"", field.token.type_name())); - format!("{}: {},", field.name, mapped) - } - }; - - fields.push(field_str); - (fields, model.r#type) - }); - - let fields_str = fields.join("\n "); - - format!( - " - // Model definition for `{path}` model - {model}: (() => {{ - return defineComponent( - world, - {{ - {fields_str} - }}, - {{ - metadata: {{ - namespace: \"{namespace}\", - name: \"{model}\", - types: [{types}], - customTypes: [{custom_types}], - }}, - }} - ); - }})(), -", - path = model.type_path, - model = model.type_name(), - types = types.join(", "), - custom_types = custom_types.join(", ") - ) - } - - // Handles a model definition and its referenced tokens - // Will map all structs and enums to TS types - // Will format the models into a object - fn handle_model(&self, models: &[&DojoModel], handled_tokens: &mut Vec) -> String { - let mut out = String::new(); - out += TypescriptPlugin::generated_header().as_str(); - out += "import { defineComponent, Type as RecsType, World } from \"@dojoengine/recs\";\n"; - out += "\n"; - out += "export type ContractComponents = Awaited>;\n"; - - out += "\n\n"; - - let mut models_structs = Vec::new(); - for model in models { - let tokens = &model.tokens; - - let mut sorted_structs = tokens.structs.clone(); - sorted_structs.sort_by(compare_tokens_by_type_name); - - let mut sorted_enums = tokens.enums.clone(); - sorted_enums.sort_by(compare_tokens_by_type_name); - - for token in &sorted_enums { - handled_tokens.push(token.to_composite().unwrap().to_owned()); - } - for token in &sorted_structs { - handled_tokens.push(token.to_composite().unwrap().to_owned()); - } - - for token in &sorted_enums { - if handled_tokens.iter().filter(|t| t.type_name() == token.type_name()).count() > 1 - { - continue; - } - out += TypescriptPlugin::format_enum(token.to_composite().unwrap()).as_str(); - } - - for token in &sorted_structs { - if handled_tokens.iter().filter(|t| t.type_name() == token.type_name()).count() > 1 - { - continue; - } - - // first index is our model struct - if token.type_name() == naming::get_name_from_tag(&model.tag) { - models_structs.push(( - naming::get_namespace_from_tag(&model.tag), - token.to_composite().unwrap().clone(), - )); - } - - out += TypescriptPlugin::format_struct(token.to_composite().unwrap()).as_str(); - } - - out += "\n"; - } - - out += " -export function defineContractComponents(world: World) { - return { -"; - - for (namespace, model) in models_structs { - out += TypescriptPlugin::format_model(&namespace, &model).as_str(); - } - - out += " }; -}\n"; - - out - } - - // Formats a system into a C# method used by the contract class - // Handled tokens should be a list of all structs and enums used by the contract - // Such as a set of referenced tokens from a model - fn format_system(system: &Function, handled_tokens: &[Composite], namespace: String) -> String { - if [ - "contract_name", - "namespace", - "tag", - "name_hash", - "selector", - "dojo_init", - "namespace_hash", - ] - .contains(&system.name.as_str()) - { - return String::new(); - } - fn map_type(token: &Token) -> String { - match token { - Token::CoreBasic(_) => TypescriptPlugin::map_type(token) - .replace("RecsType.", "").replace("Array", "[]") - // types should be lowercased - .to_lowercase(), - Token::Composite(t) => format!("models.{}", t.type_name()), - Token::Array(_) => TypescriptPlugin::map_type(token), - _ => panic!("Unsupported token type: {:?}", token), - } - } - - let args = system - .inputs - .iter() - .map(|arg| format!("{}: {}", arg.0, map_type(&arg.1))) - .collect::>() - .join(", "); - - fn handle_arg_recursive( - arg_name: &str, - arg: &Token, - handled_tokens: &[Composite], - ) -> String { - match arg { - Token::Composite(_) => { - match handled_tokens.iter().find(|t| t.type_name() == arg.type_name()) { - Some(t) => { - // Need to flatten the struct members. - match t.r#type { - CompositeType::Struct if t.type_name() == "ByteArray" => { - format!("byteArray.byteArrayFromString(props.{})", arg_name) - } - CompositeType::Struct => t - .inners - .iter() - .map(|field| format!("props.{}.{}", arg_name, field.name)) - .collect::>() - .join(",\n "), - CompositeType::Enum => format!( - "[{}].indexOf(props.{}.type)", - t.inners - .iter() - .map(|field| format!("\"{}\"", field.name)) - .collect::>() - .join(", "), - arg_name - ), - _ => { - format!("props.{}", arg_name) - } - } - } - None => format!("props.{}", arg_name), - } - } - Token::Array(_) => format!("...props.{}", arg_name), - Token::Tuple(t) => format!( - "...[{}]", - t.inners - .iter() - .enumerate() - .map(|(idx, t)| handle_arg_recursive( - &format!("props.{arg_name}[{idx}]"), - t, - handled_tokens - )) - .collect::>() - .join(", ") - ), - _ => format!("props.{}", arg_name), - } - } - - let calldata = system - .inputs - .iter() - .map(|arg| handle_arg_recursive(&arg.0, &arg.1, handled_tokens)) - .collect::>() - .join(",\n "); - - format!( - " - // Call the `{system_name}` system with the specified Account and calldata - const {system_name} = async (props: {{ account: Account{arg_sep}{args} }}) => {{ - try {{ - return await provider.execute( - props.account, - {{ - contractName: contract_name, - entrypoint: \"{system_name}\", - calldata: [{calldata}], - }}, - \"{namespace}\" - ); - }} catch (error) {{ - console.error(\"Error executing {system_name}:\", error); - throw error; - }} - }}; - ", - // selector for execute - system_name = system.name, - // add comma if we have args - arg_sep = if !args.is_empty() { ", " } else { "" }, - // formatted args to use our mapped types - args = args, - // calldata for execute - calldata = calldata, - namespace = TypescriptPlugin::get_namespace_from_tag(&namespace) - ) - } - - // Formats a contract tag into a pretty contract name - // eg. dojo_examples-actions -> actions - fn formatted_contract_name(tag: &str) -> String { - naming::get_name_from_tag(tag) - } - - fn get_namespace_from_tag(tag: &str) -> String { - tag.split('-').next().unwrap_or(tag).to_string() - } - - // Handles a contract definition and its underlying systems - // Will format the contract into a C# class and - // all systems into C# methods - // Handled tokens should be a list of all structs and enums used by the contract - fn handle_contracts( - &self, - contracts: &[&DojoContract], - handled_tokens: &[Composite], - ) -> String { - let mut out = String::new(); - out += TypescriptPlugin::generated_header().as_str(); - out += "import { Account, byteArray } from \"starknet\";\n"; - out += "import { DojoProvider } from \"@dojoengine/core\";\n"; - out += "import * as models from \"./models.gen\";\n"; - out += "\n"; - out += "export type IWorld = Awaited>;"; - - out += "\n\n"; - - out += "export async function setupWorld(provider: DojoProvider) {"; - - for contract in contracts { - let systems = contract - .systems - .iter() - .filter(|system| { - let name = system.to_function().unwrap().name.as_str(); - ![ - "contract_name", - "namespace", - "tag", - "name_hash", - "selector", - "dojo_init", - "namespace_hash", - ] - .contains(&name) - }) - .map(|system| { - TypescriptPlugin::format_system( - system.to_function().unwrap(), - handled_tokens, - contract.tag.clone(), - ) - }) - .collect::>() - .join("\n\n "); - - out += &format!( - " - // System definitions for `{}` contract - function {}() {{ - const contract_name = \"{}\"; - - {} - - return {{ - {} - }}; - }} -", - contract.tag, - // capitalize contract name - TypescriptPlugin::formatted_contract_name(&contract.tag), - TypescriptPlugin::formatted_contract_name(&contract.tag), - systems, - contract - .systems - .iter() - .filter(|system| { - let name = system.to_function().unwrap().name.as_str(); - ![ - "contract_name", - "namespace", - "tag", - "name_hash", - "selector", - "dojo_init", - "namespace_hash", - ] - .contains(&name) - }) - .map(|system| { system.to_function().unwrap().name.to_string() }) - .collect::>() - .join(", ") - ); + Self { + writers: vec![ + Box::new(TsFileWriter::new( + "models.gen.ts", + vec![ + Box::new(TsInterfaceGenerator {}), + Box::new(TsEnumGenerator {}), + Box::new(TsSchemaGenerator {}), + Box::new(TsErcGenerator {}), + ], + )), + Box::new(TsFileContractWriter::new( + "contracts.gen.ts", + vec![Box::new(TsFunctionGenerator {})], + )), + ], } - - out += " - return { - "; - - out += &contracts - .iter() - .map(|c| { - format!( - "{}: {}()", - TypescriptPlugin::formatted_contract_name(&c.tag), - TypescriptPlugin::formatted_contract_name(&c.tag) - ) - }) - .collect::>() - .join(",\n "); - - out += " - }; -}\n"; - - out } } @@ -589,29 +47,25 @@ export function defineContractComponents(world: World) { impl BuiltinPlugin for TypescriptPlugin { async fn generate_code(&self, data: &DojoData) -> BindgenResult>> { let mut out: HashMap> = HashMap::new(); - let mut handled_tokens = Vec::::new(); - - // Handle codegen for models - let models_path = Path::new("models.gen.ts").to_owned(); - let mut models = data.models.values().collect::>(); - - // Sort models based on their tag to ensure deterministic output. - models.sort_by(|a, b| a.tag.cmp(&b.tag)); - - let code = self.handle_model(models.as_slice(), &mut handled_tokens); - out.insert(models_path, code.as_bytes().to_vec()); - - // Handle codegen for contracts & systems - let contracts_path = Path::new("contracts.gen.ts").to_owned(); - let mut contracts = data.contracts.values().collect::>(); - - // Sort contracts based on their tag to ensure deterministic output. - contracts.sort_by(|a, b| a.tag.cmp(&b.tag)); - - let code = self.handle_contracts(contracts.as_slice(), &handled_tokens); + let code = self + .writers + .iter() + .map(|writer| match writer.write(writer.get_path(), data) { + Ok(c) => c, + Err(e) => { + log::error!("Failed to generate code for typescript plugin: {e}"); + ("".into(), Vec::new()) + } + }) + .collect::>(); - out.insert(contracts_path, code.as_bytes().to_vec()); + code.iter().for_each(|(path, code)| { + if code.is_empty() { + return; + } + out.insert(PathBuf::from(path), code.clone()); + }); Ok(out) } diff --git a/crates/dojo-bindgen/src/plugins/typescript/writer.rs b/crates/dojo-bindgen/src/plugins/typescript/writer.rs new file mode 100644 index 0000000000..f59d0b1cd2 --- /dev/null +++ b/crates/dojo-bindgen/src/plugins/typescript/writer.rs @@ -0,0 +1,162 @@ +use std::path::{Path, PathBuf}; + +use cainome::parser::tokens::Composite; + +use crate::error::BindgenResult; +use crate::plugins::{BindgenContractGenerator, BindgenModelGenerator, BindgenWriter, Buffer}; +use crate::DojoData; + +pub struct TsFileWriter { + path: &'static str, + generators: Vec>, +} + +impl TsFileWriter { + pub fn new(path: &'static str, generators: Vec>) -> Self { + Self { path, generators } + } +} + +impl BindgenWriter for TsFileWriter { + fn write(&self, path: &str, data: &DojoData) -> BindgenResult<(PathBuf, Vec)> { + let models_path = Path::new(path).to_owned(); + let mut models = data.models.values().collect::>(); + + // Sort models based on their tag to ensure deterministic output. + models.sort_by(|a, b| a.tag.cmp(&b.tag)); + let composites = models + .iter() + .flat_map(|m| { + let mut composites: Vec<&Composite> = Vec::new(); + let mut enum_composites = + m.tokens.enums.iter().map(|e| e.to_composite().unwrap()).collect::>(); + let mut struct_composites = + m.tokens.structs.iter().map(|s| s.to_composite().unwrap()).collect::>(); + let mut func_composites = m + .tokens + .functions + .iter() + .map(|f| f.to_composite().unwrap()) + .collect::>(); + composites.append(&mut enum_composites); + composites.append(&mut struct_composites); + composites.append(&mut func_composites); + composites + }) + .filter(|c| !(c.type_path.starts_with("dojo::") || c.type_path.starts_with("core::"))) + .collect::>(); + + let code = self + .generators + .iter() + .fold(Buffer::new(), |mut acc, g| { + composites.iter().for_each(|c| { + match g.generate(c, &mut acc) { + Ok(code) => { + if !code.is_empty() { + acc.push(code) + } + } + Err(_e) => { + log::error!("Failed to generate code for model {}", c.type_path); + } + }; + }); + acc + }) + .join("\n"); + + Ok((models_path, code.as_bytes().to_vec())) + } + + fn get_path(&self) -> &'static str { + self.path + } +} + +pub struct TsFileContractWriter { + path: &'static str, + generators: Vec>, +} + +impl TsFileContractWriter { + pub fn new(path: &'static str, generators: Vec>) -> Self { + Self { path, generators } + } +} + +impl BindgenWriter for TsFileContractWriter { + fn write(&self, path: &str, data: &DojoData) -> BindgenResult<(PathBuf, Vec)> { + let models_path = Path::new(path).to_owned(); + + let code = self + .generators + .iter() + .fold(Buffer::new(), |mut acc, g| { + data.contracts.iter().for_each(|(_, c)| { + c.systems + .iter() + .filter(|s| { + let name = s.to_function().unwrap().name.as_str(); + ![ + "contract_name", + "namespace", + "tag", + "name_hash", + "selector", + "dojo_init", + "namespace_hash", + "world", + ] + .contains(&name) + }) + .for_each(|s| match s.to_function() { + Ok(f) => match g.generate(c, f, &mut acc) { + Ok(code) => { + if !code.is_empty() { + acc.push(code) + } + } + Err(_) => { + log::error!("Failed to generate code for system {:?}", s); + } + }, + Err(_) => { + log::error!("Failed to get function out of system {:?}", s); + } + }) + }); + + acc + }) + .join("\n"); + Ok((models_path, code.as_bytes().to_vec())) + } + fn get_path(&self) -> &str { + self.path + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::path::PathBuf; + + use super::*; + use crate::{DojoData, DojoWorld}; + + #[test] + fn test_ts_file_writer() { + let writer = TsFileWriter::new("models.gen.ts", Vec::new()); + + let data = DojoData { + models: HashMap::new(), + contracts: HashMap::new(), + world: DojoWorld { name: "0x01".to_string() }, + }; + + let (path, code) = writer.write("models.gen.ts", &data).unwrap(); + assert_eq!(path, PathBuf::from("models.gen.ts")); + assert_eq!(code, Vec::::new()); + } +}